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