diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 8dd02a1bdf5e..bdaabf281fbd 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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) @@ -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) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 4d604ef8995c..481e10adde96 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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.