"""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 ANY, 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 @given('a mocked entry point group "{group}" with raw entry "{entry_spec}"') def step_mock_raw_entry(context: Context, group: str, entry_spec: str) -> None: name, value = entry_spec.split("=", 1) mock_ep = MagicMock() mock_ep.name = name mock_ep.value = value mock_ep.load = MagicMock(name="load") context.mock_group = group context.mock_eps = [mock_ep] context.mock_ep_last = mock_ep context.loader._logger = MagicMock() @then("the mocked entry point load should not be called") def step_entry_point_not_loaded(context: Context) -> None: context.mock_ep_last.load.assert_not_called() @then('a security warning should be emitted for disallowed entry point "{name}"') def step_security_warning_emitted(context: Context, name: str) -> None: logger_mock = context.loader._logger logger_mock.warning.assert_any_call( "plugin.entry_point_disallowed_prefix", name=name, value=context.mock_ep_last.value, group=context.mock_group, error=ANY, ) # --------------------------------------------------------------------------- # 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