Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/entry points by group and name #278

Merged
merged 20 commits into from Feb 24, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a49252b
Add explicit interfaces for loaded entrypoints, resolvable first by g…
jaraco Jan 23, 2021
6596183
Update tests to use new preferred API.
jaraco Jan 23, 2021
b5081fa
Capture the legacy expectation.
jaraco Jan 23, 2021
99dd224
Deprecate dict construction from EntryPoint items.
jaraco Jan 23, 2021
2eeb629
Suppress warning in test_json_dump.
jaraco Jan 23, 2021
eedd810
Add 'groups' and 'names' to EntryPoints collections.
jaraco Jan 23, 2021
720362f
Update documentation on EntryPoints to reflect the new, preferred acc…
jaraco Jan 23, 2021
28adeb8
Update changelog.
jaraco Jan 23, 2021
342a94b
Add deprecated .get to GroupedEntryPoints and test to capture expecta…
jaraco Jan 23, 2021
9448e13
Make entry point collections (more) immutable.
jaraco Feb 15, 2021
71fd4a7
Hide the deprecation warning from flake8 users
jaraco Feb 16, 2021
8320ade
Instead of presenting separate contexts for EntryPoints, unify into a…
jaraco Feb 17, 2021
f80f79d
Merge branch 'main' into feature/entry-points-by-group-and-name
jaraco Feb 21, 2021
e3d1b93
Update changelog.
jaraco Feb 21, 2021
9d55a33
Separate compatibility shim from canonical EntryPoints container.
jaraco Feb 22, 2021
d6f7c20
Add docstrings to the compatibility shim. Give primacy to group looku…
jaraco Feb 23, 2021
2db4dad
Introduce SelectableGroups, created for the 3.x line to provide forwa…
jaraco Feb 23, 2021
2def046
Address coverage misses, ignored for LegacyGroupedEntryPoints.
jaraco Feb 23, 2021
dd8da47
Leverage EntryPoints interfaces in SelectableGroups
jaraco Feb 23, 2021
bdce7ef
Merge branch 'main' into feature/entry-points-by-group-and-name
jaraco Feb 24, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGES.rst
@@ -1,3 +1,16 @@
v3.5.0
======

* ``entry_points()`` now returns an ``GroupedEntryPoints``
object, a tuple of all entry points but with a convenience
property ``groups`` and ``__getitem__`` accessor. Further,
accessing a group returns an ``EntryPoints`` object,
another tuple of entry points in the group, accessible by
name. Construction of entry points using
``dict([EntryPoint, ...])`` is now deprecated and raises
an appropriate DeprecationWarning and will be removed in
a future version.

v3.4.0
======

Expand Down
8 changes: 5 additions & 3 deletions docs/using.rst
Expand Up @@ -67,18 +67,20 @@ This package provides the following functionality via its public API.
Entry points
------------

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

>>> eps = entry_points()
>>> list(eps)
>>> sorted(eps.groups)
['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation']
>>> scripts = eps['console_scripts']
>>> wheel = [ep for ep in scripts if ep.name == 'wheel'][0]
>>> 'wheel' in scripts.names
True
>>> wheel = scripts['wheel']
>>> wheel
EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts')
>>> wheel.module
Expand Down
55 changes: 46 additions & 9 deletions importlib_metadata/__init__.py
Expand Up @@ -7,6 +7,7 @@
import email
import pathlib
import operator
import warnings
import functools
import itertools
import posixpath
Expand Down Expand Up @@ -139,11 +140,12 @@ def _for(self, dist):
def __iter__(self):
"""
Supply iter so one may construct dicts of EntryPoints by name.

>>> eps = [EntryPoint('a', 'b', 'c'), EntryPoint('d', 'e', 'f')]
>>> dict(eps)['a']
EntryPoint(name='a', value='b', group='c')
"""
msg = (
"Construction of dict of EntryPoints is deprecated in "
"favor of EntryPoints."
)
warnings.warn(msg, DeprecationWarning)
return iter((self.name, self))

def __reduce__(self):
Expand All @@ -153,6 +155,43 @@ def __reduce__(self):
)


class EntryPoints(tuple):
jaraco marked this conversation as resolved.
Show resolved Hide resolved
"""
A collection of EntryPoint objects, retrievable by name.
"""

def __getitem__(self, name) -> EntryPoint:
try:
return next(ep for ep in self if ep.name == name)
except Exception:
raise KeyError(name)

@property
def names(self):
return set(ep.name for ep in self)


class GroupedEntryPoints(tuple):
"""
A collection of EntryPoint objects, retrievable by group.
"""

def __getitem__(self, group) -> EntryPoints:
return EntryPoints(ep for ep in self if ep.group == group)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit unfortunate that this is O(N) on every key access -- this will likely surprise consumers who may access this in a loop

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Perhaps there's a more efficient way to handle it. If there's a use-case that demands it, I'd be happy to investigate an optimization. I do expect this approach to be less complex than sorting and grouping by group as before.


@property
def groups(self):
return set(ep.group for ep in self)

