Spaces:
Runtime error
Runtime error
# -*- 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 2.2). | |
""" | |
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',) | |
_643_MARKERS = ('Dynamic', 'License-File') | |
_643_FIELDS = _566_FIELDS + _643_MARKERS | |
_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) | |
_ALL_FIELDS.update(_643_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': | |
raise ValueError('Metadata 2.0 is withdrawn and not supported') | |
# return _426_FIELDS | |
elif version == '2.2': | |
return _643_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.1', '2.2'] # 2.0 removed | |
# 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 _643_FIELDS and '2.2' in possible_versions: | |
possible_versions.remove('2.2') | |
logger.debug('Removed 2.2 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) | |
is_2_2 = '2.2' in possible_versions and _has_marker(keys, _643_MARKERS) | |
if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_2) > 1: | |
raise MetadataConflictError('You used incompatible 1.1/1.2/2.1/2.2 fields') | |
# we have the choice, 1.0, or 1.2, 2.1 or 2.2 | |
# - 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.1 adds more features | |
# - 2.2 is the latest | |
if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_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' | |
if is_1_2: | |
return '1.2' | |
if is_2_1: | |
return '2.1' | |
# if is_2_2: | |
# return '2.2' | |
return '2.2' | |
# 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', 'License-File') | |
_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.1 | |
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) | |
FIELDNAME_MATCHER = re.compile('^[A-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',)), | |
'dynamic': (FIELDNAME_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 | |
def name_and_version(self): | |
return _get_name_and_version(self.name, self.version, True) | |
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 | |
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 | |
def dictionary(self): | |
if self._legacy: | |
return self._from_legacy() | |
return self._data | |
def dependencies(self): | |
if self._legacy: | |
raise NotImplementedError | |
else: | |
return extract_by_key(self._data, self.DEPENDENCY_KEYS) | |
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) | |