Repository: pypa/twine
Pull request #1136 (2024-07-08 19:19:53)

Apply ruff/Perflint rule
```
PERF401 Use a list comprehension to create a transformed list
```
A comprehension is a more efficient way to initialise a list than successive append() calls.


Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index aae12eb..5d07d19 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -43,6 +43,7 @@ jobs:
           - "3.10"
           - "3.11"
           - "3.12"
+          - "3.13"
         platform:
           - ubuntu-latest
           - macos-latest
diff --git a/changelog/1024.misc.rst b/changelog/1024.misc.rst
deleted file mode 100644
index 4448c34..0000000
--- a/changelog/1024.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Only require ``importlib_metadata`` on older Pythons.
diff --git a/changelog/1184.misc.rst b/changelog/1184.misc.rst
new file mode 100644
index 0000000..94accf9
--- /dev/null
+++ b/changelog/1184.misc.rst
@@ -0,0 +1,1 @@
+Python 3.13 is now explicitly supported.
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 0a6304a..40240c5 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -12,6 +12,27 @@ schemes recommended by the Python Packaging Authority.
 
 .. towncrier release notes start
 
+Twine 6.0.0 (2024-11-29)
+------------------------
+
+Bugfixes
+^^^^^^^^
+
+- Restore support for pkginfo 1.11 (`#1116 <https://github.com/pypa/twine/issues/1116>`_)
+
+
+Deprecations and Removals
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+- Username for PyPI and Test PyPI now defaults to __token__ but no longer overrides a username configured in the environment or supplied on the command line. Workflows still supplying anything other than __token__ for the username when uploading to PyPI or Test PyPI will now fail. Either supply __token__ or do not supply a username at all. (`#1121 <https://github.com/pypa/twine/issues/1121>`_)
+
+
+Misc
+^^^^
+
+- `#1024 <https://github.com/pypa/twine/issues/1024>`_
+
+
 Twine 5.1.1 (2024-06-26)
 ------------------------
 
diff --git a/docs/conf.py b/docs/conf.py
index 9579153..ddef188 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -291,6 +291,7 @@ nitpicky = True
 # TODO: Try to add these to intersphinx_mapping
 nitpick_ignore_regex = [
     (r"py:.*", r"pkginfo.*"),
+    ("py:class", r"warnings\.WarningMessage"),
 ]
 
 # -- Options for apidoc output ------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 52c3de4..582ccaa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ classifiers = [
 	"Programming Language :: Python :: 3.10",
 	"Programming Language :: Python :: 3.11",
 	"Programming Language :: Python :: 3.12",
+	"Programming Language :: Python :: 3.13",
 	"Programming Language :: Python :: Implementation :: CPython",
 ]
 requires-python = ">=3.8"
@@ -35,14 +36,12 @@ dependencies = [
 	"requests >= 2.20",
 	"requests-toolbelt >= 0.8.0, != 0.9.0",
 	"urllib3 >= 1.26.0",
-	"importlib-metadata >= 3.6",
+	"importlib-metadata >= 3.6; python_version < '3.10'",
 	# workaround for missing binaries on these platforms, see #1158
 	"keyring >= 15.1; platform_machine != 'ppc64le' and platform_machine != 's390x'",
 	"rfc3986 >= 1.4.0",
 	"rich >= 12.0.0",
-
-	# workaround for #1116
-	"pkginfo < 1.11",
+	"packaging",
 ]
 dynamic = ["version"]
 
@@ -68,10 +67,7 @@ keyring = ["keyring >= 15.1"]
 twine = "twine.__main__:main"
 
 [tool.setuptools]
-packages = [
-	"twine",
-	"twine.commands",
-]
+packages = ["twine", "twine.commands"]
 include-package-data = true
 license-files = ["LICENSE"]
 
diff --git a/tox.ini b/tox.ini
index 5a85ef4..e5c71aa 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 minversion = 3.8
-envlist = lint,types,py{38,39,310,311,312},integration,docs
+envlist = lint,types,py{38,39,310,311,312,313},integration,docs
 isolated_build = True
 
 [testenv]
