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