-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
10 changed files
with
833 additions
and
576 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,258 @@ | ||
""" | ||
Handling of Core Metadata for Python packages (including reading and writing). | ||
See: https://packaging.python.org/en/latest/specifications/core-metadata/ | ||
""" | ||
import os | ||
import stat | ||
import textwrap | ||
from email import message_from_file | ||
from email.message import Message | ||
from tempfile import NamedTemporaryFile | ||
from typing import Optional, List | ||
|
||
from distutils.util import rfc822_escape | ||
|
||
from . import _normalization | ||
from .extern.packaging.markers import Marker | ||
from .extern.packaging.requirements import Requirement | ||
from .extern.packaging.version import Version | ||
from .warnings import SetuptoolsDeprecationWarning | ||
|
||
|
||
def get_metadata_version(self): | ||
mv = getattr(self, 'metadata_version', None) | ||
if mv is None: | ||
mv = Version('2.1') | ||
self.metadata_version = mv | ||
return mv | ||
|
||
|
||
def rfc822_unescape(content: str) -> str: | ||
"""Reverse RFC-822 escaping by removing leading whitespaces from content.""" | ||
lines = content.splitlines() | ||
if len(lines) == 1: | ||
return lines[0].lstrip() | ||
return '\n'.join((lines[0].lstrip(), textwrap.dedent('\n'.join(lines[1:])))) | ||
|
||
|
||
def _read_field_from_msg(msg: Message, field: str) -> Optional[str]: | ||
"""Read Message header field.""" | ||
value = msg[field] | ||
if value == 'UNKNOWN': | ||
return None | ||
return value | ||
|
||
|
||
def _read_field_unescaped_from_msg(msg: Message, field: str) -> Optional[str]: | ||
"""Read Message header field and apply rfc822_unescape.""" | ||
value = _read_field_from_msg(msg, field) | ||
if value is None: | ||
return value | ||
return rfc822_unescape(value) | ||
|
||
|
||
def _read_list_from_msg(msg: Message, field: str) -> Optional[List[str]]: | ||
"""Read Message header field and return all results as list.""" | ||
values = msg.get_all(field, None) | ||
if values == []: | ||
return None | ||
return values | ||
|
||
|
||
def _read_payload_from_msg(msg: Message) -> Optional[str]: | ||
value = msg.get_payload().strip() | ||
if value == 'UNKNOWN' or not value: | ||
return None | ||
return value | ||
|
||
|
||
def read_pkg_file(self, file): | ||
"""Reads the metadata values from a file object.""" | ||
msg = message_from_file(file) | ||
|
||
self.metadata_version = Version(msg['metadata-version']) | ||
self.name = _read_field_from_msg(msg, 'name') | ||
self.version = _read_field_from_msg(msg, 'version') | ||
self.description = _read_field_from_msg(msg, 'summary') | ||
# we are filling author only. | ||
self.author = _read_field_from_msg(msg, 'author') | ||
self.maintainer = None | ||
self.author_email = _read_field_from_msg(msg, 'author-email') | ||
self.maintainer_email = None | ||
self.url = _read_field_from_msg(msg, 'home-page') | ||
self.download_url = _read_field_from_msg(msg, 'download-url') | ||
self.license = _read_field_unescaped_from_msg(msg, 'license') | ||
|
||
self.long_description = _read_field_unescaped_from_msg(msg, 'description') | ||
if self.long_description is None and self.metadata_version >= Version('2.1'): | ||
self.long_description = _read_payload_from_msg(msg) | ||
self.description = _read_field_from_msg(msg, 'summary') | ||
|
||
if 'keywords' in msg: | ||
self.keywords = _read_field_from_msg(msg, 'keywords').split(',') | ||
|
||
self.platforms = _read_list_from_msg(msg, 'platform') | ||
self.classifiers = _read_list_from_msg(msg, 'classifier') | ||
|
||
# PEP 314 - these fields only exist in 1.1 | ||
if self.metadata_version == Version('1.1'): | ||
self.requires = _read_list_from_msg(msg, 'requires') | ||
self.provides = _read_list_from_msg(msg, 'provides') | ||
self.obsoletes = _read_list_from_msg(msg, 'obsoletes') | ||
else: | ||
self.requires = None | ||
self.provides = None | ||
self.obsoletes = None | ||
|
||
self.license_files = _read_list_from_msg(msg, 'license-file') | ||
|
||
|
||
def single_line(val): | ||
""" | ||
Quick and dirty validation for Summary pypa/setuptools#1390. | ||
""" | ||
if '\n' in val: | ||
# TODO: Replace with `raise ValueError("newlines not allowed")` | ||
# after reviewing #2893. | ||
msg = "newlines are not allowed in `summary` and will break in the future" | ||
SetuptoolsDeprecationWarning.emit("Invalid config.", msg) | ||
# due_date is undefined. Controversial change, there was a lot of push back. | ||
val = val.strip().split('\n')[0] | ||
return val | ||
|
||
|
||
def write_pkg_info(self, base_dir): | ||
"""Write the PKG-INFO file into the release tree.""" | ||
temp = "" | ||
final = os.path.join(base_dir, 'PKG-INFO') | ||
try: | ||
# Use a temporary file while writing to avoid race conditions | ||
# (e.g. `importlib.metadata` reading `.egg-info/PKG-INFO`): | ||
with NamedTemporaryFile("w", encoding="utf-8", dir=base_dir, delete=False) as f: | ||
temp = f.name | ||
self.write_pkg_file(f) | ||
permissions = stat.S_IMODE(os.lstat(temp).st_mode) | ||
os.chmod(temp, permissions | stat.S_IRGRP | stat.S_IROTH) | ||
os.replace(temp, final) # atomic operation. | ||
finally: | ||
if temp and os.path.exists(temp): | ||
os.remove(temp) | ||
|
||
|
||
# Based on Python 3.5 version | ||
def write_pkg_file(self, file): # noqa: C901 # is too complex (14) # FIXME | ||
"""Write the PKG-INFO format data to a file object.""" | ||
version = self.get_metadata_version() | ||
|
||
def write_field(key, value): | ||
file.write("%s: %s\n" % (key, value)) | ||
|
||
write_field('Metadata-Version', str(version)) | ||
write_field('Name', self.get_name()) | ||
write_field('Version', self.get_version()) | ||
|
||
summary = self.get_description() | ||
if summary: | ||
write_field('Summary', single_line(summary)) | ||
|
||
optional_fields = ( | ||
('Home-page', 'url'), | ||
('Download-URL', 'download_url'), | ||
('Author', 'author'), | ||
('Author-email', 'author_email'), | ||
('Maintainer', 'maintainer'), | ||
('Maintainer-email', 'maintainer_email'), | ||
) | ||
|
||
for field, attr in optional_fields: | ||
attr_val = getattr(self, attr, None) | ||
if attr_val is not None: | ||
write_field(field, attr_val) | ||
|
||
license = self.get_license() | ||
if license: | ||
write_field('License', rfc822_escape(license)) | ||
|
||
for project_url in self.project_urls.items(): | ||
write_field('Project-URL', '%s, %s' % project_url) | ||
|
||
keywords = ','.join(self.get_keywords()) | ||
if keywords: | ||
write_field('Keywords', keywords) | ||
|
||
platforms = self.get_platforms() or [] | ||
for platform in platforms: | ||
write_field('Platform', platform) | ||
|
||
self._write_list(file, 'Classifier', self.get_classifiers()) | ||
|
||
# PEP 314 | ||
self._write_list(file, 'Requires', self.get_requires()) | ||
self._write_list(file, 'Provides', self.get_provides()) | ||
self._write_list(file, 'Obsoletes', self.get_obsoletes()) | ||
|
||
# Setuptools specific for PEP 345 | ||
if hasattr(self, 'python_requires'): | ||
write_field('Requires-Python', self.python_requires) | ||
|
||
# PEP 566 | ||
if self.long_description_content_type: | ||
write_field('Description-Content-Type', self.long_description_content_type) | ||
|
||
self._write_list(file, 'License-File', self.license_files or []) | ||
_write_requirements(self, file) | ||
|
||
long_description = self.get_long_description() | ||
if long_description: | ||
file.write("\n%s" % long_description) | ||
if not long_description.endswith("\n"): | ||
file.write("\n") | ||
|
||
|
||
def _write_requirements(self, file): | ||
for req in self._normalized_install_requires: | ||
file.write(f"Requires-Dist: {req}\n") | ||
|
||
processed_extras = {} | ||
for augmented_extra, reqs in self._normalized_extras_require.items(): | ||
# Historically, setuptools allows "augmented extras": `<extra>:<condition>` | ||
unsafe_extra, _, condition = augmented_extra.partition(":") | ||
unsafe_extra = unsafe_extra.strip() | ||
extra = _normalization.safe_extra(unsafe_extra) | ||
|
||
if extra: | ||
_write_provides_extra(file, processed_extras, extra, unsafe_extra) | ||
for req in reqs: | ||
r = _include_extra(req, extra, condition.strip()) | ||
file.write(f"Requires-Dist: {r}\n") | ||
|
||
return processed_extras | ||
|
||
|
||
def _include_extra(req: str, extra: str, condition: str) -> Requirement: | ||
r = Requirement(req) | ||
parts = ( | ||
f"({r.marker})" if r.marker else None, | ||
f"({condition})" if condition else None, | ||
f"extra == {extra!r}" if extra else None, | ||
) | ||
r.marker = Marker(" and ".join(x for x in parts if x)) | ||
return r | ||
|
||
|
||
def _write_provides_extra(file, processed_extras, safe, unsafe): | ||
previous = processed_extras.get(safe) | ||
if previous == unsafe: | ||
SetuptoolsDeprecationWarning.emit( | ||
'Ambiguity during "extra" normalization for dependencies.', | ||
f""" | ||
{previous!r} and {unsafe!r} normalize to the same value:\n | ||
{safe!r}\n | ||
In future versions, setuptools might halt the build process. | ||
""", | ||
see_url="https://peps.python.org/pep-0685/", | ||
) | ||
else: | ||
processed_extras[safe] = unsafe | ||
file.write(f"Provides-Extra: {safe}\n") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.