"""Builds the configured sources and pulls them all, independently. Independence is the whole point: an expired laundry cookie or a Jira outage costs you that one source and shows up as a warning beside it, never as an empty task list. """ from __future__ import annotations import logging from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable from app.settings_schema import AppSettings from app.sources.base import SourceError, SourceResult, TodoItem from app.sources.google_tasks import GoogleTasksSource from app.sources.jira import JiraSource from app.sources.laundry import LaundrySource from app.sources.todoist import TodoistSource log = logging.getLogger(__name__) SOURCE_LABELS = { "jira": "Jira", "todoist": "Todoist", "google_tasks": "Google Tasks", "laundry": "Laundry", "local": "My list", } def build_sources(settings: AppSettings) -> dict[str, Any]: """Instantiate every enabled source. A misconfigured one is skipped, loudly.""" timeout = settings.sources.request_timeout_seconds builders: list[tuple[str, Callable[[], Any]]] = [ ("jira", lambda: JiraSource(settings.sources.jira, timeout=timeout)), ("todoist", lambda: TodoistSource(settings.sources.todoist, timeout=timeout)), ( "google_tasks", lambda: GoogleTasksSource(settings.sources.google_tasks, timeout=timeout), ), ("laundry", lambda: LaundrySource(settings.sources.laundry, timeout=timeout)), ] enabled = { "jira": settings.sources.jira.enabled, "todoist": settings.sources.todoist.enabled, "google_tasks": settings.sources.google_tasks.enabled, "laundry": settings.sources.laundry.enabled, } sources: dict[str, Any] = {} for name, build in builders: if not enabled[name]: continue try: sources[name] = build() except SourceError as exc: log.warning("source %s is enabled but unusable: %s", name, exc) return sources def _close(source: Any) -> None: closer = getattr(source, "close", None) if callable(closer): try: closer() except Exception: # pragma: no cover log.debug("closing %s failed", type(source).__name__, exc_info=True) def fetch_all(settings: AppSettings) -> list[SourceResult]: """Fetch every enabled source in parallel; never raises.""" sources = build_sources(settings) if not sources: return [] def run(name: str, source: Any) -> SourceResult: try: items: list[TodoItem] = source.fetch() return SourceResult(source=name, items=items) except SourceError as exc: return SourceResult.failed(name, str(exc)) except Exception as exc: # pragma: no cover - defensive log.exception("source %s blew up", name) return SourceResult.failed(name, f"unexpected error: {exc}") try: with ThreadPoolExecutor(max_workers=len(sources)) as pool: futures = [pool.submit(run, name, src) for name, src in sources.items()] return [future.result() for future in futures] finally: for source in sources.values(): _close(source) def complete_remote(settings: AppSettings, item: TodoItem) -> tuple[bool, str | None]: """Close a task in its own system. Returns `(did_write, error)`.""" sources = build_sources(settings) source = sources.get(item.source) if source is None: return False, None try: return bool(source.complete(item)), None except SourceError as exc: return False, str(exc) finally: for built in sources.values(): _close(built) def check_source(settings: AppSettings, name: str) -> tuple[bool, str]: """Verify one source's credentials — what the settings page's Test button calls.""" sources = build_sources(settings) source = sources.get(name) if source is None: return False, f"{SOURCE_LABELS.get(name, name)} is not enabled or not configured." try: return True, source.check() except SourceError as exc: return False, str(exc) except Exception as exc: # pragma: no cover return False, f"unexpected error: {exc}" finally: for built in sources.values(): _close(built)