Skip to content

Commit

Permalink
Switch from pkginfo to packaging for parsing distribution metadata
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dnicolodi committed Dec 1, 2024
1 parent 928c06a commit 5f510b0
Show file tree
Hide file tree
Showing 16 changed files with 370 additions and 276 deletions.
8 changes: 8 additions & 0 deletions changelog/1180.misc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- ``packaging`` is used instead of ``pkginfo`` for parsing and
validating metadata. This aligns metadata validation to the one
performed by PyPI. ``pkginfo`` is not a dependency anymore.
- Metadata fields defined 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.
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@
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:
Expand All @@ -291,7 +292,6 @@
# TODO: Try to add these to intersphinx_mapping
nitpick_ignore_regex = [
(r"py:.*", r"pkginfo.*"),
("py:class", r"warnings\.WarningMessage"),
]

# -- Options for apidoc output ------------------------------------------------
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
]
dynamic = ["version"]

Expand Down
44 changes: 44 additions & 0 deletions tests/fixtures/everything.metadata
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
Author-email: "C. Schultz" <[email protected]>
Maintainer: C. Schultz, Universal Features Syndicate,
Los Angeles, CA <[email protected]>
Maintainer-email: "C. Schultz" <[email protected]>
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.
165 changes: 81 additions & 84 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def test_sign_file(monkeypatch):
package = package_file.PackageFile(
filename=filename,
comment=None,
metadata=pretend.stub(name="deprecated-pypirc"),
metadata=dict(name="deprecated-pypirc", version="1.2.3"),
python_version=None,
filetype=None,
)
Expand All @@ -51,7 +51,7 @@ def test_sign_file_with_identity(monkeypatch):
package = package_file.PackageFile(
filename=filename,
comment=None,
metadata=pretend.stub(name="deprecated-pypirc"),
metadata=dict(name="deprecated-pypirc", version="1.2.3"),
python_version=None,
filetype=None,
)
Expand Down Expand Up @@ -106,7 +106,7 @@ def test_package_signed_name_is_correct():
package = package_file.PackageFile(
filename=filename,
comment=None,
metadata=pretend.stub(name="deprecated-pypirc"),
metadata=dict(name="deprecated-pypirc", version="1.2.3"),
python_version=None,
filetype=None,
)
Expand Down Expand Up @@ -163,18 +163,17 @@ def test_package_safe_name_is_correct(pkg_name, expected_name):
package = package_file.PackageFile(
filename="tests/fixtures/deprecated-pypirc",
comment=None,
metadata=pretend.stub(name=pkg_name),
metadata=dict(name=pkg_name, version="1.2.3"),
python_version=None,
filetype=None,
)

assert package.safe_name == expected_name


