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