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