Files
cleveragents-core/features/steps/resource_inheritance_coverage_steps.py
T
hamza.khyari 135481f9a6
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Failing after 25s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 36s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Failing after 25s
CI / integration_tests (pull_request) Failing after 2m19s
CI / benchmark-regression (pull_request) Has been cancelled
fix(resource): address PR #618 review findings (round 1)
- P2-1: Add field_validator('inherits') on ResourceTypeSpec
- P2-2: Remove 'properties' from _COLLECTION_FIELDS (scalar override)
- P2-3: Add behave scenarios for exception rollback paths
- P2-4: Add ordering comment to BUILTIN_TYPES
- P2-5: Define RegistryHost Protocol, remove type:ignore on mixins
- P2-6: Add if_not_exists=True on migration index creation
- P3-1: Use deque.popleft() in get_ancestors for O(1) BFS
- P3-2: Filter private attrs in _to_dict vars() fallback
- P3-3: Replace _handler_cache import with clear_handler_cache()
- P3-4: Add migration downgrade test for m6_004
- P3-5: Extract ResourceDagMixin to _resource_registry_dag.py

All files remain under 500-line CONTRIBUTING limit.
Lint, typecheck, and 85 behave scenarios pass.
2026-03-10 04:06:02 +00:00

494 lines
17 KiB
Python

"""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}"
)