|
| 1 | +from __future__ import annotations |
| 2 | + |
1 | 3 | import functools
|
2 | 4 | import importlib
|
3 | 5 | import io
|
4 | 6 | from email import message_from_string
|
| 7 | +from email.generator import Generator |
| 8 | +from email.message import Message |
| 9 | +from email.parser import Parser |
| 10 | +from email.policy import EmailPolicy |
| 11 | +from pathlib import Path |
| 12 | +from unittest.mock import Mock |
5 | 13 |
|
6 | 14 | import pytest
|
7 | 15 | from packaging.metadata import Metadata
|
| 16 | +from packaging.requirements import Requirement |
8 | 17 |
|
9 | 18 | from setuptools import _reqs, sic
|
10 | 19 | from setuptools._core_metadata import rfc822_escape, rfc822_unescape
|
11 | 20 | from setuptools.command.egg_info import egg_info, write_requirements
|
| 21 | +from setuptools.config import expand, setupcfg |
12 | 22 | from setuptools.dist import Distribution
|
13 | 23 |
|
| 24 | +from .config.downloads import retrieve_file, urls_from_file |
| 25 | + |
14 | 26 | EXAMPLE_BASE_INFO = dict(
|
15 | 27 | name="package",
|
16 | 28 | version="0.0.1",
|
@@ -303,84 +315,168 @@ def test_maintainer_author(name, attrs, tmpdir):
|
303 | 315 | assert line in pkg_lines_set
|
304 | 316 |
|
305 | 317 |
|
306 |
| -def test_parity_with_metadata_from_pypa_wheel(tmp_path): |
307 |
| - attrs = dict( |
308 |
| - **EXAMPLE_BASE_INFO, |
309 |
| - # Example with complex requirement definition |
310 |
| - python_requires=">=3.8", |
311 |
| - install_requires=""" |
312 |
| - packaging==23.2 |
313 |
| - more-itertools==8.8.0; extra == "other" |
314 |
| - jaraco.text==3.7.0 |
315 |
| - importlib-resources==5.10.2; python_version<"3.8" |
316 |
| - importlib-metadata==6.0.0 ; python_version<"3.8" |
317 |
| - colorama>=0.4.4; sys_platform == "win32" |
318 |
| - """, |
319 |
| - extras_require={ |
320 |
| - "testing": """ |
321 |
| - pytest >= 6 |
322 |
| - pytest-checkdocs >= 2.4 |
323 |
| - tomli ; \\ |
324 |
| - # Using stdlib when possible |
325 |
| - python_version < "3.11" |
326 |
| - ini2toml[lite]>=0.9 |
327 |
| - """, |
328 |
| - "other": [], |
329 |
| - }, |
| 318 | +class TestParityWithMetadataFromPyPaWheel: |
| 319 | + def base_example(self): |
| 320 | + attrs = dict( |
| 321 | + **EXAMPLE_BASE_INFO, |
| 322 | + # Example with complex requirement definition |
| 323 | + python_requires=">=3.8", |
| 324 | + install_requires=""" |
| 325 | + packaging==23.2 |
| 326 | + more-itertools==8.8.0; extra == "other" |
| 327 | + jaraco.text==3.7.0 |
| 328 | + importlib-resources==5.10.2; python_version<"3.8" |
| 329 | + importlib-metadata==6.0.0 ; python_version<"3.8" |
| 330 | + colorama>=0.4.4; sys_platform == "win32" |
| 331 | + """, |
| 332 | + extras_require={ |
| 333 | + "testing": """ |
| 334 | + pytest >= 6 |
| 335 | + pytest-checkdocs >= 2.4 |
| 336 | + tomli ; \\ |
| 337 | + # Using stdlib when possible |
| 338 | + python_version < "3.11" |
| 339 | + ini2toml[lite]>=0.9 |
| 340 | + """, |
| 341 | + "other": [], |
| 342 | + }, |
| 343 | + ) |
| 344 | + # Generate a PKG-INFO file using setuptools |
| 345 | + return Distribution(attrs) |
| 346 | + |
| 347 | + def test_requires_dist(self, tmp_path): |
| 348 | + dist = self.base_example() |
| 349 | + pkg_info = _get_pkginfo(dist) |
| 350 | + assert _valid_metadata(pkg_info) |
| 351 | + |
| 352 | + # Ensure Requires-Dist is present |
| 353 | + expected = [ |
| 354 | + 'Metadata-Version:', |
| 355 | + 'Requires-Python: >=3.8', |
| 356 | + 'Provides-Extra: other', |
| 357 | + 'Provides-Extra: testing', |
| 358 | + 'Requires-Dist: tomli; python_version < "3.11" and extra == "testing"', |
| 359 | + 'Requires-Dist: more-itertools==8.8.0; extra == "other"', |
| 360 | + 'Requires-Dist: ini2toml[lite]>=0.9; extra == "testing"', |
| 361 | + ] |
| 362 | + for line in expected: |
| 363 | + assert line in pkg_info |
| 364 | + |
| 365 | + HERE = Path(__file__).parent |
| 366 | + EXAMPLES_FILE = HERE / "config/setupcfg_examples.txt" |
| 367 | + |
| 368 | + @pytest.fixture(params=[None, *urls_from_file(EXAMPLES_FILE)]) |
| 369 | + def dist(self, request, monkeypatch, tmp_path): |
| 370 | + """Example of distribution with arbitrary configuration""" |
| 371 | + monkeypatch.chdir(tmp_path) |
| 372 | + monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.42")) |
| 373 | + monkeypatch.setattr(expand, "read_files", Mock(return_value="hello world")) |
| 374 | + if request.param is None: |
| 375 | + yield self.base_example() |
| 376 | + else: |
| 377 | + # Real-world usage |
| 378 | + config = retrieve_file(request.param) |
| 379 | + yield setupcfg.apply_configuration(Distribution({}), config) |
| 380 | + |
| 381 | + @pytest.mark.uses_network |
| 382 | + def test_equivalent_output(self, tmp_path, dist): |
| 383 | + """Ensure output from setuptools is equivalent to the one from `pypa/wheel`""" |
| 384 | + # Generate a METADATA file using pypa/wheel for comparison |
| 385 | + wheel_metadata = importlib.import_module("wheel.metadata") |
| 386 | + pkginfo_to_metadata = getattr(wheel_metadata, "pkginfo_to_metadata", None) |
| 387 | + |
| 388 | + if pkginfo_to_metadata is None: # pragma: nocover |
| 389 | + pytest.xfail( |
| 390 | + "wheel.metadata.pkginfo_to_metadata is undefined, " |
| 391 | + "(this is likely to be caused by API changes in pypa/wheel" |
| 392 | + ) |
| 393 | + |
| 394 | + # Generate an simplified "egg-info" dir for pypa/wheel to convert |
| 395 | + pkg_info = _get_pkginfo(dist) |
| 396 | + egg_info_dir = tmp_path / "pkg.egg-info" |
| 397 | + egg_info_dir.mkdir(parents=True) |
| 398 | + (egg_info_dir / "PKG-INFO").write_text(pkg_info, encoding="utf-8") |
| 399 | + write_requirements(egg_info(dist), egg_info_dir, egg_info_dir / "requires.txt") |
| 400 | + |
| 401 | + # Get pypa/wheel generated METADATA but normalize requirements formatting |
| 402 | + metadata_msg = pkginfo_to_metadata(egg_info_dir, egg_info_dir / "PKG-INFO") |
| 403 | + metadata_str = _normalize_metadata(metadata_msg) |
| 404 | + pkg_info_msg = message_from_string(pkg_info) |
| 405 | + pkg_info_str = _normalize_metadata(pkg_info_msg) |
| 406 | + |
| 407 | + # Compare setuptools PKG-INFO x pypa/wheel METADATA |
| 408 | + assert metadata_str == pkg_info_str |
| 409 | + |
| 410 | + # Make sure it parses/serializes well in pypa/wheel |
| 411 | + _assert_roundtrip_message(pkg_info) |
| 412 | + |
| 413 | + |
| 414 | +def _assert_roundtrip_message(metadata: str) -> None: |
| 415 | + """Emulate the way wheel.bdist_wheel parses and regenerates the message, |
| 416 | + then ensures the metadata generated by setuptools is compatible. |
| 417 | + """ |
| 418 | + with io.StringIO(metadata) as buffer: |
| 419 | + msg = Parser().parse(buffer) |
| 420 | + |
| 421 | + serialization_policy = EmailPolicy( |
| 422 | + utf8=True, |
| 423 | + mangle_from_=False, |
| 424 | + max_line_length=0, |
330 | 425 | )
|
331 |
| - # Generate a PKG-INFO file using setuptools |
332 |
| - dist = Distribution(attrs) |
333 |
| - with io.StringIO() as fp: |
334 |
| - dist.metadata.write_pkg_file(fp) |
335 |
| - pkg_info = fp.getvalue() |
| 426 | + with io.BytesIO() as buffer: |
| 427 | + out = io.TextIOWrapper(buffer, encoding="utf-8") |
| 428 | + Generator(out, policy=serialization_policy).flatten(msg) |
| 429 | + out.flush() |
| 430 | + regenerated = buffer.getvalue() |
| 431 | + |
| 432 | + raw_metadata = bytes(metadata, "utf-8") |
| 433 | + # Normalise newlines to avoid test errors on Windows: |
| 434 | + raw_metadata = b"\n".join(raw_metadata.splitlines()) |
| 435 | + regenerated = b"\n".join(regenerated.splitlines()) |
| 436 | + assert regenerated == raw_metadata |
| 437 | + |
| 438 | + |
| 439 | +def _normalize_metadata(msg: Message) -> str: |
| 440 | + """Allow equivalent metadata to be compared directly""" |
| 441 | + # The main challenge regards the requirements and extras. |
| 442 | + # Both setuptools and wheel already apply some level of normalization |
| 443 | + # but they differ regarding which character is chosen, according to the |
| 444 | + # following spec it should be "-": |
| 445 | + # https://packaging.python.org/en/latest/specifications/name-normalization/ |
| 446 | + |
| 447 | + # Related issues: |
| 448 | + # https://github.com/pypa/packaging/issues/845 |
| 449 | + # https://github.com/pypa/packaging/issues/644#issuecomment-2429813968 |
| 450 | + |
| 451 | + extras = {x.replace("_", "-"): x for x in msg.get_all("Provides-Extra", [])} |
| 452 | + reqs = [ |
| 453 | + _normalize_req(req, extras) |
| 454 | + for req in _reqs.parse(msg.get_all("Requires-Dist", [])) |
| 455 | + ] |
| 456 | + del msg["Requires-Dist"] |
| 457 | + del msg["Provides-Extra"] |
336 | 458 |
|
337 |
| - assert _valid_metadata(pkg_info) |
| 459 | + # Ensure consistent ord |
| 460 | + for req in sorted(reqs): |
| 461 | + msg["Requires-Dist"] = req |
| 462 | + for extra in sorted(extras): |
| 463 | + msg["Provides-Extra"] = extra |
338 | 464 |
|
339 |
| - # Ensure Requires-Dist is present |
340 |
| - expected = [ |
341 |
| - 'Metadata-Version:', |
342 |
| - 'Requires-Python: >=3.8', |
343 |
| - 'Provides-Extra: other', |
344 |
| - 'Provides-Extra: testing', |
345 |
| - 'Requires-Dist: tomli; python_version < "3.11" and extra == "testing"', |
346 |
| - 'Requires-Dist: more-itertools==8.8.0; extra == "other"', |
347 |
| - 'Requires-Dist: ini2toml[lite]>=0.9; extra == "testing"', |
348 |
| - ] |
349 |
| - for line in expected: |
350 |
| - assert line in pkg_info |
| 465 | + return msg.as_string() |
351 | 466 |
|
352 |
| - # Generate a METADATA file using pypa/wheel for comparison |
353 |
| - wheel_metadata = importlib.import_module("wheel.metadata") |
354 |
| - pkginfo_to_metadata = getattr(wheel_metadata, "pkginfo_to_metadata", None) |
355 | 467 |
|
356 |
| - if pkginfo_to_metadata is None: |
357 |
| - pytest.xfail( |
358 |
| - "wheel.metadata.pkginfo_to_metadata is undefined, " |
359 |
| - "(this is likely to be caused by API changes in pypa/wheel" |
360 |
| - ) |
| 468 | +def _normalize_req(req: Requirement, extras: dict[str, str]) -> str: |
| 469 | + """Allow equivalent requirement objects to be compared directly""" |
| 470 | + as_str = str(req).replace(req.name, req.name.replace("_", "-")) |
| 471 | + for norm, orig in extras.items(): |
| 472 | + as_str = as_str.replace(orig, norm) |
| 473 | + return as_str |
361 | 474 |
|
362 |
| - # Generate an simplified "egg-info" dir for pypa/wheel to convert |
363 |
| - egg_info_dir = tmp_path / "pkg.egg-info" |
364 |
| - egg_info_dir.mkdir(parents=True) |
365 |
| - (egg_info_dir / "PKG-INFO").write_text(pkg_info, encoding="utf-8") |
366 |
| - write_requirements(egg_info(dist), egg_info_dir, egg_info_dir / "requires.txt") |
367 |
| - |
368 |
| - # Get pypa/wheel generated METADATA but normalize requirements formatting |
369 |
| - metadata_msg = pkginfo_to_metadata(egg_info_dir, egg_info_dir / "PKG-INFO") |
370 |
| - metadata_deps = set(_reqs.parse(metadata_msg.get_all("Requires-Dist"))) |
371 |
| - metadata_extras = set(metadata_msg.get_all("Provides-Extra")) |
372 |
| - del metadata_msg["Requires-Dist"] |
373 |
| - del metadata_msg["Provides-Extra"] |
374 |
| - pkg_info_msg = message_from_string(pkg_info) |
375 |
| - pkg_info_deps = set(_reqs.parse(pkg_info_msg.get_all("Requires-Dist"))) |
376 |
| - pkg_info_extras = set(pkg_info_msg.get_all("Provides-Extra")) |
377 |
| - del pkg_info_msg["Requires-Dist"] |
378 |
| - del pkg_info_msg["Provides-Extra"] |
379 |
| - |
380 |
| - # Compare setuptools PKG-INFO x pypa/wheel METADATA |
381 |
| - assert metadata_msg.as_string() == pkg_info_msg.as_string() |
382 |
| - assert metadata_deps == pkg_info_deps |
383 |
| - assert metadata_extras == pkg_info_extras |
| 475 | + |
| 476 | +def _get_pkginfo(dist: Distribution): |
| 477 | + with io.StringIO() as fp: |
| 478 | + dist.metadata.write_pkg_file(fp) |
| 479 | + return fp.getvalue() |
384 | 480 |
|
385 | 481 |
|
386 | 482 | def _valid_metadata(text: str) -> bool:
|
|
0 commit comments