Coverage for src/lumen/cortex/loader.py: 60%
96 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# TODO Adapt cortex plugin loader to use specific naming syntax
2# TODO When loading analytics plugin it fetches duckdb as plugin environment,
3# we should support local install by using duckdb wheel
4"""
5Getting plugins into the process — by configuration, never by editing the core.
7Two sources, both resolved at startup:
9* ``LUMEN_CORTEX_PLUGINS`` — a comma-separated list of ``module:ClassName``
10 (or just ``module``, if the module exposes exactly one ``Plugin`` subclass).
11* Python entry points in the ``lumen.cortex.plugins`` group, so an installed
12 package can register itself.
14A plugin that fails to load is logged and skipped. One broken extension must
15not stop a conformant FHIR server from starting.
16"""
18from __future__ import annotations
20import importlib
21import inspect
22from importlib.metadata import entry_points
23from typing import Any
25import structlog
27from ..config import CortexSettings
28from .contract import PluginRegistrationError
29from .plugin import Plugin
30from .registry import get_plugin_registry
32logger = structlog.get_logger(__name__)
34ENTRY_POINT_GROUP = "lumen.cortex.plugins"
37async def load_plugins(settings: CortexSettings) -> None:
38 """Load and register everything configuration asks for."""
39 if not settings.enabled:
40 await logger.ainfo("cortex.disabled")
41 return
43 specs = [spec.strip() for spec in settings.plugins.split(",") if spec.strip()]
44 _ensure_plugin_requirements(specs)
45 for spec in specs:
46 await _load_one(spec)
48 if settings.load_entry_points:
49 for ep in entry_points(group=ENTRY_POINT_GROUP):
50 await _register(ep.load(), source=f"entry-point:{ep.name}")
52 registry = get_plugin_registry()
53 await logger.ainfo(
54 "cortex.ready",
55 plugins=[p.name for p in registry.plugins],
56 hooks=registry.has_hooks,
57 operations=len(registry.operations),
58 )
61async def _load_one(spec: str) -> None:
62 """Load ``module:ClassName`` or ``module``."""
63 module_name, _, class_name = spec.partition(":")
64 try:
65 module = importlib.import_module(module_name)
66 except ImportError as exc:
67 await logger.aerror("cortex.plugin_import_failed", spec=spec, error=str(exc))
68 return
70 try:
71 factory = getattr(module, class_name) if class_name else _sole_plugin_class(module, spec)
72 except (AttributeError, PluginRegistrationError) as exc:
73 await logger.aerror("cortex.plugin_not_found", spec=spec, error=str(exc))
74 return
76 await _register(factory, source=spec)
79def _sole_plugin_class(module: Any, spec: str) -> type[Plugin]:
80 candidates = [
81 obj
82 for _, obj in inspect.getmembers(module, inspect.isclass)
83 if issubclass(obj, Plugin) and obj is not Plugin and obj.__module__ == module.__name__
84 ]
85 if len(candidates) != 1:
86 raise PluginRegistrationError(
87 f"{spec} exposes {len(candidates)} Plugin classes; name one as 'module:ClassName'"
88 )
89 return candidates[0]
92async def _register(factory: Any, *, source: str) -> None:
93 """Instantiate if needed, run setup, register. Failures are logged, not raised."""
94 try:
95 plugin = factory() if isinstance(factory, type) else factory
96 if not isinstance(plugin, Plugin):
97 raise PluginRegistrationError(f"{source} is not a lumen.cortex.Plugin")
98 await plugin.setup()
99 get_plugin_registry().register(plugin)
100 except Exception as exc:
101 await logger.aerror(
102 "cortex.plugin_load_failed",
103 source=source,
104 error=str(exc),
105 error_type=type(exc).__name__,
106 )
109async def unload_plugins() -> None:
110 """Tear every plugin down (server shutdown)."""
111 registry = get_plugin_registry()
112 for plugin in registry.plugins:
113 try:
114 await plugin.teardown()
115 except Exception as exc:
116 await logger.aerror("cortex.plugin_teardown_failed", plugin=plugin.name, error=str(exc))
117 registry.clear()
120def _ensure_plugin_requirements(specs: list[str]) -> None:
121 """
122 Check for requirements.txt in active plugin directories and auto-install
123 (offline with wheels if available).
124 """
125 import shutil
126 import subprocess
127 import sys
128 from pathlib import Path
130 possible_paths: list[Path] = [
131 Path("/app/plugins/requirements.txt"),
132 Path("plugins/requirements.txt"),
133 ]
135 # Check for internal plugins if configured
136 has_internal_plugin = any("internal_plugins" in spec for spec in specs)
137 if has_internal_plugin:
138 possible_paths.extend(
139 [
140 Path("/app/src/lumen/cortex/internal_plugins/analytics/requirements.txt"),
141 Path("src/lumen/cortex/internal_plugins/analytics/requirements.txt"),
142 Path("/app/src/lumen/cortex/internal_plugins/requirements.txt"),
143 Path("src/lumen/cortex/internal_plugins/requirements.txt"),
144 ]
145 )
147 for req_file in possible_paths:
148 if req_file.exists():
149 try:
150 # Check for offline wheels directory or adjacent wheels
151 wheel_dirs: list[Path] = []
152 for candidate in [req_file.parent / "wheels", req_file.parent]:
153 if candidate.exists() and any(candidate.glob("*.whl")):
154 wheel_dirs.append(candidate)
156 uv_bin = shutil.which("uv")
157 if uv_bin:
158 cmd = [
159 uv_bin,
160 "pip",
161 "install",
162 "--cache-dir",
163 "/tmp/uv_cache",
164 "-q",
165 ]
166 else:
167 cmd = [sys.executable, "-m", "pip", "install", "-q"]
169 if wheel_dirs:
170 cmd.append("--no-index")
171 for w_dir in wheel_dirs:
172 cmd.extend(["--find-links", str(w_dir)])
174 cmd.extend(["-r", str(req_file)])
176 try:
177 subprocess.check_call(cmd)
178 except subprocess.CalledProcessError as sub_exc:
179 if wheel_dirs:
180 # Fallback: retry without --no-index if wheels were for other architecture
181 structlog.get_logger(__name__).warning(
182 "cortex.plugin_requirements_offline_failed_retrying_online",
183 path=str(req_file),
184 error=str(sub_exc),
185 )
186 fallback_cmd = (
187 [
188 uv_bin,
189 "pip",
190 "install",
191 "--cache-dir",
192 "/tmp/uv_cache",
193 "-q",
194 "-r",
195 str(req_file),
196 ]
197 if uv_bin
198 else [sys.executable, "-m", "pip", "install", "-q", "-r", str(req_file)]
199 )
200 subprocess.check_call(fallback_cmd)
201 else:
202 raise
204 structlog.get_logger(__name__).info(
205 "cortex.plugin_requirements_installed",
206 path=str(req_file),
207 offline=bool(wheel_dirs),
208 )
209 except Exception as exc:
210 structlog.get_logger(__name__).error(
211 "cortex.plugin_requirements_install_failed", path=str(req_file), error=str(exc)
212 )