259 lines
8.7 KiB
Python
259 lines
8.7 KiB
Python
|
"""
|
||
|
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, _reqs
|
||
|
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 _reqs.parse(self.install_requires):
|
||
|
file.write(f"Requires-Dist: {req}\n")
|
||
|
|
||
|
processed_extras = {}
|
||
|
for augmented_extra, reqs in self.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.parse_strings(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) # create a fresh object that can be modified
|
||
|
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")
|