Coverage for node / src / stigmem_node / plugins / discovery.py: 96%
106 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Plugin package discovery via Python entry points."""
3from __future__ import annotations
5import logging
6from collections.abc import Iterable
7from dataclasses import dataclass
8from importlib.metadata import EntryPoint, entry_points
9from typing import Any
11from .errors import PluginDependencyError, PluginDiscoveryError
12from .manifest import PluginManifest
14logger = logging.getLogger("stigmem.plugins")
16ENTRY_POINT_GROUP = "stigmem.plugins"
17_State = str
18_VISITING: _State = "visiting"
19_VISITED: _State = "visited"
21# Plugins whose functionality has graduated into core. An installed copy is
22# discovered but ignored — its hooks are never registered — so an old package
23# cannot shadow, double-run, or fail-open against the now-core implementation.
24# Graduated plugins add their distribution name here.
25GRADUATED_PLUGINS: frozenset[str] = frozenset({"stigmem-plugin-memory-garden-acl"})
28@dataclass(frozen=True, slots=True)
29class DiscoveredPlugin:
30 """Plugin manifest loaded from a package entry point."""
32 manifest: PluginManifest
33 entry_point_name: str
34 entry_point_value: str
35 distribution: str | None = None
36 signing_identity: str = "unsigned"
37 signature_verified: bool = False
40def discover_plugin_manifests(
41 *, group: str = ENTRY_POINT_GROUP
42) -> tuple[DiscoveredPlugin, ...]:
43 """Load plugin manifests declared through Python package entry points.
45 Entry points in ``stigmem.plugins`` must resolve to a zero-argument callable
46 returning a :class:`PluginManifest`. PR 4-INF.2 keeps discovery separate from
47 registration so dependency ordering and startup lifecycle can build on this
48 deterministic manifest list.
49 """
51 discovered: list[DiscoveredPlugin] = []
52 seen_names: dict[str, str] = {}
53 for entry_point in sorted(_entry_points_for_group(group), key=lambda ep: ep.name):
54 factory = _load_entry_point(entry_point)
55 if not callable(factory):
56 raise PluginDiscoveryError(
57 f"plugin entry point {entry_point.name!r} loaded "
58 f"{type(factory).__name__}; expected a callable returning PluginManifest"
59 )
60 manifest = _call_manifest_factory(entry_point, factory)
61 if manifest.name in GRADUATED_PLUGINS:
62 logger.warning(
63 "Ignoring installed plugin %r: its functionality has graduated "
64 "into core. Uninstall the package — it is now a no-op.",
65 manifest.name,
66 )
67 continue
68 previous_entry_point = seen_names.get(manifest.name)
69 if previous_entry_point is not None:
70 raise PluginDiscoveryError(
71 f"duplicate plugin name {manifest.name!r} discovered from entry points "
72 f"{previous_entry_point!r} and {entry_point.name!r}"
73 )
74 seen_names[manifest.name] = entry_point.name
75 discovered.append(
76 DiscoveredPlugin(
77 manifest=manifest,
78 entry_point_name=entry_point.name,
79 entry_point_value=entry_point.value,
80 distribution=_distribution_name(entry_point),
81 )
82 )
83 return tuple(discovered)
86def resolve_plugin_dependencies(
87 discovered: Iterable[DiscoveredPlugin],
88 *,
89 registered_plugins: Iterable[str] = (),
90) -> tuple[DiscoveredPlugin, ...]:
91 """Return discovered plugins in deterministic dependency-first order.
93 Dependencies may be satisfied by other discovered plugins or by names already
94 present in the registry. Already-registered dependencies are not returned;
95 they simply unlock discovered dependents for follow-on registration.
96 """
98 by_name: dict[str, DiscoveredPlugin] = {}
99 for plugin in discovered:
100 name = plugin.manifest.name
101 if name in by_name: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 raise PluginDependencyError(f"duplicate discovered plugin name {name!r}")
103 by_name[name] = plugin
105 registered = frozenset(registered_plugins)
106 missing: dict[str, list[str]] = {}
107 for name, plugin in sorted(by_name.items()):
108 missing_deps = sorted(
109 dependency
110 for dependency in plugin.manifest.depends_on
111 if dependency not in by_name and dependency not in registered
112 )
113 if missing_deps:
114 missing[name] = missing_deps
115 if missing:
116 details = "; ".join(
117 f"{name} missing {', '.join(dependencies)}"
118 for name, dependencies in missing.items()
119 )
120 raise PluginDependencyError(f"missing plugin dependencies: {details}")
122 ordered: list[DiscoveredPlugin] = []
123 states: dict[str, _State] = {}
124 stack: list[str] = []
126 def visit(name: str) -> None:
127 state = states.get(name)
128 if state == _VISITED:
129 return
130 if state == _VISITING:
131 cycle_start = stack.index(name)
132 cycle_path = [*stack[cycle_start:], name]
133 raise PluginDependencyError(
134 f"plugin dependency cycle detected: {' -> '.join(cycle_path)}"
135 )
137 states[name] = _VISITING
138 stack.append(name)
139 plugin = by_name[name]
140 for dependency in sorted(plugin.manifest.depends_on):
141 if dependency in registered:
142 continue
143 visit(dependency)
144 stack.pop()
145 states[name] = _VISITED
146 ordered.append(plugin)
148 for name in sorted(by_name):
149 visit(name)
151 return tuple(ordered)
154def _entry_points_for_group(group: str) -> Iterable[EntryPoint]:
155 all_entry_points = entry_points()
156 if hasattr(all_entry_points, "select"): 156 ↛ 158line 156 didn't jump to line 158 because the condition on line 156 was always true
157 return all_entry_points.select(group=group)
158 return all_entry_points.get(group, ())
161def _load_entry_point(entry_point: EntryPoint) -> Any:
162 try:
163 return entry_point.load()
164 except Exception as exc:
165 raise PluginDiscoveryError(
166 f"failed to load plugin entry point {entry_point.name!r}: {exc}"
167 ) from exc
170def _call_manifest_factory(entry_point: EntryPoint, factory: Any) -> PluginManifest:
171 try:
172 manifest = factory()
173 except Exception as exc:
174 raise PluginDiscoveryError(
175 f"plugin entry point {entry_point.name!r} failed while creating "
176 f"PluginManifest: {exc}"
177 ) from exc
178 if not isinstance(manifest, PluginManifest):
179 raise PluginDiscoveryError(
180 f"plugin entry point {entry_point.name!r} returned "
181 f"{type(manifest).__name__}; expected PluginManifest"
182 )
183 return manifest
186def _distribution_name(entry_point: EntryPoint) -> str | None:
187 dist = getattr(entry_point, "dist", None)
188 if dist is None:
189 return None
190 metadata = getattr(dist, "metadata", None)
191 if metadata is None: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 return None
193 name = metadata.get("Name")
194 return str(name) if name else None