From a49252b513cf1e25e3885c60f046269ea293f13e Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 21:21:37 -0500 Subject: [PATCH 01/18] Add explicit interfaces for loaded entrypoints, resolvable first by group then by name. --- importlib_metadata/__init__.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index fac3063b..7ca3e938 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -153,6 +153,27 @@ def __reduce__(self): ) +class EntryPoints(tuple): + """ + 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) + + +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) + + class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" @@ -308,7 +329,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) @property def files(self): @@ -647,10 +669,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} + return GroupedEntryPoints(eps) def files(distribution_name): From 6596183f79a3973698c4b2b825b12682ac6e7d96 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 21:29:26 -0500 Subject: [PATCH 02/18] Update tests to use new preferred API. --- tests/test_api.py | 12 +++++++++--- tests/test_main.py | 6 ++---- tests/test_zip.py | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index a386551f..e6a5adeb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -64,18 +64,24 @@ def test_read_text(self): self.assertEqual(top_level.read_text(), 'mod\n') def test_entry_points(self): - entries = dict(entry_points()['entries']) - ep = entries['main'] + ep = entry_points()['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_metadata_for_this_package(self): md = metadata('egginfo-pkg') assert md['author'] == 'Steven Ma' diff --git a/tests/test_main.py b/tests/test_main.py index 74979be8..566262f6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -57,13 +57,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): diff --git a/tests/test_zip.py b/tests/test_zip.py index 67311da2..5a63465f 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -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'] From b5081fa78358a9bb7c47eedfe084b3f96c024c63 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 21:33:37 -0500 Subject: [PATCH 03/18] Capture the legacy expectation. --- tests/test_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index e6a5adeb..c3e8b532 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -82,6 +82,16 @@ def test_entry_points_missing_name(self): 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. + """ + eps = dict(entry_points()['entries']) + assert 'main' in eps + assert eps['main'] == entry_points()['entries']['main'] + def test_metadata_for_this_package(self): md = metadata('egginfo-pkg') assert md['author'] == 'Steven Ma' From 99dd2242ab9c8c0b4a082e135f6bbda11c19540a Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 21:50:02 -0500 Subject: [PATCH 04/18] Deprecate dict construction from EntryPoint items. --- importlib_metadata/__init__.py | 10 ++++++---- tests/test_api.py | 10 +++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 7ca3e938..681743dc 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -7,6 +7,7 @@ import email import pathlib import operator +import warnings import functools import itertools import posixpath @@ -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): diff --git a/tests/test_api.py b/tests/test_api.py index c3e8b532..8ce2e468 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ import re import textwrap import unittest +import warnings from . import fixtures from importlib_metadata import ( @@ -88,10 +89,17 @@ def test_entry_points_dict_construction(self): allowed casting those lists into maps by name using ``dict()``. Capture this now deprecated use-case. """ - eps = dict(entry_points()['entries']) + 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_metadata_for_this_package(self): md = metadata('egginfo-pkg') assert md['author'] == 'Steven Ma' From 2eeb629021d7218516f5ee43de51b8d93d32828a Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 21:54:11 -0500 Subject: [PATCH 05/18] Suppress warning in test_json_dump. --- tests/test_main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 566262f6..b778572c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,6 +3,7 @@ import pickle import textwrap import unittest +import warnings import importlib import importlib_metadata import pyfakefs.fake_filesystem_unittest as ffs @@ -247,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' From eedd810b90083fd5a2b0bb398478527011c474eb Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 22:06:29 -0500 Subject: [PATCH 06/18] Add 'groups' and 'names' to EntryPoints collections. --- importlib_metadata/__init__.py | 8 ++++++++ tests/test_api.py | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 681743dc..60967cd4 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -166,6 +166,10 @@ def __getitem__(self, name) -> EntryPoint: except Exception: raise KeyError(name) + @property + def names(self): + return set(ep.name for ep in self) + class GroupedEntryPoints(tuple): """ @@ -175,6 +179,10 @@ class GroupedEntryPoints(tuple): def __getitem__(self, group) -> EntryPoints: return EntryPoints(ep for ep in self if ep.group == group) + @property + def groups(self): + return set(ep.group for ep in self) + class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" diff --git a/tests/test_api.py b/tests/test_api.py index 8ce2e468..7672556a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -65,7 +65,11 @@ def test_read_text(self): self.assertEqual(top_level.read_text(), 'mod\n') def test_entry_points(self): - ep = entry_points()['entries']['main'] + 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, []) From 720362fe25dd0211432784de02dd483b53ee7be8 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 22:10:34 -0500 Subject: [PATCH 07/18] Update documentation on EntryPoints to reflect the new, preferred accessors. --- docs/using.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index 00409867..534c1dea 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -67,7 +67,7 @@ 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``, @@ -75,10 +75,12 @@ a ``.load()`` method to resolve the value. There are also ``.module``, ``.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 From 28adeb8f84ac3e5052ea24c93b4fa3816e1fe4e6 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 22:13:52 -0500 Subject: [PATCH 08/18] Update changelog. --- CHANGES.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 57901f23..02900674 100644 --- a/CHANGES.rst +++ b/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 ====== From 342a94ba5c373b01f3c5b827da1d4bd76ff2b04f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 22 Jan 2021 22:54:23 -0500 Subject: [PATCH 09/18] Add deprecated .get to GroupedEntryPoints and test to capture expectation. --- importlib_metadata/__init__.py | 8 ++++++++ tests/test_api.py | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 60967cd4..edcf2691 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -183,6 +183,14 @@ def __getitem__(self, group) -> EntryPoints: 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) + return self[group] or default + class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" diff --git a/tests/test_api.py b/tests/test_api.py index 7672556a..dc0c7870 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -104,6 +104,17 @@ def test_entry_points_dict_construction(self): 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' From 9448e13a10648ae5a086247dea8a17efff31b816 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Mon, 15 Feb 2021 18:05:18 -0500 Subject: [PATCH 10/18] Make entry point collections (more) immutable. --- importlib_metadata/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index edcf2691..4c8188ae 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -157,9 +157,11 @@ def __reduce__(self): class EntryPoints(tuple): """ - A collection of EntryPoint objects, retrievable by name. + An immutable collection of EntryPoint objects, retrievable by name. """ + __slots__ = () + def __getitem__(self, name) -> EntryPoint: try: return next(ep for ep in self if ep.name == name) @@ -173,9 +175,11 @@ def names(self): class GroupedEntryPoints(tuple): """ - A collection of EntryPoint objects, retrievable by group. + An immutable collection of EntryPoint objects, retrievable by group. """ + __slots__ = () + def __getitem__(self, group) -> EntryPoints: return EntryPoints(ep for ep in self if ep.group == group) From 71fd4a7b6a8141becd431edf51dac590493d61c2 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Mon, 15 Feb 2021 21:28:16 -0500 Subject: [PATCH 11/18] Hide the deprecation warning from flake8 users --- importlib_metadata/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 4c8188ae..f9af7824 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -5,6 +5,7 @@ import sys import zipp import email +import inspect import pathlib import operator import warnings @@ -191,8 +192,9 @@ def get(self, group, default=None): """ For backward compatibility, supply .get """ + is_flake8 = any('flake8' in str(frame) for frame in inspect.stack()) msg = "GroupedEntryPoints.get is deprecated. Just use __getitem__." - warnings.warn(msg, DeprecationWarning) + is_flake8 or warnings.warn(msg, DeprecationWarning) return self[group] or default From 8320adef797d5f14d9fff7b58ebc2a31a2a6a437 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 16 Feb 2021 22:01:49 -0500 Subject: [PATCH 12/18] Instead of presenting separate contexts for EntryPoints, unify into a single collection that can select on 'name' or 'group' or possibly other attributes. Expose that selection in the 'entry_points' function. --- docs/using.rst | 2 +- importlib_metadata/__init__.py | 56 +++++++++++++++++++--------------- tests/test_api.py | 26 +++++++++++----- tests/test_main.py | 4 +-- tests/test_zip.py | 2 +- 5 files changed, 54 insertions(+), 36 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index 534c1dea..bdfe3e82 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -77,7 +77,7 @@ a ``.load()`` method to resolve the value. There are also ``.module``, >>> eps = entry_points() >>> sorted(eps.groups) ['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation'] - >>> scripts = eps['console_scripts'] + >>> scripts = eps.select(group='console_scripts') >>> 'wheel' in scripts.names True >>> wheel = scripts['wheel'] diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index f9af7824..77057703 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -130,10 +130,6 @@ def _from_text(cls, text): config.read_string(text) return cls._from_config(config) - @classmethod - def _from_text_for(cls, text, dist): - return (ep._for(dist) for ep in cls._from_text(text)) - def _for(self, dist): self.dist = dist return self @@ -155,35 +151,42 @@ def __reduce__(self): (self.name, self.value, self.group), ) + def matches(self, **params): + attrs = (getattr(self, param) for param in params) + return all(map(operator.eq, params.values(), attrs)) + class EntryPoints(tuple): """ - An immutable collection of EntryPoint objects, retrievable by name. + An immutable collection of selectable EntryPoint objects. """ __slots__ = () - def __getitem__(self, name) -> EntryPoint: + def __getitem__(self, name) -> Union[EntryPoint, 'EntryPoints']: try: - return next(ep for ep in self if ep.name == name) - except Exception: + match = next(iter(self.select(name=name))) + return match + except StopIteration: + if name in self.groups: + return self._group_getitem(name) raise KeyError(name) + def _group_getitem(self, name): + """ + For backward compatability, supply .__getitem__ for groups. + """ + msg = "GroupedEntryPoints.__getitem__ is deprecated for groups. Use select." + warnings.warn(msg, DeprecationWarning) + return self.select(group=name) + + def select(self, **params): + return EntryPoints(ep for ep in self if ep.matches(**params)) + @property def names(self): return set(ep.name for ep in self) - -class GroupedEntryPoints(tuple): - """ - An immutable collection of EntryPoint objects, retrievable by group. - """ - - __slots__ = () - - def __getitem__(self, group) -> EntryPoints: - return EntryPoints(ep for ep in self if ep.group == group) - @property def groups(self): return set(ep.group for ep in self) @@ -193,9 +196,13 @@ def get(self, group, default=None): For backward compatibility, supply .get """ is_flake8 = any('flake8' in str(frame) for frame in inspect.stack()) - msg = "GroupedEntryPoints.get is deprecated. Just use __getitem__." + msg = "GroupedEntryPoints.get is deprecated. Use select." is_flake8 or warnings.warn(msg, DeprecationWarning) - return self[group] or default + return self.select(group=group) or default + + @classmethod + def _from_text_for(cls, text, dist): + return cls(ep._for(dist) for ep in EntryPoint._from_text(text)) class PackagePath(pathlib.PurePosixPath): @@ -353,8 +360,7 @@ def version(self): @property def entry_points(self): - eps = EntryPoint._from_text_for(self.read_text('entry_points.txt'), self) - return GroupedEntryPoints(eps) + return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property def files(self): @@ -687,13 +693,13 @@ def version(distribution_name): return distribution(distribution_name).version -def entry_points(): +def entry_points(**params): """Return EntryPoint objects for all installed packages. :return: EntryPoint objects for all installed packages. """ eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions()) - return GroupedEntryPoints(eps) + return EntryPoints(eps).select(**params) def files(distribution_name): diff --git a/tests/test_api.py b/tests/test_api.py index dc0c7870..a6466309 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -67,14 +67,14 @@ def test_read_text(self): def test_entry_points(self): eps = entry_points() assert 'entries' in eps.groups - entries = eps['entries'] + entries = eps.select(group='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 = entry_points()['entries'] + entries = entry_points(group='entries') for entry in ("main", "ns:sub"): ep = entries[entry] self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg')) @@ -82,10 +82,10 @@ def test_entry_points_distribution(self): def test_entry_points_missing_name(self): with self.assertRaises(KeyError): - entry_points()['entries']['missing'] + entry_points(group='entries')['missing'] def test_entry_points_missing_group(self): - assert entry_points()['missing'] == () + assert entry_points(group='missing') == () def test_entry_points_dict_construction(self): """ @@ -94,16 +94,28 @@ def test_entry_points_dict_construction(self): Capture this now deprecated use-case. """ with warnings.catch_warnings(record=True) as caught: - eps = dict(entry_points()['entries']) + eps = dict(entry_points(group='entries')) assert 'main' in eps - assert eps['main'] == entry_points()['entries']['main'] + assert eps['main'] == entry_points(group='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_getitem(self): + """ + Prior versions of entry_points() returned a dict. Ensure + that callers using '.__getitem__()' are supported but warned to + migrate. + """ + with warnings.catch_warnings(record=True): + entry_points()['entries'] == entry_points(group='entries') + + with self.assertRaises(KeyError): + entry_points()['missing'] + def test_entry_points_groups_get(self): """ Prior versions of entry_points() returned a dict. Ensure @@ -113,7 +125,7 @@ def test_entry_points_groups_get(self): 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'] + entry_points().get('missing', ()) == () def test_metadata_for_this_package(self): md = metadata('egginfo-pkg') diff --git a/tests/test_main.py b/tests/test_main.py index b778572c..e8a66c0d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -58,11 +58,11 @@ def test_import_nonexistent_module(self): importlib.import_module('does_not_exist') def test_resolve(self): - ep = entry_points()['entries']['main'] + ep = entry_points(group='entries')['main'] self.assertEqual(ep.load().__name__, "main") def test_entrypoint_with_colon_in_name(self): - ep = entry_points()['entries']['ns:sub'] + ep = entry_points(group='entries')['ns:sub'] self.assertEqual(ep.value, 'mod:main') def test_resolve_without_attr(self): diff --git a/tests/test_zip.py b/tests/test_zip.py index 5a63465f..4279046d 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -45,7 +45,7 @@ def test_zip_version_does_not_match(self): version('definitely-not-installed') def test_zip_entry_points(self): - scripts = entry_points()['console_scripts'] + scripts = entry_points(group='console_scripts') entry_point = scripts['example'] self.assertEqual(entry_point.value, 'example:main') entry_point = scripts['Example'] From e3d1b935b3a2185461aadca34192b93bfdeaa9ca Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 21 Feb 2021 12:27:41 -0500 Subject: [PATCH 13/18] Update changelog. --- CHANGES.rst | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 368723d4..2f5b1ec5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,18 +1,34 @@ -v3.5.0 +v4.0.0 ====== -* #280: ``entry_points`` now only returns entry points for - unique distributions (by name). -* ``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 +* #284: Introduces new ``EntryPoints`` object, a tuple of + ``EntryPoint`` objects but with convenience properties for + selecting and inspecting the results: + + - ``.select()`` accepts ``group`` or ``name`` keyword + parameters and returns a new ``EntryPoints`` tuple + with only those that match the selection. + - ``.groups`` property presents all of the group names. + - ``.names`` property presents the names of the entry points. + - Item access (e.g. ``eps[name]``) retrieves a single + entry point by name. + + ``entry_points()`` now returns an ``EntryPoints`` + object, but provides for backward compatibility with + a ``__getitem__`` accessor by group and a ``get()`` + method. + + Construction of entry points using ``dict([EntryPoint, ...])`` is now deprecated and raises an appropriate DeprecationWarning and will be removed in a future version. +v3.5.0 +====== + +* #280: ``entry_points`` now only returns entry points for + unique distributions (by name). + v3.4.0 ====== From 9d55a331c7d77025054e85f23bc23c614fab6856 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Mon, 22 Feb 2021 08:47:11 -0500 Subject: [PATCH 14/18] Separate compatibility shim from canonical EntryPoints container. --- importlib_metadata/__init__.py | 41 ++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 313beca2..94a82ffe 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -165,23 +165,12 @@ class EntryPoints(tuple): __slots__ = () - def __getitem__(self, name) -> Union[EntryPoint, 'EntryPoints']: + def __getitem__(self, name): # -> EntryPoint: try: - match = next(iter(self.select(name=name))) - return match + return next(iter(self.select(name=name))) except StopIteration: - if name in self.groups: - return self._group_getitem(name) raise KeyError(name) - def _group_getitem(self, name): - """ - For backward compatability, supply .__getitem__ for groups. - """ - msg = "GroupedEntryPoints.__getitem__ is deprecated for groups. Use select." - warnings.warn(msg, DeprecationWarning) - return self.select(group=name) - def select(self, **params): return EntryPoints(ep for ep in self if ep.matches(**params)) @@ -193,6 +182,23 @@ def names(self): def groups(self): return set(ep.group for ep in self) + @classmethod + def _from_text_for(cls, text, dist): + return cls(ep._for(dist) for ep in EntryPoint._from_text(text)) + + +class LegacyGroupedEntryPoints(EntryPoints): + def __getitem__(self, name) -> Union[EntryPoint, 'EntryPoints']: + try: + return super().__getitem__(name) + except KeyError: + if name not in self.groups: + raise + + msg = "GroupedEntryPoints.__getitem__ is deprecated for groups. Use select." + warnings.warn(msg, DeprecationWarning) + return self.select(group=name) + def get(self, group, default=None): """ For backward compatibility, supply .get @@ -202,9 +208,10 @@ def get(self, group, default=None): is_flake8 or warnings.warn(msg, DeprecationWarning) return self.select(group=group) or default - @classmethod - def _from_text_for(cls, text, dist): - return cls(ep._for(dist) for ep in EntryPoint._from_text(text)) + def select(self, **params): + if not params: + return self + return super().select(**params) class PackagePath(pathlib.PurePosixPath): @@ -704,7 +711,7 @@ def entry_points(**params): eps = itertools.chain.from_iterable( dist.entry_points for dist in unique(distributions()) ) - return EntryPoints(eps).select(**params) + return LegacyGroupedEntryPoints(eps).select(**params) def files(distribution_name): From d6f7c201b15c79bce7c4e27784a2bd61bdc43555 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Mon, 22 Feb 2021 20:49:20 -0500 Subject: [PATCH 15/18] Add docstrings to the compatibility shim. Give primacy to group lookup in compatibility shim. --- importlib_metadata/__init__.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 94a82ffe..bd7d4d7e 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -188,27 +188,38 @@ def _from_text_for(cls, text, dist): class LegacyGroupedEntryPoints(EntryPoints): + """ + Compatibility wrapper around EntryPoints to provide + much of the 'dict' interface previously returned by + entry_points. + """ + def __getitem__(self, name) -> Union[EntryPoint, 'EntryPoints']: - try: - return super().__getitem__(name) - except KeyError: - if name not in self.groups: - raise + """ + When accessed by name that matches a group, return the group. + """ + group = self.select(group=name) + if group: + msg = "GroupedEntryPoints.__getitem__ is deprecated for groups. Use select." + warnings.warn(msg, DeprecationWarning, stacklevel=2) + return group - msg = "GroupedEntryPoints.__getitem__ is deprecated for groups. Use select." - warnings.warn(msg, DeprecationWarning) - return self.select(group=name) + return super().__getitem__(name) def get(self, group, default=None): """ - For backward compatibility, supply .get + For backward compatibility, supply .get. """ is_flake8 = any('flake8' in str(frame) for frame in inspect.stack()) msg = "GroupedEntryPoints.get is deprecated. Use select." - is_flake8 or warnings.warn(msg, DeprecationWarning) + is_flake8 or warnings.warn(msg, DeprecationWarning, stacklevel=2) return self.select(group=group) or default def select(self, **params): + """ + Prevent transform to EntryPoints during call to entry_points if + no selection parameters were passed. + """ if not params: return self return super().select(**params) From 2db4dada379822b4767809a5c4e2436f32908658 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 23 Feb 2021 09:58:19 -0500 Subject: [PATCH 16/18] Introduce SelectableGroups, created for the 3.x line to provide forward compatibilty to the new interfaces without sacrificing backward compatibility. --- CHANGES.rst | 21 ++++++++++---- docs/using.rst | 4 +-- importlib_metadata/__init__.py | 50 ++++++++++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2f5b1ec5..f8df681d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,4 @@ -v4.0.0 +v3.6.0 ====== * #284: Introduces new ``EntryPoints`` object, a tuple of @@ -13,10 +13,21 @@ v4.0.0 - Item access (e.g. ``eps[name]``) retrieves a single entry point by name. - ``entry_points()`` now returns an ``EntryPoints`` - object, but provides for backward compatibility with - a ``__getitem__`` accessor by group and a ``get()`` - method. + ``entry_points`` now accepts "selection parameters", + same as ``EntryPoint.select()``. + + ``entry_points()`` now provides a future-compatible + ``SelectableGroups`` object that supplies the above interface + but remains a dict for compatibility. + + In the future, ``entry_points()`` will return an + ``EntryPoints`` object, but provide for backward + compatibility with a deprecated ``__getitem__`` + accessor by group and a ``get()`` method. + + If passing selection parameters to ``entry_points``, the + future behavior is invoked and an ``EntryPoints`` is the + result. Construction of entry points using ``dict([EntryPoint, ...])`` is now deprecated and raises diff --git a/docs/using.rst b/docs/using.rst index bdfe3e82..97941452 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -67,8 +67,8 @@ This package provides the following functionality via its public API. Entry points ------------ -The ``entry_points()`` function returns a sequence of all entry points, -keyed by group. Entry points are represented by ``EntryPoint`` instances; +The ``entry_points()`` function returns a collection of entry points. +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 diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index bd7d4d7e..f2dc9c07 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -187,6 +187,37 @@ def _from_text_for(cls, text, dist): return cls(ep._for(dist) for ep in EntryPoint._from_text(text)) +class SelectableGroups(dict): + """ + A backward- and forward-compatible result from + entry_points that fully implements the dict interface. + """ + + @classmethod + def load(cls, eps): + by_group = operator.attrgetter('group') + ordered = sorted(eps, key=by_group) + grouped = itertools.groupby(ordered, by_group) + return cls((group, EntryPoints(eps)) for group, eps in grouped) + + @property + def groups(self): + return self.keys() + + @property + def names(self): + return (ep.name for ep in self._all) + + @property + def _all(self): + return itertools.chain.from_iterable(self.values()) + + def select(self, **params): + if not params: + return self + return EntryPoints(self._all).select(**params) + + class LegacyGroupedEntryPoints(EntryPoints): """ Compatibility wrapper around EntryPoints to provide @@ -713,16 +744,29 @@ def version(distribution_name): return distribution(distribution_name).version -def entry_points(**params): +def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: """Return EntryPoint objects for all installed packages. - :return: EntryPoint objects for all installed packages. + Pass selection parameters (group or name) to filter the + result to entry points matching those properties (see + EntryPoints.select()). + + For compatibility, returns ``SelectableGroups`` object unless + selection parameters are supplied. In the future, this function + will return ``LegacyGroupedEntryPoints`` instead of + ``SelectableGroups`` and eventually will only return + ``EntryPoints``. + + For maximum future compatibility, pass selection parameters + or invoke ``.select`` with parameters on the result. + + :return: EntryPoints or SelectableGroups for all installed packages. """ unique = functools.partial(unique_everseen, key=operator.attrgetter('name')) eps = itertools.chain.from_iterable( dist.entry_points for dist in unique(distributions()) ) - return LegacyGroupedEntryPoints(eps).select(**params) + return SelectableGroups.load(eps).select(**params) def files(distribution_name): From 2def046c694cddfbd1967575f8ce7da95680c9c3 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 23 Feb 2021 13:07:55 -0500 Subject: [PATCH 17/18] Address coverage misses, ignored for LegacyGroupedEntryPoints. --- importlib_metadata/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index f2dc9c07..5f156ae6 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -180,6 +180,11 @@ def names(self): @property def groups(self): + """ + For coverage while SelectableGroups is present. + >>> EntryPoints().groups + set() + """ return set(ep.group for ep in self) @classmethod @@ -202,11 +207,16 @@ def load(cls, eps): @property def groups(self): - return self.keys() + return set(self.keys()) @property def names(self): - return (ep.name for ep in self._all) + """ + for coverage: + >>> SelectableGroups().names + set() + """ + return set(ep.name for ep in self._all) @property def _all(self): @@ -218,7 +228,7 @@ def select(self, **params): return EntryPoints(self._all).select(**params) -class LegacyGroupedEntryPoints(EntryPoints): +class LegacyGroupedEntryPoints(EntryPoints): # pragma: nocover """ Compatibility wrapper around EntryPoints to provide much of the 'dict' interface previously returned by From dd8da47fdf97d4420cca557742f8f075da2123e4 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Tue, 23 Feb 2021 13:12:42 -0500 Subject: [PATCH 18/18] Leverage EntryPoints interfaces in SelectableGroups --- importlib_metadata/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 5f156ae6..b0b1ae0e 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -205,9 +205,13 @@ def load(cls, eps): grouped = itertools.groupby(ordered, by_group) return cls((group, EntryPoints(eps)) for group, eps in grouped) + @property + def _all(self): + return EntryPoints(itertools.chain.from_iterable(self.values())) + @property def groups(self): - return set(self.keys()) + return self._all.groups @property def names(self): @@ -216,16 +220,12 @@ def names(self): >>> SelectableGroups().names set() """ - return set(ep.name for ep in self._all) - - @property - def _all(self): - return itertools.chain.from_iterable(self.values()) + return self._all.names def select(self, **params): if not params: return self - return EntryPoints(self._all).select(**params) + return self._all.select(**params) class LegacyGroupedEntryPoints(EntryPoints): # pragma: nocover