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