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

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:
2026-03-10 09:10:10 +00:00
parent f18f35d48f
commit bd291fbc4a
7 changed files with 1017 additions and 0 deletions
+101
View File
@@ -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",
)
+115
View File
@@ -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
+303
View File
@@ -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
+128
View File
@@ -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()
+65
View File
@@ -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
+298
View File
@@ -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",
]
+7
View File
@@ -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