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