blob: 53c1a145f5c43bfa727dd4be60e685b7111fa287 [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
7import pathlib
Jason R. Coombs8ed65032019-09-12 10:29:11 +01008import zipfile
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04009import operator
Jason R. Coombsf917efc2021-03-13 11:31:45 -050010import warnings
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040011import functools
12import itertools
Jason R. Coombs136735c2020-01-11 10:37:28 -050013import posixpath
Jason R. Coombs35d50682021-03-14 22:20:49 -040014import collections
Jason R. Coombsf917efc2021-03-13 11:31:45 -050015
16from ._itertools import unique_everseen
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040017
18from configparser import ConfigParser
19from contextlib import suppress
20from importlib import import_module
21from importlib.abc import MetaPathFinder
22from itertools import starmap
Jason R. Coombsf917efc2021-03-13 11:31:45 -050023from typing import Any, List, Mapping, Optional, Protocol, TypeVar, Union
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040024
25
26__all__ = [
27 'Distribution',
Jason R. Coombs17499d82019-09-10 14:53:31 +010028 'DistributionFinder',
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040029 'PackageNotFoundError',
30 'distribution',
31 'distributions',
32 'entry_points',
33 'files',
34 'metadata',
Jason R. Coombs35d50682021-03-14 22:20:49 -040035 'packages_distributions',
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040036 '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:
Jason R. Coombs35d50682021-03-14 22:20:49 -0400161 """
162 Get the EntryPoint in self matching name.
163 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500164 try:
165 return next(iter(self.select(name=name)))
166 except StopIteration:
167 raise KeyError(name)
168
169 def select(self, **params):
Jason R. Coombs35d50682021-03-14 22:20:49 -0400170 """
171 Select entry points from self that match the
172 given parameters (typically group and/or name).
173 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500174 return EntryPoints(ep for ep in self if ep.matches(**params))
175
176 @property
177 def names(self):
Jason R. Coombs35d50682021-03-14 22:20:49 -0400178 """
179 Return the set of all names of all entry points.
180 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500181 return set(ep.name for ep in self)
182
183 @property
184 def groups(self):
185 """
Jason R. Coombs35d50682021-03-14 22:20:49 -0400186 Return the set of all groups of all entry points.
187
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500188 For coverage while SelectableGroups is present.
189 >>> EntryPoints().groups
190 set()
191 """
192 return set(ep.group for ep in self)
193
194 @classmethod
195 def _from_text_for(cls, text, dist):
196 return cls(ep._for(dist) for ep in EntryPoint._from_text(text))
197
198
199def flake8_bypass(func):
Jason R. Coombs35d50682021-03-14 22:20:49 -0400200 # defer inspect import as performance optimization.
201 import inspect
202
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500203 is_flake8 = any('flake8' in str(frame.filename) for frame in inspect.stack()[:5])
204 return func if not is_flake8 else lambda: None
205
206
207class Deprecated:
208 """
209 Compatibility add-in for mapping to indicate that
210 mapping behavior is deprecated.
211
212 >>> recwarn = getfixture('recwarn')
213 >>> class DeprecatedDict(Deprecated, dict): pass
214 >>> dd = DeprecatedDict(foo='bar')
215 >>> dd.get('baz', None)
216 >>> dd['foo']
217 'bar'
218 >>> list(dd)
219 ['foo']
220 >>> list(dd.keys())
221 ['foo']
222 >>> 'foo' in dd
223 True
224 >>> list(dd.values())
225 ['bar']
226 >>> len(recwarn)
227 1
228 """
229
230 _warn = functools.partial(
231 warnings.warn,
232 "SelectableGroups dict interface is deprecated. Use select.",
233 DeprecationWarning,
234 stacklevel=2,
235 )
236
237 def __getitem__(self, name):
238 self._warn()
239 return super().__getitem__(name)
240
241 def get(self, name, default=None):
242 flake8_bypass(self._warn)()
243 return super().get(name, default)
244
245 def __iter__(self):
246 self._warn()
247 return super().__iter__()
248
249 def __contains__(self, *args):
250 self._warn()
251 return super().__contains__(*args)
252
253 def keys(self):
254 self._warn()
255 return super().keys()
256
257 def values(self):
258 self._warn()
259 return super().values()
260
261
262class SelectableGroups(dict):
263 """
264 A backward- and forward-compatible result from
265 entry_points that fully implements the dict interface.
266 """
267
268 @classmethod
269 def load(cls, eps):
270 by_group = operator.attrgetter('group')
271 ordered = sorted(eps, key=by_group)
272 grouped = itertools.groupby(ordered, by_group)
273 return cls((group, EntryPoints(eps)) for group, eps in grouped)
274
275 @property
276 def _all(self):
277 """
278 Reconstruct a list of all entrypoints from the groups.
279 """
280 return EntryPoints(itertools.chain.from_iterable(self.values()))
281
282 @property
283 def groups(self):
284 return self._all.groups
285
286 @property
287 def names(self):
288 """
289 for coverage:
290 >>> SelectableGroups().names
291 set()
292 """
293 return self._all.names
294
295 def select(self, **params):
296 if not params:
297 return self
298 return self._all.select(**params)
299
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400300
301class PackagePath(pathlib.PurePosixPath):
302 """A reference to a path in a package"""
303
304 def read_text(self, encoding='utf-8'):
305 with self.locate().open(encoding=encoding) as stream:
306 return stream.read()
307
308 def read_binary(self):
309 with self.locate().open('rb') as stream:
310 return stream.read()
311
312 def locate(self):
313 """Return a path-like object for this path"""
314 return self.dist.locate_file(self)
315
316
317class FileHash:
318 def __init__(self, spec):
319 self.mode, _, self.value = spec.partition('=')
320
321 def __repr__(self):
322 return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
323
324
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500325_T = TypeVar("_T")
326
327
328class PackageMetadata(Protocol):
329 def __len__(self) -> int:
330 ... # pragma: no cover
331
332 def __contains__(self, item: str) -> bool:
333 ... # pragma: no cover
334
335 def __getitem__(self, key: str) -> str:
336 ... # pragma: no cover
337
338 def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
339 """
340 Return all values associated with a possibly multi-valued key.
341 """
342
343
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400344class Distribution:
345 """A Python distribution package."""
346
347 @abc.abstractmethod
348 def read_text(self, filename):
349 """Attempt to load metadata file given by the name.
350
351 :param filename: The name of the file in the distribution info.
352 :return: The text if found, otherwise None.
353 """
354
355 @abc.abstractmethod
356 def locate_file(self, path):
357 """
358 Given a path to a file in this distribution, return a path
359 to it.
360 """
361
362 @classmethod
363 def from_name(cls, name):
364 """Return the Distribution for the given package name.
365
366 :param name: The name of the distribution package to search for.
367 :return: The Distribution instance (or subclass thereof) for the named
368 package, if found.
369 :raises PackageNotFoundError: When the named package's distribution
370 metadata cannot be found.
371 """
372 for resolver in cls._discover_resolvers():
Jason R. Coombs17499d82019-09-10 14:53:31 +0100373 dists = resolver(DistributionFinder.Context(name=name))
Jason R. Coombs161541a2020-06-05 16:34:16 -0400374 dist = next(iter(dists), None)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400375 if dist is not None:
376 return dist
377 else:
378 raise PackageNotFoundError(name)
379
380 @classmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100381 def discover(cls, **kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400382 """Return an iterable of Distribution objects for all packages.
383
Jason R. Coombs17499d82019-09-10 14:53:31 +0100384 Pass a ``context`` or pass keyword arguments for constructing
385 a context.
386
387 :context: A ``DistributionFinder.Context`` object.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400388 :return: Iterable of Distribution objects for all packages.
389 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100390 context = kwargs.pop('context', None)
391 if context and kwargs:
392 raise ValueError("cannot accept context and kwargs")
393 context = context or DistributionFinder.Context(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400394 return itertools.chain.from_iterable(
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500395 resolver(context) for resolver in cls._discover_resolvers()
396 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400397
398 @staticmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100399 def at(path):
400 """Return a Distribution for the indicated metadata path
401
402 :param path: a string or path-like object
403 :return: a concrete Distribution instance for the path
404 """
405 return PathDistribution(pathlib.Path(path))
406
407 @staticmethod
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400408 def _discover_resolvers():
409 """Search the meta_path for resolvers."""
410 declared = (
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500411 getattr(finder, 'find_distributions', None) for finder in sys.meta_path
412 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400413 return filter(None, declared)
414
Jason R. Coombs161541a2020-06-05 16:34:16 -0400415 @classmethod
416 def _local(cls, root='.'):
417 from pep517 import build, meta
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500418
Jason R. Coombs161541a2020-06-05 16:34:16 -0400419 system = build.compat_system(root)
420 builder = functools.partial(
421 meta.build,
422 source_dir=root,
423 system=system,
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500424 )
Jason R. Coombs161541a2020-06-05 16:34:16 -0400425 return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
426
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400427 @property
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500428 def metadata(self) -> PackageMetadata:
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400429 """Return the parsed metadata for this Distribution.
430
431 The returned object will have keys that name the various bits of
432 metadata. See PEP 566 for details.
433 """
434 text = (
435 self.read_text('METADATA')
436 or self.read_text('PKG-INFO')
437 # This last clause is here to support old egg-info files. Its
438 # effect is to just end up using the PathDistribution's self._path
439 # (which points to the egg-info file) attribute unchanged.
440 or self.read_text('')
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500441 )
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400442 return email.message_from_string(text)
443
444 @property
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500445 def name(self):
446 """Return the 'Name' metadata for the distribution package."""
447 return self.metadata['Name']
448
449 @property
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400450 def version(self):
451 """Return the 'Version' metadata for the distribution package."""
452 return self.metadata['Version']
453
454 @property
455 def entry_points(self):
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500456 return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400457
458 @property
459 def files(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400460 """Files in this distribution.
461
Jason R. Coombs17499d82019-09-10 14:53:31 +0100462 :return: List of PackagePath for this distribution or None
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400463
464 Result is `None` if the metadata file that enumerates files
465 (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
466 missing.
467 Result may be empty if the metadata exists but is empty.
468 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400469 file_lines = self._read_files_distinfo() or self._read_files_egginfo()
470
471 def make_file(name, hash=None, size_str=None):
472 result = PackagePath(name)
473 result.hash = FileHash(hash) if hash else None
474 result.size = int(size_str) if size_str else None
475 result.dist = self
476 return result
477
Jason R. Coombs17499d82019-09-10 14:53:31 +0100478 return file_lines and list(starmap(make_file, csv.reader(file_lines)))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400479
480 def _read_files_distinfo(self):
481 """
482 Read the lines of RECORD
483 """
484 text = self.read_text('RECORD')
485 return text and text.splitlines()
486
487 def _read_files_egginfo(self):
488 """
489 SOURCES.txt might contain literal commas, so wrap each line
490 in quotes.
491 """
492 text = self.read_text('SOURCES.txt')
493 return text and map('"{}"'.format, text.splitlines())
494
495 @property
496 def requires(self):
497 """Generated requirements specified for this Distribution"""
Jason R. Coombs17499d82019-09-10 14:53:31 +0100498 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
499 return reqs and list(reqs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400500
501 def _read_dist_info_reqs(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400502 return self.metadata.get_all('Requires-Dist')
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400503
504 def _read_egg_info_reqs(self):
505 source = self.read_text('requires.txt')
506 return source and self._deps_from_requires_text(source)
507
508 @classmethod
509 def _deps_from_requires_text(cls, source):
510 section_pairs = cls._read_sections(source.splitlines())
511 sections = {
512 section: list(map(operator.itemgetter('line'), results))
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500513 for section, results in itertools.groupby(
514 section_pairs, operator.itemgetter('section')
515 )
516 }
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400517 return cls._convert_egg_info_reqs_to_simple_reqs(sections)
518
519 @staticmethod
520 def _read_sections(lines):
521 section = None
522 for line in filter(None, lines):
523 section_match = re.match(r'\[(.*)\]$', line)
524 if section_match:
525 section = section_match.group(1)
526 continue
527 yield locals()
528
529 @staticmethod
530 def _convert_egg_info_reqs_to_simple_reqs(sections):
531 """
532 Historically, setuptools would solicit and store 'extra'
533 requirements, including those with environment markers,
534 in separate sections. More modern tools expect each
535 dependency to be defined separately, with any relevant
536 extras and environment markers attached directly to that
537 requirement. This method converts the former to the
538 latter. See _test_deps_from_requires_text for an example.
539 """
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500540
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400541 def make_condition(name):
542 return name and 'extra == "{name}"'.format(name=name)
543
544 def parse_condition(section):
545 section = section or ''
546 extra, sep, markers = section.partition(':')
547 if extra and markers:
548 markers = '({markers})'.format(markers=markers)
549 conditions = list(filter(None, [markers, make_condition(extra)]))
550 return '; ' + ' and '.join(conditions) if conditions else ''
551
552 for section, deps in sections.items():
553 for dep in deps:
554 yield dep + parse_condition(section)
555
556
557class DistributionFinder(MetaPathFinder):
558 """
559 A MetaPathFinder capable of discovering installed distributions.
560 """
561
Jason R. Coombs17499d82019-09-10 14:53:31 +0100562 class Context:
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500563 """
564 Keyword arguments presented by the caller to
565 ``distributions()`` or ``Distribution.discover()``
566 to narrow the scope of a search for distributions
567 in all DistributionFinders.
568
569 Each DistributionFinder may expect any parameters
570 and should attempt to honor the canonical
571 parameters defined below when appropriate.
572 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100573
574 name = None
575 """
576 Specific name for which a distribution finder should match.
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500577 A name of ``None`` matches all distributions.
Jason R. Coombs17499d82019-09-10 14:53:31 +0100578 """
579
580 def __init__(self, **kwargs):
581 vars(self).update(kwargs)
582
583 @property
584 def path(self):
585 """
586 The path that a distribution finder should search.
Jason R. Coombsb7a01092019-12-10 20:05:10 -0500587
588 Typically refers to Python package paths and defaults
589 to ``sys.path``.
Jason R. Coombs17499d82019-09-10 14:53:31 +0100590 """
591 return vars(self).get('path', sys.path)
592
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400593 @abc.abstractmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100594 def find_distributions(self, context=Context()):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400595 """
596 Find distributions.
597
598 Return an iterable of all Distribution instances capable of
Jason R. Coombs17499d82019-09-10 14:53:31 +0100599 loading the metadata for packages matching the ``context``,
600 a DistributionFinder.Context instance.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400601 """
602
603
Jason R. Coombs136735c2020-01-11 10:37:28 -0500604class FastPath:
605 """
606 Micro-optimized class for searching a path for
607 children.
608 """
609
610 def __init__(self, root):
611 self.root = root
Jason R. Coombs161541a2020-06-05 16:34:16 -0400612 self.base = os.path.basename(self.root).lower()
Jason R. Coombs136735c2020-01-11 10:37:28 -0500613
614 def joinpath(self, child):
615 return pathlib.Path(self.root, child)
616
617 def children(self):
618 with suppress(Exception):
619 return os.listdir(self.root or '')
620 with suppress(Exception):
621 return self.zip_children()
622 return []
623
624 def zip_children(self):
625 zip_path = zipfile.Path(self.root)
626 names = zip_path.root.namelist()
627 self.joinpath = zip_path.joinpath
628
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500629 return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
Jason R. Coombs136735c2020-01-11 10:37:28 -0500630
631 def search(self, name):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500632 return (
633 self.joinpath(child)
634 for child in self.children()
635 if name.matches(child, self.base)
636 )
Jason R. Coombs136735c2020-01-11 10:37:28 -0500637
638
639class Prepared:
640 """
641 A prepared search for metadata on a possibly-named package.
642 """
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500643
644 normalized = None
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500645 suffixes = 'dist-info', 'egg-info'
Jason R. Coombs136735c2020-01-11 10:37:28 -0500646 exact_matches = [''][:0]
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500647 egg_prefix = ''
648 versionless_egg_name = ''
Jason R. Coombs136735c2020-01-11 10:37:28 -0500649
650 def __init__(self, name):
651 self.name = name
652 if name is None:
653 return
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500654 self.normalized = self.normalize(name)
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500655 self.exact_matches = [
656 self.normalized + '.' + suffix for suffix in self.suffixes
657 ]
658 legacy_normalized = self.legacy_normalize(self.name)
659 self.egg_prefix = legacy_normalized + '-'
660 self.versionless_egg_name = legacy_normalized + '.egg'
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500661
662 @staticmethod
663 def normalize(name):
664 """
665 PEP 503 normalization plus dashes as underscores.
666 """
667 return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')
668
669 @staticmethod
670 def legacy_normalize(name):
671 """
672 Normalize the package name as found in the convention in
673 older packaging tools versions and specs.
674 """
675 return name.lower().replace('-', '_')
676
677 def matches(self, cand, base):
678 low = cand.lower()
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500679 # rpartition is faster than splitext and suitable for this purpose.
680 pre, _, ext = low.rpartition('.')
681 name, _, rest = pre.partition('-')
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500682 return (
683 low in self.exact_matches
684 or ext in self.suffixes
685 and (not self.normalized or name.replace('.', '_') == self.normalized)
686 # legacy case:
687 or self.is_egg(base)
688 and low == 'egg-info'
689 )
690
691 def is_egg(self, base):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500692 return (
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500693 base == self.versionless_egg_name
694 or base.startswith(self.egg_prefix)
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500695 and base.endswith('.egg')
696 )
Jason R. Coombs136735c2020-01-11 10:37:28 -0500697
698
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100699class MetadataPathFinder(DistributionFinder):
700 @classmethod
701 def find_distributions(cls, context=DistributionFinder.Context()):
702 """
703 Find distributions.
704
705 Return an iterable of all Distribution instances capable of
706 loading the metadata for packages matching ``context.name``
707 (or all names if ``None`` indicated) along the paths in the list
708 of directories ``context.path``.
709 """
Jason R. Coombs136735c2020-01-11 10:37:28 -0500710 found = cls._search_paths(context.name, context.path)
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100711 return map(PathDistribution, found)
712
713 @classmethod
Jason R. Coombs136735c2020-01-11 10:37:28 -0500714 def _search_paths(cls, name, paths):
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100715 """Find metadata directories in paths heuristically."""
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500716 prepared = Prepared(name)
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100717 return itertools.chain.from_iterable(
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500718 path.search(prepared) for path in map(FastPath, paths)
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500719 )
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100720
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100721
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400722class PathDistribution(Distribution):
723 def __init__(self, path):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400724 """Construct a distribution from a path to the metadata directory.
725
726 :param path: A pathlib.Path or similar object supporting
727 .joinpath(), __div__, .parent, and .read_text().
728 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400729 self._path = path
730
731 def read_text(self, filename):
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500732 with suppress(
733 FileNotFoundError,
734 IsADirectoryError,
735 KeyError,
736 NotADirectoryError,
737 PermissionError,
738 ):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400739 return self._path.joinpath(filename).read_text(encoding='utf-8')
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500740
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400741 read_text.__doc__ = Distribution.read_text.__doc__
742
743 def locate_file(self, path):
744 return self._path.parent / path
745
746
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100747def distribution(distribution_name):
748 """Get the ``Distribution`` instance for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400749
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100750 :param distribution_name: The name of the distribution package as a string.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400751 :return: A ``Distribution`` instance (or subclass thereof).
752 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100753 return Distribution.from_name(distribution_name)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400754
755
Jason R. Coombs17499d82019-09-10 14:53:31 +0100756def distributions(**kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400757 """Get all ``Distribution`` instances in the current environment.
758
759 :return: An iterable of ``Distribution`` instances.
760 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100761 return Distribution.discover(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400762
763
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500764def metadata(distribution_name) -> PackageMetadata:
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100765 """Get the metadata for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400766
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100767 :param distribution_name: The name of the distribution package to query.
Jason R. Coombsdfdca852020-12-31 12:56:43 -0500768 :return: A PackageMetadata containing the parsed metadata.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400769 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100770 return Distribution.from_name(distribution_name).metadata
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400771
772
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100773def version(distribution_name):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400774 """Get the version string for the named package.
775
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100776 :param distribution_name: The name of the distribution package to query.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400777 :return: The version string for the package as defined in the package's
778 "Version" metadata key.
779 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100780 return distribution(distribution_name).version
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400781
782
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500783def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400784 """Return EntryPoint objects for all installed packages.
785
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500786 Pass selection parameters (group or name) to filter the
787 result to entry points matching those properties (see
788 EntryPoints.select()).
789
790 For compatibility, returns ``SelectableGroups`` object unless
791 selection parameters are supplied. In the future, this function
792 will return ``EntryPoints`` instead of ``SelectableGroups``
793 even when no selection parameters are supplied.
794
795 For maximum future compatibility, pass selection parameters
796 or invoke ``.select`` with parameters on the result.
797
798 :return: EntryPoints or SelectableGroups for all installed packages.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400799 """
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500800 unique = functools.partial(unique_everseen, key=operator.attrgetter('name'))
801 eps = itertools.chain.from_iterable(
802 dist.entry_points for dist in unique(distributions())
803 )
804 return SelectableGroups.load(eps).select(**params)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400805
806
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100807def files(distribution_name):
808 """Return a list of files for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400809
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100810 :param distribution_name: The name of the distribution package to query.
811 :return: List of files composing the distribution.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400812 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100813 return distribution(distribution_name).files
814
815
816def requires(distribution_name):
817 """
818 Return a list of requirements for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400819
820 :return: An iterator of requirements, suitable for
821 packaging.requirement.Requirement.
822 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100823 return distribution(distribution_name).requires
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500824
825
826def packages_distributions() -> Mapping[str, List[str]]:
827 """
828 Return a mapping of top-level packages to their
829 distributions.
830
Jason R. Coombs35d50682021-03-14 22:20:49 -0400831 >>> import collections.abc
Jason R. Coombsf917efc2021-03-13 11:31:45 -0500832 >>> pkgs = packages_distributions()
833 >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())
834 True
835 """
836 pkg_to_dist = collections.defaultdict(list)
837 for dist in distributions():
838 for pkg in (dist.read_text('top_level.txt') or '').split():
839 pkg_to_dist[pkg].append(dist.metadata['Name'])
840 return dict(pkg_to_dist)