diff --git a/twine/auth.py b/twine/auth.py
index d8b78e6..59e2e21 100644
--- a/twine/auth.py
+++ b/twine/auth.py
@@ -39,12 +39,9 @@ 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__"
+        if self.is_pypi() and not self.input.username:
+            # Default username.
+            self.input.username = "__token__"
 
         return utils.get_userpass_value(
             self.input.username,
@@ -111,20 +108,23 @@ class Resolver:
             logger.info("password set from keyring")
             return password
 
-        # 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"
+        # Prompt for API token when required.
+        what = "API token" if self.is_pypi() else "password"
 
-        return self.prompt(prompt, getpass.getpass)
+        return self.prompt(what, getpass.getpass)
 
     def prompt(self, what: str, how: Callable[..., str]) -> str:
         return how(f"Enter your {what}: ")
 
+    def is_pypi(self) -> bool:
+        """As of 2024-01-01, PyPI requires API tokens for uploads."""
+        return cast(str, self.config["repository"]).startswith(
+            (
+                utils.DEFAULT_REPOSITORY,
+                utils.TEST_REPOSITORY,
+            )
+        )
+
 
 class Private(Resolver):
     def prompt(self, what: str, how: Optional[Callable[..., str]] = None) -> str:
diff --git a/twine/package.py b/twine/package.py
index ac8e4cf..8bcec21 100644
--- a/twine/package.py
+++ b/twine/package.py
@@ -19,13 +19,26 @@ import os
 import re
 import subprocess
 import sys
-from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union, cast
+import warnings
+from typing import (
+    Any,
+    Dict,
+    Iterable,
+    List,
+    NamedTuple,
+    Optional,
+    Sequence,
+    Tuple,
+    Union,
+    cast,
+)
 
 if sys.version_info >= (3, 10):
     import importlib.metadata as importlib_metadata
 else:
     import importlib_metadata
 
+import packaging.version
 import pkginfo
 from rich import print
 
@@ -65,12 +78,19 @@ def _safe_name(name: str) -> str:
     return re.sub("[^A-Za-z0-9.]+", "-", name)
 
 
+class CheckedDistribution(pkginfo.Distribution):
+    """A Distribution whose name and version are confirmed to be defined."""
+
+    name: str
+    version: str
+
+
 class PackageFile:
     def __init__(
         self,
         filename: str,
         comment: Optional[str],
-        metadata: pkginfo.Distribution,
+        metadata: CheckedDistribution,
         python_version: Optional[str],
         filetype: Optional[str],
     ) -> None:
@@ -100,7 +120,8 @@ class PackageFile:
         for ext, dtype in DIST_EXTENSIONS.items():
             if filename.endswith(ext):
                 try:
-                    meta = DIST_TYPES[dtype](filename)
+                    with warnings.catch_warnings(record=True) as captured:
+                        meta = DIST_TYPES[dtype](filename)
                 except EOFError:
                     raise exceptions.InvalidDistribution(
                         "Invalid distribution file: '%s'" % os.path.basename(filename)
@@ -112,7 +133,13 @@ class PackageFile:
                 "Unknown distribution format: '%s'" % os.path.basename(filename)
             )
 
-        # If pkginfo encounters a metadata version it doesn't support, it may give us
+        supported_metadata = list(pkginfo.distribution.HEADER_ATTRS)
+        if cls._is_unknown_metadata_version(captured):
+            raise exceptions.InvalidDistribution(
+                "Make sure the distribution is using a supported Metadata-Version: "
+                f"{', '.join(supported_metadata)}."
+            )
+        # If pkginfo <1.11 encounters a metadata version it doesn't support, it may give
         # back empty metadata. At the very least, we should have a name and version,
         # which could also be empty if, for example, a MANIFEST.in doesn't include
         # setup.cfg.
@@ -120,14 +147,15 @@ class PackageFile:
             f.capitalize() for f in ["name", "version"] if not getattr(meta, f)
         ]
         if missing_fields:
-            supported_metadata = list(pkginfo.distribution.HEADER_ATTRS)
-            raise exceptions.InvalidDistribution(
-                "Metadata is missing required fields: "
-                f"{', '.join(missing_fields)}.\n"
-                "Make sure the distribution includes the files where those fields "
-                "are specified, and is using a supported Metadata-Version: "
-                f"{', '.join(supported_metadata)}."
-            )
+            msg = f"Metadata is missing required fields: {', '.join(missing_fields)}."
+            if cls._pkginfo_before_1_11():
+                msg += (
+                    "\n"
+                    "Make sure the distribution includes the files where those fields "
+                    "are specified, and is using a supported Metadata-Version: "
+                    f"{', '.join(supported_metadata)}."
+                )
+            raise exceptions.InvalidDistribution(msg)
 
         py_version: Optional[str]
         if dtype == "bdist_egg":
@@ -140,7 +168,21 @@ class PackageFile:
         else:
             py_version = None
 
-        return cls(filename, comment, meta, py_version, dtype)
+        return cls(
+            filename, comment, cast(CheckedDistribution, meta), py_version, dtype
+        )
+
+    @staticmethod
+    def _is_unknown_metadata_version(
+        captured: Iterable[warnings.WarningMessage],
+    ) -> bool:
+        NMV = getattr(pkginfo.distribution, "NewMetadataVersion", None)
+        return any(warning.category is NMV for warning in captured)
+
+    @staticmethod
+    def _pkginfo_before_1_11() -> bool:
+        ver = packaging.version.Version(importlib_metadata.version("pkginfo"))
+        return ver < packaging.version.Version("1.11")
 
     def metadata_dictionary(self) -> Dict[str, MetadataValue]:
         """Merge multiple sources of metadata into a single dictionary.
diff --git a/twine/repository.py b/twine/repository.py
index 6b9397f..b512bfb 100644
--- a/twine/repository.py
+++ b/twine/repository.py
@@ -96,8 +96,7 @@ class Repository:
             if key in KEYWORDS_TO_NOT_FLATTEN or not isinstance(value, (list, tuple)):
                 data_to_send.append((key, value))
             else:
-                for item in value:
-                    data_to_send.append((key, item))
+                data_to_send.extend((key, item) for item in value)
         return data_to_send
 
     def set_certificate_authority(self, cacert: Optional[str]) -> None:
@@ -142,7 +141,10 @@ class Repository:
 
         with open(package.filename, "rb") as fp:
             data_to_send.append(
-                ("content", (package.basefilename, fp, "application/octet-stream"))
+                (
+                    "content",
+                    (package.basefilename, fp, "application/octet-stream"),
+                )
             )
             encoder = requests_toolbelt.MultipartEncoder(data_to_send)
 


Test patch:
diff --git a/tests/test_package.py b/tests/test_package.py
index 221b33c..be3d954 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -383,11 +383,6 @@ def test_fips_metadata_excludes_md5_and_blake2(monkeypatch):
 @pytest.mark.parametrize(
     "read_data, missing_fields",
     [
-        pytest.param(
-            b"Metadata-Version: 102.3\nName: test-package\nVersion: 1.0.0\n",
-            "Name, Version",
-            id="unsupported Metadata-Version",
-        ),
         pytest.param(
             b"Metadata-Version: 2.3\nName: UNKNOWN\nVersion: UNKNOWN\n",
             "Name, Version",
@@ -421,10 +416,7 @@ def test_fips_metadata_excludes_md5_and_blake2(monkeypatch):
     ],
 )
 def test_pkginfo_returns_no_metadata(read_data, missing_fields, monkeypatch):
-    """Raise an exception when pkginfo can't interpret the metadata.
-
-    This could be caused by a version number or format it doesn't support yet.
-    """
+    """Raise an exception when pkginfo can't interpret the metadata."""
     monkeypatch.setattr(package_file.wheel.Wheel, "read", lambda _: read_data)
     filename = "tests/fixtures/twine-1.5.0-py2.py3-none-any.whl"
 
