Skip to content

Commit 73cf0a9

Browse files
authored
Merge pull request #278 from python/feature/entry-points-by-group-and-name
Feature/entry points by group and name
2 parents 61a265c + bdce7ef commit 73cf0a9

File tree

6 files changed

+240
-30
lines changed

6 files changed

+240
-30
lines changed

CHANGES.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1+
v3.6.0
2+
======
3+
4+
* #284: Introduces new ``EntryPoints`` object, a tuple of
5+
``EntryPoint`` objects but with convenience properties for
6+
selecting and inspecting the results:
7+
8+
- ``.select()`` accepts ``group`` or ``name`` keyword
9+
parameters and returns a new ``EntryPoints`` tuple
10+
with only those that match the selection.
11+
- ``.groups`` property presents all of the group names.
12+
- ``.names`` property presents the names of the entry points.
13+
- Item access (e.g. ``eps[name]``) retrieves a single
14+
entry point by name.
15+
16+
``entry_points`` now accepts "selection parameters",
17+
same as ``EntryPoint.select()``.
18+
19+
``entry_points()`` now provides a future-compatible
20+
``SelectableGroups`` object that supplies the above interface
21+
but remains a dict for compatibility.
22+
23+
In the future, ``entry_points()`` will return an
24+
``EntryPoints`` object, but provide for backward
25+
compatibility with a deprecated ``__getitem__``
26+
accessor by group and a ``get()`` method.
27+
28+
If passing selection parameters to ``entry_points``, the
29+
future behavior is invoked and an ``EntryPoints`` is the
30+
result.
31+
32+
Construction of entry points using
33+
``dict([EntryPoint, ...])`` is now deprecated and raises
34+
an appropriate DeprecationWarning and will be removed in
35+
a future version.
36+
137
v3.5.0
238
======
339

docs/using.rst

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,20 @@ This package provides the following functionality via its public API.
6767
Entry points
6868
------------
6969

70-
The ``entry_points()`` function returns a dictionary of all entry points,
71-
keyed by group. Entry points are represented by ``EntryPoint`` instances;
70+
The ``entry_points()`` function returns a collection of entry points.
71+
Entry points are represented by ``EntryPoint`` instances;
7272
each ``EntryPoint`` has a ``.name``, ``.group``, and ``.value`` attributes and
7373
a ``.load()`` method to resolve the value. There are also ``.module``,
7474
``.attr``, and ``.extras`` attributes for getting the components of the
7575
``.value`` attribute::
7676

7777
>>> eps = entry_points()
78-
>>> list(eps)
78+
>>> sorted(eps.groups)
7979
['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation']
80-
>>> scripts = eps['console_scripts']
81-
>>> wheel = [ep for ep in scripts if ep.name == 'wheel'][0]
80+
>>> scripts = eps.select(group='console_scripts')
81+
>>> 'wheel' in scripts.names
82+
True
83+
>>> wheel = scripts['wheel']
8284
>>> wheel
8385
EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts')
8486
>>> wheel.module

importlib_metadata/__init__.py

Lines changed: 136 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import sys
66
import zipp
77
import email
8+
import inspect
89
import pathlib
910
import operator
11+
import warnings
1012
import functools
1113
import itertools
1214
import posixpath
@@ -130,22 +132,19 @@ def _from_text(cls, text):
130132
config.read_string(text)
131133
return cls._from_config(config)
132134

133-
@classmethod
134-
def _from_text_for(cls, text, dist):
135-
return (ep._for(dist) for ep in cls._from_text(text))
136-
137135
def _for(self, dist):
138136
self.dist = dist
139137
return self
140138

141139
def __iter__(self):
142140
"""
143141
Supply iter so one may construct dicts of EntryPoints by name.
144-
145-
>>> eps = [EntryPoint('a', 'b', 'c'), EntryPoint('d', 'e', 'f')]
146-
>>> dict(eps)['a']
147-
EntryPoint(name='a', value='b', group='c')
148142
"""
143+
msg = (
144+
"Construction of dict of EntryPoints is deprecated in "
145+
"favor of EntryPoints."
146+
)
147+
warnings.warn(msg, DeprecationWarning)
149148
return iter((self.name, self))
150149

151150
def __reduce__(self):
@@ -154,6 +153,118 @@ def __reduce__(self):
154153
(self.name, self.value, self.group),
155154
)
156155

