4
4
import csv
5
5
import sys
6
6
import email
7
+ import inspect
7
8
import pathlib
8
9
import zipfile
9
10
import operator
11
+ import warnings
10
12
import functools
11
13
import itertools
12
14
import posixpath
13
- import collections
15
+ import collections .abc
16
+
17
+ from ._itertools import unique_everseen
14
18
15
19
from configparser import ConfigParser
16
20
from contextlib import suppress
17
21
from importlib import import_module
18
22
from importlib .abc import MetaPathFinder
19
23
from itertools import starmap
20
- from typing import Any , List , Optional , Protocol , TypeVar , Union
24
+ from typing import Any , List , Mapping , Optional , Protocol , TypeVar , Union
21
25
22
26
23
27
__all__ = [
@@ -120,18 +124,19 @@ def _from_text(cls, text):
120
124
config .read_string (text )
121
125
return cls ._from_config (config )
122
126
123
- @classmethod
124
- def _from_text_for (cls , text , dist ):
125
- return (ep ._for (dist ) for ep in cls ._from_text (text ))
126
-
127
127
def _for (self , dist ):
128
128
self .dist = dist
129
129
return self
130
130
131
131
def __iter__ (self ):
132
132
"""
133
- Supply iter so one may construct dicts of EntryPoints easily .
133
+ Supply iter so one may construct dicts of EntryPoints by name .
134
134
"""
135
+ msg = (
136
+ "Construction of dict of EntryPoints is deprecated in "
137
+ "favor of EntryPoints."
138
+ )
139
+ warnings .warn (msg , DeprecationWarning )
135
140
return iter ((self .name , self ))
136
141
137
142
def __reduce__ (self ):
@@ -140,6 +145,143 @@ def __reduce__(self):
140
145
(self .name , self .value , self .group ),
141
146
)
142
147
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
+
143
285
144
286
class PackagePath (pathlib .PurePosixPath ):
145
287
"""A reference to a path in a package"""
@@ -296,7 +438,7 @@ def version(self):
296
438
297
439
@property
298
440
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 )
300
442
301
443
@property
302
444
def files (self ):
@@ -485,15 +627,22 @@ class Prepared:
485
627
"""
486
628
487
629
normalized = None
488
- suffixes = '. dist-info' , '. egg-info'
630
+ suffixes = 'dist-info' , 'egg-info'
489
631
exact_matches = ['' ][:0 ]
632
+ egg_prefix = ''
633
+ versionless_egg_name = ''
490
634
491
635
def __init__ (self , name ):
492
636
self .name = name
493
637
if name is None :
494
638
return
495
639
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'
497
646
498
647
@staticmethod
499
648
def normalize (name ):
@@ -512,8 +661,9 @@ def legacy_normalize(name):
512
661
513
662
def matches (self , cand , base ):
514
663
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 ('-' )
517
667
return (
518
668
low in self .exact_matches
519
669
or ext in self .suffixes
@@ -524,12 +674,9 @@ def matches(self, cand, base):
524
674
)
525
675
526
676
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 ''
530
677
return (
531
- base == versionless_egg_name
532
- or base .startswith (prefix )
678
+ base == self . versionless_egg_name
679
+ or base .startswith (self . egg_prefix )
533
680
and base .endswith ('.egg' )
534
681
)
535
682
@@ -551,8 +698,9 @@ def find_distributions(cls, context=DistributionFinder.Context()):
551
698
@classmethod
552
699
def _search_paths (cls , name , paths ):
553
700
"""Find metadata directories in paths heuristically."""
701
+ prepared = Prepared (name )
554
702
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 )
556
704
)
557
705
558
706
@@ -617,16 +765,28 @@ def version(distribution_name):
617
765
return distribution (distribution_name ).version
618
766
619
767
620
- def entry_points () :
768
+ def entry_points (** params ) -> Union [ EntryPoints , SelectableGroups ] :
621
769
"""Return EntryPoint objects for all installed packages.
622
770
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.
624
784
"""
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 )
630
790
631
791
632
792
def files (distribution_name ):
@@ -646,3 +806,19 @@ def requires(distribution_name):
646
806
packaging.requirement.Requirement.
647
807
"""
648
808
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