From 2afda476ef7edb78c216663273a449966549acfd Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 11:27:47 -0500 Subject: [PATCH 1/7] twine: use API tokens by default on PyPI See #561. Signed-off-by: William Woodruff --- twine/auth.py | 14 +++++++++++++- twine/settings.py | 3 --- twine/utils.py | 7 +++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/twine/auth.py b/twine/auth.py index 7273c554..e33a4e0e 100644 --- a/twine/auth.py +++ b/twine/auth.py @@ -31,6 +31,11 @@ def choose(cls, interactive: bool) -> Type["Resolver"]: @property @functools.lru_cache() def username(self) -> Optional[str]: + if self.config["repository"].startswith(utils.DEFAULT_REPOSITORY): + # As of 2024-01-01, PyPI requires API tokens for uploads, meaning + # that the username is invariant. + return "__token__" + return utils.get_userpass_value( self.input.username, self.config, @@ -90,7 +95,14 @@ def password_from_keyring_or_prompt(self) -> str: logger.info("password set from keyring") return password - return self.prompt("password", getpass.getpass) + # As of 2024-01-01, PyPI requires API tokens for uploads; + # specialize the prompt to clarify that an API token must be provided. + if self.config["repository"].startswith(utils.DEFAULT_REPOSITORY): + prompt = "API token" + else: + prompt = "password" + + return self.prompt(prompt, getpass.getpass) def prompt(self, what: str, how: Callable[..., str]) -> str: return how(f"Enter your {what}: ") diff --git a/twine/settings.py b/twine/settings.py index 3f6b15f4..d9dd4af3 100644 --- a/twine/settings.py +++ b/twine/settings.py @@ -295,9 +295,6 @@ def _handle_repository_options( repository_name, repository_url, ) - self.repository_config["repository"] = utils.normalize_repository_url( - cast(str, self.repository_config["repository"]), - ) def _handle_certificates( self, cacert: Optional[str], client_cert: Optional[str] diff --git a/twine/utils.py b/twine/utils.py index 7f76168a..484a0234 100644 --- a/twine/utils.py +++ b/twine/utils.py @@ -19,7 +19,7 @@ import os import os.path import unicodedata -from typing import Any, Callable, DefaultDict, Dict, Optional, Sequence, Union +from typing import Any, Callable, DefaultDict, Dict, Optional, Sequence, Union, cast from urllib.parse import urlparse from urllib.parse import urlunparse @@ -133,7 +133,7 @@ def get_repository_from_config( } try: - return get_config(config_file)[repository] + config = get_config(config_file)[repository] except OSError as exc: raise exceptions.InvalidConfiguration(str(exc)) except KeyError: @@ -142,6 +142,9 @@ def get_repository_from_config( f"More info: https://packaging.python.org/specifications/pypirc/ " ) + config["repository"] = normalize_repository_url(cast(str, config["repository"])) + return config + _HOSTNAMES = { "pypi.python.org", From 7f5c83c253959beec74272b3982858c3f536769e Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 11:30:37 -0500 Subject: [PATCH 2/7] auth: casts for mypy These casts should be sound, since every RepositoryConfig should have a `repository` key by this point. Signed-off-by: William Woodruff --- twine/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/twine/auth.py b/twine/auth.py index e33a4e0e..28c8b1e3 100644 --- a/twine/auth.py +++ b/twine/auth.py @@ -31,7 +31,7 @@ def choose(cls, interactive: bool) -> Type["Resolver"]: @property @functools.lru_cache() def username(self) -> Optional[str]: - if self.config["repository"].startswith(utils.DEFAULT_REPOSITORY): + if cast(str, self.config["repository"]).startswith(utils.DEFAULT_REPOSITORY): # As of 2024-01-01, PyPI requires API tokens for uploads, meaning # that the username is invariant. return "__token__" @@ -97,7 +97,7 @@ def password_from_keyring_or_prompt(self) -> str: # As of 2024-01-01, PyPI requires API tokens for uploads; # specialize the prompt to clarify that an API token must be provided. - if self.config["repository"].startswith(utils.DEFAULT_REPOSITORY): + if cast(str, self.config["repository"]).startswith(utils.DEFAULT_REPOSITORY): prompt = "API token" else: prompt = "password" From 4a2dfb0d44c5f0d195bfe33475e7dd2a26feeeaf Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 11:56:30 -0500 Subject: [PATCH 3/7] tests: fixup tests Signed-off-by: William Woodruff --- tests/test_auth.py | 2 +- tests/test_register.py | 4 ++-- tests/test_settings.py | 4 ++-- tests/test_upload.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index d5abfec7..e0626d7b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -219,7 +219,7 @@ def get_password(system, username): ) -def test_logs_cli_values(caplog): +def test_logs_cli_values(caplog, config): caplog.set_level(logging.INFO, "twine") res = auth.Resolver(config, auth.CredentialInput("username", "password")) diff --git a/tests/test_register.py b/tests/test_register.py index 60c23037..bd925bd5 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -88,7 +88,7 @@ def none_register(*args, **settings_kwargs): replaced_register = pretend.call_recorder(none_register) monkeypatch.setattr(register, "register", replaced_register) testenv = { - "TWINE_USERNAME": "pypiuser", + "TWINE_USERNAME": "this-is-ignored", "TWINE_PASSWORD": "pypipassword", "TWINE_CERT": "/foo/bar.crt", } @@ -96,5 +96,5 @@ def none_register(*args, **settings_kwargs): cli.dispatch(["register", helpers.WHEEL_FIXTURE]) register_settings = replaced_register.calls[0].args[0] assert "pypipassword" == register_settings.password - assert "pypiuser" == register_settings.username + assert "__token__" == register_settings.username assert "/foo/bar.crt" == register_settings.cacert diff --git a/tests/test_settings.py b/tests/test_settings.py index 57e980c5..fc64474b 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -33,7 +33,7 @@ def test_settings_transforms_repository_config(write_config_file): """ [pypi] repository: https://upload.pypi.org/legacy/ - username:username + username:this-is-ignored password:password """ ) @@ -43,7 +43,7 @@ def test_settings_transforms_repository_config(write_config_file): assert s.sign is False assert s.sign_with == "gpg" assert s.identity is None - assert s.username == "username" + assert s.username == "__token__" assert s.password == "password" assert s.cacert is None assert s.client_cert is None diff --git a/tests/test_upload.py b/tests/test_upload.py index 894dec6b..9c36c06e 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -551,7 +551,7 @@ def none_upload(*args, **settings_kwargs): replaced_upload = pretend.call_recorder(none_upload) monkeypatch.setattr(upload, "upload", replaced_upload) testenv = { - "TWINE_USERNAME": "pypiuser", + "TWINE_USERNAME": "this-is-ignored", "TWINE_PASSWORD": "pypipassword", "TWINE_CERT": "/foo/bar.crt", } @@ -559,7 +559,7 @@ def none_upload(*args, **settings_kwargs): cli.dispatch(["upload", "path/to/file"]) upload_settings = replaced_upload.calls[0].args[0] assert "pypipassword" == upload_settings.password - assert "pypiuser" == upload_settings.username + assert "__token__" == upload_settings.username assert "/foo/bar.crt" == upload_settings.cacert From 9ce78928f7b2e1abafec38cee12297de32038949 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 21:22:53 -0500 Subject: [PATCH 4/7] twine: check TestPyPI as well --- twine/auth.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/twine/auth.py b/twine/auth.py index 28c8b1e3..d2b25824 100644 --- a/twine/auth.py +++ b/twine/auth.py @@ -31,7 +31,9 @@ def choose(cls, interactive: bool) -> Type["Resolver"]: @property @functools.lru_cache() def username(self) -> Optional[str]: - if cast(str, self.config["repository"]).startswith(utils.DEFAULT_REPOSITORY): + if cast(str, self.config["repository"]).startswith( + (utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY) + ): # As of 2024-01-01, PyPI requires API tokens for uploads, meaning # that the username is invariant. return "__token__" @@ -97,7 +99,9 @@ def password_from_keyring_or_prompt(self) -> str: # As of 2024-01-01, PyPI requires API tokens for uploads; # specialize the prompt to clarify that an API token must be provided. - if cast(str, self.config["repository"]).startswith(utils.DEFAULT_REPOSITORY): + if cast(str, self.config["repository"]).startswith( + (utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY) + ): prompt = "API token" else: prompt = "password" From e9123128cc29a500f7d140a558262040eeb78ca3 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 21:23:04 -0500 Subject: [PATCH 5/7] tests: begin adding non-PyPI tests --- tests/test_register.py | 40 +++++++++++++++++++++++++++++++++++++++- tests/test_settings.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/tests/test_register.py b/tests/test_register.py index bd925bd5..791be27f 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -79,7 +79,8 @@ def test_non_existent_package(register_settings): register.register(register_settings, package) -def test_values_from_env(monkeypatch): +@pytest.mark.parametrize("repo", ["pypi", "testpypi"]) +def test_values_from_env_pypi(monkeypatch, repo): """Use env vars for settings when run from command line.""" def none_register(*args, **settings_kwargs): @@ -88,6 +89,8 @@ def none_register(*args, **settings_kwargs): replaced_register = pretend.call_recorder(none_register) monkeypatch.setattr(register, "register", replaced_register) testenv = { + "TWINE_REPOSITORY": repo, + # Ignored because the TWINE_REPOSITORY is PyPI/TestPyPI "TWINE_USERNAME": "this-is-ignored", "TWINE_PASSWORD": "pypipassword", "TWINE_CERT": "/foo/bar.crt", @@ -98,3 +101,38 @@ def none_register(*args, **settings_kwargs): assert "pypipassword" == register_settings.password assert "__token__" == register_settings.username assert "/foo/bar.crt" == register_settings.cacert + + +def test_values_from_env_not_pypi(monkeypatch, write_config_file): + """Use env vars for settings when run from command line.""" + + write_config_file( + """ + [distutils] + index-servers = + notpypi + + [notpypi] + repository: https://upload.example.org/legacy/ + username:someusername + password:password + """ + ) + + def none_register(*args, **settings_kwargs): + pass + + replaced_register = pretend.call_recorder(none_register) + monkeypatch.setattr(register, "register", replaced_register) + testenv = { + "TWINE_REPOSITORY": "notpypi", + "TWINE_USERNAME": "someusername", + "TWINE_PASSWORD": "pypipassword", + "TWINE_CERT": "/foo/bar.crt", + } + with helpers.set_env(**testenv): + cli.dispatch(["register", helpers.WHEEL_FIXTURE]) + register_settings = replaced_register.calls[0].args[0] + assert "pypipassword" == register_settings.password + assert "someusername" == register_settings.username + assert "/foo/bar.crt" == register_settings.cacert diff --git a/tests/test_settings.py b/tests/test_settings.py index fc64474b..52eb92e9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -27,8 +27,11 @@ def test_settings_takes_no_positional_arguments(): settings.Settings("a", "b", "c") -def test_settings_transforms_repository_config(write_config_file): - """Set repository config and defaults when .pypirc is provided.""" +def test_settings_transforms_repository_config_pypi(write_config_file): + """Set repository config and defaults when .pypirc is provided. + + Ignores the username setting due to PyPI being the index. + """ config_file = write_config_file( """ [pypi] @@ -50,6 +53,33 @@ def test_settings_transforms_repository_config(write_config_file): assert s.disable_progress_bar is False +def test_settings_transforms_repository_config_non_pypi(write_config_file): + """Set repository config and defaults when .pypirc is provided.""" + config_file = write_config_file( + """ + [distutils] + index-servers = + notpypi + + [notpypi] + repository: https://upload.example.org/legacy/ + username:someusername + password:password + """ + ) + + s = settings.Settings(config_file=config_file, repository_name="notpypi") + assert s.repository_config["repository"] == "https://upload.example.org/legacy/" + assert s.sign is False + assert s.sign_with == "gpg" + assert s.identity is None + assert s.username == "someusername" + assert s.password == "password" + assert s.cacert is None + assert s.client_cert is None + assert s.disable_progress_bar is False + + @pytest.mark.parametrize( "verbose, log_level", [(True, logging.INFO), (False, logging.WARNING)] ) From 6e94d200e20f700fa2e905dd32afeb367d321b67 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 21:26:58 -0500 Subject: [PATCH 6/7] tests: more non-PyPI tests --- tests/test_upload.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/test_upload.py b/tests/test_upload.py index 9c36c06e..c59bb4df 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -544,13 +544,16 @@ def test_skip_upload_respects_skip_existing(): ) -def test_values_from_env(monkeypatch): +@pytest.mark.parametrize("repo", ["pypi", "testpypi"]) +def test_values_from_env_pypi(monkeypatch, repo): def none_upload(*args, **settings_kwargs): pass replaced_upload = pretend.call_recorder(none_upload) monkeypatch.setattr(upload, "upload", replaced_upload) testenv = { + "TWINE_REPOSITORY": repo, + # Ignored because TWINE_REPOSITORY is PyPI/TestPyPI "TWINE_USERNAME": "this-is-ignored", "TWINE_PASSWORD": "pypipassword", "TWINE_CERT": "/foo/bar.crt", @@ -563,6 +566,39 @@ def none_upload(*args, **settings_kwargs): assert "/foo/bar.crt" == upload_settings.cacert +def test_values_from_env_non_pypi(monkeypatch, write_config_file): + write_config_file( + """ + [distutils] + index-servers = + notpypi + + [notpypi] + repository: https://upload.example.org/legacy/ + username:someusername + password:password + """ + ) + + def none_upload(*args, **settings_kwargs): + pass + + replaced_upload = pretend.call_recorder(none_upload) + monkeypatch.setattr(upload, "upload", replaced_upload) + testenv = { + "TWINE_REPOSITORY": "notpypi", + "TWINE_USERNAME": "someusername", + "TWINE_PASSWORD": "pypipassword", + "TWINE_CERT": "/foo/bar.crt", + } + with helpers.set_env(**testenv): + cli.dispatch(["upload", "path/to/file"]) + upload_settings = replaced_upload.calls[0].args[0] + assert "pypipassword" == upload_settings.password + assert "someusername" == upload_settings.username + assert "/foo/bar.crt" == upload_settings.cacert + + @pytest.mark.parametrize( "repo_url", ["https://upload.pypi.org/", "https://test.pypi.org/", "https://pypi.org/"], From b3b363aae8cf83bfbdf9228f5e80d9bdb4765053 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 4 Jan 2024 21:31:32 -0500 Subject: [PATCH 7/7] tests: lintage --- tests/test_register.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_register.py b/tests/test_register.py index 791be27f..cc2ae71e 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -105,7 +105,6 @@ def none_register(*args, **settings_kwargs): def test_values_from_env_not_pypi(monkeypatch, write_config_file): """Use env vars for settings when run from command line.""" - write_config_file( """ [distutils]