Files
cleveragents-core/features/steps/resource_handlers_steps.py
T
khyari hamza 1a0a501f66 refactor(resource): address PR review feedback for handler runtime
- extract BaseResourceHandler to eliminate ~90% duplication between
  GitCheckoutHandler and FsDirectoryHandler
- raise RuntimeError when sandbox.context is None instead of silent
  empty string fallback
- add threading.Lock to resolver handler cache for thread safety
- type resource_lookup/type_lookup as Callable instead of Any
- log original HandlerResolutionError at DEBUG before fallback
- use behave.runner.Context in step definitions per repo convention
2026-02-23 22:20:44 +00:00

463 lines
16 KiB
Python

"""Step definitions for resource_handlers.feature.
Tests the resource handler protocol, GitCheckoutHandler, FsDirectoryHandler,
handler resolver, and ResourceHandlerService orchestration bridge.
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, PropertyMock
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
SandboxStrategy,
)
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
)
from cleveragents.domain.models.core.resource_type import (
SandboxStrategy as TypeSandboxStrategy,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
SandboxContext,
SandboxStatus,
)
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
HandlerResolutionError,
clear_handler_cache,
resolve_handler,
)
from cleveragents.tool.context import BoundResource
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_resource(
resource_type_name: str,
location: str | None = None,
sandbox_strategy: SandboxStrategy | None = None,
resource_id: str = "01KJ5C5TPMP8GGX3QC83E2MAQS",
) -> Resource:
"""Create a test Resource domain object."""
return Resource(
resource_id=resource_id,
resource_type_name=resource_type_name,
classification=PhysVirt.PHYSICAL,
location=location,
sandbox_strategy=sandbox_strategy,
capabilities=ResourceCapabilities(
readable=True, writable=True, sandboxable=True
),
)
def _make_mock_sandbox_manager() -> tuple[SandboxManager, MagicMock]:
"""Create a SandboxManager with a mock factory that tracks calls."""
mock_factory = MagicMock(spec=SandboxFactory)
mock_sandbox = MagicMock()
mock_sandbox.sandbox_id = "sb-test-001"
type(mock_sandbox).status = PropertyMock(return_value=SandboxStatus.CREATED)
mock_sandbox.context = SandboxContext(
sandbox_id="sb-test-001",
sandbox_path="/tmp/sandbox/sb-test-001",
original_path="/tmp/original",
resource_id="res-test",
plan_id="plan-test",
created_at=datetime.now(),
)
mock_sandbox.create.return_value = mock_sandbox.context
mock_factory.create_sandbox.return_value = mock_sandbox
manager = SandboxManager(factory=mock_factory, cleanup_on_exit=False)
return manager, mock_factory
def _make_type_spec(
name: str = "git-checkout",
sandbox_strategy: str = "git_worktree",
handler: str
| None = "cleveragents.resource.handlers.git_checkout:GitCheckoutHandler",
) -> ResourceTypeSpec:
"""Create a minimal ResourceTypeSpec for testing."""
return ResourceTypeSpec(
name=name,
description=f"Test {name} type",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=TypeSandboxStrategy(sandbox_strategy),
user_addable=True,
built_in=True,
handler=handler,
)
# ---------------------------------------------------------------------------
# Protocol conformance
# ---------------------------------------------------------------------------
@then("GitCheckoutHandler should satisfy the ResourceHandler protocol")
def step_git_handler_protocol(context: Context) -> None:
handler = GitCheckoutHandler()
assert isinstance(handler, ResourceHandler), (
"GitCheckoutHandler does not satisfy ResourceHandler protocol"
)
@then("FsDirectoryHandler should satisfy the ResourceHandler protocol")
def step_fs_handler_protocol(context: Context) -> None:
handler = FsDirectoryHandler()
assert isinstance(handler, ResourceHandler), (
"FsDirectoryHandler does not satisfy ResourceHandler protocol"
)
# ---------------------------------------------------------------------------
# GitCheckoutHandler
# ---------------------------------------------------------------------------
@given('a git-checkout resource with location "{location}"')
def step_git_resource(context: Context, location: str) -> None:
context.handler_resource = _make_resource("git-checkout", location=location)
@given('a git-checkout resource at "{location}" using strategy "{strategy}"')
def step_git_resource_with_strategy(
context: Context, location: str, strategy: str
) -> None:
context.handler_resource = _make_resource(
"git-checkout",
location=location,
sandbox_strategy=SandboxStrategy(strategy),
)
@given("a git-checkout resource without location")
def step_git_resource_no_location(context: Context) -> None:
context.handler_resource = _make_resource("git-checkout", location=None)
@given("a sandbox manager with a mock factory")
def step_mock_sandbox_manager(context: Context) -> None:
context.handler_sandbox_manager, context.handler_mock_factory = (
_make_mock_sandbox_manager()
)
@when('I resolve the resource with GitCheckoutHandler for plan "{plan_id}"')
def step_resolve_git(context: Context, plan_id: str) -> None:
handler = GitCheckoutHandler()
context.handler_bound = handler.resolve(
resource=context.handler_resource,
plan_id=plan_id,
slot_name="repo",
sandbox_manager=context.handler_sandbox_manager,
)
@when("I try to resolve the resource with GitCheckoutHandler")
def step_try_resolve_git(context: Context) -> None:
handler = GitCheckoutHandler()
try:
handler.resolve(
resource=context.handler_resource,
plan_id="PLAN_ERR",
slot_name="repo",
sandbox_manager=context.handler_sandbox_manager,
)
context.handler_error = None
except ValueError as exc:
context.handler_error = exc
# ---------------------------------------------------------------------------
# FsDirectoryHandler
# ---------------------------------------------------------------------------
@given('an fs-directory resource with location "{location}"')
def step_fs_resource(context: Context, location: str) -> None:
context.handler_resource = _make_resource("fs-directory", location=location)
@given("an fs-directory resource without location")
def step_fs_resource_no_location(context: Context) -> None:
context.handler_resource = _make_resource("fs-directory", location=None)
@when('I resolve the resource with FsDirectoryHandler for plan "{plan_id}"')
def step_resolve_fs(context: Context, plan_id: str) -> None:
handler = FsDirectoryHandler()
context.handler_bound = handler.resolve(
resource=context.handler_resource,
plan_id=plan_id,
slot_name="workdir",
sandbox_manager=context.handler_sandbox_manager,
)
@when("I try to resolve the resource with FsDirectoryHandler")
def step_try_resolve_fs(context: Context) -> None:
handler = FsDirectoryHandler()
try:
handler.resolve(
resource=context.handler_resource,
plan_id="PLAN_ERR",
slot_name="workdir",
sandbox_manager=context.handler_sandbox_manager,
)
context.handler_error = None
except ValueError as exc:
context.handler_error = exc
# ---------------------------------------------------------------------------
# Shared handler assertions
# ---------------------------------------------------------------------------
@then('the bound resource should have slot name "{name}"')
def step_bound_slot(context: Context, name: str) -> None:
assert context.handler_bound.slot_name == name
@then('the bound resource should have resource type "{rtype}"')
def step_bound_type(context: Context, rtype: str) -> None:
assert context.handler_bound.resource_type == rtype
@then("the bound resource sandbox path should not be empty")
def step_bound_sandbox_not_empty(context: Context) -> None:
assert context.handler_bound.sandbox_path, (
f"sandbox_path is empty: {context.handler_bound.sandbox_path!r}"
)
@then('the sandbox was created with strategy "{strategy}"')
def step_sandbox_strategy(context: Context, strategy: str) -> None:
call_args = context.handler_mock_factory.create_sandbox.call_args
assert call_args is not None, "create_sandbox was not called"
assert call_args.kwargs.get("sandbox_strategy") == strategy or (
len(call_args.args) >= 3 and call_args.args[2] == strategy
), f"Expected strategy {strategy}, got {call_args}"
@then("a handler ValueError should be raised")
def step_handler_value_error(context: Context) -> None:
assert context.handler_error is not None
assert isinstance(context.handler_error, ValueError)
@then('the handler error should mention "{text}"')
def step_handler_error_text(context: Context, text: str) -> None:
assert text.lower() in str(context.handler_error).lower(), (
f"Expected '{text}' in error: {context.handler_error}"
)
# ---------------------------------------------------------------------------
# Handler Resolver
# ---------------------------------------------------------------------------
@when('I resolve handler "{ref}"')
def step_resolve_handler(context: Context, ref: str) -> None:
clear_handler_cache()
context.handler_resolved = resolve_handler(ref)
context.handler_ref_used = ref
@when("I resolve the same handler reference again")
def step_resolve_handler_again(context: Context) -> None:
context.handler_resolved_second = resolve_handler(context.handler_ref_used)
@then("the resolved handler should be a GitCheckoutHandler instance")
def step_resolved_is_git(context: Context) -> None:
assert isinstance(context.handler_resolved, GitCheckoutHandler)
@then("the resolved handler should be a FsDirectoryHandler instance")
def step_resolved_is_fs(context: Context) -> None:
assert isinstance(context.handler_resolved, FsDirectoryHandler)
@then("both resolved handlers should be the same object")
def step_same_object(context: Context) -> None:
assert context.handler_resolved is context.handler_resolved_second
@when("I try to resolve an empty handler reference")
def step_try_resolve_empty_handler(context: Context) -> None:
clear_handler_cache()
try:
resolve_handler("")
context.handler_resolution_error = None
except HandlerResolutionError as exc:
context.handler_resolution_error = exc
@when('I try to resolve handler "{ref}"')
def step_try_resolve_handler(context: Context, ref: str) -> None:
clear_handler_cache()
try:
resolve_handler(ref)
context.handler_resolution_error = None
except HandlerResolutionError as exc:
context.handler_resolution_error = exc
@then("a HandlerResolutionError should be raised")
def step_resolution_error(context: Context) -> None:
assert context.handler_resolution_error is not None
assert isinstance(context.handler_resolution_error, HandlerResolutionError)
@then('the resolution error should mention "{text}"')
def step_resolution_error_text(context: Context, text: str) -> None:
assert text.lower() in str(context.handler_resolution_error).lower(), (
f"Expected '{text}' in error: {context.handler_resolution_error}"
)
# ---------------------------------------------------------------------------
# ResourceHandlerService
# ---------------------------------------------------------------------------
@given("a resource handler service with mock lookups")
def step_handler_service(context: Context) -> None:
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
manager, _mock_factory = _make_mock_sandbox_manager()
resource = _make_resource("git-checkout", location="/tmp/test-repo")
type_spec = _make_type_spec()
context.handler_svc = ResourceHandlerService(
sandbox_manager=manager,
resource_lookup=lambda _name_or_id: resource,
type_lookup=lambda _name: type_spec,
)
context.handler_svc_manager = manager
@given("a resource handler service with mock lookups and no handler string")
def step_handler_service_no_handler(context: Context) -> None:
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
manager, _mock_factory = _make_mock_sandbox_manager()
resource = _make_resource("fs-directory", location="/tmp/test-dir")
type_spec = _make_type_spec(
name="fs-directory",
sandbox_strategy="copy_on_write",
handler=None,
)
context.handler_svc = ResourceHandlerService(
sandbox_manager=manager,
resource_lookup=lambda _name_or_id: resource,
type_lookup=lambda _name: type_spec,
)
@given('a binding result for slot "{slot}" with resource id "{res_id}"')
def step_binding_result(context: Context, slot: str, res_id: str) -> None:
if not hasattr(context, "handler_bindings"):
context.handler_bindings = []
context.handler_binding = BindingResult(
slot_name=slot,
resource_id=res_id,
resource_name=f"test-{res_id}",
binding_mode="contextual",
)
context.handler_bindings.append(context.handler_binding)
@given('a deferred binding result for slot "{slot}"')
def step_deferred_binding(context: Context, slot: str) -> None:
if not hasattr(context, "handler_bindings"):
context.handler_bindings = []
context.handler_deferred_binding = BindingResult(
slot_name=slot,
resource_id=None,
binding_mode="parameter",
deferred=True,
)
context.handler_bindings.append(context.handler_deferred_binding)
@when('I resolve the binding via resource handler service for plan "{plan_id}"')
def step_svc_resolve_binding(context: Context, plan_id: str) -> None:
context.handler_svc_bound = context.handler_svc.resolve_binding(
binding=context.handler_binding,
plan_id=plan_id,
)
@when('I resolve all bindings via resource handler service for plan "{plan_id}"')
def step_svc_resolve_all(context: Context, plan_id: str) -> None:
context.handler_svc_all = context.handler_svc.resolve_bindings(
bindings=context.handler_bindings,
plan_id=plan_id,
)
@when("I try to resolve the single deferred binding via resource handler service")
def step_svc_try_resolve_deferred(context: Context) -> None:
try:
context.handler_svc.resolve_binding(
binding=context.handler_deferred_binding,
plan_id="PLAN_ERR",
)
context.handler_error = None
except ValueError as exc:
context.handler_error = exc
@then("the handler service should return a BoundResource")
def step_svc_is_bound(context: Context) -> None:
assert isinstance(context.handler_svc_bound, BoundResource)
@then('the handler service BoundResource slot should be "{slot}"')
def step_svc_bound_slot(context: Context, slot: str) -> None:
assert context.handler_svc_bound.slot_name == slot
@then("the handler service BoundResource sandbox path should not be empty")
def step_svc_bound_path(context: Context) -> None:
assert context.handler_svc_bound.sandbox_path, (
f"sandbox_path is empty: {context.handler_svc_bound.sandbox_path!r}"
)
@then("the handler service should return {count:d} bound resource")
def step_svc_count(context: Context, count: int) -> None:
assert len(context.handler_svc_all) == count
@then('the handler service should have resolved slot "{slot}"')
def step_svc_has_slot(context: Context, slot: str) -> None:
assert slot in context.handler_svc_all