codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import functools4import io5from email import message_from_string6from email.generator import Generator7from email.message import EmailMessage8from email.parser import Parser9from email.policy import EmailPolicy10from inspect import cleandoc11from pathlib import Path12from unittest.mock import Mock13 14import jaraco.path15import pytest16from packaging.metadata import Metadata17 18from setuptools import sic19from setuptools._core_metadata import rfc822_escape, rfc822_unescape20from setuptools.config import expand, setupcfg21from setuptools.dist import Distribution22 23from .config.downloads import retrieve_file, urls_from_file24 25EXAMPLE_BASE_INFO = dict(26 name="package",27 version="0.0.1",28 author="Foo Bar",29 author_email="foo@bar.net",30 long_description="Long\ndescription",31 description="Short description",32 keywords=["one", "two"],33)34 35 36@pytest.mark.parametrize(37 ("content", "result"),38 (39 pytest.param(40 "Just a single line",41 None,42 id="single_line",43 ),44 pytest.param(45 "Multiline\nText\nwithout\nextra indents\n",46 None,47 id="multiline",48 ),49 pytest.param(50 "Multiline\n With\n\nadditional\n indentation",51 None,52 id="multiline_with_indentation",53 ),54 pytest.param(55 " Leading whitespace",56 "Leading whitespace",57 id="remove_leading_whitespace",58 ),59 pytest.param(60 " Leading whitespace\nIn\n Multiline comment",61 "Leading whitespace\nIn\n Multiline comment",62 id="remove_leading_whitespace_multiline",63 ),64 ),65)66def test_rfc822_unescape(content, result):67 assert (result or content) == rfc822_unescape(rfc822_escape(content))68 69 70def __read_test_cases():71 base = EXAMPLE_BASE_INFO72 73 params = functools.partial(dict, base)74 75 return [76 ('Metadata version 1.0', params()),77 (78 'Metadata Version 1.0: Short long description',79 params(80 long_description='Short long description',81 ),82 ),83 (84 'Metadata version 1.1: Classifiers',85 params(86 classifiers=[87 'Programming Language :: Python :: 3',88 'Programming Language :: Python :: 3.7',89 'License :: OSI Approved :: MIT License',90 ],91 ),92 ),93 (94 'Metadata version 1.1: Download URL',95 params(96 download_url='https://example.com',97 ),98 ),99 (100 'Metadata Version 1.2: Requires-Python',101 params(102 python_requires='>=3.7',103 ),104 ),105 pytest.param(106 'Metadata Version 1.2: Project-Url',107 params(project_urls=dict(Foo='https://example.bar')),108 marks=pytest.mark.xfail(109 reason="Issue #1578: project_urls not read",110 ),111 ),112 (113 'Metadata Version 2.1: Long Description Content Type',114 params(115 long_description_content_type='text/x-rst; charset=UTF-8',116 ),117 ),118 (119 'License',120 params(121 license='MIT',122 ),123 ),124 (125 'License multiline',126 params(127 license='This is a long license \nover multiple lines',128 ),129 ),130 pytest.param(131 'Metadata Version 2.1: Provides Extra',132 params(provides_extras=['foo', 'bar']),133 marks=pytest.mark.xfail(reason="provides_extras not read"),134 ),135 (136 'Missing author',137 dict(138 name='foo',139 version='1.0.0',140 author_email='snorri@sturluson.name',141 ),142 ),143 (144 'Missing author e-mail',145 dict(146 name='foo',147 version='1.0.0',148 author='Snorri Sturluson',149 ),150 ),151 (152 'Missing author and e-mail',153 dict(154 name='foo',155 version='1.0.0',156 ),157 ),158 (159 'Bypass normalized version',160 dict(161 name='foo',162 version=sic('1.0.0a'),163 ),164 ),165 ]166 167 168@pytest.mark.parametrize(("name", "attrs"), __read_test_cases())169def test_read_metadata(name, attrs):170 dist = Distribution(attrs)171 metadata_out = dist.metadata172 dist_class = metadata_out.__class__173 174 # Write to PKG_INFO and then load into a new metadata object175 PKG_INFO = io.StringIO()176 177 metadata_out.write_pkg_file(PKG_INFO)178 PKG_INFO.seek(0)179 pkg_info = PKG_INFO.read()180 assert _valid_metadata(pkg_info)181 182 PKG_INFO.seek(0)183 metadata_in = dist_class()184 metadata_in.read_pkg_file(PKG_INFO)185 186 tested_attrs = [187 ('name', dist_class.get_name),188 ('version', dist_class.get_version),189 ('author', dist_class.get_contact),190 ('author_email', dist_class.get_contact_email),191 ('metadata_version', dist_class.get_metadata_version),192 ('provides', dist_class.get_provides),193 ('description', dist_class.get_description),194 ('long_description', dist_class.get_long_description),195 ('download_url', dist_class.get_download_url),196 ('keywords', dist_class.get_keywords),197 ('platforms', dist_class.get_platforms),198 ('obsoletes', dist_class.get_obsoletes),199 ('requires', dist_class.get_requires),200 ('classifiers', dist_class.get_classifiers),201 ('project_urls', lambda s: getattr(s, 'project_urls', {})),202 ('provides_extras', lambda s: getattr(s, 'provides_extras', {})),203 ]204 205 for attr, getter in tested_attrs:206 assert getter(metadata_in) == getter(metadata_out)207 208 209def __maintainer_test_cases():210 attrs = {"name": "package", "version": "1.0", "description": "xxx"}211 212 def merge_dicts(d1, d2):213 d1 = d1.copy()214 d1.update(d2)215 216 return d1217 218 return [219 ('No author, no maintainer', attrs.copy()),220 (221 'Author (no e-mail), no maintainer',222 merge_dicts(attrs, {'author': 'Author Name'}),223 ),224 (225 'Author (e-mail), no maintainer',226 merge_dicts(227 attrs, {'author': 'Author Name', 'author_email': 'author@name.com'}228 ),229 ),230 (231 'No author, maintainer (no e-mail)',232 merge_dicts(attrs, {'maintainer': 'Maintainer Name'}),233 ),234 (235 'No author, maintainer (e-mail)',236 merge_dicts(237 attrs,238 {239 'maintainer': 'Maintainer Name',240 'maintainer_email': 'maintainer@name.com',241 },242 ),243 ),244 (245 'Author (no e-mail), Maintainer (no-email)',246 merge_dicts(247 attrs, {'author': 'Author Name', 'maintainer': 'Maintainer Name'}248 ),249 ),250 (251 'Author (e-mail), Maintainer (e-mail)',252 merge_dicts(253 attrs,254 {255 'author': 'Author Name',256 'author_email': 'author@name.com',257 'maintainer': 'Maintainer Name',258 'maintainer_email': 'maintainer@name.com',259 },260 ),261 ),262 (263 'No author (e-mail), no maintainer (e-mail)',264 merge_dicts(265 attrs,266 {267 'author_email': 'author@name.com',268 'maintainer_email': 'maintainer@name.com',269 },270 ),271 ),272 ('Author unicode', merge_dicts(attrs, {'author': '鉄沢寛'})),273 ('Maintainer unicode', merge_dicts(attrs, {'maintainer': 'Jan Łukasiewicz'})),274 ]275 276 277@pytest.mark.parametrize(("name", "attrs"), __maintainer_test_cases())278def test_maintainer_author(name, attrs, tmpdir):279 tested_keys = {280 'author': 'Author',281 'author_email': 'Author-email',282 'maintainer': 'Maintainer',283 'maintainer_email': 'Maintainer-email',284 }285 286 # Generate a PKG-INFO file287 dist = Distribution(attrs)288 fn = tmpdir.mkdir('pkg_info')289 fn_s = str(fn)290 291 dist.metadata.write_pkg_info(fn_s)292 293 with open(str(fn.join('PKG-INFO')), 'r', encoding='utf-8') as f:294 pkg_info = f.read()295 296 assert _valid_metadata(pkg_info)297 298 # Drop blank lines and strip lines from default description299 raw_pkg_lines = pkg_info.splitlines()300 pkg_lines = list(filter(None, raw_pkg_lines[:-2]))301 302 pkg_lines_set = set(pkg_lines)303 304 # Duplicate lines should not be generated305 assert len(pkg_lines) == len(pkg_lines_set)306 307 for fkey, dkey in tested_keys.items():308 val = attrs.get(dkey, None)309 if val is None:310 for line in pkg_lines:311 assert not line.startswith(fkey + ':')312 else:313 line = f'{fkey}: {val}'314 assert line in pkg_lines_set315 316 317class TestParityWithMetadataFromPyPaWheel:318 def base_example(self):319 attrs = dict(320 **EXAMPLE_BASE_INFO,321 # Example with complex requirement definition322 python_requires=">=3.8",323 install_requires="""324 packaging==23.2325 more-itertools==8.8.0; extra == "other"326 jaraco.text==3.7.0327 importlib-resources==5.10.2; python_version<"3.8"328 importlib-metadata==6.0.0 ; python_version<"3.8"329 colorama>=0.4.4; sys_platform == "win32"330 """,331 extras_require={332 "testing": """333 pytest >= 6334 pytest-checkdocs >= 2.4335 tomli ; \\336 # Using stdlib when possible337 python_version < "3.11"338 ini2toml[lite]>=0.9339 """,340 "other": [],341 },342 )343 # Generate a PKG-INFO file using setuptools344 return Distribution(attrs)345 346 def test_requires_dist(self, tmp_path):347 dist = self.base_example()348 pkg_info = _get_pkginfo(dist)349 assert _valid_metadata(pkg_info)350 351 # Ensure Requires-Dist is present352 expected = [353 'Metadata-Version:',354 'Requires-Python: >=3.8',355 'Provides-Extra: other',356 'Provides-Extra: testing',357 'Requires-Dist: tomli; python_version < "3.11" and extra == "testing"',358 'Requires-Dist: more-itertools==8.8.0; extra == "other"',359 'Requires-Dist: ini2toml[lite]>=0.9; extra == "testing"',360 ]361 for line in expected:362 assert line in pkg_info363 364 HERE = Path(__file__).parent365 EXAMPLES_FILE = HERE / "config/setupcfg_examples.txt"366 367 @pytest.fixture(params=[None, *urls_from_file(EXAMPLES_FILE)])368 def dist(self, request, monkeypatch, tmp_path):369 """Example of distribution with arbitrary configuration"""370 monkeypatch.chdir(tmp_path)371 monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.42"))372 monkeypatch.setattr(expand, "read_files", Mock(return_value="hello world"))373 monkeypatch.setattr(374 Distribution, "_finalize_license_files", Mock(return_value=None)375 )376 if request.param is None:377 yield self.base_example()378 else:379 # Real-world usage380 config = retrieve_file(request.param)381 yield setupcfg.apply_configuration(Distribution({}), config)382 383 @pytest.mark.uses_network384 def test_pkg_info_roundtrip(self, tmp_path, dist):385 """Ensure PKG-INFO round trips according to pypa/wheel's methodology"""386 # Generate an simplified "egg-info" with PKG-INFO387 pkg_info = _get_pkginfo(dist)388 389 # Emulate the way old versions of wheel.bdist_wheel used to parse and regenerate390 # the message, then ensures the metadata generated by setuptools is compatible.391 with io.StringIO(pkg_info) as buffer:392 msg = Parser(EmailMessage).parse(buffer)393 394 serialization_policy = EmailPolicy(395 utf8=True,396 mangle_from_=False,397 max_line_length=0,398 )399 with io.BytesIO() as buffer:400 out = io.TextIOWrapper(buffer, encoding="utf-8")401 Generator(out, policy=serialization_policy).flatten(msg)402 out.flush()403 regenerated = buffer.getvalue()404 405 raw_metadata = bytes(pkg_info, "utf-8")406 # Normalise newlines to avoid test errors on Windows:407 raw_metadata = b"\n".join(raw_metadata.splitlines())408 regenerated = b"\n".join(regenerated.splitlines())409 assert regenerated == raw_metadata410 411 412class TestPEP643:413 STATIC_CONFIG = {414 "setup.cfg": cleandoc(415 """416 [metadata]417 name = package418 version = 0.0.1419 author = Foo Bar420 author_email = foo@bar.net421 long_description = Long422 description423 description = Short description424 keywords = one, two425 platforms = abcd426 [options]427 install_requires = requests428 """429 ),430 "pyproject.toml": cleandoc(431 """432 [project]433 name = "package"434 version = "0.0.1"435 authors = [436 {name = "Foo Bar", email = "foo@bar.net"}437 ]438 description = "Short description"439 readme = {text = "Long\\ndescription", content-type = "text/plain"}440 keywords = ["one", "two"]441 dependencies = ["requests"]442 license = "AGPL-3.0-or-later"443 [tool.setuptools]444 provides = ["abcd"]445 obsoletes = ["abcd"]446 """447 ),448 }449 450 @pytest.mark.parametrize("file", STATIC_CONFIG.keys())451 def test_static_config_has_no_dynamic(self, file, tmpdir_cwd):452 Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8")453 metadata = _get_metadata()454 assert metadata.get_all("Dynamic") is None455 assert metadata.get_all("dynamic") is None456 457 @pytest.mark.parametrize("file", STATIC_CONFIG.keys())458 @pytest.mark.parametrize(459 "fields",460 [461 # Single dynamic field462 {"requires-python": ("python_requires", ">=3.12")},463 {"author-email": ("author_email", "snoopy@peanuts.com")},464 {"keywords": ("keywords", ["hello", "world"])},465 {"platform": ("platforms", ["abcd"])},466 # Multiple dynamic fields467 {468 "summary": ("description", "hello world"),469 "description": ("long_description", "bla bla bla bla"),470 "requires-dist": ("install_requires", ["hello-world"]),471 },472 ],473 )474 def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd):475 # We start with a static config476 Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8")477 dist = _makedist()478 479 # ... but then we simulate the effects of a plugin modifying the distribution480 for attr, value in fields.values():481 # `dist` and `dist.metadata` are complicated...482 # Some attributes work when set on `dist`, others on `dist.metadata`...483 # Here we set in both just in case (this also avoids calling `_finalize_*`)484 setattr(dist, attr, value)485 setattr(dist.metadata, attr, value)486 487 # Then we should be able to list the modified fields as Dynamic488 metadata = _get_metadata(dist)489 assert set(metadata.get_all("Dynamic")) == set(fields)490 491 @pytest.mark.parametrize(492 "extra_toml",493 [494 "# Let setuptools autofill license-files",495 "license-files = ['LICENSE*', 'AUTHORS*', 'NOTICE']",496 ],497 )498 def test_license_files_dynamic(self, extra_toml, tmpdir_cwd):499 # For simplicity (and for the time being) setuptools is not making500 # any special handling to guarantee `License-File` is considered static.501 # Instead we rely in the fact that, although suboptimal, it is OK to have502 # it as dynamics, as per:503 # https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677504 files = {505 "pyproject.toml": self.STATIC_CONFIG["pyproject.toml"].replace(506 'license = "AGPL-3.0-or-later"',507 f"dynamic = ['license']\n{extra_toml}",508 ),509 "LICENSE.md": "--- mock license ---",510 "NOTICE": "--- mock notice ---",511 "AUTHORS.txt": "--- me ---",512 }513 # Sanity checks:514 assert extra_toml in files["pyproject.toml"]515 assert 'license = "AGPL-3.0-or-later"' not in extra_toml516 517 jaraco.path.build(files)518 dist = _makedist(license_expression="AGPL-3.0-or-later")519 metadata = _get_metadata(dist)520 assert set(metadata.get_all("Dynamic")) == {521 'license-file',522 'license-expression',523 }524 assert metadata.get("License-Expression") == "AGPL-3.0-or-later"525 assert set(metadata.get_all("License-File")) == {526 "NOTICE",527 "AUTHORS.txt",528 "LICENSE.md",529 }530 531 532def _makedist(**attrs):533 dist = Distribution(attrs)534 dist.parse_config_files()535 return dist536 537 538def _get_pkginfo(dist: Distribution):539 with io.StringIO() as fp:540 dist.metadata.write_pkg_file(fp)541 return fp.getvalue()542 543 544def _get_metadata(dist: Distribution | None = None):545 return message_from_string(_get_pkginfo(dist or _makedist()))546 547 548def _valid_metadata(text: str) -> bool:549 metadata = Metadata.from_email(text, validate=True) # can raise exceptions550 return metadata is not None551 