From 0605ef02a6a5ffa8bddab6816f7ff3e0db9a442f Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Tue, 17 Dec 2024 12:45:40 +0100 Subject: [PATCH] Switch to packaging for parsing metadata and support metadata 2.4 (#1180) * Remove "content" from set of specially handled metadata fields The "content" field is always added to the form data after the package metadata has been flattened, thus it is not needed to handle it in the flattening method. Remove the associated test. This will allow to tighten typing in a successive commit. * Remove "attestations" from the set of specially handled metadata fields The "attestations" field is a string: strings do not need flattening. * Refactor code a tiny bit Avoid looking a key up into a set of one element and remove an indirection through a module global variable. This will make it a bit easier to extend the flattening logic in successive commits. * Switch from pkginfo to packaging for parsing distribution metadata The packaging package is maintained by the PyPA and it is the de-facto reference implementation for the packaging standards. Using packaging for parsing metadata guarantees support for the latest metadata versions. warehouse, the Python package index implementation used by PyPI, also uses packaging for parsing metadata. This guarantees that metadata parsing is the same on the client and server side, for the most prominent index. * Enable some more mypy checks * Move monkeypatching of metadata 2.0 support to a more proper place It was done in the support code for the wheel file format but it affects metadata loading from all supported distribution types. Move it to generic code. * Accommodate for invalid metadata produced by setuptools See pypa/setuptools#4759. --- changelog/1180.misc.txt | 10 + docs/conf.py | 7 +- mypy.ini | 3 +- pyproject.toml | 3 +- tests/fixtures/everything.metadata | 44 +++++ tests/fixtures/twine-1.5.0.zip | Bin 0 -> 35745 bytes tests/test_package.py | 285 ++++++++++++++--------------- tests/test_repository.py | 49 +++-- tests/test_sdist.py | 198 ++++++++++++++++++++ tests/test_wheel.py | 6 +- twine/cli.py | 2 +- twine/commands/check.py | 6 +- twine/distribution.py | 8 + twine/package.py | 280 ++++++++++++++++------------ twine/repository.py | 51 +++--- twine/sdist.py | 83 +++++++++ twine/wheel.py | 30 +-- 17 files changed, 714 insertions(+), 351 deletions(-) create mode 100644 changelog/1180.misc.txt create mode 100644 tests/fixtures/everything.metadata create mode 100644 tests/fixtures/twine-1.5.0.zip create mode 100644 tests/test_sdist.py create mode 100644 twine/distribution.py create mode 100644 twine/sdist.py diff --git a/changelog/1180.misc.txt b/changelog/1180.misc.txt new file mode 100644 index 00000000..db3d8158 --- /dev/null +++ b/changelog/1180.misc.txt @@ -0,0 +1,10 @@ +- ``packaging`` is used instead of ``pkginfo`` for parsing and validating + metadata. This aligns metadata validation to the one performed by PyPI. + ``packaging`` version 24.0 or later is required. Support for metadata + version 2.4 requires ``packaging`` 24.2 or later. ``pkginfo`` is not a + dependency anymore. +- With ``packaging`` version 24.2 or later, metadata fields added with + metadata version 2.4 as defined by PEP 639 are now sent to the package index + when a distribution is uploaded. This results in licensing information to + appear correctly on the package page on PyPI when uploading packages using + metadata version 2.4. diff --git a/docs/conf.py b/docs/conf.py index ddef188a..df1e1abe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -283,17 +283,12 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "requests": ("https://requests.readthedocs.io/en/latest/", None), + "packaging": ("https://packaging.pypa.io/en/latest/", None), } # Be strict about the invalid references: 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 ------------------------------------------------ autodoc_default_options = { diff --git a/mypy.ini b/mypy.ini index 0c09ee80..aea869cb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,8 +5,7 @@ show_traceback = True warn_redundant_casts = True warn_unused_configs = True warn_unused_ignores = True -; Enabling this will fail on subclasses of untyped imports, e.g. pkginfo -; disallow_subclassing_any = True +disallow_subclassing_any = True disallow_any_generics = True disallow_untyped_calls = True disallow_untyped_defs = True diff --git a/pyproject.toml b/pyproject.toml index e976d56d..62c32c04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ classifiers = [ ] requires-python = ">=3.8" dependencies = [ - "pkginfo >= 1.8.1", "readme-renderer >= 35.0", "requests >= 2.20", "requests-toolbelt >= 0.8.0, != 0.9.0", @@ -41,7 +40,7 @@ dependencies = [ "keyring >= 15.1; platform_machine != 'ppc64le' and platform_machine != 's390x'", "rfc3986 >= 1.4.0", "rich >= 12.0.0", - "packaging", + "packaging >= 24.0", "id", ] dynamic = ["version"] diff --git a/tests/fixtures/everything.metadata b/tests/fixtures/everything.metadata new file mode 100644 index 00000000..eea1351b --- /dev/null +++ b/tests/fixtures/everything.metadata @@ -0,0 +1,44 @@ +Metadata-Version: 2.4 +Name: BeagleVote +Version: 1.0a2 +Platform: ObscureUnix +Platform: RareDOS +Supported-Platform: RedHat 7.2 +Supported-Platform: i386-win32-2791 +Summary: A module for collecting votes from beagles. +Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM +Keywords: dog,puppy,voting,election +Home-page: http://www.example.com/~cschultz/bvote/ +Download-URL: …/BeagleVote-0.45.tgz +Author: C. Schultz, Universal Features Syndicate, + Los Angeles, CA +Author-email: "C. Schultz" +Maintainer: C. Schultz, Universal Features Syndicate, + Los Angeles, CA +Maintainer-email: "C. Schultz" +License: This software may only be obtained by sending the + author a postcard, and then the user promises not + to redistribute it. +License-Expression: Apache-2.0 OR BSD-2-Clause +License-File: LICENSE.APACHE +License-File: LICENSE.BSD +Classifier: Development Status :: 4 - Beta +Classifier: Environment :: Console (Text Based) +Provides-Extra: pdf +Requires-Dist: reportlab; extra == 'pdf' +Requires-Dist: pkginfo +Requires-Dist: PasteDeploy +Requires-Dist: zope.interface (>3.5.0) +Requires-Dist: pywin32 >1.0; sys_platform == 'win32' +Requires-Python: >=3 +Requires-External: C +Requires-External: libpng (>=1.5) +Requires-External: make; sys_platform != "win32" +Project-URL: Bug Tracker, http://bitbucket.org/tarek/distribute/issues/ +Project-URL: Documentation, https://example.com/BeagleVote +Provides-Dist: OtherProject +Provides-Dist: AnotherProject (3.4) +Provides-Dist: virtual_package; python_version >= "3.4" +Dynamic: Obsoletes-Dist + +This description intentionally left blank. diff --git a/tests/fixtures/twine-1.5.0.zip b/tests/fixtures/twine-1.5.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..55cbf7a1c8bafce572cf42e662f15e78e9dbe53c GIT binary patch literal 35745 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 None: def list_dependencies_and_versions() -> List[Tuple[str, str]]: deps = [ "keyring", # optional for non-desktop use - "pkginfo", + "packaging", "requests", "requests-toolbelt", "urllib3", diff --git a/twine/commands/check.py b/twine/commands/check.py index 9637651e..47d15a17 100644 --- a/twine/commands/check.py +++ b/twine/commands/check.py @@ -18,7 +18,7 @@ import io import logging import re -from typing import Dict, List, Optional, Tuple, cast +from typing import Dict, List, Tuple import readme_renderer.rst from rich import print @@ -84,8 +84,8 @@ def _check_file( package = package_file.PackageFile.from_filename(filename, comment=None) metadata = package.metadata_dictionary() - description = cast(Optional[str], metadata["description"]) - description_content_type = cast(Optional[str], metadata["description_content_type"]) + description = metadata.get("description") + description_content_type = metadata.get("description_content_type") if description_content_type is None: warnings.append( diff --git a/twine/distribution.py b/twine/distribution.py new file mode 100644 index 00000000..61e2ab7f --- /dev/null +++ b/twine/distribution.py @@ -0,0 +1,8 @@ +class Distribution: + + def read(self) -> bytes: + raise NotImplementedError + + @property + def py_version(self) -> str: + return "any" diff --git a/twine/package.py b/twine/package.py index e31d4fd4..5d8db29a 100644 --- a/twine/package.py +++ b/twine/package.py @@ -18,36 +18,31 @@ import os import re import subprocess -import sys -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 typing import Any, Dict, List, NamedTuple, Optional, Tuple, TypedDict + +from packaging import metadata +from packaging import version from rich import print from twine import exceptions +from twine import sdist from twine import wheel +# Monkeypatch Metadata 2.0 support +metadata._VALID_METADATA_VERSIONS = [ + "1.0", + "1.1", + "1.2", + "2.0", + "2.1", + "2.2", + "2.3", + "2.4", +] + DIST_TYPES = { "bdist_wheel": wheel.Wheel, - "sdist": pkginfo.SDist, + "sdist": sdist.SDist, } DIST_EXTENSIONS = { @@ -56,8 +51,6 @@ ".zip": "sdist", } -MetadataValue = Union[Optional[str], Sequence[str], Tuple[str, bytes]] - logger = logging.getLogger(__name__) @@ -72,11 +65,101 @@ 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.""" +# Map ``metadata.RawMetadata`` fields to ``PackageMetadata`` fields. Some +# fields are renamed to match the names expected in the upload form. +_RAW_TO_PACKAGE_METADATA = { + # Metadata 1.0 - PEP 241 + "metadata_version": "metadata_version", + "name": "name", + "version": "version", + "platforms": "platform", # Renamed + "summary": "summary", + "description": "description", + "keywords": "keywords", + "home_page": "home_page", + "author": "author", + "author_email": "author_email", + "license": "license", + # Metadata 1.1 - PEP 314 + "supported_platforms": "supported_platform", # Renamed + "download_url": "download_url", + "classifiers": "classifiers", + "requires": "requires", + "provides": "provides", + "obsoletes": "obsoletes", + # Metadata 1.2 - PEP 345 + "maintainer": "maintainer", + "maintainer_email": "maintainer_email", + "requires_dist": "requires_dist", + "provides_dist": "provides_dist", + "obsoletes_dist": "obsoletes_dist", + "requires_python": "requires_python", + "requires_external": "requires_external", + "project_urls": "project_urls", + # Metadata 2.1 - PEP 566 + "description_content_type": "description_content_type", + "provides_extra": "provides_extra", + # Metadata 2.2 - PEP 643 + "dynamic": "dynamic", + # Metadata 2.4 - PEP 639 + "license_expression": "license_expression", + "license_files": "license_file", # Renamed +} + + +class PackageMetadata(TypedDict, total=False): + # Metadata 1.0 - PEP 241 + metadata_version: str name: str version: str + platform: List[str] + summary: str + description: str + keywords: List[str] + home_page: str + author: str + author_email: str + license: str + + # Metadata 1.1 - PEP 314 + supported_platform: List[str] + download_url: str + classifiers: List[str] + requires: List[str] + provides: List[str] + obsoletes: List[str] + + # Metadata 1.2 - PEP 345 + maintainer: str + maintainer_email: str + requires_dist: List[str] + provides_dist: List[str] + obsoletes_dist: List[str] + requires_python: str + requires_external: List[str] + project_urls: Dict[str, str] + + # Metadata 2.1 - PEP 566 + description_content_type: str + provides_extra: List[str] + + # Metadata 2.2 - PEP 643 + dynamic: List[str] + + # Metadata 2.4 - PEP 639 + license_expression: str + license_file: List[str] + + # Additional metadata + comment: Optional[str] + pyversion: str + filetype: str + gpg_signature: Tuple[str, bytes] + attestations: str + md5_digest: str + sha256_digest: Optional[str] + blake2_256_digest: str class PackageFile: @@ -84,9 +167,9 @@ def __init__( self, filename: str, comment: Optional[str], - metadata: CheckedDistribution, - python_version: Optional[str], - filetype: Optional[str], + metadata: metadata.RawMetadata, + python_version: str, + filetype: str, ) -> None: self.filename = filename self.basefilename = os.path.basename(filename) @@ -94,7 +177,8 @@ def __init__( self.metadata = metadata self.python_version = python_version self.filetype = filetype - self.safe_name = _safe_name(metadata.name) + self.safe_name = _safe_name(metadata["name"]) + self.version: str = metadata["version"] self.signed_filename = self.filename + ".asc" self.signed_basefilename = self.basefilename + ".asc" self.gpg_signature: Optional[Tuple[str, bytes]] = None @@ -114,8 +198,9 @@ def from_filename(cls, filename: str, comment: Optional[str]) -> "PackageFile": for ext, dtype in DIST_EXTENSIONS.items(): if filename.endswith(ext): try: - with warnings.catch_warnings(record=True) as captured: - meta = DIST_TYPES[dtype](filename) + dist = DIST_TYPES[dtype](filename) + data = dist.read() + py_version = dist.py_version except EOFError: raise exceptions.InvalidDistribution( "Invalid distribution file: '%s'" % os.path.basename(filename) @@ -127,101 +212,58 @@ def from_filename(cls, filename: str, comment: Optional[str]) -> "PackageFile": "Unknown distribution format: '%s'" % os.path.basename(filename) ) - supported_metadata = list(pkginfo.distribution.HEADER_ATTRS) - if cls._is_unknown_metadata_version(captured): + # Parse and validate metadata. + meta, unparsed = metadata.parse_email(data) + if unparsed: raise exceptions.InvalidDistribution( - "Make sure the distribution is using a supported Metadata-Version: " - f"{', '.join(supported_metadata)}." + "Invalid distribution metadata: {}".format( + "; ".join( + f"unrecognized or malformed field {key!r}" for key in unparsed + ) + ) ) - # 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. - missing_fields = [ - f.capitalize() for f in ["name", "version"] if not getattr(meta, f) - ] - if missing_fields: - 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)}." + # setuptools emits License-File metadata fields while declaring + # Metadata-Version 2.1. This is invalid because the metadata + # specification does not allow to add arbitrary fields, and because + # the semantic implemented by setuptools is different than the one + # described in PEP 639. However, rejecting these packages would be + # too disruptive. Drop License-File metadata entries from the data + # sent to the package index if the declared metadata version is less + # than 2.4. + if version.Version(meta.get("metadata_version", "0")) < version.Version("2.4"): + meta.pop("license_files", None) + try: + metadata.Metadata.from_raw(meta) + except metadata.ExceptionGroup as group: + raise exceptions.InvalidDistribution( + "Invalid distribution metadata: {}".format( + "; ".join(sorted(str(e) for e in group.exceptions)) ) - raise exceptions.InvalidDistribution(msg) - - if dtype == "bdist_wheel": - py_version = cast(wheel.Wheel, meta).py_version - elif dtype == "sdist": - py_version = "source" - else: - # This should not be reached. - raise ValueError - - 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") + return cls(filename, comment, meta, py_version, dtype) - def metadata_dictionary(self) -> Dict[str, MetadataValue]: + def metadata_dictionary(self) -> PackageMetadata: """Merge multiple sources of metadata into a single dictionary. Includes values from filename, PKG-INFO, hashers, and signature. """ - meta = self.metadata - data: Dict[str, MetadataValue] = { - # identify release - "name": self.safe_name, - "version": meta.version, - # file content - "filetype": self.filetype, - "pyversion": self.python_version, - # additional meta-data - "metadata_version": meta.metadata_version, - "summary": meta.summary, - "home_page": meta.home_page, - "author": meta.author, - "author_email": meta.author_email, - "maintainer": meta.maintainer, - "maintainer_email": meta.maintainer_email, - "license": meta.license, - "description": meta.description, - "keywords": meta.keywords, - "platform": meta.platforms, - "classifiers": meta.classifiers, - "download_url": meta.download_url, - "supported_platform": meta.supported_platforms, - "comment": self.comment, - "sha256_digest": self.sha2_digest, - # PEP 314 - "provides": meta.provides, - "requires": meta.requires, - "obsoletes": meta.obsoletes, - # Metadata 1.2 - "project_urls": meta.project_urls, - "provides_dist": meta.provides_dist, - "obsoletes_dist": meta.obsoletes_dist, - "requires_dist": meta.requires_dist, - "requires_external": meta.requires_external, - "requires_python": meta.requires_python, - # Metadata 2.1 - "provides_extra": meta.provides_extras, - "description_content_type": meta.description_content_type, - # Metadata 2.2 - "dynamic": meta.dynamic, - } + data = PackageMetadata() + for key, value in self.metadata.items(): + field = _RAW_TO_PACKAGE_METADATA.get(key) + if field: + # A ``TypedDict`` only support literal key names. Here key + # names are computed but they can only be valid key names. + data[field] = value # type: ignore[literal-required] + + # override name with safe name + data["name"] = self.safe_name + # file content + data["pyversion"] = self.python_version + data["filetype"] = self.filetype + # additional meta-data + data["comment"] = self.comment + data["sha256_digest"] = self.sha2_digest if self.gpg_signature is not None: data["gpg_signature"] = self.gpg_signature diff --git a/twine/repository.py b/twine/repository.py index f04f9dbc..3b5f68a8 100644 --- a/twine/repository.py +++ b/twine/repository.py @@ -22,8 +22,6 @@ from twine import package as package_file from twine.utils import make_requests_session -KEYWORDS_TO_NOT_FLATTEN = {"gpg_signature", "attestations", "content"} - LEGACY_PYPI = "https://pypi.python.org/" LEGACY_TEST_PYPI = "https://testpypi.python.org/" WAREHOUSE = "https://upload.pypi.org/" @@ -62,13 +60,27 @@ def close(self) -> None: self.session.close() @staticmethod - def _convert_data_to_list_of_tuples(data: Dict[str, Any]) -> List[Tuple[str, Any]]: - data_to_send = [] + def _convert_metadata_to_list_of_tuples( + data: package_file.PackageMetadata, + ) -> List[Tuple[str, Any]]: + # This does what ``warehouse.forklift.parse_form_metadata()`` does, in reverse. + data_to_send: List[Tuple[str, Any]] = [] for key, value in data.items(): - if key in KEYWORDS_TO_NOT_FLATTEN or not isinstance(value, (list, tuple)): + if key == "gpg_signature": + assert isinstance(value, tuple) data_to_send.append((key, value)) - else: + elif key == "project_urls": + assert isinstance(value, dict) + for name, url in value.items(): + data_to_send.append((key, f"{name}, {url}")) + elif key == "keywords": + assert isinstance(value, list) + data_to_send.append((key, ", ".join(value))) + elif isinstance(value, (list, tuple)): data_to_send.extend((key, item) for item in value) + else: + assert isinstance(value, str) or value is None + data_to_send.append((key, value)) return data_to_send def set_certificate_authority(self, cacert: Optional[str]) -> None: @@ -80,12 +92,12 @@ def set_client_certificate(self, clientcert: Optional[str]) -> None: self.session.cert = clientcert def register(self, package: package_file.PackageFile) -> requests.Response: - data = package.metadata_dictionary() - data.update({":action": "submit", "protocol_version": "1"}) - print(f"Registering {package.basefilename}") - data_to_send = self._convert_data_to_list_of_tuples(data) + metadata = package.metadata_dictionary() + data_to_send = self._convert_metadata_to_list_of_tuples(metadata) + data_to_send.append((":action", "submit")) + data_to_send.append(("protocol_version", "1")) encoder = requests_toolbelt.MultipartEncoder(data_to_send) resp = self.session.post( self.url, @@ -98,19 +110,12 @@ def register(self, package: package_file.PackageFile) -> requests.Response: return resp def _upload(self, package: package_file.PackageFile) -> requests.Response: - data = package.metadata_dictionary() - data.update( - { - # action - ":action": "file_upload", - "protocol_version": "1", - } - ) - - data_to_send = self._convert_data_to_list_of_tuples(data) - print(f"Uploading {package.basefilename}") + metadata = package.metadata_dictionary() + data_to_send = self._convert_metadata_to_list_of_tuples(metadata) + data_to_send.append((":action", "file_upload")) + data_to_send.append(("protocol_version", "1")) with open(package.filename, "rb") as fp: data_to_send.append( ( @@ -197,7 +202,7 @@ def package_is_uploaded( releases = {} self._releases_json_data[safe_name] = releases - packages = releases.get(package.metadata.version, []) + packages = releases.get(package.version, []) for uploaded_package in packages: if uploaded_package["filename"] == package.basefilename: @@ -214,7 +219,7 @@ def release_urls(self, packages: List[package_file.PackageFile]) -> Set[str]: return set() return { - f"{url}project/{package.safe_name}/{package.metadata.version}/" + f"{url}project/{package.safe_name}/{package.version}/" for package in packages } diff --git a/twine/sdist.py b/twine/sdist.py new file mode 100644 index 00000000..4808e882 --- /dev/null +++ b/twine/sdist.py @@ -0,0 +1,83 @@ +import os +import tarfile +import zipfile +from contextlib import suppress + +from twine import distribution +from twine import exceptions + + +class SDist(distribution.Distribution): + def __new__(cls, filename: str) -> "SDist": + if cls is not SDist: + return object.__new__(cls) + + FORMATS = { + ".tar.gz": TarGzSDist, + ".zip": ZipSDist, + } + + for extension, impl in FORMATS.items(): + if filename.endswith(extension): + return impl(filename) + raise exceptions.InvalidDistribution(f"Unsupported sdist format: {filename}") + + def __init__(self, filename: str) -> None: + if not os.path.exists(filename): + raise exceptions.InvalidDistribution(f"No such file: {filename}") + self.filename = filename + + @property + def py_version(self) -> str: + return "source" + + +class TarGzSDist(SDist): + + def read(self) -> bytes: + with tarfile.open(self.filename, "r:gz") as sdist: + # The sdist must contain a single top-level direcotry... + root = os.path.commonpath(sdist.getnames()) + if root in {".", "/", ""}: + raise exceptions.InvalidDistribution( + "Too many top-level members in sdist archive: {self.filename}" + ) + # ...containing the package metadata in a ``PKG-INFO`` file. + with suppress(KeyError): + member = sdist.getmember(root.rstrip("/") + "/PKG-INFO") + if not member.isfile(): + raise exceptions.InvalidDistribution( + "PKG-INFO is not a regular file: {self.filename}" + ) + fd = sdist.extractfile(member) + assert fd is not None, "for mypy" + data = fd.read() + if b"Metadata-Version" in data: + return data + + raise exceptions.InvalidDistribution( + "No PKG-INFO in archive or " + f"PKG-INFO missing 'Metadata-Version': {self.filename}" + ) + + +class ZipSDist(SDist): + + def read(self) -> bytes: + with zipfile.ZipFile(self.filename) as sdist: + # The sdist must contain a single top-level direcotry... + root = os.path.commonpath(sdist.namelist()) + if root in {".", "/", ""}: + raise exceptions.InvalidDistribution( + "Too many top-level members in sdist archive: {self.filename}" + ) + # ...containing the package metadata in a ``PKG-INFO`` file. + with suppress(KeyError): + data = sdist.read(root.rstrip("/") + "/PKG-INFO") + if b"Metadata-Version" in data: + return data + + raise exceptions.InvalidDistribution( + "No PKG-INFO in archive or " + f"PKG-INFO missing 'Metadata-Version': {self.filename}" + ) diff --git a/twine/wheel.py b/twine/wheel.py index a2a8ba8a..c1d82352 100644 --- a/twine/wheel.py +++ b/twine/wheel.py @@ -11,22 +11,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import io import os import re import zipfile -from typing import List, Optional -from typing import cast as type_cast - -from pkginfo import distribution +from typing import List +from twine import distribution from twine import exceptions -# Monkeypatch Metadata 2.0 support -distribution.HEADER_ATTRS_2_0 = distribution.HEADER_ATTRS_1_2 -distribution.HEADER_ATTRS.update({"2.0": distribution.HEADER_ATTRS_2_0}) - - wheel_file_re = re.compile( r"""^(?P(?P.+?)(-(?P\d.+?))?) ((-(?P\d.*?))?-(?P.+?)-(?P.+?)-(?P.+?) @@ -36,15 +28,14 @@ class Wheel(distribution.Distribution): - def __init__(self, filename: str, metadata_version: Optional[str] = None) -> None: + def __init__(self, filename: str) -> None: + if not os.path.exists(filename): + raise exceptions.InvalidDistribution(f"No such file: {filename}") self.filename = filename - self.basefilename = os.path.basename(self.filename) - self.metadata_version = metadata_version - self.extractMetadata() @property def py_version(self) -> str: - wheel_info = wheel_file_re.match(self.basefilename) + wheel_info = wheel_file_re.match(os.path.basename(self.filename)) if wheel_info is None: return "any" else: @@ -88,12 +79,3 @@ def read_file(name: str) -> bytes: "No METADATA in archive or METADATA missing 'Metadata-Version': " "%s (searched %s)" % (fqn, ",".join(searched_files)) ) - - def parse(self, data: bytes) -> None: - super().parse(data) - - fp = io.StringIO(data.decode("utf-8", errors="replace")) - # msg is ``email.message.Message`` which is a legacy API documented - # here: https://docs.python.org/3/library/email.compat32-message.html - msg = distribution.parse(fp) - self.description = type_cast(str, msg.get_payload())