Files
timka123 157a113c39
lint / ruff (push) Failing after 15s
tests / desktop (push) Successful in 21s
tests / web (push) Successful in 46s
docker / image (push) Failing after 1m31s
tests / android-core (push) Successful in 1m5s
tests / backend (push) Successful in 2m33s
android / apk (push) Successful in 4m34s
fix timers
2026-10-27 18:52:54 +03:00

155 lines
5.7 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, session_id: int | None = None) -> dict:
"""`session_id` names the interval we think we are finishing.
The server ignores the call if something else is running by then, which
is what keeps two clients hitting zero at the same moment from eating
the break the first one's `auto_next` just started.
"""
return self._request(
"POST",
"/api/timer/complete",
{"auto_next": auto_next, "session_id": session_id},
)
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}"