blob: 8a731858cad704c51a3dde48bb1ed7f71be3e2ee [file] [log] [blame]
Jason R. Coombs8ed65032019-09-12 10:29:11 +01001import os
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04002import re
3import abc
4import csv
5import sys
6import email
Jason R. Coombsf917efc2021-03-13 11:31:45 -05007import inspect
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04008import pathlib
Jason R. Coombs8ed65032019-09-12 10:29:11 +01009import zipfile
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040010import operator
Jason R. Coombsf917efc2021-03-13 11:31:45 -050011import warnings
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040012import functools
13import itertools
Jason R. Coombs136735c2020-01-11 10:37:28 -050014import posixpath
Jason R. Coombsf917efc2021-03-13 11:31:45 -050015import collections.abc
16
17from ._itertools import unique_everseen
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040018
19from configparser import ConfigParser
20from contextlib import suppress
21from importlib import import_module
22from importlib.abc import MetaPathFinder
23from itertools import starmap
Jason R. Coombsf917efc2021-03-13 11:31:45 -050024from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040025
26
27__all__ = [
28 'Distribution',
Jason R. Coombs17499d82019-09-10 14:53:31 +010029 'DistributionFinder',
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040030 'PackageNotFoundError',
31 'distribution',
32 'distributions',
33 'entry_points',
34 'files',
35 'metadata',
36 'requires',
37 'version',
Jason R. Coombsdfdca852020-12-31 12:56:43 -050038]
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040039
40
41class PackageNotFoundError(ModuleNotFoundError):
42 """The package was not found."""
43
Barry Warsaw96ddc582020-10-19 14:14:21 -070044 def __str__(self):
45 tmpl = "No package metadata was found for {self.name}"
46 return tmpl.format(**locals())
47
48 @property
49 def name(self):
Jason R. Coombsdfdca852020-12-31 12:56:43 -050050 (name,) = self.args
Barry Warsaw96ddc582020-10-19 14:14:21 -070051 return name
52
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040053
Jason R. Coombsb7a01092019-12-10 20:05:10 -050054class EntryPoint(
55 collections.namedtuple('EntryPointBase', 'name value group')):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040056 """An entry point as defined by Python packaging conventions.
57
58 See `the packaging docs on entry points
59 <https://packaging.python.org/specifications/entry-points/>`_
60 for more information.
61 """
62
63 pattern = re.compile(
64 r'(?P<module>[\w.]+)\s*'
65 r'(:\s*(?P<attr>[\w.]+))?\s*'
66 r'(?P<extras>\[.*\])?\s*$'
Jason R. Coombsdfdca852020-12-31 12:56:43 -050067 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040068 """
69 A regular expression describing the syntax for an entry point,
70 which might look like:
71
72 - module
73 - package.module
74 - package.module:attribute
75 - package.module:object.attribute
76 - package.module:attr [extra1, extra2]
77
78 Other combinations are possible as well.
79
80 The expression is lenient about whitespace around the ':',
81 following the attr, and following any extras.
82 """
83
Jason R. Coombsdfdca852020-12-31 12:56:43 -050084 dist: Optional['Distribution'] = None
85
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040086 def load(self):
87 """Load the entry point from its definition. If only a module
88 is indicated by the value, return that module. Otherwise,
89 return the named object.
90 """
91 match = self.pattern.match(self.value)
92 module = import_module(match.group('module'))
93 attrs = filter(None, (match.group('attr') or '').split('.'))
94 return functools.reduce(getattr, attrs, module)
95
96 @property
Jason R. Coombs161541a2020-06-05 16:34:16 -040097 def module(self):
98 match = self.pattern.match(self.value)
99 return match.group('module')
100
101 @property
102 def attr(self):
103 match = self.pattern.match(self.value)
104 return match.group('attr')
105
106 @property
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400107 def extras(self):
108 match = self.pattern.match(self.value)
109 return list(re.finditer(r'\w+', match.group('extras') or ''))
110
111 @classmethod
112 def _from_config(cls, config):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500113 return (
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400114 cls(name, value, group)
115 for group in config.sections()
116 for name, value in config.items(group)
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500117 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400118
119 @classmethod
120 def _from_text(cls, text):
Jason R. Coombs049460d2019-07-28 14:59:24 -0400121 config = ConfigParser(delimiters='=')
Anthony Sottile65e58602019-06-07 14:23:39 -0700122 # case sensitive: https://stackoverflow.com/q/1611799/812183
123 config.optionxform = str
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500124 config.read_string(text)
125 return cls._from_config(config)
126
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500127 def _for(self, dist):
128 self.dist = dist
129 return self
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400130
131 def __iter__(self):
132 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500133 Supply iter so one may construct dicts of EntryPoints by name.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400134 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500135 msg = (
136 "Construction of dict of EntryPoints is deprecated in "
137 "favor of EntryPoints."
138 )
139 warnings.warn(msg, DeprecationWarning)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400140 return iter((self.name, self))
141
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500142 def __reduce__(self):
143 return (
144 self.__class__,
145 (self.name, self.value, self.group),
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500146 )
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500147
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500148 def matches(self, **params):
149 attrs = (getattr(self, param) for param in params)
150 return all(map(operator.eq, params.values(), attrs))
151
152
153class EntryPoints(tuple):
154 """
155 An immutable collection of selectable EntryPoint objects.
156 """
157
158 __slots__ = ()
159
160 def __getitem__(self, name): # -> EntryPoint:
161 try:
162 return next(iter(self.select(name=name)))
163 except StopIteration:
164 raise KeyError(name)
165
166 def select(self, **params):
167 return EntryPoints(ep for ep in self if ep.matches(**params))
168
169 @property
170 def names(self):
171 return set(ep.name for ep in self)
172
173 @property
174 def groups(self):
175 """
176 For coverage while SelectableGroups is present.
177 >>> EntryPoints().groups
178 set()
179 """
180 return set(ep.group for ep in self)
181
182 @classmethod
183 def _from_text_for(cls, text, dist):
184 return cls(ep._for(dist) for ep in EntryPoint._from_text(text))
185
186
187def flake8_bypass(func):
188 is_flake8 = any('flake8' in str(frame.filename) for frame in inspect.stack()[:5])
189 return func if not is_flake8 else lambda: None
190
191
192class Deprecated:
193 """
194 Compatibility add-in for mapping to indicate that
195 mapping behavior is deprecated.
196
197 >>> recwarn = getfixture('recwarn')
198 >>> class DeprecatedDict(Deprecated, dict): pass
199 >>> dd = DeprecatedDict(foo='bar')
200 >>> dd.get('baz', None)
201 >>> dd['foo']
202 'bar'
203 >>> list(dd)
204 ['foo']
205 >>> list(dd.keys())
206 ['foo']
207 >>> 'foo' in dd
208 True
209 >>> list(dd.values())
210 ['bar']
211 >>> len(recwarn)
212 1
213 """
214
215 _warn = functools.partial(
216 warnings.warn,
217 "SelectableGroups dict interface is deprecated. Use select.",
218 DeprecationWarning,
219 stacklevel=2,
220 )
221
222 def __getitem__(self, name):
223 self._warn()
224 return super().__getitem__(name)
225
226 def get(self, name, default=None):
227 flake8_bypass(self._warn)()
228 return super().get(name, default)
229
230 def __iter__(self):
231 self._warn()
232 return super().__iter__()
233
234 def __contains__(self, *args):
235 self._warn()
236 return super().__contains__(*args)
237
238 def keys(self):
239 self._warn()
240 return super().keys()
241
242 def values(self):
243 self._warn()
244 return super().values()
245
246
247class SelectableGroups(dict):
248 """
249 A backward- and forward-compatible result from
250 entry_points that fully implements the dict interface.
251 """
252
253 @classmethod
254 def load(cls, eps):
255 by_group = operator.attrgetter('group')
256 ordered = sorted(eps, key=by_group)
257 grouped = itertools.groupby(ordered, by_group)
258 return cls((group, EntryPoints(eps)) for group, eps in grouped)
259
260 @property
261 def _all(self):
262 """
263 Reconstruct a list of all entrypoints from the groups.
264 """
265 return EntryPoints(itertools.chain.from_iterable(self.values()))
266
267 @property
268 def groups(self):
269 return self._all.groups
270
271 @property
272 def names(self):
273 """
274 for coverage:
275 >>> SelectableGroups().names
276 set()
277 """
278 return self._all.names
279
280 def select(self, **params):
281 if not params:
282 return self
283 return self._all.select(**params)
284
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400285
286class PackagePath(pathlib.PurePosixPath):
287 """A reference to a path in a package"""
288
289 def read_text(self, encoding='utf-8'):
290 with self.locate().open(encoding=encoding) as stream:
291 return stream.read()
292
293 def read_binary(self):
294 with self.locate().open('rb') as stream:
295 return stream.read()
296
297 def locate(self):
298 """Return a path-like object for this path"""
299 return self.dist.locate_file(self)
300
301
302class FileHash:
303 def __init__(self, spec):
304 self.mode, _, self.value = spec.partition('=')
305
306 def __repr__(self):
307 return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
308
309
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500310_T = TypeVar("_T")
311
312
313class PackageMetadata(Protocol):
314 def __len__(self) -> int:
315 ... # pragma: no cover
316
317 def __contains__(self, item: str) -> bool:
318 ... # pragma: no cover
319
320 def __getitem__(self, key: str) -> str:
321 ... # pragma: no cover
322
323 def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
324 """
325 Return all values associated with a possibly multi-valued key.
326 """
327
328
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400329class Distribution:
330 """A Python distribution package."""
331
332 @abc.abstractmethod
333 def read_text(self, filename):
334 """Attempt to load metadata file given by the name.
335
336 :param filename: The name of the file in the distribution info.
337 :return: The text if found, otherwise None.
338 """
339
340 @abc.abstractmethod
341 def locate_file(self, path):
342 """
343 Given a path to a file in this distribution, return a path
344 to it.
345 """
346
347 @classmethod
348 def from_name(cls, name):
349 """Return the Distribution for the given package name.
350
351 :param name: The name of the distribution package to search for.
352 :return: The Distribution instance (or subclass thereof) for the named
353 package, if found.
354 :raises PackageNotFoundError: When the named package's distribution
355 metadata cannot be found.
356 """
357 for resolver in cls._discover_resolvers():
Jason R. Coombs17499d82019-09-10 14:53:31 +0100358 dists = resolver(DistributionFinder.Context(name=name))
Jason R. Coombs161541a2020-06-05 16:34:16 -0400359 dist = next(iter(dists), None)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400360 if dist is not None:
361 return dist
362 else:
363 raise PackageNotFoundError(name)
364
365 @classmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100366 def discover(cls, **kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400367 """Return an iterable of Distribution objects for all packages.
368
Jason R. Coombs17499d82019-09-10 14:53:31 +0100369 Pass a ``context`` or pass keyword arguments for constructing
370 a context.
371
372 :context: A ``DistributionFinder.Context`` object.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400373 :return: Iterable of Distribution objects for all packages.
374 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100375 context = kwargs.pop('context', None)
376 if context and kwargs:
377 raise ValueError("cannot accept context and kwargs")
378 context = context or DistributionFinder.Context(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400379 return itertools.chain.from_iterable(
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500380 resolver(context) for resolver in cls._discover_resolvers()
381 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400382
383 @staticmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100384 def at(path):
385 """Return a Distribution for the indicated metadata path
386
387 :param path: a string or path-like object
388 :return: a concrete Distribution instance for the path
389 """
390 return PathDistribution(pathlib.Path(path))
391
392 @staticmethod
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400393 def _discover_resolvers():
394 """Search the meta_path for resolvers."""
395 declared = (
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500396 getattr(finder, 'find_distributions', None) for finder in sys.meta_path
397 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400398 return filter(None, declared)
399
Jason R. Coombs161541a2020-06-05 16:34:16 -0400400 @classmethod
401 def _local(cls, root='.'):
402 from pep517 import build, meta
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500403
Jason R. Coombs161541a2020-06-05 16:34:16 -0400404 system = build.compat_system(root)
405 builder = functools.partial(
406 meta.build,
407 source_dir=root,
408 system=system,
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500409 )
Jason R. Coombs161541a2020-06-05 16:34:16 -0400410 return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
411
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400412 @property
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500413 def metadata(self) -> PackageMetadata:
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400414 """Return the parsed metadata for this Distribution.
415
416 The returned object will have keys that name the various bits of
417 metadata. See PEP 566 for details.
418 """
419 text = (
420 self.read_text('METADATA')
421 or self.read_text('PKG-INFO')
422 # This last clause is here to support old egg-info files. Its
423 # effect is to just end up using the PathDistribution's self._path
424 # (which points to the egg-info file) attribute unchanged.
425 or self.read_text('')
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500426 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400427 return email.message_from_string(text)
428
429 @property
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500430 def name(self):
431 """Return the 'Name' metadata for the distribution package."""
432 return self.metadata['Name']
433
434 @property
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400435 def version(self):
436 """Return the 'Version' metadata for the distribution package."""
437 return self.metadata['Version']
438
439 @property
440 def entry_points(self):
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500441 return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400442
443 @property
444 def files(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400445 """Files in this distribution.
446
Jason R. Coombs17499d82019-09-10 14:53:31 +0100447 :return: List of PackagePath for this distribution or None
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400448
449 Result is `None` if the metadata file that enumerates files
450 (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
451 missing.
452 Result may be empty if the metadata exists but is empty.
453 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400454 file_lines = self._read_files_distinfo() or self._read_files_egginfo()
455
456 def make_file(name, hash=None, size_str=None):
457 result = PackagePath(name)
458 result.hash = FileHash(hash) if hash else None
459 result.size = int(size_str) if size_str else None
460 result.dist = self
461 return result
462
Jason R. Coombs17499d82019-09-10 14:53:31 +0100463 return file_lines and list(starmap(make_file, csv.reader(file_lines)))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400464
465 def _read_files_distinfo(self):
466 """
467 Read the lines of RECORD
468 """
469 text = self.read_text('RECORD')
470 return text and text.splitlines()
471
472 def _read_files_egginfo(self):
473 """
474 SOURCES.txt might contain literal commas, so wrap each line
475 in quotes.
476 """
477 text = self.read_text('SOURCES.txt')
478 return text and map('"{}"'.format, text.splitlines())
479
480 @property
481 def requires(self):
482 """Generated requirements specified for this Distribution"""
Jason R. Coombs17499d82019-09-10 14:53:31 +0100483 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
484 return reqs and list(reqs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400485
486 def _read_dist_info_reqs(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400487 return self.metadata.get_all('Requires-Dist')
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400488
489 def _read_egg_info_reqs(self):
490 source = self.read_text('requires.txt')
491 return source and self._deps_from_requires_text(source)
492
493 @classmethod
494 def _deps_from_requires_text(cls, source):
495 section_pairs = cls._read_sections(source.splitlines())
496 sections = {
497 section: list(map(operator.itemgetter('line'), results))
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500498 for section, results in itertools.groupby(
499 section_pairs, operator.itemgetter('section')
500 )
501 }
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400502 return cls._convert_egg_info_reqs_to_simple_reqs(sections)
503
504 @staticmethod
505 def _read_sections(lines):
506 section = None
507 for line in filter(None, lines):
508 section_match = re.match(r'\[(.*)\]$', line)
509 if section_match:
510 section = section_match.group(1)
511 continue
512 yield locals()
513
514 @staticmethod
515 def _convert_egg_info_reqs_to_simple_reqs(sections):
516 """
517 Historically, setuptools would solicit and store 'extra'
518 requirements, including those with environment markers,
519 in separate sections. More modern tools expect each
520 dependency to be defined separately, with any relevant
521 extras and environment markers attached directly to that
522 requirement. This method converts the former to the
523 latter. See _test_deps_from_requires_text for an example.
524 """
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500525
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400526 def make_condition(name):
527 return name and 'extra == "{name}"'.format(name=name)
528
529 def parse_condition(section):
530 section = section or ''
531 extra, sep, markers = section.partition(':')
532 if extra and markers:
533 markers = '({markers})'.format(markers=markers)
534 conditions = list(filter(None, [markers, make_condition(extra)]))
535 return '; ' + ' and '.join(conditions) if conditions else ''
536
537 for section, deps in sections.items():
538 for dep in deps:
539 yield dep + parse_condition(section)
540
541
542class DistributionFinder(MetaPathFinder):
543 """
544 A MetaPathFinder capable of discovering installed distributions.
545 """
546
Jason R. Coombs17499d82019-09-10 14:53:31 +0100547 class Context:
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500548 """
549 Keyword arguments presented by the caller to
550 ``distributions()`` or ``Distribution.discover()``
551 to narrow the scope of a search for distributions
552 in all DistributionFinders.
553
554 Each DistributionFinder may expect any parameters
555 and should attempt to honor the canonical
556 parameters defined below when appropriate.
557 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100558
559 name = None
560 """
561 Specific name for which a distribution finder should match.
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500562 A name of ``None`` matches all distributions.
Jason R. Coombs17499d82019-09-10 14:53:31 +0100563 """
564
565 def __init__(self, **kwargs):
566 vars(self).update(kwargs)
567
568 @property
569 def path(self):
570 """
571 The path that a distribution finder should search.
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500572
573 Typically refers to Python package paths and defaults
574 to ``sys.path``.
Jason R. Coombs17499d82019-09-10 14:53:31 +0100575 """
576 return vars(self).get('path', sys.path)
577
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400578 @abc.abstractmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100579 def find_distributions(self, context=Context()):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400580 """
581 Find distributions.
582
583 Return an iterable of all Distribution instances capable of
Jason R. Coombs17499d82019-09-10 14:53:31 +0100584 loading the metadata for packages matching the ``context``,
585 a DistributionFinder.Context instance.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400586 """
587
588
Jason R. Coombs136735c2020-01-11 10:37:28 -0500589class FastPath:
590 """
591 Micro-optimized class for searching a path for
592 children.
593 """
594
595 def __init__(self, root):
596 self.root = root
Jason R. Coombs161541a2020-06-05 16:34:16 -0400597 self.base = os.path.basename(self.root).lower()
Jason R. Coombs136735c2020-01-11 10:37:28 -0500598
599 def joinpath(self, child):
600 return pathlib.Path(self.root, child)
601
602 def children(self):
603 with suppress(Exception):
604 return os.listdir(self.root or '')
605 with suppress(Exception):
606 return self.zip_children()
607 return []
608
609 def zip_children(self):
610 zip_path = zipfile.Path(self.root)
611 names = zip_path.root.namelist()
612 self.joinpath = zip_path.joinpath
613
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500614 return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
Jason R. Coombs136735c2020-01-11 10:37:28 -0500615
616 def search(self, name):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500617 return (
618 self.joinpath(child)
619 for child in self.children()
620 if name.matches(child, self.base)
621 )
Jason R. Coombs136735c2020-01-11 10:37:28 -0500622
623
624class Prepared:
625 """
626 A prepared search for metadata on a possibly-named package.
627 """
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500628
629 normalized = None
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500630 suffixes = 'dist-info', 'egg-info'
Jason R. Coombs136735c2020-01-11 10:37:28 -0500631 exact_matches = [''][:0]
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500632 egg_prefix = ''
633 versionless_egg_name = ''
Jason R. Coombs136735c2020-01-11 10:37:28 -0500634
635 def __init__(self, name):
636 self.name = name
637 if name is None:
638 return
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500639 self.normalized = self.normalize(name)
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500640 self.exact_matches = [
641 self.normalized + '.' + suffix for suffix in self.suffixes
642 ]
643 legacy_normalized = self.legacy_normalize(self.name)
644 self.egg_prefix = legacy_normalized + '-'
645 self.versionless_egg_name = legacy_normalized + '.egg'
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500646
647 @staticmethod
648 def normalize(name):
649 """
650 PEP 503 normalization plus dashes as underscores.
651 """
652 return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')
653
654 @staticmethod
655 def legacy_normalize(name):
656 """
657 Normalize the package name as found in the convention in
658 older packaging tools versions and specs.
659 """
660 return name.lower().replace('-', '_')
661
662 def matches(self, cand, base):
663 low = cand.lower()
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500664 # rpartition is faster than splitext and suitable for this purpose.
665 pre, _, ext = low.rpartition('.')
666 name, _, rest = pre.partition('-')
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500667 return (
668 low in self.exact_matches
669 or ext in self.suffixes
670 and (not self.normalized or name.replace('.', '_') == self.normalized)
671 # legacy case:
672 or self.is_egg(base)
673 and low == 'egg-info'
674 )
675
676 def is_egg(self, base):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500677 return (
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500678 base == self.versionless_egg_name
679 or base.startswith(self.egg_prefix)
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500680 and base.endswith('.egg')
681 )
Jason R. Coombs136735c2020-01-11 10:37:28 -0500682
683
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100684class MetadataPathFinder(DistributionFinder):
685 @classmethod
686 def find_distributions(cls, context=DistributionFinder.Context()):
687 """
688 Find distributions.
689
690 Return an iterable of all Distribution instances capable of
691 loading the metadata for packages matching ``context.name``
692 (or all names if ``None`` indicated) along the paths in the list
693 of directories ``context.path``.
694 """
Jason R. Coombs136735c2020-01-11 10:37:28 -0500695 found = cls._search_paths(context.name, context.path)
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100696 return map(PathDistribution, found)
697
698 @classmethod
Jason R. Coombs136735c2020-01-11 10:37:28 -0500699 def _search_paths(cls, name, paths):
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100700 """Find metadata directories in paths heuristically."""
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500701 prepared = Prepared(name)
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100702 return itertools.chain.from_iterable(
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500703 path.search(prepared) for path in map(FastPath, paths)
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500704 )
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100705
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100706
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400707class PathDistribution(Distribution):
708 def __init__(self, path):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400709 """Construct a distribution from a path to the metadata directory.
710
711 :param path: A pathlib.Path or similar object supporting
712 .joinpath(), __div__, .parent, and .read_text().
713 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400714 self._path = path
715
716 def read_text(self, filename):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500717 with suppress(
718 FileNotFoundError,
719 IsADirectoryError,
720 KeyError,
721 NotADirectoryError,
722 PermissionError,
723 ):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400724 return self._path.joinpath(filename).read_text(encoding='utf-8')
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500725
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400726 read_text.__doc__ = Distribution.read_text.__doc__
727
728 def locate_file(self, path):
729 return self._path.parent / path
730
731
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100732def distribution(distribution_name):
733 """Get the ``Distribution`` instance for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400734
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100735 :param distribution_name: The name of the distribution package as a string.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400736 :return: A ``Distribution`` instance (or subclass thereof).
737 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100738 return Distribution.from_name(distribution_name)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400739
740
Jason R. Coombs17499d82019-09-10 14:53:31 +0100741def distributions(**kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400742 """Get all ``Distribution`` instances in the current environment.
743
744 :return: An iterable of ``Distribution`` instances.
745 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100746 return Distribution.discover(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400747
748
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500749def metadata(distribution_name) -> PackageMetadata:
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100750 """Get the metadata for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400751
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100752 :param distribution_name: The name of the distribution package to query.
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500753 :return: A PackageMetadata containing the parsed metadata.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400754 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100755 return Distribution.from_name(distribution_name).metadata
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400756
757
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100758def version(distribution_name):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400759 """Get the version string for the named package.
760
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100761 :param distribution_name: The name of the distribution package to query.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400762 :return: The version string for the package as defined in the package's
763 "Version" metadata key.
764 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100765 return distribution(distribution_name).version
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400766
767
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500768def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400769 """Return EntryPoint objects for all installed packages.
770
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500771 Pass selection parameters (group or name) to filter the
772 result to entry points matching those properties (see
773 EntryPoints.select()).
774
775 For compatibility, returns ``SelectableGroups`` object unless
776 selection parameters are supplied. In the future, this function
777 will return ``EntryPoints`` instead of ``SelectableGroups``
778 even when no selection parameters are supplied.
779
780 For maximum future compatibility, pass selection parameters
781 or invoke ``.select`` with parameters on the result.
782
783 :return: EntryPoints or SelectableGroups for all installed packages.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400784 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500785 unique = functools.partial(unique_everseen, key=operator.attrgetter('name'))
786 eps = itertools.chain.from_iterable(
787 dist.entry_points for dist in unique(distributions())
788 )
789 return SelectableGroups.load(eps).select(**params)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400790
791
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100792def files(distribution_name):
793 """Return a list of files for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400794
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100795 :param distribution_name: The name of the distribution package to query.
796 :return: List of files composing the distribution.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400797 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100798 return distribution(distribution_name).files
799
800
801def requires(distribution_name):
802 """
803 Return a list of requirements for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400804
805 :return: An iterator of requirements, suitable for
806 packaging.requirement.Requirement.
807 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100808 return distribution(distribution_name).requires
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500809
810
811def packages_distributions() -> Mapping[str, List[str]]:
812 """
813 Return a mapping of top-level packages to their
814 distributions.
815
816 >>> pkgs = packages_distributions()
817 >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())
818 True
819 """
820 pkg_to_dist = collections.defaultdict(list)
821 for dist in distributions():
822 for pkg in (dist.read_text('top_level.txt') or '').split():
823 pkg_to_dist[pkg].append(dist.metadata['Name'])
824 return dict(pkg_to_dist)