From 521a552e562377ad966f7dca1e7b05bdc498aa75 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Tue, 10 Mar 2026 02:39:25 +0000 Subject: [PATCH] feat(extensibility): implement Plugin Architecture Framework with module:ClassName resolution Implement plugin architecture framework enabling custom implementations of tools, strategies, sandbox strategies, index backends, and pipeline components through module:ClassName resolution and entry point discovery. Key changes: - Add infrastructure/plugins/ package with PluginLoader, PluginManager, types, exceptions - Implement dynamic import via module:ClassName with security prefix allowlist - Add entry point discovery via importlib.metadata - Add Protocol validation for loaded plugin classes - Add plugin lifecycle management (discover/activate/deactivate) - Add config-driven registration (custom_module + custom_class + custom_options) - Register PluginManager in DI container - Add comprehensive Behave BDD tests, Robot integration tests, ASV benchmarks ISSUES CLOSED: #585 --- benchmarks/bench_plugin_loader.py | 180 ++++ .../extensibility/plugin_architecture.feature | 383 ++++++++ features/steps/plugin_architecture_steps.py | 841 ++++++++++++++++++ robot/helper_plugin_architecture.py | 292 ++++++ robot/plugin_architecture.robot | 89 ++ src/cleveragents/application/container.py | 4 + .../infrastructure/plugins/__init__.py | 46 + .../infrastructure/plugins/exceptions.py | 34 + .../infrastructure/plugins/loader.py | 272 ++++++ .../infrastructure/plugins/manager.py | 403 +++++++++ .../infrastructure/plugins/types.py | 145 +++ 11 files changed, 2689 insertions(+) create mode 100644 benchmarks/bench_plugin_loader.py create mode 100644 features/extensibility/plugin_architecture.feature create mode 100644 features/steps/plugin_architecture_steps.py create mode 100644 robot/helper_plugin_architecture.py create mode 100644 robot/plugin_architecture.robot create mode 100644 src/cleveragents/infrastructure/plugins/__init__.py create mode 100644 src/cleveragents/infrastructure/plugins/exceptions.py create mode 100644 src/cleveragents/infrastructure/plugins/loader.py create mode 100644 src/cleveragents/infrastructure/plugins/manager.py create mode 100644 src/cleveragents/infrastructure/plugins/types.py diff --git a/benchmarks/bench_plugin_loader.py b/benchmarks/bench_plugin_loader.py new file mode 100644 index 000000000..8b7cd6b42 --- /dev/null +++ b/benchmarks/bench_plugin_loader.py @@ -0,0 +1,180 @@ +"""ASV benchmarks for Plugin Architecture Framework. + +Measures the performance of: +- PluginLoader.load_class() import overhead +- PluginLoader.validate_protocol() check overhead +- PluginManager registration, activation, deactivation overhead +- PluginDescriptor construction overhead +- Config-driven registration overhead +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload so ASV picks up the source tree version. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.domain.models.acms.backends import TextBackend, TextResult # noqa: E402 +from cleveragents.infrastructure.plugins.loader import PluginLoader # noqa: E402 +from cleveragents.infrastructure.plugins.manager import PluginManager # noqa: E402 +from cleveragents.infrastructure.plugins.types import ( # noqa: E402 + ExtensionPoint, + PluginDescriptor, + PluginState, +) + + +# --------------------------------------------------------------------------- +# Helper classes +# --------------------------------------------------------------------------- + + +class _FakeTextBackend: + """Minimal class satisfying the TextBackend protocol.""" + + def search( + self, + query: str, + *, + scope: frozenset[str], + max_results: int = 20, + ) -> list[TextResult]: + return [] + + +# --------------------------------------------------------------------------- +# PluginLoader benchmarks +# --------------------------------------------------------------------------- + + +class PluginLoaderSuite: + """Benchmark PluginLoader class loading overhead.""" + + timeout = 60 + + def setup(self) -> None: + self.loader = PluginLoader() + + def time_load_class(self) -> None: + self.loader.load_class( + "cleveragents.domain.models.acms.stubs", + "InMemoryTextBackend", + ) + + def time_validate_protocol_pass(self) -> None: + PluginLoader.validate_protocol(_FakeTextBackend, TextBackend) + + +# --------------------------------------------------------------------------- +# PluginDescriptor benchmarks +# --------------------------------------------------------------------------- + + +class PluginDescriptorSuite: + """Benchmark PluginDescriptor construction overhead.""" + + timeout = 60 + + def time_create_minimal(self) -> None: + PluginDescriptor(name="bench-plugin") + + def time_create_full(self) -> None: + PluginDescriptor( + name="bench-full", + version="1.0.0", + author="bench", + description="benchmark plugin", + module_path="cleveragents.test", + class_name="TestClass", + extension_points=["ep1", "ep2"], + dependencies=["dep1"], + state=PluginState.DISCOVERED, + ) + + +# --------------------------------------------------------------------------- +# PluginManager benchmarks +# --------------------------------------------------------------------------- + + +class PluginManagerSuite: + """Benchmark PluginManager registration and lifecycle overhead.""" + + timeout = 60 + + def setup(self) -> None: + self.manager = PluginManager() + self._counter = 0 + + def time_register_plugin(self) -> None: + self._counter += 1 + desc = PluginDescriptor( + name=f"bench-reg-{self._counter}", + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + self.manager.register_plugin(desc) + + def time_register_from_config(self) -> None: + self._counter += 1 + config = { + "custom_module": "cleveragents.domain.models.acms.stubs", + "custom_class": "InMemoryTextBackend", + "name": f"bench-cfg-{self._counter}", + } + self.manager.register_from_config(config) + + def time_list_plugins(self) -> None: + self.manager.list_plugins() + + +class PluginManagerLifecycleSuite: + """Benchmark activate/deactivate cycle.""" + + timeout = 60 + + def setup(self) -> None: + self.manager = PluginManager() + self._counter = 0 + + def time_activate_deactivate_cycle(self) -> None: + self._counter += 1 + name = f"bench-life-{self._counter}" + desc = PluginDescriptor( + name=name, + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + self.manager.register_plugin(desc) + self.manager.activate_plugin(name) + self.manager.deactivate_plugin(name) + + +# --------------------------------------------------------------------------- +# ExtensionPoint benchmarks +# --------------------------------------------------------------------------- + + +class ExtensionPointSuite: + """Benchmark ExtensionPoint creation overhead.""" + + timeout = 60 + + def time_create_extension_point(self) -> None: + ExtensionPoint( + name="bench-ep", + protocol_type=TextBackend, + description="Benchmark extension point", + registry_key="bench_key", + ) diff --git a/features/extensibility/plugin_architecture.feature b/features/extensibility/plugin_architecture.feature new file mode 100644 index 000000000..ad8feff87 --- /dev/null +++ b/features/extensibility/plugin_architecture.feature @@ -0,0 +1,383 @@ +@extensibility @plugin_architecture +Feature: Plugin Architecture Framework with module:ClassName resolution + As a CleverAgents developer + I want a plugin architecture that loads custom implementations via + module:ClassName resolution and entry-point discovery + So that tools, strategies, backends, and pipeline components can be + extended without modifying core code + + # --------------------------------------------------------------------------- + # PluginState enum + # --------------------------------------------------------------------------- + + @plugin_state + Scenario: PluginState enum has all required values + Given the PluginState enum is available + Then it should have values "discovered", "activated", "executing", "deactivated", "errored" + + @plugin_state + Scenario: PluginState values are strings + Given the PluginState enum is available + Then each PluginState value should be a string + + # --------------------------------------------------------------------------- + # ExtensionPoint model + # --------------------------------------------------------------------------- + + @extension_point + Scenario: ExtensionPoint creation with required fields + Given I create an ExtensionPoint with name "test-ep" and a protocol type + Then the ExtensionPoint name should be "test-ep" + And the ExtensionPoint should have a protocol_type + + @extension_point + Scenario: ExtensionPoint is frozen + Given I create an ExtensionPoint with name "frozen-ep" and a protocol type + Then attempting to mutate the ExtensionPoint name should raise an error + + @extension_point + Scenario: ExtensionPoint rejects empty name + When I attempt to create an ExtensionPoint with an empty name + Then a plugin validation error should be raised + + # --------------------------------------------------------------------------- + # PluginDescriptor model + # --------------------------------------------------------------------------- + + @plugin_descriptor + Scenario: PluginDescriptor creation with defaults + Given I create a PluginDescriptor with name "my-plugin" + Then the descriptor state should be "discovered" + And the descriptor version should be "0.0.0" + And the descriptor dependencies should be empty + + @plugin_descriptor + Scenario: PluginDescriptor with full metadata + Given I create a PluginDescriptor with full metadata + Then the descriptor should have name "full-plugin" + And the descriptor should have version "1.2.3" + And the descriptor should have author "TestAuthor" + And the descriptor should have module_path "cleveragents.test" + And the descriptor should have class_name "TestClass" + + @plugin_descriptor + Scenario: PluginDescriptor state is mutable + Given I create a PluginDescriptor with name "mutable-plugin" + When I set the descriptor state to "activated" + Then the descriptor state should be "activated" + + @plugin_descriptor + Scenario: PluginDescriptor rejects empty name + When I attempt to create a PluginDescriptor with an empty name + Then a plugin validation error should be raised + + # --------------------------------------------------------------------------- + # Plugin exceptions + # --------------------------------------------------------------------------- + + @plugin_exceptions + Scenario: PluginError is base exception + Then PluginLoadError should be a subclass of PluginError + And PluginNotFoundError should be a subclass of PluginError + And ProtocolMismatchError should be a subclass of PluginError + + @plugin_exceptions + Scenario: PluginLoadError carries message + When I raise a PluginLoadError with message "test error" + Then the exception message should contain "test error" + + @plugin_exceptions + Scenario: PluginNotFoundError carries message + When I raise a PluginNotFoundError with message "not found" + Then the exception message should contain "not found" + + @plugin_exceptions + Scenario: ProtocolMismatchError carries message + When I raise a ProtocolMismatchError with message "mismatch" + Then the exception message should contain "mismatch" + + # --------------------------------------------------------------------------- + # PluginLoader — load_class success and failure + # --------------------------------------------------------------------------- + + @plugin_loader @success + Scenario: PluginLoader loads a valid class + Given a PluginLoader with default prefixes + When I load class "InMemoryTextBackend" from module "cleveragents.domain.models.acms.stubs" + Then the loaded class should not be None + And the loaded class name should be "InMemoryTextBackend" + + @plugin_loader @success + Scenario: PluginLoader loads another valid class + Given a PluginLoader with default prefixes + When I load class "InMemoryVectorBackend" from module "cleveragents.domain.models.acms.stubs" + Then the loaded class should not be None + + @plugin_loader @failure + Scenario: PluginLoader rejects module outside allowed prefix + Given a PluginLoader with default prefixes + When I attempt to load class "Path" from module "pathlib" + Then a PluginLoadError should be raised + And the plugin error message should contain "not in the allowed prefix list" + + @plugin_loader @failure + Scenario: PluginLoader fails on nonexistent module + Given a PluginLoader with default prefixes + When I attempt to load class "Foo" from module "cleveragents.nonexistent_module_xyz" + Then a PluginLoadError should be raised + And the plugin error message should contain "Cannot import module" + + @plugin_loader @failure + Scenario: PluginLoader fails on nonexistent class + Given a PluginLoader with default prefixes + When I attempt to load class "NonExistentClassXyz" from module "cleveragents.domain.models.acms.stubs" + Then a PluginLoadError should be raised + And the plugin error message should contain "not found in module" + + @plugin_loader @security + Scenario: PluginLoader with custom prefixes allows matching module + Given a PluginLoader with allowed prefixes "cleveragents.,mypackage." + When I load class "InMemoryTextBackend" from module "cleveragents.domain.models.acms.stubs" + Then the loaded class should not be None + + @plugin_loader @security + Scenario: PluginLoader with empty prefix allowlist allows anything + Given a PluginLoader with empty prefix allowlist + When I load class "Path" from module "pathlib" + Then the loaded class should not be None + + @plugin_loader + Scenario: PluginLoader allowed_prefixes property + Given a PluginLoader with default prefixes + Then the loader allowed_prefixes should contain "cleveragents." + + @plugin_loader @failure + Scenario: PluginLoader rejects non-class attribute + Given a PluginLoader with default prefixes + When I attempt to load class "logger" from module "cleveragents.infrastructure.plugins.loader" + Then a PluginLoadError should be raised + And the plugin error message should contain "is not a class" + + # --------------------------------------------------------------------------- + # Protocol validation + # --------------------------------------------------------------------------- + + @protocol_validation @success + Scenario: validate_protocol passes for conforming class + Given a class that implements TextBackend protocol + When I validate it against the TextBackend protocol + Then the validation should return True + + @protocol_validation @failure + Scenario: validate_protocol fails for non-conforming class + Given a class that does not implement any protocol + When I attempt to validate it against the TextBackend protocol + Then a ProtocolMismatchError should be raised + + # --------------------------------------------------------------------------- + # Entry-point discovery + # --------------------------------------------------------------------------- + + @entry_points + Scenario: load_from_entry_points returns empty for unknown group + Given a PluginLoader with default prefixes + When I discover plugins from entry point group "cleveragents.nonexistent_group_xyz" + Then the discovered plugin list should be empty + + @entry_points @mock + Scenario: load_from_entry_points discovers mocked entry points + Given a PluginLoader with default prefixes + And a mocked entry point group "cleveragents.plugins" with entry "test-ep=cleveragents.domain.models.acms.stubs:InMemoryTextBackend" + When I discover plugins from the mocked entry point group + Then the discovered plugin list should have 1 entry + And the first descriptor name should be "test-ep" + + # --------------------------------------------------------------------------- + # PluginManager — lifecycle + # --------------------------------------------------------------------------- + + @plugin_manager @registration + Scenario: PluginManager registers and retrieves a plugin + Given a fresh PluginManager instance + And a PluginDescriptor for "test-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + Then get_plugin should return the descriptor for "test-plugin" + And the plugin state should be "discovered" + + @plugin_manager @registration + Scenario: PluginManager rejects duplicate registration + Given a fresh PluginManager instance + And a PluginDescriptor for "dup-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I attempt to register the same descriptor again + Then a PluginError should be raised + + @plugin_manager @listing + Scenario: PluginManager lists all registered plugins + Given a fresh PluginManager instance + And I register plugins named "alpha", "beta", "gamma" + Then list_plugins should return 3 descriptors + + @plugin_manager @not_found + Scenario: PluginManager raises on unknown plugin + Given a fresh PluginManager instance + When I attempt to get plugin "nonexistent" + Then a PluginNotFoundError should be raised + + @plugin_manager @activate + Scenario: PluginManager activates a registered plugin + Given a fresh PluginManager instance + And a PluginDescriptor for "act-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "act-plugin" + Then the plugin state should be "activated" + And get_plugin_class for "act-plugin" should not be None + And get_plugin_instance for "act-plugin" should not be None + + @plugin_manager @activate + Scenario: PluginManager rejects activating already-activated plugin + Given a fresh PluginManager instance + And a PluginDescriptor for "double-act" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "double-act" + And I attempt to activate the plugin "double-act" again + Then a PluginError should be raised + + @plugin_manager @activate @failure + Scenario: PluginManager transitions to ERRORED on activation failure + Given a fresh PluginManager instance + And a PluginDescriptor for "bad-plugin" with module "cleveragents.nonexistent_module_xyz" and class "BadClass" + When I register the plugin descriptor + And I attempt to activate the plugin "bad-plugin" + Then a PluginLoadError should be raised + And the plugin "bad-plugin" state should be "errored" + + @plugin_manager @activate @failure + Scenario: PluginManager fails activation with empty module_path + Given a fresh PluginManager instance + And a PluginDescriptor for "no-path" with empty module_path + When I register the plugin descriptor + And I attempt to activate the plugin "no-path" + Then a PluginLoadError should be raised + + @plugin_manager @deactivate + Scenario: PluginManager deactivates an activated plugin + Given a fresh PluginManager instance + And a PluginDescriptor for "deact-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "deact-plugin" + And I deactivate the plugin "deact-plugin" + Then the plugin state should be "deactivated" + And get_plugin_class for "deact-plugin" should be None + And get_plugin_instance for "deact-plugin" should be None + + @plugin_manager @deactivate + Scenario: PluginManager rejects deactivating a discovered plugin + Given a fresh PluginManager instance + And a PluginDescriptor for "disc-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I attempt to deactivate the plugin "disc-plugin" + Then a PluginError should be raised + + @plugin_manager @deactivate + Scenario: PluginManager can deactivate an errored plugin + Given a fresh PluginManager instance + And a PluginDescriptor for "err-plugin" with module "cleveragents.nonexistent_module_xyz" and class "BadClass" + When I register the plugin descriptor + And I attempt to activate the plugin "err-plugin" ignoring error + And I deactivate the plugin "err-plugin" + Then the plugin state should be "deactivated" + + # --------------------------------------------------------------------------- + # Config-driven registration + # --------------------------------------------------------------------------- + + @plugin_manager @config + Scenario: PluginManager registers from config dict + Given a fresh PluginManager instance + When I register a plugin from config with custom_module "cleveragents.domain.models.acms.stubs" and custom_class "InMemoryTextBackend" + Then the registered plugin descriptor should not be None + And the plugin should be in the manager registry + + @plugin_manager @config + Scenario: PluginManager ignores config without plugin keys + Given a fresh PluginManager instance + When I register a plugin from config with no custom_module or custom_class + Then the registered plugin descriptor should be None + + @plugin_manager @config + Scenario: PluginManager returns existing for duplicate config + Given a fresh PluginManager instance + When I register a plugin from config with custom_module "cleveragents.domain.models.acms.stubs" and custom_class "InMemoryTextBackend" + And I register the same plugin from config again + Then list_plugins should return 1 descriptor + + @plugin_manager @config + Scenario: PluginManager batch registers from config list + Given a fresh PluginManager instance + When I register plugins from a config list of 3 entries + Then list_plugins should return 3 descriptors + + # --------------------------------------------------------------------------- + # Extension point management + # --------------------------------------------------------------------------- + + @plugin_manager @extension_points + Scenario: PluginManager registers and lists extension points + Given a fresh PluginManager instance + When I register an extension point named "text-backend" + Then list_extension_points should return 1 entry + + # --------------------------------------------------------------------------- + # Clear / cleanup + # --------------------------------------------------------------------------- + + @plugin_manager @clear + Scenario: PluginManager clear removes all plugins + Given a fresh PluginManager instance + And I register plugins named "a", "b", "c" + When I clear the plugin manager + Then list_plugins should return 0 descriptors + + # --------------------------------------------------------------------------- + # Thread safety + # --------------------------------------------------------------------------- + + @thread_safety + Scenario: PluginManager supports concurrent registration + Given a fresh PluginManager instance + When 10 threads register plugins concurrently + Then list_plugins should return 10 descriptors + + @thread_safety + Scenario: PluginManager supports concurrent activate and deactivate + Given a fresh PluginManager with 5 registered plugins + When 5 threads activate plugins concurrently + Then all 5 plugins should be in "activated" state + + # --------------------------------------------------------------------------- + # DI container integration + # --------------------------------------------------------------------------- + + @di_container + Scenario: DI container provides PluginManager singleton + When I resolve PluginManager from the DI container + Then the resolved PluginManager should not be None + And resolving PluginManager again should return the same instance + + # --------------------------------------------------------------------------- + # Discovery integration + # --------------------------------------------------------------------------- + + @discover + Scenario: PluginManager discover with no entry points + Given a fresh PluginManager instance + When I call discover with group "cleveragents.nonexistent_ep_group_xyz" + Then the discovered list should be empty + + @discover + Scenario: PluginManager discover registers newly found plugins + Given a fresh PluginManager instance + And a mocked entry point group for discover + When I call discover through the manager + Then newly discovered plugins should be in the registry diff --git a/features/steps/plugin_architecture_steps.py b/features/steps/plugin_architecture_steps.py new file mode 100644 index 000000000..125428a26 --- /dev/null +++ b/features/steps/plugin_architecture_steps.py @@ -0,0 +1,841 @@ +"""Behave step definitions for the Plugin Architecture Framework. + +Covers PluginState, ExtensionPoint, PluginDescriptor, PluginLoader, +PluginManager, protocol validation, entry-point discovery, config-driven +registration, thread safety, and DI container integration. + +Based on issue #585. +""" + +from __future__ import annotations + +import contextlib +import threading +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when # type: ignore[import-untyped] +from behave.runner import Context # type: ignore[import-untyped] +from pydantic import ValidationError + +from cleveragents.domain.models.acms.backends import TextBackend, TextResult +from cleveragents.infrastructure.plugins.exceptions import ( + PluginError, + PluginLoadError, + PluginNotFoundError, + ProtocolMismatchError, +) +from cleveragents.infrastructure.plugins.loader import PluginLoader +from cleveragents.infrastructure.plugins.manager import PluginManager +from cleveragents.infrastructure.plugins.types import ( + ExtensionPoint, + PluginDescriptor, + PluginState, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeTextBackend: + """Minimal class satisfying the TextBackend protocol.""" + + def search( + self, + query: str, + *, + scope: frozenset[str], + max_results: int = 20, + ) -> list[TextResult]: + return [] + + +class _NonProtocolClass: + """A class that does not implement any backend protocol.""" + + def unrelated_method(self) -> str: + return "nope" + + +# --------------------------------------------------------------------------- +# PluginState enum +# --------------------------------------------------------------------------- + + +@given("the PluginState enum is available") +def step_plugin_state_available(context: Context) -> None: + context.plugin_state = PluginState + + +@then( + 'it should have values "discovered", "activated", "executing", "deactivated", "errored"' +) +def step_plugin_state_values(context: Context) -> None: + expected = {"discovered", "activated", "executing", "deactivated", "errored"} + actual = {s.value for s in PluginState} + assert actual == expected, f"Expected {expected}, got {actual}" + + +@then("each PluginState value should be a string") +def step_plugin_state_is_str(context: Context) -> None: + for state in PluginState: + assert isinstance(state.value, str), f"{state} value is not a string" + + +# --------------------------------------------------------------------------- +# ExtensionPoint model +# --------------------------------------------------------------------------- + + +@given('I create an ExtensionPoint with name "{name}" and a protocol type') +def step_create_extension_point(context: Context, name: str) -> None: + context.ext_point = ExtensionPoint( + name=name, + protocol_type=TextBackend, + description="Test extension point", + registry_key="test_key", + ) + + +@then('the ExtensionPoint name should be "{name}"') +def step_ext_point_name(context: Context, name: str) -> None: + assert context.ext_point.name == name + + +@then("the ExtensionPoint should have a protocol_type") +def step_ext_point_protocol(context: Context) -> None: + assert context.ext_point.protocol_type is not None + + +@then("attempting to mutate the ExtensionPoint name should raise an error") +def step_ext_point_frozen(context: Context) -> None: + raised = False + try: + context.ext_point.name = "mutated" # type: ignore[misc] + except (ValidationError, TypeError): + raised = True + assert raised, "Expected frozen model to reject mutation" + + +@when("I attempt to create an ExtensionPoint with an empty name") +def step_create_ext_point_empty_name(context: Context) -> None: + context.raised_validation_error = False + try: + ExtensionPoint(name="", protocol_type=TextBackend) + except ValidationError: + context.raised_validation_error = True + + +@then("a plugin validation error should be raised") +def step_plugin_validation_error_raised(context: Context) -> None: + assert context.raised_validation_error, "Expected ValidationError" + + +# --------------------------------------------------------------------------- +# PluginDescriptor model +# --------------------------------------------------------------------------- + + +@given('I create a PluginDescriptor with name "{name}"') +def step_create_descriptor(context: Context, name: str) -> None: + context.descriptor = PluginDescriptor(name=name) + + +@given("I create a PluginDescriptor with full metadata") +def step_create_descriptor_full(context: Context) -> None: + context.descriptor = PluginDescriptor( + name="full-plugin", + version="1.2.3", + author="TestAuthor", + description="A fully described plugin", + module_path="cleveragents.test", + class_name="TestClass", + extension_points=["ep1"], + dependencies=["dep1"], + ) + + +@then('the descriptor state should be "{state}"') +def step_descriptor_state(context: Context, state: str) -> None: + actual = ( + context.descriptor.state.value + if hasattr(context.descriptor.state, "value") + else context.descriptor.state + ) + assert actual == state, f"Expected state '{state}', got '{actual}'" + + +@then('the descriptor version should be "{version}"') +def step_descriptor_version(context: Context, version: str) -> None: + assert context.descriptor.version == version + + +@then("the descriptor dependencies should be empty") +def step_descriptor_deps_empty(context: Context) -> None: + assert context.descriptor.dependencies == [] + + +@then('the descriptor should have name "{name}"') +def step_descriptor_has_name(context: Context, name: str) -> None: + assert context.descriptor.name == name + + +@then('the descriptor should have version "{version}"') +def step_descriptor_has_version(context: Context, version: str) -> None: + assert context.descriptor.version == version + + +@then('the descriptor should have author "{author}"') +def step_descriptor_has_author(context: Context, author: str) -> None: + assert context.descriptor.author == author + + +@then('the descriptor should have module_path "{path}"') +def step_descriptor_has_module_path(context: Context, path: str) -> None: + assert context.descriptor.module_path == path + + +@then('the descriptor should have class_name "{cls}"') +def step_descriptor_has_class_name(context: Context, cls: str) -> None: + assert context.descriptor.class_name == cls + + +@when('I set the descriptor state to "{state}"') +def step_set_descriptor_state(context: Context, state: str) -> None: + context.descriptor.state = PluginState(state) + + +@when("I attempt to create a PluginDescriptor with an empty name") +def step_create_descriptor_empty(context: Context) -> None: + context.raised_validation_error = False + try: + PluginDescriptor(name="") + except ValidationError: + context.raised_validation_error = True + + +# --------------------------------------------------------------------------- +# Plugin exceptions +# --------------------------------------------------------------------------- + + +@then("PluginLoadError should be a subclass of PluginError") +def step_load_error_subclass(context: Context) -> None: + assert issubclass(PluginLoadError, PluginError) + + +@then("PluginNotFoundError should be a subclass of PluginError") +def step_not_found_subclass(context: Context) -> None: + assert issubclass(PluginNotFoundError, PluginError) + + +@then("ProtocolMismatchError should be a subclass of PluginError") +def step_mismatch_subclass(context: Context) -> None: + assert issubclass(ProtocolMismatchError, PluginError) + + +@when('I raise a PluginLoadError with message "{msg}"') +def step_raise_load_error(context: Context, msg: str) -> None: + try: + raise PluginLoadError(msg) + except PluginLoadError as exc: + context.caught_exception = exc + + +@when('I raise a PluginNotFoundError with message "{msg}"') +def step_raise_not_found_error(context: Context, msg: str) -> None: + try: + raise PluginNotFoundError(msg) + except PluginNotFoundError as exc: + context.caught_exception = exc + + +@when('I raise a ProtocolMismatchError with message "{msg}"') +def step_raise_mismatch_error(context: Context, msg: str) -> None: + try: + raise ProtocolMismatchError(msg) + except ProtocolMismatchError as exc: + context.caught_exception = exc + + +@then('the exception message should contain "{text}"') +def step_exception_contains(context: Context, text: str) -> None: + assert text in str(context.caught_exception), ( + f"Expected '{text}' in '{context.caught_exception}'" + ) + + +# --------------------------------------------------------------------------- +# PluginLoader — load_class +# --------------------------------------------------------------------------- + + +@given("a PluginLoader with default prefixes") +def step_loader_default(context: Context) -> None: + context.loader = PluginLoader() + + +@given('a PluginLoader with allowed prefixes "{prefixes}"') +def step_loader_custom_prefixes(context: Context, prefixes: str) -> None: + prefix_tuple = tuple(p.strip() for p in prefixes.split(",")) + context.loader = PluginLoader(allowed_prefixes=prefix_tuple) + + +@given("a PluginLoader with empty prefix allowlist") +def step_loader_empty_prefixes(context: Context) -> None: + context.loader = PluginLoader(allowed_prefixes=()) + + +@when('I load class "{cls}" from module "{mod}"') +def step_load_class(context: Context, cls: str, mod: str) -> None: + context.loaded_class = context.loader.load_class(mod, cls) + + +@when('I attempt to load class "{cls}" from module "{mod}"') +def step_attempt_load_class(context: Context, cls: str, mod: str) -> None: + context.caught_exception = None + try: + context.loader.load_class(mod, cls) + except PluginLoadError as exc: + context.caught_exception = exc + + +@then("the loaded class should not be None") +def step_loaded_class_not_none(context: Context) -> None: + assert context.loaded_class is not None + + +@then('the loaded class name should be "{name}"') +def step_loaded_class_name(context: Context, name: str) -> None: + assert context.loaded_class.__name__ == name + + +@then("a PluginLoadError should be raised") +def step_plugin_load_error_raised(context: Context) -> None: + assert isinstance(context.caught_exception, PluginLoadError), ( + f"Expected PluginLoadError, got {type(context.caught_exception)}" + ) + + +@then('the plugin error message should contain "{text}"') +def step_plugin_error_message_contains(context: Context, text: str) -> None: + assert text in str(context.caught_exception), ( + f"Expected '{text}' in '{context.caught_exception}'" + ) + + +@then('the loader allowed_prefixes should contain "{prefix}"') +def step_loader_prefixes_contain(context: Context, prefix: str) -> None: + assert prefix in context.loader.allowed_prefixes + + +# --------------------------------------------------------------------------- +# Protocol validation +# --------------------------------------------------------------------------- + + +@given("a class that implements TextBackend protocol") +def step_conforming_class(context: Context) -> None: + context.test_class = _FakeTextBackend + + +@given("a class that does not implement any protocol") +def step_non_conforming_class(context: Context) -> None: + context.test_class = _NonProtocolClass + + +@when("I validate it against the TextBackend protocol") +def step_validate_protocol(context: Context) -> None: + context.validation_result = PluginLoader.validate_protocol( + context.test_class, + TextBackend, + ) + + +@when("I attempt to validate it against the TextBackend protocol") +def step_attempt_validate_protocol(context: Context) -> None: + context.caught_exception = None + try: + PluginLoader.validate_protocol(context.test_class, TextBackend) + except ProtocolMismatchError as exc: + context.caught_exception = exc + + +@then("the validation should return True") +def step_validation_true(context: Context) -> None: + assert context.validation_result is True + + +@then("a ProtocolMismatchError should be raised") +def step_protocol_mismatch_raised(context: Context) -> None: + assert isinstance(context.caught_exception, ProtocolMismatchError) + + +# --------------------------------------------------------------------------- +# Entry-point discovery +# --------------------------------------------------------------------------- + + +@when('I discover plugins from entry point group "{group}"') +def step_discover_entry_points(context: Context, group: str) -> None: + context.discovered = context.loader.load_from_entry_points(group=group) + + +@then("the discovered plugin list should be empty") +def step_discovered_empty(context: Context) -> None: + assert len(context.discovered) == 0 + + +@given('a mocked entry point group "{group}" with entry "{entry_spec}"') +def step_mock_entry_point(context: Context, group: str, entry_spec: str) -> None: + # Parse "name=module:ClassName" + name, value = entry_spec.split("=", 1) + mock_ep = MagicMock() + mock_ep.name = name + mock_ep.value = value + module_path, class_name = value.rsplit(":", 1) + + # Make ep.load() return the actual class + import importlib + + mod = importlib.import_module(module_path) + mock_ep.load.return_value = getattr(mod, class_name) + + # Store for use in discovery step + context.mock_group = group + context.mock_eps = [mock_ep] + + +@when("I discover plugins from the mocked entry point group") +def step_discover_mocked_eps(context: Context) -> None: + mock_result = MagicMock() + mock_result.select.return_value = context.mock_eps + with patch("importlib.metadata.entry_points", return_value=mock_result): + context.discovered = context.loader.load_from_entry_points( + group=context.mock_group, + ) + + +@then("the discovered plugin list should have {count:d} entry") +def step_discovered_count(context: Context, count: int) -> None: + assert len(context.discovered) == count, ( + f"Expected {count}, got {len(context.discovered)}" + ) + + +@then('the first descriptor name should be "{name}"') +def step_first_descriptor_name(context: Context, name: str) -> None: + assert context.discovered[0].name == name + + +# --------------------------------------------------------------------------- +# PluginManager — lifecycle +# --------------------------------------------------------------------------- + + +@given("a fresh PluginManager instance") +def step_fresh_manager(context: Context) -> None: + context.manager = PluginManager() + context.descriptor = None + context.caught_exception = None + + +@given('a PluginDescriptor for "{name}" with module "{mod}" and class "{cls}"') +def step_descriptor_for_manager( + context: Context, name: str, mod: str, cls: str +) -> None: + context.descriptor = PluginDescriptor( + name=name, + module_path=mod, + class_name=cls, + ) + + +@given('a PluginDescriptor for "{name}" with empty module_path') +def step_descriptor_empty_path(context: Context, name: str) -> None: + context.descriptor = PluginDescriptor( + name=name, + module_path="", + class_name="", + ) + + +@when("I register the plugin descriptor") +def step_register_descriptor(context: Context) -> None: + context.manager.register_plugin(context.descriptor) + + +@when("I attempt to register the same descriptor again") +def step_attempt_register_again(context: Context) -> None: + context.caught_exception = None + try: + context.manager.register_plugin(context.descriptor) + except PluginError as exc: + context.caught_exception = exc + + +@then('get_plugin should return the descriptor for "{name}"') +def step_get_plugin(context: Context, name: str) -> None: + result = context.manager.get_plugin(name) + assert result.name == name + + +@then('the plugin state should be "{state}"') +def step_plugin_state(context: Context, state: str) -> None: + desc = context.descriptor + actual = desc.state.value if hasattr(desc.state, "value") else desc.state + assert actual == state, f"Expected '{state}', got '{actual}'" + + +@then("a PluginError should be raised") +def step_plugin_error_raised(context: Context) -> None: + assert isinstance(context.caught_exception, PluginError), ( + f"Expected PluginError, got {type(context.caught_exception)}" + ) + + +@given('I register plugins named "{names_str}"') +def step_register_multiple(context: Context, names_str: str) -> None: + for name in [n.strip().strip('"') for n in names_str.split(",")]: + desc = PluginDescriptor( + name=name, + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + context.manager.register_plugin(desc) + + +@then("list_plugins should return {count:d} descriptors") +def step_list_plugins_count(context: Context, count: int) -> None: + plugins = context.manager.list_plugins() + assert len(plugins) == count, f"Expected {count}, got {len(plugins)}" + + +@then("list_plugins should return {count:d} descriptor") +def step_list_plugins_count_singular(context: Context, count: int) -> None: + plugins = context.manager.list_plugins() + assert len(plugins) == count, f"Expected {count}, got {len(plugins)}" + + +@when('I attempt to get plugin "{name}"') +def step_attempt_get_plugin(context: Context, name: str) -> None: + context.caught_exception = None + try: + context.manager.get_plugin(name) + except PluginNotFoundError as exc: + context.caught_exception = exc + + +@then("a PluginNotFoundError should be raised") +def step_not_found_raised(context: Context) -> None: + assert isinstance(context.caught_exception, PluginNotFoundError) + + +@when('I activate the plugin "{name}"') +def step_activate_plugin(context: Context, name: str) -> None: + context.manager.activate_plugin(name) + context.descriptor = context.manager.get_plugin(name) + + +@when('I attempt to activate the plugin "{name}" again') +def step_attempt_activate_again(context: Context, name: str) -> None: + context.caught_exception = None + try: + context.manager.activate_plugin(name) + except PluginError as exc: + context.caught_exception = exc + + +@when('I attempt to activate the plugin "{name}"') +def step_attempt_activate(context: Context, name: str) -> None: + context.caught_exception = None + try: + context.manager.activate_plugin(name) + except (PluginLoadError, PluginError) as exc: + context.caught_exception = exc + context.descriptor = context.manager.get_plugin(name) + + +@when('I attempt to activate the plugin "{name}" ignoring error') +def step_attempt_activate_ignore(context: Context, name: str) -> None: + with contextlib.suppress(PluginLoadError, PluginError): + context.manager.activate_plugin(name) + context.descriptor = context.manager.get_plugin(name) + + +@then('the plugin "{name}" state should be "{state}"') +def step_named_plugin_state(context: Context, name: str, state: str) -> None: + desc = context.manager.get_plugin(name) + actual = desc.state.value if hasattr(desc.state, "value") else desc.state + assert actual == state, f"Expected '{state}', got '{actual}'" + + +@then('get_plugin_class for "{name}" should not be None') +def step_plugin_class_not_none(context: Context, name: str) -> None: + assert context.manager.get_plugin_class(name) is not None + + +@then('get_plugin_instance for "{name}" should not be None') +def step_plugin_instance_not_none(context: Context, name: str) -> None: + assert context.manager.get_plugin_instance(name) is not None + + +@then('get_plugin_class for "{name}" should be None') +def step_plugin_class_none(context: Context, name: str) -> None: + assert context.manager.get_plugin_class(name) is None + + +@then('get_plugin_instance for "{name}" should be None') +def step_plugin_instance_none(context: Context, name: str) -> None: + assert context.manager.get_plugin_instance(name) is None + + +@when('I deactivate the plugin "{name}"') +def step_deactivate_plugin(context: Context, name: str) -> None: + context.manager.deactivate_plugin(name) + context.descriptor = context.manager.get_plugin(name) + + +@when('I attempt to deactivate the plugin "{name}"') +def step_attempt_deactivate(context: Context, name: str) -> None: + context.caught_exception = None + try: + context.manager.deactivate_plugin(name) + except PluginError as exc: + context.caught_exception = exc + + +# --------------------------------------------------------------------------- +# Config-driven registration +# --------------------------------------------------------------------------- + + +@when( + 'I register a plugin from config with custom_module "{mod}" and custom_class "{cls}"' +) +def step_register_from_config(context: Context, mod: str, cls: str) -> None: + config: dict[str, Any] = { + "custom_module": mod, + "custom_class": cls, + } + context.config_result = context.manager.register_from_config(config) + + +@when("I register a plugin from config with no custom_module or custom_class") +def step_register_from_config_empty(context: Context) -> None: + config: dict[str, Any] = {"unrelated": "value"} + context.config_result = context.manager.register_from_config(config) + + +@when("I register the same plugin from config again") +def step_register_from_config_again(context: Context) -> None: + config: dict[str, Any] = { + "custom_module": "cleveragents.domain.models.acms.stubs", + "custom_class": "InMemoryTextBackend", + } + context.config_result = context.manager.register_from_config(config) + + +@when("I register plugins from a config list of {count:d} entries") +def step_register_batch_config(context: Context, count: int) -> None: + configs: list[dict[str, Any]] = [ + { + "custom_module": "cleveragents.domain.models.acms.stubs", + "custom_class": "InMemoryTextBackend", + "name": f"batch-plugin-{i}", + } + for i in range(count) + ] + context.batch_result = context.manager.register_all_from_config(configs) + + +@then("the registered plugin descriptor should not be None") +def step_config_result_not_none(context: Context) -> None: + assert context.config_result is not None + + +@then("the registered plugin descriptor should be None") +def step_config_result_none(context: Context) -> None: + assert context.config_result is None + + +@then("the plugin should be in the manager registry") +def step_plugin_in_registry(context: Context) -> None: + assert len(context.manager.list_plugins()) > 0 + + +# --------------------------------------------------------------------------- +# Extension point management +# --------------------------------------------------------------------------- + + +@when('I register an extension point named "{name}"') +def step_register_ext_point(context: Context, name: str) -> None: + ep = ExtensionPoint( + name=name, + protocol_type=TextBackend, + description="Test EP", + ) + context.manager.register_extension_point(ep) + + +@then("list_extension_points should return {count:d} entry") +def step_list_ext_points_count(context: Context, count: int) -> None: + eps = context.manager.list_extension_points() + assert len(eps) == count, f"Expected {count}, got {len(eps)}" + + +# --------------------------------------------------------------------------- +# Clear +# --------------------------------------------------------------------------- + + +@when("I clear the plugin manager") +def step_clear_manager(context: Context) -> None: + context.manager.clear() + + +# --------------------------------------------------------------------------- +# Thread safety +# --------------------------------------------------------------------------- + + +@when("{n:d} threads register plugins concurrently") +def step_concurrent_register(context: Context, n: int) -> None: + errors: list[Exception] = [] + + def _register(idx: int) -> None: + try: + desc = PluginDescriptor( + name=f"thread-plugin-{idx}", + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + context.manager.register_plugin(desc) + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=_register, args=(i,)) for i in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Errors during concurrent registration: {errors}" + + +@given("a fresh PluginManager with {n:d} registered plugins") +def step_manager_with_plugins(context: Context, n: int) -> None: + context.manager = PluginManager() + for i in range(n): + desc = PluginDescriptor( + name=f"conc-plugin-{i}", + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + context.manager.register_plugin(desc) + + +@when("{n:d} threads activate plugins concurrently") +def step_concurrent_activate(context: Context, n: int) -> None: + errors: list[Exception] = [] + + def _activate(idx: int) -> None: + try: + context.manager.activate_plugin(f"conc-plugin-{idx}") + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=_activate, args=(i,)) for i in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Errors during concurrent activation: {errors}" + + +@then('all {n:d} plugins should be in "{state}" state') +def step_all_plugins_state(context: Context, n: int, state: str) -> None: + for i in range(n): + desc = context.manager.get_plugin(f"conc-plugin-{i}") + actual = desc.state.value + assert actual == state, ( + f"Plugin conc-plugin-{i}: expected '{state}', got '{actual}'" + ) + + +# --------------------------------------------------------------------------- +# DI container integration +# --------------------------------------------------------------------------- + + +@when("I resolve PluginManager from the DI container") +def step_resolve_di(context: Context) -> None: + from cleveragents.application.container import get_container, reset_container + + reset_container() + container = get_container() + context.di_manager = container.plugin_manager() + + +@then("the resolved PluginManager should not be None") +def step_di_manager_not_none(context: Context) -> None: + assert context.di_manager is not None + assert isinstance(context.di_manager, PluginManager) + + +@then("resolving PluginManager again should return the same instance") +def step_di_singleton(context: Context) -> None: + from cleveragents.application.container import get_container + + container = get_container() + second = container.plugin_manager() + assert context.di_manager is second, "PluginManager should be a singleton" + + +# --------------------------------------------------------------------------- +# Discovery integration +# --------------------------------------------------------------------------- + + +@when('I call discover with group "{group}"') +def step_call_discover(context: Context, group: str) -> None: + context.discovered = context.manager.discover(group=group) + + +@then("the discovered list should be empty") +def step_discovered_list_empty(context: Context) -> None: + assert len(context.discovered) == 0 + + +@given("a mocked entry point group for discover") +def step_mock_ep_for_discover(context: Context) -> None: + mock_ep = MagicMock() + mock_ep.name = "discover-test-plugin" + mock_ep.value = "cleveragents.domain.models.acms.stubs:InMemoryTextBackend" + + import importlib + + mod = importlib.import_module("cleveragents.domain.models.acms.stubs") + mock_ep.load.return_value = mod.InMemoryTextBackend + context.mock_discover_eps = [mock_ep] + + +@when("I call discover through the manager") +def step_call_manager_discover(context: Context) -> None: + mock_result = MagicMock() + mock_result.select.return_value = context.mock_discover_eps + with patch("importlib.metadata.entry_points", return_value=mock_result): + context.discovered = context.manager.discover( + group="cleveragents.plugins", + ) + + +@then("newly discovered plugins should be in the registry") +def step_discovered_in_registry(context: Context) -> None: + assert len(context.discovered) > 0 + for desc in context.discovered: + found = context.manager.get_plugin(desc.name) + assert found is not None diff --git a/robot/helper_plugin_architecture.py b/robot/helper_plugin_architecture.py new file mode 100644 index 000000000..2b9790ee8 --- /dev/null +++ b/robot/helper_plugin_architecture.py @@ -0,0 +1,292 @@ +"""Robot Framework helper for Plugin Architecture integration tests. + +Provides a CLI-style interface for Robot to invoke plugin loading, +validation, lifecycle, and DI resolution. Exit code 0 = success, +1 = failure. + +Usage: + python robot/helper_plugin_architecture.py +""" + +from __future__ import annotations + +import sys +import threading +from pathlib import Path + +# Ensure the src directory is on the import path. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.domain.models.acms.backends import ( # noqa: E402 + TextBackend, + TextResult, +) +from cleveragents.infrastructure.plugins.exceptions import ( # noqa: E402 + PluginError, + PluginLoadError, + PluginNotFoundError, + ProtocolMismatchError, +) +from cleveragents.infrastructure.plugins.loader import PluginLoader # noqa: E402 +from cleveragents.infrastructure.plugins.manager import PluginManager # noqa: E402 +from cleveragents.infrastructure.plugins.types import ( # noqa: E402 + ExtensionPoint, + PluginDescriptor, + PluginState, +) + + +class _FakeTextBackend: + """Minimal class satisfying the TextBackend protocol.""" + + def search( + self, + query: str, + *, + scope: frozenset[str], + max_results: int = 20, + ) -> list[TextResult]: + return [] + + +class _NonProtocolClass: + """A class that does not implement any backend protocol.""" + + pass + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print("Usage: helper_plugin_architecture.py ") + return 1 + + command: str = sys.argv[1] + + if command == "load-class": + try: + loader = PluginLoader() + cls = loader.load_class( + "cleveragents.domain.models.acms.stubs", + "InMemoryTextBackend", + ) + assert cls is not None + assert cls.__name__ == "InMemoryTextBackend" + print("plugin-load-class-ok") + return 0 + except Exception as exc: + print(f"plugin-load-class-fail: {exc}") + return 1 + + if command == "prefix-security": + try: + loader = PluginLoader() + raised = False + try: + loader.load_class("pathlib", "Path") + except PluginLoadError: + raised = True + assert raised, "Expected PluginLoadError for disallowed prefix" + print("plugin-prefix-security-ok") + return 0 + except Exception as exc: + print(f"plugin-prefix-security-fail: {exc}") + return 1 + + if command == "protocol-pass": + try: + result = PluginLoader.validate_protocol(_FakeTextBackend, TextBackend) + assert result is True + print("plugin-protocol-pass-ok") + return 0 + except Exception as exc: + print(f"plugin-protocol-pass-fail: {exc}") + return 1 + + if command == "protocol-fail": + try: + raised = False + try: + PluginLoader.validate_protocol(_NonProtocolClass, TextBackend) + except ProtocolMismatchError: + raised = True + assert raised, "Expected ProtocolMismatchError" + print("plugin-protocol-fail-ok") + return 0 + except Exception as exc: + print(f"plugin-protocol-fail-fail: {exc}") + return 1 + + if command == "lifecycle": + try: + manager = PluginManager() + desc = PluginDescriptor( + name="lifecycle-test", + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + manager.register_plugin(desc) + assert manager.get_plugin("lifecycle-test").state == PluginState.DISCOVERED + + manager.activate_plugin("lifecycle-test") + assert manager.get_plugin("lifecycle-test").state == PluginState.ACTIVATED + assert manager.get_plugin_class("lifecycle-test") is not None + assert manager.get_plugin_instance("lifecycle-test") is not None + + manager.deactivate_plugin("lifecycle-test") + assert manager.get_plugin("lifecycle-test").state == PluginState.DEACTIVATED + assert manager.get_plugin_class("lifecycle-test") is None + assert manager.get_plugin_instance("lifecycle-test") is None + + print("plugin-lifecycle-ok") + return 0 + except Exception as exc: + print(f"plugin-lifecycle-fail: {exc}") + return 1 + + if command == "config-registration": + try: + manager = PluginManager() + config = { + "custom_module": "cleveragents.domain.models.acms.stubs", + "custom_class": "InMemoryTextBackend", + "name": "config-test", + } + result = manager.register_from_config(config) + assert result is not None + assert result.name == "config-test" + + # Empty config should return None + empty = manager.register_from_config({}) + assert empty is None + + print("plugin-config-registration-ok") + return 0 + except Exception as exc: + print(f"plugin-config-registration-fail: {exc}") + return 1 + + if command == "error-handling": + try: + manager = PluginManager() + + # PluginNotFoundError + raised_not_found = False + try: + manager.get_plugin("nonexistent") + except PluginNotFoundError: + raised_not_found = True + assert raised_not_found + + # PluginLoadError on bad module + desc = PluginDescriptor( + name="bad-module", + module_path="cleveragents.nonexistent_xyz", + class_name="BadClass", + ) + manager.register_plugin(desc) + raised_load = False + try: + manager.activate_plugin("bad-module") + except PluginLoadError: + raised_load = True + assert raised_load + assert manager.get_plugin("bad-module").state == PluginState.ERRORED + + # Duplicate registration + desc2 = PluginDescriptor( + name="dup-test", + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + manager.register_plugin(desc2) + raised_dup = False + try: + manager.register_plugin(desc2) + except PluginError: + raised_dup = True + assert raised_dup + + print("plugin-error-handling-ok") + return 0 + except Exception as exc: + print(f"plugin-error-handling-fail: {exc}") + return 1 + + if command == "di-resolution": + try: + from cleveragents.application.container import ( + get_container, + reset_container, + ) + + reset_container() + container = get_container() + pm1 = container.plugin_manager() + assert isinstance(pm1, PluginManager) + pm2 = container.plugin_manager() + assert pm1 is pm2, "PluginManager should be singleton" + print("plugin-di-resolution-ok") + return 0 + except Exception as exc: + print(f"plugin-di-resolution-fail: {exc}") + return 1 + + if command == "thread-safety": + try: + manager = PluginManager() + errors: list[str] = [] + + def register_plugin(idx: int) -> None: + try: + desc = PluginDescriptor( + name=f"thread-{idx}", + module_path="cleveragents.domain.models.acms.stubs", + class_name="InMemoryTextBackend", + ) + manager.register_plugin(desc) + except Exception as exc: + errors.append(str(exc)) + + threads = [ + threading.Thread(target=register_plugin, args=(i,)) for i in range(20) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Thread errors: {errors}" + assert len(manager.list_plugins()) == 20 + print("plugin-thread-safety-ok") + return 0 + except Exception as exc: + print(f"plugin-thread-safety-fail: {exc}") + return 1 + + if command == "extension-points": + try: + manager = PluginManager() + ep = ExtensionPoint( + name="test-ep", + protocol_type=TextBackend, + description="Test extension point", + ) + manager.register_extension_point(ep) + eps = manager.list_extension_points() + assert len(eps) == 1 + assert eps[0].name == "test-ep" + print("plugin-extension-points-ok") + return 0 + except Exception as exc: + print(f"plugin-extension-points-fail: {exc}") + return 1 + + print(f"Unknown command: {command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/plugin_architecture.robot b/robot/plugin_architecture.robot new file mode 100644 index 000000000..c5dd94141 --- /dev/null +++ b/robot/plugin_architecture.robot @@ -0,0 +1,89 @@ +*** Settings *** +Documentation Integration tests for Plugin Architecture Framework +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_plugin_architecture.py + +*** Test Cases *** +PluginLoader Loads Valid Class + [Documentation] Verify PluginLoader can import a class from module:ClassName + ${result}= Run Process ${PYTHON} ${HELPER} load-class cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-load-class-ok + +PluginLoader Rejects Disallowed Module Prefix + [Documentation] Verify PluginLoader security prefix allowlist + ${result}= Run Process ${PYTHON} ${HELPER} prefix-security cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-prefix-security-ok + +PluginLoader Protocol Validation Pass + [Documentation] Verify protocol validation succeeds for conforming class + ${result}= Run Process ${PYTHON} ${HELPER} protocol-pass cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-protocol-pass-ok + +PluginLoader Protocol Validation Fail + [Documentation] Verify protocol validation fails for non-conforming class + ${result}= Run Process ${PYTHON} ${HELPER} protocol-fail cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-protocol-fail-ok + +PluginManager Lifecycle + [Documentation] Verify register -> activate -> deactivate lifecycle + ${result}= Run Process ${PYTHON} ${HELPER} lifecycle cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-lifecycle-ok + +PluginManager Config-Driven Registration + [Documentation] Verify config-driven plugin registration + ${result}= Run Process ${PYTHON} ${HELPER} config-registration cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-config-registration-ok + +PluginManager Error Handling + [Documentation] Verify error transitions and exception types + ${result}= Run Process ${PYTHON} ${HELPER} error-handling cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-error-handling-ok + +DI Container PluginManager Resolution + [Documentation] Verify DI container resolves PluginManager singleton + ${result}= Run Process ${PYTHON} ${HELPER} di-resolution cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-di-resolution-ok + +PluginManager Thread Safety + [Documentation] Verify concurrent plugin registration is thread-safe + ${result}= Run Process ${PYTHON} ${HELPER} thread-safety cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-thread-safety-ok + +PluginManager Extension Points + [Documentation] Verify extension point registration and listing + ${result}= Run Process ${PYTHON} ${HELPER} extension-points cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plugin-extension-points-ok diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index 0da4e202b..2c4335a39 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -66,6 +66,7 @@ from cleveragents.infrastructure.database.repositories import ( ) from cleveragents.infrastructure.database.unit_of_work import UnitOfWork from cleveragents.infrastructure.events.reactive import ReactiveEventBus +from cleveragents.infrastructure.plugins.manager import PluginManager from cleveragents.langgraph.bridge import RxPyLangGraphBridge from cleveragents.providers.registry import ProviderRegistry, get_provider_registry from cleveragents.reactive.route_bridge import RouteBridge @@ -414,6 +415,9 @@ class Container(containers.DeclarativeContainer): ExecutionEnvironmentResolver, ) + # Plugin Manager - Singleton (shared plugin lifecycle management) + plugin_manager = providers.Singleton(PluginManager) + # ACMS Backend Abstraction Layer — configurable via provider selection. # Default: in-memory stubs. Override with production backends # (Tantivy, FAISS, Blazegraph, etc.) via ``override_providers()``. diff --git a/src/cleveragents/infrastructure/plugins/__init__.py b/src/cleveragents/infrastructure/plugins/__init__.py new file mode 100644 index 000000000..74fb3e169 --- /dev/null +++ b/src/cleveragents/infrastructure/plugins/__init__.py @@ -0,0 +1,46 @@ +"""Plugin architecture framework for CleverAgents. + +Provides dynamic plugin discovery, loading, validation, and lifecycle +management. Plugins are resolved via ``module:ClassName`` strings and +validated against ``@runtime_checkable`` Protocol types. + +Key components: + +- :class:`PluginLoader` — dynamic import and entry-point discovery. +- :class:`PluginManager` — lifecycle management (discover/activate/deactivate). +- :class:`PluginDescriptor` — immutable metadata for a discovered plugin. +- :class:`PluginState` — lifecycle state enum. + +Based on ``docs/specification.md`` Extension Points Summary and +issue #585. + +ISSUES CLOSED: #585 +""" + +from __future__ import annotations + +from cleveragents.infrastructure.plugins.exceptions import ( + PluginError, + PluginLoadError, + PluginNotFoundError, + ProtocolMismatchError, +) +from cleveragents.infrastructure.plugins.loader import PluginLoader +from cleveragents.infrastructure.plugins.manager import PluginManager +from cleveragents.infrastructure.plugins.types import ( + ExtensionPoint, + PluginDescriptor, + PluginState, +) + +__all__ = [ + "ExtensionPoint", + "PluginDescriptor", + "PluginError", + "PluginLoadError", + "PluginLoader", + "PluginManager", + "PluginNotFoundError", + "PluginState", + "ProtocolMismatchError", +] diff --git a/src/cleveragents/infrastructure/plugins/exceptions.py b/src/cleveragents/infrastructure/plugins/exceptions.py new file mode 100644 index 000000000..2ed657129 --- /dev/null +++ b/src/cleveragents/infrastructure/plugins/exceptions.py @@ -0,0 +1,34 @@ +"""Plugin exception hierarchy. + +Defines domain-specific exceptions for the plugin architecture +framework. All plugin exceptions inherit from :class:`PluginError`. + +Based on issue #585. +""" + +from __future__ import annotations + + +class PluginError(Exception): + """Base exception for all plugin-related errors.""" + + +class PluginLoadError(PluginError): + """Raised when a plugin module cannot be found or imported. + + This covers ``ImportError`` (module not found) and + ``AttributeError`` (class not found in module) during dynamic + loading. + """ + + +class PluginNotFoundError(PluginError): + """Raised when a requested plugin is not in the registry.""" + + +class ProtocolMismatchError(PluginError): + """Raised when a loaded class does not satisfy the expected Protocol. + + The plugin was successfully imported but its class does not + implement the required ``@runtime_checkable`` Protocol interface. + """ diff --git a/src/cleveragents/infrastructure/plugins/loader.py b/src/cleveragents/infrastructure/plugins/loader.py new file mode 100644 index 000000000..d08224d06 --- /dev/null +++ b/src/cleveragents/infrastructure/plugins/loader.py @@ -0,0 +1,272 @@ +"""Plugin loader with dynamic import and entry-point discovery. + +Provides :class:`PluginLoader` for importing plugin classes from +``module:ClassName`` strings and discovering plugins via +``importlib.metadata`` entry points. + +Security: module imports are restricted to a configurable prefix +allowlist (default: ``("cleveragents.",)``), reusing the security +pattern from :class:`ComponentResolver`. + +Based on issue #585. +""" + +from __future__ import annotations + +import importlib +import importlib.metadata +from typing import Any + +import structlog + +from cleveragents.infrastructure.plugins.exceptions import ( + PluginLoadError, + ProtocolMismatchError, +) +from cleveragents.infrastructure.plugins.types import ( + PluginDescriptor, + PluginState, +) + +logger = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Default allowed module prefixes (security) +# --------------------------------------------------------------------------- + +_DEFAULT_ALLOWED_PREFIXES: tuple[str, ...] = ("cleveragents.",) + + +class PluginLoader: + """Loads plugin classes dynamically and discovers entry points. + + The loader provides two discovery mechanisms: + + 1. **Explicit import** via :meth:`load_class` using + ``module:ClassName`` strings. + 2. **Entry-point discovery** via :meth:`load_from_entry_points` + using ``importlib.metadata``. + + Security: + All module imports are validated against a configurable prefix + allowlist. Only modules whose fully-qualified name starts with + an allowed prefix may be imported. This prevents arbitrary + code execution from untrusted configuration. + + Example:: + + loader = PluginLoader() + cls = loader.load_class( + "cleveragents.domain.models.acms.stubs", + "InMemoryTextBackend", + ) + assert cls is not None + + Args: + allowed_prefixes: Module prefixes permitted for dynamic import. + Defaults to ``("cleveragents.",)``. Pass an empty tuple to + disable the allowlist (not recommended). + """ + + def __init__( + self, + *, + allowed_prefixes: tuple[str, ...] | None = None, + ) -> None: + self._allowed_prefixes: tuple[str, ...] = ( + allowed_prefixes + if allowed_prefixes is not None + else _DEFAULT_ALLOWED_PREFIXES + ) + self._logger = logger.bind(service="plugin_loader") + + # ------------------------------------------------------------------ + # Module prefix allowlist property + # ------------------------------------------------------------------ + + @property + def allowed_prefixes(self) -> tuple[str, ...]: + """Return the current module prefix allowlist.""" + return self._allowed_prefixes + + # ------------------------------------------------------------------ + # Dynamic class import + # ------------------------------------------------------------------ + + def load_class(self, module_path: str, class_name: str) -> type[Any]: + """Import and return a class from a Python module. + + Args: + module_path: Fully-qualified module path + (e.g. ``"cleveragents.domain.models.acms.stubs"``). + class_name: Name of the class within the module + (e.g. ``"InMemoryTextBackend"``). + + Returns: + The imported class object. + + Raises: + PluginLoadError: If the module prefix is not allowed, the + module cannot be imported, or the class is not found. + """ + self._validate_module_prefix(module_path) + + try: + module = importlib.import_module(module_path) + except ImportError as exc: + msg = ( + f"Cannot import module '{module_path}': {exc}. " + f"Verify the module is installed and the path is correct." + ) + self._logger.warning( + "plugin.import_failed", + module_path=module_path, + class_name=class_name, + error=str(exc), + ) + raise PluginLoadError(msg) from exc + + cls = getattr(module, class_name, None) + if cls is None: + msg = ( + f"Class '{class_name}' not found in module '{module_path}'. " + f"Verify the class name is spelled correctly." + ) + self._logger.warning( + "plugin.class_not_found", + module_path=module_path, + class_name=class_name, + ) + raise PluginLoadError(msg) + + if not isinstance(cls, type): + msg = ( + f"'{class_name}' in module '{module_path}' is not a class " + f"(got {type(cls).__name__})." + ) + raise PluginLoadError(msg) + + self._logger.debug( + "plugin.class_loaded", + module_path=module_path, + class_name=class_name, + ) + return cls + + # ------------------------------------------------------------------ + # Entry-point discovery + # ------------------------------------------------------------------ + + def load_from_entry_points( + self, + group: str = "cleveragents.plugins", + ) -> list[PluginDescriptor]: + """Discover plugins registered via ``importlib.metadata`` entry points. + + Scans the given entry-point group for plugin registrations. + Each entry point is expected to reference a class (not an + instance). + + Args: + group: Entry-point group name to scan. Defaults to + ``"cleveragents.plugins"``. + + Returns: + List of :class:`PluginDescriptor` for each discovered plugin. + Plugins that fail to load are skipped with a warning. + """ + descriptors: list[PluginDescriptor] = [] + entry_points = importlib.metadata.entry_points() + + # Python 3.12+ returns a SelectableGroups; filter by group + eps = entry_points.select(group=group) + + for ep in eps: + try: + ep.load() + module_path = ep.value.rsplit(":", 1)[0] if ":" in ep.value else "" + class_name = ep.value.rsplit(":", 1)[1] if ":" in ep.value else ep.value + + descriptor = PluginDescriptor( + name=ep.name, + module_path=module_path, + class_name=class_name, + state=PluginState.DISCOVERED, + ) + descriptors.append(descriptor) + self._logger.info( + "plugin.discovered_entry_point", + name=ep.name, + value=ep.value, + group=group, + ) + except Exception as exc: + self._logger.warning( + "plugin.entry_point_failed", + name=ep.name, + value=ep.value, + group=group, + error=str(exc), + ) + + return descriptors + + # ------------------------------------------------------------------ + # Protocol validation + # ------------------------------------------------------------------ + + @staticmethod + def validate_protocol(cls: type[Any], protocol: type[Any]) -> bool: + """Check whether *cls* satisfies a ``@runtime_checkable`` Protocol. + + Creates a temporary instance of *cls* (using a no-arg + constructor) and checks it against the protocol via + ``isinstance``. If instantiation fails, falls back to a + structural check using ``issubclass``. + + Args: + cls: The class to validate. + protocol: The ``@runtime_checkable`` Protocol to check against. + + Returns: + ``True`` if *cls* satisfies the protocol. + + Raises: + ProtocolMismatchError: If *cls* does not satisfy the protocol. + """ + # Try instance check first (most reliable for runtime_checkable) + try: + instance = cls() + if isinstance(instance, protocol): + return True + except Exception: + # If instantiation fails, try subclass check + try: + if issubclass(cls, protocol): + return True + except TypeError: + pass + + msg = ( + f"Class '{cls.__name__}' does not satisfy protocol " + f"'{protocol.__name__}'. Ensure the class implements all " + f"required methods and attributes." + ) + raise ProtocolMismatchError(msg) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _validate_module_prefix(self, module_path: str) -> None: + """Raise :class:`PluginLoadError` if prefix is not in allowlist.""" + if not self._allowed_prefixes: + return + + if not any(module_path.startswith(prefix) for prefix in self._allowed_prefixes): + msg = ( + f"Module '{module_path}' is not in the allowed prefix list: " + f"{self._allowed_prefixes}. Only modules under these prefixes " + f"may be dynamically imported." + ) + raise PluginLoadError(msg) diff --git a/src/cleveragents/infrastructure/plugins/manager.py b/src/cleveragents/infrastructure/plugins/manager.py new file mode 100644 index 000000000..444a3b157 --- /dev/null +++ b/src/cleveragents/infrastructure/plugins/manager.py @@ -0,0 +1,403 @@ +"""Plugin lifecycle manager. + +Provides :class:`PluginManager` for managing plugin discovery, +activation, deactivation, and config-driven registration. Thread-safe +via ``threading.RLock``. + +Based on issue #585. +""" + +from __future__ import annotations + +import threading +from typing import Any + +import structlog + +from cleveragents.infrastructure.plugins.exceptions import ( + PluginError, + PluginLoadError, + PluginNotFoundError, + ProtocolMismatchError, +) +from cleveragents.infrastructure.plugins.loader import PluginLoader +from cleveragents.infrastructure.plugins.types import ( + ExtensionPoint, + PluginDescriptor, + PluginState, +) + +logger = structlog.get_logger(__name__) + + +class PluginManager: + """Manages the plugin lifecycle: discover, activate, deactivate. + + The manager is the single entry point for plugin operations. It + maintains a thread-safe registry of :class:`PluginDescriptor` + entries and their resolved class objects. + + Config-driven registration reads ``custom_module``, + ``custom_class``, and ``custom_options`` from configuration dicts + to register plugins dynamically. + + Thread-safety: all public methods are guarded by ``threading.RLock``. + + Example:: + + manager = PluginManager() + descriptor = PluginDescriptor( + name="my-plugin", + module_path="cleveragents.my_module", + class_name="MyPlugin", + ) + manager.register_plugin(descriptor) + manager.activate_plugin("my-plugin") + info = manager.get_plugin("my-plugin") + manager.deactivate_plugin("my-plugin") + + Args: + loader: Optional :class:`PluginLoader` instance. If ``None``, + a default loader is created. + allowed_prefixes: Module prefix allowlist for the loader. + Ignored if *loader* is provided. + """ + + def __init__( + self, + *, + loader: PluginLoader | None = None, + allowed_prefixes: tuple[str, ...] | None = None, + ) -> None: + self._lock = threading.RLock() + self._loader = loader or PluginLoader(allowed_prefixes=allowed_prefixes) + self._plugins: dict[str, PluginDescriptor] = {} + self._classes: dict[str, type[Any]] = {} + self._instances: dict[str, object] = {} + self._extension_points: dict[str, ExtensionPoint] = {} + self._logger = logger.bind(service="plugin_manager") + + # ------------------------------------------------------------------ + # Extension point registration + # ------------------------------------------------------------------ + + def register_extension_point(self, extension_point: ExtensionPoint) -> None: + """Register an extension point the manager knows about. + + Args: + extension_point: The extension point metadata to register. + """ + with self._lock: + self._extension_points[extension_point.name] = extension_point + self._logger.debug( + "plugin_manager.extension_point_registered", + name=extension_point.name, + ) + + def list_extension_points(self) -> list[ExtensionPoint]: + """Return all registered extension points.""" + with self._lock: + return list(self._extension_points.values()) + + # ------------------------------------------------------------------ + # Plugin registration + # ------------------------------------------------------------------ + + def register_plugin(self, descriptor: PluginDescriptor) -> None: + """Register a plugin descriptor in the manager. + + The plugin is placed in ``DISCOVERED`` state. No import or + validation is performed until :meth:`activate_plugin` is called. + + Args: + descriptor: The plugin descriptor to register. + + Raises: + PluginError: If a plugin with the same name is already + registered. + """ + with self._lock: + if descriptor.name in self._plugins: + msg = f"Plugin '{descriptor.name}' is already registered" + raise PluginError(msg) + + descriptor.state = PluginState.DISCOVERED + self._plugins[descriptor.name] = descriptor + self._logger.info( + "plugin_manager.registered", + name=descriptor.name, + module_path=descriptor.module_path, + class_name=descriptor.class_name, + ) + + # ------------------------------------------------------------------ + # Plugin queries + # ------------------------------------------------------------------ + + def get_plugin(self, name: str) -> PluginDescriptor: + """Return the descriptor for a registered plugin. + + Args: + name: Plugin name. + + Returns: + The :class:`PluginDescriptor` for the named plugin. + + Raises: + PluginNotFoundError: If the plugin is not registered. + """ + with self._lock: + if name not in self._plugins: + msg = ( + f"Plugin '{name}' not found in registry. " + f"Available: {sorted(self._plugins)}" + ) + raise PluginNotFoundError(msg) + return self._plugins[name] + + def list_plugins(self) -> list[PluginDescriptor]: + """Return all registered plugin descriptors.""" + with self._lock: + return list(self._plugins.values()) + + def get_plugin_class(self, name: str) -> type[Any] | None: + """Return the loaded class for a plugin, or ``None``. + + Args: + name: Plugin name. + + Returns: + The loaded class, or ``None`` if not yet activated. + """ + with self._lock: + return self._classes.get(name) + + def get_plugin_instance(self, name: str) -> object | None: + """Return the instantiated plugin object, or ``None``. + + Args: + name: Plugin name. + + Returns: + The plugin instance, or ``None`` if not yet activated. + """ + with self._lock: + return self._instances.get(name) + + # ------------------------------------------------------------------ + # Lifecycle: discover + # ------------------------------------------------------------------ + + def discover( + self, + group: str = "cleveragents.plugins", + ) -> list[PluginDescriptor]: + """Discover plugins from entry points and register them. + + Args: + group: Entry-point group to scan. + + Returns: + List of newly discovered :class:`PluginDescriptor` instances. + """ + with self._lock: + descriptors = self._loader.load_from_entry_points(group=group) + newly_registered: list[PluginDescriptor] = [] + + for desc in descriptors: + if desc.name not in self._plugins: + self._plugins[desc.name] = desc + newly_registered.append(desc) + self._logger.info( + "plugin_manager.discovered", + name=desc.name, + ) + + return newly_registered + + # ------------------------------------------------------------------ + # Lifecycle: activate + # ------------------------------------------------------------------ + + def activate_plugin(self, name: str) -> None: + """Activate a registered plugin by loading its class. + + Imports the module, loads the class, creates an instance, and + transitions the plugin to ``ACTIVATED`` state. + + Args: + name: Plugin name. + + Raises: + PluginNotFoundError: If the plugin is not registered. + PluginLoadError: If the class cannot be imported. + PluginError: If the plugin is already activated. + """ + with self._lock: + descriptor = self.get_plugin(name) + + if descriptor.state == PluginState.ACTIVATED: + msg = f"Plugin '{name}' is already activated" + raise PluginError(msg) + + if not descriptor.module_path or not descriptor.class_name: + msg = ( + f"Plugin '{name}' has no module_path or class_name. " + f"Cannot activate." + ) + raise PluginLoadError(msg) + + try: + cls = self._loader.load_class( + descriptor.module_path, + descriptor.class_name, + ) + instance = cls() + + self._classes[name] = cls + self._instances[name] = instance + descriptor.state = PluginState.ACTIVATED + + self._logger.info( + "plugin_manager.activated", + name=name, + module_path=descriptor.module_path, + class_name=descriptor.class_name, + ) + except (PluginLoadError, ProtocolMismatchError): + descriptor.state = PluginState.ERRORED + raise + except Exception as exc: + descriptor.state = PluginState.ERRORED + msg = f"Failed to activate plugin '{name}': {exc}" + raise PluginError(msg) from exc + + # ------------------------------------------------------------------ + # Lifecycle: deactivate + # ------------------------------------------------------------------ + + def deactivate_plugin(self, name: str) -> None: + """Deactivate a plugin, removing its class and instance. + + Args: + name: Plugin name. + + Raises: + PluginNotFoundError: If the plugin is not registered. + PluginError: If the plugin is not in an activatable state. + """ + with self._lock: + descriptor = self.get_plugin(name) + + if descriptor.state not in ( + PluginState.ACTIVATED, + PluginState.ERRORED, + ): + msg = ( + f"Plugin '{name}' is in state '{descriptor.state}' " + f"and cannot be deactivated" + ) + raise PluginError(msg) + + self._classes.pop(name, None) + self._instances.pop(name, None) + descriptor.state = PluginState.DEACTIVATED + + self._logger.info( + "plugin_manager.deactivated", + name=name, + ) + + # ------------------------------------------------------------------ + # Config-driven registration + # ------------------------------------------------------------------ + + def register_from_config( + self, + config: dict[str, Any], + ) -> PluginDescriptor | None: + """Register a plugin from a configuration dictionary. + + Reads ``custom_module``, ``custom_class``, and optionally + ``custom_options`` from the config dict. + + Args: + config: Configuration dictionary with keys: + - ``custom_module`` (str): Module path. + - ``custom_class`` (str): Class name. + - ``custom_options`` (dict, optional): Extra options. + - ``name`` (str, optional): Plugin name. + + Returns: + The registered :class:`PluginDescriptor`, or ``None`` if + the config doesn't contain plugin keys. + """ + custom_module = config.get("custom_module", "") + custom_class = config.get("custom_class", "") + + if not custom_module or not custom_class: + return None + + name = config.get("name", f"{custom_module}:{custom_class}") + custom_options = config.get("custom_options", {}) + + descriptor = PluginDescriptor( + name=name, + module_path=custom_module, + class_name=custom_class, + description=str(custom_options.get("description", "")), + version=str(custom_options.get("version", "0.0.0")), + ) + + with self._lock: + if name in self._plugins: + self._logger.debug( + "plugin_manager.config_already_registered", + name=name, + ) + return self._plugins[name] + + self._plugins[name] = descriptor + self._logger.info( + "plugin_manager.registered_from_config", + name=name, + module_path=custom_module, + class_name=custom_class, + ) + return descriptor + + # ------------------------------------------------------------------ + # Batch config registration + # ------------------------------------------------------------------ + + def register_all_from_config( + self, + configs: list[dict[str, Any]], + ) -> list[PluginDescriptor]: + """Register multiple plugins from a list of config dicts. + + Args: + configs: List of configuration dictionaries. + + Returns: + List of successfully registered descriptors. + """ + registered: list[PluginDescriptor] = [] + for cfg in configs: + result = self.register_from_config(cfg) + if result is not None: + registered.append(result) + return registered + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def clear(self) -> None: + """Remove all plugins and reset state.""" + with self._lock: + self._plugins.clear() + self._classes.clear() + self._instances.clear() + self._extension_points.clear() + self._logger.debug("plugin_manager.cleared") diff --git a/src/cleveragents/infrastructure/plugins/types.py b/src/cleveragents/infrastructure/plugins/types.py new file mode 100644 index 000000000..bcf6f36d5 --- /dev/null +++ b/src/cleveragents/infrastructure/plugins/types.py @@ -0,0 +1,145 @@ +"""Plugin type definitions for the plugin architecture framework. + +Defines the core value objects used across the plugin subsystem: + +- :class:`PluginState` — lifecycle state enum for plugins. +- :class:`ExtensionPoint` — metadata describing a pluggable extension. +- :class:`PluginDescriptor` — immutable descriptor for a discovered plugin. + +Based on ``docs/specification.md`` Extension Points Summary and +issue #585. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# PluginState — lifecycle states +# --------------------------------------------------------------------------- + + +class PluginState(StrEnum): + """Lifecycle state of a plugin. + + State transitions:: + + DISCOVERED ──► ACTIVATED ──► EXECUTING ──► ACTIVATED + │ │ │ + │ └──► DEACTIVATED └──► ERRORED + │ │ + └──► ERRORED └──► DEACTIVATED + """ + + DISCOVERED = "discovered" + ACTIVATED = "activated" + EXECUTING = "executing" + DEACTIVATED = "deactivated" + ERRORED = "errored" + + +# --------------------------------------------------------------------------- +# ExtensionPoint — describes a pluggable extension slot +# --------------------------------------------------------------------------- + + +class ExtensionPoint(BaseModel): + """Metadata describing a pluggable extension slot. + + Each extension point is a named slot that accepts implementations + satisfying a specific Protocol type. + + Attributes: + name: Unique name identifying this extension point. + protocol_type: The Protocol type that implementations must satisfy. + description: Human-readable description of what this slot does. + registry_key: Key used to look up implementations in a registry. + """ + + name: str = Field( + ..., + min_length=1, + description="Unique extension point name", + ) + protocol_type: type[Any] = Field( + ..., + description="Protocol type that implementations must satisfy", + ) + description: str = Field( + default="", + description="Human-readable description", + ) + registry_key: str = Field( + default="", + description="Registry lookup key for this extension point", + ) + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + +# --------------------------------------------------------------------------- +# PluginDescriptor — immutable metadata for a discovered plugin +# --------------------------------------------------------------------------- + + +class PluginDescriptor(BaseModel): + """Immutable descriptor for a discovered plugin. + + Contains all metadata needed to load, validate, and manage a plugin + through its lifecycle. Created during discovery and stored in the + :class:`PluginManager` registry. + + Attributes: + name: Unique plugin name. + version: Semantic version string (e.g. ``"1.0.0"``). + author: Plugin author. + description: Human-readable description. + module_path: Python module path for dynamic import. + class_name: Class name within the module. + extension_points: Extension points this plugin provides. + dependencies: Names of plugins this plugin depends on. + state: Current lifecycle state. + """ + + name: str = Field( + ..., + min_length=1, + description="Unique plugin name", + ) + version: str = Field( + default="0.0.0", + description="Semantic version string", + ) + author: str = Field( + default="", + description="Plugin author", + ) + description: str = Field( + default="", + description="Human-readable description", + ) + module_path: str = Field( + default="", + description="Python module path for dynamic import", + ) + class_name: str = Field( + default="", + description="Class name within the module", + ) + extension_points: list[str] = Field( + default_factory=list, + description="Extension point names this plugin provides", + ) + dependencies: list[str] = Field( + default_factory=list, + description="Names of required plugins", + ) + state: PluginState = Field( + default=PluginState.DISCOVERED, + description="Current lifecycle state", + ) + + model_config = ConfigDict(frozen=False) -- 2.52.0