"""Step definitions for sandbox_strategy_registry_coverage.feature. These steps target specific uncovered lines in strategy_registry.py: - Lines 65-68: CustomStrategyConfig.__init__ assignment block - Line 167: register_from_config delegation - Lines 196-200: register_all_from_config skip on missing module/class - Lines 205-209: register_all_from_config error handling on load failure """ import logging from unittest.mock import MagicMock from behave import given, then, when from cleveragents.infrastructure.plugins.exceptions import ( PluginLoadError, ) from cleveragents.infrastructure.sandbox.strategy_registry import ( CustomStrategyConfig, SandboxStrategyRegistry, ) # --------------------------------------------------------------------------- # Helper: a class that satisfies _validate_protocol (has all 9 methods) # --------------------------------------------------------------------------- class _CompliantStrategy: """Dummy class with all 9 required SandboxStrategyProtocol methods.""" def create(self): ... def read(self): ... def write(self): ... def diff(self): ... def commit(self): ... def rollback(self): ... def checkpoint(self): ... def restore_checkpoint(self): ... def cleanup(self): ... class _NonCompliantStrategy: """Dummy class missing required methods.""" def create(self): ... # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("the sandbox strategy registry module is imported") def step_module_imported(context): """Verify the module is importable.""" assert CustomStrategyConfig is not None assert SandboxStrategyRegistry is not None # --------------------------------------------------------------------------- # CustomStrategyConfig success (lines 65-68) # --------------------------------------------------------------------------- @when( 'I create a CustomStrategyConfig with name "{name}" module "{module}" and class "{cls}"' ) def step_create_config(context, name, module, cls): """Create a CustomStrategyConfig with the given values.""" context.strategy_config = CustomStrategyConfig( name=name, module=module, class_name=cls, ) @then('the config should have name "{name}" module "{module}" and class_name "{cls}"') def step_verify_config_fields(context, name, module, cls): """Verify the config fields were assigned correctly.""" cfg = context.strategy_config assert cfg.name == name, f"Expected name={name!r}, got {cfg.name!r}" assert cfg.module == module, f"Expected module={module!r}, got {cfg.module!r}" assert cfg.class_name == cls, f"Expected class_name={cls!r}, got {cfg.class_name!r}" @then("the config options should default to an empty dict") def step_verify_default_options(context): """Verify options defaults to empty dict when not provided.""" assert context.strategy_config.options == {}, ( f"Expected empty dict, got {context.strategy_config.options!r}" ) @when( 'I create a CustomStrategyConfig with name "{name}" module "{module}" class "{cls}" and options' ) def step_create_config_with_options(context, name, module, cls): """Create a CustomStrategyConfig with explicit options.""" context.strategy_config = CustomStrategyConfig( name=name, module=module, class_name=cls, options={"timeout": 30, "retries": 5}, ) @then("the config options should contain the provided values") def step_verify_explicit_options(context): """Verify options stores the provided dict.""" opts = context.strategy_config.options assert opts == {"timeout": 30, "retries": 5}, f"Unexpected options: {opts!r}" # --------------------------------------------------------------------------- # Registry with mock loader helpers # --------------------------------------------------------------------------- @given("a registry with a mock loader that returns a compliant class") def step_registry_with_mock_loader(context): """Create a SandboxStrategyRegistry with a mocked PluginLoader.""" mock_loader = MagicMock() mock_loader.load_class.return_value = _CompliantStrategy context.registry = SandboxStrategyRegistry(loader=mock_loader) context.mock_loader = mock_loader @given('a CustomStrategyConfig named "{name}" with module "{module}" and class "{cls}"') def step_create_named_config(context, name, module, cls): """Create and store a CustomStrategyConfig for later use.""" context.strategy_config = CustomStrategyConfig( name=name, module=module, class_name=cls, ) # --------------------------------------------------------------------------- # register_from_config (line 167) # --------------------------------------------------------------------------- @when("I call register_from_config with that config") def step_call_register_from_config(context): """Call register_from_config using the stored config.""" context.returned_cls = context.registry.register_from_config( context.strategy_config, ) @then('the strategy "{name}" should be registered in the registry') def step_verify_strategy_registered(context, name): """Verify the strategy was successfully registered.""" assert context.registry.has(name), ( f"Strategy {name!r} not found; registered: {context.registry.list_strategies()}" ) assert context.returned_cls is _CompliantStrategy # --------------------------------------------------------------------------- # register_all_from_config: skip missing module (lines 196-200) # --------------------------------------------------------------------------- @when("I call register_all_from_config with an entry that has an empty module") def step_register_all_missing_module(context): """Call register_all_from_config with a config missing the module key.""" context.log_handler = _CapturingLogHandler() logger = logging.getLogger("cleveragents.infrastructure.sandbox.strategy_registry") # Re-enable the logger in case Alembic's fileConfig() disabled it logger.disabled = False logger.addHandler(context.log_handler) logger.setLevel(logging.DEBUG) def cleanup(): logger.removeHandler(context.log_handler) context.add_cleanup(cleanup) configs = { "bad_no_module": { "module": "", "class": "SomeClass", }, } context.registered_names = context.registry.register_all_from_config(configs) context.skipped_entry_name = "bad_no_module" @then("the returned list should not contain the skipped entry name") def step_verify_skipped_not_in_list(context): """Verify the skipped entry is not in the returned list.""" assert context.skipped_entry_name not in context.registered_names, ( f"Expected {context.skipped_entry_name!r} to be absent, " f"got {context.registered_names}" ) @then("a warning should be logged about the skipped entry") def step_verify_warning_logged(context): """Verify a warning was logged for the skipped entry.""" warnings = [r for r in context.log_handler.records if r.levelno == logging.WARNING] assert any("Skipping custom strategy" in r.getMessage() for r in warnings), ( f"Expected a 'Skipping custom strategy' warning, " f"got: {[r.getMessage() for r in warnings]}" ) @when("I call register_all_from_config with an entry that has an empty class") def step_register_all_missing_class(context): """Call register_all_from_config with a config missing the class key.""" configs = { "bad_no_class": { "module": "some.module", "class": "", }, } context.registered_names = context.registry.register_all_from_config(configs) context.skipped_entry_name = "bad_no_class" # --------------------------------------------------------------------------- # register_all_from_config: PluginLoadError (lines 205-209) # --------------------------------------------------------------------------- @given("a registry whose loader raises PluginLoadError") def step_registry_loader_raises_load_error(context): """Create a registry whose loader always raises PluginLoadError.""" mock_loader = MagicMock() mock_loader.load_class.side_effect = PluginLoadError("boom") context.registry = SandboxStrategyRegistry(loader=mock_loader) # Attach a log handler to capture error logs context.log_handler = _CapturingLogHandler() logger = logging.getLogger("cleveragents.infrastructure.sandbox.strategy_registry") # Re-enable the logger in case Alembic's fileConfig() disabled it logger.disabled = False logger.addHandler(context.log_handler) logger.setLevel(logging.DEBUG) def cleanup(): logger.removeHandler(context.log_handler) context.add_cleanup(cleanup) @when("I call register_all_from_config with an entry that will fail to load") def step_register_all_fail_load(context): """Call register_all_from_config with a valid-looking config that will fail.""" configs = { "failing_strategy": { "module": "nonexistent.module", "class": "NoSuchClass", }, } context.registered_names = context.registry.register_all_from_config(configs) @then("the returned list should be empty") def step_verify_empty_result(context): """Verify no strategies were registered.""" assert context.registered_names == [], ( f"Expected empty list, got {context.registered_names!r}" ) @then("an error should be logged about the failed registration") def step_verify_error_logged(context): """Verify an error was logged for the failed registration.""" errors = [r for r in context.log_handler.records if r.levelno == logging.ERROR] assert any( "Failed to register custom strategy" in r.getMessage() for r in errors ), f"Expected a 'Failed to register' error, got: {[r.getMessage() for r in errors]}" # --------------------------------------------------------------------------- # register_all_from_config: ProtocolMismatchError (lines 205-209) # --------------------------------------------------------------------------- @given("a registry whose loader returns a non-compliant class") def step_registry_loader_returns_bad_class(context): """Create a registry whose loader returns a class that fails protocol check.""" mock_loader = MagicMock() mock_loader.load_class.return_value = _NonCompliantStrategy context.registry = SandboxStrategyRegistry(loader=mock_loader) @when("I call register_all_from_config with an entry that will fail protocol check") def step_register_all_fail_protocol(context): """Call register_all_from_config; the protocol validation will fail.""" configs = { "bad_protocol": { "module": "some.module", "class": "BadClass", }, } context.registered_names = context.registry.register_all_from_config(configs) # --------------------------------------------------------------------------- # Internal helper: capturing log handler # --------------------------------------------------------------------------- class _CapturingLogHandler(logging.Handler): """A logging handler that captures LogRecords for assertion.""" def __init__(self): super().__init__() self.records: list[logging.LogRecord] = [] def emit(self, record: logging.LogRecord) -> None: self.records.append(record)