Repository: pypa/twine
Pull request #1149 (2024-09-21 08:21:03)

Use brackets for the default value of option arguments
The goal is to standardize the format of the help text printed by pypa commands. It is not easy to choose between brackets `[]` and parentheses `()`. I went for the [docopt](http://docopt.org/) style, which is the closest to a standard I could find
> [...] and whether that argument has a default value ([default: 10]).

This change has already been applied to setuptools (pypa/setuptools#4442).


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)
 
diff --git a/twine/settings.py b/twine/settings.py
index 9f3aefb..5aa4d96 100644
--- a/twine/settings.py
+++ b/twine/settings.py
@@ -165,8 +165,8 @@ class Settings:
             env="TWINE_REPOSITORY",
             default="pypi",
             help="The repository (package index) to upload the package to. "
-            "Should be a section in the config file (default: "
-            "%(default)s). (Can also be set via %(env)s environment "
+            "Should be a section in the config file [default: "
+            "%(default)s]. (Can also be set via %(env)s environment "
             "variable.)",
         )
         parser.add_argument(
@@ -195,7 +195,7 @@ class Settings:
         parser.add_argument(
             "--sign-with",
             default="gpg",
-            help="GPG program used to sign uploads (default: %(default)s).",
+            help="GPG program used to sign uploads [default: %(default)s].",
         )
         parser.add_argument(
             "-i",


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