Repository: pypa/twine
Pull request #1040 (2024-01-04 16:28:33)

twine: use API tokens by default on PyPI
This is still a work in progress.

See #561.


Patch:
diff --git a/twine/auth.py b/twine/auth.py
index 7273c55..d2b2582 100644
--- a/twine/auth.py
+++ b/twine/auth.py
@@ -31,6 +31,13 @@ class 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 @@ class Resolver:
             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 3f6b15f..d9dd4af 100644
--- a/twine/settings.py
+++ b/twine/settings.py
@@ -295,9 +295,6 @@ class Settings:
             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 7f76168..484a023 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -19,7 +19,7 @@ import logging
 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",


Test patch:
diff --git a/tests/test_auth.py b/tests/test_auth.py
index d5abfec..e0626d7 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -219,7 +219,7 @@ def test_get_password_keyring_key_error_logged(
     )
 
 
-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 60c2303..cc2ae71 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 test_values_from_env(monkeypatch):
     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 test_values_from_env(monkeypatch):
         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 57e980c..52eb92e 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 894dec6..c59bb4d 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 test_values_from_env(monkeypatch):
         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