codekingpro/portable-devtools
114k
1"""2APIs exposing metadata from third-party Python packages.3 4This codebase is shared between importlib.metadata in the stdlib5and importlib_metadata in PyPI. See6https://github.com/python/importlib_metadata/wiki/Development-Methodology7for more detail.8"""9 10from __future__ import annotations11 12import abc13import collections14import email15import functools16import itertools17import operator18import os19import pathlib20import posixpath21import re22import sys23import textwrap24import types25from collections.abc import Iterable, Mapping26from contextlib import suppress27from importlib import import_module28from importlib.abc import MetaPathFinder29from itertools import starmap30from typing import Any31 32from . import _meta33from ._collections import FreezableDefaultDict, Pair34from ._compat import (35 NullFinder,36 install,37)38from ._functools import method_cache, noop, pass_none, passthrough39from ._itertools import always_iterable, bucket, unique_everseen40from ._meta import PackageMetadata, SimplePath41from ._typing import md_none42from .compat import py39, py31143 44__all__ = [45 'Distribution',46 'DistributionFinder',47 'PackageMetadata',48 'PackageNotFoundError',49 'SimplePath',50 'distribution',51 'distributions',52 'entry_points',53 'files',54 'metadata',55 'packages_distributions',56 'requires',57 'version',58]59 60 61class PackageNotFoundError(ModuleNotFoundError):62 """The package was not found."""63 64 def __str__(self) -> str:65 return f"No package metadata was found for {self.name}"66 67 @property68 def name(self) -> str: # type: ignore[override] # make readonly69 (name,) = self.args70 return name71 72 73class Sectioned:74 """75 A simple entry point config parser for performance76 77 >>> for item in Sectioned.read(Sectioned._sample):78 ... print(item)79 Pair(name='sec1', value='# comments ignored')80 Pair(name='sec1', value='a = 1')81 Pair(name='sec1', value='b = 2')82 Pair(name='sec2', value='a = 2')83 84 >>> res = Sectioned.section_pairs(Sectioned._sample)85 >>> item = next(res)86 >>> item.name87 'sec1'88 >>> item.value89 Pair(name='a', value='1')90 >>> item = next(res)91 >>> item.value92 Pair(name='b', value='2')93 >>> item = next(res)94 >>> item.name95 'sec2'96 >>> item.value97 Pair(name='a', value='2')98 >>> list(res)99 []100 """101 102 _sample = textwrap.dedent(103 """104 [sec1]105 # comments ignored106 a = 1107 b = 2108 109 [sec2]110 a = 2111 """112 ).lstrip()113 114 @classmethod115 def section_pairs(cls, text):116 return (117 section._replace(value=Pair.parse(section.value))118 for section in cls.read(text, filter_=cls.valid)119 if section.name is not None120 )121 122 @staticmethod123 def read(text, filter_=None):124 lines = filter(filter_, map(str.strip, text.splitlines()))125 name = None126 for value in lines:127 section_match = value.startswith('[') and value.endswith(']')128 if section_match:129 name = value.strip('[]')130 continue131 yield Pair(name, value)132 133 @staticmethod134 def valid(line: str):135 return line and not line.startswith('#')136 137 138class _EntryPointMatch(types.SimpleNamespace):139 module: str140 attr: str141 extras: str142 143 144class EntryPoint:145 """An entry point as defined by Python packaging conventions.146 147 See `the packaging docs on entry points148 <https://packaging.python.org/specifications/entry-points/>`_149 for more information.150 151 >>> ep = EntryPoint(152 ... name=None, group=None, value='package.module:attr [extra1, extra2]')153 >>> ep.module154 'package.module'155 >>> ep.attr156 'attr'157 >>> ep.extras158 ['extra1', 'extra2']159 160 If the value package or module are not valid identifiers, a161 ValueError is raised on access.162 163 >>> EntryPoint(name=None, group=None, value='invalid-name').module164 Traceback (most recent call last):165 ...166 ValueError: ('Invalid object reference...invalid-name...167 >>> EntryPoint(name=None, group=None, value='invalid-name').attr168 Traceback (most recent call last):169 ...170 ValueError: ('Invalid object reference...invalid-name...171 >>> EntryPoint(name=None, group=None, value='invalid-name').extras172 Traceback (most recent call last):173 ...174 ValueError: ('Invalid object reference...invalid-name...175 176 The same thing happens on construction.177 178 >>> EntryPoint(name=None, group=None, value='invalid-name')179 Traceback (most recent call last):180 ...181 ValueError: ('Invalid object reference...invalid-name...182 183 """184 185 pattern = re.compile(186 r'(?P<module>[\w.]+)\s*'187 r'(:\s*(?P<attr>[\w.]+)\s*)?'188 r'((?P<extras>\[.*\])\s*)?$'189 )190 """191 A regular expression describing the syntax for an entry point,192 which might look like:193 194 - module195 - package.module196 - package.module:attribute197 - package.module:object.attribute198 - package.module:attr [extra1, extra2]199 200 Other combinations are possible as well.201 202 The expression is lenient about whitespace around the ':',203 following the attr, and following any extras.204 """205 206 name: str207 value: str208 group: str209 210 dist: Distribution | None = None211 212 def __init__(self, name: str, value: str, group: str) -> None:213 vars(self).update(name=name, value=value, group=group)214 self.module215 216 def load(self) -> Any:217 """Load the entry point from its definition. If only a module218 is indicated by the value, return that module. Otherwise,219 return the named object.220 """221 module = import_module(self.module)222 attrs = filter(None, (self.attr or '').split('.'))223 return functools.reduce(getattr, attrs, module)224 225 @property226 def module(self) -> str:227 return self._match.module228 229 @property230 def attr(self) -> str:231 return self._match.attr232 233 @property234 def extras(self) -> list[str]:235 return re.findall(r'\w+', self._match.extras or '')236 237 @functools.cached_property238 def _match(self) -> _EntryPointMatch:239 match = self.pattern.match(self.value)240 if not match:241 raise ValueError(242 'Invalid object reference. '243 'See https://packaging.python.org'244 '/en/latest/specifications/entry-points/#data-model',245 self.value,246 )247 return _EntryPointMatch(**match.groupdict())248 249 def _for(self, dist):250 vars(self).update(dist=dist)251 return self252 253 def matches(self, **params):254 """255 EntryPoint matches the given parameters.256 257 >>> ep = EntryPoint(group='foo', name='bar', value='bing:bong [extra1, extra2]')258 >>> ep.matches(group='foo')259 True260 >>> ep.matches(name='bar', value='bing:bong [extra1, extra2]')261 True262 >>> ep.matches(group='foo', name='other')263 False264 >>> ep.matches()265 True266 >>> ep.matches(extras=['extra1', 'extra2'])267 True268 >>> ep.matches(module='bing')269 True270 >>> ep.matches(attr='bong')271 True272 """273 self._disallow_dist(params)274 attrs = (getattr(self, param) for param in params)275 return all(map(operator.eq, params.values(), attrs))276 277 @staticmethod278 def _disallow_dist(params):279 """280 Querying by dist is not allowed (dist objects are not comparable).281 >>> EntryPoint(name='fan', value='fav', group='fag').matches(dist='foo')282 Traceback (most recent call last):283 ...284 ValueError: "dist" is not suitable for matching...285 """286 if "dist" in params:287 raise ValueError(288 '"dist" is not suitable for matching. '289 "Instead, use Distribution.entry_points.select() on a "290 "located distribution."291 )292 293 def _key(self):294 return self.name, self.value, self.group295 296 def __lt__(self, other):297 return self._key() < other._key()298 299 def __eq__(self, other):300 return self._key() == other._key()301 302 def __setattr__(self, name, value):303 raise AttributeError("EntryPoint objects are immutable.")304 305 def __repr__(self):306 return (307 f'EntryPoint(name={self.name!r}, value={self.value!r}, '308 f'group={self.group!r})'309 )310 311 def __hash__(self) -> int:312 return hash(self._key())313 314 315class EntryPoints(tuple):316 """317 An immutable collection of selectable EntryPoint objects.318 """319 320 __slots__ = ()321 322 def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] # Work with str instead of int323 """324 Get the EntryPoint in self matching name.325 """326 try:327 return next(iter(self.select(name=name)))328 except StopIteration:329 raise KeyError(name)330 331 def __repr__(self):332 """333 Repr with classname and tuple constructor to334 signal that we deviate from regular tuple behavior.335 """336 return '%s(%r)' % (self.__class__.__name__, tuple(self))337 338 def select(self, **params) -> EntryPoints:339 """340 Select entry points from self that match the341 given parameters (typically group and/or name).342 """343 return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params))344 345 @property346 def names(self) -> set[str]:347 """348 Return the set of all names of all entry points.349 """350 return {ep.name for ep in self}351 352 @property353 def groups(self) -> set[str]:354 """355 Return the set of all groups of all entry points.356 """357 return {ep.group for ep in self}358 359 @classmethod360 def _from_text_for(cls, text, dist):361 return cls(ep._for(dist) for ep in cls._from_text(text))362 363 @staticmethod364 def _from_text(text):365 return (366 EntryPoint(name=item.value.name, value=item.value.value, group=item.name)367 for item in Sectioned.section_pairs(text or '')368 )369 370 371class PackagePath(pathlib.PurePosixPath):372 """A reference to a path in a package"""373 374 hash: FileHash | None375 size: int376 dist: Distribution377 378 def read_text(self, encoding: str = 'utf-8') -> str:379 return self.locate().read_text(encoding=encoding)380 381 def read_binary(self) -> bytes:382 return self.locate().read_bytes()383 384 def locate(self) -> SimplePath:385 """Return a path-like object for this path"""386 return self.dist.locate_file(self)387 388 389class FileHash:390 def __init__(self, spec: str) -> None:391 self.mode, _, self.value = spec.partition('=')392 393 def __repr__(self) -> str:394 return f'<FileHash mode: {self.mode} value: {self.value}>'395 396 397class Distribution(metaclass=abc.ABCMeta):398 """399 An abstract Python distribution package.400 401 Custom providers may derive from this class and define402 the abstract methods to provide a concrete implementation403 for their environment. Some providers may opt to override404 the default implementation of some properties to bypass405 the file-reading mechanism.406 """407 408 @abc.abstractmethod409 def read_text(self, filename) -> str | None:410 """Attempt to load metadata file given by the name.411 412 Python distribution metadata is organized by blobs of text413 typically represented as "files" in the metadata directory414 (e.g. package-1.0.dist-info). These files include things415 like:416 417 - METADATA: The distribution metadata including fields418 like Name and Version and Description.419 - entry_points.txt: A series of entry points as defined in420 `the entry points spec <https://packaging.python.org/en/latest/specifications/entry-points/#file-format>`_.421 - RECORD: A record of files according to422 `this recording spec <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#the-record-file>`_.423 424 A package may provide any set of files, including those425 not listed here or none at all.426 427 :param filename: The name of the file in the distribution info.428 :return: The text if found, otherwise None.429 """430 431 @abc.abstractmethod432 def locate_file(self, path: str | os.PathLike[str]) -> SimplePath:433 """434 Given a path to a file in this distribution, return a SimplePath435 to it.436 437 This method is used by callers of ``Distribution.files()`` to438 locate files within the distribution. If it's possible for a439 Distribution to represent files in the distribution as440 ``SimplePath`` objects, it should implement this method441 to resolve such objects.442 443 Some Distribution providers may elect not to resolve SimplePath444 objects within the distribution by raising a445 NotImplementedError, but consumers of such a Distribution would446 be unable to invoke ``Distribution.files()``.447 """448 449 @classmethod450 def from_name(cls, name: str) -> Distribution:451 """Return the Distribution for the given package name.452 453 :param name: The name of the distribution package to search for.454 :return: The Distribution instance (or subclass thereof) for the named455 package, if found.456 :raises PackageNotFoundError: When the named package's distribution457 metadata cannot be found.458 :raises ValueError: When an invalid value is supplied for name.459 """460 if not name:461 raise ValueError("A distribution name is required.")462 try:463 return next(iter(cls._prefer_valid(cls.discover(name=name))))464 except StopIteration:465 raise PackageNotFoundError(name)466 467 @classmethod468 def discover(469 cls, *, context: DistributionFinder.Context | None = None, **kwargs470 ) -> Iterable[Distribution]:471 """Return an iterable of Distribution objects for all packages.472 473 Pass a ``context`` or pass keyword arguments for constructing474 a context.475 476 :context: A ``DistributionFinder.Context`` object.477 :return: Iterable of Distribution objects for packages matching478 the context.479 """480 if context and kwargs:481 raise ValueError("cannot accept context and kwargs")482 context = context or DistributionFinder.Context(**kwargs)483 return itertools.chain.from_iterable(484 resolver(context) for resolver in cls._discover_resolvers()485 )486 487 @staticmethod488 def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:489 """490 Prefer (move to the front) distributions that have metadata.491 492 Ref python/importlib_resources#489.493 """494 buckets = bucket(dists, lambda dist: bool(dist.metadata))495 return itertools.chain(buckets[True], buckets[False])496 497 @staticmethod498 def at(path: str | os.PathLike[str]) -> Distribution:499 """Return a Distribution for the indicated metadata path.500 501 :param path: a string or path-like object502 :return: a concrete Distribution instance for the path503 """504 return PathDistribution(pathlib.Path(path))505 506 @staticmethod507 def _discover_resolvers():508 """Search the meta_path for resolvers (MetadataPathFinders)."""509 declared = (510 getattr(finder, 'find_distributions', None) for finder in sys.meta_path511 )512 return filter(None, declared)513 514 @property515 def metadata(self) -> _meta.PackageMetadata | None:516 """Return the parsed metadata for this Distribution.517 518 The returned object will have keys that name the various bits of519 metadata per the520 `Core metadata specifications <https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata>`_.521 522 Custom providers may provide the METADATA file or override this523 property.524 """525 526 text = (527 self.read_text('METADATA')528 or self.read_text('PKG-INFO')529 # This last clause is here to support old egg-info files. Its530 # effect is to just end up using the PathDistribution's self._path531 # (which points to the egg-info file) attribute unchanged.532 or self.read_text('')533 )534 return self._assemble_message(text)535 536 @staticmethod537 @pass_none538 def _assemble_message(text: str) -> _meta.PackageMetadata:539 # deferred for performance (python/cpython#109829)540 from . import _adapters541 542 return _adapters.Message(email.message_from_string(text))543 544 @property545 def name(self) -> str:546 """Return the 'Name' metadata for the distribution package."""547 return md_none(self.metadata)['Name']548 549 @property550 def _normalized_name(self):551 """Return a normalized version of the name."""552 return Prepared.normalize(self.name)553 554 @property555 def version(self) -> str:556 """Return the 'Version' metadata for the distribution package."""557 return md_none(self.metadata)['Version']558 559 @property560 def entry_points(self) -> EntryPoints:561 """562 Return EntryPoints for this distribution.563 564 Custom providers may provide the ``entry_points.txt`` file565 or override this property.566 """567 return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)568 569 @property570 def files(self) -> list[PackagePath] | None:571 """Files in this distribution.572 573 :return: List of PackagePath for this distribution or None574 575 Result is `None` if the metadata file that enumerates files576 (i.e. RECORD for dist-info, or installed-files.txt or577 SOURCES.txt for egg-info) is missing.578 Result may be empty if the metadata exists but is empty.579 580 Custom providers are recommended to provide a "RECORD" file (in581 ``read_text``) or override this property to allow for callers to be582 able to resolve filenames provided by the package.583 """584 585 def make_file(name, hash=None, size_str=None):586 result = PackagePath(name)587 result.hash = FileHash(hash) if hash else None588 result.size = int(size_str) if size_str else None589 result.dist = self590 return result591 592 @pass_none593 def make_files(lines):594 # Delay csv import, since Distribution.files is not as widely used595 # as other parts of importlib.metadata596 import csv597 598 return starmap(make_file, csv.reader(lines))599 600 @pass_none601 def skip_missing_files(package_paths):602 return list(filter(lambda path: path.locate().exists(), package_paths))603 604 return skip_missing_files(605 make_files(606 self._read_files_distinfo()607 or self._read_files_egginfo_installed()608 or self._read_files_egginfo_sources()609 )610 )611 612 def _read_files_distinfo(self):613 """614 Read the lines of RECORD.615 """616 text = self.read_text('RECORD')617 return text and text.splitlines()618 619 def _read_files_egginfo_installed(self):620 """621 Read installed-files.txt and return lines in a similar622 CSV-parsable format as RECORD: each file must be placed623 relative to the site-packages directory and must also be624 quoted (since file names can contain literal commas).625 626 This file is written when the package is installed by pip,627 but it might not be written for other installation methods.628 Assume the file is accurate if it exists.629 """630 text = self.read_text('installed-files.txt')631 # Prepend the .egg-info/ subdir to the lines in this file.632 # But this subdir is only available from PathDistribution's633 # self._path.634 subdir = getattr(self, '_path', None)635 if not text or not subdir:636 return637 638 paths = (639 py311640 .relative_fix((subdir / name).resolve())641 .relative_to(self.locate_file('').resolve(), walk_up=True)642 .as_posix()643 for name in text.splitlines()644 )645 return map('"{}"'.format, paths)646 647 def _read_files_egginfo_sources(self):648 """649 Read SOURCES.txt and return lines in a similar CSV-parsable650 format as RECORD: each file name must be quoted (since it651 might contain literal commas).652 653 Note that SOURCES.txt is not a reliable source for what654 files are installed by a package. This file is generated655 for a source archive, and the files that are present656 there (e.g. setup.py) may not correctly reflect the files657 that are present after the package has been installed.658 """659 text = self.read_text('SOURCES.txt')660 return text and map('"{}"'.format, text.splitlines())661 662 @property663 def requires(self) -> list[str] | None:664 """Generated requirements specified for this Distribution"""665 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()666 return reqs and list(reqs)667 668 def _read_dist_info_reqs(self):669 return self.metadata.get_all('Requires-Dist')670 671 def _read_egg_info_reqs(self):672 source = self.read_text('requires.txt')673 return pass_none(self._deps_from_requires_text)(source)674 675 @classmethod676 def _deps_from_requires_text(cls, source):677 return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source))678 679 @staticmethod680 def _convert_egg_info_reqs_to_simple_reqs(sections):681 """682 Historically, setuptools would solicit and store 'extra'683 requirements, including those with environment markers,684 in separate sections. More modern tools expect each685 dependency to be defined separately, with any relevant686 extras and environment markers attached directly to that687 requirement. This method converts the former to the688 latter. See _test_deps_from_requires_text for an example.689 """690 691 def make_condition(name):692 return name and f'extra == "{name}"'693 694 def quoted_marker(section):695 section = section or ''696 extra, sep, markers = section.partition(':')697 if extra and markers:698 markers = f'({markers})'699 conditions = list(filter(None, [markers, make_condition(extra)]))700 return '; ' + ' and '.join(conditions) if conditions else ''701 702 def url_req_space(req):703 """704 PEP 508 requires a space between the url_spec and the quoted_marker.705 Ref python/importlib_metadata#357.706 """707 # '@' is uniquely indicative of a url_req.708 return ' ' * ('@' in req)709 710 for section in sections:711 space = url_req_space(section.value)712 yield section.value + space + quoted_marker(section.name)713 714 @property715 def origin(self):716 return self._load_json('direct_url.json')717 718 def _load_json(self, filename):719 # Deferred for performance (python/importlib_metadata#503)720 import json721 722 return pass_none(json.loads)(723 self.read_text(filename),724 object_hook=lambda data: types.SimpleNamespace(**data),725 )726 727 728class DistributionFinder(MetaPathFinder):729 """730 A MetaPathFinder capable of discovering installed distributions.731 732 Custom providers should implement this interface in order to733 supply metadata.734 """735 736 class Context:737 """738 Keyword arguments presented by the caller to739 ``distributions()`` or ``Distribution.discover()``740 to narrow the scope of a search for distributions741 in all DistributionFinders.742 743 Each DistributionFinder may expect any parameters744 and should attempt to honor the canonical745 parameters defined below when appropriate.746 747 This mechanism gives a custom provider a means to748 solicit additional details from the caller beyond749 "name" and "path" when searching distributions.750 For example, imagine a provider that exposes suites751 of packages in either a "public" or "private" ``realm``.752 A caller may wish to query only for distributions in753 a particular realm and could call754 ``distributions(realm="private")`` to signal to the755 custom provider to only include distributions from that756 realm.757 """758 759 name = None760 """761 Specific name for which a distribution finder should match.762 A name of ``None`` matches all distributions.763 """764 765 def __init__(self, **kwargs):766 vars(self).update(kwargs)767 768 @property769 def path(self) -> list[str]:770 """771 The sequence of directory path that a distribution finder772 should search.773 774 Typically refers to Python installed package paths such as775 "site-packages" directories and defaults to ``sys.path``.776 """777 return vars(self).get('path', sys.path)778 779 @abc.abstractmethod780 def find_distributions(self, context=Context()) -> Iterable[Distribution]:781 """782 Find distributions.783 784 Return an iterable of all Distribution instances capable of785 loading the metadata for packages matching the ``context``,786 a DistributionFinder.Context instance.787 """788 789 790@passthrough791def _clear_after_fork(cached):792 """Ensure ``func`` clears cached state after ``fork`` when supported.793 794 ``FastPath`` caches zip-backed ``pathlib.Path`` objects that retain a795 reference to the parent's open ``ZipFile`` handle. Re-using a cached796 instance in a forked child can therefore resurrect invalid file pointers797 and trigger ``BadZipFile``/``OSError`` failures (python/importlib_metadata#520).798 Registering ``cache_clear`` with ``os.register_at_fork`` keeps each process799 on its own cache.800 """801 getattr(os, 'register_at_fork', noop)(after_in_child=cached.cache_clear)802 803 804class FastPath:805 """806 Micro-optimized class for searching a root for children.807 808 Root is a path on the file system that may contain metadata809 directories either as natural directories or within a zip file.810 811 >>> FastPath('').children()812 ['...']813 814 FastPath objects are cached and recycled for any given root.815 816 >>> FastPath('foobar') is FastPath('foobar')817 True818 """819 820 @_clear_after_fork # type: ignore[misc]821 @functools.lru_cache()822 def __new__(cls, root):823 return super().__new__(cls)824 825 def __init__(self, root):826 self.root = root827 828 def joinpath(self, child):829 return pathlib.Path(self.root, child)830 831 def children(self):832 with suppress(Exception):833 return os.listdir(self.root or '.')834 with suppress(Exception):835 return self.zip_children()836 return []837 838 def zip_children(self):839 # deferred for performance (python/importlib_metadata#502)840 from zipp.compat.overlay import zipfile841 842 zip_path = zipfile.Path(self.root)843 names = zip_path.root.namelist()844 self.joinpath = zip_path.joinpath845 846 return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)847 848 def search(self, name):849 return self.lookup(self.mtime).search(name)850 851 @property852 def mtime(self):853 with suppress(OSError):854 return os.stat(self.root).st_mtime855 self.lookup.cache_clear()856 857 @method_cache858 def lookup(self, mtime):859 return Lookup(self)860 861 862class Lookup:863 """864 A micro-optimized class for searching a (fast) path for metadata.865 """866 867 def __init__(self, path: FastPath):868 """869 Calculate all of the children representing metadata.870 871 From the children in the path, calculate early all of the872 children that appear to represent metadata (infos) or legacy873 metadata (eggs).874 """875 876 base = os.path.basename(path.root).lower()877 base_is_egg = base.endswith(".egg")878 self.infos = FreezableDefaultDict(list)879 self.eggs = FreezableDefaultDict(list)880 881 for child in path.children():882 low = child.lower()883 if low.endswith((".dist-info", ".egg-info")):884 # rpartition is faster than splitext and suitable for this purpose.885 name = low.rpartition(".")[0].partition("-")[0]886 normalized = Prepared.normalize(name)887 self.infos[normalized].append(path.joinpath(child))888 elif base_is_egg and low == "egg-info":889 name = base.rpartition(".")[0].partition("-")[0]890 legacy_normalized = Prepared.legacy_normalize(name)891 self.eggs[legacy_normalized].append(path.joinpath(child))892 893 self.infos.freeze()894 self.eggs.freeze()895 896 def search(self, prepared: Prepared):897 """898 Yield all infos and eggs matching the Prepared query.899 """900 infos = (901 self.infos[prepared.normalized]902 if prepared903 else itertools.chain.from_iterable(self.infos.values())904 )905 eggs = (906 self.eggs[prepared.legacy_normalized]907 if prepared908 else itertools.chain.from_iterable(self.eggs.values())909 )910 return itertools.chain(infos, eggs)911 912 913class Prepared:914 """915 A prepared search query for metadata on a possibly-named package.916 917 Pre-calculates the normalization to prevent repeated operations.918 919 >>> none = Prepared(None)920 >>> none.normalized921 >>> none.legacy_normalized922 >>> bool(none)923 False924 >>> sample = Prepared('Sample__Pkg-name.foo')925 >>> sample.normalized926 'sample_pkg_name_foo'927 >>> sample.legacy_normalized928 'sample__pkg_name.foo'929 >>> bool(sample)930 True931 """932 933 normalized = None934 legacy_normalized = None935 936 def __init__(self, name: str | None):937 self.name = name938 if name is None:939 return940 self.normalized = self.normalize(name)941 self.legacy_normalized = self.legacy_normalize(name)942 943 @staticmethod944 def normalize(name):945 """946 PEP 503 normalization plus dashes as underscores.947 """948 return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')949 950 @staticmethod951 def legacy_normalize(name):952 """953 Normalize the package name as found in the convention in954 older packaging tools versions and specs.955 """956 return name.lower().replace('-', '_')957 958 def __bool__(self):959 return bool(self.name)960 961 962@install963class MetadataPathFinder(NullFinder, DistributionFinder):964 """A degenerate finder for distribution packages on the file system.965 966 This finder supplies only a find_distributions() method for versions967 of Python that do not have a PathFinder find_distributions().968 """969 970 @classmethod971 def find_distributions(972 cls, context=DistributionFinder.Context()973 ) -> Iterable[PathDistribution]:974 """975 Find distributions.976 977 Return an iterable of all Distribution instances capable of978 loading the metadata for packages matching ``context.name``979 (or all names if ``None`` indicated) along the paths in the list980 of directories ``context.path``.981 """982 found = cls._search_paths(context.name, context.path)983 return map(PathDistribution, found)984 985 @classmethod986 def _search_paths(cls, name, paths):987 """Find metadata directories in paths heuristically."""988 prepared = Prepared(name)989 return itertools.chain.from_iterable(990 path.search(prepared) for path in map(FastPath, paths)991 )992 993 @classmethod994 def invalidate_caches(cls) -> None:995 FastPath.__new__.cache_clear()996 997 998class PathDistribution(Distribution):999 def __init__(self, path: SimplePath) -> None:1000 """Construct a distribution.1001 1002 :param path: SimplePath indicating the metadata directory.1003 """1004 self._path = path1005 1006 def read_text(self, filename: str | os.PathLike[str]) -> str | None:1007 with suppress(1008 FileNotFoundError,1009 IsADirectoryError,1010 KeyError,1011 NotADirectoryError,1012 PermissionError,1013 ):1014 return self._path.joinpath(filename).read_text(encoding='utf-8')1015 1016 return None1017 1018 read_text.__doc__ = Distribution.read_text.__doc__1019 1020 def locate_file(self, path: str | os.PathLike[str]) -> SimplePath:1021 return self._path.parent / path1022 1023 @property1024 def _normalized_name(self):1025 """1026 Performance optimization: where possible, resolve the1027 normalized name from the file system path.1028 """1029 stem = os.path.basename(str(self._path))1030 return (1031 pass_none(Prepared.normalize)(self._name_from_stem(stem))1032 or super()._normalized_name1033 )1034 1035 @staticmethod1036 def _name_from_stem(stem):1037 """1038 >>> PathDistribution._name_from_stem('foo-3.0.egg-info')1039 'foo'1040 >>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info')1041 'CherryPy'1042 >>> PathDistribution._name_from_stem('face.egg-info')1043 'face'1044 >>> PathDistribution._name_from_stem('foo.bar')1045 """1046 filename, ext = os.path.splitext(stem)1047 if ext not in ('.dist-info', '.egg-info'):1048 return1049 name, sep, rest = filename.partition('-')1050 return name1051 1052 1053def distribution(distribution_name: str) -> Distribution:1054 """Get the ``Distribution`` instance for the named package.1055 1056 :param distribution_name: The name of the distribution package as a string.1057 :return: A ``Distribution`` instance (or subclass thereof).1058 """1059 return Distribution.from_name(distribution_name)1060 1061 1062def distributions(**kwargs) -> Iterable[Distribution]:1063 """Get all ``Distribution`` instances in the current environment.1064 1065 :return: An iterable of ``Distribution`` instances.1066 """1067 return Distribution.discover(**kwargs)1068 1069 1070def metadata(distribution_name: str) -> _meta.PackageMetadata | None:1071 """Get the metadata for the named package.1072 1073 :param distribution_name: The name of the distribution package to query.1074 :return: A PackageMetadata containing the parsed metadata.1075 """1076 return Distribution.from_name(distribution_name).metadata1077 1078 1079def version(distribution_name: str) -> str:1080 """Get the version string for the named package.1081 1082 :param distribution_name: The name of the distribution package to query.1083 :return: The version string for the package as defined in the package's1084 "Version" metadata key.1085 """1086 return distribution(distribution_name).version1087 1088 1089_unique = functools.partial(1090 unique_everseen,1091 key=py39.normalized_name,1092)1093"""1094Wrapper for ``distributions`` to return unique distributions by name.1095"""1096 1097 1098def entry_points(**params) -> EntryPoints:1099 """Return EntryPoint objects for all installed packages.1100 1101 Pass selection parameters (group or name) to filter the1102 result to entry points matching those properties (see1103 EntryPoints.select()).1104 1105 :return: EntryPoints for all installed packages.1106 """1107 eps = itertools.chain.from_iterable(1108 dist.entry_points for dist in _unique(distributions())1109 )1110 return EntryPoints(eps).select(**params)1111 1112 1113def files(distribution_name: str) -> list[PackagePath] | None:1114 """Return a list of files for the named package.1115 1116 :param distribution_name: The name of the distribution package to query.1117 :return: List of files composing the distribution.1118 """1119 return distribution(distribution_name).files1120 1121 1122def requires(distribution_name: str) -> list[str] | None:1123 """1124 Return a list of requirements for the named package.1125 1126 :return: An iterable of requirements, suitable for1127 packaging.requirement.Requirement.1128 """1129 return distribution(distribution_name).requires1130 1131 1132def packages_distributions() -> Mapping[str, list[str]]:1133 """1134 Return a mapping of top-level packages to their1135 distributions.1136 1137 >>> import collections.abc1138 >>> pkgs = packages_distributions()1139 >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())1140 True1141 """1142 pkg_to_dist = collections.defaultdict(list)1143 for dist in distributions():1144 for pkg in _top_level_declared(dist) or _top_level_inferred(dist):1145 pkg_to_dist[pkg].append(md_none(dist.metadata)['Name'])1146 return dict(pkg_to_dist)1147 1148 1149def _top_level_declared(dist):1150 return (dist.read_text('top_level.txt') or '').split()1151 1152 1153def _topmost(name: PackagePath) -> str | None:1154 """1155 Return the top-most parent as long as there is a parent.1156 """1157 top, *rest = name.parts1158 return top if rest else None1159 1160 1161def _get_toplevel_name(name: PackagePath) -> str:1162 """1163 Infer a possibly importable module name from a name presumed on1164 sys.path.1165 1166 >>> _get_toplevel_name(PackagePath('foo.py'))1167 'foo'1168 >>> _get_toplevel_name(PackagePath('foo'))1169 'foo'1170 >>> _get_toplevel_name(PackagePath('foo.pyc'))1171 'foo'1172 >>> _get_toplevel_name(PackagePath('foo/__init__.py'))1173 'foo'1174 >>> _get_toplevel_name(PackagePath('foo.pth'))1175 'foo.pth'1176 >>> _get_toplevel_name(PackagePath('foo.dist-info'))1177 'foo.dist-info'1178 """1179 # Defer import of inspect for performance (python/cpython#118761)1180 import inspect1181 1182 return _topmost(name) or inspect.getmodulename(name) or str(name)1183 1184 1185def _top_level_inferred(dist):1186 opt_names = set(map(_get_toplevel_name, always_iterable(dist.files)))1187 1188 def importable_name(name):1189 return '.' not in name1190 1191 return filter(importable_name, opt_names)1192 