aboutsummaryrefslogtreecommitdiff
path: root/.venv/lib/python3.12/site-packages/setuptools/tests/test_core_metadata.py
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/setuptools/tests/test_core_metadata.py')
-rw-r--r--.venv/lib/python3.12/site-packages/setuptools/tests/test_core_metadata.py622
1 files changed, 622 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/setuptools/tests/test_core_metadata.py b/.venv/lib/python3.12/site-packages/setuptools/tests/test_core_metadata.py
new file mode 100644
index 00000000..0d925111
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/setuptools/tests/test_core_metadata.py
@@ -0,0 +1,622 @@
+from __future__ import annotations
+
+import functools
+import importlib
+import io
+from email import message_from_string
+from email.generator import Generator
+from email.message import EmailMessage, Message
+from email.parser import Parser
+from email.policy import EmailPolicy
+from inspect import cleandoc
+from pathlib import Path
+from unittest.mock import Mock
+
+import jaraco.path
+import pytest
+from packaging.metadata import Metadata
+from packaging.requirements import Requirement
+
+from setuptools import _reqs, sic
+from setuptools._core_metadata import rfc822_escape, rfc822_unescape
+from setuptools.command.egg_info import egg_info, write_requirements
+from setuptools.config import expand, setupcfg
+from setuptools.dist import Distribution
+
+from .config.downloads import retrieve_file, urls_from_file
+
+EXAMPLE_BASE_INFO = dict(
+ name="package",
+ version="0.0.1",
+ author="Foo Bar",
+ author_email="foo@bar.net",
+ long_description="Long\ndescription",
+ description="Short description",
+ keywords=["one", "two"],
+)
+
+
+@pytest.mark.parametrize(
+ ("content", "result"),
+ (
+ pytest.param(
+ "Just a single line",
+ None,
+ id="single_line",
+ ),
+ pytest.param(
+ "Multiline\nText\nwithout\nextra indents\n",
+ None,
+ id="multiline",
+ ),
+ pytest.param(
+ "Multiline\n With\n\nadditional\n indentation",
+ None,
+ id="multiline_with_indentation",
+ ),
+ pytest.param(
+ " Leading whitespace",
+ "Leading whitespace",
+ id="remove_leading_whitespace",
+ ),
+ pytest.param(
+ " Leading whitespace\nIn\n Multiline comment",
+ "Leading whitespace\nIn\n Multiline comment",
+ id="remove_leading_whitespace_multiline",
+ ),
+ ),
+)
+def test_rfc822_unescape(content, result):
+ assert (result or content) == rfc822_unescape(rfc822_escape(content))
+
+
+def __read_test_cases():
+ base = EXAMPLE_BASE_INFO
+
+ params = functools.partial(dict, base)
+
+ return [
+ ('Metadata version 1.0', params()),
+ (
+ 'Metadata Version 1.0: Short long description',
+ params(
+ long_description='Short long description',
+ ),
+ ),
+ (
+ 'Metadata version 1.1: Classifiers',
+ params(
+ classifiers=[
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.7',
+ 'License :: OSI Approved :: MIT License',
+ ],
+ ),
+ ),
+ (
+ 'Metadata version 1.1: Download URL',
+ params(
+ download_url='https://example.com',
+ ),
+ ),
+ (
+ 'Metadata Version 1.2: Requires-Python',
+ params(
+ python_requires='>=3.7',
+ ),
+ ),
+ pytest.param(
+ 'Metadata Version 1.2: Project-Url',
+ params(project_urls=dict(Foo='https://example.bar')),
+ marks=pytest.mark.xfail(
+ reason="Issue #1578: project_urls not read",
+ ),
+ ),
+ (
+ 'Metadata Version 2.1: Long Description Content Type',
+ params(
+ long_description_content_type='text/x-rst; charset=UTF-8',
+ ),
+ ),
+ (
+ 'License',
+ params(
+ license='MIT',
+ ),
+ ),
+ (
+ 'License multiline',
+ params(
+ license='This is a long license \nover multiple lines',
+ ),
+ ),
+ pytest.param(
+ 'Metadata Version 2.1: Provides Extra',
+ params(provides_extras=['foo', 'bar']),
+ marks=pytest.mark.xfail(reason="provides_extras not read"),
+ ),
+ (
+ 'Missing author',
+ dict(
+ name='foo',
+ version='1.0.0',
+ author_email='snorri@sturluson.name',
+ ),
+ ),
+ (
+ 'Missing author e-mail',
+ dict(
+ name='foo',
+ version='1.0.0',
+ author='Snorri Sturluson',
+ ),
+ ),
+ (
+ 'Missing author and e-mail',
+ dict(
+ name='foo',
+ version='1.0.0',
+ ),
+ ),
+ (
+ 'Bypass normalized version',
+ dict(
+ name='foo',
+ version=sic('1.0.0a'),
+ ),
+ ),
+ ]
+
+
+@pytest.mark.parametrize(("name", "attrs"), __read_test_cases())
+def test_read_metadata(name, attrs):
+ dist = Distribution(attrs)
+ metadata_out = dist.metadata
+ dist_class = metadata_out.__class__
+
+ # Write to PKG_INFO and then load into a new metadata object
+ PKG_INFO = io.StringIO()
+
+ metadata_out.write_pkg_file(PKG_INFO)
+ PKG_INFO.seek(0)
+ pkg_info = PKG_INFO.read()
+ assert _valid_metadata(pkg_info)
+
+ PKG_INFO.seek(0)
+ metadata_in = dist_class()
+ metadata_in.read_pkg_file(PKG_INFO)
+
+ tested_attrs = [
+ ('name', dist_class.get_name),
+ ('version', dist_class.get_version),
+ ('author', dist_class.get_contact),
+ ('author_email', dist_class.get_contact_email),
+ ('metadata_version', dist_class.get_metadata_version),
+ ('provides', dist_class.get_provides),
+ ('description', dist_class.get_description),
+ ('long_description', dist_class.get_long_description),
+ ('download_url', dist_class.get_download_url),
+ ('keywords', dist_class.get_keywords),
+ ('platforms', dist_class.get_platforms),
+ ('obsoletes', dist_class.get_obsoletes),
+ ('requires', dist_class.get_requires),
+ ('classifiers', dist_class.get_classifiers),
+ ('project_urls', lambda s: getattr(s, 'project_urls', {})),
+ ('provides_extras', lambda s: getattr(s, 'provides_extras', {})),
+ ]
+
+ for attr, getter in tested_attrs:
+ assert getter(metadata_in) == getter(metadata_out)
+
+
+def __maintainer_test_cases():
+ attrs = {"name": "package", "version": "1.0", "description": "xxx"}
+
+ def merge_dicts(d1, d2):
+ d1 = d1.copy()
+ d1.update(d2)
+
+ return d1
+
+ return [
+ ('No author, no maintainer', attrs.copy()),
+ (
+ 'Author (no e-mail), no maintainer',
+ merge_dicts(attrs, {'author': 'Author Name'}),
+ ),
+ (
+ 'Author (e-mail), no maintainer',
+ merge_dicts(
+ attrs, {'author': 'Author Name', 'author_email': 'author@name.com'}
+ ),
+ ),
+ (
+ 'No author, maintainer (no e-mail)',
+ merge_dicts(attrs, {'maintainer': 'Maintainer Name'}),
+ ),
+ (
+ 'No author, maintainer (e-mail)',
+ merge_dicts(
+ attrs,
+ {
+ 'maintainer': 'Maintainer Name',
+ 'maintainer_email': 'maintainer@name.com',
+ },
+ ),
+ ),
+ (
+ 'Author (no e-mail), Maintainer (no-email)',
+ merge_dicts(
+ attrs, {'author': 'Author Name', 'maintainer': 'Maintainer Name'}
+ ),
+ ),
+ (
+ 'Author (e-mail), Maintainer (e-mail)',
+ merge_dicts(
+ attrs,
+ {
+ 'author': 'Author Name',
+ 'author_email': 'author@name.com',
+ 'maintainer': 'Maintainer Name',
+ 'maintainer_email': 'maintainer@name.com',
+ },
+ ),
+ ),
+ (
+ 'No author (e-mail), no maintainer (e-mail)',
+ merge_dicts(
+ attrs,
+ {
+ 'author_email': 'author@name.com',
+ 'maintainer_email': 'maintainer@name.com',
+ },
+ ),
+ ),
+ ('Author unicode', merge_dicts(attrs, {'author': '鉄沢寛'})),
+ ('Maintainer unicode', merge_dicts(attrs, {'maintainer': 'Jan Łukasiewicz'})),
+ ]
+
+
+@pytest.mark.parametrize(("name", "attrs"), __maintainer_test_cases())
+def test_maintainer_author(name, attrs, tmpdir):
+ tested_keys = {
+ 'author': 'Author',
+ 'author_email': 'Author-email',
+ 'maintainer': 'Maintainer',
+ 'maintainer_email': 'Maintainer-email',
+ }
+
+ # Generate a PKG-INFO file
+ dist = Distribution(attrs)
+ fn = tmpdir.mkdir('pkg_info')
+ fn_s = str(fn)
+
+ dist.metadata.write_pkg_info(fn_s)
+
+ with open(str(fn.join('PKG-INFO')), 'r', encoding='utf-8') as f:
+ pkg_info = f.read()
+
+ assert _valid_metadata(pkg_info)
+
+ # Drop blank lines and strip lines from default description
+ raw_pkg_lines = pkg_info.splitlines()
+ pkg_lines = list(filter(None, raw_pkg_lines[:-2]))
+
+ pkg_lines_set = set(pkg_lines)
+
+ # Duplicate lines should not be generated
+ assert len(pkg_lines) == len(pkg_lines_set)
+
+ for fkey, dkey in tested_keys.items():
+ val = attrs.get(dkey, None)
+ if val is None:
+ for line in pkg_lines:
+ assert not line.startswith(fkey + ':')
+ else:
+ line = f'{fkey}: {val}'
+ assert line in pkg_lines_set
+
+
+class TestParityWithMetadataFromPyPaWheel:
+ def base_example(self):
+ attrs = dict(
+ **EXAMPLE_BASE_INFO,
+ # Example with complex requirement definition
+ python_requires=">=3.8",
+ install_requires="""
+ packaging==23.2
+ more-itertools==8.8.0; extra == "other"
+ jaraco.text==3.7.0
+ importlib-resources==5.10.2; python_version<"3.8"
+ importlib-metadata==6.0.0 ; python_version<"3.8"
+ colorama>=0.4.4; sys_platform == "win32"
+ """,
+ extras_require={
+ "testing": """
+ pytest >= 6
+ pytest-checkdocs >= 2.4
+ tomli ; \\
+ # Using stdlib when possible
+ python_version < "3.11"
+ ini2toml[lite]>=0.9
+ """,
+ "other": [],
+ },
+ )
+ # Generate a PKG-INFO file using setuptools
+ return Distribution(attrs)
+
+ def test_requires_dist(self, tmp_path):
+ dist = self.base_example()
+ pkg_info = _get_pkginfo(dist)
+ assert _valid_metadata(pkg_info)
+
+ # Ensure Requires-Dist is present
+ expected = [
+ 'Metadata-Version:',
+ 'Requires-Python: >=3.8',
+ 'Provides-Extra: other',
+ 'Provides-Extra: testing',
+ 'Requires-Dist: tomli; python_version < "3.11" and extra == "testing"',
+ 'Requires-Dist: more-itertools==8.8.0; extra == "other"',
+ 'Requires-Dist: ini2toml[lite]>=0.9; extra == "testing"',
+ ]
+ for line in expected:
+ assert line in pkg_info
+
+ HERE = Path(__file__).parent
+ EXAMPLES_FILE = HERE / "config/setupcfg_examples.txt"
+
+ @pytest.fixture(params=[None, *urls_from_file(EXAMPLES_FILE)])
+ def dist(self, request, monkeypatch, tmp_path):
+ """Example of distribution with arbitrary configuration"""
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.42"))
+ monkeypatch.setattr(expand, "read_files", Mock(return_value="hello world"))
+ monkeypatch.setattr(
+ Distribution, "_finalize_license_files", Mock(return_value=None)
+ )
+ if request.param is None:
+ yield self.base_example()
+ else:
+ # Real-world usage
+ config = retrieve_file(request.param)
+ yield setupcfg.apply_configuration(Distribution({}), config)
+
+ @pytest.mark.uses_network
+ def test_equivalent_output(self, tmp_path, dist):
+ """Ensure output from setuptools is equivalent to the one from `pypa/wheel`"""
+ # Generate a METADATA file using pypa/wheel for comparison
+ wheel_metadata = importlib.import_module("wheel.metadata")
+ pkginfo_to_metadata = getattr(wheel_metadata, "pkginfo_to_metadata", None)
+
+ if pkginfo_to_metadata is None: # pragma: nocover
+ pytest.xfail(
+ "wheel.metadata.pkginfo_to_metadata is undefined, "
+ "(this is likely to be caused by API changes in pypa/wheel"
+ )
+
+ # Generate an simplified "egg-info" dir for pypa/wheel to convert
+ pkg_info = _get_pkginfo(dist)
+ egg_info_dir = tmp_path / "pkg.egg-info"
+ egg_info_dir.mkdir(parents=True)
+ (egg_info_dir / "PKG-INFO").write_text(pkg_info, encoding="utf-8")
+ write_requirements(egg_info(dist), egg_info_dir, egg_info_dir / "requires.txt")
+
+ # Get pypa/wheel generated METADATA but normalize requirements formatting
+ metadata_msg = pkginfo_to_metadata(egg_info_dir, egg_info_dir / "PKG-INFO")
+ metadata_str = _normalize_metadata(metadata_msg)
+ pkg_info_msg = message_from_string(pkg_info)
+ pkg_info_str = _normalize_metadata(pkg_info_msg)
+
+ # Compare setuptools PKG-INFO x pypa/wheel METADATA
+ assert metadata_str == pkg_info_str
+
+ # Make sure it parses/serializes well in pypa/wheel
+ _assert_roundtrip_message(pkg_info)
+
+
+class TestPEP643:
+ STATIC_CONFIG = {
+ "setup.cfg": cleandoc(
+ """
+ [metadata]
+ name = package
+ version = 0.0.1
+ author = Foo Bar
+ author_email = foo@bar.net
+ long_description = Long
+ description
+ description = Short description
+ keywords = one, two
+ platforms = abcd
+ [options]
+ install_requires = requests
+ """
+ ),
+ "pyproject.toml": cleandoc(
+ """
+ [project]
+ name = "package"
+ version = "0.0.1"
+ authors = [
+ {name = "Foo Bar", email = "foo@bar.net"}
+ ]
+ description = "Short description"
+ readme = {text = "Long\\ndescription", content-type = "text/plain"}
+ keywords = ["one", "two"]
+ dependencies = ["requests"]
+ license = "AGPL-3.0-or-later"
+ [tool.setuptools]
+ provides = ["abcd"]
+ obsoletes = ["abcd"]
+ """
+ ),
+ }
+
+ @pytest.mark.parametrize("file", STATIC_CONFIG.keys())
+ def test_static_config_has_no_dynamic(self, file, tmpdir_cwd):
+ Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8")
+ metadata = _get_metadata()
+ assert metadata.get_all("Dynamic") is None
+ assert metadata.get_all("dynamic") is None
+
+ @pytest.mark.parametrize("file", STATIC_CONFIG.keys())
+ @pytest.mark.parametrize(
+ "fields",
+ [
+ # Single dynamic field
+ {"requires-python": ("python_requires", ">=3.12")},
+ {"author-email": ("author_email", "snoopy@peanuts.com")},
+ {"keywords": ("keywords", ["hello", "world"])},
+ {"platform": ("platforms", ["abcd"])},
+ # Multiple dynamic fields
+ {
+ "summary": ("description", "hello world"),
+ "description": ("long_description", "bla bla bla bla"),
+ "requires-dist": ("install_requires", ["hello-world"]),
+ },
+ ],
+ )
+ def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd):
+ # We start with a static config
+ Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8")
+ dist = _makedist()
+
+ # ... but then we simulate the effects of a plugin modifying the distribution
+ for attr, value in fields.values():
+ # `dist` and `dist.metadata` are complicated...
+ # Some attributes work when set on `dist`, others on `dist.metadata`...
+ # Here we set in both just in case (this also avoids calling `_finalize_*`)
+ setattr(dist, attr, value)
+ setattr(dist.metadata, attr, value)
+
+ # Then we should be able to list the modified fields as Dynamic
+ metadata = _get_metadata(dist)
+ assert set(metadata.get_all("Dynamic")) == set(fields)
+
+ @pytest.mark.parametrize(
+ "extra_toml",
+ [
+ "# Let setuptools autofill license-files",
+ "license-files = ['LICENSE*', 'AUTHORS*', 'NOTICE']",
+ ],
+ )
+ def test_license_files_dynamic(self, extra_toml, tmpdir_cwd):
+ # For simplicity (and for the time being) setuptools is not making
+ # any special handling to guarantee `License-File` is considered static.
+ # Instead we rely in the fact that, although suboptimal, it is OK to have
+ # it as dynamics, as per:
+ # https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677
+ files = {
+ "pyproject.toml": self.STATIC_CONFIG["pyproject.toml"].replace(
+ 'license = "AGPL-3.0-or-later"',
+ f"dynamic = ['license']\n{extra_toml}",
+ ),
+ "LICENSE.md": "--- mock license ---",
+ "NOTICE": "--- mock notice ---",
+ "AUTHORS.txt": "--- me ---",
+ }
+ # Sanity checks:
+ assert extra_toml in files["pyproject.toml"]
+ assert 'license = "AGPL-3.0-or-later"' not in extra_toml
+
+ jaraco.path.build(files)
+ dist = _makedist(license_expression="AGPL-3.0-or-later")
+ metadata = _get_metadata(dist)
+ assert set(metadata.get_all("Dynamic")) == {
+ 'license-file',
+ 'license-expression',
+ }
+ assert metadata.get("License-Expression") == "AGPL-3.0-or-later"
+ assert set(metadata.get_all("License-File")) == {
+ "NOTICE",
+ "AUTHORS.txt",
+ "LICENSE.md",
+ }
+
+
+def _makedist(**attrs):
+ dist = Distribution(attrs)
+ dist.parse_config_files()
+ return dist
+
+
+def _assert_roundtrip_message(metadata: str) -> None:
+ """Emulate the way wheel.bdist_wheel parses and regenerates the message,
+ then ensures the metadata generated by setuptools is compatible.
+ """
+ with io.StringIO(metadata) as buffer:
+ msg = Parser(EmailMessage).parse(buffer)
+
+ serialization_policy = EmailPolicy(
+ utf8=True,
+ mangle_from_=False,
+ max_line_length=0,
+ )
+ with io.BytesIO() as buffer:
+ out = io.TextIOWrapper(buffer, encoding="utf-8")
+ Generator(out, policy=serialization_policy).flatten(msg)
+ out.flush()
+ regenerated = buffer.getvalue()
+
+ raw_metadata = bytes(metadata, "utf-8")
+ # Normalise newlines to avoid test errors on Windows:
+ raw_metadata = b"\n".join(raw_metadata.splitlines())
+ regenerated = b"\n".join(regenerated.splitlines())
+ assert regenerated == raw_metadata
+
+
+def _normalize_metadata(msg: Message) -> str:
+ """Allow equivalent metadata to be compared directly"""
+ # The main challenge regards the requirements and extras.
+ # Both setuptools and wheel already apply some level of normalization
+ # but they differ regarding which character is chosen, according to the
+ # following spec it should be "-":
+ # https://packaging.python.org/en/latest/specifications/name-normalization/
+
+ # Related issues:
+ # https://github.com/pypa/packaging/issues/845
+ # https://github.com/pypa/packaging/issues/644#issuecomment-2429813968
+
+ extras = {x.replace("_", "-"): x for x in msg.get_all("Provides-Extra", [])}
+ reqs = [
+ _normalize_req(req, extras)
+ for req in _reqs.parse(msg.get_all("Requires-Dist", []))
+ ]
+ del msg["Requires-Dist"]
+ del msg["Provides-Extra"]
+
+ # Ensure consistent ord
+ for req in sorted(reqs):
+ msg["Requires-Dist"] = req
+ for extra in sorted(extras):
+ msg["Provides-Extra"] = extra
+
+ # TODO: Handle lack of PEP 643 implementation in pypa/wheel?
+ del msg["Metadata-Version"]
+
+ return msg.as_string()
+
+
+def _normalize_req(req: Requirement, extras: dict[str, str]) -> str:
+ """Allow equivalent requirement objects to be compared directly"""
+ as_str = str(req).replace(req.name, req.name.replace("_", "-"))
+ for norm, orig in extras.items():
+ as_str = as_str.replace(orig, norm)
+ return as_str
+
+
+def _get_pkginfo(dist: Distribution):
+ with io.StringIO() as fp:
+ dist.metadata.write_pkg_file(fp)
+ return fp.getvalue()
+
+
+def _get_metadata(dist: Distribution | None = None):
+ return message_from_string(_get_pkginfo(dist or _makedist()))
+
+
+def _valid_metadata(text: str) -> bool:
+ metadata = Metadata.from_email(text, validate=True) # can raise exceptions
+ return metadata is not None