Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -1785,10 +1785,18 @@ def _load_plugin(self, manifest: PluginManifest) -> None:
else:
module = self._load_entrypoint_module(manifest)

register_fn = None
if not isinstance(module, types.ModuleType) and callable(module):
# Entry points declared as ``module:function`` resolve to the
# function object itself via ``ep.load()``, not its module.
register_fn = module
module = sys.modules.get(getattr(register_fn, "__module__", ""))

loaded.module = module

# Call register()
register_fn = getattr(module, "register", None)
if register_fn is None:
register_fn = getattr(module, "register", None)
if register_fn is None:
loaded.error = "no register() function"
logger.warning("Plugin '%s' has no register() function", manifest.name)
Expand Down Expand Up @@ -1886,8 +1894,15 @@ def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType:
spec.loader.exec_module(module)
return module

def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
"""Load a pip-installed plugin via its entry-point reference."""
def _load_entrypoint_module(
self, manifest: PluginManifest
) -> Union[types.ModuleType, Callable[..., Any]]:
"""Load a pip-installed plugin via its entry-point reference.

Returns whatever ``ep.load()`` resolves to: the module for a bare
``module`` entry point, or the referenced attribute (typically the
``register`` callable) for the ``module:function`` form.
"""
eps = importlib.metadata.entry_points()
if hasattr(eps, "select"):
group_eps = eps.select(group=ENTRY_POINTS_GROUP)
Expand Down
48 changes: 48 additions & 0 deletions tests/hermes_cli/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,54 @@ def _boom(self_inner):



def test_entry_point_function_form_registers(self, tmp_path, monkeypatch):
"""Entry points declared as ``module:function`` register via the callable.

Regression for #72052: real ``EntryPoint.load()`` returns the referenced
attribute for the ``module:function`` form, not the module. The loader
used to look for ``.register`` on that function object, find nothing,
and warn "no register() function" on every discovery pass.
"""
hermes_home = tmp_path / "hermes_test"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Entry-point plugins load only when opted into plugins.enabled.
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"plugins": {"enabled": ["fn_plugin"]}})
)

fake_module = types.ModuleType("fake_fn_plugin")
register_calls = []

def register(ctx):
register_calls.append(ctx)

register.__module__ = "fake_fn_plugin"
fake_module.register = register # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "fake_fn_plugin", fake_module)

fake_ep = MagicMock()
fake_ep.name = "fn_plugin"
fake_ep.value = "fake_fn_plugin:register"
fake_ep.group = ENTRY_POINTS_GROUP
# Mirror real importlib behavior: load() resolves to the attribute.
fake_ep.load.return_value = register

def fake_entry_points():
result = MagicMock()
result.select = MagicMock(return_value=[fake_ep])
return result

with patch("importlib.metadata.entry_points", fake_entry_points):
mgr = PluginManager()
mgr.discover_and_load()

entry = mgr._plugins["fn_plugin"]
assert entry.error is None, entry.error
assert entry.enabled
assert len(register_calls) == 1
assert entry.module is fake_module

def test_force_rediscover_clears_all_plugin_registries(self, monkeypatch):
"""force=True must clear every plugin-populated registry.

Expand Down
Loading