comparison env/lib/python3.7/site-packages/distlib/metadata.py @ 0:26e78fe6e8c4 draft

"planemo upload commit c699937486c35866861690329de38ec1a5d9f783"
author shellac
date Sat, 02 May 2020 07:14:21 -0400
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:26e78fe6e8c4
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
8 Supports all metadata formats (1.0, 1.1, 1.2, and 2.0 experimental).
9 """
10 from __future__ import unicode_literals
11
12 import codecs
13 from email import message_from_file
14 import json
15 import logging
16 import re
17
18
19 from . import DistlibException, __version__
20 from .compat import StringIO, string_types, text_type
21 from .markers import interpret
22 from .util import extract_by_key, get_extras
23 from .version import get_scheme, PEP440_VERSION_RE
24
25 logger = logging.getLogger(__name__)
26
27
28 class MetadataMissingError(DistlibException):
29 """A required metadata is missing"""
30
31
32 class MetadataConflictError(DistlibException):
33 """Attempt to read or write metadata fields that are conflictual."""
34
35
36 class MetadataUnrecognizedVersionError(DistlibException):
37 """Unknown metadata version number."""
38
39
40 class MetadataInvalidError(DistlibException):
41 """A metadata value is invalid"""
42
43 # public API of this module
44 __all__ = ['Metadata', 'PKG_INFO_ENCODING', 'PKG_INFO_PREFERRED_VERSION']
45
46 # Encoding used for the PKG-INFO files
47 PKG_INFO_ENCODING = 'utf-8'
48
49 # preferred version. Hopefully will be changed
50 # to 1.2 once PEP 345 is supported everywhere
51 PKG_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 in
95 # the metadata. Include them in the tuple literal below to allow them
96 # (for now).
97 _566_FIELDS = _426_FIELDS + ('Description-Content-Type',
98 'Requires', 'Provides')
99
100 _566_MARKERS = ('Description-Content-Type',)
101
102 _ALL_FIELDS = set()
103 _ALL_FIELDS.update(_241_FIELDS)
104 _ALL_FIELDS.update(_314_FIELDS)
105 _ALL_FIELDS.update(_345_FIELDS)
106 _ALL_FIELDS.update(_426_FIELDS)
107 _ALL_FIELDS.update(_566_FIELDS)
108
109 EXTRA_RE = re.compile(r'''extra\s*==\s*("([^"]+)"|'([^']+)')''')
110
111
112 def _version2fieldlist(version):
113 if version == '1.0':
114 return _241_FIELDS
115 elif version == '1.1':
116 return _314_FIELDS
117 elif version == '1.2':
118 return _345_FIELDS
119 elif version in ('1.3', '2.1'):
120 return _345_FIELDS + _566_FIELDS
121 elif version == '2.0':
122 return _426_FIELDS
123 raise MetadataUnrecognizedVersionError(version)
124
125
126 def _best_version(fields):
127 """Detect the best version depending on the fields used."""
128 def _has_marker(keys, markers):
129 for marker in markers:
130 if marker in keys:
131 return True
132 return False
133
134 keys = []
135 for key, value in fields.items():
136 if value in ([], 'UNKNOWN', None):
137 continue
138 keys.append(key)
139
140 possible_versions = ['1.0', '1.1', '1.2', '1.3', '2.0', '2.1']
141
142 # first let's try to see if a field is not part of one of the version
143 for key in keys:
144 if key not in _241_FIELDS and '1.0' in possible_versions:
145 possible_versions.remove('1.0')
146 logger.debug('Removed 1.0 due to %s', key)
147 if key not in _314_FIELDS and '1.1' in possible_versions:
148 possible_versions.remove('1.1')
149 logger.debug('Removed 1.1 due to %s', key)
150 if key not in _345_FIELDS and '1.2' in possible_versions:
151 possible_versions.remove('1.2')
152 logger.debug('Removed 1.2 due to %s', key)
153 if key not in _566_FIELDS and '1.3' in possible_versions:
154 possible_versions.remove('1.3')
155 logger.debug('Removed 1.3 due to %s', key)
156 if key not in _566_FIELDS and '2.1' in possible_versions:
157 if key != 'Description': # In 2.1, description allowed after headers
158 possible_versions.remove('2.1')
159 logger.debug('Removed 2.1 due to %s', key)
160 if key not in _426_FIELDS and '2.0' in possible_versions:
161 possible_versions.remove('2.0')
162 logger.debug('Removed 2.0 due to %s', key)
163
164 # possible_version contains qualified versions
165 if len(possible_versions) == 1:
166 return possible_versions[0] # found !
167 elif len(possible_versions) == 0:
168 logger.debug('Out of options - unknown metadata set: %s', fields)
169 raise MetadataConflictError('Unknown metadata set')
170
171 # let's see if one unique marker is found
172 is_1_1 = '1.1' in possible_versions and _has_marker(keys, _314_MARKERS)
173 is_1_2 = '1.2' in possible_versions and _has_marker(keys, _345_MARKERS)
174 is_2_1 = '2.1' in possible_versions and _has_marker(keys, _566_MARKERS)
175 is_2_0 = '2.0' in possible_versions and _has_marker(keys, _426_MARKERS)
176 if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_0) > 1:
177 raise MetadataConflictError('You used incompatible 1.1/1.2/2.0/2.1 fields')
178
179 # we have the choice, 1.0, or 1.2, or 2.0
180 # - 1.0 has a broken Summary field but works with all tools
181 # - 1.1 is to avoid
182 # - 1.2 fixes Summary but has little adoption
183 # - 2.0 adds more features and is very new
184 if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_0:
185 # we couldn't find any specific marker
186 if PKG_INFO_PREFERRED_VERSION in possible_versions:
187 return PKG_INFO_PREFERRED_VERSION
188 if is_1_1:
189 return '1.1'
190 if is_1_2:
191 return '1.2'
192 if is_2_1:
193 return '2.1'
194
195 return '2.0'
196
197 _ATTR2FIELD = {
198 'metadata_version': 'Metadata-Version',
199 'name': 'Name',
200 'version': 'Version',
201 'platform': 'Platform',
202 'supported_platform': 'Supported-Platform',
203 'summary': 'Summary',
204 'description': 'Description',
205 'keywords': 'Keywords',
206 'home_page': 'Home-page',
207 'author': 'Author',
208 'author_email': 'Author-email',
209 'maintainer': 'Maintainer',
210 'maintainer_email': 'Maintainer-email',
211 'license': 'License',
212 'classifier': 'Classifier',
213 'download_url': 'Download-URL',
214 'obsoletes_dist': 'Obsoletes-Dist',
215 'provides_dist': 'Provides-Dist',
216 'requires_dist': 'Requires-Dist',
217 'setup_requires_dist': 'Setup-Requires-Dist',
218 'requires_python': 'Requires-Python',
219 'requires_external': 'Requires-External',
220 'requires': 'Requires',
221 'provides': 'Provides',
222 'obsoletes': 'Obsoletes',
223 'project_url': 'Project-URL',
224 'private_version': 'Private-Version',
225 'obsoleted_by': 'Obsoleted-By',
226 'extension': 'Extension',
227 'provides_extra': 'Provides-Extra',
228 }
229
230 _PREDICATE_FIELDS = ('Requires-Dist', 'Obsoletes-Dist', 'Provides-Dist')
231 _VERSIONS_FIELDS = ('Requires-Python',)
232 _VERSION_FIELDS = ('Version',)
233 _LISTFIELDS = ('Platform', 'Classifier', 'Obsoletes',
234 'Requires', 'Provides', 'Obsoletes-Dist',
235 'Provides-Dist', 'Requires-Dist', 'Requires-External',
236 'Project-URL', 'Supported-Platform', 'Setup-Requires-Dist',
237 'Provides-Extra', 'Extension')
238 _LISTTUPLEFIELDS = ('Project-URL',)
239
240 _ELEMENTSFIELD = ('Keywords',)
241
242 _UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description')
243
244 _MISSING = object()
245
246 _FILESAFE = re.compile('[^A-Za-z0-9.]+')
247
248
249 def _get_name_and_version(name, version, for_filename=False):
250 """Return the distribution name with version.
251
252 If for_filename is true, return a filename-escaped form."""
253 if for_filename:
254 # For both name and version any runs of non-alphanumeric or '.'
255 # characters are replaced with a single '-'. Additionally any
256 # spaces in the version string become '.'
257 name = _FILESAFE.sub('-', name)
258 version = _FILESAFE.sub('-', version.replace(' ', '.'))
259 return '%s-%s' % (name, version)
260
261
262 class LegacyMetadata(object):
263 """The legacy metadata of a release.
264
265 Supports versions 1.0, 1.1 and 1.2 (auto-detected). You can
266 instantiate the class with one of these arguments (or none):
267 - *path*, the path to a metadata file
268 - *fileobj* give a file-like object with metadata as content
269 - *mapping* is a dict-like object
270 - *scheme* is a version scheme name
271 """
272 # TODO document the mapping API and UNKNOWN default key
273
274 def __init__(self, path=None, fileobj=None, mapping=None,
275 scheme='default'):
276 if [path, fileobj, mapping].count(None) < 2:
277 raise TypeError('path, fileobj and mapping are exclusive')
278 self._fields = {}
279 self.requires_files = []
280 self._dependencies = None
281 self.scheme = scheme
282 if path is not None:
283 self.read(path)
284 elif fileobj is not None:
285 self.read_file(fileobj)
286 elif mapping is not None:
287 self.update(mapping)
288 self.set_metadata_version()
289
290 def set_metadata_version(self):
291 self._fields['Metadata-Version'] = _best_version(self._fields)
292
293 def _write_field(self, fileobj, name, value):
294 fileobj.write('%s: %s\n' % (name, value))
295
296 def __getitem__(self, name):
297 return self.get(name)
298
299 def __setitem__(self, name, value):
300 return self.set(name, value)
301
302 def __delitem__(self, name):
303 field_name = self._convert_name(name)
304 try:
305 del self._fields[field_name]
306 except KeyError:
307 raise KeyError(name)
308
309 def __contains__(self, name):
310 return (name in self._fields or
311 self._convert_name(name) in self._fields)
312
313 def _convert_name(self, name):
314 if name in _ALL_FIELDS:
315 return name
316 name = name.replace('-', '_').lower()
317 return _ATTR2FIELD.get(name, name)
318
319 def _default_value(self, name):
320 if name in _LISTFIELDS or name in _ELEMENTSFIELD:
321 return []
322 return 'UNKNOWN'
323
324 def _remove_line_prefix(self, value):
325 if self.metadata_version in ('1.0', '1.1'):
326 return _LINE_PREFIX_PRE_1_2.sub('\n', value)
327 else:
328 return _LINE_PREFIX_1_2.sub('\n', value)
329
330 def __getattr__(self, name):
331 if name in _ATTR2FIELD:
332 return self[name]
333 raise AttributeError(name)
334
335 #
336 # Public API
337 #
338
339 # dependencies = property(_get_dependencies, _set_dependencies)
340
341 def get_fullname(self, filesafe=False):
342 """Return the distribution name with version.
343
344 If filesafe is true, return a filename-escaped form."""
345 return _get_name_and_version(self['Name'], self['Version'], filesafe)
346
347 def is_field(self, name):
348 """return True if name is a valid metadata key"""
349 name = self._convert_name(name)
350 return name in _ALL_FIELDS
351
352 def is_multi_field(self, name):
353 name = self._convert_name(name)
354 return name in _LISTFIELDS
355
356 def read(self, filepath):
357 """Read the metadata values from a file path."""
358 fp = codecs.open(filepath, 'r', encoding='utf-8')
359 try:
360 self.read_file(fp)
361 finally:
362 fp.close()
363
364 def read_file(self, fileob):
365 """Read the metadata values from a file object."""
366 msg = message_from_file(fileob)
367 self._fields['Metadata-Version'] = msg['metadata-version']
368
369 # When reading, get all the fields we can
370 for field in _ALL_FIELDS:
371 if field not in msg:
372 continue
373 if field in _LISTFIELDS:
374 # we can have multiple lines
375 values = msg.get_all(field)
376 if field in _LISTTUPLEFIELDS and values is not None:
377 values = [tuple(value.split(',')) for value in values]
378 self.set(field, values)
379 else:
380 # single line
381 value = msg[field]
382 if value is not None and value != 'UNKNOWN':
383 self.set(field, value)
384 # logger.debug('Attempting to set metadata for %s', self)
385 # self.set_metadata_version()
386
387 def write(self, filepath, skip_unknown=False):
388 """Write the metadata fields to filepath."""
389 fp = codecs.open(filepath, 'w', encoding='utf-8')
390 try:
391 self.write_file(fp, skip_unknown)
392 finally:
393 fp.close()
394
395 def write_file(self, fileobject, skip_unknown=False):
396 """Write the PKG-INFO format data to a file object."""
397 self.set_metadata_version()
398
399 for field in _version2fieldlist(self['Metadata-Version']):
400 values = self.get(field)
401 if skip_unknown and values in ('UNKNOWN', [], ['UNKNOWN']):
402 continue
403 if field in _ELEMENTSFIELD:
404 self._write_field(fileobject, field, ','.join(values))
405 continue
406 if field not in _LISTFIELDS:
407 if field == 'Description':
408 if self.metadata_version in ('1.0', '1.1'):
409 values = values.replace('\n', '\n ')
410 else:
411 values = values.replace('\n', '\n |')
412 values = [values]
413
414 if field in _LISTTUPLEFIELDS:
415 values = [','.join(value) for value in values]
416
417 for value in values:
418 self._write_field(fileobject, field, value)
419
420 def update(self, other=None, **kwargs):
421 """Set metadata values from the given iterable `other` and kwargs.
422
423 Behavior is like `dict.update`: If `other` has a ``keys`` method,
424 they are looped over and ``self[key]`` is assigned ``other[key]``.
425 Else, ``other`` is an iterable of ``(key, value)`` iterables.
426
427 Keys that don't match a metadata field or that have an empty value are
428 dropped.
429 """
430 def _set(key, value):
431 if key in _ATTR2FIELD and value:
432 self.set(self._convert_name(key), value)
433
434 if not other:
435 # other is None or empty container
436 pass
437 elif hasattr(other, 'keys'):
438 for k in other.keys():
439 _set(k, other[k])
440 else:
441 for k, v in other:
442 _set(k, v)
443
444 if kwargs:
445 for k, v in kwargs.items():
446 _set(k, v)
447
448 def set(self, name, value):
449 """Control then set a metadata field."""
450 name = self._convert_name(name)
451
452 if ((name in _ELEMENTSFIELD or name == 'Platform') and
453 not isinstance(value, (list, tuple))):
454 if isinstance(value, string_types):
455 value = [v.strip() for v in value.split(',')]
456 else:
457 value = []
458 elif (name in _LISTFIELDS and
459 not isinstance(value, (list, tuple))):
460 if isinstance(value, string_types):
461 value = [value]
462 else:
463 value = []
464
465 if logger.isEnabledFor(logging.WARNING):
466 project_name = self['Name']
467
468 scheme = get_scheme(self.scheme)
469 if name in _PREDICATE_FIELDS and value is not None:
470 for v in value:
471 # check that the values are valid
472 if not scheme.is_valid_matcher(v.split(';')[0]):
473 logger.warning(
474 "'%s': '%s' is not valid (field '%s')",
475 project_name, v, name)
476 # FIXME this rejects UNKNOWN, is that right?
477 elif name in _VERSIONS_FIELDS and value is not None:
478 if not scheme.is_valid_constraint_list(value):
479 logger.warning("'%s': '%s' is not a valid version (field '%s')",
480 project_name, value, name)
481 elif name in _VERSION_FIELDS and value is not None:
482 if not scheme.is_valid_version(value):
483 logger.warning("'%s': '%s' is not a valid version (field '%s')",
484 project_name, value, name)
485
486 if name in _UNICODEFIELDS:
487 if name == 'Description':
488 value = self._remove_line_prefix(value)
489
490 self._fields[name] = value
491
492 def get(self, name, default=_MISSING):
493 """Get a metadata field."""
494 name = self._convert_name(name)
495 if name not in self._fields:
496 if default is _MISSING:
497 default = self._default_value(name)
498 return default
499 if name in _UNICODEFIELDS:
500 value = self._fields[name]
501 return value
502 elif name in _LISTFIELDS:
503 value = self._fields[name]
504 if value is None:
505 return []
506 res = []
507 for val in value:
508 if name not in _LISTTUPLEFIELDS:
509 res.append(val)
510 else:
511 # That's for Project-URL
512 res.append((val[0], val[1]))
513 return res
514
515 elif name in _ELEMENTSFIELD:
516 value = self._fields[name]
517 if isinstance(value, string_types):
518 return value.split(',')
519 return self._fields[name]
520
521 def check(self, strict=False):
522 """Check if the metadata is compliant. If strict is True then raise if
523 no Name or Version are provided"""
524 self.set_metadata_version()
525
526 # XXX should check the versions (if the file was loaded)
527 missing, warnings = [], []
528
529 for attr in ('Name', 'Version'): # required by PEP 345
530 if attr not in self:
531 missing.append(attr)
532
533 if strict and missing != []:
534 msg = 'missing required metadata: %s' % ', '.join(missing)
535 raise MetadataMissingError(msg)
536
537 for attr in ('Home-page', 'Author'):
538 if attr not in self:
539 missing.append(attr)
540
541 # checking metadata 1.2 (XXX needs to check 1.1, 1.0)
542 if self['Metadata-Version'] != '1.2':
543 return missing, warnings
544
545 scheme = get_scheme(self.scheme)
546
547 def are_valid_constraints(value):
548 for v in value:
549 if not scheme.is_valid_matcher(v.split(';')[0]):
550 return False
551 return True
552
553 for fields, controller in ((_PREDICATE_FIELDS, are_valid_constraints),
554 (_VERSIONS_FIELDS,
555 scheme.is_valid_constraint_list),
556 (_VERSION_FIELDS,
557 scheme.is_valid_version)):
558 for field in fields:
559 value = self.get(field, None)
560 if value is not None and not controller(value):
561 warnings.append("Wrong value for '%s': %s" % (field, value))
562
563 return missing, warnings
564
565 def todict(self, skip_missing=False):
566 """Return fields as a dict.
567
568 Field names will be converted to use the underscore-lowercase style
569 instead of hyphen-mixed case (i.e. home_page instead of Home-page).
570 """
571 self.set_metadata_version()
572
573 mapping_1_0 = (
574 ('metadata_version', 'Metadata-Version'),
575 ('name', 'Name'),
576 ('version', 'Version'),
577 ('summary', 'Summary'),
578 ('home_page', 'Home-page'),
579 ('author', 'Author'),
580 ('author_email', 'Author-email'),
581 ('license', 'License'),
582 ('description', 'Description'),
583 ('keywords', 'Keywords'),
584 ('platform', 'Platform'),
585 ('classifiers', 'Classifier'),
586 ('download_url', 'Download-URL'),
587 )
588
589 data = {}
590 for key, field_name in mapping_1_0:
591 if not skip_missing or field_name in self._fields:
592 data[key] = self[field_name]
593
594 if self['Metadata-Version'] == '1.2':
595 mapping_1_2 = (
596 ('requires_dist', 'Requires-Dist'),
597 ('requires_python', 'Requires-Python'),
598 ('requires_external', 'Requires-External'),
599 ('provides_dist', 'Provides-Dist'),
600 ('obsoletes_dist', 'Obsoletes-Dist'),
601 ('project_url', 'Project-URL'),
602 ('maintainer', 'Maintainer'),
603 ('maintainer_email', 'Maintainer-email'),
604 )
605 for key, field_name in mapping_1_2:
606 if not skip_missing or field_name in self._fields:
607 if key != 'project_url':
608 data[key] = self[field_name]
609 else:
610 data[key] = [','.join(u) for u in self[field_name]]
611
612 elif self['Metadata-Version'] == '1.1':
613 mapping_1_1 = (
614 ('provides', 'Provides'),
615 ('requires', 'Requires'),
616 ('obsoletes', 'Obsoletes'),
617 )
618 for key, field_name in mapping_1_1:
619 if not skip_missing or field_name in self._fields:
620 data[key] = self[field_name]
621
622 return data
623
624 def add_requirements(self, requirements):
625 if self['Metadata-Version'] == '1.1':
626 # we can't have 1.1 metadata *and* Setuptools requires
627 for field in ('Obsoletes', 'Requires', 'Provides'):
628 if field in self:
629 del self[field]
630 self['Requires-Dist'] += requirements
631
632 # Mapping API
633 # TODO could add iter* variants
634
635 def keys(self):
636 return list(_version2fieldlist(self['Metadata-Version']))
637
638 def __iter__(self):
639 for key in self.keys():
640 yield key
641
642 def values(self):
643 return [self[key] for key in self.keys()]
644
645 def items(self):
646 return [(key, self[key]) for key in self.keys()]
647
648 def __repr__(self):
649 return '<%s %s %s>' % (self.__class__.__name__, self.name,
650 self.version)
651
652
653 METADATA_FILENAME = 'pydist.json'
654 WHEEL_METADATA_FILENAME = 'metadata.json'
655 LEGACY_METADATA_FILENAME = 'METADATA'
656
657
658 class Metadata(object):
659 """
660 The metadata of a release. This implementation uses 2.0 (JSON)
661 metadata where possible. If not possible, it wraps a LegacyMetadata
662 instance which handles the key-value metadata format.
663 """
664
665 METADATA_VERSION_MATCHER = re.compile(r'^\d+(\.\d+)*$')
666
667 NAME_MATCHER = re.compile('^[0-9A-Z]([0-9A-Z_.-]*[0-9A-Z])?$', re.I)
668
669 VERSION_MATCHER = PEP440_VERSION_RE
670
671 SUMMARY_MATCHER = re.compile('.{1,2047}')
672
673 METADATA_VERSION = '2.0'
674
675 GENERATOR = 'distlib (%s)' % __version__
676
677 MANDATORY_KEYS = {
678 'name': (),
679 'version': (),
680 'summary': ('legacy',),
681 }
682
683 INDEX_KEYS = ('name version license summary description author '
684 'author_email keywords platform home_page classifiers '
685 'download_url')
686
687 DEPENDENCY_KEYS = ('extras run_requires test_requires build_requires '
688 'dev_requires provides meta_requires obsoleted_by '
689 'supports_environments')
690
691 SYNTAX_VALIDATORS = {
692 'metadata_version': (METADATA_VERSION_MATCHER, ()),
693 'name': (NAME_MATCHER, ('legacy',)),
694 'version': (VERSION_MATCHER, ('legacy',)),
695 'summary': (SUMMARY_MATCHER, ('legacy',)),
696 }
697
698 __slots__ = ('_legacy', '_data', 'scheme')
699
700 def __init__(self, path=None, fileobj=None, mapping=None,
701 scheme='default'):
702 if [path, fileobj, mapping].count(None) < 2:
703 raise TypeError('path, fileobj and mapping are exclusive')
704 self._legacy = None
705 self._data = None
706 self.scheme = scheme
707 #import pdb; pdb.set_trace()
708 if mapping is not None:
709 try:
710 self._validate_mapping(mapping, scheme)
711 self._data = mapping
712 except MetadataUnrecognizedVersionError:
713 self._legacy = LegacyMetadata(mapping=mapping, scheme=scheme)
714 self.validate()
715 else:
716 data = None
717 if path:
718 with open(path, 'rb') as f:
719 data = f.read()
720 elif fileobj:
721 data = fileobj.read()
722 if data is None:
723 # Initialised with no args - to be added
724 self._data = {
725 'metadata_version': self.METADATA_VERSION,
726 'generator': self.GENERATOR,
727 }
728 else:
729 if not isinstance(data, text_type):
730 data = data.decode('utf-8')
731 try:
732 self._data = json.loads(data)
733 self._validate_mapping(self._data, scheme)
734 except ValueError:
735 # Note: MetadataUnrecognizedVersionError does not
736 # inherit from ValueError (it's a DistlibException,
737 # which should not inherit from ValueError).
738 # The ValueError comes from the json.load - if that
739 # succeeds and we get a validation error, we want
740 # that to propagate
741 self._legacy = LegacyMetadata(fileobj=StringIO(data),
742 scheme=scheme)
743 self.validate()
744
745 common_keys = set(('name', 'version', 'license', 'keywords', 'summary'))
746
747 none_list = (None, list)
748 none_dict = (None, dict)
749
750 mapped_keys = {
751 'run_requires': ('Requires-Dist', list),
752 'build_requires': ('Setup-Requires-Dist', list),
753 'dev_requires': none_list,
754 'test_requires': none_list,
755 'meta_requires': none_list,
756 'extras': ('Provides-Extra', list),
757 'modules': none_list,
758 'namespaces': none_list,
759 'exports': none_dict,
760 'commands': none_dict,
761 'classifiers': ('Classifier', list),
762 'source_url': ('Download-URL', None),
763 'metadata_version': ('Metadata-Version', None),
764 }
765
766 del none_list, none_dict
767
768 def __getattribute__(self, key):
769 common = object.__getattribute__(self, 'common_keys')
770 mapped = object.__getattribute__(self, 'mapped_keys')
771 if key in mapped:
772 lk, maker = mapped[key]
773 if self._legacy:
774 if lk is None:
775 result = None if maker is None else maker()
776 else:
777 result = self._legacy.get(lk)
778 else:
779 value = None if maker is None else maker()
780 if key not in ('commands', 'exports', 'modules', 'namespaces',
781 'classifiers'):
782 result = self._data.get(key, value)
783 else:
784 # special cases for PEP 459
785 sentinel = object()
786 result = sentinel
787 d = self._data.get('extensions')
788 if d:
789 if key == 'commands':
790 result = d.get('python.commands', value)
791 elif key == 'classifiers':
792 d = d.get('python.details')
793 if d:
794 result = d.get(key, value)
795 else:
796 d = d.get('python.exports')
797 if not d:
798 d = self._data.get('python.exports')
799 if d:
800 result = d.get(key, value)
801 if result is sentinel:
802 result = value
803 elif key not in common:
804 result = object.__getattribute__(self, key)
805 elif self._legacy:
806 result = self._legacy.get(key)
807 else:
808 result = self._data.get(key)
809 return result
810
811 def _validate_value(self, key, value, scheme=None):
812 if key in self.SYNTAX_VALIDATORS:
813 pattern, exclusions = self.SYNTAX_VALIDATORS[key]
814 if (scheme or self.scheme) not in exclusions:
815 m = pattern.match(value)
816 if not m:
817 raise MetadataInvalidError("'%s' is an invalid value for "
818 "the '%s' property" % (value,
819 key))
820
821 def __setattr__(self, key, value):
822 self._validate_value(key, value)
823 common = object.__getattribute__(self, 'common_keys')
824 mapped = object.__getattribute__(self, 'mapped_keys')
825 if key in mapped:
826 lk, _ = mapped[key]
827 if self._legacy:
828 if lk is None:
829 raise NotImplementedError
830 self._legacy[lk] = value
831 elif key not in ('commands', 'exports', 'modules', 'namespaces',
832 'classifiers'):
833 self._data[key] = value
834 else:
835 # special cases for PEP 459
836 d = self._data.setdefault('extensions', {})
837 if key == 'commands':
838 d['python.commands'] = value
839 elif key == 'classifiers':
840 d = d.setdefault('python.details', {})
841 d[key] = value
842 else:
843 d = d.setdefault('python.exports', {})
844 d[key] = value
845 elif key not in common:
846 object.__setattr__(self, key, value)
847 else:
848 if key == 'keywords':
849 if isinstance(value, string_types):
850 value = value.strip()
851 if value:
852 value = value.split()
853 else:
854 value = []
855 if self._legacy:
856 self._legacy[key] = value
857 else:
858 self._data[key] = value
859
860 @property
861 def name_and_version(self):
862 return _get_name_and_version(self.name, self.version, True)
863
864 @property
865 def provides(self):
866 if self._legacy:
867 result = self._legacy['Provides-Dist']
868 else:
869 result = self._data.setdefault('provides', [])
870 s = '%s (%s)' % (self.name, self.version)
871 if s not in result:
872 result.append(s)
873 return result
874
875 @provides.setter
876 def provides(self, value):
877 if self._legacy:
878 self._legacy['Provides-Dist'] = value
879 else:
880 self._data['provides'] = value
881
882 def get_requirements(self, reqts, extras=None, env=None):
883 """
884 Base method to get dependencies, given a set of extras
885 to satisfy and an optional environment context.
886 :param reqts: A list of sometimes-wanted dependencies,
887 perhaps dependent on extras and environment.
888 :param extras: A list of optional components being requested.
889 :param env: An optional environment for marker evaluation.
890 """
891 if self._legacy:
892 result = reqts
893 else:
894 result = []
895 extras = get_extras(extras or [], self.extras)
896 for d in reqts:
897 if 'extra' not in d and 'environment' not in d:
898 # unconditional
899 include = True
900 else:
901 if 'extra' not in d:
902 # Not extra-dependent - only environment-dependent
903 include = True
904 else:
905 include = d.get('extra') in extras
906 if include:
907 # Not excluded because of extras, check environment
908 marker = d.get('environment')
909 if marker:
910 include = interpret(marker, env)
911 if include:
912 result.extend(d['requires'])
913 for key in ('build', 'dev', 'test'):
914 e = ':%s:' % key
915 if e in extras:
916 extras.remove(e)
917 # A recursive call, but it should terminate since 'test'
918 # has been removed from the extras
919 reqts = self._data.get('%s_requires' % key, [])
920 result.extend(self.get_requirements(reqts, extras=extras,
921 env=env))
922 return result
923
924 @property
925 def dictionary(self):
926 if self._legacy:
927 return self._from_legacy()
928 return self._data
929
930 @property
931 def dependencies(self):
932 if self._legacy:
933 raise NotImplementedError
934 else:
935 return extract_by_key(self._data, self.DEPENDENCY_KEYS)
936
937 @dependencies.setter
938 def dependencies(self, value):
939 if self._legacy:
940 raise NotImplementedError
941 else:
942 self._data.update(value)
943
944 def _validate_mapping(self, mapping, scheme):
945 if mapping.get('metadata_version') != self.METADATA_VERSION:
946 raise MetadataUnrecognizedVersionError()
947 missing = []
948 for key, exclusions in self.MANDATORY_KEYS.items():
949 if key not in mapping:
950 if scheme not in exclusions:
951 missing.append(key)
952 if missing:
953 msg = 'Missing metadata items: %s' % ', '.join(missing)
954 raise MetadataMissingError(msg)
955 for k, v in mapping.items():
956 self._validate_value(k, v, scheme)
957
958 def validate(self):
959 if self._legacy:
960 missing, warnings = self._legacy.check(True)
961 if missing or warnings:
962 logger.warning('Metadata: missing: %s, warnings: %s',
963 missing, warnings)
964 else:
965 self._validate_mapping(self._data, self.scheme)
966
967 def todict(self):
968 if self._legacy:
969 return self._legacy.todict(True)
970 else:
971 result = extract_by_key(self._data, self.INDEX_KEYS)
972 return result
973
974 def _from_legacy(self):
975 assert self._legacy and not self._data
976 result = {
977 'metadata_version': self.METADATA_VERSION,
978 'generator': self.GENERATOR,
979 }
980 lmd = self._legacy.todict(True) # skip missing ones
981 for k in ('name', 'version', 'license', 'summary', 'description',
982 'classifier'):
983 if k in lmd:
984 if k == 'classifier':
985 nk = 'classifiers'
986 else:
987 nk = k
988 result[nk] = lmd[k]
989 kw = lmd.get('Keywords', [])
990 if kw == ['']:
991 kw = []
992 result['keywords'] = kw
993 keys = (('requires_dist', 'run_requires'),
994 ('setup_requires_dist', 'build_requires'))
995 for ok, nk in keys:
996 if ok in lmd and lmd[ok]:
997 result[nk] = [{'requires': lmd[ok]}]
998 result['provides'] = self.provides
999 author = {}
1000 maintainer = {}
1001 return result
1002
1003 LEGACY_MAPPING = {
1004 'name': 'Name',
1005 'version': 'Version',
1006 'license': 'License',
1007 'summary': 'Summary',
1008 'description': 'Description',
1009 'classifiers': 'Classifier',
1010 }
1011
1012 def _to_legacy(self):
1013 def process_entries(entries):
1014 reqts = set()
1015 for e in entries:
1016 extra = e.get('extra')
1017 env = e.get('environment')
1018 rlist = e['requires']
1019 for r in rlist:
1020 if not env and not extra:
1021 reqts.add(r)
1022 else:
1023 marker = ''
1024 if extra:
1025 marker = 'extra == "%s"' % extra
1026 if env:
1027 if marker:
1028 marker = '(%s) and %s' % (env, marker)
1029 else:
1030 marker = env
1031 reqts.add(';'.join((r, marker)))
1032 return reqts
1033
1034 assert self._data and not self._legacy
1035 result = LegacyMetadata()
1036 nmd = self._data
1037 for nk, ok in self.LEGACY_MAPPING.items():
1038 if nk in nmd:
1039 result[ok] = nmd[nk]
1040 r1 = process_entries(self.run_requires + self.meta_requires)
1041 r2 = process_entries(self.build_requires + self.dev_requires)
1042 if self.extras:
1043 result['Provides-Extra'] = sorted(self.extras)
1044 result['Requires-Dist'] = sorted(r1)
1045 result['Setup-Requires-Dist'] = sorted(r2)
1046 # TODO: other fields such as contacts
1047 return result
1048
1049 def write(self, path=None, fileobj=None, legacy=False, skip_unknown=True):
1050 if [path, fileobj].count(None) != 1:
1051 raise ValueError('Exactly one of path and fileobj is needed')
1052 self.validate()
1053 if legacy:
1054 if self._legacy:
1055 legacy_md = self._legacy
1056 else:
1057 legacy_md = self._to_legacy()
1058 if path:
1059 legacy_md.write(path, skip_unknown=skip_unknown)
1060 else:
1061 legacy_md.write_file(fileobj, skip_unknown=skip_unknown)
1062 else:
1063 if self._legacy:
1064 d = self._from_legacy()
1065 else:
1066 d = self._data
1067 if fileobj:
1068 json.dump(d, fileobj, ensure_ascii=True, indent=2,
1069 sort_keys=True)
1070 else:
1071 with codecs.open(path, 'w', 'utf-8') as f:
1072 json.dump(d, f, ensure_ascii=True, indent=2,
1073 sort_keys=True)
1074
1075 def add_requirements(self, requirements):
1076 if self._legacy:
1077 self._legacy.add_requirements(requirements)
1078 else:
1079 run_requires = self._data.setdefault('run_requires', [])
1080 always = None
1081 for entry in run_requires:
1082 if 'environment' not in entry and 'extra' not in entry:
1083 always = entry
1084 break
1085 if always is None:
1086 always = { 'requires': requirements }
1087 run_requires.insert(0, always)
1088 else:
1089 rset = set(always['requires']) | set(requirements)
1090 always['requires'] = sorted(rset)
1091
1092 def __repr__(self):
1093 name = self.name or '(no name)'
1094 version = self.version or 'no version'
1095 return '<%s %s %s (%s)>' % (self.__class__.__name__,
1096 self.metadata_version, name, version)