"""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 tasks(self) -> dict: """The list, read for one fact: which task is pinned. There is no Tasks page here any more — the list is the web app's job. `Controller._on_tasks` keeps the pinned id so that starting an interval from the tray attaches what you pinned this morning. """ return self._request("GET", "/api/tasks") def refresh_tasks(self) -> dict: return self._request("POST", "/api/tasks/refresh", {}) def blocker_policy(self) -> dict: return self._request("GET", "/api/blocker/policy") 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 send_report(self) -> dict: return self._request("POST", "/api/reports/daily/send", {"force": True}) 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}"