"""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) 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}). " "Is `docker compose up` running?" ) 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}"