From 00633f959f378b4476e7ce462772d141c4af474a Mon Sep 17 00:00:00 2001 From: mannnrachman Date: Mon, 27 Jul 2026 00:26:19 +0800 Subject: [PATCH] fix(plugins): register entry-point plugins declared as module:function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EntryPoint.load() resolves the module:function form to the referenced attribute (typically the register callable itself), not its module. The loader then looked for a .register attribute on that function object, found none, and warned "Plugin '' has no register() function" on every discovery pass — so pip plugins with module:function entry points never registered at all. Detect a non-module callable from ep.load() and use it directly as the register function, resolving LoadedPlugin.module from sys.modules via the callable's __module__ for attribution. The existing entry-point test masked this because its mocked ep.load() returned the module even though its declared value was module:function; the new regression test mirrors real importlib behavior. Fixes #72052 Co-Authored-By: Claude Fable 5 --- hermes_cli/plugins.py | 21 ++++++++++++-- tests/hermes_cli/test_plugins.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) 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.