Skip to content

Commit 89ec78c

Browse files
authoredJan 5, 2024
Merge pull request #1040 from woodruffw-forks/ww/pypi-mandatory-api-tokens
twine: use API tokens by default on PyPI
2 parents b54af26 + b3b363a commit 89ec78c

File tree

7 files changed

+136
-17
lines changed

7 files changed

+136
-17
lines changed
 

Diff for: ‎tests/test_auth.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def get_password(system, username):
219219
)
220220

221221

222-
def test_logs_cli_values(caplog):
222+
def test_logs_cli_values(caplog, config):
223223
caplog.set_level(logging.INFO, "twine")
224224

225225
res = auth.Resolver(config, auth.CredentialInput("username", "password"))

Diff for: ‎tests/test_register.py

+40-3
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ def test_non_existent_package(register_settings):
7979
register.register(register_settings, package)
8080

8181

82-
def test_values_from_env(monkeypatch):
82+
@pytest.mark.parametrize("repo", ["pypi", "testpypi"])
83+
def test_values_from_env_pypi(monkeypatch, repo):
8384
"""Use env vars for settings when run from command line."""
8485

8586
def none_register(*args, **settings_kwargs):
@@ -88,13 +89,49 @@ def none_register(*args, **settings_kwargs):
8889
replaced_register = pretend.call_recorder(none_register)
8990
monkeypatch.setattr(register, "register", replaced_register)
9091
testenv = {
91-
"TWINE_USERNAME": "pypiuser",
92+
"TWINE_REPOSITORY": repo,
93+
# Ignored because the TWINE_REPOSITORY is PyPI/TestPyPI
94+
"TWINE_USERNAME": "this-is-ignored",
9295
"TWINE_PASSWORD": "pypipassword",
9396
"TWINE_CERT": "/foo/bar.crt",
9497
}
9598
with helpers.set_env(**testenv):
9699
cli.dispatch(["register", helpers.WHEEL_FIXTURE])
97100
register_settings = replaced_register.calls[0].args[0]
98101
assert "pypipassword" == register_settings.password
99-
assert "pypiuser" == register_settings.username
102+
assert "__token__" == register_settings.username
103+
assert "/foo/bar.crt" == register_settings.cacert
104+
105+
106+
def test_values_from_env_not_pypi(monkeypatch, write_config_file):
107+
"""Use env vars for settings when run from command line."""
108+
write_config_file(
109+
"""
110+
[distutils]
111+
index-servers =
112+
notpypi
113+
114+
[notpypi]
115+
repository: https://upload.example.org/legacy/
116+
username:someusername
117+
password:password
118+
"""
119+
)
120+
121+
def none_register(*args, **settings_kwargs):
122+
pass
123+
124+
replaced_register = pretend.call_recorder(none_register)
125+
monkeypatch.setattr(register, "register", replaced_register)
126+
testenv = {
127+
"TWINE_REPOSITORY": "notpypi",
128+
"TWINE_USERNAME": "someusername",
129+
"TWINE_PASSWORD": "pypipassword",
130+
"TWINE_CERT": "/foo/bar.crt",
131+
}
132+
with helpers.set_env(**testenv):
133+
cli.dispatch(["register", helpers.WHEEL_FIXTURE])
134+
register_settings = replaced_register.calls[0].args[0]
135+
assert "pypipassword" == register_settings.password
136+
assert "someusername" == register_settings.username
100137
assert "/foo/bar.crt" == register_settings.cacert

Diff for: ‎tests/test_settings.py

+34-4
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ def test_settings_takes_no_positional_arguments():
2727
settings.Settings("a", "b", "c")
2828

2929

30-
def test_settings_transforms_repository_config(write_config_file):
31-
"""Set repository config and defaults when .pypirc is provided."""
30+
def test_settings_transforms_repository_config_pypi(write_config_file):
31+
"""Set repository config and defaults when .pypirc is provided.
32+
33+
Ignores the username setting due to PyPI being the index.
34+
"""
3235
config_file = write_config_file(
3336
"""
3437
[pypi]
3538
repository: https://upload.pypi.org/legacy/
36-
username:username
39+
username:this-is-ignored
3740
password:password
3841
"""
3942
)
@@ -43,7 +46,34 @@ def test_settings_transforms_repository_config(write_config_file):
4346
assert s.sign is False
4447
assert s.sign_with == "gpg"
4548
assert s.identity is None
46-
assert s.username == "username"
49+
assert s.username == "__token__"
50+
assert s.password == "password"
51+
assert s.cacert is None
52+
assert s.client_cert is None
53+
assert s.disable_progress_bar is False
54+
55+
56+
def test_settings_transforms_repository_config_non_pypi(write_config_file):
57+
"""Set repository config and defaults when .pypirc is provided."""
58+
config_file = write_config_file(
59+
"""
60+
[distutils]
61+
index-servers =
62+
notpypi
63+
64+
[notpypi]
65+
repository: https://upload.example.org/legacy/
66+
username:someusername
67+
password:password
68+
"""
69+
)
70+
71+
s = settings.Settings(config_file=config_file, repository_name="notpypi")
72+
assert s.repository_config["repository"] == "https://upload.example.org/legacy/"
73+
assert s.sign is False
74+
assert s.sign_with == "gpg"
75+
assert s.identity is None
76+
assert s.username == "someusername"
4777
assert s.password == "password"
4878
assert s.cacert is None
4979
assert s.client_cert is None

