mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
focus_project: source adapter interface
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Fail-closed: a failing adapter logs and contributes 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
This commit is contained in:
parent
f99b77af89
commit
eb1c015315
87
focus_project/focus_project/sources/__init__.py
Normal file
87
focus_project/focus_project/sources/__init__.py
Normal file
@ -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.<name>`` 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]
|
||||
94
focus_project/focus_project/tests/test_sources.py
Normal file
94
focus_project/focus_project/tests/test_sources.py
Normal file
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user