Files
kadence/desktop/kadence_desktop/api.py
T
timka123andClaude Opus 5 738734d485 docs: the phone client, and the Glyph traps worth not re-learning
The android README still described the app as unbuilt and pointed `glyph/libs/`
at a path that does not exist. It now covers what the app is made of, how to get
the SDK, and the three Glyph behaviours that cost an afternoon — two of which
fail with no exception and no log at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 16:17:25 +03:00

161 lines
5.9 KiB
Python

"""HTTP client for the backend.
Deliberately stdlib-only (`urllib`): the desktop app should start with the
system Python on a fresh machine, and a pomodoro client does not need an async
HTTP stack to call eight endpoints.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
class ApiError(RuntimeError):
"""A call failed. The message is meant to be shown in the UI."""
def __init__(self, message: str, status: int = 0) -> None:
super().__init__(message)
self.status = status
class Api:
def __init__(self, base_url: str, token: str = "", timeout: float = 10.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout
def configure(self, base_url: str, token: str) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
# ---- plumbing ----------------------------------------------------------
def _request(self, method: str, path: str, payload: Any | None = None) -> Any:
url = f"{self.base_url}{path}"
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
if self.token:
headers["X-API-Token"] = self.token
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as exc:
detail = _detail(exc)
if exc.code == 401:
hint = (
"Set the API token in Settings → Backend."
if self.token
else "No API token configured — add it in Settings → Backend."
)
raise ApiError(f"{detail} {hint}", exc.code) from exc
raise ApiError(detail, exc.code) from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise ApiError(
f"Cannot reach the backend at {self.base_url} ({exc}). "
"Check the server URL in Settings → Backend, or is "
"`docker compose up` running locally?"
) from exc
except json.JSONDecodeError as exc:
raise ApiError(f"The backend sent something that is not JSON: {exc}") from exc
# ---- endpoints ---------------------------------------------------------
def health(self) -> dict:
return self._request("GET", "/api/health")
def timer_state(self) -> dict:
return self._request("GET", "/api/timer/state")
def start(self, kind: str | None = None, task_id: int | None = None, minutes: int | None = None) -> dict:
return self._request(
"POST", "/api/timer/start", {"kind": kind, "task_id": task_id, "minutes": minutes}
)
def pause(self) -> dict:
return self._request("POST", "/api/timer/pause", {})
def resume(self) -> dict:
return self._request("POST", "/api/timer/resume", {})
def complete(self, auto_next: bool = True) -> dict:
return self._request("POST", "/api/timer/complete", {"auto_next": auto_next})
def abandon(self, reason: str | None = None) -> dict:
return self._request("POST", "/api/timer/abandon", {"reason": reason})
def extend(self, minutes: int = 5) -> dict:
return self._request("POST", "/api/timer/extend", {"minutes": minutes})
def interrupt(self) -> dict:
return self._request("POST", "/api/timer/interrupt", {})
def attach_task(self, task_id: int | None) -> dict:
return self._request("POST", "/api/timer/task", {"task_id": task_id})
def tasks(self) -> dict:
return self._request("GET", "/api/tasks")
def refresh_tasks(self) -> dict:
return self._request("POST", "/api/tasks/refresh", {})
def patch_task(self, task_id: int, patch: dict) -> dict:
"""Edit a task's local fields (pinning, estimate). Completion is not ours."""
return self._request("PATCH", f"/api/tasks/{task_id}", patch)
def create_task(self, title: str, due_date: str | None = None) -> dict:
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
def blocker_policy(self) -> dict:
return self._request("GET", "/api/blocker/policy")
def blocker_events(self) -> dict:
return self._request("GET", "/api/blocker/events")
def report_block(self, kind: str, target: str, action: str = "blocked", details: str | None = None) -> dict:
return self._request(
"POST",
"/api/blocker/events",
{"kind": kind, "target": target, "action": action, "details": details},
)
def daily_report(self) -> dict:
return self._request("GET", "/api/reports/daily")
def send_report(self) -> dict:
return self._request("POST", "/api/reports/daily/send", {"force": True})
def stats(self) -> dict:
return self._request("GET", "/api/stats/summary?days=30")
def settings(self) -> dict:
return self._request("GET", "/api/settings")
def patch_settings(self, patch: dict) -> dict:
return self._request("PUT", "/api/settings", patch)
def _detail(exc: urllib.error.HTTPError) -> str:
try:
body = json.loads(exc.read())
except Exception:
return f"{exc.code} {exc.reason}"
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, str):
return detail
if isinstance(detail, list):
return "; ".join(
f"{'.'.join(str(part) for part in item.get('loc', [])[1:])}: {item.get('msg', '')}"
for item in detail
)
return f"{exc.code} {exc.reason}"