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