agnes-the-ai-analyst/cli/v2_client.py
ZdenekSrotyr 16373d6b0b feat(cli): agnes store + agnes my-stack commands
Adds CLI coverage for the new REST surface introduced in this PR:

  agnes store list / show / install / uninstall / upload / delete
  agnes my-stack show / toggle

Covers 11 of the 15 new endpoints — listing, detail, install/uninstall,
upload (multipart), delete, my-stack get + curated toggle. Photo / docs
download endpoints intentionally skipped; analyst-side automation rarely
needs raw bytes back, and the web UI already covers them.

cli/v2_client.py: api_post_multipart + api_put_multipart helpers (httpx
files= passthrough). api_delete + api_put_json fillers were already
needed for non-multipart writes; added together.

Tests: tests/test_cli_store.py — help-text smoke tests + happy-path
mocked tests for list, install, upload, my-stack show, my-stack toggle.
12 new tests, all green.
2026-05-05 08:18:12 +02:00

129 lines
4.1 KiB
Python

"""HTTP client helpers for /api/v2/* endpoints (CLI side)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import io
import httpx
import pyarrow as pa
from cli.config import get_server_url, get_token
from cli.error_render import render_error
@dataclass
class V2ClientError(Exception):
status_code: int
body: Any
# `message` retained for backwards compat with any existing caller
# that reads `.message`. Renderer is the canonical str path now.
message: str = ""
def __str__(self) -> str:
# Prefer the structured renderer — it pretty-prints typed BQ errors
# (cross_project_forbidden, remote_scan_too_large, etc.) instead
# of the historical truncate-and-flatten form. Falls back to
# truncated form for unrecognized bodies, so we never make output
# WORSE than the status-quo (#160 §4.7).
return render_error(self.status_code, self.body)
def _headers() -> dict:
token = get_token()
return {"Authorization": f"Bearer {token}"} if token else {}
def _parse_error_body(r: httpx.Response) -> Any:
if "json" in r.headers.get("content-type", ""):
try:
return r.json()
except Exception:
return r.text
return r.text
def api_get_json(path: str, **params) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.get(url, headers=_headers(), params=params or None, timeout=30)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
return r.json()
def api_post_json(path: str, payload: dict) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.post(url, json=payload, headers=_headers(), timeout=120)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
return r.json()
def api_delete(path: str) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.delete(url, headers=_headers(), timeout=30)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
if not r.content:
return {}
if "json" in r.headers.get("content-type", ""):
return r.json()
return {}
def api_put_json(path: str, payload: dict) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.put(url, json=payload, headers=_headers(), timeout=30)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
if not r.content:
return {}
return r.json()
def api_post_multipart(
path: str,
*,
files: dict | None = None,
data: dict | None = None,
) -> dict:
"""POST a multipart/form-data request — used for Store ZIP/photo uploads.
`files` mirrors httpx.post(..., files=...): each value is an
(filename, bytes, content_type) tuple or an open file-like object.
`data` is the form fields. Returns parsed JSON.
"""
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.post(
url, files=files or None, data=data or None,
headers=_headers(), timeout=600,
)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
return r.json()
def api_put_multipart(
path: str,
*,
files: dict | None = None,
data: dict | None = None,
) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.put(
url, files=files or None, data=data or None,
headers=_headers(), timeout=600,
)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
return r.json()
def api_post_arrow(path: str, payload: dict) -> pa.Table:
"""Post JSON, expect Arrow IPC stream response."""
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.post(url, json=payload, headers=_headers(), timeout=600)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
reader = pa.ipc.open_stream(io.BytesIO(r.content))
return reader.read_all()