568 lines
20 KiB
Python
568 lines
20 KiB
Python
"""Implementation of the Metadata for Python packages PEPs.
|
|
|
|
Supports all metadata formats (1.0, 1.1, 1.2).
|
|
"""
|
|
|
|
import re
|
|
import logging
|
|
|
|
from io import StringIO
|
|
from email import message_from_file
|
|
from packaging import logger
|
|
from packaging.markers import interpret
|
|
from packaging.version import (is_valid_predicate, is_valid_version,
|
|
is_valid_versions)
|
|
from packaging.errors import (MetadataMissingError,
|
|
MetadataConflictError,
|
|
MetadataUnrecognizedVersionError)
|
|
|
|
try:
|
|
# docutils is installed
|
|
from docutils.utils import Reporter
|
|
from docutils.parsers.rst import Parser
|
|
from docutils import frontend
|
|
from docutils import nodes
|
|
|
|
class SilentReporter(Reporter):
|
|
|
|
def __init__(self, source, report_level, halt_level, stream=None,
|
|
debug=0, encoding='ascii', error_handler='replace'):
|
|
self.messages = []
|
|
Reporter.__init__(self, source, report_level, halt_level, stream,
|
|
debug, encoding, error_handler)
|
|
|
|
def system_message(self, level, message, *children, **kwargs):
|
|
self.messages.append((level, message, children, kwargs))
|
|
|
|
_HAS_DOCUTILS = True
|
|
except ImportError:
|
|
# docutils is not installed
|
|
_HAS_DOCUTILS = False
|
|
|
|
# public API of this module
|
|
__all__ = ['Metadata', 'PKG_INFO_ENCODING', 'PKG_INFO_PREFERRED_VERSION']
|
|
|
|
# Encoding used for the PKG-INFO files
|
|
PKG_INFO_ENCODING = 'utf-8'
|
|
|
|
# preferred version. Hopefully will be changed
|
|
# to 1.2 once PEP 345 is supported everywhere
|
|
PKG_INFO_PREFERRED_VERSION = '1.0'
|
|
|
|
_LINE_PREFIX = re.compile('\n \|')
|
|
_241_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',
|
|
'Summary', 'Description',
|
|
'Keywords', 'Home-page', 'Author', 'Author-email',
|
|
'License')
|
|
|
|
_314_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',
|
|
'Supported-Platform', 'Summary', 'Description',
|
|
'Keywords', 'Home-page', 'Author', 'Author-email',
|
|
'License', 'Classifier', 'Download-URL', 'Obsoletes',
|
|
'Provides', 'Requires')
|
|
|
|
_314_MARKERS = ('Obsoletes', 'Provides', 'Requires', 'Classifier',
|
|
'Download-URL')
|
|
|
|
_345_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',
|
|
'Supported-Platform', 'Summary', 'Description',
|
|
'Keywords', 'Home-page', 'Author', 'Author-email',
|
|
'Maintainer', 'Maintainer-email', 'License',
|
|
'Classifier', 'Download-URL', 'Obsoletes-Dist',
|
|
'Project-URL', 'Provides-Dist', 'Requires-Dist',
|
|
'Requires-Python', 'Requires-External')
|
|
|
|
_345_MARKERS = ('Provides-Dist', 'Requires-Dist', 'Requires-Python',
|
|
'Obsoletes-Dist', 'Requires-External', 'Maintainer',
|
|
'Maintainer-email', 'Project-URL')
|
|
|
|
_ALL_FIELDS = set()
|
|
_ALL_FIELDS.update(_241_FIELDS)
|
|
_ALL_FIELDS.update(_314_FIELDS)
|
|
_ALL_FIELDS.update(_345_FIELDS)
|
|
|
|
|
|
def _version2fieldlist(version):
|
|
if version == '1.0':
|
|
return _241_FIELDS
|
|
elif version == '1.1':
|
|
return _314_FIELDS
|
|
elif version == '1.2':
|
|
return _345_FIELDS
|
|
raise MetadataUnrecognizedVersionError(version)
|
|
|
|
|
|
def _best_version(fields):
|
|
"""Detect the best version depending on the fields used."""
|
|
def _has_marker(keys, markers):
|
|
for marker in markers:
|
|
if marker in keys:
|
|
return True
|
|
return False
|
|
|
|
keys = list(fields)
|
|
possible_versions = ['1.0', '1.1', '1.2']
|
|
|
|
# first let's try to see if a field is not part of one of the version
|
|
for key in keys:
|
|
if key not in _241_FIELDS and '1.0' in possible_versions:
|
|
possible_versions.remove('1.0')
|
|
if key not in _314_FIELDS and '1.1' in possible_versions:
|
|
possible_versions.remove('1.1')
|
|
if key not in _345_FIELDS and '1.2' in possible_versions:
|
|
possible_versions.remove('1.2')
|
|
|
|
# possible_version contains qualified versions
|
|
if len(possible_versions) == 1:
|
|
return possible_versions[0] # found !
|
|
elif len(possible_versions) == 0:
|
|
raise MetadataConflictError('Unknown metadata set')
|
|
|
|
# let's see if one unique marker is found
|
|
is_1_1 = '1.1' in possible_versions and _has_marker(keys, _314_MARKERS)
|
|
is_1_2 = '1.2' in possible_versions and _has_marker(keys, _345_MARKERS)
|
|
if is_1_1 and is_1_2:
|
|
raise MetadataConflictError('You used incompatible 1.1 and 1.2 fields')
|
|
|
|
# we have the choice, either 1.0, or 1.2
|
|
# - 1.0 has a broken Summary field but works with all tools
|
|
# - 1.1 is to avoid
|
|
# - 1.2 fixes Summary but is not widespread yet
|
|
if not is_1_1 and not is_1_2:
|
|
# we couldn't find any specific marker
|
|
if PKG_INFO_PREFERRED_VERSION in possible_versions:
|
|
return PKG_INFO_PREFERRED_VERSION
|
|
if is_1_1:
|
|
return '1.1'
|
|
|
|
# default marker when 1.0 is disqualified
|
|
return '1.2'
|
|
|
|
|
|
_ATTR2FIELD = {
|
|
'metadata_version': 'Metadata-Version',
|
|
'name': 'Name',
|
|
'version': 'Version',
|
|
'platform': 'Platform',
|
|
'supported_platform': 'Supported-Platform',
|
|
'summary': 'Summary',
|
|
'description': 'Description',
|
|
'keywords': 'Keywords',
|
|
'home_page': 'Home-page',
|
|
'author': 'Author',
|
|
'author_email': 'Author-email',
|
|
'maintainer': 'Maintainer',
|
|
'maintainer_email': 'Maintainer-email',
|
|
'license': 'License',
|
|
'classifier': 'Classifier',
|
|
'download_url': 'Download-URL',
|
|
'obsoletes_dist': 'Obsoletes-Dist',
|
|
'provides_dist': 'Provides-Dist',
|
|
'requires_dist': 'Requires-Dist',
|
|
'requires_python': 'Requires-Python',
|
|
'requires_external': 'Requires-External',
|
|
'requires': 'Requires',
|
|
'provides': 'Provides',
|
|
'obsoletes': 'Obsoletes',
|
|
'project_url': 'Project-URL',
|
|
}
|
|
|
|
_PREDICATE_FIELDS = ('Requires-Dist', 'Obsoletes-Dist', 'Provides-Dist')
|
|
_VERSIONS_FIELDS = ('Requires-Python',)
|
|
_VERSION_FIELDS = ('Version',)
|
|
_LISTFIELDS = ('Platform', 'Classifier', 'Obsoletes',
|
|
'Requires', 'Provides', 'Obsoletes-Dist',
|
|
'Provides-Dist', 'Requires-Dist', 'Requires-External',
|
|
'Project-URL', 'Supported-Platform')
|
|
_LISTTUPLEFIELDS = ('Project-URL',)
|
|
|
|
_ELEMENTSFIELD = ('Keywords',)
|
|
|
|
_UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description')
|
|
|
|
_MISSING = object()
|
|
|
|
_FILESAFE = re.compile('[^A-Za-z0-9.]+')
|
|
|
|
class Metadata:
|
|
"""The metadata of a release.
|
|
|
|
Supports versions 1.0, 1.1 and 1.2 (auto-detected). You can
|
|
instantiate the class with one of these arguments (or none):
|
|
- *path*, the path to a METADATA file
|
|
- *fileobj* give a file-like object with METADATA as content
|
|
- *mapping* is a dict-like object
|
|
"""
|
|
# TODO document that execution_context and platform_dependent are used
|
|
# to filter on query, not when setting a key
|
|
# also document the mapping API and UNKNOWN default key
|
|
|
|
def __init__(self, path=None, platform_dependent=False,
|
|
execution_context=None, fileobj=None, mapping=None):
|
|
self._fields = {}
|
|
self.requires_files = []
|
|
self.docutils_support = _HAS_DOCUTILS
|
|
self.platform_dependent = platform_dependent
|
|
self.execution_context = execution_context
|
|
if [path, fileobj, mapping].count(None) < 2:
|
|
raise TypeError('path, fileobj and mapping are exclusive')
|
|
if path is not None:
|
|
self.read(path)
|
|
elif fileobj is not None:
|
|
self.read_file(fileobj)
|
|
elif mapping is not None:
|
|
self.update(mapping)
|
|
|
|
def _set_best_version(self):
|
|
self._fields['Metadata-Version'] = _best_version(self._fields)
|
|
|
|
def _write_field(self, file, name, value):
|
|
file.write('%s: %s\n' % (name, value))
|
|
|
|
def __getitem__(self, name):
|
|
return self.get(name)
|
|
|
|
def __setitem__(self, name, value):
|
|
return self.set(name, value)
|
|
|
|
def __delitem__(self, name):
|
|
field_name = self._convert_name(name)
|
|
try:
|
|
del self._fields[field_name]
|
|
except KeyError:
|
|
raise KeyError(name)
|
|
self._set_best_version()
|
|
|
|
def __contains__(self, name):
|
|
return (name in self._fields or
|
|
self._convert_name(name) in self._fields)
|
|
|
|
def _convert_name(self, name):
|
|
if name in _ALL_FIELDS:
|
|
return name
|
|
name = name.replace('-', '_').lower()
|
|
return _ATTR2FIELD.get(name, name)
|
|
|
|
def _default_value(self, name):
|
|
if name in _LISTFIELDS or name in _ELEMENTSFIELD:
|
|
return []
|
|
return 'UNKNOWN'
|
|
|
|
def _check_rst_data(self, data):
|
|
"""Return warnings when the provided data has syntax errors."""
|
|
source_path = StringIO()
|
|
parser = Parser()
|
|
settings = frontend.OptionParser().get_default_values()
|
|
settings.tab_width = 4
|
|
settings.pep_references = None
|
|
settings.rfc_references = None
|
|
reporter = SilentReporter(source_path,
|
|
settings.report_level,
|
|
settings.halt_level,
|
|
stream=settings.warning_stream,
|
|
debug=settings.debug,
|
|
encoding=settings.error_encoding,
|
|
error_handler=settings.error_encoding_error_handler)
|
|
|
|
document = nodes.document(settings, reporter, source=source_path)
|
|
document.note_source(source_path, -1)
|
|
try:
|
|
parser.parse(data, document)
|
|
except AttributeError:
|
|
reporter.messages.append((-1, 'Could not finish the parsing.',
|
|
'', {}))
|
|
|
|
return reporter.messages
|
|
|
|
def _platform(self, value):
|
|
if not self.platform_dependent or ';' not in value:
|
|
return True, value
|
|
value, marker = value.split(';')
|
|
return interpret(marker, self.execution_context), value
|
|
|
|
def _remove_line_prefix(self, value):
|
|
return _LINE_PREFIX.sub('\n', value)
|
|
|
|
#
|
|
# Public API
|
|
#
|
|
def get_fullname(self, filesafe=False):
|
|
"""Return the distribution name with version.
|
|
|
|
If filesafe is true, return a filename-escaped form."""
|
|
name, version = self['Name'], self['Version']
|
|
if filesafe:
|
|
# For both name and version any runs of non-alphanumeric or '.'
|
|
# characters are replaced with a single '-'. Additionally any
|
|
# spaces in the version string become '.'
|
|
name = _FILESAFE.sub('-', name)
|
|
version = _FILESAFE.sub('-', version.replace(' ', '.'))
|
|
return '%s-%s' % (name, version)
|
|
|
|
def is_metadata_field(self, name):
|
|
"""return True if name is a valid metadata key"""
|
|
name = self._convert_name(name)
|
|
return name in _ALL_FIELDS
|
|
|
|
def is_multi_field(self, name):
|
|
name = self._convert_name(name)
|
|
return name in _LISTFIELDS
|
|
|
|
def read(self, filepath):
|
|
"""Read the metadata values from a file path."""
|
|
with open(filepath, 'r', encoding='utf-8') as fp:
|
|
self.read_file(fp)
|
|
|
|
def read_file(self, fileob):
|
|
"""Read the metadata values from a file object."""
|
|
msg = message_from_file(fileob)
|
|
self._fields['Metadata-Version'] = msg['metadata-version']
|
|
|
|
for field in _version2fieldlist(self['Metadata-Version']):
|
|
if field in _LISTFIELDS:
|
|
# we can have multiple lines
|
|
values = msg.get_all(field)
|
|
if field in _LISTTUPLEFIELDS and values is not None:
|
|
values = [tuple(value.split(',')) for value in values]
|
|
self.set(field, values)
|
|
else:
|
|
# single line
|
|
value = msg[field]
|
|
if value is not None and value != 'UNKNOWN':
|
|
self.set(field, value)
|
|
|
|
def write(self, filepath):
|
|
"""Write the metadata fields to filepath."""
|
|
with open(filepath, 'w', encoding='utf-8') as fp:
|
|
self.write_file(fp)
|
|
|
|
def write_file(self, fileobject):
|
|
"""Write the PKG-INFO format data to a file object."""
|
|
self._set_best_version()
|
|
for field in _version2fieldlist(self['Metadata-Version']):
|
|
values = self.get(field)
|
|
if field in _ELEMENTSFIELD:
|
|
self._write_field(fileobject, field, ','.join(values))
|
|
continue
|
|
if field not in _LISTFIELDS:
|
|
if field == 'Description':
|
|
values = values.replace('\n', '\n |')
|
|
values = [values]
|
|
|
|
if field in _LISTTUPLEFIELDS:
|
|
values = [','.join(value) for value in values]
|
|
|
|
for value in values:
|
|
self._write_field(fileobject, field, value)
|
|
|
|
def update(self, other=None, **kwargs):
|
|
"""Set metadata values from the given iterable `other` and kwargs.
|
|
|
|
Behavior is like `dict.update`: If `other` has a ``keys`` method,
|
|
they are looped over and ``self[key]`` is assigned ``other[key]``.
|
|
Else, ``other`` is an iterable of ``(key, value)`` iterables.
|
|
|
|
Keys that don't match a metadata field or that have an empty value are
|
|
dropped.
|
|
"""
|
|
# XXX the code should just use self.set, which does tbe same checks and
|
|
# conversions already, but that would break packaging.pypi: it uses the
|
|
# update method, which does not call _set_best_version (which set
|
|
# does), and thus allows having a Metadata object (as long as you don't
|
|
# modify or write it) with extra fields from PyPI that are not fields
|
|
# defined in Metadata PEPs. to solve it, the best_version system
|
|
# should be reworked so that it's called only for writing, or in a new
|
|
# strict mode, or with a new, more lax Metadata subclass in p7g.pypi
|
|
def _set(key, value):
|
|
if key in _ATTR2FIELD and value:
|
|
self.set(self._convert_name(key), value)
|
|
|
|
if not other:
|
|
# other is None or empty container
|
|
pass
|
|
elif hasattr(other, 'keys'):
|
|
for k in other.keys():
|
|
_set(k, other[k])
|
|
else:
|
|
for k, v in other:
|
|
_set(k, v)
|
|
|
|
if kwargs:
|
|
for k, v in kwargs.items():
|
|
_set(k, v)
|
|
|
|
def set(self, name, value):
|
|
"""Control then set a metadata field."""
|
|
name = self._convert_name(name)
|
|
|
|
if ((name in _ELEMENTSFIELD or name == 'Platform') and
|
|
not isinstance(value, (list, tuple))):
|
|
if isinstance(value, str):
|
|
value = [v.strip() for v in value.split(',')]
|
|
else:
|
|
value = []
|
|
elif (name in _LISTFIELDS and
|
|
not isinstance(value, (list, tuple))):
|
|
if isinstance(value, str):
|
|
value = [value]
|
|
else:
|
|
value = []
|
|
|
|
if logger.isEnabledFor(logging.WARNING):
|
|
project_name = self['Name']
|
|
|
|
if name in _PREDICATE_FIELDS and value is not None:
|
|
for v in value:
|
|
# check that the values are valid predicates
|
|
if not is_valid_predicate(v.split(';')[0]):
|
|
logger.warning(
|
|
'%r: %r is not a valid predicate (field %r)',
|
|
project_name, v, name)
|
|
# FIXME this rejects UNKNOWN, is that right?
|
|
elif name in _VERSIONS_FIELDS and value is not None:
|
|
if not is_valid_versions(value):
|
|
logger.warning('%r: %r is not a valid version (field %r)',
|
|
project_name, value, name)
|
|
elif name in _VERSION_FIELDS and value is not None:
|
|
if not is_valid_version(value):
|
|
logger.warning('%r: %r is not a valid version (field %r)',
|
|
project_name, value, name)
|
|
|
|
if name in _UNICODEFIELDS:
|
|
if name == 'Description':
|
|
value = self._remove_line_prefix(value)
|
|
|
|
self._fields[name] = value
|
|
self._set_best_version()
|
|
|
|
def get(self, name, default=_MISSING):
|
|
"""Get a metadata field."""
|
|
name = self._convert_name(name)
|
|
if name not in self._fields:
|
|
if default is _MISSING:
|
|
default = self._default_value(name)
|
|
return default
|
|
if name in _UNICODEFIELDS:
|
|
value = self._fields[name]
|
|
return value
|
|
elif name in _LISTFIELDS:
|
|
value = self._fields[name]
|
|
if value is None:
|
|
return []
|
|
res = []
|
|
for val in value:
|
|
valid, val = self._platform(val)
|
|
if not valid:
|
|
continue
|
|
if name not in _LISTTUPLEFIELDS:
|
|
res.append(val)
|
|
else:
|
|
# That's for Project-URL
|
|
res.append((val[0], val[1]))
|
|
return res
|
|
|
|
elif name in _ELEMENTSFIELD:
|
|
valid, value = self._platform(self._fields[name])
|
|
if not valid:
|
|
return []
|
|
if isinstance(value, str):
|
|
return value.split(',')
|
|
valid, value = self._platform(self._fields[name])
|
|
if not valid:
|
|
return None
|
|
return value
|
|
|
|
def check(self, strict=False, restructuredtext=False):
|
|
"""Check if the metadata is compliant. If strict is False then raise if
|
|
no Name or Version are provided"""
|
|
# XXX should check the versions (if the file was loaded)
|
|
missing, warnings = [], []
|
|
|
|
for attr in ('Name', 'Version'): # required by PEP 345
|
|
if attr not in self:
|
|
missing.append(attr)
|
|
|
|
if strict and missing != []:
|
|
msg = 'missing required metadata: %s' % ', '.join(missing)
|
|
raise MetadataMissingError(msg)
|
|
|
|
for attr in ('Home-page', 'Author'):
|
|
if attr not in self:
|
|
missing.append(attr)
|
|
|
|
if _HAS_DOCUTILS and restructuredtext:
|
|
warnings.extend(self._check_rst_data(self['Description']))
|
|
|
|
# checking metadata 1.2 (XXX needs to check 1.1, 1.0)
|
|
if self['Metadata-Version'] != '1.2':
|
|
return missing, warnings
|
|
|
|
def is_valid_predicates(value):
|
|
for v in value:
|
|
if not is_valid_predicate(v.split(';')[0]):
|
|
return False
|
|
return True
|
|
|
|
for fields, controller in ((_PREDICATE_FIELDS, is_valid_predicates),
|
|
(_VERSIONS_FIELDS, is_valid_versions),
|
|
(_VERSION_FIELDS, is_valid_version)):
|
|
for field in fields:
|
|
value = self.get(field, None)
|
|
if value is not None and not controller(value):
|
|
warnings.append('Wrong value for %r: %s' % (field, value))
|
|
|
|
return missing, warnings
|
|
|
|
def todict(self):
|
|
"""Return fields as a dict.
|
|
|
|
Field names will be converted to use the underscore-lowercase style
|
|
instead of hyphen-mixed case (i.e. home_page instead of Home-page).
|
|
"""
|
|
data = {
|
|
'metadata_version': self['Metadata-Version'],
|
|
'name': self['Name'],
|
|
'version': self['Version'],
|
|
'summary': self['Summary'],
|
|
'home_page': self['Home-page'],
|
|
'author': self['Author'],
|
|
'author_email': self['Author-email'],
|
|
'license': self['License'],
|
|
'description': self['Description'],
|
|
'keywords': self['Keywords'],
|
|
'platform': self['Platform'],
|
|
'classifier': self['Classifier'],
|
|
'download_url': self['Download-URL'],
|
|
}
|
|
|
|
if self['Metadata-Version'] == '1.2':
|
|
data['requires_dist'] = self['Requires-Dist']
|
|
data['requires_python'] = self['Requires-Python']
|
|
data['requires_external'] = self['Requires-External']
|
|
data['provides_dist'] = self['Provides-Dist']
|
|
data['obsoletes_dist'] = self['Obsoletes-Dist']
|
|
data['project_url'] = [','.join(url) for url in
|
|
self['Project-URL']]
|
|
|
|
elif self['Metadata-Version'] == '1.1':
|
|
data['provides'] = self['Provides']
|
|
data['requires'] = self['Requires']
|
|
data['obsoletes'] = self['Obsoletes']
|
|
|
|
return data
|
|
|
|
# Mapping API
|
|
|
|
def keys(self):
|
|
return _version2fieldlist(self['Metadata-Version'])
|
|
|
|
def __iter__(self):
|
|
for key in self.keys():
|
|
yield key
|
|
|
|
def values(self):
|
|
return [self[key] for key in list(self.keys())]
|
|
|
|
def items(self):
|
|
return [(key, self[key]) for key in list(self.keys())]
|