@@ -434,9 +426,33 @@ def test_pkginfo_returns_no_metadata(read_data, missing_fields, monkeypatch):
     assert (
         f"Metadata is missing required fields: {missing_fields}." in err.value.args[0]
     )
+
+
+def test_pkginfo_unrecognized_version(monkeypatch):
+    """Raise an exception when pkginfo doesn't recognize the version."""
+    data = b"Metadata-Version: 102.3\nName: test-package\nVersion: 1.0.0\n"
+    monkeypatch.setattr(package_file.wheel.Wheel, "read", lambda _: data)
+    filename = "tests/fixtures/twine-1.5.0-py2.py3-none-any.whl"
+
+    with pytest.raises(exceptions.InvalidDistribution) as err:
+        package_file.PackageFile.from_filename(filename, comment=None)
+
     assert "1.0, 1.1, 1.2, 2.0, 2.1, 2.2" in err.value.args[0]
 
 
+def test_pkginfo_returns_no_metadata_py_below_1_11(monkeypatch):
+    """Raise special msg when pkginfo can't interpret metadata on pkginfo < 1.11."""
+    data = b"Metadata-Version: 2.2\nName: UNKNOWN\nVersion: 1.0.0\n"
+    monkeypatch.setattr(package_file.wheel.Wheel, "read", lambda _: data)
+    monkeypatch.setattr(package_file.importlib_metadata, "version", lambda pkg: "1.10")
+    filename = "tests/fixtures/twine-1.5.0-py2.py3-none-any.whl"
+
+    with pytest.raises(exceptions.InvalidDistribution) as err:
+        package_file.PackageFile.from_filename(filename, comment=None)
+
+    assert "Make sure the distribution includes" in err.value.args[0]
+
+
 def test_malformed_from_file(monkeypatch):
     """Raise an exception when malformed package file triggers EOFError."""
     filename = "tests/fixtures/malformed.tar.gz"
diff --git a/tests/test_register.py b/tests/test_register.py
index cc2ae71..eb20e0e 100644
--- a/tests/test_register.py
+++ b/tests/test_register.py
@@ -90,8 +90,7 @@ def test_values_from_env_pypi(monkeypatch, repo):
     monkeypatch.setattr(register, "register", replaced_register)
     testenv = {
         "TWINE_REPOSITORY": repo,
-        # Ignored because the TWINE_REPOSITORY is PyPI/TestPyPI
-        "TWINE_USERNAME": "this-is-ignored",
+        "TWINE_USERNAME": "pypiuser",
         "TWINE_PASSWORD": "pypipassword",
         "TWINE_CERT": "/foo/bar.crt",
     }
@@ -99,7 +98,7 @@ def test_values_from_env_pypi(monkeypatch, repo):
         cli.dispatch(["register", helpers.WHEEL_FIXTURE])
     register_settings = replaced_register.calls[0].args[0]
     assert "pypipassword" == register_settings.password
-    assert "__token__" == register_settings.username
+    assert "pypiuser" == register_settings.username
     assert "/foo/bar.crt" == register_settings.cacert
 
 
diff --git a/tests/test_upload.py b/tests/test_upload.py
index a2dc513..105d0de 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -564,8 +564,7 @@ def test_values_from_env_pypi(monkeypatch, repo):
     monkeypatch.setattr(upload, "upload", replaced_upload)
     testenv = {
         "TWINE_REPOSITORY": repo,
-        # Ignored because TWINE_REPOSITORY is PyPI/TestPyPI
-        "TWINE_USERNAME": "this-is-ignored",
+        "TWINE_USERNAME": "pypiuser",
         "TWINE_PASSWORD": "pypipassword",
         "TWINE_CERT": "/foo/bar.crt",
     }
@@ -573,7 +572,7 @@ def test_values_from_env_pypi(monkeypatch, repo):
         cli.dispatch(["upload", "path/to/file"])
     upload_settings = replaced_upload.calls[0].args[0]
     assert "pypipassword" == upload_settings.password
-    assert "__token__" == upload_settings.username
+    assert "pypiuser" == upload_settings.username
     assert "/foo/bar.crt" == upload_settings.cacert