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