156+
def matches(self, **params):
157+
attrs = (getattr(self, param) for param in params)
158+
return all(map(operator.eq, params.values(), attrs))
159+
160+
161+
class EntryPoints(tuple):
162+
"""
163+
An immutable collection of selectable EntryPoint objects.
164+
"""
165+
166+
__slots__ = ()
167+
168+
def __getitem__(self, name): # -> EntryPoint:
169+
try:
170+
return next(iter(self.select(name=name)))
171+
except StopIteration:
172+
raise KeyError(name)
173+
174+
def select(self, **params):
175+
return EntryPoints(ep for ep in self if ep.matches(**params))
176+
177+
@property
178+
def names(self):
179+
return set(ep.name for ep in self)
180+
181+
@property
182+
def groups(self):
183+
"""
184+
For coverage while SelectableGroups is present.
185+
>>> EntryPoints().groups
186+
set()
187+
"""
188+
return set(ep.group for ep in self)
189+
190+
@classmethod
191+
def _from_text_for(cls, text, dist):
192+
return cls(ep._for(dist) for ep in EntryPoint._from_text(text))
193+
194+
195+
class SelectableGroups(dict):
196+
"""
197+
A backward- and forward-compatible result from
198+
entry_points that fully implements the dict interface.
199+
"""
200+
201+
@classmethod
202+
def load(cls, eps):
203+
by_group = operator.attrgetter('group')
204+
ordered = sorted(eps, key=by_group)
205+
grouped = itertools.groupby(ordered, by_group)
206+
return cls((group, EntryPoints(eps)) for group, eps in grouped)
207+
208+
@property
209+
def _all(self):
210+
return EntryPoints(itertools.chain.from_iterable(self.values()))
211+
212+
@property
213+
def groups(self):
214+
return self._all.groups
215+
216+
@property
217+
def names(self):
218+
"""
219+
for coverage:
220+
>>> SelectableGroups().names
221+
set()
222+
"""
223+
return self._all.names
224+
225+
def select(self, **params):
226+
if not params:
227+
return self
228+
return self._all.select(**params)
229+
230+
231+
class LegacyGroupedEntryPoints(EntryPoints): # pragma: nocover
232+
"""
233+
Compatibility wrapper around EntryPoints to provide
234+
much of the 'dict' interface previously returned by
235+
entry_points.
236+
"""
237+
238+
def __getitem__(self, name) -> Union[EntryPoint, 'EntryPoints']:
239+
"""
240+
When accessed by name that matches a group, return the group.
241+
"""
242+
group = self.select(group=name)
243+
if group:
244+
msg = "GroupedEntryPoints.__getitem__ is deprecated for groups. Use select."
245+
warnings.warn(msg, DeprecationWarning, stacklevel=2)
246+
return group
247+
248+
return super().__getitem__(name)
249+
250+
def get(self, group, default=None):
251+
"""
252+
For backward compatibility, supply .get.
253+
"""
254+
is_flake8 = any('flake8' in str(frame) for frame in inspect.stack())
255+
msg = "GroupedEntryPoints.get is deprecated. Use select."
256+
is_flake8 or warnings.warn(msg, DeprecationWarning, stacklevel=2)
257+
return self.select(group=group) or default
258+
259+
def select(self, **params):
260+
"""
261+
Prevent transform to EntryPoints during call to entry_points if
262+
no selection parameters were passed.
263+
"""
264+
if not params:
265+
return self
266+
return super().select(**params)
267+
157268

158269
class PackagePath(pathlib.PurePosixPath):
159270
"""A reference to a path in a package"""
@@ -310,7 +421,7 @@ def version(self):
310421

311422
@property
312423
def entry_points(self):
313-
return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
424+
return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self)
314425

315426
@property
316427
def files(self):
@@ -643,19 +754,29 @@ def version(distribution_name):
643754
return distribution(distribution_name).version
644755

645756

646-
def entry_points():
757+
def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
647758
"""Return EntryPoint objects for all installed packages.
648759
649-
:return: EntryPoint objects for all installed packages.
760+
Pass selection parameters (group or name) to filter the
761+
result to entry points matching those properties (see
762+
EntryPoints.select()).
763+
764+
For compatibility, returns ``SelectableGroups`` object unless
765+
selection parameters are supplied. In the future, this function
766+
will return ``LegacyGroupedEntryPoints`` instead of
767+
``SelectableGroups`` and eventually will only return
768+
``EntryPoints``.
769+
770+
For maximum future compatibility, pass selection parameters
771+
or invoke ``.select`` with parameters on the result.
772+
773+
:return: EntryPoints or SelectableGroups for all installed packages.
650774
"""
651775
unique = functools.partial(unique_everseen, key=operator.attrgetter('name'))
652776
eps = itertools.chain.from_iterable(
653777
dist.entry_points for dist in unique(distributions())
654778
)
655-
by_group = operator.attrgetter('group')
656-
ordered = sorted(eps, key=by_group)
657-
grouped = itertools.groupby(ordered, by_group)
658-
return {group: tuple(eps) for group, eps in grouped}
779+
return SelectableGroups.load(eps).select(**params)
659780

660781

661782
def files(distribution_name):

