ยปCore Development>Code coverage>Lib/packaging/metadata.py

Python code coverage for Lib/packaging/metadata.py

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