• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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