feat(extensibility): implement Plugin Architecture Framework with module:ClassName resolution #666
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <command>
|
||||
"""
|
||||
|
||||
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 <command>")
|
||||
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())
|
||||
@@ -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
|
||||
@@ -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()``.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user