codekingpro/portable-devtools
114k
1# -*- coding: utf-8 -*-2#3# Copyright (C) 2012 The Python Software Foundation.4# See LICENSE.txt and CONTRIBUTORS.txt.5#6"""Implementation of the Metadata for Python packages PEPs.7 8Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1 and 2.2).9"""10from __future__ import unicode_literals11 12import codecs13from email import message_from_file14import json15import logging16import re17 18 19from . import DistlibException, __version__20from .compat import StringIO, string_types, text_type21from .markers import interpret22from .util import extract_by_key, get_extras23from .version import get_scheme, PEP440_VERSION_RE24 25logger = logging.getLogger(__name__)26 27 28class MetadataMissingError(DistlibException):29 """A required metadata is missing"""30 31 32class MetadataConflictError(DistlibException):33 """Attempt to read or write metadata fields that are conflictual."""34 35 36class MetadataUnrecognizedVersionError(DistlibException):37 """Unknown metadata version number."""38 39 40class MetadataInvalidError(DistlibException):41 """A metadata value is invalid"""42 43# public API of this module44__all__ = ['Metadata', 'PKG_INFO_ENCODING', 'PKG_INFO_PREFERRED_VERSION']45 46# Encoding used for the PKG-INFO files47PKG_INFO_ENCODING = 'utf-8'48 49# preferred version. Hopefully will be changed50# to 1.2 once PEP 345 is supported everywhere51PKG_INFO_PREFERRED_VERSION = '1.1'52 53_LINE_PREFIX_1_2 = re.compile('\n \\|')54_LINE_PREFIX_PRE_1_2 = re.compile('\n ')55_241_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',56 'Summary', 'Description',57 'Keywords', 'Home-page', 'Author', 'Author-email',58 'License')59 60_314_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',61 'Supported-Platform', 'Summary', 'Description',62 'Keywords', 'Home-page', 'Author', 'Author-email',63 'License', 'Classifier', 'Download-URL', 'Obsoletes',64 'Provides', 'Requires')65 66_314_MARKERS = ('Obsoletes', 'Provides', 'Requires', 'Classifier',67 'Download-URL')68 69_345_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',70 'Supported-Platform', 'Summary', 'Description',71 'Keywords', 'Home-page', 'Author', 'Author-email',72 'Maintainer', 'Maintainer-email', 'License',73 'Classifier', 'Download-URL', 'Obsoletes-Dist',74 'Project-URL', 'Provides-Dist', 'Requires-Dist',75 'Requires-Python', 'Requires-External')76 77_345_MARKERS = ('Provides-Dist', 'Requires-Dist', 'Requires-Python',78 'Obsoletes-Dist', 'Requires-External', 'Maintainer',79 'Maintainer-email', 'Project-URL')80 81_426_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',82 'Supported-Platform', 'Summary', 'Description',83 'Keywords', 'Home-page', 'Author', 'Author-email',84 'Maintainer', 'Maintainer-email', 'License',85 'Classifier', 'Download-URL', 'Obsoletes-Dist',86 'Project-URL', 'Provides-Dist', 'Requires-Dist',87 'Requires-Python', 'Requires-External', 'Private-Version',88 'Obsoleted-By', 'Setup-Requires-Dist', 'Extension',89 'Provides-Extra')90 91_426_MARKERS = ('Private-Version', 'Provides-Extra', 'Obsoleted-By',92 'Setup-Requires-Dist', 'Extension')93 94# See issue #106: Sometimes 'Requires' and 'Provides' occur wrongly in95# the metadata. Include them in the tuple literal below to allow them96# (for now).97# Ditto for Obsoletes - see issue #140.98_566_FIELDS = _426_FIELDS + ('Description-Content-Type',99 'Requires', 'Provides', 'Obsoletes')100 101_566_MARKERS = ('Description-Content-Type',)102 103_643_MARKERS = ('Dynamic', 'License-File')104 105_643_FIELDS = _566_FIELDS + _643_MARKERS106 107_ALL_FIELDS = set()108_ALL_FIELDS.update(_241_FIELDS)109_ALL_FIELDS.update(_314_FIELDS)110_ALL_FIELDS.update(_345_FIELDS)111_ALL_FIELDS.update(_426_FIELDS)112_ALL_FIELDS.update(_566_FIELDS)113_ALL_FIELDS.update(_643_FIELDS)114 115EXTRA_RE = re.compile(r'''extra\s*==\s*("([^"]+)"|'([^']+)')''')116 117 118def _version2fieldlist(version):119 if version == '1.0':120 return _241_FIELDS121 elif version == '1.1':122 return _314_FIELDS123 elif version == '1.2':124 return _345_FIELDS125 elif version in ('1.3', '2.1'):126 # avoid adding field names if already there127 return _345_FIELDS + tuple(f for f in _566_FIELDS if f not in _345_FIELDS)128 elif version == '2.0':129 raise ValueError('Metadata 2.0 is withdrawn and not supported')130 # return _426_FIELDS131 elif version == '2.2':132 return _643_FIELDS133 raise MetadataUnrecognizedVersionError(version)134 135 136def _best_version(fields):137 """Detect the best version depending on the fields used."""138 def _has_marker(keys, markers):139 return any(marker in keys for marker in markers)140 141 keys = [key for key, value in fields.items() if value not in ([], 'UNKNOWN', None)]142 possible_versions = ['1.0', '1.1', '1.2', '1.3', '2.1', '2.2'] # 2.0 removed143 144 # first let's try to see if a field is not part of one of the version145 for key in keys:146 if key not in _241_FIELDS and '1.0' in possible_versions:147 possible_versions.remove('1.0')148 logger.debug('Removed 1.0 due to %s', key)149 if key not in _314_FIELDS and '1.1' in possible_versions:150 possible_versions.remove('1.1')151 logger.debug('Removed 1.1 due to %s', key)152 if key not in _345_FIELDS and '1.2' in possible_versions:153 possible_versions.remove('1.2')154 logger.debug('Removed 1.2 due to %s', key)155 if key not in _566_FIELDS and '1.3' in possible_versions:156 possible_versions.remove('1.3')157 logger.debug('Removed 1.3 due to %s', key)158 if key not in _566_FIELDS and '2.1' in possible_versions:159 if key != 'Description': # In 2.1, description allowed after headers160 possible_versions.remove('2.1')161 logger.debug('Removed 2.1 due to %s', key)162 if key not in _643_FIELDS and '2.2' in possible_versions:163 possible_versions.remove('2.2')164 logger.debug('Removed 2.2 due to %s', key)165 # if key not in _426_FIELDS and '2.0' in possible_versions:166 # possible_versions.remove('2.0')167 # logger.debug('Removed 2.0 due to %s', key)168 169 # possible_version contains qualified versions170 if len(possible_versions) == 1:171 return possible_versions[0] # found !172 elif len(possible_versions) == 0:173 logger.debug('Out of options - unknown metadata set: %s', fields)174 raise MetadataConflictError('Unknown metadata set')175 176 # let's see if one unique marker is found177 is_1_1 = '1.1' in possible_versions and _has_marker(keys, _314_MARKERS)178 is_1_2 = '1.2' in possible_versions and _has_marker(keys, _345_MARKERS)179 is_2_1 = '2.1' in possible_versions and _has_marker(keys, _566_MARKERS)180 # is_2_0 = '2.0' in possible_versions and _has_marker(keys, _426_MARKERS)181 is_2_2 = '2.2' in possible_versions and _has_marker(keys, _643_MARKERS)182 if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_2) > 1:183 raise MetadataConflictError('You used incompatible 1.1/1.2/2.1/2.2 fields')184 185 # we have the choice, 1.0, or 1.2, 2.1 or 2.2186 # - 1.0 has a broken Summary field but works with all tools187 # - 1.1 is to avoid188 # - 1.2 fixes Summary but has little adoption189 # - 2.1 adds more features190 # - 2.2 is the latest191 if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_2:192 # we couldn't find any specific marker193 if PKG_INFO_PREFERRED_VERSION in possible_versions:194 return PKG_INFO_PREFERRED_VERSION195 if is_1_1:196 return '1.1'197 if is_1_2:198 return '1.2'199 if is_2_1:200 return '2.1'201 # if is_2_2:202 # return '2.2'203 204 return '2.2'205 206# This follows the rules about transforming keys as described in207# https://www.python.org/dev/peps/pep-0566/#id17208_ATTR2FIELD = {209 name.lower().replace("-", "_"): name for name in _ALL_FIELDS210}211_FIELD2ATTR = {field: attr for attr, field in _ATTR2FIELD.items()}212 213_PREDICATE_FIELDS = ('Requires-Dist', 'Obsoletes-Dist', 'Provides-Dist')214_VERSIONS_FIELDS = ('Requires-Python',)215_VERSION_FIELDS = ('Version',)216_LISTFIELDS = ('Platform', 'Classifier', 'Obsoletes',217 'Requires', 'Provides', 'Obsoletes-Dist',218 'Provides-Dist', 'Requires-Dist', 'Requires-External',219 'Project-URL', 'Supported-Platform', 'Setup-Requires-Dist',220 'Provides-Extra', 'Extension', 'License-File')221_LISTTUPLEFIELDS = ('Project-URL',)222 223_ELEMENTSFIELD = ('Keywords',)224 225_UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description')226 227_MISSING = object()228 229_FILESAFE = re.compile('[^A-Za-z0-9.]+')230 231 232def _get_name_and_version(name, version, for_filename=False):233 """Return the distribution name with version.234 235 If for_filename is true, return a filename-escaped form."""236 if for_filename:237 # For both name and version any runs of non-alphanumeric or '.'238 # characters are replaced with a single '-'. Additionally any239 # spaces in the version string become '.'240 name = _FILESAFE.sub('-', name)241 version = _FILESAFE.sub('-', version.replace(' ', '.'))242 return '%s-%s' % (name, version)243 244 245class LegacyMetadata(object):246 """The legacy metadata of a release.247 248 Supports versions 1.0, 1.1, 1.2, 2.0 and 1.3/2.1 (auto-detected). You can249 instantiate the class with one of these arguments (or none):250 - *path*, the path to a metadata file251 - *fileobj* give a file-like object with metadata as content252 - *mapping* is a dict-like object253 - *scheme* is a version scheme name254 """255 # TODO document the mapping API and UNKNOWN default key256 257 def __init__(self, path=None, fileobj=None, mapping=None,258 scheme='default'):259 if [path, fileobj, mapping].count(None) < 2:260 raise TypeError('path, fileobj and mapping are exclusive')261 self._fields = {}262 self.requires_files = []263 self._dependencies = None264 self.scheme = scheme265 if path is not None:266 self.read(path)267 elif fileobj is not None:268 self.read_file(fileobj)269 elif mapping is not None:270 self.update(mapping)271 self.set_metadata_version()272 273 def set_metadata_version(self):274 self._fields['Metadata-Version'] = _best_version(self._fields)275 276 def _write_field(self, fileobj, name, value):277 fileobj.write('%s: %s\n' % (name, value))278 279 def __getitem__(self, name):280 return self.get(name)281 282 def __setitem__(self, name, value):283 return self.set(name, value)284 285 def __delitem__(self, name):286 field_name = self._convert_name(name)287 try:288 del self._fields[field_name]289 except KeyError:290 raise KeyError(name)291 292 def __contains__(self, name):293 return (name in self._fields or294 self._convert_name(name) in self._fields)295 296 def _convert_name(self, name):297 if name in _ALL_FIELDS:298 return name299 name = name.replace('-', '_').lower()300 return _ATTR2FIELD.get(name, name)301 302 def _default_value(self, name):303 if name in _LISTFIELDS or name in _ELEMENTSFIELD:304 return []305 return 'UNKNOWN'306 307 def _remove_line_prefix(self, value):308 if self.metadata_version in ('1.0', '1.1'):309 return _LINE_PREFIX_PRE_1_2.sub('\n', value)310 else:311 return _LINE_PREFIX_1_2.sub('\n', value)312 313 def __getattr__(self, name):314 if name in _ATTR2FIELD:315 return self[name]316 raise AttributeError(name)317 318 #319 # Public API320 #321 322# dependencies = property(_get_dependencies, _set_dependencies)323 324 def get_fullname(self, filesafe=False):325 """Return the distribution name with version.326 327 If filesafe is true, return a filename-escaped form."""328 return _get_name_and_version(self['Name'], self['Version'], filesafe)329 330 def is_field(self, name):331 """return True if name is a valid metadata key"""332 name = self._convert_name(name)333 return name in _ALL_FIELDS334 335 def is_multi_field(self, name):336 name = self._convert_name(name)337 return name in _LISTFIELDS338 339 def read(self, filepath):340 """Read the metadata values from a file path."""341 fp = codecs.open(filepath, 'r', encoding='utf-8')342 try:343 self.read_file(fp)344 finally:345 fp.close()346 347 def read_file(self, fileob):348 """Read the metadata values from a file object."""349 msg = message_from_file(fileob)350 self._fields['Metadata-Version'] = msg['metadata-version']351 352 # When reading, get all the fields we can353 for field in _ALL_FIELDS:354 if field not in msg:355 continue356 if field in _LISTFIELDS:357 # we can have multiple lines358 values = msg.get_all(field)359 if field in _LISTTUPLEFIELDS and values is not None:360 values = [tuple(value.split(',')) for value in values]361 self.set(field, values)362 else:363 # single line364 value = msg[field]365 if value is not None and value != 'UNKNOWN':366 self.set(field, value)367 368 # PEP 566 specifies that the body be used for the description, if369 # available370 body = msg.get_payload()371 self["Description"] = body if body else self["Description"]372 # logger.debug('Attempting to set metadata for %s', self)373 # self.set_metadata_version()374 375 def write(self, filepath, skip_unknown=False):376 """Write the metadata fields to filepath."""377 fp = codecs.open(filepath, 'w', encoding='utf-8')378 try:379 self.write_file(fp, skip_unknown)380 finally:381 fp.close()382 383 def write_file(self, fileobject, skip_unknown=False):384 """Write the PKG-INFO format data to a file object."""385 self.set_metadata_version()386 387 for field in _version2fieldlist(self['Metadata-Version']):388 values = self.get(field)389 if skip_unknown and values in ('UNKNOWN', [], ['UNKNOWN']):390 continue391 if field in _ELEMENTSFIELD:392 self._write_field(fileobject, field, ','.join(values))393 continue394 if field not in _LISTFIELDS:395 if field == 'Description':396 if self.metadata_version in ('1.0', '1.1'):397 values = values.replace('\n', '\n ')398 else:399 values = values.replace('\n', '\n |')400 values = [values]401 402 if field in _LISTTUPLEFIELDS:403 values = [','.join(value) for value in values]404 405 for value in values:406 self._write_field(fileobject, field, value)407 408 def update(self, other=None, **kwargs):409 """Set metadata values from the given iterable `other` and kwargs.410 411 Behavior is like `dict.update`: If `other` has a ``keys`` method,412 they are looped over and ``self[key]`` is assigned ``other[key]``.413 Else, ``other`` is an iterable of ``(key, value)`` iterables.414 415 Keys that don't match a metadata field or that have an empty value are416 dropped.417 """418 def _set(key, value):419 if key in _ATTR2FIELD and value:420 self.set(self._convert_name(key), value)421 422 if not other:423 # other is None or empty container424 pass425 elif hasattr(other, 'keys'):426 for k in other.keys():427 _set(k, other[k])428 else:429 for k, v in other:430 _set(k, v)431 432 if kwargs:433 for k, v in kwargs.items():434 _set(k, v)435 436 def set(self, name, value):437 """Control then set a metadata field."""438 name = self._convert_name(name)439 440 if ((name in _ELEMENTSFIELD or name == 'Platform') and441 not isinstance(value, (list, tuple))):442 if isinstance(value, string_types):443 value = [v.strip() for v in value.split(',')]444 else:445 value = []446 elif (name in _LISTFIELDS and447 not isinstance(value, (list, tuple))):448 if isinstance(value, string_types):449 value = [value]450 else:451 value = []452 453 if logger.isEnabledFor(logging.WARNING):454 project_name = self['Name']455 456 scheme = get_scheme(self.scheme)457 if name in _PREDICATE_FIELDS and value is not None:458 for v in value:459 # check that the values are valid460 if not scheme.is_valid_matcher(v.split(';')[0]):461 logger.warning(462 "'%s': '%s' is not valid (field '%s')",463 project_name, v, name)464 # FIXME this rejects UNKNOWN, is that right?465 elif name in _VERSIONS_FIELDS and value is not None:466 if not scheme.is_valid_constraint_list(value):467 logger.warning("'%s': '%s' is not a valid version (field '%s')",468 project_name, value, name)469 elif name in _VERSION_FIELDS and value is not None:470 if not scheme.is_valid_version(value):471 logger.warning("'%s': '%s' is not a valid version (field '%s')",472 project_name, value, name)473 474 if name in _UNICODEFIELDS:475 if name == 'Description':476 value = self._remove_line_prefix(value)477 478 self._fields[name] = value479 480 def get(self, name, default=_MISSING):481 """Get a metadata field."""482 name = self._convert_name(name)483 if name not in self._fields:484 if default is _MISSING:485 default = self._default_value(name)486 return default487 if name in _UNICODEFIELDS:488 value = self._fields[name]489 return value490 elif name in _LISTFIELDS:491 value = self._fields[name]492 if value is None:493 return []494 res = []495 for val in value:496 if name not in _LISTTUPLEFIELDS:497 res.append(val)498 else:499 # That's for Project-URL500 res.append((val[0], val[1]))501 return res502 503 elif name in _ELEMENTSFIELD:504 value = self._fields[name]505 if isinstance(value, string_types):506 return value.split(',')507 return self._fields[name]508 509 def check(self, strict=False):510 """Check if the metadata is compliant. If strict is True then raise if511 no Name or Version are provided"""512 self.set_metadata_version()513 514 # XXX should check the versions (if the file was loaded)515 missing, warnings = [], []516 517 for attr in ('Name', 'Version'): # required by PEP 345518 if attr not in self:519 missing.append(attr)520 521 if strict and missing != []:522 msg = 'missing required metadata: %s' % ', '.join(missing)523 raise MetadataMissingError(msg)524 525 for attr in ('Home-page', 'Author'):526 if attr not in self:527 missing.append(attr)528 529 # checking metadata 1.2 (XXX needs to check 1.1, 1.0)530 if self['Metadata-Version'] != '1.2':531 return missing, warnings532 533 scheme = get_scheme(self.scheme)534 535 def are_valid_constraints(value):536 for v in value:537 if not scheme.is_valid_matcher(v.split(';')[0]):538 return False539 return True540 541 for fields, controller in ((_PREDICATE_FIELDS, are_valid_constraints),542 (_VERSIONS_FIELDS,543 scheme.is_valid_constraint_list),544 (_VERSION_FIELDS,545 scheme.is_valid_version)):546 for field in fields:547 value = self.get(field, None)548 if value is not None and not controller(value):549 warnings.append("Wrong value for '%s': %s" % (field, value))550 551 return missing, warnings552 553 def todict(self, skip_missing=False):554 """Return fields as a dict.555 556 Field names will be converted to use the underscore-lowercase style557 instead of hyphen-mixed case (i.e. home_page instead of Home-page).558 This is as per https://www.python.org/dev/peps/pep-0566/#id17.559 """560 self.set_metadata_version()561 562 fields = _version2fieldlist(self['Metadata-Version'])563 564 data = {}565 566 for field_name in fields:567 if not skip_missing or field_name in self._fields:568 key = _FIELD2ATTR[field_name]569 if key != 'project_url':570 data[key] = self[field_name]571 else:572 data[key] = [','.join(u) for u in self[field_name]]573 574 return data575 576 def add_requirements(self, requirements):577 if self['Metadata-Version'] == '1.1':578 # we can't have 1.1 metadata *and* Setuptools requires579 for field in ('Obsoletes', 'Requires', 'Provides'):580 if field in self:581 del self[field]582 self['Requires-Dist'] += requirements583 584 # Mapping API585 # TODO could add iter* variants586 587 def keys(self):588 return list(_version2fieldlist(self['Metadata-Version']))589 590 def __iter__(self):591 for key in self.keys():592 yield key593 594 def values(self):595 return [self[key] for key in self.keys()]596 597 def items(self):598 return [(key, self[key]) for key in self.keys()]599 600 def __repr__(self):601 return '<%s %s %s>' % (self.__class__.__name__, self.name,602 self.version)603 604 605METADATA_FILENAME = 'pydist.json'606WHEEL_METADATA_FILENAME = 'metadata.json'607LEGACY_METADATA_FILENAME = 'METADATA'608 609 610class Metadata(object):611 """612 The metadata of a release. This implementation uses 2.1613 metadata where possible. If not possible, it wraps a LegacyMetadata614 instance which handles the key-value metadata format.615 """616 617 METADATA_VERSION_MATCHER = re.compile(r'^\d+(\.\d+)*$')618 619 NAME_MATCHER = re.compile('^[0-9A-Z]([0-9A-Z_.-]*[0-9A-Z])?$', re.I)620 621 FIELDNAME_MATCHER = re.compile('^[A-Z]([0-9A-Z-]*[0-9A-Z])?$', re.I)622 623 VERSION_MATCHER = PEP440_VERSION_RE624 625 SUMMARY_MATCHER = re.compile('.{1,2047}')626 627 METADATA_VERSION = '2.0'628 629 GENERATOR = 'distlib (%s)' % __version__630 631 MANDATORY_KEYS = {632 'name': (),633 'version': (),634 'summary': ('legacy',),635 }636 637 INDEX_KEYS = ('name version license summary description author '638 'author_email keywords platform home_page classifiers '639 'download_url')640 641 DEPENDENCY_KEYS = ('extras run_requires test_requires build_requires '642 'dev_requires provides meta_requires obsoleted_by '643 'supports_environments')644 645 SYNTAX_VALIDATORS = {646 'metadata_version': (METADATA_VERSION_MATCHER, ()),647 'name': (NAME_MATCHER, ('legacy',)),648 'version': (VERSION_MATCHER, ('legacy',)),649 'summary': (SUMMARY_MATCHER, ('legacy',)),650 'dynamic': (FIELDNAME_MATCHER, ('legacy',)),651 }652 653 __slots__ = ('_legacy', '_data', 'scheme')654 655 def __init__(self, path=None, fileobj=None, mapping=None,656 scheme='default'):657 if [path, fileobj, mapping].count(None) < 2:658 raise TypeError('path, fileobj and mapping are exclusive')659 self._legacy = None660 self._data = None661 self.scheme = scheme662 #import pdb; pdb.set_trace()663 if mapping is not None:664 try:665 self._validate_mapping(mapping, scheme)666 self._data = mapping667 except MetadataUnrecognizedVersionError:668 self._legacy = LegacyMetadata(mapping=mapping, scheme=scheme)669 self.validate()670 else:671 data = None672 if path:673 with open(path, 'rb') as f:674 data = f.read()675 elif fileobj:676 data = fileobj.read()677 if data is None:678 # Initialised with no args - to be added679 self._data = {680 'metadata_version': self.METADATA_VERSION,681 'generator': self.GENERATOR,682 }683 else:684 if not isinstance(data, text_type):685 data = data.decode('utf-8')686 try:687 self._data = json.loads(data)688 self._validate_mapping(self._data, scheme)689 except ValueError:690 # Note: MetadataUnrecognizedVersionError does not691 # inherit from ValueError (it's a DistlibException,692 # which should not inherit from ValueError).693 # The ValueError comes from the json.load - if that694 # succeeds and we get a validation error, we want695 # that to propagate696 self._legacy = LegacyMetadata(fileobj=StringIO(data),697 scheme=scheme)698 self.validate()699 700 common_keys = set(('name', 'version', 'license', 'keywords', 'summary'))701 702 none_list = (None, list)703 none_dict = (None, dict)704 705 mapped_keys = {706 'run_requires': ('Requires-Dist', list),707 'build_requires': ('Setup-Requires-Dist', list),708 'dev_requires': none_list,709 'test_requires': none_list,710 'meta_requires': none_list,711 'extras': ('Provides-Extra', list),712 'modules': none_list,713 'namespaces': none_list,714 'exports': none_dict,715 'commands': none_dict,716 'classifiers': ('Classifier', list),717 'source_url': ('Download-URL', None),718 'metadata_version': ('Metadata-Version', None),719 }720 721 del none_list, none_dict722 723 def __getattribute__(self, key):724 common = object.__getattribute__(self, 'common_keys')725 mapped = object.__getattribute__(self, 'mapped_keys')726 if key in mapped:727 lk, maker = mapped[key]728 if self._legacy:729 if lk is None:730 result = None if maker is None else maker()731 else:732 result = self._legacy.get(lk)733 else:734 value = None if maker is None else maker()735 if key not in ('commands', 'exports', 'modules', 'namespaces',736 'classifiers'):737 result = self._data.get(key, value)738 else:739 # special cases for PEP 459740 sentinel = object()741 result = sentinel742 d = self._data.get('extensions')743 if d:744 if key == 'commands':745 result = d.get('python.commands', value)746 elif key == 'classifiers':747 d = d.get('python.details')748 if d:749 result = d.get(key, value)750 else:751 d = d.get('python.exports')752 if not d:753 d = self._data.get('python.exports')754 if d:755 result = d.get(key, value)756 if result is sentinel:757 result = value758 elif key not in common:759 result = object.__getattribute__(self, key)760 elif self._legacy:761 result = self._legacy.get(key)762 else:763 result = self._data.get(key)764 return result765 766 def _validate_value(self, key, value, scheme=None):767 if key in self.SYNTAX_VALIDATORS:768 pattern, exclusions = self.SYNTAX_VALIDATORS[key]769 if (scheme or self.scheme) not in exclusions:770 m = pattern.match(value)771 if not m:772 raise MetadataInvalidError("'%s' is an invalid value for "773 "the '%s' property" % (value,774 key))775 776 def __setattr__(self, key, value):777 self._validate_value(key, value)778 common = object.__getattribute__(self, 'common_keys')779 mapped = object.__getattribute__(self, 'mapped_keys')780 if key in mapped:781 lk, _ = mapped[key]782 if self._legacy:783 if lk is None:784 raise NotImplementedError785 self._legacy[lk] = value786 elif key not in ('commands', 'exports', 'modules', 'namespaces',787 'classifiers'):788 self._data[key] = value789 else:790 # special cases for PEP 459791 d = self._data.setdefault('extensions', {})792 if key == 'commands':793 d['python.commands'] = value794 elif key == 'classifiers':795 d = d.setdefault('python.details', {})796 d[key] = value797 else:798 d = d.setdefault('python.exports', {})799 d[key] = value800 elif key not in common:801 object.__setattr__(self, key, value)802 else:803 if key == 'keywords':804 if isinstance(value, string_types):805 value = value.strip()806 if value:807 value = value.split()808 else:809 value = []810 if self._legacy:811 self._legacy[key] = value812 else:813 self._data[key] = value814 815 @property816 def name_and_version(self):817 return _get_name_and_version(self.name, self.version, True)818 819 @property820 def provides(self):821 if self._legacy:822 result = self._legacy['Provides-Dist']823 else:824 result = self._data.setdefault('provides', [])825 s = '%s (%s)' % (self.name, self.version)826 if s not in result:827 result.append(s)828 return result829 830 @provides.setter831 def provides(self, value):832 if self._legacy:833 self._legacy['Provides-Dist'] = value834 else:835 self._data['provides'] = value836 837 def get_requirements(self, reqts, extras=None, env=None):838 """839 Base method to get dependencies, given a set of extras840 to satisfy and an optional environment context.841 :param reqts: A list of sometimes-wanted dependencies,842 perhaps dependent on extras and environment.843 :param extras: A list of optional components being requested.844 :param env: An optional environment for marker evaluation.845 """846 if self._legacy:847 result = reqts848 else:849 result = []850 extras = get_extras(extras or [], self.extras)851 for d in reqts:852 if 'extra' not in d and 'environment' not in d:853 # unconditional854 include = True855 else:856 if 'extra' not in d:857 # Not extra-dependent - only environment-dependent858 include = True859 else:860 include = d.get('extra') in extras861 if include:862 # Not excluded because of extras, check environment863 marker = d.get('environment')864 if marker:865 include = interpret(marker, env)866 if include:867 result.extend(d['requires'])868 for key in ('build', 'dev', 'test'):869 e = ':%s:' % key870 if e in extras:871 extras.remove(e)872 # A recursive call, but it should terminate since 'test'873 # has been removed from the extras874 reqts = self._data.get('%s_requires' % key, [])875 result.extend(self.get_requirements(reqts, extras=extras,876 env=env))877 return result878 879 @property880 def dictionary(self):881 if self._legacy:882 return self._from_legacy()883 return self._data884 885 @property886 def dependencies(self):887 if self._legacy:888 raise NotImplementedError889 else:890 return extract_by_key(self._data, self.DEPENDENCY_KEYS)891 892 @dependencies.setter893 def dependencies(self, value):894 if self._legacy:895 raise NotImplementedError896 else:897 self._data.update(value)898 899 def _validate_mapping(self, mapping, scheme):900 if mapping.get('metadata_version') != self.METADATA_VERSION:901 raise MetadataUnrecognizedVersionError()902 missing = []903 for key, exclusions in self.MANDATORY_KEYS.items():904 if key not in mapping:905 if scheme not in exclusions:906 missing.append(key)907 if missing:908 msg = 'Missing metadata items: %s' % ', '.join(missing)909 raise MetadataMissingError(msg)910 for k, v in mapping.items():911 self._validate_value(k, v, scheme)912 913 def validate(self):914 if self._legacy:915 missing, warnings = self._legacy.check(True)916 if missing or warnings:917 logger.warning('Metadata: missing: %s, warnings: %s',918 missing, warnings)919 else:920 self._validate_mapping(self._data, self.scheme)921 922 def todict(self):923 if self._legacy:924 return self._legacy.todict(True)925 else:926 result = extract_by_key(self._data, self.INDEX_KEYS)927 return result928 929 def _from_legacy(self):930 assert self._legacy and not self._data931 result = {932 'metadata_version': self.METADATA_VERSION,933 'generator': self.GENERATOR,934 }935 lmd = self._legacy.todict(True) # skip missing ones936 for k in ('name', 'version', 'license', 'summary', 'description',937 'classifier'):938 if k in lmd:939 if k == 'classifier':940 nk = 'classifiers'941 else:942 nk = k943 result[nk] = lmd[k]944 kw = lmd.get('Keywords', [])945 if kw == ['']:946 kw = []947 result['keywords'] = kw948 keys = (('requires_dist', 'run_requires'),949 ('setup_requires_dist', 'build_requires'))950 for ok, nk in keys:951 if ok in lmd and lmd[ok]:952 result[nk] = [{'requires': lmd[ok]}]953 result['provides'] = self.provides954 author = {}955 maintainer = {}956 return result957 958 LEGACY_MAPPING = {959 'name': 'Name',960 'version': 'Version',961 ('extensions', 'python.details', 'license'): 'License',962 'summary': 'Summary',963 'description': 'Description',964 ('extensions', 'python.project', 'project_urls', 'Home'): 'Home-page',965 ('extensions', 'python.project', 'contacts', 0, 'name'): 'Author',966 ('extensions', 'python.project', 'contacts', 0, 'email'): 'Author-email',967 'source_url': 'Download-URL',968 ('extensions', 'python.details', 'classifiers'): 'Classifier',969 }970 971 def _to_legacy(self):972 def process_entries(entries):973 reqts = set()974 for e in entries:975 extra = e.get('extra')976 env = e.get('environment')977 rlist = e['requires']978 for r in rlist:979 if not env and not extra:980 reqts.add(r)981 else:982 marker = ''983 if extra:984 marker = 'extra == "%s"' % extra985 if env:986 if marker:987 marker = '(%s) and %s' % (env, marker)988 else:989 marker = env990 reqts.add(';'.join((r, marker)))991 return reqts992 993 assert self._data and not self._legacy994 result = LegacyMetadata()995 nmd = self._data996 # import pdb; pdb.set_trace()997 for nk, ok in self.LEGACY_MAPPING.items():998 if not isinstance(nk, tuple):999 if nk in nmd:1000 result[ok] = nmd[nk]1001 else:1002 d = nmd1003 found = True1004 for k in nk:1005 try:1006 d = d[k]1007 except (KeyError, IndexError):1008 found = False1009 break1010 if found:1011 result[ok] = d1012 r1 = process_entries(self.run_requires + self.meta_requires)1013 r2 = process_entries(self.build_requires + self.dev_requires)1014 if self.extras:1015 result['Provides-Extra'] = sorted(self.extras)1016 result['Requires-Dist'] = sorted(r1)1017 result['Setup-Requires-Dist'] = sorted(r2)1018 # TODO: any other fields wanted1019 return result1020 1021 def write(self, path=None, fileobj=None, legacy=False, skip_unknown=True):1022 if [path, fileobj].count(None) != 1:1023 raise ValueError('Exactly one of path and fileobj is needed')1024 self.validate()1025 if legacy:1026 if self._legacy:1027 legacy_md = self._legacy1028 else:1029 legacy_md = self._to_legacy()1030 if path:1031 legacy_md.write(path, skip_unknown=skip_unknown)1032 else:1033 legacy_md.write_file(fileobj, skip_unknown=skip_unknown)1034 else:1035 if self._legacy:1036 d = self._from_legacy()1037 else:1038 d = self._data1039 if fileobj:1040 json.dump(d, fileobj, ensure_ascii=True, indent=2,1041 sort_keys=True)1042 else:1043 with codecs.open(path, 'w', 'utf-8') as f:1044 json.dump(d, f, ensure_ascii=True, indent=2,1045 sort_keys=True)1046 1047 def add_requirements(self, requirements):1048 if self._legacy:1049 self._legacy.add_requirements(requirements)1050 else:1051 run_requires = self._data.setdefault('run_requires', [])1052 always = None1053 for entry in run_requires:1054 if 'environment' not in entry and 'extra' not in entry:1055 always = entry1056 break1057 if always is None:1058 always = { 'requires': requirements }1059 run_requires.insert(0, always)1060 else:1061 rset = set(always['requires']) | set(requirements)1062 always['requires'] = sorted(rset)1063 1064 def __repr__(self):1065 name = self.name or '(no name)'1066 version = self.version or 'no version'1067 return '<%s %s %s (%s)>' % (self.__class__.__name__,1068 self.metadata_version, name, version)1069 