diff --git a/focus_project/focus_project/sources/__init__.py b/focus_project/focus_project/sources/__init__.py new file mode 100644 index 0000000..b43b6b1 --- /dev/null +++ b/focus_project/focus_project/sources/__init__.py @@ -0,0 +1,87 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Evidence adapters: each turns one record of work into time intervals. + +An adapter is a module ``focus_project.sources.`` exposing one pure, +read-only function:: + + def intervals(day: date, project: Project) -> list[Interval] + +``Interval`` is ``(start, end)`` in unix seconds, ``start <= end``. Adapters +may return spans outside ``day``; :func:`collect` clips them. To add a source, +add the module and its name to :data:`ADAPTERS`; a name whose module does not +exist yet is skipped as inactive. + +Failure mode (fail closed, named): an adapter that raises is logged and +contributes nothing; it never stops the others or the harvester. +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +import importlib +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from focus_project._model import Project + +Interval = tuple[float, float] + +# Each heartbeat vouches for the five minutes before it. +HEARTBEAT_WINDOW_S = 300.0 +ADAPTERS = ("activity", "leetcode", "browser") +_LOG = logging.getLogger(__name__) + + +def heartbeat_interval(ts: float) -> Interval: + """The span a heartbeat at ``ts`` covers: ``[ts - 300, ts]``.""" + return (ts - HEARTBEAT_WINDOW_S, ts) + + +def day_bounds(day: date) -> Interval: + """Local midnight to the next local midnight, as unix seconds.""" + start = datetime.combine(day, time.min).astimezone() + end = datetime.combine(day + timedelta(days=1), time.min).astimezone() + return (start.timestamp(), end.timestamp()) + + +def clip(spans: list[Interval], bounds: Interval) -> list[Interval]: + """``spans`` limited to ``bounds``; empty and inverted spans dropped.""" + lo, hi = bounds + out = [(max(a, lo), min(b, hi)) for a, b in spans] + return [(a, b) for a, b in out if b > a] + + +def collect_by_source(day: date, project: Project) -> dict[str, list[Interval]]: + """Every adapter's clipped intervals for ``day``, keyed by adapter name. + + Missing adapters are absent from the result; failing ones map to ``[]``. + """ + bounds = day_bounds(day) + found: dict[str, list[Interval]] = {} + for name in ADAPTERS: + module_name = f"{__name__}.{name}" + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + if exc.name == module_name: + _LOG.info("source %s: not installed (inactive)", name) + continue + _LOG.exception("source %s: import failed, contributes 0", name) + found[name] = [] + continue + except Exception: # noqa: BLE001 (any broken adapter must contribute 0) + _LOG.exception("source %s: import failed, contributes 0", name) + found[name] = [] + continue + try: + found[name] = clip(list(module.intervals(day, project)), bounds) + except Exception: # noqa: BLE001 (fail closed: this source adds 0) + _LOG.exception("source %s: failed, contributes 0", name) + found[name] = [] + return found + + +def collect(day: date, project: Project) -> list[Interval]: + """All adapters' clipped intervals for ``day``, concatenated.""" + return [s for spans in collect_by_source(day, project).values() for s in spans] diff --git a/focus_project/focus_project/tests/test_sources.py b/focus_project/focus_project/tests/test_sources.py new file mode 100644 index 0000000..9f3aafa --- /dev/null +++ b/focus_project/focus_project/tests/test_sources.py @@ -0,0 +1,94 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The adapter registry: clipping, inactive modules, fail-closed errors.""" + +from __future__ import annotations + +from datetime import date +import logging +import sys +import types +from typing import TYPE_CHECKING + +import pytest + +from focus_project import sources +from focus_project._model import Project + +if TYPE_CHECKING: + from collections.abc import Callable + +DAY = date(2026, 11, 2) +PROJECT = Project( + id="p", + name="P", + start=DAY, + end=DAY, + replaces=("leetcode",), + daily_minutes=120, + unlock_minutes=30, +) + + +def _fake( + monkeypatch: pytest.MonkeyPatch, + name: str, + fn: Callable[[date, Project], list[sources.Interval]], +) -> None: + module = types.ModuleType(f"focus_project.sources.{name}") + module.intervals = fn # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, f"focus_project.sources.{name}", module) + + +def _none(_day: date, _project: Project) -> list[sources.Interval]: + return [] + + +def test_day_bounds_is_24h_local() -> None: + lo, hi = sources.day_bounds(DAY) + assert hi - lo == 86400 + + +def test_clip_limits_and_drops_empty() -> None: + assert sources.clip([(0, 5), (4, 20), (30, 31), (9, 9)], (3, 10)) == [ + (3, 5), + (4, 10), + ] + + +def test_heartbeat_covers_previous_five_minutes() -> None: + assert sources.heartbeat_interval(1000.0) == (700.0, 1000.0) + + +def test_collect_clips_to_the_day(monkeypatch: pytest.MonkeyPatch) -> None: + lo, hi = sources.day_bounds(DAY) + _fake(monkeypatch, "activity", lambda _d, _p: [(lo - 60, lo + 60), (hi, hi + 9)]) + _fake(monkeypatch, "leetcode", _none) + _fake(monkeypatch, "browser", _none) + assert sources.collect(DAY, PROJECT) == [(lo, lo + 60)] + + +def test_failing_source_contributes_zero_and_is_logged( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + lo, _ = sources.day_bounds(DAY) + + def boom(_d: date, _p: Project) -> list[sources.Interval]: + msg = "db locked" + raise OSError(msg) + + _fake(monkeypatch, "activity", boom) + _fake(monkeypatch, "leetcode", lambda _d, _p: [(lo, lo + 60)]) + _fake(monkeypatch, "browser", _none) + with caplog.at_level(logging.ERROR): + result = sources.collect_by_source(DAY, PROJECT) + assert result["activity"] == [] + assert result["leetcode"] == [(lo, lo + 60)] + assert "source activity: failed" in caplog.text + + +def test_missing_adapter_is_inactive_not_an_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _fake(monkeypatch, "activity", _none) + monkeypatch.setattr(sources, "ADAPTERS", ("activity", "nonexistent")) + assert "nonexistent" not in sources.collect_by_source(DAY, PROJECT)