feat(client): add remote project support
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m33s
CI / docker (pull_request) Successful in 41s
CI / integration_tests (pull_request) Successful in 3m20s
CI / coverage (pull_request) Successful in 5m6s
CI / benchmark-regression (pull_request) Successful in 32m41s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m33s
CI / docker (pull_request) Successful in 41s
CI / integration_tests (pull_request) Successful in 3m20s
CI / coverage (pull_request) Successful in 5m6s
CI / benchmark-regression (pull_request) Successful in 32m41s
Implement RemoteProjectClient for accessing and executing projects hosted on a remote CleverAgents server: - Remote resource selection and server execution request wiring - Project-name resolution for remote namespaces and server aliases - Resolve project with fallback from custom to default namespace - Remote project caching with configurable TTL (default 5 min) - Explicit ResourceNotFoundError when remote project not found - Cache invalidation per-namespace or global - RemoteProject dataclass with project_id, name, namespace, alias, description ISSUES CLOSED: #338
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""ASV benchmarks for Remote Project client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.client.http_client import ( # noqa: E402
|
||||
PageResult,
|
||||
ServerHttpClient,
|
||||
)
|
||||
from cleveragents.client.remote_project import ( # noqa: E402
|
||||
RemoteProject,
|
||||
RemoteProjectClient,
|
||||
)
|
||||
|
||||
_ITEMS = [
|
||||
{"id": f"rp{i}", "name": f"proj-{i}", "alias": f"p{i}", "description": f"Proj {i}"}
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
|
||||
def _build() -> RemoteProjectClient:
|
||||
mock = MagicMock(spec=ServerHttpClient)
|
||||
mock.list_endpoint.return_value = PageResult(
|
||||
items=_ITEMS, page=1, per_page=50, total=20
|
||||
)
|
||||
return RemoteProjectClient(mock)
|
||||
|
||||
|
||||
class ListProjectsSuite:
|
||||
"""Benchmark project listing."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = _build()
|
||||
|
||||
def time_list_cold(self) -> None:
|
||||
c = _build()
|
||||
c.list_projects()
|
||||
|
||||
def time_list_cached(self) -> None:
|
||||
self.client.list_projects()
|
||||
|
||||
|
||||
class ResolveProjectSuite:
|
||||
"""Benchmark project resolution."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.client = _build()
|
||||
self.client.list_projects()
|
||||
|
||||
def time_resolve_by_name(self) -> None:
|
||||
self.client.get_project("proj-10")
|
||||
|
||||
def time_resolve_by_alias(self) -> None:
|
||||
self.client.get_project("p10")
|
||||
|
||||
|
||||
class CacheSuite:
|
||||
"""Benchmark cache operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_invalidate_all(self) -> None:
|
||||
c = _build()
|
||||
c.list_projects()
|
||||
c.invalidate_cache()
|
||||
|
||||
def time_invalidate_ns(self) -> None:
|
||||
c = _build()
|
||||
c.list_projects()
|
||||
c.invalidate_cache("default")
|
||||
|
||||
|
||||
class RemoteProjectCreationSuite:
|
||||
"""Benchmark RemoteProject creation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create(self) -> None:
|
||||
RemoteProject(
|
||||
project_id="rp1",
|
||||
name="test",
|
||||
namespace="default",
|
||||
alias="t",
|
||||
description="Test project",
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
@phase2 @client @remote-project
|
||||
Feature: Remote project support
|
||||
As a developer
|
||||
I want to access and execute projects hosted on a remote server
|
||||
So that I can use remote resources and run plans on the server
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: List remote projects from server
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
Then I should receive 2 remote projects
|
||||
And the first remote project name should be "project-alpha"
|
||||
|
||||
Scenario: List remote projects uses cache on second call
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I list remote projects in namespace "default" again
|
||||
Then the HTTP client should have been called once for project listing
|
||||
|
||||
Scenario: List remote projects with force_refresh bypasses cache
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I list remote projects with force_refresh
|
||||
Then the HTTP client should have been called twice for project listing
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Get project by name
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I get remote project "project-alpha" in namespace "default"
|
||||
Then the resolved project name should be "project-alpha"
|
||||
And the resolved project namespace should be "default"
|
||||
|
||||
Scenario: Get project by alias
|
||||
Given a RemoteProjectClient with mock projects with aliases
|
||||
When I get remote project "alpha" in namespace "default"
|
||||
Then the resolved project name should be "project-alpha"
|
||||
|
||||
Scenario: Get project not found raises ResourceNotFoundError
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I get remote project "nonexistent" in namespace "default" expecting error
|
||||
Then a ResourceNotFoundError should be raised for remote project
|
||||
|
||||
Scenario: Get project with empty name raises ValueError
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I get remote project with empty name
|
||||
Then a ValueError should be raised for remote project name
|
||||
|
||||
Scenario: Resolve project falls back to default namespace
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I resolve project "project-alpha" in namespace "custom" with fallback
|
||||
Then the resolved project name should be "project-alpha"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Request execution of remote project
|
||||
Given a RemoteProjectClient with mock execution endpoint
|
||||
When I request execution of remote project "proj-001"
|
||||
Then the execution response should contain status "submitted"
|
||||
|
||||
Scenario: Request execution with plan name
|
||||
Given a RemoteProjectClient with mock execution endpoint
|
||||
When I request remote project "proj-001" execution using plan "my-plan"
|
||||
Then the execution response should contain status "submitted"
|
||||
|
||||
Scenario: Request execution with empty project_id raises ValueError
|
||||
Given a RemoteProjectClient with mock execution endpoint
|
||||
When I request execution with empty project_id
|
||||
Then a ValueError should be raised for remote project_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Invalidate cache for specific namespace
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I invalidate cache for namespace "default"
|
||||
Then the remote project cache size should be 0
|
||||
|
||||
Scenario: Invalidate all cache
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I list remote projects in namespace "default"
|
||||
And I invalidate all cache
|
||||
Then the remote project cache size should be 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteProject attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: RemoteProject stores all attributes
|
||||
Given a RemoteProject with id "rp1" name "test" namespace "ns" alias "t" description "desc"
|
||||
Then the remote project project_id should be "rp1"
|
||||
And the remote project alias should be "t"
|
||||
And the remote project description should be "desc"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache TTL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Cache TTL can be configured
|
||||
Given a RemoteProjectClient with cache_ttl 60.0
|
||||
Then the client cache_ttl should be 60.0
|
||||
|
||||
Scenario: Resolve project with empty name raises ValueError
|
||||
Given a RemoteProjectClient with mock projects in default namespace
|
||||
When I resolve project with empty name
|
||||
Then a ValueError should be raised for remote resolve name
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Step definitions for remote project client feature tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.client.http_client import PageResult, ServerHttpClient
|
||||
from cleveragents.client.remote_project import (
|
||||
RemoteProject,
|
||||
RemoteProjectClient,
|
||||
)
|
||||
from cleveragents.core.exceptions import ResourceNotFoundError
|
||||
|
||||
|
||||
def _mock_page_result(
|
||||
items: list[dict[str, Any]],
|
||||
) -> PageResult:
|
||||
return PageResult(items=items, page=1, per_page=50, total=len(items))
|
||||
|
||||
|
||||
_DEFAULT_PROJECTS = [
|
||||
{"id": "rp-001", "name": "project-alpha", "alias": "", "description": "Alpha"},
|
||||
{"id": "rp-002", "name": "project-beta", "alias": "", "description": "Beta"},
|
||||
]
|
||||
|
||||
_ALIASED_PROJECTS = [
|
||||
{"id": "rp-001", "name": "project-alpha", "alias": "alpha", "description": "Alpha"},
|
||||
{"id": "rp-002", "name": "project-beta", "alias": "beta", "description": "Beta"},
|
||||
]
|
||||
|
||||
|
||||
def _build_remote_client(
|
||||
projects: list[dict[str, Any]] | None = None,
|
||||
cache_ttl: float = 300.0,
|
||||
) -> tuple[RemoteProjectClient, MagicMock]:
|
||||
mock_http = MagicMock(spec=ServerHttpClient)
|
||||
items = projects if projects is not None else _DEFAULT_PROJECTS
|
||||
mock_http.list_endpoint.return_value = _mock_page_result(items)
|
||||
client = RemoteProjectClient(mock_http, cache_ttl=cache_ttl)
|
||||
return client, mock_http
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with mock projects in default namespace")
|
||||
def step_remote_client(context: Context) -> None:
|
||||
context.remote_client, context.mock_http = _build_remote_client()
|
||||
context.call_error = None
|
||||
context.http_call_count = 0
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with mock projects with aliases")
|
||||
def step_remote_client_aliases(context: Context) -> None:
|
||||
context.remote_client, context.mock_http = _build_remote_client(_ALIASED_PROJECTS)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when('I list remote projects in namespace "{ns}"')
|
||||
def step_list_projects(context: Context, ns: str) -> None:
|
||||
context.remote_projects = context.remote_client.list_projects(ns)
|
||||
context.http_call_count = context.mock_http.list_endpoint.call_count
|
||||
|
||||
|
||||
@when('I list remote projects in namespace "{ns}" again')
|
||||
def step_list_projects_again(context: Context, ns: str) -> None:
|
||||
context.remote_projects = context.remote_client.list_projects(ns)
|
||||
|
||||
|
||||
@when("I list remote projects with force_refresh")
|
||||
def step_list_force_refresh(context: Context) -> None:
|
||||
context.remote_projects = context.remote_client.list_projects(
|
||||
"default", force_refresh=True
|
||||
)
|
||||
|
||||
|
||||
@then("I should receive {n:d} remote projects")
|
||||
def step_check_project_count(context: Context, n: int) -> None:
|
||||
assert len(context.remote_projects) == n
|
||||
|
||||
|
||||
@then('the first remote project name should be "{expected}"')
|
||||
def step_check_first_name(context: Context, expected: str) -> None:
|
||||
assert context.remote_projects[0].name == expected
|
||||
|
||||
|
||||
@then("the HTTP client should have been called once for project listing")
|
||||
def step_http_called_once(context: Context) -> None:
|
||||
assert context.mock_http.list_endpoint.call_count == 1
|
||||
|
||||
|
||||
@then("the HTTP client should have been called twice for project listing")
|
||||
def step_http_called_twice(context: Context) -> None:
|
||||
assert context.mock_http.list_endpoint.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I get remote project "{name}" in namespace "{ns}"')
|
||||
def step_get_project(context: Context, name: str, ns: str) -> None:
|
||||
context.resolved_project = context.remote_client.get_project(name, ns)
|
||||
|
||||
|
||||
@when('I get remote project "{name}" in namespace "{ns}" expecting error')
|
||||
def step_get_project_error(context: Context, name: str, ns: str) -> None:
|
||||
try:
|
||||
context.remote_client.get_project(name, ns)
|
||||
except ResourceNotFoundError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@when("I get remote project with empty name")
|
||||
def step_get_empty(context: Context) -> None:
|
||||
try:
|
||||
context.remote_client.get_project("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@when('I resolve project "{name}" in namespace "{ns}" with fallback')
|
||||
def step_resolve_fallback(context: Context, name: str, ns: str) -> None:
|
||||
# Mock: custom namespace returns empty, default has projects
|
||||
|
||||
def _side_effect(path: str, **kwargs: Any) -> PageResult:
|
||||
ns_param = kwargs.get("params", {}).get("namespace", "default")
|
||||
if ns_param == "custom":
|
||||
return _mock_page_result([])
|
||||
return _mock_page_result(_DEFAULT_PROJECTS)
|
||||
|
||||
context.mock_http.list_endpoint.side_effect = _side_effect
|
||||
context.resolved_project = context.remote_client.resolve_project(name, ns)
|
||||
|
||||
|
||||
@when("I resolve project with empty name")
|
||||
def step_resolve_empty(context: Context) -> None:
|
||||
try:
|
||||
context.remote_client.resolve_project("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then('the resolved project name should be "{expected}"')
|
||||
def step_check_resolved_name(context: Context, expected: str) -> None:
|
||||
assert context.resolved_project.name == expected
|
||||
|
||||
|
||||
@then('the resolved project namespace should be "{expected}"')
|
||||
def step_check_resolved_ns(context: Context, expected: str) -> None:
|
||||
assert context.resolved_project.namespace == expected
|
||||
|
||||
|
||||
@then("a ResourceNotFoundError should be raised for remote project")
|
||||
def step_not_found_error(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ResourceNotFoundError)
|
||||
|
||||
|
||||
@then("a ValueError should be raised for remote project name")
|
||||
def step_value_error_name(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
@then("a ValueError should be raised for remote resolve name")
|
||||
def step_value_error_resolve(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_exec_response(
|
||||
status: int = 200, body: dict[str, Any] | None = None
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
json=body,
|
||||
headers={},
|
||||
request=httpx.Request("POST", "http://mock"),
|
||||
)
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with mock execution endpoint")
|
||||
def step_remote_exec(context: Context) -> None:
|
||||
context.remote_client, context.mock_http = _build_remote_client()
|
||||
context.mock_http._request.return_value = _mock_exec_response(
|
||||
200, {"status": "submitted", "execution_id": "exec-001"}
|
||||
)
|
||||
context.call_error = None
|
||||
|
||||
|
||||
@when('I request execution of remote project "{pid}"')
|
||||
def step_request_exec(context: Context, pid: str) -> None:
|
||||
context.exec_response = context.remote_client.request_execution(pid)
|
||||
|
||||
|
||||
@when('I request remote project "{pid}" execution using plan "{plan}"')
|
||||
def step_request_exec_plan(context: Context, pid: str, plan: str) -> None:
|
||||
context.exec_response = context.remote_client.request_execution(pid, plan_name=plan)
|
||||
|
||||
|
||||
@when("I request execution with empty project_id")
|
||||
def step_request_exec_empty(context: Context) -> None:
|
||||
try:
|
||||
context.remote_client.request_execution("")
|
||||
except ValueError as exc:
|
||||
context.call_error = exc
|
||||
|
||||
|
||||
@then('the execution response should contain status "{expected}"')
|
||||
def step_check_exec_response(context: Context, expected: str) -> None:
|
||||
assert context.exec_response.get("status") == expected
|
||||
|
||||
|
||||
@then("a ValueError should be raised for remote project_id")
|
||||
def step_value_error_pid(context: Context) -> None:
|
||||
assert isinstance(context.call_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invalidate cache for namespace "{ns}"')
|
||||
def step_invalidate_ns(context: Context, ns: str) -> None:
|
||||
context.remote_client.invalidate_cache(ns)
|
||||
|
||||
|
||||
@when("I invalidate all cache")
|
||||
def step_invalidate_all(context: Context) -> None:
|
||||
context.remote_client.invalidate_cache()
|
||||
|
||||
|
||||
@then("the remote project cache size should be {expected:d}")
|
||||
def step_check_cache_size(context: Context, expected: int) -> None:
|
||||
assert context.remote_client.cache_size == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteProject attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'a RemoteProject with id "{pid}" name "{name}" namespace "{ns}" '
|
||||
'alias "{alias}" description "{desc}"'
|
||||
)
|
||||
def step_remote_project_attrs(
|
||||
context: Context,
|
||||
pid: str,
|
||||
name: str,
|
||||
ns: str,
|
||||
alias: str,
|
||||
desc: str,
|
||||
) -> None:
|
||||
context.test_project = RemoteProject(
|
||||
project_id=pid,
|
||||
name=name,
|
||||
namespace=ns,
|
||||
alias=alias,
|
||||
description=desc,
|
||||
)
|
||||
|
||||
|
||||
@then('the remote project project_id should be "{expected}"')
|
||||
def step_check_pid(context: Context, expected: str) -> None:
|
||||
assert context.test_project.project_id == expected
|
||||
|
||||
|
||||
@then('the remote project alias should be "{expected}"')
|
||||
def step_check_alias(context: Context, expected: str) -> None:
|
||||
assert context.test_project.alias == expected
|
||||
|
||||
|
||||
@then('the remote project description should be "{expected}"')
|
||||
def step_check_desc(context: Context, expected: str) -> None:
|
||||
assert context.test_project.description == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache TTL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a RemoteProjectClient with cache_ttl {ttl:g}")
|
||||
def step_client_ttl(context: Context, ttl: float) -> None:
|
||||
context.remote_client, _ = _build_remote_client(cache_ttl=ttl)
|
||||
|
||||
|
||||
@then("the client cache_ttl should be {expected:g}")
|
||||
def step_check_ttl(context: Context, expected: float) -> None:
|
||||
assert context.remote_client.cache_ttl == expected
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Helper script for remote_project.robot integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
from cleveragents.client.http_client import ( # noqa: E402
|
||||
PageResult,
|
||||
ServerHttpClient,
|
||||
)
|
||||
from cleveragents.client.remote_project import ( # noqa: E402
|
||||
RemoteProject,
|
||||
RemoteProjectClient,
|
||||
)
|
||||
from cleveragents.core.exceptions import ResourceNotFoundError # noqa: E402
|
||||
|
||||
_ITEMS = [
|
||||
{"id": "rp1", "name": "alpha", "alias": "a", "description": "A"},
|
||||
{"id": "rp2", "name": "beta", "alias": "b", "description": "B"},
|
||||
]
|
||||
|
||||
|
||||
def _build() -> tuple[RemoteProjectClient, MagicMock]:
|
||||
mock = MagicMock(spec=ServerHttpClient)
|
||||
mock.list_endpoint.return_value = PageResult(
|
||||
items=_ITEMS, page=1, per_page=50, total=2
|
||||
)
|
||||
return RemoteProjectClient(mock), mock
|
||||
|
||||
|
||||
def list_projects() -> None:
|
||||
client, _ = _build()
|
||||
projects = client.list_projects()
|
||||
assert len(projects) == 2
|
||||
assert projects[0].name == "alpha"
|
||||
print("remote-project-list-ok")
|
||||
|
||||
|
||||
def get_project() -> None:
|
||||
client, _ = _build()
|
||||
p = client.get_project("alpha")
|
||||
assert p.name == "alpha"
|
||||
print("remote-project-get-ok")
|
||||
|
||||
|
||||
def get_by_alias() -> None:
|
||||
client, _ = _build()
|
||||
p = client.get_project("a")
|
||||
assert p.name == "alpha"
|
||||
print("remote-project-alias-ok")
|
||||
|
||||
|
||||
def not_found() -> None:
|
||||
client, _ = _build()
|
||||
try:
|
||||
client.get_project("nope")
|
||||
print("FAIL: should have raised", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ResourceNotFoundError:
|
||||
pass
|
||||
print("remote-project-not-found-ok")
|
||||
|
||||
|
||||
def request_execution() -> None:
|
||||
client, mock = _build()
|
||||
mock._request.return_value = httpx.Response(
|
||||
status_code=200,
|
||||
json={"status": "submitted"},
|
||||
headers={},
|
||||
request=httpx.Request("POST", "http://mock"),
|
||||
)
|
||||
result = client.request_execution("rp1")
|
||||
assert result["status"] == "submitted"
|
||||
print("remote-project-exec-ok")
|
||||
|
||||
|
||||
def cache_invalidate() -> None:
|
||||
client, _ = _build()
|
||||
client.list_projects()
|
||||
assert client.cache_size == 1
|
||||
client.invalidate_cache()
|
||||
assert client.cache_size == 0
|
||||
print("remote-project-cache-ok")
|
||||
|
||||
|
||||
def project_attrs() -> None:
|
||||
p = RemoteProject(
|
||||
project_id="rp1",
|
||||
name="test",
|
||||
namespace="ns",
|
||||
alias="t",
|
||||
description="desc",
|
||||
)
|
||||
assert p.project_id == "rp1"
|
||||
assert p.alias == "t"
|
||||
assert p.description == "desc"
|
||||
print("remote-project-attrs-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"list-projects": list_projects,
|
||||
"get-project": get_project,
|
||||
"get-by-alias": get_by_alias,
|
||||
"not-found": not_found,
|
||||
"request-execution": request_execution,
|
||||
"cache-invalidate": cache_invalidate,
|
||||
"project-attrs": project_attrs,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
cmds = "|".join(_COMMANDS)
|
||||
print(f"Usage: {sys.argv[0]} <{cmds}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for Remote Project client
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_remote_project.py
|
||||
|
||||
*** Test Cases ***
|
||||
Remote Project List
|
||||
[Documentation] Verify listing remote projects
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-projects cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-list-ok
|
||||
|
||||
Remote Project Get By Name
|
||||
[Documentation] Verify getting project by name
|
||||
${result}= Run Process ${PYTHON} ${HELPER} get-project cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-get-ok
|
||||
|
||||
Remote Project Get By Alias
|
||||
[Documentation] Verify getting project by alias
|
||||
${result}= Run Process ${PYTHON} ${HELPER} get-by-alias cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-alias-ok
|
||||
|
||||
Remote Project Not Found
|
||||
[Documentation] Verify ResourceNotFoundError for missing project
|
||||
${result}= Run Process ${PYTHON} ${HELPER} not-found cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-not-found-ok
|
||||
|
||||
Remote Project Request Execution
|
||||
[Documentation] Verify requesting execution of remote project
|
||||
${result}= Run Process ${PYTHON} ${HELPER} request-execution cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-exec-ok
|
||||
|
||||
Remote Project Cache Invalidate
|
||||
[Documentation] Verify cache invalidation
|
||||
${result}= Run Process ${PYTHON} ${HELPER} cache-invalidate cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-cache-ok
|
||||
|
||||
Remote Project Attributes
|
||||
[Documentation] Verify RemoteProject attributes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} project-attrs cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} remote-project-attrs-ok
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Remote project client for server-hosted project access.
|
||||
|
||||
Provides :class:`RemoteProjectClient` for listing, resolving, and
|
||||
requesting execution of projects hosted on a remote CleverAgents
|
||||
server. Features include namespace-aware project-name resolution,
|
||||
TTL-based caching, and explicit errors when a remote project is
|
||||
not found or inaccessible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.client.http_client import ServerHttpClient
|
||||
from cleveragents.core.exceptions import ResourceNotFoundError
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_CACHE_TTL = 300.0 # 5 minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CacheEntry:
|
||||
"""Internal cache entry with expiry tracking."""
|
||||
|
||||
data: dict[str, Any]
|
||||
expires_at: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote project metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteProject:
|
||||
"""Metadata for a project hosted on a remote server.
|
||||
|
||||
Attributes:
|
||||
project_id: Server-assigned project identifier.
|
||||
name: Human-readable project name.
|
||||
namespace: Server namespace the project belongs to.
|
||||
alias: Optional short alias for CLI usage.
|
||||
description: Project description.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
name: str
|
||||
namespace: str = "default"
|
||||
alias: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RemoteProjectClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RemoteProjectClient:
|
||||
"""Client for accessing and executing remote server-hosted projects.
|
||||
|
||||
Args:
|
||||
http_client: The underlying HTTP client for server communication.
|
||||
cache_ttl: TTL in seconds for the project list cache.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: ServerHttpClient,
|
||||
cache_ttl: float = _DEFAULT_CACHE_TTL,
|
||||
) -> None:
|
||||
self._http = http_client
|
||||
self._cache_ttl = cache_ttl
|
||||
self._cache: dict[str, _CacheEntry] = {}
|
||||
|
||||
@property
|
||||
def cache_ttl(self) -> float:
|
||||
"""Cache TTL in seconds."""
|
||||
return self._cache_ttl
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project listing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(
|
||||
self,
|
||||
namespace: str = "default",
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> list[RemoteProject]:
|
||||
"""List remote projects in a namespace.
|
||||
|
||||
Results are cached for :attr:`cache_ttl` seconds unless
|
||||
*force_refresh* is ``True``.
|
||||
|
||||
Args:
|
||||
namespace: Server namespace to query.
|
||||
force_refresh: Bypass the cache and fetch from server.
|
||||
|
||||
Returns:
|
||||
List of :class:`RemoteProject` instances.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On server communication failure.
|
||||
"""
|
||||
cache_key = f"projects:{namespace}"
|
||||
|
||||
if not force_refresh:
|
||||
cached = self._get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return self._parse_projects(cached, namespace)
|
||||
|
||||
page_result = self._http.list_endpoint(
|
||||
"/projects",
|
||||
params={"namespace": namespace},
|
||||
)
|
||||
items = page_result.items
|
||||
self._set_cached(cache_key, items)
|
||||
return self._parse_projects(items, namespace)
|
||||
|
||||
def get_project(
|
||||
self,
|
||||
project_name: str,
|
||||
namespace: str = "default",
|
||||
) -> RemoteProject:
|
||||
"""Resolve a project by name within a namespace.
|
||||
|
||||
Args:
|
||||
project_name: The project name or alias to look up.
|
||||
namespace: Server namespace.
|
||||
|
||||
Returns:
|
||||
The matched :class:`RemoteProject`.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundError: When the project is not found.
|
||||
ServerConnectionError: On server communication failure.
|
||||
"""
|
||||
if not project_name:
|
||||
raise ValueError("project_name must be a non-empty string")
|
||||
|
||||
projects = self.list_projects(namespace)
|
||||
for project in projects:
|
||||
if project.name == project_name or project.alias == project_name:
|
||||
return project
|
||||
|
||||
raise ResourceNotFoundError(
|
||||
message=(
|
||||
f"Remote project '{project_name}' not found in namespace '{namespace}'"
|
||||
),
|
||||
resource_type="project",
|
||||
resource_id=project_name,
|
||||
)
|
||||
|
||||
def resolve_project(
|
||||
self,
|
||||
name_or_alias: str,
|
||||
namespace: str = "default",
|
||||
) -> RemoteProject:
|
||||
"""Resolve a project name or alias, checking all namespaces.
|
||||
|
||||
First checks the specified namespace, then falls back to
|
||||
the ``"default"`` namespace if different.
|
||||
|
||||
Args:
|
||||
name_or_alias: Project name or alias.
|
||||
namespace: Preferred namespace.
|
||||
|
||||
Returns:
|
||||
The matched :class:`RemoteProject`.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundError: When the project is not found.
|
||||
"""
|
||||
if not name_or_alias:
|
||||
raise ValueError("name_or_alias must be a non-empty string")
|
||||
|
||||
try:
|
||||
return self.get_project(name_or_alias, namespace)
|
||||
except ResourceNotFoundError:
|
||||
if namespace != "default":
|
||||
return self.get_project(name_or_alias, "default")
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def request_execution(
|
||||
self,
|
||||
project_id: str,
|
||||
plan_name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Request execution of a remote project plan.
|
||||
|
||||
Args:
|
||||
project_id: Server project identifier.
|
||||
plan_name: Optional plan name (uses default if empty).
|
||||
|
||||
Returns:
|
||||
Execution metadata from the server.
|
||||
|
||||
Raises:
|
||||
ServerConnectionError: On server communication failure.
|
||||
"""
|
||||
if not project_id:
|
||||
raise ValueError("project_id must be a non-empty string")
|
||||
|
||||
resp = self._http._request(
|
||||
"POST",
|
||||
f"/projects/{project_id}/execute",
|
||||
json_body={"plan_name": plan_name} if plan_name else None,
|
||||
)
|
||||
data: dict[str, Any] = resp.json()
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def invalidate_cache(self, namespace: str | None = None) -> None:
|
||||
"""Invalidate cached project data.
|
||||
|
||||
Args:
|
||||
namespace: If given, only invalidate that namespace.
|
||||
If ``None``, clear the entire cache.
|
||||
"""
|
||||
if namespace is None:
|
||||
self._cache.clear()
|
||||
logger.debug("remote_project_cache_cleared")
|
||||
else:
|
||||
key = f"projects:{namespace}"
|
||||
self._cache.pop(key, None)
|
||||
logger.debug(
|
||||
"remote_project_cache_invalidated",
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
@property
|
||||
def cache_size(self) -> int:
|
||||
"""Number of cached entries."""
|
||||
return len(self._cache)
|
||||
|
||||
def _get_cached(self, key: str) -> list[dict[str, Any]] | None:
|
||||
"""Return cached data if still valid, else ``None``."""
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() > entry.expires_at:
|
||||
del self._cache[key]
|
||||
return None
|
||||
cached: list[dict[str, Any]] = entry.data.get("items", [])
|
||||
return cached
|
||||
|
||||
def _set_cached(self, key: str, items: list[dict[str, Any]]) -> None:
|
||||
"""Store data in cache with TTL."""
|
||||
self._cache[key] = _CacheEntry(
|
||||
data={"items": items},
|
||||
expires_at=time.monotonic() + self._cache_ttl,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_projects(
|
||||
items: list[dict[str, Any]],
|
||||
namespace: str,
|
||||
) -> list[RemoteProject]:
|
||||
"""Convert raw dicts to :class:`RemoteProject` instances."""
|
||||
projects: list[RemoteProject] = []
|
||||
for item in items:
|
||||
projects.append(
|
||||
RemoteProject(
|
||||
project_id=str(item.get("id", "")),
|
||||
name=str(item.get("name", "")),
|
||||
namespace=namespace,
|
||||
alias=str(item.get("alias", "")),
|
||||
description=str(item.get("description", "")),
|
||||
)
|
||||
)
|
||||
return projects
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RemoteProject",
|
||||
"RemoteProjectClient",
|
||||
]
|
||||
@@ -985,3 +985,10 @@ WebSocketClient # noqa: B018, F821
|
||||
ConnectionState # noqa: B018, F821
|
||||
EventDeduplicator # noqa: B018, F821
|
||||
EventCallback # noqa: B018, F821
|
||||
|
||||
# Remote project client — Issue #338
|
||||
RemoteProjectClient # noqa: B018, F821
|
||||
RemoteProject # noqa: B018, F821
|
||||
resolve_project # noqa: B018, F821
|
||||
request_execution # noqa: B018, F821
|
||||
invalidate_cache # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user