Diff for: ‎tests/test_upload.py

+39-3
Original file line numberDiff line numberDiff line change
@@ -544,22 +544,58 @@ def test_skip_upload_respects_skip_existing():
544544
)
545545

546546

547-
def test_values_from_env(monkeypatch):
547+
@pytest.mark.parametrize("repo", ["pypi", "testpypi"])
548+
def test_values_from_env_pypi(monkeypatch, repo):
548549
def none_upload(*args, **settings_kwargs):
549550
pass
550551

551552
replaced_upload = pretend.call_recorder(none_upload)
552553
monkeypatch.setattr(upload, "upload", replaced_upload)
553554
testenv = {
554-
"TWINE_USERNAME": "pypiuser",
555+
"TWINE_REPOSITORY": repo,
556+
# Ignored because TWINE_REPOSITORY is PyPI/TestPyPI
557+
"TWINE_USERNAME": "this-is-ignored",
555558
"TWINE_PASSWORD": "pypipassword",
556559
"TWINE_CERT": "/foo/bar.crt",
557560
}
558561
with helpers.set_env(**testenv):
559562
cli.dispatch(["upload", "path/to/file"])
560563
upload_settings = replaced_upload.calls[0].args[0]
561564
assert "pypipassword" == upload_settings.password
562-
assert "pypiuser" == upload_settings.username
565+
assert "__token__" == upload_settings.username
566+
assert "/foo/bar.crt" == upload_settings.cacert
567+
568+
569+
def test_values_from_env_non_pypi(monkeypatch, write_config_file):
570+
write_config_file(
571+
"""
572+
[distutils]
573+
index-servers =
574+
notpypi
575+
576+
[notpypi]
577+
repository: https://upload.example.org/legacy/
578+
username:someusername
579+
password:password
580+
"""
581+
)
582+
583+
def none_upload(*args, **settings_kwargs):
584+
pass
585+
586+
replaced_upload = pretend.call_recorder(none_upload)
587+
monkeypatch.setattr(upload, "upload", replaced_upload)
588+
testenv = {
589+
"TWINE_REPOSITORY": "notpypi",
590+
"TWINE_USERNAME": "someusername",
591+
"TWINE_PASSWORD": "pypipassword",
592+
"TWINE_CERT": "/foo/bar.crt",
593+
}
594+
with helpers.set_env(**testenv):
595+
cli.dispatch(["upload", "path/to/file"])
596+
upload_settings = replaced_upload.calls[0].args[0]
597+
assert "pypipassword" == upload_settings.password
598+
assert "someusername" == upload_settings.username
563599
assert "/foo/bar.crt" == upload_settings.cacert
564600

565601

Diff for: ‎twine/auth.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ def choose(cls, interactive: bool) -> Type["Resolver"]:
3131
@property
3232
@functools.lru_cache()
3333
def username(self) -> Optional[str]:
34+
if cast(str, self.config["repository"]).startswith(
35+
(utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY)
36+
):
37+
# As of 2024-01-01, PyPI requires API tokens for uploads, meaning
38+
# that the username is invariant.
39+
return "__token__"
40+
3441
return utils.get_userpass_value(
3542
self.input.username,
3643
self.config,
@@ -90,7 +97,16 @@ def password_from_keyring_or_prompt(self) -> str:
9097
logger.info("password set from keyring")
9198
return password
9299

93-
return self.prompt("password", getpass.getpass)
100+
# As of 2024-01-01, PyPI requires API tokens for uploads;
101+
# specialize the prompt to clarify that an API token must be provided.
102+
if cast(str, self.config["repository"]).startswith(
103+
(utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY)
104+
):
105+
prompt = "API token"
106+
else:
107+
prompt = "password"
108+
109+
return self.prompt(prompt, getpass.getpass)
94110

95111
def prompt(self, what: str, how: Callable[..., str]) -> str:
96112
return how(f"Enter your {what}: ")

Diff for: ‎twine/settings.py

-3
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,6 @@ def _handle_repository_options(
295295
repository_name,
296296
repository_url,
297297
)
298-
self.repository_config["repository"] = utils.normalize_repository_url(
299-
cast(str, self.repository_config["repository"]),
300-
)
301298

302299
def _handle_certificates(
303300
self, cacert: Optional[str], client_cert: Optional[str]

Diff for: ‎twine/utils.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import os
2020
import os.path
2121
import unicodedata
22-
from typing import Any, Callable, DefaultDict, Dict, Optional, Sequence, Union
22+
from typing import Any, Callable, DefaultDict, Dict, Optional, Sequence, Union, cast
2323
from urllib.parse import urlparse
2424
from urllib.parse import urlunparse
2525

@@ -133,7 +133,7 @@ def get_repository_from_config(
133133
}
134134

135135
try:
136-
return get_config(config_file)[repository]
136+
config = get_config(config_file)[repository]
137137
except OSError as exc:
138138
raise exceptions.InvalidConfiguration(str(exc))
139139
except KeyError:
@@ -142,6 +142,9 @@ def get_repository_from_config(
142142
f"More info: https://packaging.python.org/specifications/pypirc/ "
143143
)
144144

145+
config["repository"] = normalize_repository_url(cast(str, config["repository"]))
146+
return config
147+
145148

146149
_HOSTNAMES = {
147150
"pypi.python.org",

0 commit comments

Comments
 (0)