44import csv
55import sys
66import email
7+ import inspect
78import pathlib
89import zipfile
910import operator
11+ import warnings
1012import functools
1113import itertools
1214import posixpath
13- import collections
15+ import collections .abc
16+
17+ from ._itertools import unique_everseen
1418
1519from configparser import ConfigParser
1620from contextlib import suppress
1721from importlib import import_module
1822from importlib .abc import MetaPathFinder
1923from itertools import starmap
20- from typing import Any , List , Optional , Protocol , TypeVar , Union
24+ from typing import Any , List , Mapping , Optional , Protocol , TypeVar , Union
2125
2226
2327__all__ = [
@@ -120,18 +124,19 @@ def _from_text(cls, text):
120124 config .read_string (text )
121125 return cls ._from_config (config )
122126
123- @classmethod
124- def _from_text_for (cls , text , dist ):
125- return (ep ._for (dist ) for ep in cls ._from_text (text ))
126-
127127 def _for (self , dist ):
128128 self .dist = dist
129129 return self
130130
131131 def __iter__ (self ):
132132 """
133- Supply iter so one may construct dicts of EntryPoints easily .
133+ Supply iter so one may construct dicts of EntryPoints by name .
134134 """
135+ msg = (
136+ "Construction of dict of EntryPoints is deprecated in "
137+ "favor of EntryPoints."
138+ )
139+ warnings .warn (msg , DeprecationWarning )
135140 return iter ((self .name , self ))
136141
137142 def __reduce__ (self ):
@@ -140,6 +145,143 @@ def __reduce__(self):
140145 (self .name , self .value , self .group ),
141146 )
142147
148+ def matches (self , ** params ):
149+ attrs = (getattr (self , param ) for param in params )
150+ return all (map (operator .eq , params .values (), attrs ))
151+
152+
153+ class EntryPoints (tuple ):
154+ """
155+ An immutable collection of selectable EntryPoint objects.
156+ """
157+
158+ __slots__ = ()
159+
160+ def __getitem__ (self , name ): # -> EntryPoint:
161+ try :
162+ return next (iter (self .select (name = name )))
163+ except StopIteration :
164+ raise KeyError (name )
165+
166+ def select (self , ** params ):
167+ return EntryPoints (ep for ep in self if ep .matches (** params ))
168+
169+ @property
170+ def names (self ):
171+ return set (ep .name for ep in self )
172+
173+ @property
174+ def groups (self ):
175+ """
176+ For coverage while SelectableGroups is present.
177+ >>> EntryPoints().groups
178+ set()
179+ """
180+ return set (ep .group for ep in self )
181+
182+ @classmethod
183+ def _from_text_for (cls , text , dist ):
184+ return cls (ep ._for (dist ) for ep in EntryPoint ._from_text (text ))
185+
186+
187+ def flake8_bypass (func ):
188+ is_flake8 = any ('flake8' in str (frame .filename ) for frame in inspect .stack ()[:5 ])
189+ return func if not is_flake8 else lambda : None
190+
191+
192+ class Deprecated :
193+ """
194+ Compatibility add-in for mapping to indicate that
195+ mapping behavior is deprecated.
196+
197+ >>> recwarn = getfixture('recwarn')
198+ >>> class DeprecatedDict(Deprecated, dict): pass
199+ >>> dd = DeprecatedDict(foo='bar')
200+ >>> dd.get('baz', None)
201+ >>> dd['foo']
202+ 'bar'
203+ >>> list(dd)
204+ ['foo']
205+ >>> list(dd.keys())
206+ ['foo']
207+ >>> 'foo' in dd
208+ True
209+ >>> list(dd.values())
210+ ['bar']
211+ >>> len(recwarn)
212+ 1
213+ """
214+
215+ _warn = functools .partial (
216+ warnings .warn ,
217+ "SelectableGroups dict interface is deprecated. Use select." ,
218+ DeprecationWarning ,
219+ stacklevel = 2 ,
220+ )
221+
222+ def __getitem__ (self , name ):
223+ self ._warn ()
224+ return super ().__getitem__ (name )
225+
226+ def get (self , name , default = None ):
227+ flake8_bypass (self ._warn )()
228+ return super ().get (name , default )
229+
230+ def __iter__ (self ):
231+ self ._warn ()
232+ return super ().__iter__ ()
233+
234+ def __contains__ (self , * args ):
235+ self ._warn ()
236+ return super ().__contains__ (* args )
237+
238+ def keys (self ):
239+ self ._warn ()
240+ return super ().keys ()
241+
242+ def values (self ):
243+ self ._warn ()
244+ return super ().values ()
245+
246+
247+ class SelectableGroups (dict ):
248+ """
249+ A backward- and forward-compatible result from
250+ entry_points that fully implements the dict interface.
251+ """
252+
253+ @classmethod
254+ def load (cls , eps ):
255+ by_group = operator .attrgetter ('group' )
256+ ordered = sorted (eps , key = by_group )
257+ grouped = itertools .groupby (ordered , by_group )
258+ return cls ((group , EntryPoints (eps )) for group , eps in grouped )
259+
260+ @property
261+ def _all (self ):
262+ """
263+ Reconstruct a list of all entrypoints from the groups.
264+ """
265+ return EntryPoints (itertools .chain .from_iterable (self .values ()))
266+
267+ @property
268+ def groups (self ):
269+ return self ._all .groups
270+
271+ @property
272+ def names (self ):
273+ """
274+ for coverage:
275+ >>> SelectableGroups().names
276+ set()
277+ """
278+ return self ._all .names
279+
280+ def select (self , ** params ):
281+ if not params :
282+ return self
283+ return self ._all .select (** params )
284+
143285
144286class PackagePath (pathlib .PurePosixPath ):
145287 """A reference to a path in a package"""
@@ -296,7 +438,7 @@ def version(self):
296438
297439 @property
298440 def entry_points (self ):
299- return list ( EntryPoint ._from_text_for (self .read_text ('entry_points.txt' ), self ) )
441+ return EntryPoints ._from_text_for (self .read_text ('entry_points.txt' ), self )
300442
301443 @property
302444 def files (self ):
@@ -485,15 +627,22 @@ class Prepared:
485627 """
486628
487629 normalized = None
488- suffixes = '. dist-info' , '. egg-info'
630+ suffixes = 'dist-info' , 'egg-info'
489631 exact_matches = ['' ][:0 ]
632+ egg_prefix = ''
633+ versionless_egg_name = ''
490634
491635 def __init__ (self , name ):
492636 self .name = name
493637 if name is None :
494638 return
495639 self .normalized = self .normalize (name )
496- self .exact_matches = [self .normalized + suffix for suffix in self .suffixes ]
640+ self .exact_matches = [
641+ self .normalized + '.' + suffix for suffix in self .suffixes
642+ ]
643+ legacy_normalized = self .legacy_normalize (self .name )
644+ self .egg_prefix = legacy_normalized + '-'
645+ self .versionless_egg_name = legacy_normalized + '.egg'
497646
498647 @staticmethod
499648 def normalize (name ):
@@ -512,8 +661,9 @@ def legacy_normalize(name):
512661
513662 def matches (self , cand , base ):
514663 low = cand .lower ()
515- pre , ext = os .path .splitext (low )
516- name , sep , rest = pre .partition ('-' )
664+ # rpartition is faster than splitext and suitable for this purpose.
665+ pre , _ , ext = low .rpartition ('.' )
666+ name , _ , rest = pre .partition ('-' )
517667 return (
518668 low in self .exact_matches
519669 or ext in self .suffixes
@@ -524,12 +674,9 @@ def matches(self, cand, base):
524674 )
525675
526676 def is_egg (self , base ):
527- normalized = self .legacy_normalize (self .name or '' )
528- prefix = normalized + '-' if normalized else ''
529- versionless_egg_name = normalized + '.egg' if self .name else ''
530677 return (
531- base == versionless_egg_name
532- or base .startswith (prefix )
678+ base == self . versionless_egg_name
679+ or base .startswith (self . egg_prefix )
533680 and base .endswith ('.egg' )
534681 )
535682
@@ -551,8 +698,9 @@ def find_distributions(cls, context=DistributionFinder.Context()):
551698 @classmethod
552699 def _search_paths (cls , name , paths ):
553700 """Find metadata directories in paths heuristically."""
701+ prepared = Prepared (name )
554702 return itertools .chain .from_iterable (
555- path .search (Prepared ( name ) ) for path in map (FastPath , paths )
703+ path .search (prepared ) for path in map (FastPath , paths )
556704 )
557705
558706
@@ -617,16 +765,28 @@ def version(distribution_name):
617765 return distribution (distribution_name ).version
618766
619767
620- def entry_points () :
768+ def entry_points (** params ) -> Union [ EntryPoints , SelectableGroups ] :
621769 """Return EntryPoint objects for all installed packages.
622770
623- :return: EntryPoint objects for all installed packages.
771+ Pass selection parameters (group or name) to filter the
772+ result to entry points matching those properties (see
773+ EntryPoints.select()).
774+
775+ For compatibility, returns ``SelectableGroups`` object unless
776+ selection parameters are supplied. In the future, this function
777+ will return ``EntryPoints`` instead of ``SelectableGroups``
778+ even when no selection parameters are supplied.
779+
780+ For maximum future compatibility, pass selection parameters
781+ or invoke ``.select`` with parameters on the result.
782+
783+ :return: EntryPoints or SelectableGroups for all installed packages.
624784 """
625- eps = itertools . chain . from_iterable ( dist . entry_points for dist in distributions ( ))
626- by_group = operator . attrgetter ( 'group' )
627- ordered = sorted ( eps , key = by_group )
628- grouped = itertools . groupby ( ordered , by_group )
629- return { group : tuple (eps ) for group , eps in grouped }
785+ unique = functools . partial ( unique_everseen , key = operator . attrgetter ( 'name' ))
786+ eps = itertools . chain . from_iterable (
787+ dist . entry_points for dist in unique ( distributions () )
788+ )
789+ return SelectableGroups . load (eps ). select ( ** params )
630790
631791
632792def files (distribution_name ):
@@ -646,3 +806,19 @@ def requires(distribution_name):
646806 packaging.requirement.Requirement.
647807 """
648808 return distribution (distribution_name ).requires
809+
810+
811+ def packages_distributions () -> Mapping [str , List [str ]]:
812+ """
813+ Return a mapping of top-level packages to their
814+ distributions.
815+
816+ >>> pkgs = packages_distributions()
817+ >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values())
818+ True
819+ """
820+ pkg_to_dist = collections .defaultdict (list )
821+ for dist in distributions ():
822+ for pkg in (dist .read_text ('top_level.txt' ) or '' ).split ():
823+ pkg_to_dist [pkg ].append (dist .metadata ['Name' ])
824+ return dict (pkg_to_dist )
0 commit comments