3
0
Fork 0

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
This commit is contained in:
Lance Edgar 2026-03-17 11:26:38 -05:00
parent 17efdb9572
commit 35a0897b21
4 changed files with 74 additions and 14 deletions

View file

@ -42,6 +42,7 @@ intersphinx_mapping = {
"rattail": ("https://docs.wuttaproject.org/rattail/", None), "rattail": ("https://docs.wuttaproject.org/rattail/", None),
"rattail-manual": ("https://docs.wuttaproject.org/rattail-manual/", None), "rattail-manual": ("https://docs.wuttaproject.org/rattail-manual/", None),
"rich": ("https://rich.readthedocs.io/en/latest/", 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), "sqlalchemy": ("http://docs.sqlalchemy.org/en/latest/", None),
"wutta-continuum": ("https://docs.wuttaproject.org/wutta-continuum/", None), "wutta-continuum": ("https://docs.wuttaproject.org/wutta-continuum/", None),
"wuttasync": ("https://docs.wuttaproject.org/wuttasync/", None), "wuttasync": ("https://docs.wuttaproject.org/wuttasync/", None),

View file

@ -222,13 +222,9 @@ Glossary
entry point entry point
This refers to a "setuptools-style" entry point specifically, This refers to a "setuptools-style" entry point specifically,
which is a mechanism used to register "plugins" and the like. which is a mechanism used to register "plugins" and the like.
This lets the app / config discover features dynamically. Most This lets the app / config discover features dynamically.
notably used to register :term:`commands<command>` and
:term:`subcommands<subcommand>`.
For more info see the `Python Packaging User Guide`_. For more info see :doc:`setuptools:userguide/entry_point` in the setuptools docs.
.. _Python Packaging User Guide: https://packaging.python.org/en/latest/specifications/entry-points/
handler handler
Similar to a "plugin" concept but only *one* handler may be used Similar to a "plugin" concept but only *one* handler may be used

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttJamaican -- Base package for Wutta Framework # WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2025 Lance Edgar # Copyright © 2023-2026 Lance Edgar
# #
# This file is part of Wutta Framework. # This file is part of Wutta Framework.
# #
@ -110,22 +110,36 @@ def get_value(obj, key):
return getattr(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 <entry
point>`.
This is used to locate "plugins" and similar things, e.g. the set This is used to locate "plugins" and similar things, e.g. discover
of subcommands which belong to a main command. 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 :param group: The group (string name) of entry points to be
loaded, e.g. ``'wutta.commands'``. 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 :param ignore_errors: If false (the default), any errors will be
raised normally. If true, errors will be logged but not raised normally. If true, errors will be logged but not
raised. raised.
:returns: A dictionary whose keys are the entry point names, and :returns: A dict of entry points, as described above.
values are the loaded entry points.
""" """
entry_points = {} entry_points = {}
@ -150,7 +164,16 @@ def load_entry_points(group, ignore_errors=False):
raise raise
log.warning("failed to load entry point: %s", entry_point, exc_info=True) log.warning("failed to load entry point: %s", entry_point, exc_info=True)
else: 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 return entry_points

View file

@ -184,6 +184,46 @@ class TestLoadEntryPoints(TestCase):
entry_points.select.assert_called_once_with(group="wuttatest.thingers") entry_points.select.assert_called_once_with(group="wuttatest.thingers")
entry_point.load.assert_called_once_with() 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): class TestLoadObject(TestCase):