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..cc2ae71e 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,7 +89,9 @@ def none_register(*args, **settings_kwargs): replaced_register = pretend.call_recorder(none_register) monkeypatch.setattr(register, "register", replaced_register) testenv = { - "TWINE_USERNAME": "pypiuser", + "TWINE_REPOSITORY": repo, + # Ignored because the TWINE_REPOSITORY is PyPI/TestPyPI + "TWINE_USERNAME": "this-is-ignored", "TWINE_PASSWORD": "pypipassword", "TWINE_CERT": "/foo/bar.crt", } @@ -96,5 +99,39 @@ 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 + + +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 57e980c5..52eb92e9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -27,13 +27,16 @@ 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] repository: https://upload.pypi.org/legacy/ - username:username + username:this-is-ignored password:password """ ) @@ -43,7 +46,34 @@ 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 + 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 diff --git a/tests/test_upload.py b/tests/test_upload.py index 894dec6b..c59bb4df 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -544,14 +544,17 @@ 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_USERNAME": "pypiuser", + "TWINE_REPOSITORY": repo, + # Ignored because TWINE_REPOSITORY is PyPI/TestPyPI + "TWINE_USERNAME": "this-is-ignored", "TWINE_PASSWORD": "pypipassword", "TWINE_CERT": "/foo/bar.crt", } @@ -559,7 +562,40 @@ 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 + + +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 diff --git a/twine/auth.py b/twine/auth.py index 7273c554..d2b25824 100644 --- a/twine/auth.py +++ b/twine/auth.py @@ -31,6 +31,13 @@ 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, utils.TEST_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 +97,16 @@ 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 cast(str, self.config["repository"]).startswith( + (utils.DEFAULT_REPOSITORY, utils.TEST_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",