Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/distlib/metadata.py: 48%
642 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-25 06:51 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-25 06:51 +0000
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.
8Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1 and 2.2).
9"""
10from __future__ import unicode_literals
12import codecs
13from email import message_from_file
14import json
15import logging
16import re
19from . import DistlibException, __version__
20from .compat import StringIO, string_types, text_type
21from .markers import interpret
22from .util import extract_by_key, get_extras
23from .version import get_scheme, PEP440_VERSION_RE
25logger = logging.getLogger(__name__)
28class MetadataMissingError(DistlibException):
29 """A required metadata is missing"""
32class MetadataConflictError(DistlibException):
33 """Attempt to read or write metadata fields that are conflictual."""
36class MetadataUnrecognizedVersionError(DistlibException):
37 """Unknown metadata version number."""
40class MetadataInvalidError(DistlibException):
41 """A metadata value is invalid"""
43# public API of this module
44__all__ = ['Metadata', 'PKG_INFO_ENCODING', 'PKG_INFO_PREFERRED_VERSION']
46# Encoding used for the PKG-INFO files
47PKG_INFO_ENCODING = 'utf-8'
49# preferred version. Hopefully will be changed
50# to 1.2 once PEP 345 is supported everywhere
51PKG_INFO_PREFERRED_VERSION = '1.1'
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')
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')
66_314_MARKERS = ('Obsoletes', 'Provides', 'Requires', 'Classifier',
67 'Download-URL')
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')
77_345_MARKERS = ('Provides-Dist', 'Requires-Dist', 'Requires-Python',
78 'Obsoletes-Dist', 'Requires-External', 'Maintainer',
79 'Maintainer-email', 'Project-URL')
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')
91_426_MARKERS = ('Private-Version', 'Provides-Extra', 'Obsoleted-By',
92 'Setup-Requires-Dist', 'Extension')
94# See issue #106: Sometimes 'Requires' and 'Provides' occur wrongly in
95# the metadata. Include them in the tuple literal below to allow them
96# (for now).
97# Ditto for Obsoletes - see issue #140.
98_566_FIELDS = _426_FIELDS + ('Description-Content-Type',
99 'Requires', 'Provides', 'Obsoletes')
101_566_MARKERS = ('Description-Content-Type',)
103_643_MARKERS = ('Dynamic', 'License-File')
105_643_FIELDS = _566_FIELDS + _643_MARKERS
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)
115EXTRA_RE = re.compile(r'''extra\s*==\s*("([^"]+)"|'([^']+)')''')
118def _version2fieldlist(version):
119 if version == '1.0':
120 return _241_FIELDS
121 elif version == '1.1':
122 return _314_FIELDS
123 elif version == '1.2':
124 return _345_FIELDS
125 elif version in ('1.3', '2.1'):
126 # avoid adding field names if already there
127 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_FIELDS
131 elif version == '2.2':
132 return _643_FIELDS
133 raise MetadataUnrecognizedVersionError(version)
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)
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 removed
144 # first let's try to see if a field is not part of one of the version
145 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 headers
160 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)
169 # possible_version contains qualified versions
170 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')
176 # let's see if one unique marker is found
177 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')
185 # we have the choice, 1.0, or 1.2, 2.1 or 2.2
186 # - 1.0 has a broken Summary field but works with all tools
187 # - 1.1 is to avoid
188 # - 1.2 fixes Summary but has little adoption
189 # - 2.1 adds more features
190 # - 2.2 is the latest
191 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 marker
193 if PKG_INFO_PREFERRED_VERSION in possible_versions:
194 return PKG_INFO_PREFERRED_VERSION
195 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'
204 return '2.2'
206# This follows the rules about transforming keys as described in
207# https://www.python.org/dev/peps/pep-0566/#id17
208_ATTR2FIELD = {
209 name.lower().replace("-", "_"): name for name in _ALL_FIELDS
210}
211_FIELD2ATTR = {field: attr for attr, field in _ATTR2FIELD.items()}
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',)
223_ELEMENTSFIELD = ('Keywords',)
225_UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description')
227_MISSING = object()
229_FILESAFE = re.compile('[^A-Za-z0-9.]+')
232def _get_name_and_version(name, version, for_filename=False):
233 """Return the distribution name with version.
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 any
239 # spaces in the version string become '.'
240 name = _FILESAFE.sub('-', name)
241 version = _FILESAFE.sub('-', version.replace(' ', '.'))
242 return '%s-%s' % (name, version)
245class LegacyMetadata(object):
246 """The legacy metadata of a release.
248 Supports versions 1.0, 1.1, 1.2, 2.0 and 1.3/2.1 (auto-detected). You can
249 instantiate the class with one of these arguments (or none):
250 - *path*, the path to a metadata file
251 - *fileobj* give a file-like object with metadata as content
252 - *mapping* is a dict-like object
253 - *scheme* is a version scheme name
254 """
255 # TODO document the mapping API and UNKNOWN default key
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 = None
264 self.scheme = scheme
265 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()
273 def set_metadata_version(self):
274 self._fields['Metadata-Version'] = _best_version(self._fields)
276 def _write_field(self, fileobj, name, value):
277 fileobj.write('%s: %s\n' % (name, value))
279 def __getitem__(self, name):
280 return self.get(name)
282 def __setitem__(self, name, value):
283 return self.set(name, value)
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)
292 def __contains__(self, name):
293 return (name in self._fields or
294 self._convert_name(name) in self._fields)
296 def _convert_name(self, name):
297 if name in _ALL_FIELDS:
298 return name
299 name = name.replace('-', '_').lower()
300 return _ATTR2FIELD.get(name, name)
302 def _default_value(self, name):
303 if name in _LISTFIELDS or name in _ELEMENTSFIELD:
304 return []
305 return 'UNKNOWN'
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)
313 def __getattr__(self, name):
314 if name in _ATTR2FIELD:
315 return self[name]
316 raise AttributeError(name)
318 #
319 # Public API
320 #
322# dependencies = property(_get_dependencies, _set_dependencies)
324 def get_fullname(self, filesafe=False):
325 """Return the distribution name with version.
327 If filesafe is true, return a filename-escaped form."""
328 return _get_name_and_version(self['Name'], self['Version'], filesafe)
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_FIELDS
335 def is_multi_field(self, name):
336 name = self._convert_name(name)
337 return name in _LISTFIELDS
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()
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']
352 # When reading, get all the fields we can
353 for field in _ALL_FIELDS:
354 if field not in msg:
355 continue
356 if field in _LISTFIELDS:
357 # we can have multiple lines
358 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 line
364 value = msg[field]
365 if value is not None and value != 'UNKNOWN':
366 self.set(field, value)
368 # PEP 566 specifies that the body be used for the description, if
369 # available
370 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()
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()
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()
387 for field in _version2fieldlist(self['Metadata-Version']):
388 values = self.get(field)
389 if skip_unknown and values in ('UNKNOWN', [], ['UNKNOWN']):
390 continue
391 if field in _ELEMENTSFIELD:
392 self._write_field(fileobject, field, ','.join(values))
393 continue
394 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]
402 if field in _LISTTUPLEFIELDS:
403 values = [','.join(value) for value in values]
405 for value in values:
406 self._write_field(fileobject, field, value)
408 def update(self, other=None, **kwargs):
409 """Set metadata values from the given iterable `other` and kwargs.
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.
415 Keys that don't match a metadata field or that have an empty value are
416 dropped.
417 """
418 def _set(key, value):
419 if key in _ATTR2FIELD and value:
420 self.set(self._convert_name(key), value)
422 if not other:
423 # other is None or empty container
424 pass
425 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)
432 if kwargs:
433 for k, v in kwargs.items():
434 _set(k, v)
436 def set(self, name, value):
437 """Control then set a metadata field."""
438 name = self._convert_name(name)
440 if ((name in _ELEMENTSFIELD or name == 'Platform') and
441 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 and
447 not isinstance(value, (list, tuple))):
448 if isinstance(value, string_types):
449 value = [value]
450 else:
451 value = []
453 if logger.isEnabledFor(logging.WARNING):
454 project_name = self['Name']
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 valid
460 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)
474 if name in _UNICODEFIELDS:
475 if name == 'Description':
476 value = self._remove_line_prefix(value)
478 self._fields[name] = value
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 default
487 if name in _UNICODEFIELDS:
488 value = self._fields[name]
489 return value
490 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-URL
500 res.append((val[0], val[1]))
501 return res
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]
509 def check(self, strict=False):
510 """Check if the metadata is compliant. If strict is True then raise if
511 no Name or Version are provided"""
512 self.set_metadata_version()
514 # XXX should check the versions (if the file was loaded)
515 missing, warnings = [], []
517 for attr in ('Name', 'Version'): # required by PEP 345
518 if attr not in self:
519 missing.append(attr)
521 if strict and missing != []:
522 msg = 'missing required metadata: %s' % ', '.join(missing)
523 raise MetadataMissingError(msg)
525 for attr in ('Home-page', 'Author'):
526 if attr not in self:
527 missing.append(attr)
529 # checking metadata 1.2 (XXX needs to check 1.1, 1.0)
530 if self['Metadata-Version'] != '1.2':
531 return missing, warnings
533 scheme = get_scheme(self.scheme)
535 def are_valid_constraints(value):
536 for v in value:
537 if not scheme.is_valid_matcher(v.split(';')[0]):
538 return False
539 return True
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))
551 return missing, warnings
553 def todict(self, skip_missing=False):
554 """Return fields as a dict.
556 Field names will be converted to use the underscore-lowercase style
557 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()
562 fields = _version2fieldlist(self['Metadata-Version'])
564 data = {}
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]]
574 return data
576 def add_requirements(self, requirements):
577 if self['Metadata-Version'] == '1.1':
578 # we can't have 1.1 metadata *and* Setuptools requires
579 for field in ('Obsoletes', 'Requires', 'Provides'):
580 if field in self:
581 del self[field]
582 self['Requires-Dist'] += requirements
584 # Mapping API
585 # TODO could add iter* variants
587 def keys(self):
588 return list(_version2fieldlist(self['Metadata-Version']))
590 def __iter__(self):
591 for key in self.keys():
592 yield key
594 def values(self):
595 return [self[key] for key in self.keys()]
597 def items(self):
598 return [(key, self[key]) for key in self.keys()]
600 def __repr__(self):
601 return '<%s %s %s>' % (self.__class__.__name__, self.name,
602 self.version)
605METADATA_FILENAME = 'pydist.json'
606WHEEL_METADATA_FILENAME = 'metadata.json'
607LEGACY_METADATA_FILENAME = 'METADATA'
610class Metadata(object):
611 """
612 The metadata of a release. This implementation uses 2.1
613 metadata where possible. If not possible, it wraps a LegacyMetadata
614 instance which handles the key-value metadata format.
615 """
617 METADATA_VERSION_MATCHER = re.compile(r'^\d+(\.\d+)*$')
619 NAME_MATCHER = re.compile('^[0-9A-Z]([0-9A-Z_.-]*[0-9A-Z])?$', re.I)
621 FIELDNAME_MATCHER = re.compile('^[A-Z]([0-9A-Z-]*[0-9A-Z])?$', re.I)
623 VERSION_MATCHER = PEP440_VERSION_RE
625 SUMMARY_MATCHER = re.compile('.{1,2047}')
627 METADATA_VERSION = '2.0'
629 GENERATOR = 'distlib (%s)' % __version__
631 MANDATORY_KEYS = {
632 'name': (),
633 'version': (),
634 'summary': ('legacy',),
635 }
637 INDEX_KEYS = ('name version license summary description author '
638 'author_email keywords platform home_page classifiers '
639 'download_url')
641 DEPENDENCY_KEYS = ('extras run_requires test_requires build_requires '
642 'dev_requires provides meta_requires obsoleted_by '
643 'supports_environments')
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 }
653 __slots__ = ('_legacy', '_data', 'scheme')
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 = None
660 self._data = None
661 self.scheme = scheme
662 #import pdb; pdb.set_trace()
663 if mapping is not None:
664 try:
665 self._validate_mapping(mapping, scheme)
666 self._data = mapping
667 except MetadataUnrecognizedVersionError:
668 self._legacy = LegacyMetadata(mapping=mapping, scheme=scheme)
669 self.validate()
670 else:
671 data = None
672 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 added
679 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 not
691 # inherit from ValueError (it's a DistlibException,
692 # which should not inherit from ValueError).
693 # The ValueError comes from the json.load - if that
694 # succeeds and we get a validation error, we want
695 # that to propagate
696 self._legacy = LegacyMetadata(fileobj=StringIO(data),
697 scheme=scheme)
698 self.validate()
700 common_keys = set(('name', 'version', 'license', 'keywords', 'summary'))
702 none_list = (None, list)
703 none_dict = (None, dict)
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 }
721 del none_list, none_dict
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 459
740 sentinel = object()
741 result = sentinel
742 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 = value
758 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 result
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))
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 NotImplementedError
785 self._legacy[lk] = value
786 elif key not in ('commands', 'exports', 'modules', 'namespaces',
787 'classifiers'):
788 self._data[key] = value
789 else:
790 # special cases for PEP 459
791 d = self._data.setdefault('extensions', {})
792 if key == 'commands':
793 d['python.commands'] = value
794 elif key == 'classifiers':
795 d = d.setdefault('python.details', {})
796 d[key] = value
797 else:
798 d = d.setdefault('python.exports', {})
799 d[key] = value
800 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] = value
812 else:
813 self._data[key] = value
815 @property
816 def name_and_version(self):
817 return _get_name_and_version(self.name, self.version, True)
819 @property
820 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 result
830 @provides.setter
831 def provides(self, value):
832 if self._legacy:
833 self._legacy['Provides-Dist'] = value
834 else:
835 self._data['provides'] = value
837 def get_requirements(self, reqts, extras=None, env=None):
838 """
839 Base method to get dependencies, given a set of extras
840 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 = reqts
848 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 # unconditional
854 include = True
855 else:
856 if 'extra' not in d:
857 # Not extra-dependent - only environment-dependent
858 include = True
859 else:
860 include = d.get('extra') in extras
861 if include:
862 # Not excluded because of extras, check environment
863 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:' % key
870 if e in extras:
871 extras.remove(e)
872 # A recursive call, but it should terminate since 'test'
873 # has been removed from the extras
874 reqts = self._data.get('%s_requires' % key, [])
875 result.extend(self.get_requirements(reqts, extras=extras,
876 env=env))
877 return result
879 @property
880 def dictionary(self):
881 if self._legacy:
882 return self._from_legacy()
883 return self._data
885 @property
886 def dependencies(self):
887 if self._legacy:
888 raise NotImplementedError
889 else:
890 return extract_by_key(self._data, self.DEPENDENCY_KEYS)
892 @dependencies.setter
893 def dependencies(self, value):
894 if self._legacy:
895 raise NotImplementedError
896 else:
897 self._data.update(value)
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)
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)
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 result
929 def _from_legacy(self):
930 assert self._legacy and not self._data
931 result = {
932 'metadata_version': self.METADATA_VERSION,
933 'generator': self.GENERATOR,
934 }
935 lmd = self._legacy.todict(True) # skip missing ones
936 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 = k
943 result[nk] = lmd[k]
944 kw = lmd.get('Keywords', [])
945 if kw == ['']:
946 kw = []
947 result['keywords'] = kw
948 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.provides
954 author = {}
955 maintainer = {}
956 return result
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 }
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"' % extra
985 if env:
986 if marker:
987 marker = '(%s) and %s' % (env, marker)
988 else:
989 marker = env
990 reqts.add(';'.join((r, marker)))
991 return reqts
993 assert self._data and not self._legacy
994 result = LegacyMetadata()
995 nmd = self._data
996 # 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 = nmd
1003 found = True
1004 for k in nk:
1005 try:
1006 d = d[k]
1007 except (KeyError, IndexError):
1008 found = False
1009 break
1010 if found:
1011 result[ok] = d
1012 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 wanted
1019 return result
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._legacy
1028 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._data
1039 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)
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 = None
1053 for entry in run_requires:
1054 if 'environment' not in entry and 'extra' not in entry:
1055 always = entry
1056 break
1057 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)
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)