From 35a0897b21866dc976eb352a66c542a1434af986 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 17 Mar 2026 11:26:38 -0500 Subject: [PATCH] fix: add `lists` param for `load_entry_points()` function wuttasync needs to let various projects define import/export handlers using the same key, so it needs a way to discover them all without this logic auto-discarding duplicate keys --- docs/conf.py | 1 + docs/glossary.rst | 8 ++------ src/wuttjamaican/util.py | 39 +++++++++++++++++++++++++++++++-------- tests/test_util.py | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 81229b2..a0ce914 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,7 @@ intersphinx_mapping = { "rattail": ("https://docs.wuttaproject.org/rattail/", None), "rattail-manual": ("https://docs.wuttaproject.org/rattail-manual/", None), "rich": ("https://rich.readthedocs.io/en/latest/", None), + "setuptools": ("https://setuptools.pypa.io/en/latest/", None), "sqlalchemy": ("http://docs.sqlalchemy.org/en/latest/", None), "wutta-continuum": ("https://docs.wuttaproject.org/wutta-continuum/", None), "wuttasync": ("https://docs.wuttaproject.org/wuttasync/", None), diff --git a/docs/glossary.rst b/docs/glossary.rst index 47df4b0..f41aa60 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -222,13 +222,9 @@ Glossary entry point This refers to a "setuptools-style" entry point specifically, which is a mechanism used to register "plugins" and the like. - This lets the app / config discover features dynamically. Most - notably used to register :term:`commands` and - :term:`subcommands`. + This lets the app / config discover features dynamically. - For more info see the `Python Packaging User Guide`_. - - .. _Python Packaging User Guide: https://packaging.python.org/en/latest/specifications/entry-points/ + For more info see :doc:`setuptools:userguide/entry_point` in the setuptools docs. handler Similar to a "plugin" concept but only *one* handler may be used diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index b1ef966..a3ac393 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2025 Lance Edgar +# Copyright © 2023-2026 Lance Edgar # # This file is part of Wutta Framework. # @@ -110,22 +110,36 @@ def get_value(obj, key): return getattr(obj, key) -def load_entry_points(group, ignore_errors=False): +def load_entry_points(group, lists=False, ignore_errors=False): """ - Load a set of ``setuptools``-style entry points. + Load a set of ``setuptools``-style :term:`entry points `. - This is used to locate "plugins" and similar things, e.g. the set - of subcommands which belong to a main command. + This is used to locate "plugins" and similar things, e.g. discover + which batch handlers are installed. + + Logic will inspect the registered entry points and return a dict + whose keys are the entry point names. By default the dict values + will be the loaded objects as referenced by each entry point. + + In some cases (notably, import handlers for wuttasync) the keys + may not always be unique. This allows multiple projects to define + entry points for the same key. If you specify ``lists=True`` then + the dict values will each be *lists* of loaded objects instead. + (Otherwise some entry points would be discarded when duplicate + keys are found.) :param group: The group (string name) of entry points to be loaded, e.g. ``'wutta.commands'``. + :param lists: Whether to return lists instead of single object + values. + :param ignore_errors: If false (the default), any errors will be raised normally. If true, errors will be logged but not raised. - :returns: A dictionary whose keys are the entry point names, and - values are the loaded entry points. + :returns: A dict of entry points, as described above. """ entry_points = {} @@ -150,7 +164,16 @@ def load_entry_points(group, ignore_errors=False): raise log.warning("failed to load entry point: %s", entry_point, exc_info=True) else: - entry_points[entry_point.name] = ep + if lists: + entry_points.setdefault(entry_point.name, []).append(ep) + else: + if entry_point.name in entry_points: + log.warning( + "overwriting existing key '%s' with entry point: %s", + entry_point.name, + ep, + ) + entry_points[entry_point.name] = ep return entry_points diff --git a/tests/test_util.py b/tests/test_util.py index ddf220e..9106d8c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -184,6 +184,46 @@ class TestLoadEntryPoints(TestCase): entry_points.select.assert_called_once_with(group="wuttatest.thingers") entry_point.load.assert_called_once_with() + def test_duplicate_key(self): + + # two entry points, with same name + ep1 = MagicMock() + ep2 = MagicMock() + ep1.name = "not-quite-unique-key" + ep2.name = "not-quite-unique-key" + ep1.load.return_value = ep1 + ep2.load.return_value = ep2 + + # they both are included for "all" entry points + entry_points = MagicMock() + entry_points.select.return_value = [ep1, ep2] + importlib = MagicMock() + importlib.metadata.entry_points.return_value = entry_points + with patch.dict("sys.modules", **{"importlib": importlib}): + + # but only the 2nd entry point is returned + result = mod.load_entry_points("console_scripts") + self.assertIsInstance(result, dict) + self.assertGreaterEqual(len(result), 1) + self.assertIn("not-quite-unique-key", result) + self.assertIs(result["not-quite-unique-key"], ep2) + + def test_lists(self): + + # classic behvaior + result = mod.load_entry_points("console_scripts", lists=False) + self.assertIsInstance(result, dict) + self.assertIn("pip", result) + self.assertTrue(callable(result["pip"])) + + # lists behavior + result = mod.load_entry_points("console_scripts", lists=True) + self.assertIsInstance(result, dict) + self.assertIn("pip", result) + self.assertIsInstance(result["pip"], list) + self.assertEqual(len(result["pip"]), 1) + self.assertTrue(callable(result["pip"][0])) + class TestLoadObject(TestCase):