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