SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
This commit is contained in:
torben committed 2026-09-20 20:52:05 +02:00
1 parent 3c1d4cb028
commit e07d1ca42a
17 files changed
+1112 -239

No files matched your search

+13 -2
View File
@@ -426,6 +426,11 @@ def check_tasks_provider() -> Check:
"nothing configured". Provider reachability itself never affects the
exit code, same as `check_git_repo`'s remote check: the app being closed
is normal, not a fault.
For `superproductivity`, only the instance's configured `access` path is
ever attempted (Gitea #133) - `api` reports API reachability, `snapshot`
reports whether a backup file is ready; the other path is simply not a
finding, since this instance never touches it.
"""
from chemenu import config
from chemenu.errors import ValidationError
@@ -456,14 +461,20 @@ def check_tasks_provider() -> Check:
"tasks-provider", "FAIL", str(exc),
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
)
# Only the configured access path is a finding (Gitea #133) - the
# other one is not attempted at all, so it has nothing to report.
if sp_cfg.access == sp.ACCESS_API:
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
return Check(
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}",
)
try:
snapshot_path = sp.latest_snapshot_path(sp_cfg)
read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}"
except ValidationError as exc:
read_state = f"read path not ready ({exc})"
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
return Check(
"tasks-provider", "OK", f"superproductivity: {read_state}; {api_state}",
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
)
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
+9 -1
View File
@@ -290,6 +290,15 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
return None
reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
# E.g. a superproductivity instance with access: "snapshot" (Gitea
# #133) - the tracker is read-only from here, so this refuses before
# either the collision check or the page write, exactly like any
# other precondition failure.
fail(str(exc))
existing = find_project(reader, page_title)
if existing is not None:
if resume:
@@ -302,7 +311,6 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"instead of being refused here."
)
writer = tasks.build_writer(cfg, reader)
try:
writer.create_project(page_title)
except HumanInterventionRequired as exc:
+7
View File
@@ -23,6 +23,8 @@ def render_report(report: ReviewReport) -> str:
message`, so a hit can be told apart from the summary line without a
schema - the same shape `search`'s table takes for the same reason."""
lines: list[str] = []
if report.source is not None:
lines.append(f"Source: {report.source.kind} ({report.source.detail})")
if report.checks_skipped:
lines.append("INCOMPLETE - the following check(s) did not run:")
for check, reason in report.checks_skipped:
@@ -59,6 +61,11 @@ def report_to_dict(report: ReviewReport) -> dict:
],
"kb_project_count": report.kb_project_count,
"complete": report.complete,
"source": (
{"kind": report.source.kind, "detail": report.source.detail}
if report.source is not None
else None
),
}
+11 -1
View File
@@ -27,7 +27,7 @@ from chemenu import config, kb_scan
from chemenu.errors import ValidationError
from chemenu.tasks import build_reader
from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import OpenItems, normalize_project_name
from chemenu.tasks.protocol import OpenItems, ReadSource, normalize_project_name
CHECK_STALLED = "stalled"
CHECK_WAITING_OVERDUE = "waiting_overdue"
@@ -61,6 +61,7 @@ class ReviewReport:
checks_run: tuple[str, ...]
checks_skipped: tuple[tuple[str, str], ...] # (check, reason)
kb_project_count: int
source: Optional[ReadSource]
@property
def complete(self) -> bool:
@@ -128,6 +129,14 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
reader = build_reader(cfg)
kb_projects = _load_kb_projects(Path(root) / "kb")
try:
source: Optional[ReadSource] = reader.source()
except ValidationError:
# The same read that would answer this has already failed, or is
# about to below - `source` degrading to `None` here is no worse
# than the check it would have described also being skipped.
source = None
findings: list[Finding] = []
checks_run: list[str] = []
checks_skipped: list[tuple[str, str]] = []
@@ -248,4 +257,5 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
checks_run=tuple(checks_run),
checks_skipped=tuple(checks_skipped),
kb_project_count=len(kb_projects),
source=source,
)
+20 -2
View File
@@ -24,11 +24,16 @@ from chemenu.tasks.protocol import TaskReader, TaskWriter
def build_reader(cfg: TasksConfig) -> TaskReader:
"""Dispatch on `cfg.provider` to a concrete `TaskReader`."""
"""Dispatch on `cfg.provider` to a concrete `TaskReader`. For
`superproductivity` the concrete class also depends on
`access` (Gitea #133): `"api"` reads the live local REST API,
`"snapshot"` reads the backup file - never both, never a fallback."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
if sp_cfg.access == sp.ACCESS_API:
return sp.SuperProductivityApiReader(sp_cfg)
return sp.SuperProductivityReader(sp_cfg)
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
@@ -37,10 +42,23 @@ def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
already-built `reader` - a writer that needs to re-check the read path
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through
the same object its caller does, rather than opening a second one."""
the same object its caller does, rather than opening a second one.
For `superproductivity`, a writer exists only when `access: "api"`
(Gitea #133): on `access: "snapshot"` the tracker is read-only from here
by construction, so this raises `ValidationError` rather than returning a
writer that could never do anything - the same posture as "no writer is
wired up for this provider at all", just scoped to one access mode of
one provider instead of the whole provider."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
if sp_cfg.access != sp.ACCESS_API:
raise ValidationError(
"superproductivity: the tracker is read-only from here (access: "
f"'{sp_cfg.access}') - the write path only exists on an access: 'api' "
"instance (Gitea #133)."
)
return sp.SuperProductivityWriter(sp_cfg, reader)
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
+3 -2
View File
@@ -66,8 +66,9 @@ def read_config(root: "Any") -> "TasksConfig | None":
expected = (
'{"schema": 1, "provider": "superproductivity", '
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
'"someday_stale_months": 5}, "superproductivity": {"backups_dir": "...", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}}'
'"someday_stale_months": 5}, "superproductivity": {"access": "api", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."} '
'(or {"access": "snapshot", "backups_dir": "..."} - see INSTALL.md)}'
)
try:
provider = str(data["provider"])
+46 -1
View File
@@ -15,7 +15,19 @@ waiting-for item; the person stays in the title's free text), and which
someday/maybe items exist and when they last moved. None of this is cached
here - a `TaskReader` re-reads on every call, so a caller checking `verify()`
after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
sees the current state, not a snapshot from before that step.
never sees a value this process cached from before that step.
**Re-reading is not the same as reading the current state** (Gitea #134,
resolved by #133's design rather than by a fix here): a point-in-time source
- Super Productivity's `access: "snapshot"`, say - re-reads the *latest file
on disk* on every call, which is only as current as that file's own age; a
caller's `verify()` can still answer "not yet" against a step that already
happened, if nothing has written a fresher file since. Only a genuinely live
source - `access: "api"` - re-reads the actual current state. A `TaskReader`
that is not always live should say so through `source()`
(`chemenu.tasks.protocol.ReadSource`), so a caller can tell "re-read, but
possibly stale" apart from "re-read, and current" instead of assuming the
stronger claim for every provider.
"""
from __future__ import annotations
@@ -43,12 +55,36 @@ class WaitingItem:
Faelligkeitsdatum") - a provider that has no separate concept for this
must not fall back to reusing the due date, it must decide it cannot
supply the field and leave it `None` instead.
**This rule binds the concept, not a field's name** (Gitea #135's own
correction, after #124's Super Productivity adapter read the wrong field
under this exact rule): a provider whose own vocabulary does not line up
with "due date" - a field called `due*` that actually means scheduling
rather than a deadline, say - must be checked against what its
documentation says the field *means*, not against what its name suggests
to an outsider. Getting this backwards produced a real bug: a whole class
of tickler (an all-day, notification-free follow-up) silently never
counted as `follow_up_at` at all.
"""
title: str
follow_up_at: Optional[date]
@dataclass(frozen=True)
class ReadSource:
"""Where one `TaskReader.source()` call's data came from, for display
only (Gitea #133) - `chemenu.review`/`wikitool doctor` show it, no check
branches on it. `kind` is provider-defined (e.g. `"api"`/`"snapshot"` for
Super Productivity); `detail` is the human-readable line, which for a
point-in-time source (a snapshot file, not a live call) names its age -
the two access paths can live on different machines and nobody may ever
see them side by side, so the answer itself has to say how fresh it is."""
kind: str
detail: str
@dataclass(frozen=True)
class OpenItems:
"""A project's momentary open-loop count, plus the subset that is
@@ -99,6 +135,15 @@ class TaskReader(Protocol):
projects."""
...
def source(self) -> ReadSource:
"""Where this reader's data comes from, for display (Gitea #133) -
never consulted by a check, only by `chemenu.review`/`wikitool doctor`
to say which path answered and, for a point-in-time source, how old
it is. Must not perform a network call beyond what answering it
cheaply requires - a provider whose read path is always live can
answer this without touching the network at all."""
...
class TaskWriter(Protocol):
"""The write path a provider adapter offers only if it actually can
+319 -93
View File
@@ -1,57 +1,87 @@
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30).
"""The Super Productivity adapter (Gitea #124, #133, #135; #119 D2/D8/D9/D30).
Read and write deliberately use different transports (#124's own design note,
carried as its first acceptance criterion): the read path is headless file
access, the write path is Super Productivity's own local REST API - so a
review can run with the app closed, and a create only needs the app open when
it actually has to ask it for something.
Read and write access are chosen **per instance, explicitly, exclusively**
(Gitea #133): a headless-operated instance sets `access: "snapshot"` and only
ever reads the periodic backup file on disk; a desktop instance sets
`access: "api"` and only ever talks to Super Productivity's own local REST
API, which also carries the current live state and the one write call this
adapter offers. There is no third value, no default, and no runtime fallback
between the two - the config decides once, at read time, which half of this
module ever runs.
## Read path: the backup snapshot, not a live `db.json`
## `access: "snapshot"` - the backup file, not a live `db.json`
Desktop Super Productivity keeps its live state in IndexedDB, not in a flat
file called `db.json` on disk - there is no such file to read headlessly.
What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
writes the complete app state as `JSON.stringify(data)` into
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last
by filename (its own comment: "timestamps sort lexically"). That snapshot's
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy`
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`),
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only
looks at the three keys it needs, each an `@ngrx/entity` `EntityState`
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts`
`Task`/`Project`/`Tag`). Verified against the `master` branch of
`super-productivity/super-productivity` on 2026-09-19 - unversioned, per
#124's own note, so a future release is free to reshape it without warning,
which is exactly why every read below fails loudly on a shape it does not
recognize rather than guessing.
`<userData>/backups/YYYY-MM-DD_HHmmss.json` on every backup - a fixed-width
timestamp name, so a lexical sort is also a chronological one, which is what
`latest_snapshot_path` relies on. That snapshot's top-level shape is
`AppDataComplete`/`AppDataCompleteLegacy` (`src/app/op-log/model/model-config.ts`
/ `src/app/imex/sync/sync.model.ts`), keyed by feature name - `task`,
`project`, `tag`, ... - and this reader only looks at the three keys it
needs, each an `@ngrx/entity` `EntityState` (`{"ids": [...], "entities": {...}}`,
`packages/plugin-api/src/types.ts` `Task`/`Project`/`Tag`). Unversioned, per
#124's own note, so a future release is free to reshape it without warning -
every read below fails loudly on a shape it does not recognize rather than
guessing.
## The two GTD conventions this adapter encodes (#119 D9/D30)
## `access: "api"` - the local REST API, the current live state
The routes live in the renderer, not the Electron main process:
`src/app/core/electron/local-rest-api-handler.service.ts` registers
`GET /status`, `GET /focus`, `GET|POST /task-control/*`, `GET|POST /tasks`,
`GET|PATCH|DELETE /tasks/:id`, `GET /projects`, `GET /tags` - project and tag
CRUD do not exist. Every endpoint but `GET /health` requires
`Authorization: Bearer <api_token>`; a closed app or an unauthenticated
request are not the same failure - `GET /health` answers `503 APP_NOT_READY`
when the backend is up but the renderer is not yet, which this adapter
surfaces as its own message rather than folding into "unreachable".
`GET /projects` runs through `selectUnarchivedProjects` and excludes
`isArchived` projects server-side; the snapshot path below does the same
filtering itself, so the two access paths agree on that without either one
needing to know how the other got there (verified against `master`,
2026-09-20).
## The two GTD conventions this adapter encodes (#119 D9/D30, corrected #135)
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
Productivity has no native field for either:
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
task. Any other tag is left alone.
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) -
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full
stop; this adapter never substitutes the due date for it.
- **`follow_up_at`** is the task's own *scheduled* date - `dueWithTime` if
set, else `dueDay` (`task.model.ts`'s own read rule: "check dueWithTime
FIRST"). This is **not** the earlier `remindAt` mapping from #124: `remindAt`
only exists when a task is scheduled with a specific time *and* someone
asked for a notification, so an all-day, notification-free tickler carried
no `follow_up_at` at all under that mapping - a real gap #135 closed.
`deadlineDay`/`deadlineWithTime`/`deadlineRemindAt` are Super Productivity's
actual due-date fields (its own model docstrings say so) and are never read
here (#119 D9 continues to exclude them) - the point of #135's correction
is that `due*` was never the thing D9 excludes, whatever the name suggests.
A task with neither `dueWithTime` nor `dueDay` has no `follow_up_at`, full
stop; this adapter never substitutes the deadline for it.
## Someday/Maybe: a project's own backlog
#124 (later, #119) left this as "zu verifizieren": Super Productivity's
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of
task ids per project, apart from the active `taskIds` list a project's board
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one
project at a time; no tag convention is needed.
Super Productivity's `ProjectBasicCfg.backlogTaskIds` is exactly this - a
second, separate list of task ids per project, apart from the active
`taskIds` list a project's board shows. This adapter reads someday/maybe
items from `backlogTaskIds`, one project at a time; no tag convention is
needed.
## Write path: no project-creation endpoint exists
## Write path: no project-creation endpoint exists, on either access mode
The local REST API (`electron/local-rest-api-handler.service.ts`, verified the
same day) routes `GET /projects` but has no `POST /projects` at all - task
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project`
can therefore not create a project itself; see its docstring and
`chemenu.errors.HumanInterventionRequired`.
Neither transport routes `POST /projects` - task CRUD exists, project CRUD
does not, verified the same day as the rest of this module. So
`SuperProductivityWriter.create_project` can never create a project itself
regardless of `access`; see its docstring and
`chemenu.errors.HumanInterventionRequired`. A writer is offered at all only
when `access: "api"` - see `chemenu.tasks.build_writer` - because on
`access: "snapshot"` the tracker is read-only from here by construction, not
by an extra check bolted onto this module (Gitea #133).
"""
from __future__ import annotations
@@ -67,6 +97,7 @@ from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks.protocol import (
OpenItems,
ProjectSummary,
ReadSource,
SomedayItem,
WaitingItem,
find_project,
@@ -79,82 +110,119 @@ WAITING_TAG_TITLE = "waiting"
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
_EXPECTED_PROVIDER_CONFIG = (
'{"backups_dir": "~/.config/superProductivity/backups", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
' (or "db_path" instead of "backups_dir" to pin one exact file)'
ACCESS_API = "api"
ACCESS_SNAPSHOT = "snapshot"
KNOWN_ACCESS = (ACCESS_API, ACCESS_SNAPSHOT)
# `electron/backup.ts` writes exactly this shape - a fixed-width timestamp,
# no prefix. Restricting the glob to it (Gitea #133) is what keeps a manually
# exported file (`sp-backup_*.json` and friends, which sort *after* every
# timestamp lexically) from ever being picked as "newest".
_SNAPSHOT_GLOB = (
"[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_"
"[0-9][0-9][0-9][0-9][0-9][0-9].json"
)
_EXPECTED_API_CONFIG = '{"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
_EXPECTED_SNAPSHOT_CONFIG = '{"access": "snapshot", "backups_dir": "~/.config/superProductivity/backups"}'
@dataclass(frozen=True)
class SuperProductivityConfig:
"""This provider's own section of `.wikitool-tasks.json`
(`TasksConfig.provider_config`)."""
(`TasksConfig.provider_config`). `access` decides both halves at once -
which path is read *and* whether a write path exists at all - and the
section carries only the fields that access path uses (Gitea #133): a
`snapshot` config with an `api_base_url` in it, or an `api` config with a
`backups_dir` in it, is rejected at read time, not ignored."""
# Exactly one of these two names where to read from. `db_path` wins when
# both are set - it names one exact file, which is a stronger statement
# than "the newest file in this directory".
access: str
backups_dir: Optional[Path]
db_path: Optional[Path]
api_base_url: str
# Unused by this module today - nothing it calls needs authentication
# (`GET /health` is the one unauthenticated exception, and there is no
# write call at all, see the module docstring). Carried through anyway so
# a future capability that does need it does not require a config-shape
# migration to add it.
api_token: Optional[str]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
if not isinstance(data, dict):
raise ValidationError(
f"superproductivity config must be an object. Expected: {_EXPECTED_PROVIDER_CONFIG}"
"superproductivity config must be an object. Expected one of: "
f"{_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
)
access = data.get("access")
if access not in KNOWN_ACCESS:
raise ValidationError(
"superproductivity config needs 'access', either 'api' or 'snapshot' - "
"required, no default and no fallback between them (Gitea #133). Got: "
f"{access!r}. Expected one of: {_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
)
if access == ACCESS_API:
extra = sorted(set(data) - {"access", "api_base_url", "api_token"})
if extra:
raise ValidationError(
f"superproductivity config: access: 'api' does not take {extra} - a "
"section names only one access path's own fields (Gitea #133). "
f"Expected: {_EXPECTED_API_CONFIG}"
)
api_token = data.get("api_token")
if not isinstance(api_token, str) or not api_token:
raise ValidationError(
"superproductivity config: api_token is required when access: 'api' - "
"every endpoint but GET /health requires Authorization: Bearer <token>. "
f"Expected: {_EXPECTED_API_CONFIG}"
)
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
return cls(access=access, backups_dir=None, api_base_url=api_base_url, api_token=api_token)
extra = sorted(set(data) - {"access", "backups_dir"})
if extra:
raise ValidationError(
f"superproductivity config: access: 'snapshot' does not take {extra} - a "
"section names only one access path's own fields (Gitea #133). "
f"Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
)
backups_dir = data.get("backups_dir")
db_path = data.get("db_path")
if backups_dir is None and db_path is None:
if not backups_dir:
raise ValidationError(
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
"superproductivity config: backups_dir is required when access: 'snapshot' - "
f"the read path has nothing to read otherwise. Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
)
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
api_token = data.get("api_token")
if api_token is not None and not isinstance(api_token, str):
raise ValidationError("superproductivity config: api_token must be a string.")
return cls(
backups_dir=Path(backups_dir).expanduser() if backups_dir else None,
db_path=Path(db_path).expanduser() if db_path else None,
api_base_url=api_base_url,
api_token=api_token,
access=access,
backups_dir=Path(backups_dir).expanduser(),
api_base_url=DEFAULT_API_BASE_URL,
api_token=None,
)
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
"""The one file to read: `db_path` if given, else the lexically-greatest
`*.json` filename under `backups_dir` - the same ordering
`electron/backup.ts` itself relies on ("timestamps sort lexically")."""
if cfg.db_path is not None:
if not cfg.db_path.is_file():
raise ValidationError(
f"superproductivity: db_path does not exist: {cfg.db_path}"
)
return cfg.db_path
"""The lexically-greatest `YYYY-MM-DD_HHmmss.json` filename under
`backups_dir` - the same ordering `electron/backup.ts` writes by
construction. Only files matching that exact pattern are candidates
(Gitea #133): a manual export (`sp-backup_*.json` and its variants) sorts
lexically *after* every timestamp and would otherwise pin every reader to
itself forever."""
directory = cfg.backups_dir
assert directory is not None # from_dict guarantees at least one is set
assert directory is not None # from_dict guarantees this for access: snapshot
if not directory.is_dir():
raise ValidationError(
f"superproductivity: backups_dir does not exist: {directory}"
)
candidates = sorted(directory.glob("*.json"))
candidates = sorted(directory.glob(_SNAPSHOT_GLOB))
if not candidates:
raise ValidationError(
f"superproductivity: no *.json backup file found under {directory}. Take a "
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), "
"or point db_path/backups_dir at where it actually writes them."
f"superproductivity: no timestamped backup file (YYYY-MM-DD_HHmmss.json) found "
f"under {directory}. Take a backup from Super Productivity (Settings -> Backup & "
"Sync -> Local backups), or point backups_dir at where it actually writes them."
)
return candidates[-1]
def _snapshot_age_days(path: Path) -> int:
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
return (datetime.now(tz=timezone.utc) - mtime).days
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
path = latest_snapshot_path(cfg)
try:
@@ -194,10 +262,37 @@ def _epoch_ms_to_date(value: Any) -> Optional[date]:
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date()
def _iso_day_to_date(value: Any) -> Optional[date]:
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _follow_up_at(task: dict) -> Optional[date]:
"""`follow_up_at` per #135's corrected mapping: `dueWithTime` first (Super
Productivity's own read rule - it takes priority over `dueDay`), else
`dueDay`. Never `deadline*` (#119 D9) and never the old `remindAt`."""
due_with_time = _epoch_ms_to_date(task.get("dueWithTime"))
if due_with_time is not None:
return due_with_time
return _iso_day_to_date(task.get("dueDay"))
def _is_waiting(task: dict, tags_by_id: dict[str, dict]) -> bool:
for tag_id in task.get("tagIds") or []:
tag = tags_by_id.get(tag_id)
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
return True
return False
class SuperProductivityReader:
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
for why that matters."""
"""`TaskReader` over a Super Productivity backup snapshot
(`access: "snapshot"`). Re-reads the snapshot on every call - see
`chemenu.errors.HumanInterventionRequired` for why that matters."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
@@ -208,6 +303,11 @@ class SuperProductivityReader:
projects = _entity_state(snapshot, "project", path)
tasks = _entity_state(snapshot, "task", path)
tags = _entity_state(snapshot, "tag", path)
# Both access paths exclude archived projects (Gitea #133) - the API
# does it server-side (`selectUnarchivedProjects`), this path mirrors
# it explicitly so the two agree without either knowing about the
# other.
projects = {pid: p for pid, p in projects.items() if not p.get("isArchived")}
return path, projects, tasks, tags
def projects(self) -> list[ProjectSummary]:
@@ -220,13 +320,6 @@ class SuperProductivityReader:
for record in projects.values()
]
def _is_waiting(self, task: dict, tags: dict[str, dict]) -> bool:
for tag_id in task.get("tagIds") or []:
tag = tags.get(tag_id)
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
return True
return False
def open_items(self, project_name: str) -> OpenItems:
_, projects, tasks, tags = self._read()
target = normalize_project_name(project_name)
@@ -244,12 +337,9 @@ class SuperProductivityReader:
if task is None or task.get("isDone"):
continue
count += 1
if self._is_waiting(task, tags):
if _is_waiting(task, tags):
waiting.append(
WaitingItem(
title=str(task.get("title", "")),
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
)
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
)
return OpenItems(count=count, waiting=tuple(waiting))
@@ -269,6 +359,14 @@ class SuperProductivityReader:
)
return items
def source(self) -> ReadSource:
path = latest_snapshot_path(self._cfg)
age = _snapshot_age_days(path)
return ReadSource(
kind=ACCESS_SNAPSHOT,
detail=f"snapshot {path.name}, {age} day(s) old",
)
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
"""Whether the local REST API answers `GET /health` right now - the one
@@ -283,11 +381,139 @@ def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
return False
def _expect_list(value: Any, what: str) -> list[dict]:
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ValidationError(
f"superproductivity: API {what} did not return a list of objects - the response "
"shape does not match what this adapter expects."
)
return value
class _ApiClient:
"""The one HTTP transport `SuperProductivityApiReader` uses - a thin,
loudly-failing wrapper, not a general REST client."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def get(self, path: str, *, timeout: float = 10.0) -> Any:
url = self._cfg.api_base_url.rstrip("/") + path
request = urllib.request.Request(
url, headers={"Authorization": f"Bearer {self._cfg.api_token}"}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 503:
raise ValidationError(
"superproductivity: API answered 503 APP_NOT_READY for "
f"{path} - the app's backend is up but its renderer is not ready yet. "
"Wait a moment and retry."
) from exc
raise ValidationError(
f"superproductivity: API returned HTTP {exc.code} for {path}."
) from exc
except (urllib.error.URLError, OSError) as exc:
raise ValidationError(
f"superproductivity: API not reachable at {self._cfg.api_base_url} ({exc}). "
"Is Super Productivity running?"
) from exc
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise ValidationError(
f"superproductivity: API returned unparseable JSON for {path}."
) from exc
class SuperProductivityApiReader:
"""`TaskReader` over the local REST API (`access: "api"`) - the current
live state, re-fetched on every call. `GET /projects` already excludes
archived projects server-side; open items are counted against
`project.taskIds` rather than `GET /tasks?projectId=`, because a subtask
inherits its parent's `projectId` and would otherwise be double-counted
against that filter (Gitea #133) - the same source of truth
`SuperProductivityReader` uses on the snapshot side."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
self._client = _ApiClient(cfg)
def _read(self) -> tuple[list[dict], dict[str, dict], dict[str, dict]]:
projects = _expect_list(self._client.get("/projects"), "/projects")
tasks = _expect_list(self._client.get("/tasks"), "/tasks")
tags = _expect_list(self._client.get("/tags"), "/tags")
# `GET /projects` already runs through `selectUnarchivedProjects`
# server-side - filtered again here so both access paths hold the
# same guarantee (Gitea #133) rather than one of them trusting the
# other end to have done it.
projects = [p for p in projects if not p.get("isArchived")]
tasks_by_id = {t["id"]: t for t in tasks if isinstance(t.get("id"), str)}
tags_by_id = {t["id"]: t for t in tags if isinstance(t.get("id"), str)}
return projects, tasks_by_id, tags_by_id
def projects(self) -> list[ProjectSummary]:
projects, _, _ = self._read()
return [
ProjectSummary(name=str(p.get("title", "")), created=_epoch_ms_to_date(p.get("created")))
for p in projects
]
def open_items(self, project_name: str) -> OpenItems:
projects, tasks_by_id, tags_by_id = self._read()
target = normalize_project_name(project_name)
project = next(
(p for p in projects if normalize_project_name(str(p.get("title", ""))) == target), None
)
if project is None:
return OpenItems(count=0, waiting=())
waiting: list[WaitingItem] = []
count = 0
for task_id in project.get("taskIds") or []:
task = tasks_by_id.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
if _is_waiting(task, tags_by_id):
waiting.append(
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
)
return OpenItems(count=count, waiting=tuple(waiting))
def someday_items(self) -> list[SomedayItem]:
projects, tasks_by_id, _ = self._read()
items: list[SomedayItem] = []
for project in projects:
for task_id in project.get("backlogTaskIds") or []:
task = tasks_by_id.get(task_id)
if task is None or task.get("isDone"):
continue
items.append(
SomedayItem(
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
)
return items
def source(self) -> ReadSource:
# Static by construction - every call above re-fetches, so there is
# nothing "live" needs to check first, and no network call is spent
# just to answer this.
return ReadSource(kind=ACCESS_API, detail="live (local REST API)")
class SuperProductivityWriter:
"""`TaskWriter` over the local REST API - except there is no API call
this can actually make, see the module docstring."""
this can actually make, see the module docstring. Only offered by
`chemenu.tasks.build_writer` when `access: "api"` (Gitea #133) - on
`access: "snapshot"` the tracker is read-only from here, and that refusal
happens before this class is ever constructed."""
def __init__(self, cfg: SuperProductivityConfig, reader: SuperProductivityReader):
def __init__(self, cfg: SuperProductivityConfig, reader):
self._cfg = cfg
self._reader = reader
+24 -1
View File
@@ -371,6 +371,8 @@ def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance):
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance):
"""`access: snapshot` - only the read path is a finding; the API is not
even attempted (Gitea #133)."""
backups = config.ROOT / "sp-backups"
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({
@@ -379,14 +381,35 @@ def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(insta
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"superproductivity": {"backups_dir": str(backups)},
"superproductivity": {"access": "snapshot", "backups_dir": str(backups)},
}),
encoding="utf-8",
)
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider")
assert "access=snapshot" in detail
assert "not ready" in detail
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
"""`access: api` - the API reachability is the finding; the (irrelevant)
backup file is not even looked at (Gitea #133)."""
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({
"schema": 1, "provider": "superproductivity",
"thresholds": {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1", "api_token": "t"},
}),
encoding="utf-8",
)
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider")
assert "access=api" in detail
assert "not reachable" in detail
+166 -49
View File
@@ -1,4 +1,8 @@
import contextlib
import http.server
import json
import threading
from pathlib import Path
from chemenu.commands._util import coerce_set_value, parse_set_fields
from chemenu.commands.new_page import _page_subdir
@@ -12,34 +16,50 @@ _TASKS_THRESHOLDS = {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
}
_API_TOKEN = "test-token"
def _write_tasks_config(root, db_path) -> None:
def _write_tasks_config(root, base_url: str) -> None:
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
provider at `db_path` - same shape `test_review.py`'s own helper writes,
duplicated locally rather than imported since test files in this suite
do not import each other (see `instructions/dev/testing-conventions.md`
for the isolation this mirrors one layer up)."""
provider's `access: "api"` path at `base_url` (Gitea #133) - `new
project` only ever has a write path on that access mode. Duplicated
locally rather than imported since test files in this suite do not
import each other (see `instructions/dev/testing-conventions.md` for the
isolation this mirrors one layer up)."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"db_path": str(db_path)},
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
}),
encoding="utf-8",
)
def _write_sp_snapshot(root, project_titles: list[str]):
"""A Super Productivity backup snapshot whose `project` entity state
holds one project per title in `project_titles` and nothing else -
enough for `find_project`/`create_project`'s own preflight, which is all
`new project`'s tracker step reads or writes (it never calls
`open_items`/`someday_items`)."""
db_path = root / "db.json"
def _write_tasks_config_snapshot(root, backups_dir) -> None:
"""The other access path (Gitea #133) - used only by the tests below
that confirm `new project` refuses entirely against it."""
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
def _write_sp_backup(root, project_titles: list[str]) -> Path:
"""A Super Productivity backup snapshot, named the way `electron/backup.ts`
actually names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133), whose `project`
entity state holds one project per title in `project_titles` and nothing
else. Used only by the `access: "snapshot"` refusal tests - that path
never reaches a tracker write regardless of what this file contains."""
backups_dir = root / "backups"
backups_dir.mkdir(exist_ok=True)
projects = {
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
for i, title in enumerate(project_titles)
}
db_path.write_text(
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({
"project": {"ids": list(projects), "entities": projects},
"task": {"ids": [], "entities": {}},
@@ -47,7 +67,69 @@ def _write_sp_snapshot(root, project_titles: list[str]):
}),
encoding="utf-8",
)
return db_path
return backups_dir
def _make_api_handler(state: dict):
class Handler(http.server.BaseHTTPRequestHandler):
posted = False
def do_GET(self): # noqa: N802 - stdlib method name
if self.path == "/health":
self._reply(200, {"ok": True})
return
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
self._reply(401, {"error": "unauthorized"})
return
if self.path == "/projects":
self._reply(200, state["projects"])
return
if self.path in ("/tasks", "/tags"):
self._reply(200, [])
return
self._reply(404, {"error": "not found"})
def do_POST(self): # noqa: N802
# `new project` must never attempt this on the API path either -
# there is no POST /projects endpoint upstream (Gitea #124/#133).
Handler.posted = True
self._reply(404, {"error": "no such endpoint (fixture)"})
def _reply(self, code: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence stderr noise during the test run
pass
return Handler
@contextlib.contextmanager
def _api_server(project_titles: list[str]):
"""A stub Super Productivity local REST API serving just enough of
`GET /projects`/`/tasks`/`/tags` for `find_project`/`create_project`'s
own preflight - the only thing `new project`'s tracker step reads."""
state = {"projects": [
{"id": f"p{i}", "title": title, "created": 1700000000000, "taskIds": [], "backlogTaskIds": []}
for i, title in enumerate(project_titles)
]}
handler_cls = _make_api_handler(state)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server, handler_cls
finally:
server.shutdown()
thread.join(timeout=2)
def _base_url(server: http.server.HTTPServer) -> str:
return f"http://127.0.0.1:{server.server_address[1]}"
def _invoke_new(monkeypatch, kb_dir, args):
@@ -174,33 +256,35 @@ def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch,
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
"""Super Productivity can never create a project itself (Gitea #124) -
the first attempt against a free name must exit 42 and create nothing on
either side."""
either side, and never attempt a POST (Gitea #133: no such endpoint
exists on the API path either)."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, [])
_write_tasks_config(root, db_path)
with _api_server([]) as (server, handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 42
assert "NEEDS USER CLEARANCE" in result.output
assert "--resume" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 42
assert "NEEDS USER CLEARANCE" in result.output
assert "--resume" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
assert handler_cls.posted is False
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
"""The tracker already has this name (case-insensitively) and --resume
was not passed - #126's AC: refuse, name the collision, create nothing."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["kueche renovieren"])
_write_tasks_config(root, db_path)
with _api_server(["kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "already exists" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "already exists" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
@@ -209,15 +293,15 @@ def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypat
re-run with --resume must verify it via the read path and continue to
page creation instead of treating it as a collision."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
_write_tasks_config(root, db_path)
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 0, result.output
assert "already existed" in result.output
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 0, result.output
assert "already existed" in result.output
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
@@ -226,13 +310,45 @@ def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monke
message again, not a silent pass-through (#126's own wording: "wirft das
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
root = kb_dir.parent
db_path = _write_sp_snapshot(root, [])
_write_tasks_config(root, db_path)
with _api_server([]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 42
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
"""Gitea #133: `access: "snapshot"` is read-only from here - `new
project` creates neither a tracker project nor a page and exits 1, not
42 (nothing is waiting on a human's clearance, the command simply cannot
do this from a snapshot instance), pointing at an access: "api" one."""
root = kb_dir.parent
backups_dir = _write_sp_backup(root, [])
_write_tasks_config_snapshot(root, backups_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "access: 'api'" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_also_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
"""--resume changes nothing about the snapshot-access refusal (Gitea
#133's own AC) - even against a tracker that already has the project."""
root = kb_dir.parent
backups_dir = _write_sp_backup(root, ["Kueche renovieren"])
_write_tasks_config_snapshot(root, backups_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 42
assert result.exit_code == 1
assert "access: 'api'" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
@@ -253,17 +369,18 @@ def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch
import chemenu.commands.new_page as new_page
root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
_write_tasks_config(root, db_path)
def _boom(path, frontmatter, body):
raise OSError("disk full (fixture)")
monkeypatch.setattr(new_page, "write_page", _boom)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 1
assert "already confirmed to exist" in " ".join(result.output.split())
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
+83 -27
View File
@@ -51,8 +51,14 @@ def _entity_state(records: dict[str, dict]) -> dict:
def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path:
db_path = root / "db.json"
db_path.write_text(
"""Writes a Super Productivity backup snapshot into a fresh `backups/`
directory under `root`, named the way `electron/backup.ts` actually
names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133) so the hardened glob in
`latest_snapshot_path` picks it up. Returns the `backups_dir`, not the
file itself - that is what a `superproductivity` config section names."""
backups_dir = root / "backups"
backups_dir.mkdir(exist_ok=True)
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({
"project": _entity_state(projects),
"task": _entity_state(tasks),
@@ -60,16 +66,16 @@ def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path
}),
encoding="utf-8",
)
return db_path
return backups_dir
def _write_tasks_config(root: Path, db_path: Path, thresholds: dict | None = None) -> None:
def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None = None) -> None:
(root / ".wikitool-tasks.json").write_text(
json.dumps({
"schema": 1,
"provider": "superproductivity",
"thresholds": thresholds or THRESHOLDS,
"superproductivity": {"db_path": str(db_path)},
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
@@ -110,8 +116,8 @@ def _tree(root: Path) -> dict[str, bytes]:
def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire):
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", state)
report = run_review(tmp_path, today=TODAY)
@@ -131,10 +137,10 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "remindAt": _ms(2026, month, remind_day)}}
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, month, remind_day)}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
db_path = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
@@ -142,6 +148,23 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
assert fired == should_fire
def test_check2_fires_for_an_all_day_waiting_item_with_no_due_with_time(tmp_path):
"""The gap Gitea #135 closed: a `waiting` task scheduled all-day
(`dueDay`, no `dueWithTime`, no reminder) must still surface as overdue -
under #124's original `remindAt` mapping it silently never did."""
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueDay": "2026-01-01"}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
assert any(f.check == CHECK_WAITING_OVERDUE for f in report.findings)
# --- check 3: unpaged tracker project ----------------------------------------
@@ -153,8 +176,8 @@ def test_check3_unpaged_project_age_threshold(tmp_path, created_year_month_day,
y, m, d = created_year_month_day
projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d),
"taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
(tmp_path / "kb" / "gtd").mkdir(parents=True)
report = run_review(tmp_path, today=TODAY)
@@ -168,8 +191,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}}
db_path = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
@@ -181,8 +204,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
def test_check4_fires_when_no_tracker_project_exists(tmp_path):
db_path = _write_snapshot(tmp_path, {}, {}, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, {}, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ghost Project", "active")
report = run_review(tmp_path, today=TODAY)
@@ -199,8 +222,8 @@ def test_a_run_touches_no_file(tmp_path):
"taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [],
"updated": _ms(2020, 1, 1)}}
db_path = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
before = _tree(tmp_path)
@@ -221,8 +244,8 @@ def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
"taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
"tagIds": [], "updated": _ms(y, m, 1)}}
db_path = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings)
@@ -239,10 +262,10 @@ def test_no_tasks_config_is_a_clear_validation_error(tmp_path):
def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path):
"""A db_path that does not exist is the unreachable-provider case: checks
"""A backups_dir that does not exist is the unreachable-provider case: checks
1/2/3/4 cannot run at all, but check 5 uses the same failing read and is
skipped too - the report must say so, never look like a quiet week."""
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json")
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
report = run_review(tmp_path, today=TODAY)
@@ -275,7 +298,7 @@ def test_cli_incomplete_report_exits_1_and_still_prints(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json")
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
(tmp_path / "kb" / "gtd").mkdir(parents=True)
result = runner.invoke(app, ["review"])
@@ -294,8 +317,8 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
text_result = runner.invoke(app, ["review"])
@@ -321,6 +344,39 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
}
def test_report_names_its_source_and_the_snapshot_age(tmp_path):
"""Gitea #133's own AC: every answer says which access path it came from,
and a snapshot answer says how old it is."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
assert report.source is not None
assert report.source.kind == "snapshot"
assert "day(s) old" in report.source.detail
def test_cli_shows_the_source_in_both_render_forms(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
text_result = runner.invoke(app, ["review"])
json_result = runner.invoke(app, ["review", "--json"])
assert "Source: snapshot" in text_result.output
payload = json.loads(json_result.output)
assert payload["source"]["kind"] == "snapshot"
assert "day(s) old" in payload["source"]["detail"]
def test_review_is_exempt_from_the_iteration_budget():
assert is_exempt("review", []) is True
assert is_exempt("review", ["--json"]) is True
@@ -339,8 +395,8 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path)
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True)
+344 -46
View File
@@ -1,21 +1,23 @@
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124).
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124, #133, #135).
The fixture snapshot below mirrors the real shape verified against
`super-productivity/super-productivity`'s `master` branch on 2026-09-19: a
flat top-level object with `project`/`task`/`tag` as `@ngrx/entity`
`{"ids": [...], "entities": {...}}` maps (see the module docstring for the
exact source files). No test here starts a real Super Productivity instance,
touches the network beyond a loopback socket, or reads a path under the
developer's home - everything is built inside `tmp_path`
(`instructions/dev/testing-conventions.md`).
`super-productivity/super-productivity`'s `master` branch: a flat top-level
object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
"entities": {...}}` maps for the snapshot path, and the same records as a
flat list (the local REST API's own shape) for the API path (see the module
docstring for the exact source files). No test here starts a real Super
Productivity instance or touches anything beyond a loopback socket and
`tmp_path` (`instructions/dev/testing-conventions.md`).
"""
from __future__ import annotations
import contextlib
import http.server
import json
import threading
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
import pytest
@@ -55,7 +57,7 @@ def _snapshot() -> dict:
"projectId": "p1",
"isDone": False,
"tagIds": ["tag-wait"],
"remindAt": _ms(2026, 3, 1),
"dueWithTime": _ms(2026, 3, 1),
},
"t2": {
"id": "t2",
@@ -94,73 +96,115 @@ def _write_snapshot(path: Path, data: dict | None = None) -> None:
path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8")
def _minimal_snapshot(task_extra: dict) -> dict:
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False,
"tagIds": ["tag-wait"], **task_extra}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
return {"project": _entity_state(projects), "task": _entity_state(tasks), "tag": _entity_state(tags)}
@pytest.fixture
def cfg(tmp_path) -> sp.SuperProductivityConfig:
db_path = tmp_path / "db.json"
_write_snapshot(db_path)
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_120000.json")
return sp.SuperProductivityConfig(
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
# --- from_dict ---------------------------------------------------------------
# --- from_dict -----------------------------------------------------------------
def test_from_dict_requires_backups_dir_or_db_path():
with pytest.raises(ValidationError):
sp.SuperProductivityConfig.from_dict({"api_base_url": "http://127.0.0.1:3876"})
def test_from_dict_requires_access():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
def test_from_dict_defaults_the_api_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"})
def test_from_dict_rejects_an_unknown_access_value():
with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"access": "auto", "backups_dir": "/x"})
def test_from_dict_api_requires_a_token():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict({"access": "api"})
def test_from_dict_api_defaults_the_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "api", "api_token": "t"})
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
assert cfg_.access == sp.ACCESS_API
def test_from_dict_expands_user_in_paths(monkeypatch):
def test_from_dict_api_rejects_a_snapshot_field():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_token": "t", "backups_dir": "/x"}
)
def test_from_dict_snapshot_requires_backups_dir():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict({"access": "snapshot"})
def test_from_dict_snapshot_rejects_an_api_field():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict(
{"access": "snapshot", "backups_dir": "/x", "api_token": "t"}
)
def test_from_dict_snapshot_expands_user_in_backups_dir(monkeypatch):
monkeypatch.setenv("HOME", "/home/fixture")
cfg_ = sp.SuperProductivityConfig.from_dict({"backups_dir": "~/backups"})
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": "~/backups"})
assert cfg_.backups_dir == Path("/home/fixture/backups")
# --- latest_snapshot_path / schema drift -------------------------------------
def test_db_path_must_exist(tmp_path):
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "missing.json",
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
def test_from_dict_rejects_db_path_entirely():
"""`db_path` was #133's own casualty - it must not silently work as an
alias for `backups_dir` under either access mode."""
with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_)
sp.SuperProductivityConfig.from_dict({"access": "snapshot", "db_path": "/x/db.json"})
# --- latest_snapshot_path / schema drift ---------------------------------------
def test_backups_dir_must_exist(tmp_path):
cfg_ = sp.SuperProductivityConfig(
backups_dir=tmp_path / "nope", db_path=None,
access=sp.ACCESS_SNAPSHOT, backups_dir=tmp_path / "nope",
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_)
def test_backups_dir_with_no_json_files_is_an_error(tmp_path):
def test_backups_dir_with_no_timestamped_file_is_an_error(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
(backups / "sp-backup_2026-01-01.json").write_text("{}", encoding="utf-8")
cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_)
def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
def test_backups_dir_picks_the_lexically_latest_timestamped_file(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
older = _snapshot()
older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project"
newer = _snapshot()
_write_snapshot(backups / "2026-01-01T000000Z.json", older)
_write_snapshot(backups / "2026-02-01T000000Z.json", newer)
_write_snapshot(backups / "2026-01-01_000000.json", older)
_write_snapshot(backups / "2026-02-01_000000.json", newer)
cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
reader = sp.SuperProductivityReader(cfg_)
names = {p.name for p in reader.projects()}
@@ -168,10 +212,31 @@ def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
assert "Old Snapshot Project" not in names
def test_a_manual_export_never_wins_even_placed_next_to_an_older_timestamp(tmp_path):
"""`sp-backup_*.json` sorts lexically *after* every timestamp - the
hardened glob (Gitea #133) must never pick it, whatever its own name or
mtime looks like next to the real backups."""
backups = tmp_path / "backups"
backups.mkdir()
timestamped = _snapshot()
manual_export = _snapshot()
manual_export["project"]["entities"]["p1"]["title"] = "From Manual Export"
_write_snapshot(backups / "2026-01-01_000000.json", timestamped)
_write_snapshot(backups / "sp-backup_2026-06-01.json", manual_export)
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
reader = sp.SuperProductivityReader(cfg_)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "From Manual Export" not in names
def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
data = _snapshot()
data["task"] = ["not", "an", "entity", "state"]
_write_snapshot(cfg.db_path, data)
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="task"):
reader.projects()
@@ -180,13 +245,13 @@ def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
def test_missing_top_level_key_fails_loud(cfg):
data = _snapshot()
del data["project"]
_write_snapshot(cfg.db_path, data)
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="project"):
reader.projects()
# --- read path ----------------------------------------------------------------
# --- read path (snapshot) -------------------------------------------------------
def test_projects_lists_name_and_created(cfg):
reader = sp.SuperProductivityReader(cfg)
@@ -195,13 +260,23 @@ def test_projects_lists_name_and_created(cfg):
assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
def test_projects_excludes_archived(cfg):
data = _snapshot()
data["project"]["entities"]["p1"]["isArchived"] = True
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" not in names
assert "Kueche renovieren" in names
def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg):
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("ship CHEMENU 7.0")
assert result.count == 2
def test_open_items_reports_waiting_with_follow_up_at_not_due_date(cfg):
def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("Ship Chemenu 7.0")
assert len(result.waiting) == 1
@@ -230,7 +305,49 @@ def test_someday_items_come_from_backlog_task_ids_only(cfg):
assert items[0].modified == date(2026, 1, 15)
# --- write path: HumanInterventionRequired -----------------------------------
def test_source_names_the_snapshot_file(cfg):
source = sp.SuperProductivityReader(cfg).source()
assert source.kind == sp.ACCESS_SNAPSHOT
assert "2026-03-01_120000.json" in source.detail
# --- follow_up_at mapping (Gitea #135) -------------------------------------------
def test_follow_up_at_reads_due_with_time(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueWithTime": _ms(2026, 3, 1)}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 1)
def test_follow_up_at_falls_back_to_due_day(cfg):
"""The gap #135 closed: an all-day, notification-free tickler has no
`dueWithTime` and no (now-removed) `remindAt` at all, and must still be
read as a follow-up date."""
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueDay": "2026-03-15"}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 15)
def test_follow_up_at_prefers_due_with_time_over_due_day(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
"dueWithTime": _ms(2026, 3, 1), "dueDay": "2026-04-01",
}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 1)
def test_follow_up_at_never_reads_remind_at_or_deadline_fields(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
"remindAt": _ms(2026, 3, 1),
"deadlineDay": "2026-03-01",
"deadlineWithTime": _ms(2026, 3, 1),
"deadlineRemindAt": _ms(2026, 3, 1),
}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at is None
# --- write path: HumanInterventionRequired ---------------------------------------
def test_create_project_refuses_a_name_collision(cfg):
reader = sp.SuperProductivityReader(cfg)
@@ -257,17 +374,17 @@ def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
"taskIds": [], "backlogTaskIds": [],
}
_write_snapshot(cfg.db_path, data)
_write_snapshot(sp.latest_snapshot_path(cfg), data)
assert exc.verify() is True
# --- health -------------------------------------------------------------------
# --- health ------------------------------------------------------------------------
def test_health_is_false_when_nothing_listens(tmp_path):
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "db.json",
api_base_url="http://127.0.0.1:1", api_token=None,
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
assert sp.health(cfg_, timeout=0.5) is False
@@ -289,10 +406,191 @@ def test_health_is_true_when_the_endpoint_answers(tmp_path):
try:
port = server.server_address[1]
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "db.json",
api_base_url=f"http://127.0.0.1:{port}", api_token=None,
access=sp.ACCESS_API, backups_dir=None,
api_base_url=f"http://127.0.0.1:{port}", api_token="t",
)
assert sp.health(cfg_, timeout=2.0) is True
finally:
server.shutdown()
thread.join(timeout=2)
# --- the API read path (Gitea #133) -------------------------------------------------
def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None):
status_override = status_override or {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/health":
self._reply(200, {"ok": True})
return
override = status_override.get(self.path)
if override is not None:
self._reply(override, {"error": "fixture"})
return
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
if self.path in routes:
self._reply(200, routes[self.path])
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload: Any) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # noqa: D401 - silence stderr noise
pass
return Handler
@contextlib.contextmanager
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None):
handler_cls = _make_api_handler(routes, token, status_override=status_override)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=2)
def _api_cfg(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityConfig:
port = server.server_address[1]
return sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url=f"http://127.0.0.1:{port}", api_token=token,
)
def _fixture_records() -> tuple[list[dict], list[dict], list[dict]]:
projects = [
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": ["t1", "t2"], "backlogTaskIds": ["t3"]},
{"id": "p2", "title": "Archived Project", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": [], "isArchived": True},
]
tasks = [
{"id": "t1", "title": "Warte auf Angebot vom Elektriker - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueDay": "2026-03-15"},
# A subtask: inherits projectId from its parent, is NOT itself in
# project.taskIds (only t1/t2 are) - counting via project.taskIds
# must not pick it up, matching the snapshot path (Gitea #133).
{"id": "t2b", "title": "Unterschritt von t1", "isDone": False, "tagIds": [],
"projectId": "p1", "parentId": "t1"},
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
{"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, "tagIds": [],
"updated": _ms(2026, 1, 15)},
]
tags = [{"id": "tag-wait", "title": "Waiting"}]
return projects, tasks, tags
def test_api_reader_projects_excludes_archived():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "Archived Project" not in names
def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_filter():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
result = reader.open_items("Ship Chemenu 7.0")
assert result.count == 2 # t1, t2 - not the subtask t2b
assert len(result.waiting) == 1
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
def test_api_reader_someday_items():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
items = reader.someday_items()
assert [i.title for i in items] == ["Irgendwann Keller aufraeumen"]
def test_api_reader_401_without_the_right_token_fails_loud():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}, token="right-token") as server:
cfg_ = _api_cfg(server, token="wrong-token")
with pytest.raises(ValidationError):
sp.SuperProductivityApiReader(cfg_).projects()
def test_api_reader_503_app_not_ready_is_its_own_message():
with _api_server({"/projects": []}, status_override={"/projects": 503}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
with pytest.raises(ValidationError, match="APP_NOT_READY"):
reader.projects()
def test_api_reader_unreachable_fails_loud_not_silent(tmp_path):
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
with pytest.raises(ValidationError):
sp.SuperProductivityApiReader(cfg_).projects()
def test_api_reader_non_list_response_fails_loud():
with _api_server({"/projects": {"not": "a list"}}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
with pytest.raises(ValidationError):
reader.projects()
def test_api_reader_source_is_live_and_needs_no_network():
"""`source()` on the API path must not itself perform a request - a
static description is correct regardless of reachability (Gitea #133)."""
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
source = sp.SuperProductivityApiReader(cfg_).source()
assert source.kind == sp.ACCESS_API
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
projects, tasks, tags = _fixture_records()
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_000000.json", {
"project": _entity_state({p["id"]: p for p in projects}),
"task": _entity_state({t["id"]: t for t in tasks}),
"tag": _entity_state({t["id"]: t for t in tags}),
})
snapshot_reader = sp.SuperProductivityReader(sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
))
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
api_reader = sp.SuperProductivityApiReader(_api_cfg(server))
assert {p.name: p.created for p in snapshot_reader.projects()} == \
{p.name: p.created for p in api_reader.projects()}
snap_items = snapshot_reader.open_items("Ship Chemenu 7.0")
api_items = api_reader.open_items("Ship Chemenu 7.0")
assert snap_items.count == api_items.count
assert [(w.title, w.follow_up_at) for w in snap_items.waiting] == \
[(w.title, w.follow_up_at) for w in api_items.waiting]
assert [(s.title, s.modified) for s in snapshot_reader.someday_items()] == \
[(s.title, s.modified) for s in api_reader.someday_items()]
+1 -1
View File
@@ -19,8 +19,8 @@ VALID = {
"someday_stale_months": 5,
},
"superproductivity": {
"access": "snapshot",
"backups_dir": "/tmp/does-not-need-to-exist-for-parsing",
"api_base_url": "http://127.0.0.1:3876",
},
}