Repository: pypa/twine (python)

Issue:
[self tests] test_pkginfo_returns_no_metadata fails against pkginfo 1.11.0
### Is there an existing issue for this?

- [X] I have searched the existing issues (open and closed), and could not find an existing issue

### What keywords did you use to search existing issues?

test_pkginfo_returns_no_metadata


### What operating system are you using?

Linux

### If you selected 'Other', describe your Operating System here

_No response_

### What version of Python are you running?

```console
python3 --version
Python 3.12.2
```


### How did you install twine? Did you use your operating system's package manager or pip or something else?

```console
git clone https://github.com/pypa/twine
```


### What version of twine do you have installed (include the complete output)

```console
twine version 5.1.1.dev7+g6fbf880 (importlib-metadata: 7.1.0, keyring: 25.2.1,
pkginfo: 1.11.0, requests: 2.32.3, requests-toolbelt: 1.0.0, urllib3: 2.2.1)
```


### Which package repository are you using?

https://pypi.org/

### Please describe the issue that you are experiencing

`test_pkginfo_returns_no_metadata` test fails with:

```
====================================== FAILURES ======================================
___________ test_pkginfo_returns_no_metadata[unsupported Metadata-Version] ___________

read_data = b'Metadata-Version: 102.3\nName: test-package\nVersion: 1.0.0\n'
missing_fields = 'Name, Version'
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7ff1586950d0>

    @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",
                id="missing Name and Version",
            ),
            pytest.param(
                b"Metadata-Version: 2.2\nName: UNKNOWN\nVersion: UNKNOWN\n",
                "Name, Version",
                id="missing Name and Version",
            ),
            pytest.param(
                b"Metadata-Version: 2.3\nName: UNKNOWN\nVersion: 1.0.0\n",
                "Name",
                id="missing Name",
            ),
            pytest.param(
                b"Metadata-Version: 2.2\nName: UNKNOWN\nVersion: 1.0.0\n",
                "Name",
                id="missing Name",
            ),
            pytest.param(
                b"Metadata-Version: 2.3\nName: test-package\nVersion: UNKNOWN\n",
                "Version",
                id="missing Version",
            ),
            pytest.param(
                b"Metadata-Version: 2.2\nName: test-package\nVersion: UNKNOWN\n",
                "Version",
                id="missing Version",
            ),
        ],
    )
    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.
        """
        monkeypatch.setattr(package_file.wheel.Wheel, "read", lambda _: read_data)
        filename = "tests/fixtures/twine-1.5.0-py2.py3-none-any.whl"
    
>       with pytest.raises(exceptions.InvalidDistribution) as err:
E       Failed: DID NOT RAISE <class 'twine.exceptions.InvalidDistribution'>

tests/test_package.py:431: Failed
```

### Please list the steps required to reproduce this behaviour

python3 -m tox -e py

### Anything else you'd like to mention?

`pkginfo` `1.11.0` changed behavior if it deals with unknown metadata version:

https://bugs.launchpad.net/pkginfo/+bug/2058697
https://bazaar.launchpad.net/~tseaver/pkginfo/trunk/revision/235

Pull request:
Restore support for pkginfo 1.11
For now, this approach retains the current behavior that an unrecognized metadata version will fail with an error.

This approach splits out the detection based on the version of pkginfo. Starting with pkginfo 1.11, it now properly detects when a metadata version is unrecognized and errors on that condition explicitly. For backward compatibility, until pkginfo 1.11 is the minimum, the check will still provide guidance to consider the metadata version when fields are missing.

Closes #1116 


Patch:
diff --git a/changelog/1121.removal.rst b/changelog/1121.removal.rst
new file mode 100644
index 0000000..36c5dd1
--- /dev/null
+++ b/changelog/1121.removal.rst
@@ -0,0 +1,1 @@
+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.
\ No newline at end of file
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..24e5052 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,9 +40,7 @@ dependencies = [
 	"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"]
 
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..5b2e288 100644
--- a/twine/repository.py
+++ b/twine/repository.py
@@ -142,7 +142,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