feat: live tracker suite - WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (#156)
CI / verify (push) Failing after 2m1s
Release / release (push) Successful in 38s

Files changed:
- .gitea/scripts/start-radicale.sh
- .gitea/sp-live/Dockerfile
- .gitea/sp-live/resolve-version.sh
- .gitea/workflows/ci.yml
- .gitea/workflows/sp-live-image.yml
- .gitea/workflows/tracker-live.yml
- .gitignore
- CHANGES.md
- DEVELOPMENT.md
- INSTALL.md
- VERSION
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/testing-conventions.md
- instructions/dev/tracker-testing.md
- kb/gtd/INDEX.md
- kb/gtd/technik/Chemenu 8.0.0 freigeben.md
- kb/gtd/technik/Windows nativ unterstützen.md
- kb/index.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/config.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/fixtures/sp/MANIFEST.json
- tools/chemenu/tests/fixtures/sp/api/health.json
- tools/chemenu/tests/fixtures/sp/api/projects.json
- tools/chemenu/tests/fixtures/sp/api/tags.json
- tools/chemenu/tests/fixtures/sp/api/tasks.json
- tools/chemenu/tests/fixtures/sp/seed-backup.json
- tools/chemenu/tests/record_sp_fixtures.py
- tools/chemenu/tests/sp_headless.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_sp_recorded.py
- tools/chemenu/tests/test_task_cmd.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_tracker_live.py
- tools/chemenu/tests/tracker_live.py
- tools/pytest.ini
This commit is contained in:
torben committed 2026-09-30 13:23:24 +02:00
1 parent 529793b255
commit b0c64772cc
43 files changed
+2279 -32

No files matched your search

+2
View File
@@ -158,6 +158,8 @@ REQUIRED_IGNORE_CANARIES = (
# The task-tracker provider opt-in (Gitea #124) - same shape again:
# per-checkout, never committed, once a credential lands in it.
".wikitool-tasks.json",
# The live suite's tracker profiles (Gitea #156) - credentials again, one file per tracker.
".wikitool-tasks.d/probe.json",
)
REQUIRED_TRACKED_PATHS = (
"reports/CONTRACT.md",
+14 -9
View File
@@ -443,8 +443,8 @@ def check_tasks_provider() -> Check:
except ValidationError as exc:
return Check(
"tasks-provider", "FAIL", str(exc),
f"Fix or delete {config.TASKS_CONFIG_FILENAME} - a broken one is not treated as "
"'no tracker configured'",
f"Fix or delete {config.tasks_config_label(config.ROOT)} - a broken one is not "
"treated as 'no tracker configured'",
)
if cfg is None:
return Check(
@@ -453,6 +453,9 @@ def check_tasks_provider() -> Check:
"review needs one, everything else does not)",
)
override = config.tasks_config_override()
via = f" [config: {override} via {config.ENV_TASKS_CONFIG}]" if override is not None else ""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
@@ -461,14 +464,14 @@ def check_tasks_provider() -> Check:
except ValidationError as exc:
return Check(
"tasks-provider", "FAIL", str(exc),
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
f"Fix the 'superproductivity' section of {config.tasks_config_label(config.ROOT)}",
)
# 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}",
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}{via}",
)
try:
snapshot_path = sp.latest_snapshot_path(sp_cfg)
@@ -476,7 +479,7 @@ def check_tasks_provider() -> Check:
except ValidationError as exc:
read_state = f"read path not ready ({exc})"
return Check(
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}{via}",
)
if cfg.provider == "caldav":
@@ -487,12 +490,12 @@ def check_tasks_provider() -> Check:
except ValidationError as exc:
return Check(
"tasks-provider", "FAIL", str(exc),
f"Fix the 'caldav' section of {config.TASKS_CONFIG_FILENAME}",
f"Fix the 'caldav' section of {config.tasks_config_label(config.ROOT)}",
)
state = cd.probe(cd_cfg)
return Check("tasks-provider", "OK", f"caldav: {state}")
return Check("tasks-provider", "OK", f"caldav: {state}{via}")
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured{via}")
def check_session_id() -> Check:
@@ -642,7 +645,9 @@ def run_doctor() -> list[Check]:
"its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means "
"the write path does not exist at all; malformed is a `FAIL`.",
"Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` "
"(no tracker configured), malformed is a `FAIL`.",
"(no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, "
"that file is read instead and the finding names it; a set variable that names no file "
"is a `FAIL`, never `OK`.",
"For a configured `superproductivity` provider, the configured `access` path's own "
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
"with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
+9 -1
View File
@@ -104,7 +104,11 @@ def report_to_dict(report: ReviewReport) -> dict:
"A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a "
"tracker project with no determinable creation date - is its own finding "
"(`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.",
"Thresholds come from `.wikitool-tasks.json`, never from the schema.",
"Thresholds come from the tracker configuration, never from the schema.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"Text output is one `[check] project: message` line per finding, preceded by a "
"`Source:` line naming which access path answered and, for `superproductivity`'s "
"`access: \"snapshot\"`, the snapshot's age. `--json` carries the same findings plus "
@@ -123,6 +127,10 @@ def report_to_dict(report: ReviewReport) -> dict:
reaction="Not fixed by retrying unchanged - configure or repair "
"`.wikitool-tasks.json` first",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure(
cause="The provider was reachable at config-parse time but a read call failed "
"mid-run; the full report (findings plus which checks ran) was printed first",
+30 -3
View File
@@ -58,6 +58,13 @@ def _parse_follow_up_at(text: str) -> datetime.date:
fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.")
def _read_config():
try:
return tasks_config.read_config(config.ROOT)
except ValidationError as exc:
fail(str(exc))
@app.command("new")
@cli_contract.record(cli_contract.CommandRecord(
path="task new",
@@ -87,6 +94,10 @@ def _parse_follow_up_at(text: str) -> datetime.date:
"`--follow-up-at` is refused without `--waiting` - it is never a due date on its own.",
"`--notes` carries a freetext backref (e.g. to the `kb/` source page the item came "
"from), stored verbatim, never parsed.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"No `.wikitool-tasks.json` fails immediately with a \"no tracker configured\" message.",
"A provider whose configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
@@ -102,6 +113,10 @@ def _parse_follow_up_at(text: str) -> datetime.date:
cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure(
cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without "
"`--waiting` or not `YYYY-MM-DD`",
@@ -191,7 +206,7 @@ def task_new_command(
)
follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None
cfg = tasks_config.read_config(config.ROOT)
cfg = _read_config()
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
@@ -252,6 +267,10 @@ def task_new_command(
cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first, then retry once",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
),
examples=(
'tools/wikitool task list --project "Homelab migration"',
@@ -271,7 +290,7 @@ def task_list_command(
So a caller can get an item's id for `task close` without first running
`wikitool review` (Gitea #138). Read-only; works against either access
mode a provider offers."""
cfg = tasks_config.read_config(config.ROOT)
cfg = _read_config()
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
@@ -311,6 +330,10 @@ def task_list_command(
"write this stack makes.",
"`--id` is the provider's own item id, from `task list` or a `review` finding - never a "
"title.",
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
"run against several trackers in turn). A set variable that names no file is an "
"error, never \"no tracker configured\".",
"No `.wikitool-tasks.json` fails with a \"no tracker configured\" message.",
"A provider whose configured access path has no write path (Super Productivity's "
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
@@ -322,6 +345,10 @@ def task_list_command(
cause="No `.wikitool-tasks.json` - no tracker configured",
reaction="Not transient - configure a tracker first",
),
cli_contract.Failure(
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
reaction="Not transient - fix the path or unset the variable",
),
cli_contract.Failure(
cause="An `--id` matching no tracker item right now",
reaction="Get a current id from `task list` or `review`, then retry once",
@@ -355,7 +382,7 @@ def task_close_command(
The only closing write this stack makes (Gitea #138); see
`chemenu.tasks.protocol.TaskWriter.close_item` and
`docs/knowledge-and-commitment.md` for why."""
cfg = tasks_config.read_config(config.ROOT)
cfg = _read_config()
if cfg is None:
fail(
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
+29
View File
@@ -257,6 +257,35 @@ UPLOAD_CONFIG_FILENAME = ".wikitool-upload.json"
# else just fine, it only can't run the weekly review (#125, not yet built).
TASKS_CONFIG_FILENAME = ".wikitool-tasks.json"
# Reads a tracker configuration from another file than `<root>/.wikitool-tasks.json`
# (Gitea #156), so one checkout can be run against several trackers one after the
# other without swapping a file. Registered in `_WIKITOOL_ENV`
# (tools/chemenu/tests/conftest.py). A relative value is taken from the working
# directory. Set but pointing at nothing is an error, never "no tracker".
ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG"
def tasks_config_override() -> "Path | None":
"""The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank."""
value = os.environ.get(ENV_TASKS_CONFIG, "").strip()
return Path(value).expanduser().resolve() if value else None
def tasks_config_path(root: "Path | str") -> Path:
"""The tracker configuration file this process reads: the override when set,
else `<root>/.wikitool-tasks.json`."""
override = tasks_config_override()
return override if override is not None else Path(root) / TASKS_CONFIG_FILENAME
def tasks_config_label(root: "Path | str") -> str:
"""How a message names the file `tasks_config_path` reads - the bare file
name by default, the actual path and the variable when the override is on."""
override = tasks_config_override()
if override is None:
return TASKS_CONFIG_FILENAME
return f"{override} (from {ENV_TASKS_CONFIG})"
def default_author() -> str | None:
"""The author to stamp a new source page with, per instance.
+16 -9
View File
@@ -1,4 +1,5 @@
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`.
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`, or the
file `$WIKITOOL_TASKS_CONFIG` names instead (Gitea #156, `config.tasks_config_path`).
Same posture as `chemenu.upload.read_config`: absent means "no tracker
configured for this instance", a legitimate state that `doctor` reports as OK,
@@ -46,22 +47,28 @@ def read_config(root: "Any") -> "TasksConfig | None":
"""The tracker configuration for `root`, or `None` when the file is
absent - which means no tracker is configured for this instance, not that
one failed to load."""
from pathlib import Path
import json
path = Path(root) / config.TASKS_CONFIG_FILENAME
path = config.tasks_config_path(root)
label = config.tasks_config_label(root)
if not path.is_file():
if config.tasks_config_override() is not None:
raise ValidationError(
f"{config.ENV_TASKS_CONFIG} points at {path}, which is not a file. An override "
"that names nothing is never read as 'no tracker configured' - fix the path or "
"unset the variable."
)
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME} is unreadable ({exc}). It decides which task "
f"{label} is unreadable ({exc}). It decides which task "
"tracker the weekly review talks to, so a broken file is not treated as 'no "
"tracker configured' - fix it or delete it deliberately."
) from exc
if not isinstance(data, dict):
raise ValidationError(f"{config.TASKS_CONFIG_FILENAME} must contain a JSON object.")
raise ValidationError(f"{label} must contain a JSON object.")
expected = (
'{"schema": 1, "provider": "superproductivity", '
@@ -78,13 +85,13 @@ def read_config(root: "Any") -> "TasksConfig | None":
someday_stale_months = int(raw_thresholds["someday_stale_months"])
except (KeyError, TypeError, ValueError) as exc:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME} is missing or misshapes a required field ({exc}). "
f"{label} is missing or misshapes a required field ({exc}). "
f"Expected: {expected}"
) from exc
if provider not in KNOWN_PROVIDERS:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: unknown provider {provider!r}. "
f"{label}: unknown provider {provider!r}. "
f"Known: {', '.join(KNOWN_PROVIDERS)}"
)
for field_name, value in (
@@ -94,13 +101,13 @@ def read_config(root: "Any") -> "TasksConfig | None":
):
if value <= 0:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: thresholds.{field_name} must be positive."
f"{label}: thresholds.{field_name} must be positive."
)
provider_config = data.get(provider)
if not isinstance(provider_config, dict):
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: missing or non-object {provider!r} section "
f"{label}: missing or non-object {provider!r} section "
f"holding that provider's own connection settings. Expected: {expected}"
)
+1
View File
@@ -32,6 +32,7 @@ _WIKITOOL_ENV = (
"WIKITOOL_UPDATE_URL",
"WIKITOOL_UPDATE_TOKEN",
"CHEMENU_ROOT",
"WIKITOOL_TASKS_CONFIG",
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
# Environment git reads for identity or for where its repo lives. A stray
+7
View File
@@ -0,0 +1,7 @@
{
"sp_version": "19.1.0",
"recorded": "2026-09-30",
"how": "Packaged Super Productivity (.deb) headless in the chemenu-sp-live image, seeded from seed-backup.json; api/*.json are the raw answers of GET /health, /projects, /tasks and /tags after creating four items and closing one through wikitool's own writer.",
"seed-backup.json": "A backup the app wrote itself, plus three projects ('Chemenu Live-Test', 'Chemenu 8.0.0 freigeben', 'Windows nativ unterstützen' as demo-2) and the tag 'waiting', cloned from entities the app produced.",
"refresh": "See instructions/dev/tracker-testing.md, section Refreshing the fixtures."
}
+7
View File
@@ -0,0 +1,7 @@
{
"data": {
"rendererReady": true,
"server": "up"
},
"ok": true
}
+187
View File
@@ -0,0 +1,187 @@
{
"data": [
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"doneOn": null,
"icon": "inbox",
"id": "INBOX_PROJECT",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [
"cbN7F3EMo67k9RrfMjS26",
"KfvaOD_bl7mIn-Fq0W9NE"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Inbox"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"created": 1790745002886,
"doneOn": null,
"icon": null,
"id": "chemenu-live-test",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [
"gwedT3TNeJm35AaKMj_Ov",
"dCf43Dr1Ei9Gc1DxysY5q",
"EBm18ZV1F1LrVV7hsauGu"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Chemenu Live-Test"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"created": 1790745002886,
"doneOn": null,
"icon": null,
"id": "demo-1",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Chemenu 8.0.0 freigeben"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"backlogTaskIds": [],
"created": 1790745002886,
"doneOn": null,
"icon": null,
"id": "demo-2",
"isArchived": false,
"isDone": false,
"isEnableBacklog": false,
"isHiddenFromMenu": false,
"noteIds": [],
"taskIds": [],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "500",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": false,
"primary": "rgb(144, 187, 165)",
"warn": "#e11826"
},
"title": "Windows nativ unterst\u00fctzen"
}
],
"ok": true
}
+88
View File
@@ -0,0 +1,88 @@
{
"data": [
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"color": null,
"created": 1790744929943,
"icon": "wb_sunny",
"id": "TODAY",
"taskIds": [
"cbN7F3EMo67k9RrfMjS26",
"gwedT3TNeJm35AaKMj_Ov",
"EBm18ZV1F1LrVV7hsauGu",
"KfvaOD_bl7mIn-Fq0W9NE"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "400",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": true,
"primary": "#6495ED",
"warn": "#e11826"
},
"title": "Today"
},
{
"advancedCfg": {
"worklogExportSettings": {
"cols": [
"DATE",
"START",
"END",
"TIME_CLOCK",
"TITLES_INCLUDING_SUB"
],
"groupBy": "DATE",
"roundEndTimeTo": null,
"roundStartTimeTo": null,
"roundWorkTimeTo": null,
"separateTasksBy": " | "
}
},
"color": null,
"created": 1790745002886,
"icon": null,
"id": "tag-waiting",
"taskIds": [
"dCf43Dr1Ei9Gc1DxysY5q"
],
"theme": {
"accent": "#ff4081",
"backgroundImageBlur": 0,
"backgroundImageDark": "",
"backgroundImageLight": null,
"backgroundOverlayOpacity": 20,
"hueAccent": "500",
"huePrimary": "400",
"hueWarn": "500",
"isAutoContrast": true,
"isDisableBackgroundTint": true,
"primary": "#6495ED",
"warn": "#e11826"
},
"title": "waiting"
}
],
"ok": true
}
+64
View File
@@ -0,0 +1,64 @@
{
"data": [
{
"attachments": [],
"created": 1790744930824,
"dueDay": "2026-09-30",
"id": "KfvaOD_bl7mIn-Fq0W9NE",
"isDone": false,
"projectId": "INBOX_PROJECT",
"subTaskIds": [],
"tagIds": [],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "seed marker task"
},
{
"attachments": [],
"created": 1790764434509,
"dueDay": "2026-09-30",
"id": "EBm18ZV1F1LrVV7hsauGu",
"isDone": false,
"notes": "kb/gtd/technik/Chemenu Live-Test.md",
"projectId": "chemenu-live-test",
"subTaskIds": [],
"tagIds": [],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "Recorded: offene Aufgabe"
},
{
"attachments": [],
"created": 1790764434554,
"dueDay": "2026-08-01",
"id": "dCf43Dr1Ei9Gc1DxysY5q",
"isDone": false,
"projectId": "chemenu-live-test",
"subTaskIds": [],
"tagIds": [
"tag-waiting"
],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "Recorded: warte auf Antwort"
},
{
"attachments": [],
"created": 1790764434974,
"dueDay": "2026-09-30",
"id": "cbN7F3EMo67k9RrfMjS26",
"isDone": false,
"projectId": "INBOX_PROJECT",
"subTaskIds": [],
"tagIds": [],
"timeEstimate": 0,
"timeSpent": 0,
"timeSpentOnDay": {},
"title": "Recorded: Posteingang"
}
],
"ok": true
}
+1
View File
@@ -0,0 +1 @@
{"task": {"ids": ["KfvaOD_bl7mIn-Fq0W9NE"], "entities": {"KfvaOD_bl7mIn-Fq0W9NE": {"id": "KfvaOD_bl7mIn-Fq0W9NE", "subTaskIds": [], "timeSpentOnDay": {}, "timeSpent": 0, "timeEstimate": 0, "isDone": false, "title": "seed marker task", "tagIds": [], "created": 1790744930824, "attachments": [], "projectId": "INBOX_PROJECT", "dueDay": "2026-09-30"}}, "currentTaskId": null, "selectedTaskId": null, "taskDetailTargetPanel": null, "lastCurrentTaskId": null, "isDataLoaded": false, "dismissedCalendarAutoImportEventIdsByProvider": {}}, "project": {"ids": ["INBOX_PROJECT", "chemenu-live-test", "demo-1", "demo-2"], "entities": {"INBOX_PROJECT": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "inbox", "id": "INBOX_PROJECT", "title": "Inbox"}, "chemenu-live-test": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "chemenu-live-test", "title": "Chemenu Live-Test", "created": 1790745002886}, "demo-1": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-1", "title": "Chemenu 8.0.0 freigeben", "created": 1790745002886}, "demo-2": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-2", "title": "Windows nativ unterst\u00fctzen", "created": 1790745002886}}}, "tag": {"ids": ["TODAY", "tag-waiting"], "entities": {"TODAY": {"color": null, "created": 1790744929943, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": true, "primary": "#6495ED", "huePrimary": "400", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "wb_sunny", "id": "TODAY", "title": "Today"}, "tag-waiting": {"color": null, "created": 1790745002886, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": trLine truncated
+76
View File
@@ -0,0 +1,76 @@
"""Re-record `fixtures/sp/api/*.json` from a real Super Productivity (Gitea #156).
Not a test module. Run it where the packaged app is installed - the `chemenu-sp-live`
image, or any machine with `CHEMENU_LIVE_SP_BINARY` set - from `tools/`:
CHEMENU_LIVE_SP_BINARY=... python -m chemenu.tests.record_sp_fixtures <out-dir>
It starts the seeded headless app, creates four items and closes one through wikitool's own
writer, then stores the raw answers of `GET /health`, `/projects`, `/tasks` and `/tags`.
`instructions/dev/tracker-testing.md` says when to run it and what to update beside it.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import urllib.request
from datetime import date
from pathlib import Path
from chemenu.tasks import superproductivity as sp
from chemenu.tests import sp_headless
PROJECT = "Chemenu Live-Test"
def main(out: Path) -> None:
binary = os.environ.get("CHEMENU_LIVE_SP_BINARY")
if not binary:
sys.exit("CHEMENU_LIVE_SP_BINARY names no Super Productivity binary")
executable = Path(binary)
out.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix="chemenu-record-sp-"))
process = sp_headless.start(executable, work / "profile", marker_project=PROJECT)
try:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": process.base_url, "api_token": process.token}
)
reader = sp.SuperProductivityApiReader(cfg)
writer = sp.SuperProductivityWriter(cfg, reader)
writer.create_item(
"Recorded: offene Aufgabe", project_name=PROJECT,
notes="kb/gtd/technik/Chemenu Live-Test.md",
)
writer.create_item(
"Recorded: warte auf Antwort", project_name=PROJECT,
waiting=True, follow_up_at=date(2026, 8, 1),
)
writer.create_item("Recorded: erledigt", project_name=PROJECT)
writer.create_item("Recorded: Posteingang", project_name=None)
done = next(i for i in reader.open_items(PROJECT).items if i.title == "Recorded: erledigt")
writer.close_item(done.id)
def get(path: str, auth: bool) -> object:
headers = {"Authorization": f"Bearer {process.token}"} if auth else {}
request = urllib.request.Request(process.base_url + path, headers=headers)
with urllib.request.urlopen(request, timeout=10) as response:
return json.loads(response.read())
for name, path, auth in (
("health", "/health", False), ("projects", "/projects", True),
("tasks", "/tasks", True), ("tags", "/tags", True),
):
(out / f"{name}.json").write_text(
json.dumps(get(path, auth), indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
print("recorded against Super Productivity", sp_headless.installed_version(executable))
finally:
process.stop()
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: python -m chemenu.tests.record_sp_fixtures <out-dir>")
main(Path(sys.argv[1]))
+305
View File
@@ -0,0 +1,305 @@
"""A throwaway Super Productivity for the live tracker suite (Gitea #156).
Not a test module. `tracker_live` uses it to start the packaged desktop app with no
visible display, seeded from `fixtures/sp/seed-backup.json`, and to stop it again.
Everything here was found out by running the real app (v19.1.0, see
`instructions/dev/tracker-testing.md`):
- the Local REST API exists only when `SP_FORCE_LOCAL_REST_API=1` is set, listens on
the fixed port 3876 and takes the token from `SP_FORCE_LOCAL_REST_API_TOKEN`;
- a packaged app needs `NODE_ENV=DEV` plus `--custom-url` at its own bundled
`index.html`, or it opens a remote page;
- data comes in through a backup file in `<profile>/backups/`, and the app asks
"restore the newest backup?" with a `window.confirm` at startup, which has to be
accepted over the Chromium DevTools protocol - there is no flag for it.
Standard library only: the suite must run wherever `tools/requirements.txt` does.
"""
from __future__ import annotations
import base64
import json
import os
import secrets
import shutil
import signal
import socket
import struct
import subprocess
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
API_PORT = 3876
CDP_PORT = 9222
SEED_BACKUP = Path(__file__).parent / "fixtures" / "sp" / "seed-backup.json"
class LiveAbort(RuntimeError):
"""The live suite refuses to touch a tracker. Raised before the first write."""
def health(port: int = API_PORT, timeout: float = 2.0) -> Optional[dict]:
"""`GET /health` unwrapped, or `None` when nothing (Super Productivity) answers."""
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except (OSError, ValueError, urllib.error.URLError):
return None
if not isinstance(payload, dict) or not payload.get("ok"):
return None
data = payload.get("data")
return data if isinstance(data, dict) else None
# --- a minimal WebSocket client, just enough for the DevTools protocol -----------------
def _ws_connect(host: str, port: int, path: str, timeout: float) -> socket.socket:
sock = socket.create_connection((host, port), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode("ascii")
sock.sendall(
(
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
).encode("ascii")
)
buffer = b""
while b"\r\n\r\n" not in buffer:
chunk = sock.recv(4096)
if not chunk:
raise OSError("DevTools closed the connection during the handshake")
buffer += chunk
if b" 101 " not in buffer.split(b"\r\n", 1)[0]:
raise OSError(f"DevTools refused the WebSocket upgrade: {buffer[:80]!r}")
return sock
def _ws_send(sock: socket.socket, text: str) -> None:
payload = text.encode("utf-8")
mask = os.urandom(4)
length = len(payload)
if length < 126:
header = struct.pack("!BB", 0x81, 0x80 | length)
elif length < 65536:
header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
else:
header = struct.pack("!BBQ", 0x81, 0x80 | 127, length)
masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
sock.sendall(header + mask + masked)
def _recv_exact(sock: socket.socket, count: int) -> bytes:
data = b""
while len(data) < count:
chunk = sock.recv(count - len(data))
if not chunk:
raise OSError("DevTools closed the connection")
data += chunk
return data
def _ws_recv(sock: socket.socket) -> Optional[str]:
"""The next text message, or `None` on a close frame. Server frames are unmasked."""
while True:
first, second = _recv_exact(sock, 2)
opcode = first & 0x0F
length = second & 0x7F
if length == 126:
(length,) = struct.unpack("!H", _recv_exact(sock, 2))
elif length == 127:
(length,) = struct.unpack("!Q", _recv_exact(sock, 8))
payload = _recv_exact(sock, length)
if opcode == 0x8:
return None
if opcode in (0x1, 0x0):
return payload.decode("utf-8", "replace")
def _accept_dialogs(cdp_port: int, stop: threading.Event, log: list[str]) -> None:
"""Accept every JavaScript dialog the renderer opens until `stop` is set."""
while not stop.is_set():
try:
with urllib.request.urlopen(f"http://127.0.0.1:{cdp_port}/json", timeout=2) as resp:
targets = [t for t in json.loads(resp.read()) if t.get("type") == "page"]
if not targets:
raise OSError("no page target yet")
ws_url = targets[0]["webSocketDebuggerUrl"]
path = "/" + ws_url.split("/", 3)[3]
sock = _ws_connect("127.0.0.1", cdp_port, path, timeout=2)
except (OSError, ValueError, KeyError, IndexError):
stop.wait(0.5)
continue
try:
_ws_send(sock, json.dumps({"id": 1, "method": "Page.enable"}))
sock.settimeout(1.0)
while not stop.is_set():
try:
message = _ws_recv(sock)
except socket.timeout:
continue
if message is None:
break
event = json.loads(message)
if event.get("method") == "Page.javascriptDialogOpening":
params = event.get("params", {})
log.append(f"{params.get('type')}: {str(params.get('message'))[:120]}")
_ws_send(sock, json.dumps({
"id": 2, "method": "Page.handleJavaScriptDialog",
"params": {"accept": True},
}))
except (OSError, ValueError):
stop.wait(0.5)
finally:
sock.close()
# --- the app ---------------------------------------------------------------------------
@dataclass
class SpProcess:
process: "subprocess.Popen[bytes]"
profile_dir: Path
token: str
port: int
log_path: Path
dialogs: list[str]
_stop: threading.Event
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.port}"
def stop(self) -> None:
self._stop.set()
if self.process.poll() is None:
try:
os.killpg(self.process.pid, signal.SIGTERM)
self.process.wait(timeout=15)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(self.process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def installed_version(executable: Path) -> str:
"""The packaged version, for the report line - `unknown` rather than a guess."""
override = os.environ.get("CHEMENU_LIVE_SP_VERSION")
if override:
return override
if shutil.which("dpkg-query"):
result = subprocess.run(
["dpkg-query", "-W", "-f=${Version}", "superproductivity"],
capture_output=True, text=True, check=False,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return "unknown"
def _api_projects(base_url: str, token: str) -> list[str]:
request = urllib.request.Request(
f"{base_url}/projects", headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(request, timeout=5) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return [str(p.get("title")) for p in payload.get("data", [])]
def start(
executable: Path,
profile_dir: Path,
*,
marker_project: str,
port: int = API_PORT,
cdp_port: int = CDP_PORT,
startup_timeout: float = 240.0,
) -> SpProcess:
"""Start the packaged app on a fresh, seeded profile and wait until it is ready.
Refuses when anything already answers on `port`: the port is fixed, so an answering
app is somebody's real Super Productivity, and seeding "our" one would either fail
or, worse, write into theirs.
"""
if health(port) is not None:
raise LiveAbort(
f"something already answers on 127.0.0.1:{port} - a running Super Productivity "
"owns the fixed API port, so the live suite will not start a second one next to "
"it. Quit that app, or point CHEMENU_LIVE_PROFILE at it deliberately."
)
if not executable.is_file():
raise LiveAbort(f"CHEMENU_LIVE_SP_BINARY does not name a file: {executable}")
if not os.environ.get("DISPLAY") and not shutil.which("xvfb-run"):
raise LiveAbort("no DISPLAY and no xvfb-run on PATH - the app needs a display to start.")
backups = profile_dir / "backups"
backups.mkdir(parents=True, exist_ok=True)
shutil.copyfile(SEED_BACKUP, backups / "2026-01-01_000000.json")
app_dir = executable.parent
token = secrets.token_urlsafe(24)
env = {
**os.environ,
"NODE_ENV": "DEV",
"SP_FORCE_LOCAL_REST_API": "1",
"SP_FORCE_LOCAL_REST_API_TOKEN": token,
}
command = [
str(executable),
f"--custom-url=file://{app_dir}/resources/app.asar/.tmp/angular-dist/browser/index.html",
f"--user-data-dir={profile_dir}",
"--disable-tray", "--no-sandbox",
f"--remote-debugging-port={cdp_port}",
]
if not os.environ.get("DISPLAY"):
command = ["xvfb-run", "-a", *command]
log_path = profile_dir.parent / f"{profile_dir.name}-sp.log"
with log_path.open("wb") as log:
process = subprocess.Popen(
command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True
)
stop = threading.Event()
dialogs: list[str] = []
threading.Thread(
target=_accept_dialogs, args=(cdp_port, stop, dialogs), daemon=True
).start()
handle = SpProcess(process, profile_dir, token, port, log_path, dialogs, stop)
def log_tail() -> str:
try:
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "(no log)"
return "\n".join(lines[-25:])
base_url = handle.base_url
deadline = time.monotonic() + startup_timeout
try:
while time.monotonic() < deadline:
if process.poll() is not None:
raise LiveAbort(
f"Super Productivity exited during startup; log {log_path}:\n{log_tail()}"
)
state = health(port)
if state and state.get("rendererReady") is True:
try:
if marker_project in _api_projects(base_url, token):
return handle
except (OSError, ValueError):
pass
time.sleep(2)
raise LiveAbort(
f"Super Productivity was not ready with project '{marker_project}' within "
f"{startup_timeout:.0f}s; log {log_path}:\n{log_tail()}"
)
except BaseException:
handle.stop()
raise
+25
View File
@@ -533,3 +533,28 @@ def test_doctor_json_is_machine_readable(instance, capsys):
rows = json.loads(capsys.readouterr().out)
assert any(row["name"] == "structure" for row in rows)
assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows)
def test_tasks_provider_fails_when_the_override_names_a_missing_file(instance, monkeypatch):
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(config.ROOT / "gone.json"))
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "FAIL"
assert "gone.json" in _detail(checks, "tasks-provider")
def test_tasks_provider_names_an_active_override(instance, monkeypatch):
profile = config.ROOT / "profile.json"
profile.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",
)
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
assert "WIKITOOL_TASKS_CONFIG" in _detail(checks, "tasks-provider")
+11
View File
@@ -591,3 +591,14 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True
).stdout
assert before == after == ""
def test_cli_override_naming_a_missing_file_is_an_error_naming_the_path(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(tmp_path / "gone.json"))
result = runner.invoke(app, ["review"])
assert result.exit_code == 1
assert "gone.json" in result.output
assert "no task tracker is configured" not in result.output
+113
View File
@@ -0,0 +1,113 @@
"""Super Productivity's real answers, replayed without the app (Gitea #156, E4).
`fixtures/sp/` holds what the packaged app itself produced - a backup it wrote, and the raw
answers of its Local REST API after a fixed scenario (`MANIFEST.json` names the version and
the day). Every push checks the adapter against them, so a change to the adapter that the
hand-written fakes in `test_superproductivity.py` would let through fails here; whether the
*app* has changed since is what the nightly live suite answers (`test_tracker_live.py`).
"""
from __future__ import annotations
import contextlib
import http.server
import json
import shutil
import threading
from datetime import date
from pathlib import Path
import pytest
from chemenu.tasks import superproductivity as sp
FIXTURES = Path(__file__).parent / "fixtures" / "sp"
MARKER = "Chemenu Live-Test"
def _recorded(name: str) -> bytes:
return (FIXTURES / "api" / f"{name}.json").read_bytes()
@contextlib.contextmanager
def _replay_server():
routes = {
"/health": _recorded("health"), "/projects": _recorded("projects"),
"/tasks": _recorded("tasks"), "/tags": _recorded("tags"),
}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
body = routes.get(self.path.split("?", 1)[0])
if body is None:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}"
finally:
server.shutdown()
thread.join(timeout=5)
def test_the_manifest_names_the_version_and_the_day():
manifest = json.loads((FIXTURES / "MANIFEST.json").read_text(encoding="utf-8"))
assert manifest["sp_version"].count(".") == 2
date.fromisoformat(manifest["recorded"])
def test_the_api_reader_reads_the_apps_own_answers():
with _replay_server() as base_url:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
)
reader = sp.SuperProductivityApiReader(cfg)
names = {p.name for p in reader.projects()}
assert names == {MARKER, "Chemenu 8.0.0 freigeben", "Windows nativ unterstützen"}
items = reader.open_items(MARKER)
assert {i.title for i in items.items} == {
"Recorded: offene Aufgabe", "Recorded: warte auf Antwort",
}
assert items.count == 2
assert [(w.title, w.follow_up_at) for w in items.waiting] == [
("Recorded: warte auf Antwort", date(2026, 8, 1)),
]
assert reader.open_items("Windows nativ unterstützen").count == 0
assert sp.health(cfg) is True
def test_the_inbox_item_is_invisible_to_every_project():
with _replay_server() as base_url:
cfg = sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
)
reader = sp.SuperProductivityApiReader(cfg)
seen = {i.title for p in reader.projects() for i in reader.open_items(p.name).items}
assert "Recorded: Posteingang" not in seen and "seed marker task" not in seen
def test_the_snapshot_reader_reads_a_backup_the_app_wrote(tmp_path):
backups = tmp_path / "backups"
backups.mkdir()
shutil.copyfile(FIXTURES / "seed-backup.json", backups / "2026-01-01_000000.json")
cfg = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": str(backups)})
reader = sp.SuperProductivityReader(cfg)
assert {p.name for p in reader.projects()} == {
MARKER, "Chemenu 8.0.0 freigeben", "Windows nativ unterstützen",
}
assert reader.open_items(MARKER).count == 0
assert reader.someday_items() == []
+35
View File
@@ -387,3 +387,38 @@ def test_close_on_snapshot_access_is_refused_the_same_way_as_task_new(monkeypatc
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
assert result.exit_code == 1
assert "access: 'api'" in result.output
# --- WIKITOOL_TASKS_CONFIG (Gitea #156) ----------------------------------------------
def test_an_override_naming_a_missing_file_fails_naming_the_path(monkeypatch, kb_dir):
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(kb_dir.parent / "no-such-profile.json"))
for args in (
["task", "list", "--project", "x"],
["task", "close", "--id", "t1"],
["task", "new", "--title", "x", "--inbox"],
):
result = _invoke(monkeypatch, kb_dir, args)
assert result.exit_code == 1, args
flat = "".join(result.output.split())
assert "no-such-profile.json" in flat, args
assert "notasktrackerisconfigured" not in flat, args
def test_an_override_points_a_command_at_another_tracker_without_touching_the_root_file(
monkeypatch, kb_dir, tmp_path,
):
root = kb_dir.parent
state = {"projects": [{**_project_record(), "taskIds": ["t1"]}],
"tasks": [{"id": "t1", "title": "Ueber das Profil", "isDone": False, "tagIds": []}]}
with _api_server(state) as server:
profile_dir = tmp_path / "profiles"
profile_dir.mkdir()
_write_tasks_config(profile_dir, _base_url(server))
profile = profile_dir / ".wikitool-tasks.json"
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "Ship Chemenu 7.0"])
assert result.exit_code == 0, result.output
assert "Ueber das Profil" in result.output
assert not (root / ".wikitool-tasks.json").exists()
+47
View File
@@ -79,3 +79,50 @@ def test_missing_provider_section_is_rejected(tmp_path):
_write(tmp_path, data)
with pytest.raises(ValidationError):
tasks_config.read_config(tmp_path)
# --- the WIKITOOL_TASKS_CONFIG override (Gitea #156) --------------------------------
def test_override_is_read_instead_of_the_root_file(tmp_path, monkeypatch):
root = tmp_path / "root"
root.mkdir()
_write(root, {**VALID, "provider": "asana"}) # would be rejected if it were read
profile = tmp_path / "profile.json"
profile.write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
assert tasks_config.read_config(root).provider == "superproductivity"
def test_override_works_with_no_root_file_at_all(tmp_path, monkeypatch):
profile = tmp_path / "profile.json"
profile.write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
def test_a_relative_override_is_taken_from_the_working_directory(tmp_path, monkeypatch):
(tmp_path / "profile.json").write_text(json.dumps(VALID), encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", "profile.json")
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
def test_an_override_naming_a_missing_file_is_an_error_never_no_tracker(tmp_path, monkeypatch):
_write(tmp_path, VALID) # a root file must not be a fallback for a broken override
missing = tmp_path / "gone.json"
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(missing))
with pytest.raises(ValidationError, match="gone.json"):
tasks_config.read_config(tmp_path)
def test_a_blank_override_is_no_override(tmp_path, monkeypatch):
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", " ")
assert tasks_config.read_config(tmp_path) is None
def test_a_broken_override_file_names_its_own_path(tmp_path, monkeypatch):
profile = tmp_path / "profile.json"
profile.write_text("{not json", encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
with pytest.raises(ValidationError, match="profile.json"):
tasks_config.read_config(tmp_path)
+279
View File
@@ -0,0 +1,279 @@
"""The live tracker suite (Gitea #156) and the proof that its safety rules hold.
The `live_tracker` tests need a tracker and skip without one; the rest of this file
never does. Which tracker, how to start one, and what to do with a red run:
`instructions/dev/tracker-testing.md`.
"""
from __future__ import annotations
import contextlib
import http.server
import json
import threading
from datetime import date
from pathlib import Path
import pytest
from typer.testing import CliRunner
from chemenu.cli import app
from chemenu.review import CHECK_WAITING_OVERDUE, run_review
from chemenu.frontmatter_io import write_page
from chemenu.tasks.protocol import (
OpenItems, ProjectSummary, ReadSource, SomedayItem,
)
from chemenu.tests import sp_headless
from chemenu.tests import tracker_live as live
from chemenu.tests.tracker_live import LiveAbort, LiveTarget
runner = CliRunner()
# --- the live scenario ---------------------------------------------------------------------
@pytest.fixture(scope="session")
def live_pool():
pool: dict[str, "LiveTarget | None"] = {}
yield pool
for target in pool.values():
if target is not None:
target.cleanup()
@pytest.fixture(params=live.KINDS)
def target(request, live_pool) -> LiveTarget:
kind = request.param
if kind not in live_pool:
try:
live_pool[kind] = live.build_target(kind)
except LiveAbort as exc:
pytest.fail(f"live tracker '{kind}' refused to start: {exc}")
found = live_pool[kind]
if found is None:
if kind in live.required_kinds():
pytest.fail(f"CHEMENU_LIVE_REQUIRE names '{kind}' but no such tracker is configured")
pytest.skip(f"no live '{kind}' tracker configured (instructions/dev/tracker-testing.md)")
return found
def _project_page(root: Path, name: str) -> None:
write_page(
root / "kb" / "gtd" / "technik" / f"{name}.md",
{
"type": "types/project.md", "state": "active", "responsibility": "technik",
"created": "2026-01-01", "modified": "2026-01-01", "provenance": "general",
"summary": f"Live-test project {name}.",
},
f"\n# {name}\n\n## Ziel\n\nLive test.\n",
)
def _provisioner(target: LiveTarget):
"""Only a tracker the suite owns end to end gets its marker created for it."""
if target.kind != "caldav":
return None
def provision() -> None:
reader = live.reader_for(target.config)
from chemenu.tasks import caldav as cd
cfg = cd.CalDAVConfig.from_dict(target.config["caldav"])
writer = cd.CalDAVWriter(cfg, reader)
for name in (live.MARKER_PROJECT, cfg.inbox_list, cfg.someday_list):
if name not in {p.name for p in reader.projects()}:
writer.create_project(name)
return provision
def _open_titles(reader) -> dict[str, str]:
return {item.title: item.id for item in reader.open_items(live.MARKER_PROJECT).items}
@pytest.mark.live_tracker
def test_the_documented_workflow_runs_against_the_real_tracker(
target, kb_dir, tmp_path, monkeypatch, record_property, capsys,
):
root = kb_dir.parent
profile = tmp_path / "live-profile.json"
profile.write_text(json.dumps(target.config), encoding="utf-8")
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
with capsys.disabled():
print(f"\n[tracker-live] {target.label} - version {target.version} - run {live.RUN_ID}")
record_property("tracker_kind", target.kind)
record_property("tracker_version", target.version)
reader = live.reader_for(target.config)
live.guard(reader, provision=_provisioner(target))
_project_page(root, live.MARKER_PROJECT)
open_title, waiting_title = live.run_id_titles()
overdue = live.overdue_day(date.today())
try:
created = runner.invoke(app, ["task", "new", "--project", live.MARKER_PROJECT,
"--title", open_title])
assert created.exit_code == 0, created.output
created = runner.invoke(app, ["task", "new", "--project", live.MARKER_PROJECT,
"--title", waiting_title, "--waiting",
"--follow-up-at", overdue.isoformat()])
assert created.exit_code == 0, created.output
listing = runner.invoke(app, ["task", "list", "--project", live.MARKER_PROJECT])
assert listing.exit_code == 0, listing.output
assert open_title in listing.output and waiting_title in listing.output
titles = _open_titles(reader)
assert open_title in titles and waiting_title in titles
waiting = {w.title: w for w in reader.open_items(live.MARKER_PROJECT).waiting}
assert waiting_title in waiting and open_title not in waiting
assert waiting[waiting_title].follow_up_at == overdue
report = run_review(root, today=date.today())
assert any(
f.check == CHECK_WAITING_OVERDUE and f.item_id == titles[waiting_title]
for f in report.findings
), [f for f in report.findings]
for title in (open_title, waiting_title):
closed = runner.invoke(app, ["task", "close", "--id", titles[title]])
assert closed.exit_code == 0, closed.output
assert not {open_title, waiting_title} & set(_open_titles(reader))
finally:
for title, item_id in _open_titles(reader).items():
if title.startswith(live.ITEM_PREFIX):
runner.invoke(app, ["task", "close", "--id", item_id])
# --- the safety rules, against fakes -------------------------------------------------------
class _RecordingReader:
"""A tracker that knows the listed projects and counts every call that could write."""
def __init__(self, names: list[str]):
self._names = names
self.writes = 0
def projects(self):
return [ProjectSummary(name=n, created=None) for n in self._names]
def open_items(self, project_name):
return OpenItems(count=0, waiting=(), items=())
def someday_items(self) -> list[SomedayItem]:
return []
def source(self):
return ReadSource(kind="fake", detail="fake")
def test_guard_aborts_when_the_marker_project_is_missing_and_nothing_was_written():
reader = _RecordingReader(["Inbox", "Somebody's real project"])
with pytest.raises(LiveAbort, match="Chemenu Live-Test"):
live.guard(reader)
assert reader.writes == 0
def test_guard_accepts_the_marker_in_any_case_and_spacing():
live.guard(_RecordingReader([" chemenu LIVE-test "]))
def test_guard_provisions_only_when_a_provisioner_is_given():
names: list[str] = []
reader = _RecordingReader(names)
def provision():
names.append(live.MARKER_PROJECT)
live.guard(reader, provision=provision)
assert names == [live.MARKER_PROJECT]
with pytest.raises(LiveAbort):
live.guard(_RecordingReader([]))
@contextlib.contextmanager
def _answering_health_server():
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
body = json.dumps({"ok": True, "data": {"server": "up", "rendererReady": True}}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # noqa: D401
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.server_address[1]
finally:
server.shutdown()
thread.join(timeout=5)
def test_start_refuses_when_the_api_port_already_answers(tmp_path):
executable = tmp_path / "superproductivity"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with _answering_health_server() as port:
with pytest.raises(LiveAbort, match="already answers"):
sp_headless.start(executable, tmp_path / "profile", marker_project="x", port=port)
assert not (tmp_path / "profile").exists()
def test_a_profile_with_a_home_relative_path_is_refused(tmp_path, monkeypatch):
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
(tmp_path / live.PROFILE_DIR).mkdir()
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": live.THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": "~/.config/sp/backups"},
}))
with pytest.raises(LiveAbort, match="absolute"):
live.load_profile("mine")
@pytest.mark.parametrize("name", ["", "../escape", "a/b", ".hidden"])
def test_a_profile_name_cannot_leave_the_profile_directory(name):
with pytest.raises(LiveAbort, match="bare profile name"):
live.profile_path(name)
def test_a_profile_is_read_exactly_as_written(tmp_path, monkeypatch):
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
(tmp_path / live.PROFILE_DIR).mkdir()
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": live.THRESHOLDS,
"superproductivity": {"access": "snapshot", "backups_dir": str(tmp_path)},
"live_test_version": "19.1.0",
}))
target = live.load_profile("mine")
assert target.version == "19.1.0"
assert "live_test_version" not in target.config
def test_required_kinds_turn_a_missing_tracker_into_a_failure():
assert live.required_kinds({"CHEMENU_LIVE_REQUIRE": "caldav, sp"}) == {"caldav", "sp"}
assert live.required_kinds({}) == set()
assert live.build_target("caldav", {}) is None
assert live.build_target("sp", {}) is None
assert live.build_target("profile", {}) is None
def test_caldav_target_needs_both_credentials():
with pytest.raises(LiveAbort, match="USER"):
live.caldav_target_from_env({"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/"})
target = live.caldav_target_from_env({
"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/",
"CHEMENU_LIVE_CALDAV_USER": "u", "CHEMENU_LIVE_CALDAV_PASSWORD": "p",
})
assert target is not None and target.config["provider"] == "caldav"
def test_item_titles_carry_the_run_prefix():
for title in live.run_id_titles():
assert title.startswith(live.ITEM_PREFIX)
assert live.ITEM_PREFIX.startswith("[live-test ")
+206
View File
@@ -0,0 +1,206 @@
"""The live tracker suite's targets, guard and scenario (Gitea #156).
Not a test module - `test_tracker_live.py` runs it. The suite is the one place this
repo talks to a real tracker, so its safety rules live in code here rather than in a
comment:
1. Only the project named `MARKER_PROJECT` is ever written to. It must already exist;
this module never creates it in a tracker somebody else owns (`guard`).
2. Every item it creates carries `ITEM_PREFIX`, which names this run.
3. Nothing is ever deleted - the only closing write is `task close`.
4. Any violated precondition raises `LiveAbort` *before* the first write.
A target is one tracker the suite may run against. Three ways to name one, all through
the environment, none of them a fixed address:
- `CHEMENU_LIVE_SP_BINARY`: the packaged Super Productivity; the suite starts its own
seeded, headless copy (`sp_headless`).
- `CHEMENU_LIVE_CALDAV_URL` + `_USER` + `_PASSWORD`: a CalDAV server the suite may
provision the marker calendar in (Radicale in CI).
- `CHEMENU_LIVE_PROFILE`: a profile file `.wikitool-tasks.d/<name>.json` in the
checkout - a tracker of the user's own, used exactly as configured.
"""
from __future__ import annotations
import json
import os
import shutil
import tempfile
import uuid
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Any, Callable, Optional
from chemenu import tasks
from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import TaskReader, find_project, normalize_project_name
from chemenu.tests.sp_headless import LiveAbort
MARKER_PROJECT = "Chemenu Live-Test"
RUN_ID = uuid.uuid4().hex[:8]
ITEM_PREFIX = f"[live-test {RUN_ID}]"
PROFILE_DIR = ".wikitool-tasks.d"
KINDS = ("sp", "caldav", "profile")
THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
REPO_ROOT = Path(__file__).resolve().parents[3]
@dataclass
class LiveTarget:
kind: str
label: str
version: str
config: dict[str, Any]
cleanup: Callable[[], None] = field(default=lambda: None)
def required_kinds(environ: "os._Environ[str] | dict[str, str]" = os.environ) -> set[str]:
"""Kinds that must run rather than skip (`CHEMENU_LIVE_REQUIRE=caldav,sp`) - CI's way of
making a skipped live suite a red one."""
raw = environ.get("CHEMENU_LIVE_REQUIRE", "")
return {part.strip() for part in raw.split(",") if part.strip()}
def _reject_home_relative(value: Any, where: str) -> None:
if isinstance(value, str) and value.startswith("~"):
raise LiveAbort(
f"{where}: '{value}' starts with '~'. Profiles carry absolute paths only - a "
"`~` would resolve against whichever HOME the run happens to have."
)
if isinstance(value, dict):
for key, inner in value.items():
_reject_home_relative(inner, f"{where}.{key}")
if isinstance(value, list):
for index, inner in enumerate(value):
_reject_home_relative(inner, f"{where}[{index}]")
def profile_path(name: str) -> Path:
if not name or "/" in name or name.startswith("."):
raise LiveAbort(f"CHEMENU_LIVE_PROFILE must be a bare profile name, got {name!r}.")
return REPO_ROOT / PROFILE_DIR / f"{name}.json"
def load_profile(name: str) -> LiveTarget:
path = profile_path(name)
if not path.is_file():
raise LiveAbort(f"no profile file {path} - see instructions/dev/tracker-testing.md.")
data = json.loads(path.read_text(encoding="utf-8"))
_reject_home_relative(data, path.name)
return LiveTarget(
kind="profile",
label=f"profile {name}",
version=str(data.get("live_test_version", "unknown")),
config={key: value for key, value in data.items() if key != "live_test_version"},
)
def caldav_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
url = environ.get("CHEMENU_LIVE_CALDAV_URL")
if not url:
return None
user = environ.get("CHEMENU_LIVE_CALDAV_USER", "")
password = environ.get("CHEMENU_LIVE_CALDAV_PASSWORD", "")
if not user or not password:
raise LiveAbort("CHEMENU_LIVE_CALDAV_URL needs CHEMENU_LIVE_CALDAV_USER and _PASSWORD.")
return LiveTarget(
kind="caldav",
label="CalDAV " + url,
version=environ.get("CHEMENU_LIVE_CALDAV_VERSION", "unknown"),
config={
"schema": 1,
"provider": "caldav",
"thresholds": THRESHOLDS,
"caldav": {
"url": url, "username": user, "app_password": password,
"inbox_list": "Inbox", "someday_list": "Someday",
},
},
)
def sp_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
binary = environ.get("CHEMENU_LIVE_SP_BINARY")
if not binary:
return None
from chemenu.tests import sp_headless
executable = Path(binary)
workdir = Path(tempfile.mkdtemp(prefix="chemenu-live-sp-"))
process = sp_headless.start(executable, workdir / "profile", marker_project=MARKER_PROJECT)
def cleanup() -> None:
process.stop()
shutil.rmtree(workdir, ignore_errors=True)
return LiveTarget(
kind="sp",
label="Super Productivity (headless)",
version=sp_headless.installed_version(executable),
config={
"schema": 1,
"provider": "superproductivity",
"thresholds": THRESHOLDS,
"superproductivity": {
"access": "api", "api_base_url": process.base_url, "api_token": process.token,
},
},
cleanup=cleanup,
)
def build_target(kind: str, environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
if kind == "sp":
return sp_target_from_env(environ)
if kind == "caldav":
return caldav_target_from_env(environ)
if kind == "profile":
name = environ.get("CHEMENU_LIVE_PROFILE")
return load_profile(name) if name else None
raise ValueError(f"unknown live tracker kind {kind!r}")
# --- guard and scenario ------------------------------------------------------------------
def reader_for(target_config: dict[str, Any]) -> TaskReader:
"""The reader `target_config` describes, built the way the commands build it."""
provider = target_config["provider"]
cfg = tasks_config.TasksConfig(
provider=provider,
thresholds=tasks_config.Thresholds(**target_config["thresholds"]),
provider_config=target_config[provider],
)
return tasks.build_reader(cfg)
def guard(reader: TaskReader, *, provision: Optional[Callable[[], None]] = None) -> None:
"""Refuse before the first write unless the marker project exists.
`provision`, given only for a target the suite owns end to end (Radicale in CI), creates
the marker first; a profile target never gets one, so a missing marker there is an abort.
"""
if find_project(reader, MARKER_PROJECT) is None and provision is not None:
provision()
if find_project(reader, MARKER_PROJECT) is None:
raise LiveAbort(
f"the tracker has no project named '{MARKER_PROJECT}'. The live suite writes "
"only into that project and never creates it in a tracker it does not own - "
"create it there first (instructions/dev/tracker-testing.md)."
)
def run_id_titles() -> tuple[str, str]:
return f"{ITEM_PREFIX} offen", f"{ITEM_PREFIX} warte auf Antwort"
def overdue_day(today: date) -> date:
return today - timedelta(days=30)
__all__ = [
"ITEM_PREFIX", "KINDS", "LiveAbort", "LiveTarget", "MARKER_PROJECT", "THRESHOLDS",
"build_target", "guard", "normalize_project_name", "overdue_day", "reader_for",
"required_kinds", "run_id_titles",
]