Files
cleveragents-core/robot/helper_resource_handler_crud.py
T
hamza.khyari 90e5bbb99c
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 3m17s
CI / unit_tests (pull_request) Successful in 3m38s
CI / quality (pull_request) Successful in 3m42s
CI / typecheck (pull_request) Successful in 3m54s
CI / security (pull_request) Successful in 4m2s
CI / docker (pull_request) Successful in 50s
CI / e2e_tests (pull_request) Successful in 8m32s
CI / integration_tests (pull_request) Successful in 6m54s
CI / coverage (pull_request) Successful in 14m10s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 59m15s
feat(resource): implement ResourceHandler CRUD and discovery methods
Extend the ResourceHandler protocol with six content operations (read,
write, delete, list_children, diff, discover_children) and four frozen
dataclass result types (Content, WriteResult, DeleteResult, DiffResult).

Handler implementations:
- GitCheckoutHandler: read via git show (binary-safe), write/delete via
  filesystem ops, list via git ls-tree, diff via git diff --no-index
  with locale-safe shortstat parsing, discover via git ls-tree -d
- FsDirectoryHandler: full CRUD via pathlib/os/difflib/shutil
- DevcontainerHandler: read/write/discover via devcontainer exec
- CloudResourceHandler: NotImplementedError stubs for protocol compliance
- DatabaseResourceHandler: inherits base NotImplementedError stubs

Security:
- Path traversal guard (_safe_resolve) on all read/write/delete ops
  using os.sep-suffixed startswith check to prevent prefix collisions
- Empty-path deletion rejected with PermissionError

Tests:
- 22 Behave scenarios (115 steps): CRUD for FsDirectory and GitCheckout,
  path traversal rejection (3 scenarios), NotImplementedError defaults
- 2 Robot integration tests: read -> write -> diff cycle on real temp
  directories and git repos

ISSUES CLOSED: #827
2026-03-24 13:53:41 +00:00

182 lines
5.9 KiB
Python

"""Helper script for resource_handler_crud.robot integration tests.
Exercises the read -> write -> diff cycle on a real temporary git
repository using GitCheckoutHandler, and on a real temporary directory
using FsDirectoryHandler.
Issue #827: ResourceHandler CRUD and discovery methods.
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import NoReturn
_ROOT = Path(__file__).resolve().parents[1]
_SRC = str(_ROOT / "src")
# Ensure local source takes precedence over installed package.
# Remove any existing cleveragents modules so importlib re-resolves
# from sys.path[0] (our local src).
sys.path.insert(0, _SRC)
for _mod_name in list(sys.modules):
if _mod_name.startswith("cleveragents"):
del sys.modules[_mod_name]
from cleveragents.domain.models.core.resource import ( # noqa: E402
PhysVirt,
Resource,
ResourceCapabilities,
)
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler # noqa: E402
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler # noqa: E402
def _fail(msg: str) -> NoReturn:
print(msg, file=sys.stderr)
sys.exit(1)
def _make_resource(rtype: str, location: str) -> Resource:
return Resource(
resource_id="01KJ5C5TPMP8GGX3QC83E2MAQS",
resource_type_name=rtype,
classification=PhysVirt.PHYSICAL,
location=location,
capabilities=ResourceCapabilities(
readable=True, writable=True, sandboxable=True
),
)
# ---------------------------------------------------------------------------
# fs-read-write-diff: FsDirectoryHandler read -> write -> diff cycle
# ---------------------------------------------------------------------------
def fs_read_write_diff() -> None:
"""Test read -> write -> diff cycle on FsDirectoryHandler."""
handler = FsDirectoryHandler()
# Create two temp dirs: source and comparison
src = tempfile.mkdtemp(prefix="crud_robot_src_")
cmp_dir = tempfile.mkdtemp(prefix="crud_robot_cmp_")
# Write identical files to both
Path(src, "file.txt").write_text("original\n", encoding="utf-8")
Path(cmp_dir, "file.txt").write_text("original\n", encoding="utf-8")
resource = _make_resource("fs-directory", src)
# 1. Read
content = handler.read(resource=resource, path="file.txt")
if content.text.strip() != "original":
_fail(f"read failed: expected 'original', got '{content.text.strip()}'")
# 2. Write (modify)
result = handler.write(resource=resource, path="file.txt", data=b"modified\n")
if not result.success:
_fail(f"write failed: {result.message}")
# 3. Diff against comparison dir (should detect change)
diff = handler.diff(resource=resource, other_location=cmp_dir)
if not diff.has_changes:
_fail("diff should detect changes after write")
if diff.files_changed < 1:
_fail(f"diff files_changed={diff.files_changed}, expected >= 1")
# 4. List children
children = handler.list_children(resource=resource)
if "file.txt" not in children:
_fail(f"list_children missing 'file.txt': {children}")
print("fs-read-write-diff-ok")
# ---------------------------------------------------------------------------
# git-read-write-diff: GitCheckoutHandler read -> write -> diff cycle
# ---------------------------------------------------------------------------
def git_read_write_diff() -> None:
"""Test read -> write -> diff cycle on GitCheckoutHandler."""
handler = GitCheckoutHandler()
# Create a temp git repo
repo = tempfile.mkdtemp(prefix="crud_robot_git_")
subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo,
capture_output=True,
check=True,
)
Path(repo, "code.py").write_text("print('hello')\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=repo,
capture_output=True,
check=True,
)
resource = _make_resource("git-checkout", repo)
# Comparison dir
cmp_dir = tempfile.mkdtemp(prefix="crud_robot_cmp_")
Path(cmp_dir, "code.py").write_text("print('hello')\n", encoding="utf-8")
# 1. Read
content = handler.read(resource=resource, path="code.py")
if "hello" not in content.text:
_fail(f"read failed: 'hello' not in '{content.text}'")
# 2. Write
result = handler.write(resource=resource, path="code.py", data=b"print('world')\n")
if not result.success:
_fail(f"write failed: {result.message}")
# 3. Diff (should detect change since we modified the file)
diff = handler.diff(resource=resource, other_location=cmp_dir)
if not diff.has_changes:
_fail("diff should detect changes after write")
# 4. List children
children = handler.list_children(resource=resource)
if "code.py" not in children:
_fail(f"list_children missing 'code.py': {children}")
# 5. Discover children (no subdirs expected)
handler.discover_children(resource=resource)
# May or may not have children — just ensure no crash
print("git-read-write-diff-ok")
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"fs-read-write-diff": fs_read_write_diff,
"git-read-write-diff": git_read_write_diff,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
file=sys.stderr,
)
sys.exit(1)
_COMMANDS[sys.argv[1]]()