Coverage for node / src / stigmem_node / plugins / registry.py: 90%
400 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-25 01:49 +0000
1"""Hook registry dispatch machinery for PR 4-INF.1."""
3from __future__ import annotations
5import contextlib
6import inspect
7import logging
8import threading
9import time
10from collections.abc import Callable
11from concurrent.futures import ThreadPoolExecutor
12from concurrent.futures import TimeoutError as FutureTimeoutError
13from dataclasses import dataclass
14from datetime import UTC, datetime
15from importlib.metadata import PackageNotFoundError, version
16from typing import Any, TypeVar, cast
18from packaging.specifiers import InvalidSpecifier, SpecifierSet
19from packaging.version import Version
21from stigmem_node.observability.metrics import (
22 PLUGIN_HANDLER_DURATION,
23 PLUGIN_HANDLER_ERROR,
24 PLUGIN_HANDLER_INVOCATION,
25 PLUGIN_HANDLERS_PER_HOOK,
26 PLUGIN_HOOK_DURATION,
27 PLUGIN_HOOK_FIRE,
28 PLUGIN_REGISTERED_COUNT,
29 PLUGIN_REGISTRATION,
30 PLUGIN_VOTING_DECISION,
31)
33from .context import CoreApis, PluginContext
34from .errors import ManifestError, PluginExecutionError, RegistryFrozenError, RejectError
35from .handlers import (
36 ALLOW_SINGLETON,
37 Allow,
38 AuditEvent,
39 Deny,
40 Failure,
41 PluginHealth,
42 PluginHealthReport,
43 PluginHealthStatus,
44 PluginInfo,
45 Success,
46 VotingDecision,
47)
48from .hooks import HOOK_SPECS, HookOrdering, HookSemantic
49from .manifest import PluginManifest
51logger = logging.getLogger("stigmem.plugins.registry")
52_FALLBACK_STIGMEM_VERSION = "0.9.0a9"
54T = TypeVar("T")
57@dataclass(frozen=True, slots=True)
58class HandlerEntry:
59 hook: str
60 handler: Callable[..., Any]
61 plugin_name: str
62 handler_name: str
63 ctx: PluginContext
64 is_core: bool
65 async_safe: bool
66 timeout_seconds: float | None
69class HookRegistry:
70 """Deterministic in-process hook registry."""
72 def __init__(
73 self,
74 *,
75 core_apis: CoreApis | None = None,
76 emit_metrics: bool = False,
77 handler_timeout_seconds: float | None = None,
78 ) -> None:
79 if handler_timeout_seconds is not None:
80 _validate_timeout_seconds(handler_timeout_seconds)
81 self._handlers: dict[str, tuple[HandlerEntry, ...]] = {hook: () for hook in HOOK_SPECS}
82 self._plugin_names: set[str] = set()
83 self._plugin_order: list[str] = []
84 self._plugin_versions: dict[str, str] = {}
85 self._plugin_infos: dict[str, PluginInfo] = {}
86 self._plugin_signing_metadata: dict[str, dict[str, Any]] = {}
87 self._plugin_contexts: dict[str, PluginContext] = {}
88 self._plugin_health_checks: dict[str, Callable[..., Any]] = {}
89 self._plugin_health_reports: dict[str, PluginHealthReport] = {}
90 self._core_apis = core_apis or CoreApis()
91 self._emit_metrics = emit_metrics
92 self._handler_timeout_seconds = handler_timeout_seconds
93 self._timeout_executor: ThreadPoolExecutor | None = None
94 self._frozen = False
95 self._emitting_registry_audit = False
96 self._lock = threading.RLock()
98 def register_plugin(
99 self,
100 manifest: PluginManifest,
101 *,
102 discovery_source: dict[str, Any] | None = None,
103 signing_identity: str = "unsigned",
104 signing_metadata: dict[str, Any] | None = None,
105 ) -> None:
106 """Register all handlers from a manually supplied plugin manifest."""
107 audit_metadata = _registration_audit_metadata(
108 discovery_source=discovery_source,
109 signing_identity=signing_identity,
110 signing_metadata=signing_metadata,
111 )
112 with self._lock:
113 self._ensure_mutable()
114 if manifest.name in self._plugin_names:
115 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="duplicate")
116 self._emit_registry_audit(
117 "plugin.registration_failed",
118 manifest=manifest,
119 reason="duplicate",
120 metadata=audit_metadata,
121 )
122 raise ManifestError(f"plugin {manifest.name!r} is already registered")
123 try:
124 self._validate_manifest_compatibility(manifest)
125 self._validate_manifest_handler_signatures(manifest)
126 except ManifestError as exc:
127 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="manifest_invalid")
128 self._emit_registry_audit(
129 "plugin.registration_failed",
130 manifest=manifest,
131 reason="manifest_invalid",
132 validation_failure=str(exc),
133 metadata=audit_metadata,
134 )
135 raise
136 ctx = PluginContext(
137 plugin_name=manifest.name,
138 plugin_version=manifest.version,
139 capabilities=manifest.capabilities,
140 core_apis=self._core_apis,
141 )
142 decision = self.fire_voting("config_validate", plugin=manifest)
143 if isinstance(decision, Deny):
144 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="config_validate")
145 self._emit_registry_audit(
146 "plugin.registration_failed",
147 manifest=manifest,
148 reason="config_validate",
149 validation_failure=decision.reason,
150 metadata=audit_metadata,
151 )
152 raise ManifestError(
153 f"plugin {manifest.name!r} failed config validation: {decision.reason}"
154 )
155 own_config_validator = manifest.hooks.get("config_validate")
156 if own_config_validator is not None:
157 try:
158 own_decision = own_config_validator(ctx, plugin=manifest)
159 except Exception as exc:
160 self._metric_inc(
161 PLUGIN_REGISTRATION, outcome="failure", reason="config_exception"
162 )
163 self._emit_registry_audit(
164 "plugin.registration_failed",
165 manifest=manifest,
166 reason="config_exception",
167 validation_failure=str(exc),
168 metadata=audit_metadata,
169 )
170 raise ManifestError(
171 f"plugin {manifest.name!r} config validator failed: {exc}"
172 ) from exc
173 if isinstance(own_decision, Deny):
174 self._metric_inc(
175 PLUGIN_REGISTRATION, outcome="failure", reason="config_validate"
176 )
177 self._emit_registry_audit(
178 "plugin.registration_failed",
179 manifest=manifest,
180 reason="config_validate",
181 validation_failure=own_decision.reason,
182 metadata=audit_metadata,
183 )
184 raise ManifestError(
185 f"plugin {manifest.name!r} failed config validation: {own_decision.reason}"
186 )
187 if not isinstance(own_decision, Allow): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 self._metric_inc(PLUGIN_REGISTRATION, outcome="failure", reason="config_result")
189 self._emit_registry_audit(
190 "plugin.registration_failed",
191 manifest=manifest,
192 reason="config_result",
193 validation_failure=type(own_decision).__name__,
194 metadata=audit_metadata,
195 )
196 raise ManifestError(
197 f"plugin {manifest.name!r} config validator returned "
198 f"{type(own_decision).__name__}; expected Allow or Deny"
199 )
200 self._plugin_names.add(manifest.name)
201 self._plugin_order.append(manifest.name)
202 self._plugin_versions[manifest.name] = manifest.version
203 self._plugin_infos[manifest.name] = PluginInfo(
204 name=manifest.name,
205 version=manifest.version,
206 capabilities=tuple(sorted(manifest.capabilities)),
207 hooks=tuple(sorted(manifest.hooks)),
208 depends_on=tuple(sorted(manifest.depends_on)),
209 discovery_source=audit_metadata["discovery_source"],
210 signed_by=signing_identity,
211 )
212 if signing_metadata is not None:
213 self._plugin_signing_metadata[manifest.name] = dict(signing_metadata)
214 self._plugin_contexts[manifest.name] = ctx
215 if manifest.health_check is not None:
216 self._plugin_health_checks[manifest.name] = manifest.health_check
217 for hook, handler in manifest.hooks.items():
218 self._add_handler(
219 hook=hook,
220 handler=handler,
221 plugin_name=manifest.name,
222 handler_name=f"{manifest.name}.{getattr(handler, '__name__', 'handler')}",
223 ctx=ctx,
224 is_core=False,
225 async_safe=manifest.async_safe,
226 )
227 self._metric_inc(PLUGIN_REGISTRATION, outcome="success", reason="")
228 self._metric_set(PLUGIN_REGISTERED_COUNT, len(self._plugin_names))
229 self._emit_registry_audit(
230 "plugin.registered",
231 manifest=manifest,
232 metadata=audit_metadata,
233 )
234 logger.info(
235 "registered plugin %r version=%s hooks=%s capabilities=%s",
236 manifest.name,
237 manifest.version,
238 sorted(manifest.hooks),
239 sorted(manifest.capabilities),
240 )
242 def register_core_handler(
243 self,
244 hook: str,
245 handler: Callable[..., Any],
246 *,
247 name: str,
248 capabilities: frozenset[str] | None = None,
249 ) -> None:
250 """Register a core handler with an explicit sortable name.
252 Use names such as ``core.001.sanitize`` and ``core.002.cid`` when
253 relative ordering among core handlers matters.
254 """
255 ctx = PluginContext(
256 plugin_name="core",
257 plugin_version="0.0.0",
258 capabilities=capabilities or frozenset(),
259 core_apis=self._core_apis,
260 )
261 with self._lock:
262 self._ensure_mutable()
263 self._add_handler(
264 hook=hook,
265 handler=handler,
266 plugin_name="core",
267 handler_name=name,
268 ctx=ctx,
269 is_core=True,
270 async_safe=True,
271 )
273 def fire_voting(self, hook: str, **kwargs: Any) -> VotingDecision:
274 self._ensure_semantic(hook, HookSemantic.VOTING)
275 with self._observe_hook(hook):
276 for entry in self._handlers[hook]:
277 try:
278 result = self._invoke_handler(entry, **kwargs)
279 except RejectError as exc:
280 self._metric_inc(PLUGIN_VOTING_DECISION, hook=hook, decision="deny")
281 self._emit_handler_denied(entry, reason=exc.reason)
282 return Deny(exc.reason)
283 except Exception as exc:
284 self._emit_handler_error(entry, exc)
285 raise PluginExecutionError(
286 f"handler {entry.handler_name!r} failed for hook {hook!r}: {exc}"
287 ) from exc
288 if result is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 raise PluginExecutionError(
290 f"handler {entry.handler_name!r} returned None; expected VotingDecision"
291 )
292 if isinstance(result, Deny):
293 self._metric_inc(PLUGIN_VOTING_DECISION, hook=hook, decision="deny")
294 self._emit_handler_denied(entry, reason=result.reason)
295 return result
296 if not isinstance(result, Allow):
297 raise PluginExecutionError(
298 f"handler {entry.handler_name!r} returned {type(result).__name__}; "
299 "expected Allow or Deny"
300 )
301 self._metric_inc(PLUGIN_VOTING_DECISION, hook=hook, decision="allow")
302 return ALLOW_SINGLETON
304 def fire_filter_chain(self, hook: str, value: T, **kwargs: Any) -> T:
305 self._ensure_semantic(hook, HookSemantic.FILTER_CHAIN)
306 with self._observe_hook(hook):
307 result: T = value
308 for entry in self._handlers[hook]:
309 try:
310 next_result = self._invoke_handler(entry, result, **kwargs)
311 except Exception as exc:
312 self._emit_handler_error(entry, exc)
313 raise PluginExecutionError(
314 f"handler {entry.handler_name!r} failed for hook {hook!r}: {exc}"
315 ) from exc
316 if next_result is None:
317 raise PluginExecutionError(
318 f"handler {entry.handler_name!r} returned None for "
319 f"filter-chain hook {hook!r}"
320 )
321 result = cast(T, next_result)
322 return result
324 def fire_score_delta(
325 self, hook: str, scored_results: list[Any], **kwargs: Any
326 ) -> dict[str, float]:
327 self._ensure_semantic(hook, HookSemantic.SCORE_DELTA)
328 with self._observe_hook(hook):
329 combined: dict[str, float] = {}
330 for entry in self._handlers[hook]:
331 try:
332 deltas = self._invoke_handler(entry, scored_results, **kwargs)
333 except Exception as exc:
334 self._emit_handler_error(entry, exc)
335 raise PluginExecutionError(
336 f"handler {entry.handler_name!r} failed for hook {hook!r}: {exc}"
337 ) from exc
338 if not isinstance(deltas, dict):
339 raise PluginExecutionError(
340 f"handler {entry.handler_name!r} returned {type(deltas).__name__}; "
341 "expected dict[str, float]"
342 )
343 for fact_id, delta in deltas.items():
344 combined[str(fact_id)] = combined.get(str(fact_id), 0.0) + float(delta)
345 return combined
347 def fire_fire_and_forget(self, hook: str, **kwargs: Any) -> None:
348 spec = self._ensure_semantic(hook, HookSemantic.FIRE_AND_FORGET)
349 with self._observe_hook(hook):
350 for entry in self._handlers[hook]:
351 try:
352 self._invoke_handler(entry, **kwargs)
353 except Exception as exc:
354 logger.warning(
355 "handler %r failed for hook %r", entry.handler_name, hook, exc_info=True
356 )
357 self._emit_handler_error(entry, exc)
358 if spec.strict_audit:
359 raise PluginExecutionError(
360 f"handler {entry.handler_name!r} failed for audit hook {hook!r}: {exc}"
361 ) from exc
363 def handlers_for(self, hook: str) -> tuple[HandlerEntry, ...]:
364 self._require_known_hook(hook)
365 return self._handlers[hook]
367 def registered_plugins(self) -> frozenset[str]:
368 return frozenset(self._plugin_names)
370 def plugin_registration_order(self) -> tuple[str, ...]:
371 return tuple(self._plugin_order)
373 def plugin_versions(self) -> dict[str, str]:
374 return dict(self._plugin_versions)
376 def plugin_infos(self) -> tuple[PluginInfo, ...]:
377 return tuple(self._plugin_infos[name] for name in self._plugin_order)
379 def plugin_info(self, name: str) -> PluginInfo | None:
380 return self._plugin_infos.get(name)
382 def plugin_signing_metadata(self, name: str) -> dict[str, Any]:
383 return dict(self._plugin_signing_metadata.get(name, {}))
385 def development_unsigned_plugins(self) -> tuple[str, ...]:
386 return tuple(
387 name
388 for name in self._plugin_order
389 if self._plugin_signing_metadata.get(name, {}).get("trust_decision")
390 == "development_unsigned_override"
391 )
393 def poll_plugin_health(self) -> tuple[PluginHealthReport, ...]:
394 """Run lifecycle health checks and record the latest report per plugin.
396 Health is informational for PR 4-INF.2; unhealthy plugins remain
397 registered and their handlers stay active until a future policy layer
398 chooses otherwise.
399 """
400 reports = tuple(self._poll_one_plugin_health(name) for name in self._plugin_order)
401 self._plugin_health_reports.update({report.plugin_name: report for report in reports})
402 return reports
404 def plugin_health_reports(self) -> tuple[PluginHealthReport, ...]:
405 return tuple(
406 report
407 for name in self._plugin_order
408 if (report := self._plugin_health_reports.get(name)) is not None
409 )
411 def freeze(self) -> None:
412 """Reject subsequent startup-time mutations.
414 Handler collections are stored as tuples throughout registration; freeze
415 records the startup boundary so runtime dispatch remains read-only.
416 """
417 with self._lock:
418 self._frozen = True
419 if self._timeout_executor is None and self._has_timeout_handlers(): 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true
420 self._timeout_executor = ThreadPoolExecutor(
421 max_workers=max(1, self._timeout_handler_count()),
422 thread_name_prefix="stigmem-plugin-timeout",
423 )
425 def _add_handler(
426 self,
427 *,
428 hook: str,
429 handler: Callable[..., Any],
430 plugin_name: str,
431 handler_name: str,
432 ctx: PluginContext,
433 is_core: bool,
434 async_safe: bool,
435 ) -> None:
436 self._require_known_hook(hook)
437 entry = HandlerEntry(
438 hook=hook,
439 handler=handler,
440 plugin_name=plugin_name,
441 handler_name=handler_name,
442 ctx=ctx,
443 is_core=is_core,
444 async_safe=async_safe,
445 timeout_seconds=self._timeout_for_handler(handler),
446 )
447 existing = list(self._handlers[hook])
448 existing.append(entry)
449 existing.sort(key=lambda item: self._sort_key(hook, item))
450 self._handlers[hook] = tuple(existing)
451 self._metric_set(PLUGIN_HANDLERS_PER_HOOK, len(self._handlers[hook]), hook=hook)
453 def _invoke_handler(self, entry: HandlerEntry, *args: Any, **kwargs: Any) -> Any:
454 timeout_seconds = entry.timeout_seconds
455 if timeout_seconds is not None:
456 return self._invoke_handler_with_timeout(entry, timeout_seconds, *args, **kwargs)
457 if not self._emit_metrics:
458 return entry.handler(entry.ctx, *args, **kwargs)
459 self._metric_inc(PLUGIN_HANDLER_INVOCATION, hook=entry.hook, plugin=entry.plugin_name)
460 start = time.perf_counter()
461 try:
462 return entry.handler(entry.ctx, *args, **kwargs)
463 except Exception as exc:
464 self._metric_inc(
465 PLUGIN_HANDLER_ERROR,
466 hook=entry.hook,
467 plugin=entry.plugin_name,
468 error_type=type(exc).__name__,
469 )
470 raise
471 finally:
472 self._metric_observe(
473 PLUGIN_HANDLER_DURATION,
474 time.perf_counter() - start,
475 hook=entry.hook,
476 plugin=entry.plugin_name,
477 )
479 def _invoke_handler_with_timeout(
480 self, entry: HandlerEntry, timeout_seconds: float, *args: Any, **kwargs: Any
481 ) -> Any:
482 executor = self._timeout_executor
483 if executor is None: 483 ↛ 489line 483 didn't jump to line 489 because the condition on line 483 was always true
484 executor = ThreadPoolExecutor(
485 max_workers=1,
486 thread_name_prefix="stigmem-plugin-timeout",
487 )
488 self._timeout_executor = executor
489 self._metric_inc(PLUGIN_HANDLER_INVOCATION, hook=entry.hook, plugin=entry.plugin_name)
490 start = time.perf_counter()
491 future = executor.submit(entry.handler, entry.ctx, *args, **kwargs)
492 try:
493 return future.result(timeout=timeout_seconds)
494 except FutureTimeoutError as exc:
495 future.cancel()
496 timeout_exc = PluginExecutionError(
497 f"handler {entry.handler_name!r} timed out for hook {entry.hook!r} "
498 f"after {timeout_seconds:.3f}s"
499 )
500 self._metric_inc(
501 PLUGIN_HANDLER_ERROR,
502 hook=entry.hook,
503 plugin=entry.plugin_name,
504 error_type="timeout",
505 )
506 raise timeout_exc from exc
507 except Exception as exc:
508 self._metric_inc(
509 PLUGIN_HANDLER_ERROR,
510 hook=entry.hook,
511 plugin=entry.plugin_name,
512 error_type=type(exc).__name__,
513 )
514 raise
515 finally:
516 self._metric_observe(
517 PLUGIN_HANDLER_DURATION,
518 time.perf_counter() - start,
519 hook=entry.hook,
520 plugin=entry.plugin_name,
521 )
523 @contextlib.contextmanager
524 def _observe_hook(self, hook: str) -> Any:
525 if not self._emit_metrics:
526 yield
527 return
528 self._metric_inc(PLUGIN_HOOK_FIRE, hook=hook)
529 start = time.perf_counter()
530 try:
531 yield
532 finally:
533 self._metric_observe(PLUGIN_HOOK_DURATION, time.perf_counter() - start, hook=hook)
535 def _metric_inc(self, metric: Any, **labels: str) -> None:
536 if not self._emit_metrics:
537 return
538 try:
539 metric.labels(**labels).inc()
540 except Exception as exc:
541 logger.debug("failed to increment plugin metric %s: %s", metric, exc)
543 def _metric_observe(self, metric: Any, value: float, **labels: str) -> None:
544 if not self._emit_metrics:
545 return
546 try:
547 metric.labels(**labels).observe(value)
548 except Exception as exc:
549 logger.debug("failed to observe plugin metric %s: %s", metric, exc)
551 def _metric_set(self, metric: Any, value: int, **labels: str) -> None:
552 if not self._emit_metrics:
553 return
554 try:
555 if labels:
556 metric.labels(**labels).set(value)
557 else:
558 metric.set(value)
559 except Exception as exc:
560 logger.debug("failed to set plugin metric %s: %s", metric, exc)
562 def _emit_handler_denied(self, entry: HandlerEntry, *, reason: str) -> None:
563 self._emit_registry_audit(
564 "plugin.handler_denied",
565 plugin_name=entry.plugin_name,
566 target_uri=f"plugin:{entry.plugin_name}",
567 metadata={
568 "plugin_name": entry.plugin_name,
569 "hook": entry.hook,
570 "handler_name": entry.handler_name,
571 "reason": reason,
572 },
573 failure_reason=reason,
574 )
576 def _emit_handler_error(self, entry: HandlerEntry, exc: Exception) -> None:
577 self._emit_registry_audit(
578 "plugin.handler_error",
579 plugin_name=entry.plugin_name,
580 target_uri=f"plugin:{entry.plugin_name}",
581 metadata={
582 "plugin_name": entry.plugin_name,
583 "hook": entry.hook,
584 "handler_name": entry.handler_name,
585 "error_type": type(exc).__name__,
586 "error": str(exc),
587 },
588 failure_reason=str(exc),
589 exception_type=type(exc).__name__,
590 )
592 def _emit_registry_audit(
593 self,
594 event_type: str,
595 *,
596 manifest: PluginManifest | None = None,
597 plugin_name: str | None = None,
598 target_uri: str | None = None,
599 reason: str | None = None,
600 validation_failure: str | None = None,
601 metadata: dict[str, Any] | None = None,
602 failure_reason: str | None = None,
603 exception_type: str | None = None,
604 ) -> None:
605 if self._emitting_registry_audit:
606 return
607 name = plugin_name or (manifest.name if manifest is not None else "unknown")
608 event_metadata = dict(metadata or {})
609 if manifest is not None:
610 event_metadata.update(
611 {
612 "plugin_name": manifest.name,
613 "version": manifest.version,
614 "capabilities": sorted(manifest.capabilities),
615 "hooks": sorted(manifest.hooks),
616 "async_safe": manifest.async_safe,
617 "signed_by": event_metadata.get("signed_by", "unsigned"),
618 "requires_stigmem": manifest.requires_stigmem,
619 }
620 )
621 if reason is not None:
622 event_metadata["reason"] = reason
623 if validation_failure is not None:
624 event_metadata["validation_failure"] = validation_failure
625 outcome = (
626 Failure(
627 reason=failure_reason or reason or "plugin audit event",
628 exception_type=exception_type,
629 )
630 if event_type.endswith("_failed") or failure_reason is not None
631 else Success()
632 )
633 self._emitting_registry_audit = True
634 try:
635 self.fire_fire_and_forget(
636 "audit_emit",
637 event=AuditEvent(
638 event_type=event_type,
639 actor_uri="system:plugin-registry",
640 target_uri=target_uri or f"plugin:{name}",
641 tenant_id="system",
642 timestamp=datetime.now(UTC),
643 outcome=outcome,
644 metadata=event_metadata,
645 ),
646 )
647 except Exception:
648 logger.warning(
649 "failed to emit plugin registry audit event %r", event_type, exc_info=True
650 )
651 finally:
652 self._emitting_registry_audit = False
654 def _sort_key(self, hook: str, entry: HandlerEntry) -> tuple[int, str]:
655 policy = HOOK_SPECS[hook].ordering
656 if policy in (HookOrdering.CORE_FIRST, HookOrdering.CORE_ONLY_DEFAULT):
657 partition = 0 if entry.is_core else 1
658 elif policy == HookOrdering.PLUGINS_FIRST:
659 partition = 1 if entry.is_core else 0
660 else:
661 partition = 0
662 return partition, entry.handler_name
664 def _timeout_for_handler(self, handler: Callable[..., Any]) -> float | None:
665 timeout = getattr(handler, "__plugin_timeout__", None)
666 if timeout is None:
667 timeout = self._handler_timeout_seconds
668 if timeout is None:
669 return None
670 return _validate_timeout_seconds(timeout)
672 def _has_timeout_handlers(self) -> bool:
673 return any(
674 entry.timeout_seconds is not None
675 for entries in self._handlers.values()
676 for entry in entries
677 )
679 def _timeout_handler_count(self) -> int:
680 return sum(
681 1
682 for entries in self._handlers.values()
683 for entry in entries
684 if entry.timeout_seconds is not None
685 )
687 def _poll_one_plugin_health(self, plugin_name: str) -> PluginHealthReport:
688 checked_at = datetime.now(UTC)
689 version = self._plugin_versions[plugin_name]
690 checker = self._plugin_health_checks.get(plugin_name)
691 if checker is None:
692 return PluginHealthReport(
693 plugin_name=plugin_name,
694 plugin_version=version,
695 status=PluginHealthStatus.UNKNOWN,
696 message="no health check registered",
697 checked_at=checked_at,
698 )
699 try:
700 result = checker(self._plugin_contexts[plugin_name])
701 except Exception as exc:
702 return PluginHealthReport(
703 plugin_name=plugin_name,
704 plugin_version=version,
705 status=PluginHealthStatus.UNHEALTHY,
706 message=str(exc),
707 checked_at=checked_at,
708 error_summary=f"{type(exc).__name__}: {exc}",
709 )
710 if not isinstance(result, PluginHealth): 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true
711 return PluginHealthReport(
712 plugin_name=plugin_name,
713 plugin_version=version,
714 status=PluginHealthStatus.UNHEALTHY,
715 message=f"health_check returned {type(result).__name__}; expected PluginHealth",
716 checked_at=checked_at,
717 error_summary=f"invalid result: {type(result).__name__}",
718 )
719 return PluginHealthReport(
720 plugin_name=plugin_name,
721 plugin_version=version,
722 status=result.status,
723 message=result.message,
724 checked_at=checked_at,
725 )
727 def _validate_manifest_compatibility(self, manifest: PluginManifest) -> None:
728 try:
729 specifier = SpecifierSet(manifest.requires_stigmem)
730 except InvalidSpecifier as exc:
731 raise ManifestError(
732 f"plugin {manifest.name!r} has invalid requires_stigmem "
733 f"{manifest.requires_stigmem!r}: {exc}"
734 ) from exc
735 current_version = Version(_current_stigmem_version())
736 if not specifier.contains(current_version, prereleases=True):
737 raise ManifestError(
738 f"plugin {manifest.name!r} requires stigmem {manifest.requires_stigmem!r}, "
739 f"but current version is {current_version}"
740 )
742 def _validate_manifest_handler_signatures(self, manifest: PluginManifest) -> None:
743 for hook, handler in manifest.hooks.items():
744 semantic = HOOK_SPECS[hook].semantic
745 required_positional = (
746 2
747 if semantic
748 in (
749 HookSemantic.FILTER_CHAIN,
750 HookSemantic.SCORE_DELTA,
751 )
752 else 1
753 )
754 if not _accepts_positional_args(handler, required_positional):
755 raise ManifestError(
756 f"handler for hook {hook!r} on plugin {manifest.name!r} must accept "
757 f"at least {required_positional} positional argument"
758 f"{'' if required_positional == 1 else 's'}"
759 )
761 def _ensure_mutable(self) -> None:
762 if self._frozen:
763 raise RegistryFrozenError("registry is frozen; cannot register handlers")
765 def _ensure_semantic(self, hook: str, semantic: HookSemantic) -> Any:
766 spec = self._require_known_hook(hook)
767 if spec.semantic != semantic: 767 ↛ 768line 767 didn't jump to line 768 because the condition on line 767 was never true
768 raise PluginExecutionError(
769 f"hook {hook!r} has semantic {spec.semantic}; cannot fire as {semantic}"
770 )
771 return spec
773 def _require_known_hook(self, hook: str) -> Any:
774 try:
775 return HOOK_SPECS[hook]
776 except KeyError as exc:
777 raise ManifestError(f"unknown hook {hook!r}") from exc
780_REGISTRY = HookRegistry()
783def get_registry() -> HookRegistry:
784 return _REGISTRY
787def set_registry(registry: HookRegistry) -> HookRegistry:
788 """Replace the process registry, returning the previous instance."""
789 global _REGISTRY
790 previous = _REGISTRY
791 _REGISTRY = registry
792 return previous
795def register_core_handler(
796 hook: str,
797 handler: Callable[..., Any],
798 *,
799 name: str,
800 capabilities: frozenset[str] | None = None,
801) -> None:
802 get_registry().register_core_handler(hook, handler, name=name, capabilities=capabilities)
805def _current_stigmem_version() -> str:
806 try:
807 from stigmem_node import __version__
809 return __version__
810 except ImportError:
811 pass
812 try:
813 return version("stigmem-node")
814 except PackageNotFoundError:
815 return _FALLBACK_STIGMEM_VERSION
818def _registration_audit_metadata(
819 *,
820 discovery_source: dict[str, Any] | None,
821 signing_identity: str,
822 signing_metadata: dict[str, Any] | None = None,
823) -> dict[str, Any]:
824 metadata = {
825 "discovery_source": discovery_source or {"type": "manual"},
826 "signed_by": signing_identity,
827 }
828 if signing_metadata is not None:
829 metadata["signing"] = dict(signing_metadata)
830 return metadata
833def _accepts_positional_args(handler: Callable[..., Any], required_count: int) -> bool:
834 try:
835 signature = inspect.signature(handler)
836 except (TypeError, ValueError):
837 return False
838 positional_count = 0
839 for parameter in signature.parameters.values():
840 if parameter.kind == inspect.Parameter.VAR_POSITIONAL:
841 return True
842 if parameter.kind in (
843 inspect.Parameter.POSITIONAL_ONLY,
844 inspect.Parameter.POSITIONAL_OR_KEYWORD,
845 ):
846 positional_count += 1
847 return positional_count >= required_count
850def _validate_timeout_seconds(value: float) -> float:
851 timeout = float(value)
852 if timeout <= 0: 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true
853 raise ValueError("handler timeout must be positive")
854 if timeout > 30: 854 ↛ 855line 854 didn't jump to line 855 because the condition on line 854 was never true
855 raise ValueError("handler timeout cannot exceed 30 seconds")
856 return timeout