tests/test_api.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import re
22
import textwrap
33
import unittest
4+
import warnings
45

56
from . import fixtures
67
from importlib_metadata import (
@@ -64,13 +65,16 @@ def test_read_text(self):
6465
self.assertEqual(top_level.read_text(), 'mod\n')
6566

6667
def test_entry_points(self):
67-
entries = dict(entry_points()['entries'])
68+
eps = entry_points()
69+
assert 'entries' in eps.groups
70+
entries = eps.select(group='entries')
71+
assert 'main' in entries.names
6872
ep = entries['main']
6973
self.assertEqual(ep.value, 'mod:main')
7074
self.assertEqual(ep.extras, [])
7175

7276
def test_entry_points_distribution(self):
73-
entries = dict(entry_points()['entries'])
77+
entries = entry_points(group='entries')
7478
for entry in ("main", "ns:sub"):
7579
ep = entries[entry]
7680
self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg'))
@@ -96,14 +100,61 @@ def test_entry_points_unique_packages(self):
96100
},
97101
}
98102
fixtures.build_files(alt_pkg, alt_site_dir)
99-
entries = dict(entry_points()['entries'])
103+
entries = entry_points(group='entries')
100104
assert not any(
101105
ep.dist.name == 'distinfo-pkg' and ep.dist.version == '1.0.0'
102-
for ep in entries.values()
106+
for ep in entries
103107
)
104108
# ns:sub doesn't exist in alt_pkg
105109
assert 'ns:sub' not in entries
106110

111+
def test_entry_points_missing_name(self):
112+
with self.assertRaises(KeyError):
113+
entry_points(group='entries')['missing']
114+
115+
def test_entry_points_missing_group(self):
116+
assert entry_points(group='missing') == ()
117+
118+
def test_entry_points_dict_construction(self):
119+
"""
120+
Prior versions of entry_points() returned simple lists and
121+
allowed casting those lists into maps by name using ``dict()``.
122+
Capture this now deprecated use-case.
123+
"""
124+
with warnings.catch_warnings(record=True) as caught:
125+
eps = dict(entry_points(group='entries'))
126+
127+
assert 'main' in eps
128+
assert eps['main'] == entry_points(group='entries')['main']
129+
130+
# check warning
131+
expected = next(iter(caught))
132+
assert expected.category is DeprecationWarning
133+
assert "Construction of dict of EntryPoints is deprecated" in str(expected)
134+
135+
def test_entry_points_groups_getitem(self):
136+
"""
137+
Prior versions of entry_points() returned a dict. Ensure
138+
that callers using '.__getitem__()' are supported but warned to
139+
migrate.
140+
"""
141+
with warnings.catch_warnings(record=True):
142+
entry_points()['entries'] == entry_points(group='entries')
143+
144+
with self.assertRaises(KeyError):
145+
entry_points()['missing']
146+
147+
def test_entry_points_groups_get(self):
148+
"""
149+
Prior versions of entry_points() returned a dict. Ensure
150+
that callers using '.get()' are supported but warned to
151+
migrate.
152+
"""
153+
with warnings.catch_warnings(record=True):
154+
entry_points().get('missing', 'default') == 'default'
155+
entry_points().get('entries', 'default') == entry_points()['entries']
156+
entry_points().get('missing', ()) == ()
157+
107158
def test_metadata_for_this_package(self):
108159
md = metadata('egginfo-pkg')
109160
assert md['author'] == 'Steven Ma'

tests/test_main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pickle
44
import textwrap
55
import unittest
6+
import warnings
67
import importlib
78
import importlib_metadata
89
import pyfakefs.fake_filesystem_unittest as ffs
@@ -57,13 +58,11 @@ def test_import_nonexistent_module(self):
5758
importlib.import_module('does_not_exist')
5859

5960
def test_resolve(self):
60-
entries = dict(entry_points()['entries'])
61-
ep = entries['main']
61+
ep = entry_points(group='entries')['main']
6262
self.assertEqual(ep.load().__name__, "main")
6363

6464
def test_entrypoint_with_colon_in_name(self):
65-
entries = dict(entry_points()['entries'])
66-
ep = entries['ns:sub']
65+
ep = entry_points(group='entries')['ns:sub']
6766
self.assertEqual(ep.value, 'mod:main')
6867

6968
def test_resolve_without_attr(self):
@@ -249,7 +248,8 @@ def test_json_dump(self):
249248
json should not expect to be able to dump an EntryPoint
250249
"""
251250
with self.assertRaises(Exception):
252-
json.dumps(self.ep)
251+
with warnings.catch_warnings(record=True):
252+
json.dumps(self.ep)
253253

254254
def test_module(self):
255255
assert self.ep.module == 'value'

0 commit comments

Comments
 (0)