`[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>
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""Todoist as a todo source.
|
|
|
|
A personal API token sent as a Bearer token — no OAuth dance. jira-helper asks
|
|
Todoist's own `overdue | today` filter so Todoist's timezone and recurrence
|
|
rules decide what is due; here the default is *every* open task (you need
|
|
something to point the timer at), with the filter still available as a setting
|
|
for people who want the narrower list.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.settings_schema import TodoistSourceSettings
|
|
from app.sources.base import SourceError, TodoItem
|
|
|
|
_API_BASE = "https://api.todoist.com/api/v1"
|
|
_TASK_URL = "https://app.todoist.com/app/task/{task_id}"
|
|
_PAGE_SIZE = 200
|
|
|
|
# Todoist stores priority upside down from how its apps label it: 4 is "p1".
|
|
_PRIORITY_LABELS = {4: "p1", 3: "p2", 2: "p3"}
|
|
|
|
|
|
class TodoistSource:
|
|
name = "todoist"
|
|
|
|
def __init__(self, config: TodoistSourceSettings, *, timeout: int = 30) -> None:
|
|
if not config.api_token:
|
|
raise SourceError("Todoist needs an API token (Settings → Integrations).")
|
|
self._config = config
|
|
self._client = httpx.Client(
|
|
base_url=_API_BASE,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"Authorization": f"Bearer {config.api_token}",
|
|
},
|
|
timeout=timeout,
|
|
)
|
|
|
|
# ---- plumbing ----------------------------------------------------------
|
|
|
|
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
try:
|
|
resp = self._client.get(path, params=params)
|
|
except httpx.HTTPError as exc:
|
|
raise SourceError(f"Todoist is unreachable: {exc}") from exc
|
|
if resp.status_code in (401, 403):
|
|
raise SourceError("Todoist rejected the API token.")
|
|
if not resp.is_success:
|
|
raise SourceError(f"Todoist 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("results", [])
|
|
cursor = data.get("next_cursor")
|
|
if not cursor:
|
|
return
|
|
params["cursor"] = cursor
|
|
|
|
def _projects(self) -> dict[str, str]:
|
|
return {
|
|
project["id"]: project.get("name") or "(untitled project)"
|
|
for project in self._paginate("/projects", {"limit": _PAGE_SIZE})
|
|
if project.get("id")
|
|
}
|
|
|
|
def _selected(self, project_id: str, project_name: str) -> bool:
|
|
if not self._config.projects:
|
|
return True
|
|
wanted = set(self._config.projects)
|
|
return bool(wanted & {project_id, project_name})
|
|
|
|
# ---- source API --------------------------------------------------------
|
|
|
|
def fetch(self) -> list[TodoItem]:
|
|
projects = self._projects()
|
|
query = self._config.filter_query.strip()
|
|
if query:
|
|
raw_tasks = self._paginate("/tasks/filter", {"query": query, "limit": _PAGE_SIZE})
|
|
else:
|
|
raw_tasks = self._paginate("/tasks", {"limit": _PAGE_SIZE})
|
|
|
|
items: list[TodoItem] = []
|
|
for raw in raw_tasks:
|
|
if raw.get("checked") or raw.get("is_deleted"):
|
|
continue
|
|
project_id = raw.get("project_id") or ""
|
|
project_name = projects.get(project_id, "(unknown project)")
|
|
if not self._selected(project_id, project_name):
|
|
continue
|
|
task_id = str(raw.get("id") or "")
|
|
due = raw.get("due") or {}
|
|
items.append(
|
|
TodoItem(
|
|
source=self.name,
|
|
external_id=task_id,
|
|
title=(raw.get("content") or "").strip() or "(no content)",
|
|
description=(raw.get("description") or "").strip() or None,
|
|
url=_TASK_URL.format(task_id=task_id) if task_id else None,
|
|
project=project_name,
|
|
priority=_PRIORITY_LABELS.get(raw.get("priority")),
|
|
due_date=_parse_due(due),
|
|
labels=list(raw.get("labels") or []),
|
|
raw={"recurring": bool(due.get("is_recurring"))},
|
|
)
|
|
)
|
|
return items
|
|
|
|
def check(self) -> str:
|
|
projects = self._projects()
|
|
return f"{len(projects)} Todoist project(s) visible"
|
|
|
|
def close(self) -> None:
|
|
self._client.close()
|
|
|
|
|
|
def _parse_due(due: Any) -> date | None:
|
|
if not isinstance(due, dict):
|
|
return None
|
|
raw = due.get("date")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return date.fromisoformat(str(raw)[:10])
|
|
except ValueError:
|
|
return None
|