def get(self, group, default=None):
"""
For backward compatibility, supply .get
"""
msg = "GroupedEntryPoints.get is deprecated. Just use __getitem__."
warnings.warn(msg, DeprecationWarning)
jaraco marked this conversation as resolved.
Show resolved Hide resolved
return self[group] or default


class PackagePath(pathlib.PurePosixPath):
"""A reference to a path in a package"""

Expand Down Expand Up @@ -308,7 +347,8 @@ def version(self):

@property
def entry_points(self):
return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
eps = EntryPoint._from_text_for(self.read_text('entry_points.txt'), self)
return GroupedEntryPoints(eps)
jaraco marked this conversation as resolved.
Show resolved Hide resolved

@property
def files(self):
Expand Down Expand Up @@ -647,10 +687,7 @@ def entry_points():
:return: EntryPoint objects for all installed packages.
"""
eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions())
by_group = operator.attrgetter('group')
ordered = sorted(eps, key=by_group)
grouped = itertools.groupby(ordered, by_group)
return {group: tuple(eps) for group, eps in grouped}
jaraco marked this conversation as resolved.
Show resolved Hide resolved
return GroupedEntryPoints(eps)


def files(distribution_name):
Expand Down
43 changes: 41 additions & 2 deletions tests/test_api.py
@@ -1,6 +1,7 @@
import re
import textwrap
import unittest
import warnings

from . import fixtures
from importlib_metadata import (
Expand Down Expand Up @@ -64,18 +65,56 @@ def test_read_text(self):
self.assertEqual(top_level.read_text(), 'mod\n')

def test_entry_points(self):
entries = dict(entry_points()['entries'])
eps = entry_points()
assert 'entries' in eps.groups
entries = eps['entries']
assert 'main' in entries.names
ep = entries['main']
self.assertEqual(ep.value, 'mod:main')
self.assertEqual(ep.extras, [])

def test_entry_points_distribution(self):
entries = dict(entry_points()['entries'])
entries = entry_points()['entries']
for entry in ("main", "ns:sub"):
ep = entries[entry]
self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg'))
self.assertEqual(ep.dist.version, "1.0.0")

def test_entry_points_missing_name(self):
with self.assertRaises(KeyError):
entry_points()['entries']['missing']

def test_entry_points_missing_group(self):
assert entry_points()['missing'] == ()

def test_entry_points_dict_construction(self):
"""
Prior versions of entry_points() returned simple lists and
allowed casting those lists into maps by name using ``dict()``.
Capture this now deprecated use-case.
"""
with warnings.catch_warnings(record=True) as caught:
eps = dict(entry_points()['entries'])

assert 'main' in eps
assert eps['main'] == entry_points()['entries']['main']

# check warning
expected = next(iter(caught))
assert expected.category is DeprecationWarning
assert "Construction of dict of EntryPoints is deprecated" in str(expected)

def test_entry_points_groups_get(self):
"""
Prior versions of entry_points() returned a dict. Ensure
that callers using '.get()' are supported but warned to
migrate.
"""
with warnings.catch_warnings(record=True):
entry_points().get('missing', 'default') == 'default'
entry_points().get('entries', 'default') == entry_points()['entries']
entry_points().get('missing', ()) == entry_points()['missing']

def test_metadata_for_this_package(self):
md = metadata('egginfo-pkg')
assert md['author'] == 'Steven Ma'
Expand Down
10 changes: 5 additions & 5 deletions tests/test_main.py
Expand Up @@ -3,6 +3,7 @@
import pickle
import textwrap
import unittest
import warnings
import importlib
import importlib_metadata
import pyfakefs.fake_filesystem_unittest as ffs
Expand Down Expand Up @@ -57,13 +58,11 @@ def test_import_nonexistent_module(self):
importlib.import_module('does_not_exist')

def test_resolve(self):
entries = dict(entry_points()['entries'])
ep = entries['main']
ep = entry_points()['entries']['main']
self.assertEqual(ep.load().__name__, "main")

def test_entrypoint_with_colon_in_name(self):
entries = dict(entry_points()['entries'])
ep = entries['ns:sub']
ep = entry_points()['entries']['ns:sub']
self.assertEqual(ep.value, 'mod:main')

def test_resolve_without_attr(self):
Expand Down Expand Up @@ -249,7 +248,8 @@ def test_json_dump(self):
json should not expect to be able to dump an EntryPoint
"""
with self.assertRaises(Exception):
json.dumps(self.ep)
with warnings.catch_warnings(record=True):
json.dumps(self.ep)

def test_module(self):
assert self.ep.module == 'value'
Expand Down
2 changes: 1 addition & 1 deletion tests/test_zip.py
Expand Up @@ -45,7 +45,7 @@ def test_zip_version_does_not_match(self):
version('definitely-not-installed')

def test_zip_entry_points(self):
scripts = dict(entry_points()['console_scripts'])
scripts = entry_points()['console_scripts']
entry_point = scripts['example']
self.assertEqual(entry_point.value, 'example:main')
entry_point = scripts['Example']
Expand Down