Skip to content

Commit b537f53

Browse files
committed
Improve core metadata compliance for PKG-INFO (#3903, #3904)
2 parents c520238 + f4dd7e2 commit b537f53

File tree

10 files changed

+833
-576
lines changed

10 files changed

+833
-576
lines changed

setuptools/_core_metadata.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""
2+
Handling of Core Metadata for Python packages (including reading and writing).
3+
4+
See: https://packaging.python.org/en/latest/specifications/core-metadata/
5+
"""
6+
import os
7+
import stat
8+
import textwrap
9+
from email import message_from_file
10+
from email.message import Message
11+
from tempfile import NamedTemporaryFile
12+
from typing import Optional, List
13+
14+
from distutils.util import rfc822_escape
15+
16+
from . import _normalization
17+
from .extern.packaging.markers import Marker
18+
from .extern.packaging.requirements import Requirement
19+
from .extern.packaging.version import Version
20+
from .warnings import SetuptoolsDeprecationWarning
21+
22+
23+
def get_metadata_version(self):
24+
mv = getattr(self, 'metadata_version', None)
25+
if mv is None:
26+
mv = Version('2.1')
27+
self.metadata_version = mv
28+
return mv
29+
30+
31+
def rfc822_unescape(content: str) -> str:
32+
"""Reverse RFC-822 escaping by removing leading whitespaces from content."""
33+
lines = content.splitlines()
34+
if len(lines) == 1:
35+
return lines[0].lstrip()
36+
return '\n'.join((lines[0].lstrip(), textwrap.dedent('\n'.join(lines[1:]))))
37+
38+
39+
def _read_field_from_msg(msg: Message, field: str) -> Optional[str]:
40+
"""Read Message header field."""
41+
value = msg[field]
42+
if value == 'UNKNOWN':
43+
return None
44+
return value
45+
46+
47+
def _read_field_unescaped_from_msg(msg: Message, field: str) -> Optional[str]:
48+
"""Read Message header field and apply rfc822_unescape."""
49+
value = _read_field_from_msg(msg, field)
50+
if value is None:
51+
return value
52+
return rfc822_unescape(value)
53+
54+
55+
def _read_list_from_msg(msg: Message, field: str) -> Optional[List[str]]:
56+
"""Read Message header field and return all results as list."""
57+
values = msg.get_all(field, None)
58+
if values == []:
59+
return None
60+
return values
61+
62+
63+
def _read_payload_from_msg(msg: Message) -> Optional[str]:
64+
value = msg.get_payload().strip()
65+
if value == 'UNKNOWN' or not value:
66+
return None
67+
return value
68+
69+
70+
def read_pkg_file(self, file):
71+
"""Reads the metadata values from a file object."""
72+
msg = message_from_file(file)
73+
74+
self.metadata_version = Version(msg['metadata-version'])
75+
self.name = _read_field_from_msg(msg, 'name')
76+
self.version = _read_field_from_msg(msg, 'version')
77+
self.description = _read_field_from_msg(msg, 'summary')
78+
# we are filling author only.
79+
self.author = _read_field_from_msg(msg, 'author')
80+
self.maintainer = None
81+
self.author_email = _read_field_from_msg(msg, 'author-email')
82+
self.maintainer_email = None
83+
self.url = _read_field_from_msg(msg, 'home-page')
84+
self.download_url = _read_field_from_msg(msg, 'download-url')
85+
self.license = _read_field_unescaped_from_msg(msg, 'license')
86+
87+
self.long_description = _read_field_unescaped_from_msg(msg, 'description')
88+
if self.long_description is None and self.metadata_version >= Version('2.1'):
89+
self.long_description = _read_payload_from_msg(msg)
90+
self.description = _read_field_from_msg(msg, 'summary')
91+
92+
if 'keywords' in msg:
93+
self.keywords = _read_field_from_msg(msg, 'keywords').split(',')
94+
95+
self.platforms = _read_list_from_msg(msg, 'platform')
96+
self.classifiers = _read_list_from_msg(msg, 'classifier')
97+
98+
# PEP 314 - these fields only exist in 1.1
99+
if self.metadata_version == Version('1.1'):
100+
self.requires = _read_list_from_msg(msg, 'requires')
101+
self.provides = _read_list_from_msg(msg, 'provides')
102+
self.obsoletes = _read_list_from_msg(msg, 'obsoletes')
103+
else:
104+
self.requires = None
105+
self.provides = None
106+
self.obsoletes = None
107+
108+
self.license_files = _read_list_from_msg(msg, 'license-file')
109+
110+
111+
def single_line(val):
112+
"""
113+
Quick and dirty validation for Summary pypa/setuptools#1390.
114+
"""
115+
if '\n' in val:
116+
# TODO: Replace with `raise ValueError("newlines not allowed")`
117+
# after reviewing #2893.
118+
msg = "newlines are not allowed in `summary` and will break in the future"
119+
SetuptoolsDeprecationWarning.emit("Invalid config.", msg)
120+
# due_date is undefined. Controversial change, there was a lot of push back.
121+
val = val.strip().split('\n')[0]
122+
return val
123+
124+
125+
def write_pkg_info(self, base_dir):
126+
"""Write the PKG-INFO file into the release tree."""
127+
temp = ""
128+
final = os.path.join(base_dir, 'PKG-INFO')
129+
try:
130+
# Use a temporary file while writing to avoid race conditions
131+
# (e.g. `importlib.metadata` reading `.egg-info/PKG-INFO`):
132+
with NamedTemporaryFile("w", encoding="utf-8", dir=base_dir, delete=False) as f:
133+
temp = f.name
134+
self.write_pkg_file(f)
135+
permissions = stat.S_IMODE(os.lstat(temp).st_mode)
136+
os.chmod(temp, permissions | stat.S_IRGRP | stat.S_IROTH)
137+
os.replace(temp, final) # atomic operation.
138+
finally:
139+
if temp and os.path.exists(temp):
140+
os.remove(temp)
141+
142+
143+
# Based on Python 3.5 version
144+
def write_pkg_file(self, file): # noqa: C901 # is too complex (14) # FIXME
145+
"""Write the PKG-INFO format data to a file object."""
146+
version = self.get_metadata_version()
147+
148+
def write_field(key, value):
149+
file.write("%s: %s\n" % (key, value))
150+
151+
write_field('Metadata-Version', str(version))
152+
write_field('Name', self.get_name())
153+
write_field('Version', self.get_version())
154+
155+
summary = self.get_description()
156+
if summary:
157+
write_field('Summary', single_line(summary))
158+
159+
optional_fields = (
160+
('Home-page', 'url'),
161+
('Download-URL', 'download_url'),
162+
('Author', 'author'),
163+
('Author-email', 'author_email'),
164+
('Maintainer', 'maintainer'),
165+
('Maintainer-email', 'maintainer_email'),
166+
)
167+
168+
for field, attr in optional_fields:
169+
attr_val = getattr(self, attr, None)
170+
if attr_val is not None:
171+
write_field(field, attr_val)
172+
173+
license = self.get_license()
174+
if license:
175+
write_field('License', rfc822_escape(license))
176+
177+
for project_url in self.project_urls.items():
178+
write_field('Project-URL', '%s, %s' % project_url)
179+
180+
keywords = ','.join(self.get_keywords())
181+
if keywords:
182+
write_field('Keywords', keywords)
183+
184+
platforms = self.get_platforms() or []
185+
for platform in platforms:
186+
write_field('Platform', platform)
187+
188+
self._write_list(file, 'Classifier', self.get_classifiers())
189+
190+
# PEP 314
191+
self._write_list(file, 'Requires', self.get_requires())
192+
self._write_list(file, 'Provides', self.get_provides())
193+
self._write_list(file, 'Obsoletes', self.get_obsoletes())
194+
195+
# Setuptools specific for PEP 345
196+
if hasattr(self, 'python_requires'):
197+
write_field('Requires-Python', self.python_requires)
198+
199+
# PEP 566
200+
if self.long_description_content_type:
201+
write_field('Description-Content-Type', self.long_description_content_type)
202+
203+
self._write_list(file, 'License-File', self.license_files or [])
204+
_write_requirements(self, file)
205+
206+
long_description = self.get_long_description()
207+
if long_description:
208+
file.write("\n%s" % long_description)
209+
if not long_description.endswith("\n"):
210+
file.write("\n")
211+
212+
213+
def _write_requirements(self, file):
214+
for req in self._normalized_install_requires:
215+
file.write(f"Requires-Dist: {req}\n")
216+
217+
processed_extras = {}
218+
for augmented_extra, reqs in self._normalized_extras_require.items():
219+
# Historically, setuptools allows "augmented extras": `<extra>:<condition>`
220+
unsafe_extra, _, condition = augmented_extra.partition(":")
221+
unsafe_extra = unsafe_extra.strip()
222+
extra = _normalization.safe_extra(unsafe_extra)
223+
224+
if extra:
225+
_write_provides_extra(file, processed_extras, extra, unsafe_extra)
226+
for req in reqs:
227+
r = _include_extra(req, extra, condition.strip())
228+
file.write(f"Requires-Dist: {r}\n")
229+
230+
return processed_extras
231+
232+
233+
def _include_extra(req: str, extra: str, condition: str) -> Requirement:
234+
r = Requirement(req)
235+
parts = (
236+
f"({r.marker})" if r.marker else None,
237+
f"({condition})" if condition else None,
238+
f"extra == {extra!r}" if extra else None,
239+
)
240+
r.marker = Marker(" and ".join(x for x in parts if x))
241+
return r
242+
243+
244+
def _write_provides_extra(file, processed_extras, safe, unsafe):
245+
previous = processed_extras.get(safe)
246+
if previous == unsafe:
247+
SetuptoolsDeprecationWarning.emit(
248+
'Ambiguity during "extra" normalization for dependencies.',
249+
f"""
250+
{previous!r} and {unsafe!r} normalize to the same value:\n
251+
{safe!r}\n
252+
In future versions, setuptools might halt the build process.
253+
""",
254+
see_url="https://peps.python.org/pep-0685/",
255+
)
256+
else:
257+
processed_extras[safe] = unsafe
258+
file.write(f"Provides-Extra: {safe}\n")

setuptools/_normalization.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# https://packaging.python.org/en/latest/specifications/core-metadata/#name
1515
_VALID_NAME = re.compile(r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.I)
1616
_UNSAFE_NAME_CHARS = re.compile(r"[^A-Z0-9.]+", re.I)
17+
_NON_ALPHANUMERIC = re.compile(r"[^A-Z0-9]+", re.I)
1718

1819

1920
def safe_identifier(name: str) -> str:
@@ -92,6 +93,16 @@ def best_effort_version(version: str) -> str:
9293
return safe_name(v)
9394

9495

96+
def safe_extra(extra: str) -> str:
97+
"""Normalize extra name according to PEP 685
98+
>>> safe_extra("_FrIeNdLy-._.-bArD")
99+
'friendly-bard'
100+
>>> safe_extra("FrIeNdLy-._.-bArD__._-")
101+
'friendly-bard'
102+
"""
103+
return _NON_ALPHANUMERIC.sub("-", extra).strip("-").lower()
104+
105+
95106
def filename_component(value: str) -> str:
96107
"""Normalize each component of a filename (e.g. distribution/version part of wheel)
97108
Note: ``value`` needs to be already normalized.

0 commit comments

Comments
 (0)