"""Steps for resource_inheritance_coverage.feature — diff-coverage boost.""" from __future__ import annotations import os import tempfile import types from typing import Any from unittest.mock import patch import yaml from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from cleveragents.application.services.resource_registry_service import ( ResourceRegistryService, ) from cleveragents.core.exceptions import NotFoundError, ValidationError from cleveragents.infrastructure.database.models import Base from cleveragents.resource.handlers.resolver import ( HandlerResolutionError, clear_handler_cache, resolve_handler, resolve_handler_polymorphic, ) from cleveragents.resource.inheritance import ( ResourceTypeParentRemovalError, resolve_fields, ) def _make_service() -> ResourceRegistryService: """Create a ResourceRegistryService backed by an in-memory SQLite DB.""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) factory = sessionmaker(bind=engine) return ResourceRegistryService(session_factory=factory) def _write_yaml(data: dict[str, Any]) -> str: """Write a YAML config to a temp file and return the path.""" fd, path = tempfile.mkstemp(suffix=".yaml") with os.fdopen(fd, "w") as f: yaml.dump(data, f) return path @given("a fresh in-memory registry service for cov") def step_fresh_service(context: Context) -> None: context.cov_svc = _make_service() context.cov_error = None context.cov_result = None # type: ignore[assignment] @given('a YAML config for "acme/child" that inherits "nonexistent-parent" for cov') def step_yaml_nonexistent_parent(context: Context) -> None: context.cov_yaml_path = _write_yaml( { "name": "acme/child", "description": "Child type", "resource_kind": "physical", "sandbox_strategy": "copy_on_write", "inherits": "nonexistent-parent", "user_addable": True, } ) @given( 'a YAML config for "acme/child" that inherits a type causing chain error for cov' ) def step_yaml_chain_error(context: Context) -> None: # Register a parent that itself has an invalid inherits pointing to # a missing grandparent — so chain validation will fail when we # register the child referencing this parent via the service. # Instead, we inject a mock that makes validate_chain raise. context.cov_yaml_path = _write_yaml( { "name": "acme/chain-child", "description": "Chain child", "resource_kind": "physical", "sandbox_strategy": "copy_on_write", "inherits": "git-checkout", } ) context.cov_chain_error_mock = True @given('a YAML config for a built-in type name "git-checkout" for cov') def step_yaml_duplicate(context: Context) -> None: context.cov_yaml_path = _write_yaml( { "name": "git-checkout", "description": "Duplicate", "resource_kind": "physical", "sandbox_strategy": "copy_on_write", "user_addable": True, "built_in": True, "cli_args": [], "parent_types": [], "child_types": [], "handler": "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler", } ) @when("I call register_type with that config for cov") def step_register_type(context: Context) -> None: svc: ResourceRegistryService = context.cov_svc try: if getattr(context, "cov_chain_error_mock", False): from cleveragents.resource.inheritance import ( ResourceTypeParentNotFoundError, ) with patch( "cleveragents.application.services.resource_registry_service" ".validate_chain", side_effect=ResourceTypeParentNotFoundError("Mocked chain error"), ): svc.register_type(context.cov_yaml_path) else: svc.register_type(context.cov_yaml_path) except (ValidationError, NotFoundError) as exc: context.cov_error = exc @then('a ValidationError should be raised mentioning "{text}" for cov') def step_validation_error_mentioning(context: Context, text: str) -> None: assert context.cov_error is not None, "Expected an error but none was raised" assert isinstance(context.cov_error, ValidationError), ( f"Expected ValidationError, got {type(context.cov_error).__name__}" ) assert text.lower() in str(context.cov_error).lower(), ( f"Expected '{text}' in error message, got: {context.cov_error}" ) @then("a ValidationError should be raised mentioning chain error for cov") def step_validation_error_chain(context: Context) -> None: assert context.cov_error is not None, "Expected an error but none was raised" assert isinstance(context.cov_error, ValidationError), ( f"Expected ValidationError, got {type(context.cov_error).__name__}" ) @when('I call remove_type for "{name}" for cov') def step_remove_type(context: Context, name: str) -> None: svc: ResourceRegistryService = context.cov_svc try: svc.remove_type(name) except ( ValidationError, NotFoundError, ResourceTypeParentRemovalError, ) as exc: context.cov_error = exc @given('a custom type "{name}" is registered for cov') def step_register_custom_type(context: Context, name: str) -> None: path = _write_yaml( { "name": name, "description": f"Custom type {name}", "resource_kind": "physical", "sandbox_strategy": "copy_on_write", "user_addable": True, } ) context.cov_svc.register_type(path) @given('a resource of type "{type_name}" exists for cov') def step_register_resource(context: Context, type_name: str) -> None: context.cov_svc.register_resource(type_name, name=f"test/{type_name}-res") @given('a child type "{child}" inheriting "{parent}" is registered for cov') def step_register_child_type(context: Context, child: str, parent: str) -> None: path = _write_yaml( { "name": child, "description": f"Child type {child}", "resource_kind": "physical", "sandbox_strategy": "copy_on_write", "inherits": parent, } ) context.cov_svc.register_type(path) @then("a ResourceTypeParentRemovalError should be raised for cov") def step_parent_removal_error(context: Context) -> None: assert context.cov_error is not None, "Expected error" assert isinstance(context.cov_error, ResourceTypeParentRemovalError) @then("no error should be raised for cov") def step_no_error(context: Context) -> None: assert context.cov_error is None, f"Expected no error, got: {context.cov_error}" @when('I call resolve_type_inheritance_chain for "{name}" for cov') def step_resolve_chain(context: Context, name: str) -> None: svc: ResourceRegistryService = context.cov_svc try: context.cov_result = svc.resolve_type_inheritance_chain(name) except (NotFoundError, ValidationError) as exc: context.cov_error = exc @then('the chain should contain "{a}" and "{b}"') def step_chain_contains(context: Context, a: str, b: str) -> None: chain = context.cov_result assert isinstance(chain, list), f"Expected list, got {type(chain)}" assert a in chain, f"Expected '{a}' in chain {chain}" assert b in chain, f"Expected '{b}' in chain {chain}" @then("a NotFoundError should be raised for cov") def step_not_found_error(context: Context) -> None: assert context.cov_error is not None, "Expected error" assert isinstance(context.cov_error, NotFoundError) @when('I check is_subtype_of "{child}" of "{ancestor}" for cov') def step_is_subtype_of(context: Context, child: str, ancestor: str) -> None: svc: ResourceRegistryService = context.cov_svc context.cov_result = svc.is_subtype_of(child, ancestor) @then("the is_subtype_of result should be True for cov") def step_result_true(context: Context) -> None: assert context.cov_result is True @then("the is_subtype_of result should be False for cov") def step_result_false(context: Context) -> None: assert context.cov_result is False @given('a resource of type "container-instance" exists for cov ops') def step_register_container_resource(context: Context) -> None: svc: ResourceRegistryService = context.cov_svc svc.register_resource("container-instance", name="test/my-container") @when('I call list_resources with type_name "{tn}" and exact=True for cov') def step_list_exact(context: Context, tn: str) -> None: svc: ResourceRegistryService = context.cov_svc context.cov_result = svc.list_resources(type_name=tn, exact=True) @then('the result list should contain only "{tn}" typed resources for cov') def step_list_only_type(context: Context, tn: str) -> None: for r in context.cov_result: assert r.resource_type_name == tn, ( f"Expected type '{tn}', got '{r.resource_type_name}'" ) @given("two resources of incompatible types for cov ops") def step_incompatible_resources(context: Context) -> None: svc: ResourceRegistryService = context.cov_svc # devcontainer-file allows parent_types=["devcontainer-instance"] # and child_types=[] — so it cannot be a parent of anything. # git-checkout has child_types that do NOT include "container-instance". svc.register_resource("git-checkout", name="test/parent-gc", location="/tmp/gc") svc.register_resource("container-instance", name="test/child-ci") context.cov_parent_name = "test/parent-gc" context.cov_child_name = "test/child-ci" @when("I call link_child with incompatible types for cov ops") def step_link_incompatible(context: Context) -> None: svc: ResourceRegistryService = context.cov_svc try: svc.link_child(context.cov_parent_name, context.cov_child_name) except (ValidationError, NotFoundError) as exc: context.cov_error = exc @given("a parent resource with children of mixed types for cov ops") def step_mixed_children(context: Context) -> None: svc: ResourceRegistryService = context.cov_svc svc.register_resource("git-checkout", name="test/tree-root", location="/tmp/tree") svc.register_resource("fs-directory", name="test/child-fs", location="/tmp/fs") svc.register_resource( "devcontainer-instance", name="test/child-dc", location="/tmp/dc" ) svc.link_child("test/tree-root", "test/child-fs") svc.link_child("test/tree-root", "test/child-dc") @when("I call get_resource_tree with type_filter for cov ops") def step_tree_filter(context: Context) -> None: svc: ResourceRegistryService = context.cov_svc context.cov_result = svc.get_resource_tree( "test/tree-root", type_filter="fs-directory" ) @then("the tree should only contain children matching the filter for cov") def step_tree_filter_check(context: Context) -> None: tree = context.cov_result assert len(tree) == 1 root_node = tree[0] children = root_node["children"] for child_node in children: r = child_node["resource"] assert r.resource_type_name == "fs-directory", ( f"Expected fs-directory, got {r.resource_type_name}" ) @then('a HandlerResolutionError should be raised mentioning "{text}" for cov') def step_handler_error_mentioning(context: Context, text: str) -> None: assert context.cov_error is not None, "Expected error" assert isinstance(context.cov_error, HandlerResolutionError), ( f"Expected HandlerResolutionError, got {type(context.cov_error).__name__}" ) assert text.lower() in str(context.cov_error).lower(), ( f"Expected '{text}' in '{context.cov_error}'" ) @when("I call resolve_handler with a class that fails to instantiate for cov") def step_resolve_handler_instantiate_error(context: Context) -> None: clear_handler_cache() # Create a temp module with a class whose __init__ raises mod = types.ModuleType("_cov_test_bad_init") exec( "class BadHandler:\n def __init__(self):\n" " raise RuntimeError('boom')", mod.__dict__, ) import sys sys.modules["_cov_test_bad_init"] = mod try: resolve_handler("_cov_test_bad_init:BadHandler") except HandlerResolutionError as exc: context.cov_error = exc finally: sys.modules.pop("_cov_test_bad_init", None) @when("I call resolve_handler with a module that has an import error for cov") def step_resolve_handler_import_error(context: Context) -> None: clear_handler_cache() # Patch importlib.import_module to raise RuntimeError (not # ModuleNotFoundError) to hit the generic except at resolver.py:91-94. with patch( "importlib.import_module", side_effect=RuntimeError("synthetic import error"), ): try: resolve_handler("some.module:SomeClass") except HandlerResolutionError as exc: context.cov_error = exc @when( "I call resolve_handler with a class that does not satisfy ResourceHandler for cov" ) def step_resolve_handler_not_protocol(context: Context) -> None: clear_handler_cache() mod = types.ModuleType("_cov_test_no_protocol") exec("class NotAHandler:\n pass", mod.__dict__) import sys sys.modules["_cov_test_no_protocol"] = mod try: resolve_handler("_cov_test_no_protocol:NotAHandler") except HandlerResolutionError as exc: context.cov_error = exc finally: sys.modules.pop("_cov_test_no_protocol", None) @when("I call resolve_handler_polymorphic with a broken chain for cov") def step_resolve_handler_poly_broken(context: Context) -> None: clear_handler_cache() # Registry with a cycle registry: dict[str, Any] = { "typeA": {"inherits": "typeB", "handler": None}, "typeB": {"inherits": "typeA", "handler": None}, } try: resolve_handler_polymorphic("typeA", registry) except HandlerResolutionError as exc: context.cov_error = exc @given("a type registry with child having no handler but ancestor has one for cov") def step_registry_ancestor_handler(context: Context) -> None: context.cov_poly_registry = { "base-type": { "inherits": None, "handler": ( "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler" ), }, "mid-type": {"inherits": "base-type", "handler": None}, "leaf-type": {"inherits": "mid-type", "handler": None}, } @when("I call resolve_handler_polymorphic for the child type for cov") def step_resolve_poly_child(context: Context) -> None: clear_handler_cache() try: context.cov_result = resolve_handler_polymorphic( "leaf-type", context.cov_poly_registry ) except HandlerResolutionError as exc: context.cov_error = exc @then("a valid handler should be returned for cov") def step_valid_handler(context: Context) -> None: from cleveragents.resource.handlers.protocol import ResourceHandler assert context.cov_result is not None assert isinstance(context.cov_result, ResourceHandler) @given("a registry where parent has properties and child overrides one for cov") def step_registry_properties(context: Context) -> None: context.cov_prop_registry = { "base": { "inherits": None, "properties": {"color": "red", "size": "large"}, "capabilities": {"read": True}, }, "child": { "inherits": "base", "properties": {"color": "blue"}, "capabilities": {"write": True}, }, } @when("I call resolve_fields for the child for cov") def step_resolve_fields_child(context: Context) -> None: context.cov_result = resolve_fields("child", context.cov_prop_registry) @then("the child properties should replace parent properties for cov") def step_merged_properties(context: Context) -> None: result = context.cov_result props = result.get("properties", {}) # After P2-2, properties is a scalar override: child replaces parent entirely. assert props.get("color") == "blue", f"Expected 'blue', got {props.get('color')}" assert props.get("size") is None, ( f"Expected None (not inherited), got {props.get('size')}" ) @given("a registry with a gap in the chain for cov") def step_registry_gap(context: Context) -> None: context.cov_gap_registry = { "child": {"inherits": "base", "capabilities": {"read": True}}, "base": {"inherits": None, "capabilities": {"write": True}}, } context.cov_gap_type = "child" @when("I call resolve_fields for the type with a gap for cov") def step_resolve_fields_gap(context: Context) -> None: from unittest.mock import patch as _patch with _patch( "cleveragents.resource.inheritance.resolve_inheritance_chain", return_value=["child", "nonexistent", "base"], ): context.cov_result = resolve_fields( context.cov_gap_type, context.cov_gap_registry ) @then("the result should still contain the available fields for cov") def step_result_has_fields(context: Context) -> None: result = context.cov_result assert isinstance(result, dict) # Should have merged capabilities from child and base, skipping gap caps = result.get("capabilities", {}) assert caps.get("read") is True or caps.get("write") is True, ( f"Expected some capabilities, got {caps}" )