Coverage for src/lumen/cortex/registry.py: 99%
84 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2The process-wide list of registered plugins.
4Registration is the whole contract for getting code into the server: a plugin
5is named in configuration (or handed to :func:`lumen.register_plugin` in a
6development session) and everything it declared is wired up here. Adding a
7plugin never touches the core.
8"""
10from __future__ import annotations
12from collections.abc import Awaitable, Callable
13from dataclasses import dataclass
14from typing import Any
16import structlog
18from .contract import PluginRegistrationError, check_version
19from .plugin import HookSpec, OperationSpec, Plugin
21logger = structlog.get_logger(__name__)
24@dataclass(frozen=True)
25class RegisteredHook:
26 plugin_name: str
27 plugin_version: str
28 spec: HookSpec
29 callback: Callable[..., Awaitable[Any]]
32@dataclass(frozen=True)
33class RegisteredOperation:
34 plugin_name: str
35 plugin_version: str
36 spec: OperationSpec
37 callback: Callable[..., Awaitable[Any]]
40class PluginRegistry:
41 """Which plugins are loaded and what they listen to."""
43 def __init__(self) -> None:
44 self._plugins: dict[str, Plugin] = {}
45 self._hooks: list[RegisteredHook] = []
46 self._operations: list[RegisteredOperation] = []
48 # ── State ─────────────────────────────────────────────────────────────────
50 @property
51 def plugins(self) -> list[Plugin]:
52 return list(self._plugins.values())
54 @property
55 def has_hooks(self) -> bool:
56 """False in the default configuration — the write path checks this first."""
57 return bool(self._hooks)
59 @property
60 def operations(self) -> list[RegisteredOperation]:
61 return list(self._operations)
63 def hooks_for(self, event: str, resource_type: str) -> list[RegisteredHook]:
64 return [h for h in self._hooks if h.spec.matches(event, resource_type)]
66 # ── Registration ──────────────────────────────────────────────────────────
68 def register(self, plugin: Plugin, *, replace: bool = False) -> None:
69 """Wire up ``plugin``'s hooks and operations.
71 ``replace`` re-registers a plugin of the same name, which is what makes
72 editing a plugin in a notebook and re-running the cell work.
73 """
74 if not plugin.name:
75 raise PluginRegistrationError(f"{type(plugin).__name__} must set a `name`")
76 if plugin.name in self._plugins:
77 if not replace:
78 raise PluginRegistrationError(f"A plugin named {plugin.name!r} is already loaded")
79 self.unregister(plugin.name)
81 check_version(plugin.requires_cortex)
83 hooks = [
84 RegisteredHook(plugin.name, plugin.version, spec, callback)
85 for spec, callback in plugin.declared_hooks()
86 ]
87 operations = [
88 RegisteredOperation(plugin.name, plugin.version, spec, callback)
89 for spec, callback in plugin.declared_operations()
90 ]
91 if not hooks and not operations:
92 raise PluginRegistrationError(
93 f"Plugin {plugin.name!r} declares neither a hook nor an operation"
94 )
96 # Publish first, commit second. A plugin whose second operation clashes
97 # with an existing one must leave nothing behind — otherwise the later
98 # cleanup withdraws operations this plugin never owned, and a built-in
99 # one disappears with it.
100 published: list[RegisteredOperation] = []
101 try:
102 for registered in operations:
103 _publish_operation(registered)
104 published.append(registered)
105 except Exception:
106 for registered in published:
107 _withdraw_operation(registered)
108 raise
110 self._plugins[plugin.name] = plugin
111 self._hooks.extend(hooks)
112 self._operations.extend(operations)
114 logger.info(
115 "cortex.plugin_registered",
116 plugin=plugin.name,
117 version=plugin.version,
118 hooks=len(hooks),
119 operations=len(operations),
120 )
122 def unregister(self, name: str) -> None:
123 """Remove a plugin and everything it declared."""
124 if name not in self._plugins:
125 return
126 for registered in [op for op in self._operations if op.plugin_name == name]:
127 _withdraw_operation(registered)
128 self._hooks = [h for h in self._hooks if h.plugin_name != name]
129 self._operations = [o for o in self._operations if o.plugin_name != name]
130 del self._plugins[name]
131 logger.info("cortex.plugin_unregistered", plugin=name)
133 def clear(self) -> None:
134 """Remove every plugin (shutdown, and test isolation)."""
135 for name in list(self._plugins):
136 self.unregister(name)
139_registry: PluginRegistry | None = None
142def get_plugin_registry() -> PluginRegistry:
143 global _registry
144 if _registry is None:
145 _registry = PluginRegistry()
146 return _registry
149def _publish_operation(registered: RegisteredOperation) -> None:
150 """Make a plugin operation reachable over HTTP and visible in /metadata."""
151 from ..api.operations import register_plugin_operation
153 register_plugin_operation(registered)
156def _withdraw_operation(registered: RegisteredOperation) -> None:
157 from ..api.operations import unregister_plugin_operation
159 unregister_plugin_operation(registered)