diff options
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.py | 622 |
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 |