1059 lines
38 KiB
Python
1059 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# Copyright (C) 2012 The Python Software Foundation.
|
|
# See LICENSE.txt and CONTRIBUTORS.txt.
|
|
#
|
|
"""Implementation of the Metadata for Python packages PEPs.
|
|
|
|
Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1 and withdrawn 2.0).
|
|
"""
|
|
from __future__ import unicode_literals
|
|
|
|
import codecs
|
|
from email import message_from_file
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
|
|
from . import DistlibException, __version__
|
|
from .compat import StringIO, string_types, text_type
|
|
from .markers import interpret
|
|
from .util import extract_by_key, get_extras
|
|
from .version import get_scheme, PEP440_VERSION_RE
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MetadataMissingError(DistlibException):
|
|
"""A required metadata is missing"""
|
|
|
|
|
|
class MetadataConflictError(DistlibException):
|
|
"""Attempt to read or write metadata fields that are conflictual."""
|
|
|
|
|
|
class MetadataUnrecognizedVersionError(DistlibException):
|
|
"""Unknown metadata version number."""
|
|
|
|
|
|
class MetadataInvalidError(DistlibException):
|
|
"""A metadata value is invalid"""
|
|
|
|
# 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.1'
|
|
|
|
_LINE_PREFIX_1_2 = re.compile('\n \\|')
|
|
_LINE_PREFIX_PRE_1_2 = 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')
|
|
|
|
_426_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', 'Private-Version',
|
|
'Obsoleted-By', 'Setup-Requires-Dist', 'Extension',
|
|
'Provides-Extra')
|
|
|
|
_426_MARKERS = ('Private-Version', 'Provides-Extra', 'Obsoleted-By',
|
|
'Setup-Requires-Dist', 'Extension')
|
|
|
|
# See issue #106: Sometimes 'Requires' and 'Provides' occur wrongly in
|
|
# the metadata. Include them in the tuple literal below to allow them
|
|
# (for now).
|
|
# Ditto for Obsoletes - see issue #140.
|
|
_566_FIELDS = _426_FIELDS + ('Description-Content-Type',
|
|
'Requires', 'Provides', 'Obsoletes')
|
|
|
|
_566_MARKERS = ('Description-Content-Type',)
|
|
|
|
_ALL_FIELDS = set()
|
|
_ALL_FIELDS.update(_241_FIELDS)
|
|
_ALL_FIELDS.update(_314_FIELDS)
|
|
_ALL_FIELDS.update(_345_FIELDS)
|
|
_ALL_FIELDS.update(_426_FIELDS)
|
|
_ALL_FIELDS.update(_566_FIELDS)
|
|
|
|
EXTRA_RE = re.compile(r'''extra\s*==\s*("([^"]+)"|'([^']+)')''')
|
|
|
|
|
|
def _version2fieldlist(version):
|
|
if version == '1.0':
|
|
return _241_FIELDS
|
|
elif version == '1.1':
|
|
return _314_FIELDS
|
|
elif version == '1.2':
|
|
return _345_FIELDS
|
|
elif version in ('1.3', '2.1'):
|
|
# avoid adding field names if already there
|
|
return _345_FIELDS + tuple(f for f in _566_FIELDS if f not in _345_FIELDS)
|
|
elif version == '2.0':
|
|
return _426_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 = []
|
|
for key, value in fields.items():
|
|
if value in ([], 'UNKNOWN', None):
|
|
continue
|
|
keys.append(key)
|
|
|
|
possible_versions = ['1.0', '1.1', '1.2', '1.3', '2.0', '2.1']
|
|
|
|
# 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')
|
|
logger.debug('Removed 1.0 due to %s', key)
|
|
if key not in _314_FIELDS and '1.1' in possible_versions:
|
|
possible_versions.remove('1.1')
|
|
logger.debug('Removed 1.1 due to %s', key)
|
|
if key not in _345_FIELDS and '1.2' in possible_versions:
|
|
possible_versions.remove('1.2')
|
|
logger.debug('Removed 1.2 due to %s', key)
|
|
if key not in _566_FIELDS and '1.3' in possible_versions:
|
|
possible_versions.remove('1.3')
|
|
logger.debug('Removed 1.3 due to %s', key)
|
|
if key not in _566_FIELDS and '2.1' in possible_versions:
|
|
if key != 'Description': # In 2.1, description allowed after headers
|
|
possible_versions.remove('2.1')
|
|
logger.debug('Removed 2.1 due to %s', key)
|
|
if key not in _426_FIELDS and '2.0' in possible_versions:
|
|
possible_versions.remove('2.0')
|
|
logger.debug('Removed 2.0 due to %s', key)
|
|
|
|
# possible_version contains qualified versions
|
|
if len(possible_versions) == 1:
|
|
return possible_versions[0] # found !
|
|
elif len(possible_versions) == 0:
|
|
logger.debug('Out of options - unknown metadata set: %s', fields)
|
|
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)
|
|
is_2_1 = '2.1' in possible_versions and _has_marker(keys, _566_MARKERS)
|
|
is_2_0 = '2.0' in possible_versions and _has_marker(keys, _426_MARKERS)
|
|
if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_0) > 1:
|
|
raise MetadataConflictError('You used incompatible 1.1/1.2/2.0/2.1 fields')
|
|
|
|
# we have the choice, 1.0, or 1.2, or 2.0
|
|
# - 1.0 has a broken Summary field but works with all tools
|
|
# - 1.1 is to avoid
|
|
# - 1.2 fixes Summary but has little adoption
|
|
# - 2.0 adds more features and is very new
|
|
if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_0:
|
|
# 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'
|
|
if is_1_2:
|
|
return '1.2'
|
|
if is_2_1:
|
|
return '2.1'
|
|
|
|
return '2.0'
|
|
|
|
# This follows the rules about transforming keys as described in
|
|
# https://www.python.org/dev/peps/pep-0566/#id17
|
|
_ATTR2FIELD = {
|
|
name.lower().replace("-", "_"): name for name in _ALL_FIELDS
|
|
}
|
|
_FIELD2ATTR = {field: attr for attr, field in _ATTR2FIELD.items()}
|
|
|
|
_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', 'Setup-Requires-Dist',
|
|
'Provides-Extra', 'Extension')
|
|
_LISTTUPLEFIELDS = ('Project-URL',)
|
|
|
|
_ELEMENTSFIELD = ('Keywords',)
|
|
|
|
_UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description')
|
|
|
|
_MISSING = object()
|
|
|
|
_FILESAFE = re.compile('[^A-Za-z0-9.]+')
|
|
|
|
|
|
def _get_name_and_version(name, version, for_filename=False):
|
|
"""Return the distribution name with version.
|
|
|
|
If for_filename is true, return a filename-escaped form."""
|
|
if for_filename:
|
|
# 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)
|
|
|
|
|
|
class LegacyMetadata(object):
|
|
"""The legacy metadata of a release.
|
|
|
|
Supports versions 1.0, 1.1, 1.2, 2.0 and 1.3/2.1 (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
|
|
- *scheme* is a version scheme name
|
|
"""
|
|
# TODO document the mapping API and UNKNOWN default key
|
|
|
|
def __init__(self, path=None, fileobj=None, mapping=None,
|
|
scheme='default'):
|
|
if [path, fileobj, mapping].count(None) < 2:
|
|
raise TypeError('path, fileobj and mapping are exclusive')
|
|
self._fields = {}
|
|
self.requires_files = []
|
|
self._dependencies = None
|
|
self.scheme = scheme
|
|
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)
|
|
self.set_metadata_version()
|
|
|
|
def set_metadata_version(self):
|
|
self._fields['Metadata-Version'] = _best_version(self._fields)
|
|
|
|
def _write_field(self, fileobj, name, value):
|
|
fileobj.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)
|
|
|
|
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 _remove_line_prefix(self, value):
|
|
if self.metadata_version in ('1.0', '1.1'):
|
|
return _LINE_PREFIX_PRE_1_2.sub('\n', value)
|
|
else:
|
|
return _LINE_PREFIX_1_2.sub('\n', value)
|
|
|
|
def __getattr__(self, name):
|
|
if name in _ATTR2FIELD:
|
|
return self[name]
|
|
raise AttributeError(name)
|
|
|
|
#
|
|
# Public API
|
|
#
|
|
|
|
# dependencies = property(_get_dependencies, _set_dependencies)
|
|
|
|
def get_fullname(self, filesafe=False):
|
|
"""Return the distribution name with version.
|
|
|
|
If filesafe is true, return a filename-escaped form."""
|
|
return _get_name_and_version(self['Name'], self['Version'], filesafe)
|
|
|
|
def is_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."""
|
|
fp = codecs.open(filepath, 'r', encoding='utf-8')
|
|
try:
|
|
self.read_file(fp)
|
|
finally:
|
|
fp.close()
|
|
|
|
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']
|
|
|
|
# When reading, get all the fields we can
|
|
for field in _ALL_FIELDS:
|
|
if field not in msg:
|
|
continue
|
|
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)
|
|
|
|
# PEP 566 specifies that the body be used for the description, if
|
|
# available
|
|
body = msg.get_payload()
|
|
self["Description"] = body if body else self["Description"]
|
|
# logger.debug('Attempting to set metadata for %s', self)
|
|
# self.set_metadata_version()
|
|
|
|
def write(self, filepath, skip_unknown=False):
|
|
"""Write the metadata fields to filepath."""
|
|
fp = codecs.open(filepath, 'w', encoding='utf-8')
|
|
try:
|
|
self.write_file(fp, skip_unknown)
|
|
finally:
|
|
fp.close()
|
|
|
|
def write_file(self, fileobject, skip_unknown=False):
|
|
"""Write the PKG-INFO format data to a file object."""
|
|
self.set_metadata_version()
|
|
|
|
for field in _version2fieldlist(self['Metadata-Version']):
|
|
values = self.get(field)
|
|
if skip_unknown and values in ('UNKNOWN', [], ['UNKNOWN']):
|
|
continue
|
|
if field in _ELEMENTSFIELD:
|
|
self._write_field(fileobject, field, ','.join(values))
|
|
continue
|
|
if field not in _LISTFIELDS:
|
|
if field == 'Description':
|
|
if self.metadata_version in ('1.0', '1.1'):
|
|
values = values.replace('\n', '\n ')
|
|
else:
|
|
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.
|
|
"""
|
|
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, string_types):
|
|
value = [v.strip() for v in value.split(',')]
|
|
else:
|
|
value = []
|
|
elif (name in _LISTFIELDS and
|
|
not isinstance(value, (list, tuple))):
|
|
if isinstance(value, string_types):
|
|
value = [value]
|
|
else:
|
|
value = []
|
|
|
|
if logger.isEnabledFor(logging.WARNING):
|
|
project_name = self['Name']
|
|
|
|
scheme = get_scheme(self.scheme)
|
|
if name in _PREDICATE_FIELDS and value is not None:
|
|
for v in value:
|
|
# check that the values are valid
|
|
if not scheme.is_valid_matcher(v.split(';')[0]):
|
|
logger.warning(
|
|
"'%s': '%s' is not valid (field '%s')",
|
|
project_name, v, name)
|
|
# FIXME this rejects UNKNOWN, is that right?
|
|
elif name in _VERSIONS_FIELDS and value is not None:
|
|
if not scheme.is_valid_constraint_list(value):
|
|
logger.warning("'%s': '%s' is not a valid version (field '%s')",
|
|
project_name, value, name)
|
|
elif name in _VERSION_FIELDS and value is not None:
|
|
if not scheme.is_valid_version(value):
|
|
logger.warning("'%s': '%s' is not a valid version (field '%s')",
|
|
project_name, value, name)
|
|
|
|
if name in _UNICODEFIELDS:
|
|
if name == 'Description':
|
|
value = self._remove_line_prefix(value)
|
|
|
|
self._fields[name] = value
|
|
|
|
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:
|
|
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:
|
|
value = self._fields[name]
|
|
if isinstance(value, string_types):
|
|
return value.split(',')
|
|
return self._fields[name]
|
|
|
|
def check(self, strict=False):
|
|
"""Check if the metadata is compliant. If strict is True then raise if
|
|
no Name or Version are provided"""
|
|
self.set_metadata_version()
|
|
|
|
# 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)
|
|
|
|
# checking metadata 1.2 (XXX needs to check 1.1, 1.0)
|
|
if self['Metadata-Version'] != '1.2':
|
|
return missing, warnings
|
|
|
|
scheme = get_scheme(self.scheme)
|
|
|
|
def are_valid_constraints(value):
|
|
for v in value:
|
|
if not scheme.is_valid_matcher(v.split(';')[0]):
|
|
return False
|
|
return True
|
|
|
|
for fields, controller in ((_PREDICATE_FIELDS, are_valid_constraints),
|
|
(_VERSIONS_FIELDS,
|
|
scheme.is_valid_constraint_list),
|
|
(_VERSION_FIELDS,
|
|
scheme.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 '%s': %s" % (field, value))
|
|
|
|
return missing, warnings
|
|
|
|
def todict(self, skip_missing=False):
|
|
"""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).
|
|
This is as per https://www.python.org/dev/peps/pep-0566/#id17.
|
|
"""
|
|
self.set_metadata_version()
|
|
|
|
fields = _version2fieldlist(self['Metadata-Version'])
|
|
|
|
data = {}
|
|
|
|
for field_name in fields:
|
|
if not skip_missing or field_name in self._fields:
|
|
key = _FIELD2ATTR[field_name]
|
|
if key != 'project_url':
|
|
data[key] = self[field_name]
|
|
else:
|
|
data[key] = [','.join(u) for u in self[field_name]]
|
|
|
|
return data
|
|
|
|
def add_requirements(self, requirements):
|
|
if self['Metadata-Version'] == '1.1':
|
|
# we can't have 1.1 metadata *and* Setuptools requires
|
|
for field in ('Obsoletes', 'Requires', 'Provides'):
|
|
if field in self:
|
|
del self[field]
|
|
self['Requires-Dist'] += requirements
|
|
|
|
# Mapping API
|
|
# TODO could add iter* variants
|
|
|
|
def keys(self):
|
|
return list(_version2fieldlist(self['Metadata-Version']))
|
|
|
|
def __iter__(self):
|
|
for key in self.keys():
|
|
yield key
|
|
|
|
def values(self):
|
|
return [self[key] for key in self.keys()]
|
|
|
|
def items(self):
|
|
return [(key, self[key]) for key in self.keys()]
|
|
|
|
def __repr__(self):
|
|
return '<%s %s %s>' % (self.__class__.__name__, self.name,
|
|
self.version)
|
|
|
|
|
|
METADATA_FILENAME = 'pydist.json'
|
|
WHEEL_METADATA_FILENAME = 'metadata.json'
|
|
LEGACY_METADATA_FILENAME = 'METADATA'
|
|
|
|
|
|
class Metadata(object):
|
|
"""
|
|
The metadata of a release. This implementation uses 2.0 (JSON)
|
|
metadata where possible. If not possible, it wraps a LegacyMetadata
|
|
instance which handles the key-value metadata format.
|
|
"""
|
|
|
|
METADATA_VERSION_MATCHER = re.compile(r'^\d+(\.\d+)*$')
|
|
|
|
NAME_MATCHER = re.compile('^[0-9A-Z]([0-9A-Z_.-]*[0-9A-Z])?$', re.I)
|
|
|
|
VERSION_MATCHER = PEP440_VERSION_RE
|
|
|
|
SUMMARY_MATCHER = re.compile('.{1,2047}')
|
|
|
|
METADATA_VERSION = '2.0'
|
|
|
|
GENERATOR = 'distlib (%s)' % __version__
|
|
|
|
MANDATORY_KEYS = {
|
|
'name': (),
|
|
'version': (),
|
|
'summary': ('legacy',),
|
|
}
|
|
|
|
INDEX_KEYS = ('name version license summary description author '
|
|
'author_email keywords platform home_page classifiers '
|
|
'download_url')
|
|
|
|
DEPENDENCY_KEYS = ('extras run_requires test_requires build_requires '
|
|
'dev_requires provides meta_requires obsoleted_by '
|
|
'supports_environments')
|
|
|
|
SYNTAX_VALIDATORS = {
|
|
'metadata_version': (METADATA_VERSION_MATCHER, ()),
|
|
'name': (NAME_MATCHER, ('legacy',)),
|
|
'version': (VERSION_MATCHER, ('legacy',)),
|
|
'summary': (SUMMARY_MATCHER, ('legacy',)),
|
|
}
|
|
|
|
__slots__ = ('_legacy', '_data', 'scheme')
|
|
|
|
def __init__(self, path=None, fileobj=None, mapping=None,
|
|
scheme='default'):
|
|
if [path, fileobj, mapping].count(None) < 2:
|
|
raise TypeError('path, fileobj and mapping are exclusive')
|
|
self._legacy = None
|
|
self._data = None
|
|
self.scheme = scheme
|
|
#import pdb; pdb.set_trace()
|
|
if mapping is not None:
|
|
try:
|
|
self._validate_mapping(mapping, scheme)
|
|
self._data = mapping
|
|
except MetadataUnrecognizedVersionError:
|
|
self._legacy = LegacyMetadata(mapping=mapping, scheme=scheme)
|
|
self.validate()
|
|
else:
|
|
data = None
|
|
if path:
|
|
with open(path, 'rb') as f:
|
|
data = f.read()
|
|
elif fileobj:
|
|
data = fileobj.read()
|
|
if data is None:
|
|
# Initialised with no args - to be added
|
|
self._data = {
|
|
'metadata_version': self.METADATA_VERSION,
|
|
'generator': self.GENERATOR,
|
|
}
|
|
else:
|
|
if not isinstance(data, text_type):
|
|
data = data.decode('utf-8')
|
|
try:
|
|
self._data = json.loads(data)
|
|
self._validate_mapping(self._data, scheme)
|
|
except ValueError:
|
|
# Note: MetadataUnrecognizedVersionError does not
|
|
# inherit from ValueError (it's a DistlibException,
|
|
# which should not inherit from ValueError).
|
|
# The ValueError comes from the json.load - if that
|
|
# succeeds and we get a validation error, we want
|
|
# that to propagate
|
|
self._legacy = LegacyMetadata(fileobj=StringIO(data),
|
|
scheme=scheme)
|
|
self.validate()
|
|
|
|
common_keys = set(('name', 'version', 'license', 'keywords', 'summary'))
|
|
|
|
none_list = (None, list)
|
|
none_dict = (None, dict)
|
|
|
|
mapped_keys = {
|
|
'run_requires': ('Requires-Dist', list),
|
|
'build_requires': ('Setup-Requires-Dist', list),
|
|
'dev_requires': none_list,
|
|
'test_requires': none_list,
|
|
'meta_requires': none_list,
|
|
'extras': ('Provides-Extra', list),
|
|
'modules': none_list,
|
|
'namespaces': none_list,
|
|
'exports': none_dict,
|
|
'commands': none_dict,
|
|
'classifiers': ('Classifier', list),
|
|
'source_url': ('Download-URL', None),
|
|
'metadata_version': ('Metadata-Version', None),
|
|
}
|
|
|
|
del none_list, none_dict
|
|
|
|
def __getattribute__(self, key):
|
|
common = object.__getattribute__(self, 'common_keys')
|
|
mapped = object.__getattribute__(self, 'mapped_keys')
|
|
if key in mapped:
|
|
lk, maker = mapped[key]
|
|
if self._legacy:
|
|
if lk is None:
|
|
result = None if maker is None else maker()
|
|
else:
|
|
result = self._legacy.get(lk)
|
|
else:
|
|
value = None if maker is None else maker()
|
|
if key not in ('commands', 'exports', 'modules', 'namespaces',
|
|
'classifiers'):
|
|
result = self._data.get(key, value)
|
|
else:
|
|
# special cases for PEP 459
|
|
sentinel = object()
|
|
result = sentinel
|
|
d = self._data.get('extensions')
|
|
if d:
|
|
if key == 'commands':
|
|
result = d.get('python.commands', value)
|
|
elif key == 'classifiers':
|
|
d = d.get('python.details')
|
|
if d:
|
|
result = d.get(key, value)
|
|
else:
|
|
d = d.get('python.exports')
|
|
if not d:
|
|
d = self._data.get('python.exports')
|
|
if d:
|
|
result = d.get(key, value)
|
|
if result is sentinel:
|
|
result = value
|
|
elif key not in common:
|
|
result = object.__getattribute__(self, key)
|
|
elif self._legacy:
|
|
result = self._legacy.get(key)
|
|
else:
|
|
result = self._data.get(key)
|
|
return result
|
|
|
|
def _validate_value(self, key, value, scheme=None):
|
|
if key in self.SYNTAX_VALIDATORS:
|
|
pattern, exclusions = self.SYNTAX_VALIDATORS[key]
|
|
if (scheme or self.scheme) not in exclusions:
|
|
m = pattern.match(value)
|
|
if not m:
|
|
raise MetadataInvalidError("'%s' is an invalid value for "
|
|
"the '%s' property" % (value,
|
|
key))
|
|
|
|
def __setattr__(self, key, value):
|
|
self._validate_value(key, value)
|
|
common = object.__getattribute__(self, 'common_keys')
|
|
mapped = object.__getattribute__(self, 'mapped_keys')
|
|
if key in mapped:
|
|
lk, _ = mapped[key]
|
|
if self._legacy:
|
|
if lk is None:
|
|
raise NotImplementedError
|
|
self._legacy[lk] = value
|
|
elif key not in ('commands', 'exports', 'modules', 'namespaces',
|
|
'classifiers'):
|
|
self._data[key] = value
|
|
else:
|
|
# special cases for PEP 459
|
|
d = self._data.setdefault('extensions', {})
|
|
if key == 'commands':
|
|
d['python.commands'] = value
|
|
elif key == 'classifiers':
|
|
d = d.setdefault('python.details', {})
|
|
d[key] = value
|
|
else:
|
|
d = d.setdefault('python.exports', {})
|
|
d[key] = value
|
|
elif key not in common:
|
|
object.__setattr__(self, key, value)
|
|
else:
|
|
if key == 'keywords':
|
|
if isinstance(value, string_types):
|
|
value = value.strip()
|
|
if value:
|
|
value = value.split()
|
|
else:
|
|
value = []
|
|
if self._legacy:
|
|
self._legacy[key] = value
|
|
else:
|
|
self._data[key] = value
|
|
|
|
@property
|
|
def name_and_version(self):
|
|
return _get_name_and_version(self.name, self.version, True)
|
|
|
|
@property
|
|
def provides(self):
|
|
if self._legacy:
|
|
result = self._legacy['Provides-Dist']
|
|
else:
|
|
result = self._data.setdefault('provides', [])
|
|
s = '%s (%s)' % (self.name, self.version)
|
|
if s not in result:
|
|
result.append(s)
|
|
return result
|
|
|
|
@provides.setter
|
|
def provides(self, value):
|
|
if self._legacy:
|
|
self._legacy['Provides-Dist'] = value
|
|
else:
|
|
self._data['provides'] = value
|
|
|
|
def get_requirements(self, reqts, extras=None, env=None):
|
|
"""
|
|
Base method to get dependencies, given a set of extras
|
|
to satisfy and an optional environment context.
|
|
:param reqts: A list of sometimes-wanted dependencies,
|
|
perhaps dependent on extras and environment.
|
|
:param extras: A list of optional components being requested.
|
|
:param env: An optional environment for marker evaluation.
|
|
"""
|
|
if self._legacy:
|
|
result = reqts
|
|
else:
|
|
result = []
|
|
extras = get_extras(extras or [], self.extras)
|
|
for d in reqts:
|
|
if 'extra' not in d and 'environment' not in d:
|
|
# unconditional
|
|
include = True
|
|
else:
|
|
if 'extra' not in d:
|
|
# Not extra-dependent - only environment-dependent
|
|
include = True
|
|
else:
|
|
include = d.get('extra') in extras
|
|
if include:
|
|
# Not excluded because of extras, check environment
|
|
marker = d.get('environment')
|
|
if marker:
|
|
include = interpret(marker, env)
|
|
if include:
|
|
result.extend(d['requires'])
|
|
for key in ('build', 'dev', 'test'):
|
|
e = ':%s:' % key
|
|
if e in extras:
|
|
extras.remove(e)
|
|
# A recursive call, but it should terminate since 'test'
|
|
# has been removed from the extras
|
|
reqts = self._data.get('%s_requires' % key, [])
|
|
result.extend(self.get_requirements(reqts, extras=extras,
|
|
env=env))
|
|
return result
|
|
|
|
@property
|
|
def dictionary(self):
|
|
if self._legacy:
|
|
return self._from_legacy()
|
|
return self._data
|
|
|
|
@property
|
|
def dependencies(self):
|
|
if self._legacy:
|
|
raise NotImplementedError
|
|
else:
|
|
return extract_by_key(self._data, self.DEPENDENCY_KEYS)
|
|
|
|
@dependencies.setter
|
|
def dependencies(self, value):
|
|
if self._legacy:
|
|
raise NotImplementedError
|
|
else:
|
|
self._data.update(value)
|
|
|
|
def _validate_mapping(self, mapping, scheme):
|
|
if mapping.get('metadata_version') != self.METADATA_VERSION:
|
|
raise MetadataUnrecognizedVersionError()
|
|
missing = []
|
|
for key, exclusions in self.MANDATORY_KEYS.items():
|
|
if key not in mapping:
|
|
if scheme not in exclusions:
|
|
missing.append(key)
|
|
if missing:
|
|
msg = 'Missing metadata items: %s' % ', '.join(missing)
|
|
raise MetadataMissingError(msg)
|
|
for k, v in mapping.items():
|
|
self._validate_value(k, v, scheme)
|
|
|
|
def validate(self):
|
|
if self._legacy:
|
|
missing, warnings = self._legacy.check(True)
|
|
if missing or warnings:
|
|
logger.warning('Metadata: missing: %s, warnings: %s',
|
|
missing, warnings)
|
|
else:
|
|
self._validate_mapping(self._data, self.scheme)
|
|
|
|
def todict(self):
|
|
if self._legacy:
|
|
return self._legacy.todict(True)
|
|
else:
|
|
result = extract_by_key(self._data, self.INDEX_KEYS)
|
|
return result
|
|
|
|
def _from_legacy(self):
|
|
assert self._legacy and not self._data
|
|
result = {
|
|
'metadata_version': self.METADATA_VERSION,
|
|
'generator': self.GENERATOR,
|
|
}
|
|
lmd = self._legacy.todict(True) # skip missing ones
|
|
for k in ('name', 'version', 'license', 'summary', 'description',
|
|
'classifier'):
|
|
if k in lmd:
|
|
if k == 'classifier':
|
|
nk = 'classifiers'
|
|
else:
|
|
nk = k
|
|
result[nk] = lmd[k]
|
|
kw = lmd.get('Keywords', [])
|
|
if kw == ['']:
|
|
kw = []
|
|
result['keywords'] = kw
|
|
keys = (('requires_dist', 'run_requires'),
|
|
('setup_requires_dist', 'build_requires'))
|
|
for ok, nk in keys:
|
|
if ok in lmd and lmd[ok]:
|
|
result[nk] = [{'requires': lmd[ok]}]
|
|
result['provides'] = self.provides
|
|
author = {}
|
|
maintainer = {}
|
|
return result
|
|
|
|
LEGACY_MAPPING = {
|
|
'name': 'Name',
|
|
'version': 'Version',
|
|
('extensions', 'python.details', 'license'): 'License',
|
|
'summary': 'Summary',
|
|
'description': 'Description',
|
|
('extensions', 'python.project', 'project_urls', 'Home'): 'Home-page',
|
|
('extensions', 'python.project', 'contacts', 0, 'name'): 'Author',
|
|
('extensions', 'python.project', 'contacts', 0, 'email'): 'Author-email',
|
|
'source_url': 'Download-URL',
|
|
('extensions', 'python.details', 'classifiers'): 'Classifier',
|
|
}
|
|
|
|
def _to_legacy(self):
|
|
def process_entries(entries):
|
|
reqts = set()
|
|
for e in entries:
|
|
extra = e.get('extra')
|
|
env = e.get('environment')
|
|
rlist = e['requires']
|
|
for r in rlist:
|
|
if not env and not extra:
|
|
reqts.add(r)
|
|
else:
|
|
marker = ''
|
|
if extra:
|
|
marker = 'extra == "%s"' % extra
|
|
if env:
|
|
if marker:
|
|
marker = '(%s) and %s' % (env, marker)
|
|
else:
|
|
marker = env
|
|
reqts.add(';'.join((r, marker)))
|
|
return reqts
|
|
|
|
assert self._data and not self._legacy
|
|
result = LegacyMetadata()
|
|
nmd = self._data
|
|
# import pdb; pdb.set_trace()
|
|
for nk, ok in self.LEGACY_MAPPING.items():
|
|
if not isinstance(nk, tuple):
|
|
if nk in nmd:
|
|
result[ok] = nmd[nk]
|
|
else:
|
|
d = nmd
|
|
found = True
|
|
for k in nk:
|
|
try:
|
|
d = d[k]
|
|
except (KeyError, IndexError):
|
|
found = False
|
|
break
|
|
if found:
|
|
result[ok] = d
|
|
r1 = process_entries(self.run_requires + self.meta_requires)
|
|
r2 = process_entries(self.build_requires + self.dev_requires)
|
|
if self.extras:
|
|
result['Provides-Extra'] = sorted(self.extras)
|
|
result['Requires-Dist'] = sorted(r1)
|
|
result['Setup-Requires-Dist'] = sorted(r2)
|
|
# TODO: any other fields wanted
|
|
return result
|
|
|
|
def write(self, path=None, fileobj=None, legacy=False, skip_unknown=True):
|
|
if [path, fileobj].count(None) != 1:
|
|
raise ValueError('Exactly one of path and fileobj is needed')
|
|
self.validate()
|
|
if legacy:
|
|
if self._legacy:
|
|
legacy_md = self._legacy
|
|
else:
|
|
legacy_md = self._to_legacy()
|
|
if path:
|
|
legacy_md.write(path, skip_unknown=skip_unknown)
|
|
else:
|
|
legacy_md.write_file(fileobj, skip_unknown=skip_unknown)
|
|
else:
|
|
if self._legacy:
|
|
d = self._from_legacy()
|
|
else:
|
|
d = self._data
|
|
if fileobj:
|
|
json.dump(d, fileobj, ensure_ascii=True, indent=2,
|
|
sort_keys=True)
|
|
else:
|
|
with codecs.open(path, 'w', 'utf-8') as f:
|
|
json.dump(d, f, ensure_ascii=True, indent=2,
|
|
sort_keys=True)
|
|
|
|
def add_requirements(self, requirements):
|
|
if self._legacy:
|
|
self._legacy.add_requirements(requirements)
|
|
else:
|
|
run_requires = self._data.setdefault('run_requires', [])
|
|
always = None
|
|
for entry in run_requires:
|
|
if 'environment' not in entry and 'extra' not in entry:
|
|
always = entry
|
|
break
|
|
if always is None:
|
|
always = { 'requires': requirements }
|
|
run_requires.insert(0, always)
|
|
else:
|
|
rset = set(always['requires']) | set(requirements)
|
|
always['requires'] = sorted(rset)
|
|
|
|
def __repr__(self):
|
|
name = self.name or '(no name)'
|
|
version = self.version or 'no version'
|
|
return '<%s %s %s (%s)>' % (self.__class__.__name__,
|
|
self.metadata_version, name, version)
|