def test_metadata_dictionary_keys():
"""Merge multiple sources of metadata into a single dictionary."""
package = package_file.PackageFile.from_filename(helpers.SDIST_FIXTURE, None)
assert set(package.metadata_dictionary()) == {
def test_supported_metadata_fields():
"""Check that ``twine.package.PackageMetadata`` contains the expected fields."""
assert set(package_file.PackageMetadata.__annotations__) == {
# identify release
"name",
"version",
Expand All @@ -192,14 +191,16 @@ def test_metadata_dictionary_keys():
"license",
"description",
"keywords",
"platform",
"platforms",
"classifiers",
"download_url",
"supported_platform",
"supported_platforms",
"comment",
"md5_digest",
"sha256_digest",
"blake2_256_digest",
"gpg_signature",
"attestations",
# PEP 314
"provides",
"requires",
Expand All @@ -216,16 +217,19 @@ def test_metadata_dictionary_keys():
"description_content_type",
# Metadata 2.2
"dynamic",
# Metadata 2.4
"license_expression",
"license_files",
}


@pytest.mark.parametrize("gpg_signature", [(None), (pretend.stub())])
@pytest.mark.parametrize("attestation", [(None), ({"fake": "attestation"})])
def test_metadata_dictionary_values(gpg_signature, attestation):
"""Pass values from pkginfo.Distribution through to dictionary."""
meta = pretend.stub(
meta = dict(
name="whatever",
version=pretend.stub(),
version="1.2.3",
metadata_version=pretend.stub(),
summary=pretend.stub(),
home_page=pretend.stub(),
Expand All @@ -236,10 +240,10 @@ def test_metadata_dictionary_values(gpg_signature, attestation):
license=pretend.stub(),
description=pretend.stub(),
keywords=pretend.stub(),
platforms=pretend.stub(),
platform=pretend.stub(),
classifiers=pretend.stub(),
download_url=pretend.stub(),
supported_platforms=pretend.stub(),
supported_platform=pretend.stub(),
provides=pretend.stub(),
requires=pretend.stub(),
obsoletes=pretend.stub(),
Expand All @@ -249,7 +253,7 @@ def test_metadata_dictionary_values(gpg_signature, attestation):
requires_dist=pretend.stub(),
requires_external=pretend.stub(),
requires_python=pretend.stub(),
provides_extras=pretend.stub(),
provides_extra=pretend.stub(),
description_content_type=pretend.stub(),
dynamic=pretend.stub(),
)
Expand All @@ -269,48 +273,48 @@ def test_metadata_dictionary_values(gpg_signature, attestation):

# identify release
assert result["name"] == package.safe_name
assert result["version"] == meta.version
assert result["version"] == package.version == meta["version"]

# file content
assert result["filetype"] == package.filetype
assert result["pyversion"] == package.python_version

# additional meta-data
assert result["metadata_version"] == meta.metadata_version
assert result["summary"] == meta.summary
assert result["home_page"] == meta.home_page
assert result["author"] == meta.author
assert result["author_email"] == meta.author_email
assert result["maintainer"] == meta.maintainer
assert result["maintainer_email"] == meta.maintainer_email
assert result["license"] == meta.license
assert result["description"] == meta.description
assert result["keywords"] == meta.keywords
assert result["platform"] == meta.platforms
assert result["classifiers"] == meta.classifiers
assert result["download_url"] == meta.download_url
assert result["supported_platform"] == meta.supported_platforms
assert result["metadata_version"] == meta["metadata_version"]
assert result["summary"] == meta["summary"]
assert result["home_page"] == meta["home_page"]
assert result["author"] == meta["author"]
assert result["author_email"] == meta["author_email"]
assert result["maintainer"] == meta["maintainer"]
assert result["maintainer_email"] == meta["maintainer_email"]
assert result["license"] == meta["license"]
assert result["description"] == meta["description"]
assert result["keywords"] == meta["keywords"]
assert result["platform"] == meta["platform"]
assert result["classifiers"] == meta["classifiers"]
assert result["download_url"] == meta["download_url"]
assert result["supported_platform"] == meta["supported_platform"]
assert result["comment"] == package.comment

# PEP 314
assert result["provides"] == meta.provides
assert result["requires"] == meta.requires
assert result["obsoletes"] == meta.obsoletes
assert result["provides"] == meta["provides"]
assert result["requires"] == meta["requires"]
assert result["obsoletes"] == meta["obsoletes"]

# Metadata 1.2
assert result["project_urls"] == meta.project_urls
assert result["provides_dist"] == meta.provides_dist
assert result["obsoletes_dist"] == meta.obsoletes_dist
assert result["requires_dist"] == meta.requires_dist
assert result["requires_external"] == meta.requires_external
assert result["requires_python"] == meta.requires_python
assert result["project_urls"] == meta["project_urls"]
assert result["provides_dist"] == meta["provides_dist"]
assert result["obsoletes_dist"] == meta["obsoletes_dist"]
assert result["requires_dist"] == meta["requires_dist"]
assert result["requires_external"] == meta["requires_external"]
assert result["requires_python"] == meta["requires_python"]

# Metadata 2.1
assert result["provides_extra"] == meta.provides_extras
assert result["description_content_type"] == meta.description_content_type
assert result["provides_extra"] == meta["provides_extra"]
assert result["description_content_type"] == meta["description_content_type"]

# Metadata 2.2
assert result["dynamic"] == meta.dynamic
assert result["dynamic"] == meta["dynamic"]

# GPG signature
assert result.get("gpg_signature") == gpg_signature
Expand Down Expand Up @@ -381,76 +385,69 @@ def test_fips_metadata_excludes_md5_and_blake2(monkeypatch):


@pytest.mark.parametrize(
"read_data, missing_fields",
"read_data, exception_message",
[
pytest.param(
b"Metadata-Version: 2.3\nName: UNKNOWN\nVersion: UNKNOWN\n",
"Name, Version",
b"Metadata-Version: 102.3\nName: test-package\nVersion: 1.0.0\n",
"'102.3' is not a valid metadata version",
id="unsupported Metadata-Version",
),
pytest.param(
b"Metadata-Version: 2.3\nName: test-package\nVersion: UNKNOWN\n",
"'UNKNOWN' is invalid for 'version'",
id="invalid Version",
),
pytest.param(
b"Metadata-Version: 2.2\nName: test-package\nVersion: UNKNOWN\n",
"'UNKNOWN' is invalid for 'version'",
id="invalid Version",
),
pytest.param(
b"Metadata-Version: 2.3\n",
"'name' is a required field; 'version' is a required field",
id="missing Name and Version",
),
pytest.param(
b"Metadata-Version: 2.2\nName: UNKNOWN\nVersion: UNKNOWN\n",
"Name, Version",
b"Metadata-Version: 2.2\n",
"'name' is a required field; 'version' is a required field",
id="missing Name and Version",
),
pytest.param(
b"Metadata-Version: 2.3\nName: UNKNOWN\nVersion: 1.0.0\n",
"Name",
b"Metadata-Version: 2.3\nVersion: 1.0.0\n",
"'name' is a required field",
id="missing Name",
),
pytest.param(
b"Metadata-Version: 2.2\nName: UNKNOWN\nVersion: 1.0.0\n",
"Name",
b"Metadata-Version: 2.2\nVersion: 1.0.0\n",
"'name' is a required field",
id="missing Name",
),
pytest.param(
b"Metadata-Version: 2.3\nName: test-package\nVersion: UNKNOWN\n",
"Version",
b"Metadata-Version: 2.3\nName: test-package\n",
"'version' is a required field",
id="missing Version",
),
pytest.param(
b"Metadata-Version: 2.2\nName: test-package\nVersion: UNKNOWN\n",
"Version",
b"Metadata-Version: 2.2\nName: test-package\n",
"'version' is a required field",
id="missing Version",
),
pytest.param(
b"Metadata-Version: 2.2\nName: test-package\nVersion: 1.0.0\nFoo: bar\n",
"unrecognized or malformed field 'foo'",
id="unrecognized field",
),
],
)
def test_pkginfo_returns_no_metadata(read_data, missing_fields, monkeypatch):
def test_pkginfo_returns_no_metadata(read_data, exception_message, monkeypatch):
"""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"

with pytest.raises(exceptions.InvalidDistribution) as err:
package_file.PackageFile.from_filename(filename, comment=None)

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]
assert exception_message in err.value.args[0]


def test_malformed_from_file(monkeypatch):
Expand Down
Loading

0 comments on commit 5f510b0

Please sign in to comment.