blob: dbb53b265c369c6584d684afee00cfcbbb0ab75b [file] [log] [blame]
Tarek Ziade1231a4e2011-05-19 13:07:25 +02001"""Implementation of the Metadata for Python packages PEPs.
2
3Supports all metadata formats (1.0, 1.1, 1.2).
4"""
5
6import re
7import logging
8
9from io import StringIO
10from email import message_from_file
11from packaging import logger
12from packaging.markers import interpret
13from packaging.version import (is_valid_predicate, is_valid_version,
14 is_valid_versions)
15from packaging.errors import (MetadataMissingError,
16 MetadataConflictError,
17 MetadataUnrecognizedVersionError)
18
19try:
20 # docutils is installed
21 from docutils.utils import Reporter
22 from docutils.parsers.rst import Parser
23 from docutils import frontend
24 from docutils import nodes
25
26 class SilentReporter(Reporter):
27
28 def __init__(self, source, report_level, halt_level, stream=None,
29 debug=0, encoding='ascii', error_handler='replace'):
30 self.messages = []
31 Reporter.__init__(self, source, report_level, halt_level, stream,
32 debug, encoding, error_handler)
33
34 def system_message(self, level, message, *children, **kwargs):
35 self.messages.append((level, message, children, kwargs))
36
37 _HAS_DOCUTILS = True
38except ImportError:
39 # docutils is not installed
40 _HAS_DOCUTILS = False
41
42# public API of this module
43__all__ = ['Metadata', 'PKG_INFO_ENCODING', 'PKG_INFO_PREFERRED_VERSION']
44
45# Encoding used for the PKG-INFO files
46PKG_INFO_ENCODING = 'utf-8'
47
48# preferred version. Hopefully will be changed
49# to 1.2 once PEP 345 is supported everywhere
50PKG_INFO_PREFERRED_VERSION = '1.0'
51
52_LINE_PREFIX = re.compile('\n \|')
53_241_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',
54 'Summary', 'Description',
55 'Keywords', 'Home-page', 'Author', 'Author-email',
56 'License')
57
58_314_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',
59 'Supported-Platform', 'Summary', 'Description',
60 'Keywords', 'Home-page', 'Author', 'Author-email',
61 'License', 'Classifier', 'Download-URL', 'Obsoletes',
62 'Provides', 'Requires')
63
Éric Araujoe6db7a32011-09-10 05:22:48 +020064_314_MARKERS = ('Obsoletes', 'Provides', 'Requires', 'Classifier',
65 'Download-URL')
Tarek Ziade1231a4e2011-05-19 13:07:25 +020066
67_345_FIELDS = ('Metadata-Version', 'Name', 'Version', 'Platform',
68 'Supported-Platform', 'Summary', 'Description',
69 'Keywords', 'Home-page', 'Author', 'Author-email',
70 'Maintainer', 'Maintainer-email', 'License',
71 'Classifier', 'Download-URL', 'Obsoletes-Dist',
72 'Project-URL', 'Provides-Dist', 'Requires-Dist',
73 'Requires-Python', 'Requires-External')
74
75_345_MARKERS = ('Provides-Dist', 'Requires-Dist', 'Requires-Python',
76 'Obsoletes-Dist', 'Requires-External', 'Maintainer',
77 'Maintainer-email', 'Project-URL')
78
79_ALL_FIELDS = set()
80_ALL_FIELDS.update(_241_FIELDS)
81_ALL_FIELDS.update(_314_FIELDS)
82_ALL_FIELDS.update(_345_FIELDS)
83
84
85def _version2fieldlist(version):
86 if version == '1.0':
87 return _241_FIELDS
88 elif version == '1.1':
89 return _314_FIELDS
90 elif version == '1.2':
91 return _345_FIELDS
92 raise MetadataUnrecognizedVersionError(version)
93
94
95def _best_version(fields):
96 """Detect the best version depending on the fields used."""
97 def _has_marker(keys, markers):
98 for marker in markers:
99 if marker in keys:
100 return True
101 return False
102
103 keys = list(fields)
104 possible_versions = ['1.0', '1.1', '1.2']
105
106 # first let's try to see if a field is not part of one of the version
107 for key in keys:
108 if key not in _241_FIELDS and '1.0' in possible_versions:
109 possible_versions.remove('1.0')
110 if key not in _314_FIELDS and '1.1' in possible_versions:
111 possible_versions.remove('1.1')
112 if key not in _345_FIELDS and '1.2' in possible_versions:
113 possible_versions.remove('1.2')
114
115 # possible_version contains qualified versions
116 if len(possible_versions) == 1:
117 return possible_versions[0] # found !
118 elif len(possible_versions) == 0:
119 raise MetadataConflictError('Unknown metadata set')
120
121 # let's see if one unique marker is found
122 is_1_1 = '1.1' in possible_versions and _has_marker(keys, _314_MARKERS)
123 is_1_2 = '1.2' in possible_versions and _has_marker(keys, _345_MARKERS)
124 if is_1_1 and is_1_2:
125 raise MetadataConflictError('You used incompatible 1.1 and 1.2 fields')
126
127 # we have the choice, either 1.0, or 1.2
128 # - 1.0 has a broken Summary field but works with all tools
129 # - 1.1 is to avoid
130 # - 1.2 fixes Summary but is not widespread yet
131 if not is_1_1 and not is_1_2:
132 # we couldn't find any specific marker
133 if PKG_INFO_PREFERRED_VERSION in possible_versions:
134 return PKG_INFO_PREFERRED_VERSION
135 if is_1_1:
136 return '1.1'
137
138 # default marker when 1.0 is disqualified
139 return '1.2'
140
141
142_ATTR2FIELD = {
143 'metadata_version': 'Metadata-Version',
144 'name': 'Name',
145 'version': 'Version',
146 'platform': 'Platform',
147 'supported_platform': 'Supported-Platform',
148 'summary': 'Summary',
149 'description': 'Description',
150 'keywords': 'Keywords',
151 'home_page': 'Home-page',
152 'author': 'Author',
153 'author_email': 'Author-email',
154 'maintainer': 'Maintainer',
155 'maintainer_email': 'Maintainer-email',
156 'license': 'License',
157 'classifier': 'Classifier',
158 'download_url': 'Download-URL',
159 'obsoletes_dist': 'Obsoletes-Dist',
160 'provides_dist': 'Provides-Dist',
161 'requires_dist': 'Requires-Dist',
162 'requires_python': 'Requires-Python',
163 'requires_external': 'Requires-External',
164 'requires': 'Requires',
165 'provides': 'Provides',
166 'obsoletes': 'Obsoletes',
167 'project_url': 'Project-URL',
168}
169
170_PREDICATE_FIELDS = ('Requires-Dist', 'Obsoletes-Dist', 'Provides-Dist')
171_VERSIONS_FIELDS = ('Requires-Python',)
172_VERSION_FIELDS = ('Version',)
173_LISTFIELDS = ('Platform', 'Classifier', 'Obsoletes',
174 'Requires', 'Provides', 'Obsoletes-Dist',
175 'Provides-Dist', 'Requires-Dist', 'Requires-External',
176 'Project-URL', 'Supported-Platform')
177_LISTTUPLEFIELDS = ('Project-URL',)
178
179_ELEMENTSFIELD = ('Keywords',)
180
181_UNICODEFIELDS = ('Author', 'Maintainer', 'Summary', 'Description')
182
183_MISSING = object()
184
185
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200186class Metadata:
187 """The metadata of a release.
188
189 Supports versions 1.0, 1.1 and 1.2 (auto-detected). You can
190 instantiate the class with one of these arguments (or none):
191 - *path*, the path to a METADATA file
192 - *fileobj* give a file-like object with METADATA as content
193 - *mapping* is a dict-like object
194 """
195 # TODO document that execution_context and platform_dependent are used
196 # to filter on query, not when setting a key
197 # also document the mapping API and UNKNOWN default key
198
199 def __init__(self, path=None, platform_dependent=False,
200 execution_context=None, fileobj=None, mapping=None):
201 self._fields = {}
202 self.requires_files = []
203 self.docutils_support = _HAS_DOCUTILS
204 self.platform_dependent = platform_dependent
205 self.execution_context = execution_context
206 if [path, fileobj, mapping].count(None) < 2:
207 raise TypeError('path, fileobj and mapping are exclusive')
208 if path is not None:
209 self.read(path)
210 elif fileobj is not None:
211 self.read_file(fileobj)
212 elif mapping is not None:
213 self.update(mapping)
214
215 def _set_best_version(self):
216 self._fields['Metadata-Version'] = _best_version(self._fields)
217
218 def _write_field(self, file, name, value):
219 file.write('%s: %s\n' % (name, value))
220
221 def __getitem__(self, name):
222 return self.get(name)
223
224 def __setitem__(self, name, value):
225 return self.set(name, value)
226
227 def __delitem__(self, name):
228 field_name = self._convert_name(name)
229 try:
230 del self._fields[field_name]
231 except KeyError:
232 raise KeyError(name)
233 self._set_best_version()
234
235 def __contains__(self, name):
236 return (name in self._fields or
237 self._convert_name(name) in self._fields)
238
239 def _convert_name(self, name):
240 if name in _ALL_FIELDS:
241 return name
242 name = name.replace('-', '_').lower()
243 return _ATTR2FIELD.get(name, name)
244
245 def _default_value(self, name):
246 if name in _LISTFIELDS or name in _ELEMENTSFIELD:
247 return []
248 return 'UNKNOWN'
249
250 def _check_rst_data(self, data):
251 """Return warnings when the provided data has syntax errors."""
252 source_path = StringIO()
253 parser = Parser()
254 settings = frontend.OptionParser().get_default_values()
255 settings.tab_width = 4
256 settings.pep_references = None
257 settings.rfc_references = None
258 reporter = SilentReporter(source_path,
259 settings.report_level,
260 settings.halt_level,
261 stream=settings.warning_stream,
262 debug=settings.debug,
263 encoding=settings.error_encoding,
264 error_handler=settings.error_encoding_error_handler)
265
266 document = nodes.document(settings, reporter, source=source_path)
267 document.note_source(source_path, -1)
268 try:
269 parser.parse(data, document)
270 except AttributeError:
271 reporter.messages.append((-1, 'Could not finish the parsing.',
272 '', {}))
273
274 return reporter.messages
275
276 def _platform(self, value):
277 if not self.platform_dependent or ';' not in value:
278 return True, value
279 value, marker = value.split(';')
280 return interpret(marker, self.execution_context), value
281
282 def _remove_line_prefix(self, value):
283 return _LINE_PREFIX.sub('\n', value)
284
285 #
286 # Public API
287 #
288 def get_fullname(self):
289 """Return the distribution name with version"""
290 return '%s-%s' % (self['Name'], self['Version'])
291
292 def is_metadata_field(self, name):
293 """return True if name is a valid metadata key"""
294 name = self._convert_name(name)
295 return name in _ALL_FIELDS
296
297 def is_multi_field(self, name):
298 name = self._convert_name(name)
299 return name in _LISTFIELDS
300
301 def read(self, filepath):
302 """Read the metadata values from a file path."""
Victor Stinnerc3364522011-05-19 18:49:56 +0200303 with open(filepath, 'r', encoding='utf-8') as fp:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200304 self.read_file(fp)
305
306 def read_file(self, fileob):
307 """Read the metadata values from a file object."""
308 msg = message_from_file(fileob)
309 self._fields['Metadata-Version'] = msg['metadata-version']
310
311 for field in _version2fieldlist(self['Metadata-Version']):
312 if field in _LISTFIELDS:
313 # we can have multiple lines
314 values = msg.get_all(field)
315 if field in _LISTTUPLEFIELDS and values is not None:
316 values = [tuple(value.split(',')) for value in values]
317 self.set(field, values)
318 else:
319 # single line
320 value = msg[field]
321 if value is not None and value != 'UNKNOWN':
322 self.set(field, value)
323
324 def write(self, filepath):
325 """Write the metadata fields to filepath."""
Victor Stinnerc3364522011-05-19 18:49:56 +0200326 with open(filepath, 'w', encoding='utf-8') as fp:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200327 self.write_file(fp)
328
329 def write_file(self, fileobject):
330 """Write the PKG-INFO format data to a file object."""
331 self._set_best_version()
332 for field in _version2fieldlist(self['Metadata-Version']):
333 values = self.get(field)
334 if field in _ELEMENTSFIELD:
335 self._write_field(fileobject, field, ','.join(values))
336 continue
337 if field not in _LISTFIELDS:
338 if field == 'Description':
339 values = values.replace('\n', '\n |')
340 values = [values]
341
342 if field in _LISTTUPLEFIELDS:
343 values = [','.join(value) for value in values]
344
345 for value in values:
346 self._write_field(fileobject, field, value)
347
348 def update(self, other=None, **kwargs):
349 """Set metadata values from the given iterable `other` and kwargs.
350
351 Behavior is like `dict.update`: If `other` has a ``keys`` method,
352 they are looped over and ``self[key]`` is assigned ``other[key]``.
353 Else, ``other`` is an iterable of ``(key, value)`` iterables.
354
355 Keys that don't match a metadata field or that have an empty value are
356 dropped.
357 """
Éric Araujo6bbd7752011-09-10 05:18:20 +0200358 # XXX the code should just use self.set, which does tbe same checks and
359 # conversions already, but that would break packaging.pypi: it uses the
360 # update method, which does not call _set_best_version (which set
361 # does), and thus allows having a Metadata object (as long as you don't
362 # modify or write it) with extra fields from PyPI that are not fields
363 # defined in Metadata PEPs. to solve it, the best_version system
364 # should be reworked so that it's called only for writing, or in a new
365 # strict mode, or with a new, more lax Metadata subclass in p7g.pypi
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200366 def _set(key, value):
367 if key in _ATTR2FIELD and value:
368 self.set(self._convert_name(key), value)
369
Éric Araujo6bbd7752011-09-10 05:18:20 +0200370 if not other:
371 # other is None or empty container
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200372 pass
373 elif hasattr(other, 'keys'):
374 for k in other.keys():
375 _set(k, other[k])
376 else:
377 for k, v in other:
378 _set(k, v)
379
380 if kwargs:
Éric Araujo6bbd7752011-09-10 05:18:20 +0200381 for k, v in kwargs.items():
382 _set(k, v)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200383
384 def set(self, name, value):
385 """Control then set a metadata field."""
386 name = self._convert_name(name)
387
388 if ((name in _ELEMENTSFIELD or name == 'Platform') and
389 not isinstance(value, (list, tuple))):
390 if isinstance(value, str):
391 value = [v.strip() for v in value.split(',')]
392 else:
393 value = []
394 elif (name in _LISTFIELDS and
395 not isinstance(value, (list, tuple))):
396 if isinstance(value, str):
397 value = [value]
398 else:
399 value = []
400
401 if logger.isEnabledFor(logging.WARNING):
Tarek Ziadeb9c09872011-05-30 12:25:38 +0200402 project_name = self['Name']
403
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200404 if name in _PREDICATE_FIELDS and value is not None:
405 for v in value:
406 # check that the values are valid predicates
407 if not is_valid_predicate(v.split(';')[0]):
408 logger.warning(
Tarek Ziadeb9c09872011-05-30 12:25:38 +0200409 '%r: %r is not a valid predicate (field %r)',
410 project_name, v, name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200411 # FIXME this rejects UNKNOWN, is that right?
412 elif name in _VERSIONS_FIELDS and value is not None:
413 if not is_valid_versions(value):
Tarek Ziadeb9c09872011-05-30 12:25:38 +0200414 logger.warning('%r: %r is not a valid version (field %r)',
415 project_name, value, name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200416 elif name in _VERSION_FIELDS and value is not None:
417 if not is_valid_version(value):
Tarek Ziadeb9c09872011-05-30 12:25:38 +0200418 logger.warning('%r: %r is not a valid version (field %r)',
419 project_name, value, name)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200420
421 if name in _UNICODEFIELDS:
422 if name == 'Description':
423 value = self._remove_line_prefix(value)
424
425 self._fields[name] = value
426 self._set_best_version()
427
428 def get(self, name, default=_MISSING):
429 """Get a metadata field."""
430 name = self._convert_name(name)
431 if name not in self._fields:
432 if default is _MISSING:
433 default = self._default_value(name)
434 return default
435 if name in _UNICODEFIELDS:
436 value = self._fields[name]
437 return value
438 elif name in _LISTFIELDS:
439 value = self._fields[name]
440 if value is None:
441 return []
442 res = []
443 for val in value:
444 valid, val = self._platform(val)
445 if not valid:
446 continue
447 if name not in _LISTTUPLEFIELDS:
448 res.append(val)
449 else:
450 # That's for Project-URL
451 res.append((val[0], val[1]))
452 return res
453
454 elif name in _ELEMENTSFIELD:
455 valid, value = self._platform(self._fields[name])
456 if not valid:
457 return []
458 if isinstance(value, str):
459 return value.split(',')
460 valid, value = self._platform(self._fields[name])
461 if not valid:
462 return None
463 return value
464
465 def check(self, strict=False, restructuredtext=False):
466 """Check if the metadata is compliant. If strict is False then raise if
467 no Name or Version are provided"""
468 # XXX should check the versions (if the file was loaded)
469 missing, warnings = [], []
470
471 for attr in ('Name', 'Version'): # required by PEP 345
472 if attr not in self:
473 missing.append(attr)
474
475 if strict and missing != []:
476 msg = 'missing required metadata: %s' % ', '.join(missing)
477 raise MetadataMissingError(msg)
478
479 for attr in ('Home-page', 'Author'):
480 if attr not in self:
481 missing.append(attr)
482
483 if _HAS_DOCUTILS and restructuredtext:
484 warnings.extend(self._check_rst_data(self['Description']))
485
486 # checking metadata 1.2 (XXX needs to check 1.1, 1.0)
487 if self['Metadata-Version'] != '1.2':
488 return missing, warnings
489
490 def is_valid_predicates(value):
491 for v in value:
492 if not is_valid_predicate(v.split(';')[0]):
493 return False
494 return True
495
496 for fields, controller in ((_PREDICATE_FIELDS, is_valid_predicates),
497 (_VERSIONS_FIELDS, is_valid_versions),
498 (_VERSION_FIELDS, is_valid_version)):
499 for field in fields:
500 value = self.get(field, None)
501 if value is not None and not controller(value):
502 warnings.append('Wrong value for %r: %s' % (field, value))
503
504 return missing, warnings
505
506 def todict(self):
507 """Return fields as a dict.
508
509 Field names will be converted to use the underscore-lowercase style
510 instead of hyphen-mixed case (i.e. home_page instead of Home-page).
511 """
512 data = {
513 'metadata_version': self['Metadata-Version'],
514 'name': self['Name'],
515 'version': self['Version'],
516 'summary': self['Summary'],
517 'home_page': self['Home-page'],
518 'author': self['Author'],
519 'author_email': self['Author-email'],
520 'license': self['License'],
521 'description': self['Description'],
522 'keywords': self['Keywords'],
523 'platform': self['Platform'],
524 'classifier': self['Classifier'],
525 'download_url': self['Download-URL'],
526 }
527
528 if self['Metadata-Version'] == '1.2':
529 data['requires_dist'] = self['Requires-Dist']
530 data['requires_python'] = self['Requires-Python']
531 data['requires_external'] = self['Requires-External']
532 data['provides_dist'] = self['Provides-Dist']
533 data['obsoletes_dist'] = self['Obsoletes-Dist']
534 data['project_url'] = [','.join(url) for url in
535 self['Project-URL']]
536
537 elif self['Metadata-Version'] == '1.1':
538 data['provides'] = self['Provides']
539 data['requires'] = self['Requires']
540 data['obsoletes'] = self['Obsoletes']
541
542 return data
543
544 # Mapping API
545
546 def keys(self):
547 return _version2fieldlist(self['Metadata-Version'])
548
549 def __iter__(self):
550 for key in self.keys():
551 yield key
552
553 def values(self):
554 return [self[key] for key in list(self.keys())]
555
556 def items(self):
557 return [(key, self[key]) for key in list(self.keys())]