Files
cleveragents-core/features/steps/resource_cli_tree_steps.py
T
freemo ad91b4e89d feat(cli): add resource tree and inspect commands
Add tree, inspect, link-child, and unlink-child subcommands to the
agents resource CLI group. Extend ResourceRegistryService with
link_child, unlink_child, get_children, and get_resource_tree methods.

- tree: display resource hierarchy with --depth, --type, and format options
- inspect: show resource details with --tree and --file options
- link-child/unlink-child: manage DAG edges with cycle detection
- 24 Behave scenarios, 4 Robot integration tests, ASV benchmarks
- CLI reference documentation in docs/reference/resource_cli.md
2026-02-20 08:48:33 -05:00

619 lines
21 KiB
Python

"""Step definitions for resource_cli_tree.feature."""
from __future__ import annotations
import json
import tempfile
from io import StringIO
from pathlib import Path
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from rich.console import Console
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.infrastructure.database.models import Base
def _make_tree_service(context: Context) -> ResourceRegistryService:
"""Create or return a cached ResourceRegistryService for tree tests."""
if not hasattr(context, "tree_cli_service"):
engine = create_engine("sqlite:///:memory:", echo=False)
@event.listens_for(engine, "connect")
def _fk(conn: Any, _rec: Any) -> None:
conn.cursor().execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
context.tree_cli_service = ResourceRegistryService(session_factory=factory)
return context.tree_cli_service
def _capture_tree_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
"""Run a CLI function capturing its console output and success status."""
buf = StringIO()
console = Console(file=buf, width=200, no_color=True)
import cleveragents.cli.commands.resource as resource_mod
orig_console = resource_mod.console
resource_mod.console = console
failed = False
try:
func(*args, **kwargs)
except SystemExit:
failed = True
except Exception:
failed = True
finally:
resource_mod.console = orig_console
return buf.getvalue(), failed
def _patch_tree_service(context: Context) -> Any:
"""Monkey-patch the container to return our in-memory service."""
import cleveragents.cli.commands.resource as resource_mod
orig_fn = resource_mod._get_registry_service
def _mock_get() -> ResourceRegistryService:
return _make_tree_service(context)
resource_mod._get_registry_service = _mock_get
return orig_fn
def _unpatch_tree_service(orig_fn: Any) -> None:
"""Restore original service getter."""
import cleveragents.cli.commands.resource as resource_mod
resource_mod._get_registry_service = orig_fn
# ---- Background ----
@given("a fresh resource tree test registry")
def step_fresh_tree_registry(context: Context) -> None:
"""Set up a fresh in-memory database for tree tests."""
engine = create_engine("sqlite:///:memory:", echo=False)
@event.listens_for(engine, "connect")
def _fk(conn: Any, _rec: Any) -> None:
conn.cursor().execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
context.tree_cli_service = ResourceRegistryService(session_factory=factory)
context.tree_cli_output = ""
context.tree_cli_failed = False
context.tree_temp_dir = None
@given("resource tree built-in types are bootstrapped")
def step_tree_bootstrap(context: Context) -> None:
"""Bootstrap built-in resource types for tree tests."""
service = _make_tree_service(context)
service.bootstrap_builtin_types()
@given('a resource tree resource "{type_name}" named "{name}" at "{path}"')
def step_tree_add_resource(
context: Context, type_name: str, name: str, path: str
) -> None:
"""Add a resource for tree tests."""
service = _make_tree_service(context)
service.register_resource(
type_name=type_name,
name=name,
location=path,
description=f"Test resource {name}",
read_only=False,
properties={"path": path},
)
@given('a resource tree resource "{type_name}" named "{name}" at a temp directory')
def step_tree_add_resource_temp(context: Context, type_name: str, name: str) -> None:
"""Add a resource at a temp directory."""
tmp_dir = tempfile.mkdtemp()
context.tree_temp_dir = tmp_dir
service = _make_tree_service(context)
service.register_resource(
type_name=type_name,
name=name,
location=tmp_dir,
description=f"Test resource {name}",
read_only=False,
properties={"path": tmp_dir},
)
@given('a temp file "{filename}" with content "{content}"')
def step_tree_create_temp_file(context: Context, filename: str, content: str) -> None:
"""Create a temp file in the temp directory."""
tmp_dir = context.tree_temp_dir
filepath = Path(tmp_dir) / filename
filepath.write_text(content, encoding="utf-8")
@given('resource tree child "{child}" is linked to parent "{parent}"')
def step_tree_link_child(context: Context, child: str, parent: str) -> None:
"""Link a child to a parent."""
service = _make_tree_service(context)
service.link_child(parent, child)
# ---- Tree commands ----
@when('I display resource tree for "{name}"')
def step_run_tree(context: Context, name: str) -> None:
"""Run resource tree command."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I display resource tree depth-limited to {depth:d} for "{name}"')
def step_run_tree_depth(context: Context, depth: int, name: str) -> None:
"""Run resource tree with depth limit."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=depth, type_filter=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I display resource tree type-filtered to "{type_filter}" for "{name}"')
def step_run_tree_type(context: Context, type_filter: str, name: str) -> None:
"""Run resource tree with type filter."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=type_filter, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I display resource tree as "{fmt}" for "{name}"')
def step_run_tree_fmt(context: Context, fmt: str, name: str) -> None:
"""Run resource tree with format."""
from cleveragents.cli.commands.resource import resource_tree
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=None, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Inspect commands ----
@when('I run basic inspect on "{name}"')
def step_run_inspect(context: Context, name: str) -> None:
"""Run resource inspect command."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I run inspect with subtree on "{name}"')
def step_run_inspect_tree(context: Context, name: str) -> None:
"""Run resource inspect with --tree."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=True, file=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I run inspect with file "{filepath}" on "{name}"')
def step_run_inspect_file(context: Context, filepath: str, name: str) -> None:
"""Run resource inspect with --file."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=filepath, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I run inspect formatted as "{fmt}" on "{name}"')
def step_run_inspect_fmt(context: Context, fmt: str, name: str) -> None:
"""Run resource inspect with format."""
from cleveragents.cli.commands.resource import resource_inspect
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=None, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Link-child commands ----
@when('I link DAG child "{child}" to parent "{parent}"')
def step_run_link_child(context: Context, child: str, parent: str) -> None:
"""Run resource link-child command."""
from cleveragents.cli.commands.resource import resource_link_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_link_child, parent=parent, child=child, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I link as "{fmt}" DAG child "{child}" to parent "{parent}"')
def step_run_link_child_fmt(
context: Context, fmt: str, child: str, parent: str
) -> None:
"""Run resource link-child with format."""
from cleveragents.cli.commands.resource import resource_link_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_link_child, parent=parent, child=child, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Unlink-child commands ----
@when('I unlink DAG child "{child}" from parent "{parent}" confirmed')
def step_run_unlink_child_yes(context: Context, child: str, parent: str) -> None:
"""Run resource unlink-child with --yes."""
from cleveragents.cli.commands.resource import resource_unlink_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_unlink_child, parent=parent, child=child, yes=True, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I unlink as "{fmt}" DAG child "{child}" from parent "{parent}" confirmed')
def step_run_unlink_child_yes_fmt(
context: Context, fmt: str, child: str, parent: str
) -> None:
"""Run resource unlink-child with --yes and format."""
from cleveragents.cli.commands.resource import resource_unlink_child
orig = _patch_tree_service(context)
try:
output, failed = _capture_tree_output(
resource_unlink_child, parent=parent, child=child, yes=True, fmt=fmt
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
@when('I unlink DAG child "{child}" from parent "{parent}" declined')
def step_run_unlink_child_no_yes(context: Context, child: str, parent: str) -> None:
"""Run resource unlink-child without --yes (confirmation denied)."""
from cleveragents.cli.commands.resource import resource_unlink_child
orig = _patch_tree_service(context)
try:
with patch(
"cleveragents.cli.commands.resource.typer.confirm",
return_value=False,
):
output, failed = _capture_tree_output(
resource_unlink_child,
parent=parent,
child=child,
yes=False,
fmt="rich",
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
_unpatch_tree_service(orig)
# ---- Assertions ----
@then('the tree output should contain "{text}"')
def step_tree_output_contains(context: Context, text: str) -> None:
"""Check that CLI output contains the expected text."""
output = context.tree_cli_output
assert text.lower() in output.lower(), (
f"Expected '{text}' in output, got:\n{output}"
)
@then('the tree output should not contain "{text}"')
def step_tree_output_not_contains(context: Context, text: str) -> None:
"""Check that CLI output does not contain the text."""
output = context.tree_cli_output
assert text.lower() not in output.lower(), (
f"Did not expect '{text}' in output, got:\n{output}"
)
@then("the tree command should fail")
def step_tree_command_failed(context: Context) -> None:
"""Check that the command failed."""
assert context.tree_cli_failed, "Expected command to fail but it succeeded"
@then("the tree output should be valid JSON")
def step_tree_output_valid_json(context: Context) -> None:
"""Check that the output is valid JSON."""
output = context.tree_cli_output.strip()
try:
json.loads(output)
except json.JSONDecodeError as exc:
raise AssertionError(f"Output is not valid JSON: {exc}\n{output}") from exc
@then('the tree output should show "{first}" before "{second}"')
def step_tree_output_order(context: Context, first: str, second: str) -> None:
"""Check that first appears before second in output."""
output = context.tree_cli_output.lower()
first_pos = output.find(first.lower())
second_pos = output.find(second.lower())
assert first_pos >= 0, f"'{first}' not found in output:\n{context.tree_cli_output}"
assert second_pos >= 0, (
f"'{second}' not found in output:\n{context.tree_cli_output}"
)
assert first_pos < second_pos, (
f"'{first}' (pos {first_pos}) should appear before "
f"'{second}' (pos {second_pos}) in output:\n{context.tree_cli_output}"
)
# ---- get_children steps ----
@when('I get children of resource "{name}"')
def step_get_children(context: Context, name: str) -> None:
"""Call get_children on the service."""
service = _make_tree_service(context)
context.tree_children_result = service.get_children(name)
@then("the children list should have {count:d} items")
def step_children_count(context: Context, count: int) -> None:
"""Assert children list length."""
assert len(context.tree_children_result) == count, (
f"Expected {count} children, got {len(context.tree_children_result)}"
)
@then('the first child name should be "{name}"')
def step_first_child_name(context: Context, name: str) -> None:
"""Assert the first child's name."""
first = context.tree_children_result[0]
assert first.name == name, f"Expected first child '{name}', got '{first.name}'"
# ---- No-properties resource ----
@given(
'a resource tree resource "{type_name}" named "{name}" at "{path}" with no properties'
)
def step_tree_add_resource_no_props(
context: Context, type_name: str, name: str, path: str
) -> None:
"""Add a resource with no properties."""
service = _make_tree_service(context)
service.register_resource(
type_name=type_name,
name=name,
location=path,
description=f"Test resource {name}",
read_only=False,
properties=None,
)
# ---- No-location resource ----
@given('a resource tree resource "{type_name}" named "{name}" with no location')
def step_tree_add_resource_no_location(
context: Context, type_name: str, name: str
) -> None:
"""Add a resource with no location and no properties."""
service = _make_tree_service(context)
service.register_resource(
type_name=type_name,
name=name,
location=None,
description=f"Test resource {name}",
read_only=False,
properties=None,
)
# ---- Unreadable file ----
@given('a temp file that is unreadable named "{filename}"')
def step_create_unreadable_file(context: Context, filename: str) -> None:
"""Create a file that cannot be read (a directory posing as a file)."""
tmp_dir = context.tree_temp_dir
filepath = Path(tmp_dir) / filename
# Create a subdirectory with the filename - reading it as text will raise OSError
filepath.mkdir(parents=True, exist_ok=True)
# ---- CleverAgentsError steps ----
@when('I display resource tree for "{name}" with a service error')
def step_run_tree_with_error(context: Context, name: str) -> None:
"""Run resource tree but make get_resource_tree raise CleverAgentsError."""
from cleveragents.cli.commands.resource import resource_tree
from cleveragents.core.exceptions import CleverAgentsError as CAError
orig = _patch_tree_service(context)
service = _make_tree_service(context)
original_get_tree = service.get_resource_tree
def _raise_error(*args: Any, **kwargs: Any) -> Any:
raise CAError("simulated service error")
service.get_resource_tree = _raise_error
try:
output, failed = _capture_tree_output(
resource_tree, resource=name, depth=-1, type_filter=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
service.get_resource_tree = original_get_tree
_unpatch_tree_service(orig)
@when('I run inspect on "{name}" with a service error')
def step_run_inspect_with_error(context: Context, name: str) -> None:
"""Run resource inspect but make show_resource raise CleverAgentsError."""
from cleveragents.cli.commands.resource import resource_inspect
from cleveragents.core.exceptions import CleverAgentsError as CAError
orig = _patch_tree_service(context)
service = _make_tree_service(context)
original_show = service.show_resource
def _raise_error(*args: Any, **kwargs: Any) -> Any:
raise CAError("simulated inspect error")
service.show_resource = _raise_error
try:
output, failed = _capture_tree_output(
resource_inspect, resource=name, tree=False, file=None, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
service.show_resource = original_show
_unpatch_tree_service(orig)
@when('I link DAG child "{child}" to parent "{parent}" with a service error')
def step_run_link_child_with_error(context: Context, child: str, parent: str) -> None:
"""Run link-child but make link_child raise CleverAgentsError."""
from cleveragents.cli.commands.resource import resource_link_child
from cleveragents.core.exceptions import CleverAgentsError as CAError
orig = _patch_tree_service(context)
service = _make_tree_service(context)
original_link = service.link_child
def _raise_error(*args: Any, **kwargs: Any) -> None:
raise CAError("simulated link error")
service.link_child = _raise_error
try:
output, failed = _capture_tree_output(
resource_link_child, parent=parent, child=child, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
service.link_child = original_link
_unpatch_tree_service(orig)
@when('I unlink DAG child "{child}" from parent "{parent}" with a service error')
def step_run_unlink_child_with_error(context: Context, child: str, parent: str) -> None:
"""Run unlink-child but make unlink_child raise CleverAgentsError."""
from cleveragents.cli.commands.resource import resource_unlink_child
from cleveragents.core.exceptions import CleverAgentsError as CAError
orig = _patch_tree_service(context)
service = _make_tree_service(context)
original_unlink = service.unlink_child
def _raise_error(*args: Any, **kwargs: Any) -> None:
raise CAError("simulated unlink error")
service.unlink_child = _raise_error
try:
output, failed = _capture_tree_output(
resource_unlink_child, parent=parent, child=child, yes=True, fmt="rich"
)
context.tree_cli_output = output
context.tree_cli_failed = failed
finally:
service.unlink_child = original_unlink
_unpatch_tree_service(orig)