Skip to content

Commit e622859

Browse files
authored
Preserve original PKG-INFO contents when creating wheel (instead of calling wheel.metadata.pkginfo_to_metadata) (#4701)
2 parents 5400015 + 0b5b417 commit e622859

File tree

3 files changed

+195
-110
lines changed

3 files changed

+195
-110
lines changed

newsfragments/4701.feature.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Preserve original ``PKG-INFO`` into ``METADATA`` when creating wheel
2+
(instead of calling ``wheel.metadata.pkginfo_to_metadata``).
3+
This helps to be more compliant with the flow specified in PEP 517.

setuptools/command/bdist_wheel.py

+24-38
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,12 @@
1414
import sysconfig
1515
import warnings
1616
from collections.abc import Iterable, Sequence
17-
from email.generator import BytesGenerator, Generator
18-
from email.policy import EmailPolicy
17+
from email.generator import BytesGenerator
1918
from glob import iglob
2019
from typing import Literal, cast
2120
from zipfile import ZIP_DEFLATED, ZIP_STORED
2221

2322
from packaging import tags, version as _packaging_version
24-
from wheel.metadata import pkginfo_to_metadata
2523
from wheel.wheelfile import WheelFile
2624

2725
from .. import Command, __version__, _shutil
@@ -569,42 +567,30 @@ def adios(p: str) -> None:
569567

570568
raise ValueError(err)
571569

572-
if os.path.isfile(egginfo_path):
573-
# .egg-info is a single file
574-
pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path)
575-
os.mkdir(distinfo_path)
576-
else:
577-
# .egg-info is a directory
578-
pkginfo_path = os.path.join(egginfo_path, "PKG-INFO")
579-
pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path)
580-
581-
# ignore common egg metadata that is useless to wheel
582-
shutil.copytree(
583-
egginfo_path,
584-
distinfo_path,
585-
ignore=lambda x, y: {
586-
"PKG-INFO",
587-
"requires.txt",
588-
"SOURCES.txt",
589-
"not-zip-safe",
590-
},
591-
)
592-
593-
# delete dependency_links if it is only whitespace
594-
dependency_links_path = os.path.join(distinfo_path, "dependency_links.txt")
595-
with open(dependency_links_path, encoding="utf-8") as dependency_links_file:
596-
dependency_links = dependency_links_file.read().strip()
597-
if not dependency_links:
598-
adios(dependency_links_path)
599-
600-
pkg_info_path = os.path.join(distinfo_path, "METADATA")
601-
serialization_policy = EmailPolicy(
602-
utf8=True,
603-
mangle_from_=False,
604-
max_line_length=0,
570+
# .egg-info is a directory
571+
pkginfo_path = os.path.join(egginfo_path, "PKG-INFO")
572+
573+
# ignore common egg metadata that is useless to wheel
574+
shutil.copytree(
575+
egginfo_path,
576+
distinfo_path,
577+
ignore=lambda x, y: {
578+
"PKG-INFO",
579+
"requires.txt",
580+
"SOURCES.txt",
581+
"not-zip-safe",
582+
},
605583
)
606-
with open(pkg_info_path, "w", encoding="utf-8") as out:
607-
Generator(out, policy=serialization_policy).flatten(pkg_info)
584+
585+
# delete dependency_links if it is only whitespace
586+
dependency_links_path = os.path.join(distinfo_path, "dependency_links.txt")
587+
with open(dependency_links_path, encoding="utf-8") as dependency_links_file:
588+
dependency_links = dependency_links_file.read().strip()
589+
if not dependency_links:
590+
adios(dependency_links_path)
591+
592+
metadata_path = os.path.join(distinfo_path, "METADATA")
593+
shutil.copy(pkginfo_path, metadata_path)
608594

609595
for license_path in self.license_paths:
610596
filename = os.path.basename(license_path)

setuptools/tests/test_core_metadata.py

+168-72
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
1+
from __future__ import annotations
2+
13
import functools
24
import importlib
35
import io
46
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
513

614
import pytest
715
from packaging.metadata import Metadata
16+
from packaging.requirements import Requirement
817

918
from setuptools import _reqs, sic
1019
from setuptools._core_metadata import rfc822_escape, rfc822_unescape
1120
from setuptools.command.egg_info import egg_info, write_requirements
21+
from setuptools.config import expand, setupcfg
1222
from setuptools.dist import Distribution
1323

24+
from .config.downloads import retrieve_file, urls_from_file
25+
1426
EXAMPLE_BASE_INFO = dict(
1527
name="package",
1628
version="0.0.1",
@@ -303,84 +315,168 @@ def test_maintainer_author(name, attrs, tmpdir):
303315
assert line in pkg_lines_set
304316

305317

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,
330425
)
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"]
336458

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
338464

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()
351466

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)
355467

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
361474

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()
384480

385481

386482
def _valid_metadata(text: str) -> bool:

0 commit comments

Comments
 (0)