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