Files
kadence/backend/app/sources/google_tasks.py
T
timka123andClaude Opus 5 ec7eedbc66 style: say which lint rules this repo keeps, then keep them
`[tool.ruff]` set a line length and a target version and nothing else, so which
rules ran depended on the installed ruff's defaults — fine while nobody ran it,
not fine for a CI job that can turn a push red because a tool changed its mind.

The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that
are each a real decision rather than a shrug: B008, because FastAPI's whole
dependency idiom is a call in a default argument; E501, because wrapping prose
is a formatter's business; and RUF001, because the reports and the bot speak
Russian and "ambiguous character" is the wrong complaint about a string the
user is meant to read.

The 73 findings that remained are fixed — mostly `datetime.timezone.utc` →
`datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order,
and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two
try/except/pass pairs, unpacking instead of list concatenation, one negated
return.

`ruff format` is deliberately *not* adopted: it would reformat 51 files, and
some of the layout here is hand-set (the comment tables, the DNS wire-format
structs) in ways a formatter flattens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:28:57 +03:00

158 lines
5.7 KiB
Python

"""Google Tasks as a todo source.
OAuth 2.0: the long-lived refresh token from the settings is exchanged for an
access token per run (cached for the life of the object). Obtain the refresh
token with jira-helper's `uv run google-tasks-auth` — the credentials are
interchangeable, so an existing setup works here unchanged.
Unlike jira-helper, undated tasks are kept by default: "someday" items are
still things you can spend a pomodoro on.
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import date
from typing import Any
import httpx
from app.settings_schema import GoogleTasksSourceSettings
from app.sources.base import SourceError, TodoItem
_TOKEN_URL = "https://oauth2.googleapis.com/token"
_API_BASE = "https://tasks.googleapis.com/tasks/v1"
_PAGE_SIZE = 100
class GoogleTasksSource:
name = "google_tasks"
def __init__(self, config: GoogleTasksSourceSettings, *, timeout: int = 30) -> None:
if not (config.client_id and config.client_secret and config.refresh_token):
raise SourceError(
"Google Tasks needs a client id, client secret and refresh token."
)
self._config = config
self._client = httpx.Client(timeout=timeout)
self._access_token: str | None = None
# ---- plumbing ----------------------------------------------------------
def _token(self) -> str:
if self._access_token:
return self._access_token
try:
resp = self._client.post(
_TOKEN_URL,
data={
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"refresh_token": self._config.refresh_token,
"grant_type": "refresh_token",
},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(
"Could not refresh the Google access token "
f"({resp.status_code}). Re-run the Google auth flow for a fresh "
"refresh token."
)
token = (resp.json() or {}).get("access_token")
if not token:
raise SourceError("Google's token response carried no access_token.")
self._access_token = token
return token
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
try:
resp = self._client.get(
f"{_API_BASE}{path}",
params=params,
headers={"Authorization": f"Bearer {self._token()}"},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Google GET {path} -> {resp.status_code}: {resp.text[:300]}")
return resp.json() or {}
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
params = dict(params)
while True:
data = self._get(path, params)
yield from data.get("items", [])
token = data.get("nextPageToken")
if not token:
return
params["pageToken"] = token
def _task_lists(self) -> list[dict[str, Any]]:
lists = list(self._paginate("/users/@me/lists", {"maxResults": _PAGE_SIZE}))
if not self._config.lists:
return lists
wanted = set(self._config.lists)
return [
task_list
for task_list in lists
if wanted & {task_list.get("id", ""), task_list.get("title", "")}
]
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
items: list[TodoItem] = []
for task_list in self._task_lists():
list_id = task_list.get("id")
if not list_id:
continue
list_title = task_list.get("title") or "(untitled list)"
for raw in self._paginate(
f"/lists/{list_id}/tasks",
{
"maxResults": _PAGE_SIZE,
"showCompleted": "false",
"showHidden": "false",
"showDeleted": "false",
},
):
if raw.get("status") == "completed":
continue
due = _parse_due(raw.get("due"))
if due is None and not self._config.include_undated:
continue
task_id = raw.get("id") or ""
items.append(
TodoItem(
source=self.name,
# The list id is part of every write path, so it has to
# survive the round-trip through our database.
external_id=f"{list_id}:{task_id}",
title=(raw.get("title") or "").strip() or "(no title)",
description=(raw.get("notes") or "").strip() or None,
url=raw.get("webViewLink"),
project=list_title,
due_date=due,
raw={"list_id": list_id, "task_id": task_id},
)
)
return items
def check(self) -> str:
return f"{len(self._task_lists())} Google task list(s) visible"
def close(self) -> None:
self._client.close()
def _parse_due(raw: Any) -> date | None:
"""Google returns RFC-3339, but only the date part is ever meaningful."""
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None