diff --git a/pyproject.toml b/pyproject.toml index 52c3de43..4cf2f5a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "keyring >= 15.1; platform_machine != 'ppc64le' and platform_machine != 's390x'", "rfc3986 >= 1.4.0", "rich >= 12.0.0", + "packaging >= 24.1", # workaround for #1116 "pkginfo < 1.11", diff --git a/tests/test_check.py b/tests/test_check.py index 1adb5342..fb4c242d 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -281,6 +281,80 @@ def test_main(monkeypatch): assert check_stub.calls == [pretend.call(["dist/*"], strict=False)] +def test_fails_invalid_classifiers(tmp_path, capsys, caplog): + sdist = build_sdist( + tmp_path, + { + "setup.cfg": ( + """ + [metadata] + name = test-package + version = 0.0.1 + long_description = file:README.md + long_description_content_type = text/markdown + classifiers = + Framework | Django | 5 + """ + ), + "README.md": ( + """ + # test-package + + A test package. + """ + ), + }, + ) + + assert check.check([sdist]) + + assert capsys.readouterr().out == f"Checking {sdist}: FAILED\n" + + assert len(caplog.record_tuples) > 0 + assert caplog.record_tuples == [ + ( + "twine.commands.check", + logging.ERROR, + "`Framework | Django | 5` is not a valid classifier" + " and would prevent upload to PyPI.\n", + ) + ] + + +def test_passes_valid_classifiers(tmp_path, capsys, caplog): + sdist = build_sdist( + tmp_path, + { + "setup.cfg": ( + """ + [metadata] + name = test-package + version = 0.0.1 + long_description = file:README.md + long_description_content_type = text/markdown + classifiers = + Programming Language :: Python :: 3 + Framework :: Django + Framework :: Django :: 5.1 + """ + ), + "README.md": ( + """ + # test-package + + A test package. + """ + ), + }, + ) + + assert not check.check([sdist]) + + assert capsys.readouterr().out == f"Checking {sdist}: PASSED\n" + + assert caplog.record_tuples == [] + + # TODO: Test print() color output # TODO: Test log formatting diff --git a/twine/commands/check.py b/twine/commands/check.py index ed9324cb..329280d3 100644 --- a/twine/commands/check.py +++ b/twine/commands/check.py @@ -105,6 +105,9 @@ def _check_file( if rendering_result is None: is_ok = False + # Will raise an exception if the metadata is invalid + package.metadata_obj() + return warnings, is_ok diff --git a/twine/package.py b/twine/package.py index ac8e4cf1..4ea2f037 100644 --- a/twine/package.py +++ b/twine/package.py @@ -21,6 +21,8 @@ import sys from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union, cast +from packaging.metadata import Metadata as PackagingMetadata + if sys.version_info >= (3, 10): import importlib.metadata as importlib_metadata else: @@ -142,6 +144,49 @@ def from_filename(cls, filename: str, comment: Optional[str]) -> "PackageFile": return cls(filename, comment, meta, py_version, dtype) + def metadata_obj(self) -> PackagingMetadata: + meta = self.metadata + # TODO: this should probably take metadata_dictionary as base and modify + # it to remove the keys that aren't accepted by packaging.RawMetadata + data = { + # Metadata 1.0 - PEP 241 + "metadata_version": meta.metadata_version, + "name": self.safe_name, + "version": meta.version, + "platforms": meta.supported_platforms, + "summary": meta.summary, + "description": meta.description, + "keywords": meta.keywords, + "home_page": meta.home_page, + "author": meta.author, + "author_email": meta.author_email, + "license": meta.license, + # Metadata 1.1 - PEP 314 + "supported_platforms": meta.supported_platforms, + "download_url": meta.download_url, + "classifiers": meta.classifiers, + "requires": meta.requires, + "provides": meta.provides, + "obsoletes": meta.obsoletes, + # Metadata 1.2 - PEP 345 + "maintainer": meta.maintainer, + "maintainer_email": meta.maintainer_email, + "requires_dist": meta.requires_dist, + "provides_dist": meta.provides_dist, + "obsoletes_dist": meta.obsoletes_dist, + "requires_python": meta.requires_python, + "requires_external": meta.requires_external, + "project_urls": meta.project_urls, + # Metadata 2.1 - PEP 566 + "description_content_type": meta.description_content_type, + "provides_extra": meta.provides_extras, + } + if meta.metadata_version and meta.metadata_version > "2.1": + # Metadata 2.2 - PEP 643 + data.update({"dynamic": meta.dynamic}) + + return PackagingMetadata.from_raw(data, validate=True) # type: ignore[arg-type] + def metadata_dictionary(self) -> Dict[str, MetadataValue]: """Merge multiple sources of metadata into a single dictionary.