1from __future__ import annotations
2
3import os
4import re
5import abc
6import sys
7import json
8import zipp
9import email
10import types
11import inspect
12import pathlib
13import operator
14import textwrap
15import functools
16import itertools
17import posixpath
18import collections
19
20from . import _meta
21from .compat import py39, py311
22from ._collections import FreezableDefaultDict, Pair
23from ._compat import (
24 NullFinder,
25 install,
26)
27from ._functools import method_cache, pass_none
28from ._itertools import always_iterable, unique_everseen
29from ._meta import PackageMetadata, SimplePath
30
31from contextlib import suppress
32from importlib import import_module
33from importlib.abc import MetaPathFinder
34from itertools import starmap
35from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast
36
37__all__ = [
38 'Distribution',
39 'DistributionFinder',
40 'PackageMetadata',
41 'PackageNotFoundError',
42 'distribution',
43 'distributions',
44 'entry_points',
45 'files',
46 'metadata',
47 'packages_distributions',
48 'requires',
49 'version',
50]
51
52
53class PackageNotFoundError(ModuleNotFoundError):
54 """The package was not found."""
55
56 def __str__(self) -> str:
57 return f"No package metadata was found for {self.name}"
58
59 @property
60 def name(self) -> str: # type: ignore[override]
61 (name,) = self.args
62 return name
63
64
65class Sectioned:
66 """
67 A simple entry point config parser for performance
68
69 >>> for item in Sectioned.read(Sectioned._sample):
70 ... print(item)
71 Pair(name='sec1', value='# comments ignored')
72 Pair(name='sec1', value='a = 1')
73 Pair(name='sec1', value='b = 2')
74 Pair(name='sec2', value='a = 2')
75
76 >>> res = Sectioned.section_pairs(Sectioned._sample)
77 >>> item = next(res)
78 >>> item.name
79 'sec1'
80 >>> item.value
81 Pair(name='a', value='1')
82 >>> item = next(res)
83 >>> item.value
84 Pair(name='b', value='2')
85 >>> item = next(res)
86 >>> item.name
87 'sec2'
88 >>> item.value
89 Pair(name='a', value='2')
90 >>> list(res)
91 []
92 """
93
94 _sample = textwrap.dedent(
95 """
96 [sec1]
97 # comments ignored
98 a = 1
99 b = 2
100
101 [sec2]
102 a = 2
103 """
104 ).lstrip()
105
106 @classmethod
107 def section_pairs(cls, text):
108 return (
109 section._replace(value=Pair.parse(section.value))
110 for section in cls.read(text, filter_=cls.valid)
111 if section.name is not None
112 )
113
114 @staticmethod
115 def read(text, filter_=None):
116 lines = filter(filter_, map(str.strip, text.splitlines()))
117 name = None
118 for value in lines:
119 section_match = value.startswith('[') and value.endswith(']')
120 if section_match:
121 name = value.strip('[]')
122 continue
123 yield Pair(name, value)
124
125 @staticmethod
126 def valid(line: str):
127 return line and not line.startswith('#')
128
129
130class EntryPoint:
131 """An entry point as defined by Python packaging conventions.
132
133 See `the packaging docs on entry points
134 <https://packaging.python.org/specifications/entry-points/>`_
135 for more information.
136
137 >>> ep = EntryPoint(
138 ... name=None, group=None, value='package.module:attr [extra1, extra2]')
139 >>> ep.module
140 'package.module'
141 >>> ep.attr
142 'attr'
143 >>> ep.extras
144 ['extra1', 'extra2']
145 """
146
147 pattern = re.compile(
148 r'(?P<module>[\w.]+)\s*'
149 r'(:\s*(?P<attr>[\w.]+)\s*)?'
150 r'((?P<extras>\[.*\])\s*)?$'
151 )
152 """
153 A regular expression describing the syntax for an entry point,
154 which might look like:
155
156 - module
157 - package.module
158 - package.module:attribute
159 - package.module:object.attribute
160 - package.module:attr [extra1, extra2]
161
162 Other combinations are possible as well.
163
164 The expression is lenient about whitespace around the ':',
165 following the attr, and following any extras.
166 """
167
168 name: str
169 value: str
170 group: str
171
172 dist: Optional[Distribution] = None
173
174 def __init__(self, name: str, value: str, group: str) -> None:
175 vars(self).update(name=name, value=value, group=group)
176
177 def load(self) -> Any:
178 """Load the entry point from its definition. If only a module
179 is indicated by the value, return that module. Otherwise,
180 return the named object.
181 """
182 match = cast(Match, self.pattern.match(self.value))
183 module = import_module(match.group('module'))
184 attrs = filter(None, (match.group('attr') or '').split('.'))
185 return functools.reduce(getattr, attrs, module)
186
187 @property
188 def module(self) -> str:
189 match = self.pattern.match(self.value)
190 assert match is not None
191 return match.group('module')
192
193 @property
194 def attr(self) -> str:
195 match = self.pattern.match(self.value)
196 assert match is not None
197 return match.group('attr')
198
199 @property
200 def extras(self) -> List[str]:
201 match = self.pattern.match(self.value)
202 assert match is not None
203 return re.findall(r'\w+', match.group('extras') or '')
204
205 def _for(self, dist):
206 vars(self).update(dist=dist)
207 return self
208
209 def matches(self, **params):
210 """
211 EntryPoint matches the given parameters.
212
213 >>> ep = EntryPoint(group='foo', name='bar', value='bing:bong [extra1, extra2]')
214 >>> ep.matches(group='foo')
215 True
216 >>> ep.matches(name='bar', value='bing:bong [extra1, extra2]')
217 True
218 >>> ep.matches(group='foo', name='other')
219 False
220 >>> ep.matches()
221 True
222 >>> ep.matches(extras=['extra1', 'extra2'])
223 True
224 >>> ep.matches(module='bing')
225 True
226 >>> ep.matches(attr='bong')
227 True
228 """
229 attrs = (getattr(self, param) for param in params)
230 return all(map(operator.eq, params.values(), attrs))
231
232 def _key(self):
233 return self.name, self.value, self.group
234
235 def __lt__(self, other):
236 return self._key() < other._key()
237
238 def __eq__(self, other):
239 return self._key() == other._key()
240
241 def __setattr__(self, name, value):
242 raise AttributeError("EntryPoint objects are immutable.")
243
244 def __repr__(self):
245 return (
246 f'EntryPoint(name={self.name!r}, value={self.value!r}, '
247 f'group={self.group!r})'
248 )
249
250 def __hash__(self) -> int:
251 return hash(self._key())
252
253
254class EntryPoints(tuple):
255 """
256 An immutable collection of selectable EntryPoint objects.
257 """
258
259 __slots__ = ()
260
261 def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override]
262 """
263 Get the EntryPoint in self matching name.
264 """
265 try:
266 return next(iter(self.select(name=name)))
267 except StopIteration:
268 raise KeyError(name)
269
270 def __repr__(self):
271 """
272 Repr with classname and tuple constructor to
273 signal that we deviate from regular tuple behavior.
274 """
275 return '%s(%r)' % (self.__class__.__name__, tuple(self))
276
277 def select(self, **params) -> EntryPoints:
278 """
279 Select entry points from self that match the
280 given parameters (typically group and/or name).
281 """
282 return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params))
283
284 @property
285 def names(self) -> Set[str]:
286 """
287 Return the set of all names of all entry points.
288 """
289 return {ep.name for ep in self}
290
291 @property
292 def groups(self) -> Set[str]:
293 """
294 Return the set of all groups of all entry points.
295 """
296 return {ep.group for ep in self}
297
298 @classmethod
299 def _from_text_for(cls, text, dist):
300 return cls(ep._for(dist) for ep in cls._from_text(text))
301
302 @staticmethod
303 def _from_text(text):
304 return (
305 EntryPoint(name=item.value.name, value=item.value.value, group=item.name)
306 for item in Sectioned.section_pairs(text or '')
307 )
308
309
310class PackagePath(pathlib.PurePosixPath):
311 """A reference to a path in a package"""
312
313 hash: Optional[FileHash]
314 size: int
315 dist: Distribution
316
317 def read_text(self, encoding: str = 'utf-8') -> str: # type: ignore[override]
318 return self.locate().read_text(encoding=encoding)
319
320 def read_binary(self) -> bytes:
321 return self.locate().read_bytes()
322
323 def locate(self) -> SimplePath:
324 """Return a path-like object for this path"""
325 return self.dist.locate_file(self)
326
327
328class FileHash:
329 def __init__(self, spec: str) -> None:
330 self.mode, _, self.value = spec.partition('=')
331
332 def __repr__(self) -> str:
333 return f'<FileHash mode: {self.mode} value: {self.value}>'
334
335
336class Distribution(metaclass=abc.ABCMeta):
337 """
338 An abstract Python distribution package.
339
340 Custom providers may derive from this class and define
341 the abstract methods to provide a concrete implementation
342 for their environment. Some providers may opt to override
343 the default implementation of some properties to bypass
344 the file-reading mechanism.
345 """
346
347 @abc.abstractmethod
348 def read_text(self, filename) -> Optional[str]:
349 """Attempt to load metadata file given by the name.
350
351 Python distribution metadata is organized by blobs of text
352 typically represented as "files" in the metadata directory
353 (e.g. package-1.0.dist-info). These files include things
354 like:
355
356 - METADATA: The distribution metadata including fields
357 like Name and Version and Description.
358 - entry_points.txt: A series of entry points as defined in
359 `the entry points spec <https://packaging.python.org/en/latest/specifications/entry-points/#file-format>`_.
360 - RECORD: A record of files according to
361 `this recording spec <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#the-record-file>`_.
362
363 A package may provide any set of files, including those
364 not listed here or none at all.
365
366 :param filename: The name of the file in the distribution info.
367 :return: The text if found, otherwise None.
368 """
369
370 @abc.abstractmethod
371 def locate_file(self, path: str | os.PathLike[str]) -> SimplePath:
372 """
373 Given a path to a file in this distribution, return a SimplePath
374 to it.
375 """
376
377 @classmethod
378 def from_name(cls, name: str) -> Distribution:
379 """Return the Distribution for the given package name.
380
381 :param name: The name of the distribution package to search for.
382 :return: The Distribution instance (or subclass thereof) for the named
383 package, if found.
384 :raises PackageNotFoundError: When the named package's distribution
385 metadata cannot be found.
386 :raises ValueError: When an invalid value is supplied for name.
387 """
388 if not name:
389 raise ValueError("A distribution name is required.")
390 try:
391 return next(iter(cls.discover(name=name)))
392 except StopIteration:
393 raise PackageNotFoundError(name)
394
395 @classmethod
396 def discover(
397 cls, *, context: Optional[DistributionFinder.Context] = None, **kwargs
398 ) -> Iterable[Distribution]:
399 """Return an iterable of Distribution objects for all packages.
400
401 Pass a ``context`` or pass keyword arguments for constructing
402 a context.
403
404 :context: A ``DistributionFinder.Context`` object.
405 :return: Iterable of Distribution objects for packages matching
406 the context.
407 """
408 if context and kwargs:
409 raise ValueError("cannot accept context and kwargs")
410 context = context or DistributionFinder.Context(**kwargs)
411 return itertools.chain.from_iterable(
412 resolver(context) for resolver in cls._discover_resolvers()
413 )
414
415 @staticmethod
416 def at(path: str | os.PathLike[str]) -> Distribution:
417 """Return a Distribution for the indicated metadata path.
418
419 :param path: a string or path-like object
420 :return: a concrete Distribution instance for the path
421 """
422 return PathDistribution(pathlib.Path(path))
423
424 @staticmethod
425 def _discover_resolvers():
426 """Search the meta_path for resolvers (MetadataPathFinders)."""
427 declared = (
428 getattr(finder, 'find_distributions', None) for finder in sys.meta_path
429 )
430 return filter(None, declared)
431
432 @property
433 def metadata(self) -> _meta.PackageMetadata:
434 """Return the parsed metadata for this Distribution.
435
436 The returned object will have keys that name the various bits of
437 metadata per the
438 `Core metadata specifications <https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata>`_.
439
440 Custom providers may provide the METADATA file or override this
441 property.
442 """
443 # deferred for performance (python/cpython#109829)
444 from . import _adapters
445
446 opt_text = (
447 self.read_text('METADATA')
448 or self.read_text('PKG-INFO')
449 # This last clause is here to support old egg-info files. Its
450 # effect is to just end up using the PathDistribution's self._path
451 # (which points to the egg-info file) attribute unchanged.
452 or self.read_text('')
453 )
454 text = cast(str, opt_text)
455 return _adapters.Message(email.message_from_string(text))
456
457 @property
458 def name(self) -> str:
459 """Return the 'Name' metadata for the distribution package."""
460 return self.metadata['Name']
461
462 @property
463 def _normalized_name(self):
464 """Return a normalized version of the name."""
465 return Prepared.normalize(self.name)
466
467 @property
468 def version(self) -> str:
469 """Return the 'Version' metadata for the distribution package."""
470 return self.metadata['Version']
471
472 @property
473 def entry_points(self) -> EntryPoints:
474 """
475 Return EntryPoints for this distribution.
476
477 Custom providers may provide the ``entry_points.txt`` file
478 or override this property.
479 """
480 return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
481
482 @property
483 def files(self) -> Optional[List[PackagePath]]:
484 """Files in this distribution.
485
486 :return: List of PackagePath for this distribution or None
487
488 Result is `None` if the metadata file that enumerates files
489 (i.e. RECORD for dist-info, or installed-files.txt or
490 SOURCES.txt for egg-info) is missing.
491 Result may be empty if the metadata exists but is empty.
492
493 Custom providers are recommended to provide a "RECORD" file (in
494 ``read_text``) or override this property to allow for callers to be
495 able to resolve filenames provided by the package.
496 """
497
498 def make_file(name, hash=None, size_str=None):
499 result = PackagePath(name)
500 result.hash = FileHash(hash) if hash else None
501 result.size = int(size_str) if size_str else None
502 result.dist = self
503 return result
504
505 @pass_none
506 def make_files(lines):
507 # Delay csv import, since Distribution.files is not as widely used
508 # as other parts of importlib.metadata
509 import csv
510
511 return starmap(make_file, csv.reader(lines))
512
513 @pass_none
514 def skip_missing_files(package_paths):
515 return list(filter(lambda path: path.locate().exists(), package_paths))
516
517 return skip_missing_files(
518 make_files(
519 self._read_files_distinfo()
520 or self._read_files_egginfo_installed()
521 or self._read_files_egginfo_sources()
522 )
523 )
524
525 def _read_files_distinfo(self):
526 """
527 Read the lines of RECORD.
528 """
529 text = self.read_text('RECORD')
530 return text and text.splitlines()
531
532 def _read_files_egginfo_installed(self):
533 """
534 Read installed-files.txt and return lines in a similar
535 CSV-parsable format as RECORD: each file must be placed
536 relative to the site-packages directory and must also be
537 quoted (since file names can contain literal commas).
538
539 This file is written when the package is installed by pip,
540 but it might not be written for other installation methods.
541 Assume the file is accurate if it exists.
542 """
543 text = self.read_text('installed-files.txt')
544 # Prepend the .egg-info/ subdir to the lines in this file.
545 # But this subdir is only available from PathDistribution's
546 # self._path.
547 subdir = getattr(self, '_path', None)
548 if not text or not subdir:
549 return
550
551 paths = (
552 py311.relative_fix((subdir / name).resolve())
553 .relative_to(self.locate_file('').resolve(), walk_up=True)
554 .as_posix()
555 for name in text.splitlines()
556 )
557 return map('"{}"'.format, paths)
558
559 def _read_files_egginfo_sources(self):
560 """
561 Read SOURCES.txt and return lines in a similar CSV-parsable
562 format as RECORD: each file name must be quoted (since it
563 might contain literal commas).
564
565 Note that SOURCES.txt is not a reliable source for what
566 files are installed by a package. This file is generated
567 for a source archive, and the files that are present
568 there (e.g. setup.py) may not correctly reflect the files
569 that are present after the package has been installed.
570 """
571 text = self.read_text('SOURCES.txt')
572 return text and map('"{}"'.format, text.splitlines())
573
574 @property
575 def requires(self) -> Optional[List[str]]:
576 """Generated requirements specified for this Distribution"""
577 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
578 return reqs and list(reqs)
579
580 def _read_dist_info_reqs(self):
581 return self.metadata.get_all('Requires-Dist')
582
583 def _read_egg_info_reqs(self):
584 source = self.read_text('requires.txt')
585 return pass_none(self._deps_from_requires_text)(source)
586
587 @classmethod
588 def _deps_from_requires_text(cls, source):
589 return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source))
590
591 @staticmethod
592 def _convert_egg_info_reqs_to_simple_reqs(sections):
593 """
594 Historically, setuptools would solicit and store 'extra'
595 requirements, including those with environment markers,
596 in separate sections. More modern tools expect each
597 dependency to be defined separately, with any relevant
598 extras and environment markers attached directly to that
599 requirement. This method converts the former to the
600 latter. See _test_deps_from_requires_text for an example.
601 """
602
603 def make_condition(name):
604 return name and f'extra == "{name}"'
605
606 def quoted_marker(section):
607 section = section or ''
608 extra, sep, markers = section.partition(':')
609 if extra and markers:
610 markers = f'({markers})'
611 conditions = list(filter(None, [markers, make_condition(extra)]))
612 return '; ' + ' and '.join(conditions) if conditions else ''
613
614 def url_req_space(req):
615 """
616 PEP 508 requires a space between the url_spec and the quoted_marker.
617 Ref python/importlib_metadata#357.
618 """
619 # '@' is uniquely indicative of a url_req.
620 return ' ' * ('@' in req)
621
622 for section in sections:
623 space = url_req_space(section.value)
624 yield section.value + space + quoted_marker(section.name)
625
626 @property
627 def origin(self):
628 return self._load_json('direct_url.json')
629
630 def _load_json(self, filename):
631 return pass_none(json.loads)(
632 self.read_text(filename),
633 object_hook=lambda data: types.SimpleNamespace(**data),
634 )
635
636
637class DistributionFinder(MetaPathFinder):
638 """
639 A MetaPathFinder capable of discovering installed distributions.
640
641 Custom providers should implement this interface in order to
642 supply metadata.
643 """
644
645 class Context:
646 """
647 Keyword arguments presented by the caller to
648 ``distributions()`` or ``Distribution.discover()``
649 to narrow the scope of a search for distributions
650 in all DistributionFinders.
651
652 Each DistributionFinder may expect any parameters
653 and should attempt to honor the canonical
654 parameters defined below when appropriate.
655
656 This mechanism gives a custom provider a means to
657 solicit additional details from the caller beyond
658 "name" and "path" when searching distributions.
659 For example, imagine a provider that exposes suites
660 of packages in either a "public" or "private" ``realm``.
661 A caller may wish to query only for distributions in
662 a particular realm and could call
663 ``distributions(realm="private")`` to signal to the
664 custom provider to only include distributions from that
665 realm.
666 """
667
668 name = None
669 """
670 Specific name for which a distribution finder should match.
671 A name of ``None`` matches all distributions.
672 """
673
674 def __init__(self, **kwargs):
675 vars(self).update(kwargs)
676
677 @property
678 def path(self) -> List[str]:
679 """
680 The sequence of directory path that a distribution finder
681 should search.
682
683 Typically refers to Python installed package paths such as
684 "site-packages" directories and defaults to ``sys.path``.
685 """
686 return vars(self).get('path', sys.path)
687
688 @abc.abstractmethod
689 def find_distributions(self, context=Context()) -> Iterable[Distribution]:
690 """
691 Find distributions.
692
693 Return an iterable of all Distribution instances capable of
694 loading the metadata for packages matching the ``context``,
695 a DistributionFinder.Context instance.
696 """
697
698
699class FastPath:
700 """
701 Micro-optimized class for searching a root for children.
702
703 Root is a path on the file system that may contain metadata
704 directories either as natural directories or within a zip file.
705
706 >>> FastPath('').children()
707 ['...']
708
709 FastPath objects are cached and recycled for any given root.
710
711 >>> FastPath('foobar') is FastPath('foobar')
712 True
713 """
714
715 @functools.lru_cache() # type: ignore
716 def __new__(cls, root):
717 return super().__new__(cls)
718
719 def __init__(self, root):
720 self.root = root
721
722 def joinpath(self, child):
723 return pathlib.Path(self.root, child)
724
725 def children(self):
726 with suppress(Exception):
727 return os.listdir(self.root or '.')
728 with suppress(Exception):
729 return self.zip_children()
730 return []
731
732 def zip_children(self):
733 zip_path = zipp.Path(self.root)
734 names = zip_path.root.namelist()
735 self.joinpath = zip_path.joinpath
736
737 return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
738
739 def search(self, name):
740 return self.lookup(self.mtime).search(name)
741
742 @property
743 def mtime(self):
744 with suppress(OSError):
745 return os.stat(self.root).st_mtime
746 self.lookup.cache_clear()
747
748 @method_cache
749 def lookup(self, mtime):
750 return Lookup(self)
751
752
753class Lookup:
754 """
755 A micro-optimized class for searching a (fast) path for metadata.
756 """
757
758 def __init__(self, path: FastPath):
759 """
760 Calculate all of the children representing metadata.
761
762 From the children in the path, calculate early all of the
763 children that appear to represent metadata (infos) or legacy
764 metadata (eggs).
765 """
766
767 base = os.path.basename(path.root).lower()
768 base_is_egg = base.endswith(".egg")
769 self.infos = FreezableDefaultDict(list)
770 self.eggs = FreezableDefaultDict(list)
771
772 for child in path.children():
773 low = child.lower()
774 if low.endswith((".dist-info", ".egg-info")):
775 # rpartition is faster than splitext and suitable for this purpose.
776 name = low.rpartition(".")[0].partition("-")[0]
777 normalized = Prepared.normalize(name)
778 self.infos[normalized].append(path.joinpath(child))
779 elif base_is_egg and low == "egg-info":
780 name = base.rpartition(".")[0].partition("-")[0]
781 legacy_normalized = Prepared.legacy_normalize(name)
782 self.eggs[legacy_normalized].append(path.joinpath(child))
783
784 self.infos.freeze()
785 self.eggs.freeze()
786
787 def search(self, prepared: Prepared):
788 """
789 Yield all infos and eggs matching the Prepared query.
790 """
791 infos = (
792 self.infos[prepared.normalized]
793 if prepared
794 else itertools.chain.from_iterable(self.infos.values())
795 )
796 eggs = (
797 self.eggs[prepared.legacy_normalized]
798 if prepared
799 else itertools.chain.from_iterable(self.eggs.values())
800 )
801 return itertools.chain(infos, eggs)
802
803
804class Prepared:
805 """
806 A prepared search query for metadata on a possibly-named package.
807
808 Pre-calculates the normalization to prevent repeated operations.
809
810 >>> none = Prepared(None)
811 >>> none.normalized
812 >>> none.legacy_normalized
813 >>> bool(none)
814 False
815 >>> sample = Prepared('Sample__Pkg-name.foo')
816 >>> sample.normalized
817 'sample_pkg_name_foo'
818 >>> sample.legacy_normalized
819 'sample__pkg_name.foo'
820 >>> bool(sample)
821 True
822 """
823
824 normalized = None
825 legacy_normalized = None
826
827 def __init__(self, name: Optional[str]):
828 self.name = name
829 if name is None:
830 return
831 self.normalized = self.normalize(name)
832 self.legacy_normalized = self.legacy_normalize(name)
833
834 @staticmethod
835 def normalize(name):
836 """
837 PEP 503 normalization plus dashes as underscores.
838 """
839 return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')
840
841 @staticmethod
842 def legacy_normalize(name):
843 """
844 Normalize the package name as found in the convention in
845 older packaging tools versions and specs.
846 """
847 return name.lower().replace('-', '_')
848
849 def __bool__(self):
850 return bool(self.name)
851
852
853@install
854class MetadataPathFinder(NullFinder, DistributionFinder):
855 """A degenerate finder for distribution packages on the file system.
856
857 This finder supplies only a find_distributions() method for versions
858 of Python that do not have a PathFinder find_distributions().
859 """
860
861 @classmethod
862 def find_distributions(
863 cls, context=DistributionFinder.Context()
864 ) -> Iterable[PathDistribution]:
865 """
866 Find distributions.
867
868 Return an iterable of all Distribution instances capable of
869 loading the metadata for packages matching ``context.name``
870 (or all names if ``None`` indicated) along the paths in the list
871 of directories ``context.path``.
872 """
873 found = cls._search_paths(context.name, context.path)
874 return map(PathDistribution, found)
875
876 @classmethod
877 def _search_paths(cls, name, paths):
878 """Find metadata directories in paths heuristically."""
879 prepared = Prepared(name)
880 return itertools.chain.from_iterable(
881 path.search(prepared) for path in map(FastPath, paths)
882 )
883
884 @classmethod
885 def invalidate_caches(cls) -> None:
886 FastPath.__new__.cache_clear()
887
888
889class PathDistribution(Distribution):
890 def __init__(self, path: SimplePath) -> None:
891 """Construct a distribution.
892
893 :param path: SimplePath indicating the metadata directory.
894 """
895 self._path = path
896
897 def read_text(self, filename: str | os.PathLike[str]) -> Optional[str]:
898 with suppress(
899 FileNotFoundError,
900 IsADirectoryError,
901 KeyError,
902 NotADirectoryError,
903 PermissionError,
904 ):
905 return self._path.joinpath(filename).read_text(encoding='utf-8')
906
907 return None
908
909 read_text.__doc__ = Distribution.read_text.__doc__
910
911 def locate_file(self, path: str | os.PathLike[str]) -> SimplePath:
912 return self._path.parent / path
913
914 @property
915 def _normalized_name(self):
916 """
917 Performance optimization: where possible, resolve the
918 normalized name from the file system path.
919 """
920 stem = os.path.basename(str(self._path))
921 return (
922 pass_none(Prepared.normalize)(self._name_from_stem(stem))
923 or super()._normalized_name
924 )
925
926 @staticmethod
927 def _name_from_stem(stem):
928 """
929 >>> PathDistribution._name_from_stem('foo-3.0.egg-info')
930 'foo'
931 >>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info')
932 'CherryPy'
933 >>> PathDistribution._name_from_stem('face.egg-info')
934 'face'
935 >>> PathDistribution._name_from_stem('foo.bar')
936 """
937 filename, ext = os.path.splitext(stem)
938 if ext not in ('.dist-info', '.egg-info'):
939 return
940 name, sep, rest = filename.partition('-')
941 return name
942
943
944def distribution(distribution_name: str) -> Distribution:
945 """Get the ``Distribution`` instance for the named package.
946
947 :param distribution_name: The name of the distribution package as a string.
948 :return: A ``Distribution`` instance (or subclass thereof).
949 """
950 return Distribution.from_name(distribution_name)
951
952
953def distributions(**kwargs) -> Iterable[Distribution]:
954 """Get all ``Distribution`` instances in the current environment.
955
956 :return: An iterable of ``Distribution`` instances.
957 """
958 return Distribution.discover(**kwargs)
959
960
961def metadata(distribution_name: str) -> _meta.PackageMetadata:
962 """Get the metadata for the named package.
963
964 :param distribution_name: The name of the distribution package to query.
965 :return: A PackageMetadata containing the parsed metadata.
966 """
967 return Distribution.from_name(distribution_name).metadata
968
969
970def version(distribution_name: str) -> str:
971 """Get the version string for the named package.
972
973 :param distribution_name: The name of the distribution package to query.
974 :return: The version string for the package as defined in the package's
975 "Version" metadata key.
976 """
977 return distribution(distribution_name).version
978
979
980_unique = functools.partial(
981 unique_everseen,
982 key=py39.normalized_name,
983)
984"""
985Wrapper for ``distributions`` to return unique distributions by name.
986"""
987
988
989def entry_points(**params) -> EntryPoints:
990 """Return EntryPoint objects for all installed packages.
991
992 Pass selection parameters (group or name) to filter the
993 result to entry points matching those properties (see
994 EntryPoints.select()).
995
996 :return: EntryPoints for all installed packages.
997 """
998 eps = itertools.chain.from_iterable(
999 dist.entry_points for dist in _unique(distributions())
1000 )
1001 return EntryPoints(eps).select(**params)
1002
1003
1004def files(distribution_name: str) -> Optional[List[PackagePath]]:
1005 """Return a list of files for the named package.
1006
1007 :param distribution_name: The name of the distribution package to query.
1008 :return: List of files composing the distribution.
1009 """
1010 return distribution(distribution_name).files
1011
1012
1013def requires(distribution_name: str) -> Optional[List[str]]:
1014 """
1015 Return a list of requirements for the named package.
1016
1017 :return: An iterable of requirements, suitable for
1018 packaging.requirement.Requirement.
1019 """
1020 return distribution(distribution_name).requires
1021
1022
1023def packages_distributions() -> Mapping[str, List[str]]:
1024 """
1025 Return a mapping of top-level packages to their
1026 distributions.
1027
1028 >>> import collections.abc
1029 >>> pkgs = packages_distributions()
1030 >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())
1031 True
1032 """
1033 pkg_to_dist = collections.defaultdict(list)
1034 for dist in distributions():
1035 for pkg in _top_level_declared(dist) or _top_level_inferred(dist):
1036 pkg_to_dist[pkg].append(dist.metadata['Name'])
1037 return dict(pkg_to_dist)
1038
1039
1040def _top_level_declared(dist):
1041 return (dist.read_text('top_level.txt') or '').split()
1042
1043
1044def _topmost(name: PackagePath) -> Optional[str]:
1045 """
1046 Return the top-most parent as long as there is a parent.
1047 """
1048 top, *rest = name.parts
1049 return top if rest else None
1050
1051
1052def _get_toplevel_name(name: PackagePath) -> str:
1053 """
1054 Infer a possibly importable module name from a name presumed on
1055 sys.path.
1056
1057 >>> _get_toplevel_name(PackagePath('foo.py'))
1058 'foo'
1059 >>> _get_toplevel_name(PackagePath('foo'))
1060 'foo'
1061 >>> _get_toplevel_name(PackagePath('foo.pyc'))
1062 'foo'
1063 >>> _get_toplevel_name(PackagePath('foo/__init__.py'))
1064 'foo'
1065 >>> _get_toplevel_name(PackagePath('foo.pth'))
1066 'foo.pth'
1067 >>> _get_toplevel_name(PackagePath('foo.dist-info'))
1068 'foo.dist-info'
1069 """
1070 return _topmost(name) or (
1071 # python/typeshed#10328
1072 inspect.getmodulename(name) # type: ignore
1073 or str(name)
1074 )
1075
1076
1077def _top_level_inferred(dist):
1078 opt_names = set(map(_get_toplevel_name, always_iterable(dist.files)))
1079
1080 def importable_name(name):
1081 return '.' not in name
1082
1083 return filter(importable_name, opt_names)