diff --git a/hermes_cli/plugin_compat.py b/hermes_cli/plugin_compat.py index 023a7bacad..7c8145eb3c 100644 --- a/hermes_cli/plugin_compat.py +++ b/hermes_cli/plugin_compat.py @@ -149,17 +149,50 @@ def scan_source(src: str, rel: str, manifest: Dict[str, Dict[str, str]]) -> List return sorted(set(hits), key=lambda h: (h.file, h.line, h.old)) +_scan_lock = threading.Lock() +_scan_cache: Dict[str, Tuple[Tuple[Tuple[str, int, int], ...], List[Hit]]] = {} + + +def _plugin_dir_signature(plugin_dir: Path) -> Tuple[Tuple[str, int, int], ...]: + """``(relpath, mtime_ns, size)`` of every ``.py`` under the plugin — the cache key for a scan. + A stat walk costs ~1 ms; the AST scan it replaces costs ~10 ms per file.""" + sig = [] + for p in _iter_py(plugin_dir): + try: + st = p.stat() + except OSError: + continue + sig.append((str(p.relative_to(plugin_dir)), st.st_mtime_ns, st.st_size)) + return tuple(sorted(sig)) + + def scan_plugin(plugin_dir: Optional[Path], manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]: - manifest = load_manifest() if manifest is None else manifest + """Hits for one plugin directory. With the default manifest the result is cached process-wide + on the directory's file signature: a multiplex gateway discovers plugins once per served + profile, and re-parsing every plugin's source per profile was ~0.4s × profiles on the boot path.""" + loaded = load_manifest() + manifest = loaded if manifest is None else manifest + cacheable = manifest is loaded if not manifest or not plugin_dir or not Path(plugin_dir).is_dir(): return [] + plugin_dir = Path(plugin_dir) + key = str(plugin_dir.resolve(strict=False)) + signature = _plugin_dir_signature(plugin_dir) if cacheable else () + if cacheable: + with _scan_lock: + cached = _scan_cache.get(key) + if cached is not None and cached[0] == signature: + return list(cached[1]) hits: List[Hit] = [] - for p in _iter_py(Path(plugin_dir)): + for p in _iter_py(plugin_dir): try: src = p.read_text(encoding="utf-8", errors="replace") except OSError: continue hits += scan_source(src, str(p.relative_to(plugin_dir)), manifest) + if cacheable: + with _scan_lock: + _scan_cache[key] = (signature, list(hits)) return hits diff --git a/tests/hermes_cli/test_plugin_compat_scan_cache.py b/tests/hermes_cli/test_plugin_compat_scan_cache.py new file mode 100644 index 0000000000..801ab972fb --- /dev/null +++ b/tests/hermes_cli/test_plugin_compat_scan_cache.py @@ -0,0 +1,43 @@ +"""``scan_plugin`` caches its AST scan on the plugin dir's file signature. + +A multiplex gateway runs plugin discovery once per served profile; before the cache every profile +re-parsed every external plugin's source (~0.4s × profiles on the gateway boot path). +""" +from __future__ import annotations + +from pathlib import Path + +from hermes_cli import plugin_compat + + +def _install_plugin(root: Path) -> Path: + plugin = root / "demo-plugin" + plugin.mkdir() + (plugin / "__init__.py").write_text("from old.facade import thing\n", encoding="utf-8") + return plugin + + +def test_scan_plugin_parses_each_plugin_once_until_its_files_change(tmp_path, monkeypatch) -> None: + manifest = {"old.facade": {"thing": "new.home.thing"}} + monkeypatch.setattr(plugin_compat, "load_manifest", lambda: manifest) + monkeypatch.setattr(plugin_compat, "_scan_cache", {}) + parses = [] + real_scan_source = plugin_compat.scan_source + monkeypatch.setattr(plugin_compat, "scan_source", + lambda src, rel, m: parses.append(rel) or real_scan_source(src, rel, m)) + plugin = _install_plugin(tmp_path) + + first = plugin_compat.scan_plugin(plugin) + second = plugin_compat.scan_plugin(plugin) # a second profile discovering the same plugin + assert [h.old for h in first] == ["old.facade.thing"] + assert second == first + assert parses == ["__init__.py"], "second discovery must reuse the first scan" + + (plugin / "__init__.py").write_text("from old.facade import thing, other\n", encoding="utf-8") + third = plugin_compat.scan_plugin(plugin) # size changed -> rescanned + assert parses == ["__init__.py", "__init__.py"] + assert [h.old for h in third] == ["old.facade.thing"] + + # A caller-supplied manifest is never served from (or written to) the cache. + plugin_compat.scan_plugin(plugin, {"old.facade": {"other": "x"}}) + assert len(parses) == 3