feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -0,0 +1,89 @@
|
||||
# Artemis Thin Client Design
|
||||
|
||||
## Goals
|
||||
|
||||
Developer machines install only a small remote SDK, while the host connected to
|
||||
Android devices runs the full Artemis stack. The SDK must:
|
||||
|
||||
- have no dependency on ADB, device drivers, agents, LLMs, web services, or image libraries;
|
||||
- never attempt to start a local Artemis daemon;
|
||||
- remain compatible with the existing server API;
|
||||
- allow the server to add agent and device capabilities independently; and
|
||||
- provide explicit capability negotiation and errors as the protocol evolves.
|
||||
|
||||
## Boundary
|
||||
|
||||
The client owns request modeling, HTTP communication, status polling, error
|
||||
normalization, and result models. Device discovery, task scheduling, concurrency
|
||||
control, model selection, execution, logs, videos, and artifact generation all
|
||||
belong to the server.
|
||||
|
||||
The distribution is named `artemis-client`, while the Python import is
|
||||
`artemis_client`. This prevents it from shadowing the full runtime's top-level
|
||||
`artemis` package. The full runtime can depend on this package later and
|
||||
re-export the client from `artemis` for compatibility.
|
||||
|
||||
## Phase 1: Compatibility Adapter
|
||||
|
||||
The first release calls the current `/api/*` administration endpoints directly.
|
||||
The client generates a task ID and maps it to the server's existing `session_id`
|
||||
to make retries idempotent. While a task is queued but not yet visible in the
|
||||
session database, the client reconstructs its state from `queue` and
|
||||
`active_tasks` in `/api/status`.
|
||||
|
||||
Experimental parameters live in `options`. Older servers ignore unknown fields,
|
||||
while newer servers can consume them without requiring an immediate SDK release.
|
||||
|
||||
## Phase 2: Stable Remote API
|
||||
|
||||
The server should expose versioned endpoints dedicated to remote clients:
|
||||
|
||||
```text
|
||||
POST /api/v1/tasks
|
||||
GET /api/v1/tasks/{task_id}
|
||||
DELETE /api/v1/tasks/{task_id}
|
||||
GET /api/v1/tasks/{task_id}/events
|
||||
GET /api/v1/tasks/{task_id}/artifacts
|
||||
GET /api/v1/devices
|
||||
GET /api/v1/capabilities
|
||||
```
|
||||
|
||||
The administration API and remote SDK API should remain separate. The remote API
|
||||
uses a consistent error envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "DEVICE_OFFLINE",
|
||||
"message": "The selected device is offline",
|
||||
"retryable": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
1. `/api/v1` may add optional request and response fields, but must not change existing field semantics.
|
||||
2. The client ignores unknown response fields and preserves the original response in each model's `raw` attribute.
|
||||
3. Every new request field must have a server-side default.
|
||||
4. The SDK checks `/api/v1/capabilities` before using a new feature.
|
||||
5. Breaking changes ship under `/api/v2`; they are never applied in place.
|
||||
6. The SDK and server follow semantic versioning independently.
|
||||
|
||||
## Security
|
||||
|
||||
The current daemon exposes administration and credential-related endpoints and
|
||||
must not listen on a public interface without protection. Use an SSH or VPN tunnel
|
||||
in the short term. In production, place a reverse proxy in front of the daemon,
|
||||
expose only the remote API, and enforce HTTPS, bearer-token authentication,
|
||||
request-size limits, rate limits, and audit logging. Tokens are accepted only
|
||||
through arguments or environment variables and are never persisted by the SDK.
|
||||
|
||||
## Future Work
|
||||
|
||||
- SSE event subscriptions with reconnection support;
|
||||
- artifact manifests and streaming downloads;
|
||||
- batch task submission;
|
||||
- server and API version constraints;
|
||||
- retries limited to network failures and idempotent requests; and
|
||||
- OpenAPI contract tests to prevent server and SDK drift.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Artemis Client
|
||||
|
||||
`artemis-client` is the remote Python SDK that runs on a developer machine. It
|
||||
communicates with the Artemis daemon on a device host over HTTP and contains no
|
||||
ADB, device-driver, agent, LLM, or image-processing code.
|
||||
|
||||
Runtime dependencies: **zero**. HTTP, JSON, and asynchronous adapters use only
|
||||
the Python standard library.
|
||||
|
||||
## Installation
|
||||
|
||||
Install from a local checkout:
|
||||
|
||||
```powershell
|
||||
uv add --editable C:\path\to\artemis\packages\artemis-client
|
||||
```
|
||||
|
||||
Install from the Git repository subdirectory:
|
||||
|
||||
```powershell
|
||||
uv add "artemis-client @ git+https://github.com/google/artemis.git#subdirectory=packages/artemis-client"
|
||||
```
|
||||
|
||||
## Connect to a Device Host
|
||||
|
||||
The current Artemis daemon administration API does not include built-in remote
|
||||
authentication. Do not expose its port directly to the public internet. During
|
||||
the initial rollout, connect through an SSH tunnel:
|
||||
|
||||
```powershell
|
||||
ssh -L 8000:127.0.0.1:8000 user@artemis-host
|
||||
```
|
||||
|
||||
Then connect the SDK to the local tunnel endpoint:
|
||||
|
||||
```python
|
||||
from artemis_client import ArtemisClient
|
||||
|
||||
client = ArtemisClient("http://127.0.0.1:8000")
|
||||
```
|
||||
|
||||
For production, place a reverse proxy in front of the daemon that enforces HTTPS
|
||||
and bearer-token authentication and exposes only task-related APIs.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from artemis_client import ArtemisClient
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = ArtemisClient(
|
||||
os.environ["ARTEMIS_BASE_URL"],
|
||||
token=os.environ.get("ARTEMIS_TOKEN"),
|
||||
)
|
||||
|
||||
handle = await client.submit(
|
||||
"Open the Battery page in Settings and verify the battery percentage",
|
||||
profile="flash",
|
||||
device_serial="emulator-5554",
|
||||
)
|
||||
print("queued:", handle.task_id)
|
||||
|
||||
result = await client.wait_for_task(handle.task_id, timeout=600)
|
||||
if not result.succeeded:
|
||||
raise RuntimeError(result.error or result.status)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
You can also submit a task and wait for it in one call:
|
||||
|
||||
```python
|
||||
result = await client.run("Open Android Settings and check the Wi-Fi status")
|
||||
```
|
||||
|
||||
Main methods:
|
||||
|
||||
- `health()` quickly checks whether the server and scheduler are alive.
|
||||
- `readiness()` runs full device and toolchain diagnostics and may take longer.
|
||||
- `capabilities()` negotiates API features and falls back for older servers.
|
||||
- `list_devices()` lists devices available on the host.
|
||||
- `submit()` submits an idempotent task and returns immediately.
|
||||
- `get_task()` retrieves a task.
|
||||
- `wait_for_task()` waits for a terminal state.
|
||||
- `run()` submits a task and waits for completion.
|
||||
- `stop()` stops one task.
|
||||
|
||||
By default, `submit()` generates a UUID on the client and sends it as the current
|
||||
server's `session_id`. Network retries reuse the same `task_id`, preventing the
|
||||
server from creating duplicate tasks.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Version 0.1 supports these current Artemis daemon endpoints:
|
||||
|
||||
- `POST /api/run`
|
||||
- `GET /api/sessions/{session_id}`
|
||||
- `GET /api/status`
|
||||
- `POST /api/stop`
|
||||
- `GET /api/devices`
|
||||
- `GET /api/system/readiness`
|
||||
|
||||
Capability discovery first attempts the future `GET /api/v1/capabilities`
|
||||
endpoint. If an older server returns 404, the SDK uses its known baseline
|
||||
capability set.
|
||||
|
||||
See [DESIGN.md](DESIGN.md) for the architecture and protocol evolution plan.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Run one task against an Artemis host."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from artemis_client import ArtemisClient
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = ArtemisClient(
|
||||
os.environ.get("ARTEMIS_BASE_URL", "http://127.0.0.1:8000"),
|
||||
token=os.environ.get("ARTEMIS_TOKEN"),
|
||||
)
|
||||
|
||||
devices = await client.list_devices()
|
||||
if not devices:
|
||||
raise RuntimeError("The Artemis host did not report an Android device")
|
||||
|
||||
result = await client.run(
|
||||
"Open the Battery page in Android Settings and verify the battery percentage.",
|
||||
profile="flash",
|
||||
device_serial=devices[0].serial,
|
||||
)
|
||||
if not result.succeeded:
|
||||
raise RuntimeError(result.error or f"Task ended with status {result.status}")
|
||||
print(f"Task {result.task_id} completed on {result.device_serial}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "artemis-client"
|
||||
version = "0.1.0"
|
||||
description = "Zero-runtime-dependency Python client for a remote Artemis host"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "Apache-2.0"
|
||||
authors = [
|
||||
{ name = "Artemis contributors" },
|
||||
]
|
||||
dependencies = []
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
artemis_client = ["py.typed"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "tests", "examples"]
|
||||
extraPaths = ["src"]
|
||||
pythonVersion = "3.10"
|
||||
typeCheckingMode = "standard"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Public API for the lightweight Artemis remote client."""
|
||||
|
||||
from artemis_client.client import ArtemisClient
|
||||
from artemis_client.errors import (
|
||||
ApiError,
|
||||
ArtemisClientError,
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
ProtocolError,
|
||||
TaskRejectedError,
|
||||
TaskTimeoutError,
|
||||
)
|
||||
from artemis_client.models import Capabilities, Device, TaskHandle, TaskResult
|
||||
from artemis_client.transport import JsonTransport
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"ApiError",
|
||||
"ArtemisClient",
|
||||
"ArtemisClientError",
|
||||
"AuthenticationError",
|
||||
"Capabilities",
|
||||
"ConflictError",
|
||||
"Device",
|
||||
"JsonTransport",
|
||||
"NetworkError",
|
||||
"NotFoundError",
|
||||
"ProtocolError",
|
||||
"TaskHandle",
|
||||
"TaskRejectedError",
|
||||
"TaskResult",
|
||||
"TaskTimeoutError",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,365 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Asynchronous, remote-only Artemis client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from artemis_client.errors import NotFoundError, ProtocolError, TaskRejectedError, TaskTimeoutError
|
||||
from artemis_client.models import Capabilities, Device, TaskHandle, TaskResult
|
||||
from artemis_client.transport import JsonTransport
|
||||
|
||||
|
||||
class _Transport(Protocol):
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
) -> Any: ...
|
||||
|
||||
|
||||
class ArtemisClient:
|
||||
"""Thin client for an Artemis daemon running on another host.
|
||||
|
||||
This class never imports ADB, an Artemis agent, an LLM provider, or a
|
||||
device driver. All execution is delegated to ``base_url``.
|
||||
"""
|
||||
|
||||
_LEGACY_FEATURES = frozenset(
|
||||
{
|
||||
"tasks.submit",
|
||||
"tasks.get",
|
||||
"tasks.stop",
|
||||
"devices.list",
|
||||
"system.readiness",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
*,
|
||||
token: str | None = None,
|
||||
request_timeout: float = 30.0,
|
||||
poll_interval: float = 1.0,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
transport: _Transport | None = None,
|
||||
device_id: str | None = None,
|
||||
device_serial: str | None = None,
|
||||
default_profile: Literal["flash", "pro"] = "flash",
|
||||
concurrency_mode: str = "per_device",
|
||||
max_concurrency: int | None = None,
|
||||
standalone: bool = False,
|
||||
) -> None:
|
||||
if poll_interval <= 0:
|
||||
raise ValueError("poll_interval must be greater than zero")
|
||||
resolved_base_url = base_url or os.environ.get("ARTEMIS_BASE_URL")
|
||||
if not resolved_base_url:
|
||||
daemon_host = os.environ.get("ARTEMIS_DAEMON_HOST", "127.0.0.1")
|
||||
daemon_port = os.environ.get("ARTEMIS_DAEMON_PORT", "8000")
|
||||
resolved_base_url = f"http://{daemon_host}:{daemon_port}"
|
||||
|
||||
self.base_url = resolved_base_url.rstrip("/")
|
||||
self.poll_interval = float(poll_interval)
|
||||
self._device_serial = device_serial or device_id
|
||||
self.default_profile = default_profile
|
||||
self.concurrency_mode = str(concurrency_mode).strip().lower()
|
||||
self.max_concurrency = max_concurrency
|
||||
self.standalone = standalone
|
||||
self._transport = transport or JsonTransport(
|
||||
resolved_base_url,
|
||||
token=token,
|
||||
timeout=request_timeout,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@property
|
||||
def device_id(self) -> str | None:
|
||||
"""Default remote device serial (legacy-compatible alias)."""
|
||||
return self._device_serial
|
||||
|
||||
@device_id.setter
|
||||
def device_id(self, value: str | None) -> None:
|
||||
self._device_serial = value
|
||||
|
||||
@property
|
||||
def device_serial(self) -> str | None:
|
||||
return self._device_serial
|
||||
|
||||
@device_serial.setter
|
||||
def device_serial(self, value: str | None) -> None:
|
||||
self._device_serial = value
|
||||
|
||||
def set_device(self, device_serial: str) -> ArtemisClient:
|
||||
"""Set the default remote device and return this client."""
|
||||
self._device_serial = device_serial
|
||||
return self
|
||||
|
||||
def set_concurrency_mode(self, mode: str) -> ArtemisClient:
|
||||
"""Retain the legacy setting for callers migrating to server-side queues."""
|
||||
self.concurrency_mode = str(mode).strip().lower()
|
||||
return self
|
||||
|
||||
async def health(self) -> Mapping[str, Any]:
|
||||
"""Perform a fast liveness check against the scheduler status API."""
|
||||
payload = await self._request("GET", "/api/status")
|
||||
return self._mapping(payload, endpoint="/api/status")
|
||||
|
||||
async def readiness(self) -> Mapping[str, Any]:
|
||||
"""Run the host's full device and toolchain readiness diagnostics.
|
||||
|
||||
This endpoint can take substantially longer than :meth:`health`; set
|
||||
a larger ``request_timeout`` when constructing the client if needed.
|
||||
"""
|
||||
payload = await self._request("GET", "/api/system/readiness")
|
||||
return self._mapping(payload, endpoint="/api/system/readiness")
|
||||
|
||||
async def capabilities(self) -> Capabilities:
|
||||
"""Discover server features, with a baseline for today's legacy API."""
|
||||
try:
|
||||
payload = await self._request("GET", "/api/v1/capabilities")
|
||||
except NotFoundError:
|
||||
return Capabilities(api_version="legacy", features=self._LEGACY_FEATURES)
|
||||
return Capabilities.from_payload(self._mapping(payload, endpoint="/api/v1/capabilities"))
|
||||
|
||||
async def list_devices(self) -> tuple[Device, ...]:
|
||||
"""List Android devices visible to the remote Artemis host."""
|
||||
payload = await self._request("GET", "/api/devices")
|
||||
if isinstance(payload, Mapping):
|
||||
raw_devices = payload.get("devices", [])
|
||||
else:
|
||||
raw_devices = payload
|
||||
if not isinstance(raw_devices, list):
|
||||
raise ProtocolError("/api/devices response must contain a device list")
|
||||
devices: list[Device] = []
|
||||
for item in raw_devices:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ProtocolError("/api/devices contained a non-object device entry")
|
||||
devices.append(Device.from_payload(item))
|
||||
return tuple(devices)
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
goal: str,
|
||||
*,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_serial: str | None = None,
|
||||
expected_output: str | None = None,
|
||||
enable_outputter: bool | None = None,
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
) -> TaskHandle:
|
||||
"""Submit one task and return immediately after scheduler admission.
|
||||
|
||||
``task_id`` is generated client-side and sent as the legacy
|
||||
``session_id``. Reusing it makes submission retries idempotent.
|
||||
Experimental, forward-compatible fields belong in ``options``.
|
||||
"""
|
||||
normalized_goal = goal.strip()
|
||||
if not normalized_goal:
|
||||
raise ValueError("goal must not be empty")
|
||||
if task_id is None:
|
||||
resolved_task_id = str(uuid.uuid4())
|
||||
else:
|
||||
try:
|
||||
resolved_task_id = str(uuid.UUID(task_id))
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise ValueError("task_id must be a valid UUID string") from exc
|
||||
resolved_profile = profile or self.default_profile
|
||||
resolved_device = device_serial or self.device_serial
|
||||
payload: dict[str, Any] = {
|
||||
"goal": normalized_goal,
|
||||
"profile": resolved_profile,
|
||||
"session_id": resolved_task_id,
|
||||
"ingress": "python_sdk",
|
||||
}
|
||||
optional_values = {
|
||||
"device_serial": resolved_device,
|
||||
"expected_output": expected_output,
|
||||
"enable_outputter": enable_outputter,
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"conversation_id": conversation_id,
|
||||
"options": dict(options) if options is not None else None,
|
||||
}
|
||||
payload.update({key: value for key, value in optional_values.items() if value is not None})
|
||||
|
||||
response = self._mapping(
|
||||
await self._request("POST", "/api/run", json_body=payload),
|
||||
endpoint="/api/run",
|
||||
)
|
||||
status = str(response.get("status") or "unknown").lower()
|
||||
tasks = response.get("tasks")
|
||||
if status == "rejected" or (isinstance(tasks, list) and not tasks):
|
||||
detail = str(response.get("error") or "Artemis host rejected the task")
|
||||
raise TaskRejectedError(detail)
|
||||
if not isinstance(tasks, list) or not tasks or not isinstance(tasks[0], Mapping):
|
||||
raise ProtocolError("/api/run response did not contain an admitted task")
|
||||
|
||||
task_payload = dict(tasks[0])
|
||||
task_payload.setdefault("session_id", resolved_task_id)
|
||||
task_payload.setdefault("status", status)
|
||||
return TaskHandle.from_payload(task_payload)
|
||||
|
||||
async def get_task(self, task_id: str) -> TaskResult:
|
||||
"""Get a task from session storage or the live scheduler queue."""
|
||||
try:
|
||||
payload = await self._request("GET", f"/api/sessions/{task_id}")
|
||||
return TaskResult.from_payload(
|
||||
self._mapping(payload, endpoint=f"/api/sessions/{task_id}"),
|
||||
task_id=task_id,
|
||||
)
|
||||
except NotFoundError:
|
||||
status_payload = self._mapping(
|
||||
await self._request("GET", "/api/status"),
|
||||
endpoint="/api/status",
|
||||
)
|
||||
live_task = self._find_live_task(status_payload, task_id)
|
||||
if live_task is None:
|
||||
return TaskResult(task_id=task_id, status="launching")
|
||||
return TaskResult.from_payload(live_task, task_id=task_id)
|
||||
|
||||
async def wait_for_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
timeout: float = 1800.0,
|
||||
poll_interval: float | None = None,
|
||||
) -> TaskResult:
|
||||
"""Wait until a task reaches a terminal state."""
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout must be greater than zero")
|
||||
interval = self.poll_interval if poll_interval is None else float(poll_interval)
|
||||
if interval <= 0:
|
||||
raise ValueError("poll_interval must be greater than zero")
|
||||
|
||||
started = time.monotonic()
|
||||
while True:
|
||||
result = await self.get_task(task_id)
|
||||
if result.done:
|
||||
return result
|
||||
elapsed = time.monotonic() - started
|
||||
if elapsed >= timeout:
|
||||
raise TaskTimeoutError(task_id, timeout)
|
||||
await asyncio.sleep(min(interval, max(0.0, timeout - elapsed)))
|
||||
|
||||
async def run(
|
||||
self,
|
||||
goal: str,
|
||||
*,
|
||||
profile: Literal["flash", "pro"] | None = None,
|
||||
device_serial: str | None = None,
|
||||
expected_output: str | None = None,
|
||||
enable_outputter: bool | None = None,
|
||||
locked_app_package: str | None = None,
|
||||
app_path: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
timeout: float = 1800.0,
|
||||
poll_interval: float | None = None,
|
||||
) -> TaskResult:
|
||||
"""Submit a task and wait for its terminal result."""
|
||||
handle = await self.submit(
|
||||
goal,
|
||||
profile=profile,
|
||||
device_serial=device_serial,
|
||||
expected_output=expected_output,
|
||||
enable_outputter=enable_outputter,
|
||||
locked_app_package=locked_app_package,
|
||||
app_path=app_path,
|
||||
conversation_id=conversation_id,
|
||||
task_id=task_id,
|
||||
options=options,
|
||||
)
|
||||
return await self.wait_for_task(
|
||||
handle.task_id,
|
||||
timeout=timeout,
|
||||
poll_interval=poll_interval,
|
||||
)
|
||||
|
||||
async def run_task(self, task: Any, **overrides: Any) -> TaskResult:
|
||||
"""Run a task-like object while delegating all execution remotely."""
|
||||
goal = getattr(task, "goal", None)
|
||||
if not isinstance(goal, str) or not goal.strip():
|
||||
raise ValueError("task must expose a non-empty goal attribute")
|
||||
values: dict[str, Any] = {
|
||||
"profile": getattr(task, "profile", None),
|
||||
"device_serial": getattr(task, "device_serial", None)
|
||||
or getattr(task, "device_id", None),
|
||||
"locked_app_package": getattr(task, "locked_package", None),
|
||||
}
|
||||
values.update(overrides)
|
||||
return await self.run(goal, **values)
|
||||
|
||||
async def stop(self, task_id: str) -> bool:
|
||||
"""Request cancellation of one remote task."""
|
||||
payload = self._mapping(
|
||||
await self._request(
|
||||
"POST",
|
||||
"/api/stop",
|
||||
json_body={"session_id": task_id},
|
||||
),
|
||||
endpoint="/api/stop",
|
||||
)
|
||||
return str(payload.get("status") or "").lower() == "stopped"
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
return await asyncio.to_thread(
|
||||
self._transport.request,
|
||||
method,
|
||||
path,
|
||||
json_body=json_body,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mapping(payload: Any, *, endpoint: str) -> Mapping[str, Any]:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ProtocolError(f"{endpoint} response must be a JSON object")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _find_live_task(
|
||||
scheduler: Mapping[str, Any],
|
||||
task_id: str,
|
||||
) -> Mapping[str, Any] | None:
|
||||
for collection_name in ("queue", "active_tasks"):
|
||||
collection = scheduler.get(collection_name) or []
|
||||
if not isinstance(collection, list):
|
||||
continue
|
||||
for item in collection:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
item_id = item.get("task_id") or item.get("session_id")
|
||||
if str(item_id) == task_id:
|
||||
return item
|
||||
|
||||
active_id = scheduler.get("task_id") or scheduler.get("session_id")
|
||||
if active_id is not None and str(active_id) == task_id:
|
||||
synthesized = dict(scheduler)
|
||||
synthesized.setdefault("session_id", task_id)
|
||||
return synthesized
|
||||
return None
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Exceptions raised by the Artemis remote client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ArtemisClientError(Exception):
|
||||
"""Base class for all errors raised by this package."""
|
||||
|
||||
|
||||
class NetworkError(ArtemisClientError):
|
||||
"""The Artemis host could not be reached or the connection failed."""
|
||||
|
||||
|
||||
class ProtocolError(ArtemisClientError):
|
||||
"""The server returned a response that does not satisfy the API contract."""
|
||||
|
||||
|
||||
class ApiError(ArtemisClientError):
|
||||
"""The Artemis host returned an unsuccessful HTTP response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
*,
|
||||
payload: Any = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.payload = payload
|
||||
super().__init__(f"Artemis API returned HTTP {status_code}: {detail}")
|
||||
|
||||
|
||||
class AuthenticationError(ApiError):
|
||||
"""Authentication or authorization failed."""
|
||||
|
||||
|
||||
class NotFoundError(ApiError):
|
||||
"""The requested Artemis resource does not exist."""
|
||||
|
||||
|
||||
class ConflictError(ApiError):
|
||||
"""The request conflicts with the current server or device state."""
|
||||
|
||||
|
||||
class TaskRejectedError(ArtemisClientError):
|
||||
"""The server accepted the request but rejected task admission."""
|
||||
|
||||
|
||||
class TaskTimeoutError(ArtemisClientError):
|
||||
"""Waiting for an Artemis task exceeded the caller's deadline."""
|
||||
|
||||
def __init__(self, task_id: str, timeout: float) -> None:
|
||||
self.task_id = task_id
|
||||
self.timeout = timeout
|
||||
super().__init__(f"Task {task_id} did not finish within {timeout:g} seconds")
|
||||
@@ -0,0 +1,212 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Dependency-free data models for the Artemis remote API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
TERMINAL_TASK_STATUSES = frozenset(
|
||||
{"completed", "success", "failed", "cancelled", "canceled", "rejected"}
|
||||
)
|
||||
SUCCESS_TASK_STATUSES = frozenset({"completed", "success"})
|
||||
|
||||
|
||||
def _string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _integer(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _device_from_payload(payload: Mapping[str, Any]) -> str | None:
|
||||
direct = _string(payload.get("device_serial") or payload.get("device_id"))
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
device_info = payload.get("device_info")
|
||||
if isinstance(device_info, str):
|
||||
try:
|
||||
device_info = json.loads(device_info)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(device_info, Mapping):
|
||||
return _string(device_info.get("device_serial") or device_info.get("device_id"))
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskHandle:
|
||||
"""A task accepted by the remote Artemis scheduler."""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
device_serial: str | None = None
|
||||
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
"""Compatibility alias for servers that call a task a session."""
|
||||
return self.task_id
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, Any]) -> TaskHandle:
|
||||
task_id = _string(payload.get("task_id") or payload.get("session_id") or payload.get("id"))
|
||||
if not task_id:
|
||||
from artemis_client.errors import ProtocolError
|
||||
|
||||
raise ProtocolError("Task admission response did not contain a task/session ID")
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
status=(_string(payload.get("status")) or "queued").lower(),
|
||||
device_serial=_device_from_payload(payload),
|
||||
raw=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskResult:
|
||||
"""Current or terminal state of a remote Artemis task."""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
goal: str | None = None
|
||||
profile: str | None = None
|
||||
device_serial: str | None = None
|
||||
output: Any = None
|
||||
error: str | None = None
|
||||
turns: int | None = None
|
||||
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self.task_id
|
||||
|
||||
@property
|
||||
def trace_id(self) -> str:
|
||||
return self.task_id
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self.status in TERMINAL_TASK_STATUSES
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.status in SUCCESS_TASK_STATUSES
|
||||
|
||||
@classmethod
|
||||
def from_payload(
|
||||
cls,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
) -> TaskResult:
|
||||
resolved_id = _string(
|
||||
payload.get("task_id")
|
||||
or payload.get("session_id")
|
||||
or payload.get("trace_id")
|
||||
or payload.get("id")
|
||||
or task_id
|
||||
)
|
||||
if not resolved_id:
|
||||
from artemis_client.errors import ProtocolError
|
||||
|
||||
raise ProtocolError("Task response did not contain a task/session ID")
|
||||
|
||||
output = payload.get("output")
|
||||
if output is None:
|
||||
output = payload.get("result")
|
||||
if output is None:
|
||||
output = payload.get("summary")
|
||||
|
||||
return cls(
|
||||
task_id=resolved_id,
|
||||
status=(_string(payload.get("status")) or "unknown").lower(),
|
||||
goal=_string(payload.get("goal") or payload.get("initial_goal")),
|
||||
profile=_string(payload.get("profile")),
|
||||
device_serial=_device_from_payload(payload),
|
||||
output=output,
|
||||
error=_string(payload.get("error") or payload.get("error_message")),
|
||||
turns=_integer(payload.get("turns") or payload.get("current_turn")),
|
||||
raw=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Device:
|
||||
"""A device reported by the remote Artemis host."""
|
||||
|
||||
serial: str
|
||||
state: str
|
||||
model: str | None = None
|
||||
product: str | None = None
|
||||
busy: bool = False
|
||||
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, Any]) -> Device:
|
||||
serial = _string(
|
||||
payload.get("serial") or payload.get("device_serial") or payload.get("device_id")
|
||||
)
|
||||
if not serial:
|
||||
from artemis_client.errors import ProtocolError
|
||||
|
||||
raise ProtocolError("Device response did not contain a serial number")
|
||||
state = (_string(payload.get("state") or payload.get("status")) or "unknown").lower()
|
||||
busy_value = payload.get("busy")
|
||||
if busy_value is None:
|
||||
busy_value = payload.get("is_busy")
|
||||
return cls(
|
||||
serial=serial,
|
||||
state=state,
|
||||
model=_string(payload.get("model")),
|
||||
product=_string(payload.get("product")),
|
||||
busy=bool(busy_value) or state in {"busy", "running", "locked"},
|
||||
raw=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Capabilities:
|
||||
"""Features advertised by an Artemis host."""
|
||||
|
||||
api_version: str
|
||||
features: frozenset[str]
|
||||
server_version: str | None = None
|
||||
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
||||
|
||||
def supports(self, feature: str) -> bool:
|
||||
return feature in self.features
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, Any]) -> Capabilities:
|
||||
raw_features = payload.get("features") or []
|
||||
if isinstance(raw_features, Mapping):
|
||||
features = frozenset(str(key) for key, enabled in raw_features.items() if enabled)
|
||||
elif isinstance(raw_features, (list, tuple, set, frozenset)):
|
||||
features = frozenset(str(item) for item in raw_features)
|
||||
else:
|
||||
features = frozenset()
|
||||
return cls(
|
||||
api_version=_string(payload.get("api_version")) or "unknown",
|
||||
server_version=_string(payload.get("server_version") or payload.get("version")),
|
||||
features=features,
|
||||
raw=dict(payload),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Small JSON-over-HTTP transport implemented with the Python standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from artemis_client.errors import (
|
||||
ApiError,
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
ProtocolError,
|
||||
)
|
||||
|
||||
|
||||
class JsonTransport:
|
||||
"""Synchronous transport used internally by the async client.
|
||||
|
||||
Calls are moved to worker threads by :class:`ArtemisClient`, keeping the
|
||||
public API asynchronous without adding an HTTP dependency.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
token: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
ssl_context: ssl.SSLContext | None = None,
|
||||
) -> None:
|
||||
normalized = base_url.rstrip("/")
|
||||
parsed = urlsplit(normalized)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("base_url must be an absolute http:// or https:// URL")
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout must be greater than zero")
|
||||
|
||||
self.base_url = normalized
|
||||
self.timeout = float(timeout)
|
||||
self.ssl_context = ssl_context
|
||||
self.headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "artemis-client/0.1",
|
||||
**dict(headers or {}),
|
||||
}
|
||||
if token:
|
||||
self.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
url = f"{self.base_url}{path}"
|
||||
body = None
|
||||
headers = dict(self.headers)
|
||||
if json_body is not None:
|
||||
body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=method.upper(),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request,
|
||||
timeout=self.timeout,
|
||||
context=self.ssl_context,
|
||||
) as response:
|
||||
data = response.read()
|
||||
if not data:
|
||||
return None
|
||||
try:
|
||||
return json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ProtocolError(f"Artemis host returned invalid JSON from {path}") from exc
|
||||
except urllib.error.HTTPError as exc:
|
||||
payload = self._decode_error_payload(exc)
|
||||
detail = self._error_detail(payload, exc.reason)
|
||||
error_type = {
|
||||
401: AuthenticationError,
|
||||
403: AuthenticationError,
|
||||
404: NotFoundError,
|
||||
409: ConflictError,
|
||||
}.get(exc.code, ApiError)
|
||||
raise error_type(exc.code, detail, payload=payload) from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
raise NetworkError(f"Could not reach Artemis host at {url}: {reason}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _decode_error_payload(error: urllib.error.HTTPError) -> Any:
|
||||
try:
|
||||
data = error.read()
|
||||
except OSError:
|
||||
return None
|
||||
if not data:
|
||||
return None
|
||||
try:
|
||||
return json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
@staticmethod
|
||||
def _error_detail(payload: Any, fallback: Any) -> str:
|
||||
if isinstance(payload, Mapping):
|
||||
detail = payload.get("detail") or payload.get("error") or payload.get("message")
|
||||
if detail is not None:
|
||||
return str(detail)
|
||||
if isinstance(payload, str) and payload.strip():
|
||||
return payload.strip()
|
||||
return str(fallback or "request failed")
|
||||
@@ -0,0 +1,246 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from artemis_client import (
|
||||
ArtemisClient,
|
||||
NotFoundError,
|
||||
ProtocolError,
|
||||
TaskRejectedError,
|
||||
TaskTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self) -> None:
|
||||
self.responses: dict[tuple[str, str], list[Any]] = defaultdict(list)
|
||||
self.calls: list[tuple[str, str, Mapping[str, Any] | None]] = []
|
||||
|
||||
def add(self, method: str, path: str, *responses: Any) -> None:
|
||||
self.responses[(method, path)].extend(responses)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
self.calls.append((method, path, json_body))
|
||||
queued = self.responses[(method, path)]
|
||||
if not queued:
|
||||
raise AssertionError(f"Unexpected request: {method} {path}")
|
||||
response = queued.pop(0)
|
||||
if isinstance(response, BaseException):
|
||||
raise response
|
||||
return response
|
||||
|
||||
|
||||
class ArtemisClientTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.transport = FakeTransport()
|
||||
self.client = ArtemisClient(
|
||||
"https://artemis.example.test",
|
||||
poll_interval=0.001,
|
||||
transport=self.transport,
|
||||
)
|
||||
|
||||
async def test_submit_sends_idempotent_session_id(self) -> None:
|
||||
task_id = "00000000-0000-4000-8000-000000000123"
|
||||
self.transport.add(
|
||||
"POST",
|
||||
"/api/run",
|
||||
{
|
||||
"status": "started",
|
||||
"tasks": [
|
||||
{
|
||||
"session_id": task_id,
|
||||
"status": "pending",
|
||||
"device_serial": "pixel-8",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
handle = await self.client.submit(
|
||||
"Open Settings",
|
||||
task_id=task_id,
|
||||
device_serial="pixel-8",
|
||||
options={"record_video": True},
|
||||
)
|
||||
|
||||
self.assertEqual(handle.task_id, task_id)
|
||||
self.assertEqual(handle.device_serial, "pixel-8")
|
||||
body = self.transport.calls[0][2]
|
||||
assert body is not None
|
||||
self.assertEqual(body["session_id"], task_id)
|
||||
self.assertEqual(body["ingress"], "python_sdk")
|
||||
self.assertEqual(body["options"], {"record_video": True})
|
||||
|
||||
async def test_submit_rejected_task_raises_specific_error(self) -> None:
|
||||
self.transport.add(
|
||||
"POST",
|
||||
"/api/run",
|
||||
{
|
||||
"status": "rejected",
|
||||
"error": "Device is offline",
|
||||
"tasks": [],
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(TaskRejectedError, "Device is offline"):
|
||||
await self.client.submit("Open Settings")
|
||||
|
||||
async def test_submit_rejects_non_uuid_task_id(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "valid UUID"):
|
||||
await self.client.submit("Open Settings", task_id="not-a-uuid")
|
||||
|
||||
async def test_run_finds_queued_task_then_reads_terminal_session(self) -> None:
|
||||
task_id = "00000000-0000-4000-8000-000000000124"
|
||||
self.transport.add(
|
||||
"POST",
|
||||
"/api/run",
|
||||
{
|
||||
"status": "started",
|
||||
"tasks": [{"session_id": task_id, "status": "pending"}],
|
||||
},
|
||||
)
|
||||
self.transport.add(
|
||||
"GET",
|
||||
f"/api/sessions/{task_id}",
|
||||
NotFoundError(404, "not created yet"),
|
||||
{
|
||||
"session_id": task_id,
|
||||
"status": "completed",
|
||||
"goal": "Open Settings",
|
||||
"current_turn": 4,
|
||||
"summary": "Battery page opened",
|
||||
},
|
||||
)
|
||||
self.transport.add(
|
||||
"GET",
|
||||
"/api/status",
|
||||
{
|
||||
"status": "running",
|
||||
"queue": [{"session_id": task_id, "status": "pending"}],
|
||||
},
|
||||
)
|
||||
|
||||
result = await self.client.run("Open Settings", task_id=task_id, timeout=1)
|
||||
|
||||
self.assertTrue(result.done)
|
||||
self.assertTrue(result.succeeded)
|
||||
self.assertEqual(result.turns, 4)
|
||||
self.assertEqual(result.output, "Battery page opened")
|
||||
|
||||
async def test_get_task_returns_launching_when_not_visible_yet(self) -> None:
|
||||
self.transport.add(
|
||||
"GET",
|
||||
"/api/sessions/new-task",
|
||||
NotFoundError(404, "missing"),
|
||||
)
|
||||
self.transport.add("GET", "/api/status", {"status": "idle", "queue": []})
|
||||
|
||||
result = await self.client.get_task("new-task")
|
||||
|
||||
self.assertEqual(result.status, "launching")
|
||||
self.assertFalse(result.done)
|
||||
|
||||
async def test_wait_for_task_times_out(self) -> None:
|
||||
class LaunchingClient(ArtemisClient):
|
||||
async def get_task(self, task_id: str): # type: ignore[override]
|
||||
from artemis_client import TaskResult
|
||||
|
||||
return TaskResult(task_id=task_id, status="running")
|
||||
|
||||
client = LaunchingClient(
|
||||
"https://artemis.example.test",
|
||||
poll_interval=0.001,
|
||||
transport=self.transport,
|
||||
)
|
||||
with self.assertRaises(TaskTimeoutError):
|
||||
await client.wait_for_task("slow-task", timeout=0.003)
|
||||
|
||||
async def test_list_devices_accepts_legacy_shape(self) -> None:
|
||||
self.transport.add(
|
||||
"GET",
|
||||
"/api/devices",
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"serial": "emulator-5554",
|
||||
"state": "device",
|
||||
"model": "Pixel_8",
|
||||
"busy": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
devices = await self.client.list_devices()
|
||||
|
||||
self.assertEqual(len(devices), 1)
|
||||
self.assertEqual(devices[0].serial, "emulator-5554")
|
||||
self.assertTrue(devices[0].busy)
|
||||
|
||||
async def test_capabilities_falls_back_for_legacy_server(self) -> None:
|
||||
self.transport.add(
|
||||
"GET",
|
||||
"/api/v1/capabilities",
|
||||
NotFoundError(404, "not implemented"),
|
||||
)
|
||||
|
||||
capabilities = await self.client.capabilities()
|
||||
|
||||
self.assertEqual(capabilities.api_version, "legacy")
|
||||
self.assertTrue(capabilities.supports("tasks.submit"))
|
||||
|
||||
async def test_health_uses_fast_scheduler_endpoint(self) -> None:
|
||||
self.transport.add("GET", "/api/status", {"status": "idle"})
|
||||
|
||||
health = await self.client.health()
|
||||
|
||||
self.assertEqual(health["status"], "idle")
|
||||
|
||||
async def test_readiness_uses_full_diagnostics_endpoint(self) -> None:
|
||||
self.transport.add(
|
||||
"GET",
|
||||
"/api/system/readiness",
|
||||
{"overall_status": "ready"},
|
||||
)
|
||||
|
||||
readiness = await self.client.readiness()
|
||||
|
||||
self.assertEqual(readiness["overall_status"], "ready")
|
||||
|
||||
async def test_invalid_devices_payload_is_rejected(self) -> None:
|
||||
self.transport.add("GET", "/api/devices", {"devices": "not-a-list"})
|
||||
|
||||
with self.assertRaises(ProtocolError):
|
||||
await self.client.list_devices()
|
||||
|
||||
async def test_stop_targets_one_session(self) -> None:
|
||||
self.transport.add("POST", "/api/stop", {"status": "stopped"})
|
||||
|
||||
stopped = await self.client.stop("task-stop")
|
||||
|
||||
self.assertTrue(stopped)
|
||||
self.assertEqual(
|
||||
self.transport.calls[0],
|
||||
("POST", "/api/stop", {"session_id": "task-stop"}),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from email.message import Message
|
||||
from unittest.mock import patch
|
||||
|
||||
from artemis_client import AuthenticationError, JsonTransport, NetworkError, ProtocolError
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self) -> FakeResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self.payload
|
||||
|
||||
|
||||
class JsonTransportTests(unittest.TestCase):
|
||||
def test_rejects_non_http_base_url(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
JsonTransport("file:///tmp/artemis")
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_sends_json_and_bearer_token(self, urlopen) -> None:
|
||||
urlopen.return_value = FakeResponse(json.dumps({"ok": True}).encode())
|
||||
transport = JsonTransport("https://host.example/", token="secret")
|
||||
|
||||
result = transport.request("POST", "/api/run", json_body={"goal": "test"})
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
request = urlopen.call_args.args[0]
|
||||
self.assertEqual(request.full_url, "https://host.example/api/run")
|
||||
self.assertEqual(request.headers["Authorization"], "Bearer secret")
|
||||
self.assertEqual(json.loads(request.data), {"goal": "test"})
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_maps_unauthorized_response(self, urlopen) -> None:
|
||||
urlopen.side_effect = urllib.error.HTTPError(
|
||||
"https://host.example/api/run",
|
||||
401,
|
||||
"Unauthorized",
|
||||
Message(),
|
||||
io.BytesIO(b'{"detail":"bad token"}'),
|
||||
)
|
||||
transport = JsonTransport("https://host.example")
|
||||
|
||||
with self.assertRaisesRegex(AuthenticationError, "bad token"):
|
||||
transport.request("GET", "/api/run")
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_invalid_json_raises_protocol_error(self, urlopen) -> None:
|
||||
urlopen.return_value = FakeResponse(b"not-json")
|
||||
transport = JsonTransport("https://host.example")
|
||||
|
||||
with self.assertRaises(ProtocolError):
|
||||
transport.request("GET", "/api/status")
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_network_failure_is_wrapped(self, urlopen) -> None:
|
||||
urlopen.side_effect = urllib.error.URLError("connection refused")
|
||||
transport = JsonTransport("https://host.example")
|
||||
|
||||
with self.assertRaisesRegex(NetworkError, "connection refused"):
|
||||
transport.request("GET", "/api/status")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user