From bd291fbc4a3d46fbf9f6ae44d6459be249012ed8 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Tue, 10 Mar 2026 09:10:10 +0000 Subject: [PATCH] feat(client): add remote project support 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 --- benchmarks/server_remote_project_bench.py | 101 ++++++++ features/client/remote_project.feature | 115 ++++++++ features/steps/remote_project_steps.py | 303 ++++++++++++++++++++++ robot/helper_remote_project.py | 128 +++++++++ robot/remote_project.robot | 65 +++++ src/cleveragents/client/remote_project.py | 298 +++++++++++++++++++++ vulture_whitelist.py | 7 + 7 files changed, 1017 insertions(+) create mode 100644 benchmarks/server_remote_project_bench.py create mode 100644 features/client/remote_project.feature create mode 100644 features/steps/remote_project_steps.py create mode 100644 robot/helper_remote_project.py create mode 100644 robot/remote_project.robot create mode 100644 src/cleveragents/client/remote_project.py diff --git a/benchmarks/server_remote_project_bench.py b/benchmarks/server_remote_project_bench.py new file mode 100644 index 000000000..82ab713d8 --- /dev/null +++ b/benchmarks/server_remote_project_bench.py @@ -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", + ) diff --git a/features/client/remote_project.feature b/features/client/remote_project.feature new file mode 100644 index 000000000..4f879650a --- /dev/null +++ b/features/client/remote_project.feature @@ -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 diff --git a/features/steps/remote_project_steps.py b/features/steps/remote_project_steps.py new file mode 100644 index 000000000..1393f1f6c --- /dev/null +++ b/features/steps/remote_project_steps.py @@ -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 diff --git a/robot/helper_remote_project.py b/robot/helper_remote_project.py new file mode 100644 index 000000000..a0c063759 --- /dev/null +++ b/robot/helper_remote_project.py @@ -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() diff --git a/robot/remote_project.robot b/robot/remote_project.robot new file mode 100644 index 000000000..e22e19e5f --- /dev/null +++ b/robot/remote_project.robot @@ -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 diff --git a/src/cleveragents/client/remote_project.py b/src/cleveragents/client/remote_project.py new file mode 100644 index 000000000..52a40ebec --- /dev/null +++ b/src/cleveragents/client/remote_project.py @@ -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", +] diff --git a/vulture_whitelist.py b/vulture_whitelist.py index d164637bc..e5888bcf7 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -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