init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,125 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user