blob: 302d61d505cb3a3a97fc136375d63b87638b4f1a [file] [log] [blame]
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04001import io
Jason R. Coombs8ed65032019-09-12 10:29:11 +01002import os
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04003import re
4import abc
5import csv
6import sys
7import email
8import pathlib
Jason R. Coombs8ed65032019-09-12 10:29:11 +01009import zipfile
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040010import operator
11import functools
12import itertools
Jason R. Coombs136735c2020-01-11 10:37:28 -050013import posixpath
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040014import collections
15
16from configparser import ConfigParser
17from contextlib import suppress
18from importlib import import_module
19from importlib.abc import MetaPathFinder
20from itertools import starmap
21
22
23__all__ = [
24 'Distribution',
Jason R. Coombs17499d82019-09-10 14:53:31 +010025 'DistributionFinder',
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040026 'PackageNotFoundError',
27 'distribution',
28 'distributions',
29 'entry_points',
30 'files',
31 'metadata',
32 'requires',
33 'version',
34 ]
35
36
37class PackageNotFoundError(ModuleNotFoundError):
38 """The package was not found."""
39
Barry Warsaw96ddc582020-10-19 14:14:21 -070040 def __str__(self):
41 tmpl = "No package metadata was found for {self.name}"
42 return tmpl.format(**locals())
43
44 @property
45 def name(self):
46 name, = self.args
47 return name
48
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040049
Jason R. Coombsb7a01092019-12-10 20:05:10 -050050class EntryPoint(
51 collections.namedtuple('EntryPointBase', 'name value group')):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040052 """An entry point as defined by Python packaging conventions.
53
54 See `the packaging docs on entry points
55 <https://packaging.python.org/specifications/entry-points/>`_
56 for more information.
57 """
58
59 pattern = re.compile(
60 r'(?P<module>[\w.]+)\s*'
61 r'(:\s*(?P<attr>[\w.]+))?\s*'
62 r'(?P<extras>\[.*\])?\s*$'
63 )
64 """
65 A regular expression describing the syntax for an entry point,
66 which might look like:
67
68 - module
69 - package.module
70 - package.module:attribute
71 - package.module:object.attribute
72 - package.module:attr [extra1, extra2]
73
74 Other combinations are possible as well.
75
76 The expression is lenient about whitespace around the ':',
77 following the attr, and following any extras.
78 """
79
80 def load(self):
81 """Load the entry point from its definition. If only a module
82 is indicated by the value, return that module. Otherwise,
83 return the named object.
84 """
85 match = self.pattern.match(self.value)
86 module = import_module(match.group('module'))
87 attrs = filter(None, (match.group('attr') or '').split('.'))
88 return functools.reduce(getattr, attrs, module)
89
90 @property
Jason R. Coombs161541a2020-06-05 16:34:16 -040091 def module(self):
92 match = self.pattern.match(self.value)
93 return match.group('module')
94
95 @property
96 def attr(self):
97 match = self.pattern.match(self.value)
98 return match.group('attr')
99
100 @property
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400101 def extras(self):
102 match = self.pattern.match(self.value)
103 return list(re.finditer(r'\w+', match.group('extras') or ''))
104
105 @classmethod
106 def _from_config(cls, config):
107 return [
108 cls(name, value, group)
109 for group in config.sections()
110 for name, value in config.items(group)
111 ]
112
113 @classmethod
114 def _from_text(cls, text):
Jason R. Coombs049460d2019-07-28 14:59:24 -0400115 config = ConfigParser(delimiters='=')
Anthony Sottile65e58602019-06-07 14:23:39 -0700116 # case sensitive: https://stackoverflow.com/q/1611799/812183
117 config.optionxform = str
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400118 try:
119 config.read_string(text)
120 except AttributeError: # pragma: nocover
121 # Python 2 has no read_string
122 config.readfp(io.StringIO(text))
123 return EntryPoint._from_config(config)
124
125 def __iter__(self):
126 """
127 Supply iter so one may construct dicts of EntryPoints easily.
128 """
129 return iter((self.name, self))
130
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500131 def __reduce__(self):
132 return (
133 self.__class__,
134 (self.name, self.value, self.group),
135 )
136
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400137
138class PackagePath(pathlib.PurePosixPath):
139 """A reference to a path in a package"""
140
141 def read_text(self, encoding='utf-8'):
142 with self.locate().open(encoding=encoding) as stream:
143 return stream.read()
144
145 def read_binary(self):
146 with self.locate().open('rb') as stream:
147 return stream.read()
148
149 def locate(self):
150 """Return a path-like object for this path"""
151 return self.dist.locate_file(self)
152
153
154class FileHash:
155 def __init__(self, spec):
156 self.mode, _, self.value = spec.partition('=')
157
158 def __repr__(self):
159 return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
160
161
162class Distribution:
163 """A Python distribution package."""
164
165 @abc.abstractmethod
166 def read_text(self, filename):
167 """Attempt to load metadata file given by the name.
168
169 :param filename: The name of the file in the distribution info.
170 :return: The text if found, otherwise None.
171 """
172
173 @abc.abstractmethod
174 def locate_file(self, path):
175 """
176 Given a path to a file in this distribution, return a path
177 to it.
178 """
179
180 @classmethod
181 def from_name(cls, name):
182 """Return the Distribution for the given package name.
183
184 :param name: The name of the distribution package to search for.
185 :return: The Distribution instance (or subclass thereof) for the named
186 package, if found.
187 :raises PackageNotFoundError: When the named package's distribution
188 metadata cannot be found.
189 """
190 for resolver in cls._discover_resolvers():
Jason R. Coombs17499d82019-09-10 14:53:31 +0100191 dists = resolver(DistributionFinder.Context(name=name))
Jason R. Coombs161541a2020-06-05 16:34:16 -0400192 dist = next(iter(dists), None)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400193 if dist is not None:
194 return dist
195 else:
196 raise PackageNotFoundError(name)
197
198 @classmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100199 def discover(cls, **kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400200 """Return an iterable of Distribution objects for all packages.
201
Jason R. Coombs17499d82019-09-10 14:53:31 +0100202 Pass a ``context`` or pass keyword arguments for constructing
203 a context.
204
205 :context: A ``DistributionFinder.Context`` object.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400206 :return: Iterable of Distribution objects for all packages.
207 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100208 context = kwargs.pop('context', None)
209 if context and kwargs:
210 raise ValueError("cannot accept context and kwargs")
211 context = context or DistributionFinder.Context(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400212 return itertools.chain.from_iterable(
Jason R. Coombs17499d82019-09-10 14:53:31 +0100213 resolver(context)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400214 for resolver in cls._discover_resolvers()
215 )
216
217 @staticmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100218 def at(path):
219 """Return a Distribution for the indicated metadata path
220
221 :param path: a string or path-like object
222 :return: a concrete Distribution instance for the path
223 """
224 return PathDistribution(pathlib.Path(path))
225
226 @staticmethod
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400227 def _discover_resolvers():
228 """Search the meta_path for resolvers."""
229 declared = (
230 getattr(finder, 'find_distributions', None)
231 for finder in sys.meta_path
232 )
233 return filter(None, declared)
234
Jason R. Coombs161541a2020-06-05 16:34:16 -0400235 @classmethod
236 def _local(cls, root='.'):
237 from pep517 import build, meta
238 system = build.compat_system(root)
239 builder = functools.partial(
240 meta.build,
241 source_dir=root,
242 system=system,
243 )
244 return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
245
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400246 @property
247 def metadata(self):
248 """Return the parsed metadata for this Distribution.
249
250 The returned object will have keys that name the various bits of
251 metadata. See PEP 566 for details.
252 """
253 text = (
254 self.read_text('METADATA')
255 or self.read_text('PKG-INFO')
256 # This last clause is here to support old egg-info files. Its
257 # effect is to just end up using the PathDistribution's self._path
258 # (which points to the egg-info file) attribute unchanged.
259 or self.read_text('')
260 )
261 return email.message_from_string(text)
262
263 @property
264 def version(self):
265 """Return the 'Version' metadata for the distribution package."""
266 return self.metadata['Version']
267
268 @property
269 def entry_points(self):
270 return EntryPoint._from_text(self.read_text('entry_points.txt'))
271
272 @property
273 def files(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400274 """Files in this distribution.
275
Jason R. Coombs17499d82019-09-10 14:53:31 +0100276 :return: List of PackagePath for this distribution or None
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400277
278 Result is `None` if the metadata file that enumerates files
279 (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
280 missing.
281 Result may be empty if the metadata exists but is empty.
282 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400283 file_lines = self._read_files_distinfo() or self._read_files_egginfo()
284
285 def make_file(name, hash=None, size_str=None):
286 result = PackagePath(name)
287 result.hash = FileHash(hash) if hash else None
288 result.size = int(size_str) if size_str else None
289 result.dist = self
290 return result
291
Jason R. Coombs17499d82019-09-10 14:53:31 +0100292 return file_lines and list(starmap(make_file, csv.reader(file_lines)))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400293
294 def _read_files_distinfo(self):
295 """
296 Read the lines of RECORD
297 """
298 text = self.read_text('RECORD')
299 return text and text.splitlines()
300
301 def _read_files_egginfo(self):
302 """
303 SOURCES.txt might contain literal commas, so wrap each line
304 in quotes.
305 """
306 text = self.read_text('SOURCES.txt')
307 return text and map('"{}"'.format, text.splitlines())
308
309 @property
310 def requires(self):
311 """Generated requirements specified for this Distribution"""
Jason R. Coombs17499d82019-09-10 14:53:31 +0100312 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
313 return reqs and list(reqs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400314
315 def _read_dist_info_reqs(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400316 return self.metadata.get_all('Requires-Dist')
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400317
318 def _read_egg_info_reqs(self):
319 source = self.read_text('requires.txt')
320 return source and self._deps_from_requires_text(source)
321
322 @classmethod
323 def _deps_from_requires_text(cls, source):
324 section_pairs = cls._read_sections(source.splitlines())
325 sections = {
326 section: list(map(operator.itemgetter('line'), results))
327 for section, results in
328 itertools.groupby(section_pairs, operator.itemgetter('section'))
329 }
330 return cls._convert_egg_info_reqs_to_simple_reqs(sections)
331
332 @staticmethod
333 def _read_sections(lines):
334 section = None
335 for line in filter(None, lines):
336 section_match = re.match(r'\[(.*)\]$', line)
337 if section_match:
338 section = section_match.group(1)
339 continue
340 yield locals()
341
342 @staticmethod
343 def _convert_egg_info_reqs_to_simple_reqs(sections):
344 """
345 Historically, setuptools would solicit and store 'extra'
346 requirements, including those with environment markers,
347 in separate sections. More modern tools expect each
348 dependency to be defined separately, with any relevant
349 extras and environment markers attached directly to that
350 requirement. This method converts the former to the
351 latter. See _test_deps_from_requires_text for an example.
352 """
353 def make_condition(name):
354 return name and 'extra == "{name}"'.format(name=name)
355
356 def parse_condition(section):
357 section = section or ''
358 extra, sep, markers = section.partition(':')
359 if extra and markers:
360 markers = '({markers})'.format(markers=markers)
361 conditions = list(filter(None, [markers, make_condition(extra)]))
362 return '; ' + ' and '.join(conditions) if conditions else ''
363
364 for section, deps in sections.items():
365 for dep in deps:
366 yield dep + parse_condition(section)
367
368
369class DistributionFinder(MetaPathFinder):
370 """
371 A MetaPathFinder capable of discovering installed distributions.
372 """
373
Jason R. Coombs17499d82019-09-10 14:53:31 +0100374 class Context:
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500375 """
376 Keyword arguments presented by the caller to
377 ``distributions()`` or ``Distribution.discover()``
378 to narrow the scope of a search for distributions
379 in all DistributionFinders.
380
381 Each DistributionFinder may expect any parameters
382 and should attempt to honor the canonical
383 parameters defined below when appropriate.
384 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100385
386 name = None
387 """
388 Specific name for which a distribution finder should match.
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500389 A name of ``None`` matches all distributions.
Jason R. Coombs17499d82019-09-10 14:53:31 +0100390 """
391
392 def __init__(self, **kwargs):
393 vars(self).update(kwargs)
394
395 @property
396 def path(self):
397 """
398 The path that a distribution finder should search.
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500399
400 Typically refers to Python package paths and defaults
401 to ``sys.path``.
Jason R. Coombs17499d82019-09-10 14:53:31 +0100402 """
403 return vars(self).get('path', sys.path)
404
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400405 @abc.abstractmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100406 def find_distributions(self, context=Context()):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400407 """
408 Find distributions.
409
410 Return an iterable of all Distribution instances capable of
Jason R. Coombs17499d82019-09-10 14:53:31 +0100411 loading the metadata for packages matching the ``context``,
412 a DistributionFinder.Context instance.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400413 """
414
415
Jason R. Coombs136735c2020-01-11 10:37:28 -0500416class FastPath:
417 """
418 Micro-optimized class for searching a path for
419 children.
420 """
421
422 def __init__(self, root):
423 self.root = root
Jason R. Coombs161541a2020-06-05 16:34:16 -0400424 self.base = os.path.basename(self.root).lower()
Jason R. Coombs136735c2020-01-11 10:37:28 -0500425
426 def joinpath(self, child):
427 return pathlib.Path(self.root, child)
428
429 def children(self):
430 with suppress(Exception):
431 return os.listdir(self.root or '')
432 with suppress(Exception):
433 return self.zip_children()
434 return []
435
436 def zip_children(self):
437 zip_path = zipfile.Path(self.root)
438 names = zip_path.root.namelist()
439 self.joinpath = zip_path.joinpath
440
Jason R. Coombs161541a2020-06-05 16:34:16 -0400441 return dict.fromkeys(
442 child.split(posixpath.sep, 1)[0]
Jason R. Coombs136735c2020-01-11 10:37:28 -0500443 for child in names
444 )
445
446 def is_egg(self, search):
Jason R. Coombse5bd7362020-02-11 21:58:47 -0500447 base = self.base
Jason R. Coombs136735c2020-01-11 10:37:28 -0500448 return (
Jason R. Coombse5bd7362020-02-11 21:58:47 -0500449 base == search.versionless_egg_name
450 or base.startswith(search.prefix)
451 and base.endswith('.egg'))
Jason R. Coombs136735c2020-01-11 10:37:28 -0500452
453 def search(self, name):
454 for child in self.children():
455 n_low = child.lower()
456 if (n_low in name.exact_matches
457 or n_low.startswith(name.prefix)
458 and n_low.endswith(name.suffixes)
459 # legacy case:
460 or self.is_egg(name) and n_low == 'egg-info'):
461 yield self.joinpath(child)
462
463
464class Prepared:
465 """
466 A prepared search for metadata on a possibly-named package.
467 """
468 normalized = ''
469 prefix = ''
470 suffixes = '.dist-info', '.egg-info'
471 exact_matches = [''][:0]
Jason R. Coombse5bd7362020-02-11 21:58:47 -0500472 versionless_egg_name = ''
Jason R. Coombs136735c2020-01-11 10:37:28 -0500473
474 def __init__(self, name):
475 self.name = name
476 if name is None:
477 return
478 self.normalized = name.lower().replace('-', '_')
479 self.prefix = self.normalized + '-'
480 self.exact_matches = [
481 self.normalized + suffix for suffix in self.suffixes]
Jason R. Coombse5bd7362020-02-11 21:58:47 -0500482 self.versionless_egg_name = self.normalized + '.egg'
Jason R. Coombs136735c2020-01-11 10:37:28 -0500483
484
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100485class MetadataPathFinder(DistributionFinder):
486 @classmethod
487 def find_distributions(cls, context=DistributionFinder.Context()):
488 """
489 Find distributions.
490
491 Return an iterable of all Distribution instances capable of
492 loading the metadata for packages matching ``context.name``
493 (or all names if ``None`` indicated) along the paths in the list
494 of directories ``context.path``.
495 """
Jason R. Coombs136735c2020-01-11 10:37:28 -0500496 found = cls._search_paths(context.name, context.path)
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100497 return map(PathDistribution, found)
498
499 @classmethod
Jason R. Coombs136735c2020-01-11 10:37:28 -0500500 def _search_paths(cls, name, paths):
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100501 """Find metadata directories in paths heuristically."""
502 return itertools.chain.from_iterable(
Jason R. Coombs136735c2020-01-11 10:37:28 -0500503 path.search(Prepared(name))
504 for path in map(FastPath, paths)
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100505 )
506
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100507
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400508class PathDistribution(Distribution):
509 def __init__(self, path):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400510 """Construct a distribution from a path to the metadata directory.
511
512 :param path: A pathlib.Path or similar object supporting
513 .joinpath(), __div__, .parent, and .read_text().
514 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400515 self._path = path
516
517 def read_text(self, filename):
Anthony Sottile80878312019-05-29 17:13:12 -0700518 with suppress(FileNotFoundError, IsADirectoryError, KeyError,
519 NotADirectoryError, PermissionError):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400520 return self._path.joinpath(filename).read_text(encoding='utf-8')
521 read_text.__doc__ = Distribution.read_text.__doc__
522
523 def locate_file(self, path):
524 return self._path.parent / path
525
526
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100527def distribution(distribution_name):
528 """Get the ``Distribution`` instance for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400529
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100530 :param distribution_name: The name of the distribution package as a string.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400531 :return: A ``Distribution`` instance (or subclass thereof).
532 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100533 return Distribution.from_name(distribution_name)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400534
535
Jason R. Coombs17499d82019-09-10 14:53:31 +0100536def distributions(**kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400537 """Get all ``Distribution`` instances in the current environment.
538
539 :return: An iterable of ``Distribution`` instances.
540 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100541 return Distribution.discover(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400542
543
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100544def metadata(distribution_name):
545 """Get the metadata for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400546
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100547 :param distribution_name: The name of the distribution package to query.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400548 :return: An email.Message containing the parsed metadata.
549 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100550 return Distribution.from_name(distribution_name).metadata
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400551
552
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100553def version(distribution_name):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400554 """Get the version string for the named package.
555
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100556 :param distribution_name: The name of the distribution package to query.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400557 :return: The version string for the package as defined in the package's
558 "Version" metadata key.
559 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100560 return distribution(distribution_name).version
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400561
562
563def entry_points():
564 """Return EntryPoint objects for all installed packages.
565
566 :return: EntryPoint objects for all installed packages.
567 """
568 eps = itertools.chain.from_iterable(
569 dist.entry_points for dist in distributions())
570 by_group = operator.attrgetter('group')
571 ordered = sorted(eps, key=by_group)
572 grouped = itertools.groupby(ordered, by_group)
573 return {
574 group: tuple(eps)
575 for group, eps in grouped
576 }
577
578
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100579def files(distribution_name):
580 """Return a list of files for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400581
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100582 :param distribution_name: The name of the distribution package to query.
583 :return: List of files composing the distribution.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400584 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100585 return distribution(distribution_name).files
586
587
588def requires(distribution_name):
589 """
590 Return a list of requirements for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400591
592 :return: An iterator of requirements, suitable for
593 packaging.requirement.Requirement.
594 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100595 return distribution(distribution_name).requires