Files
chemenu/tools/chemenu/tasks/protocol.py
T
torben 62d1c5e636
CI / verify (push) Successful in 54s
Release / release (push) Successful in 36s
task: Weekly review proposes task new/task close; tracker gains a closing write path
Files changed:
- CHANGES.md
- INSTALL.md
- README.md
- VERSION
- docs/knowledge-and-commitment.md
- instructions/gtd-weekly-review/SKILL.md
- instructions/ingest-large-tree.md
- instructions/wiki-ingest/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
2026-09-22 21:46:09 +02:00

271 lines
12 KiB
Python

"""The provider-agnostic read/write shape (Gitea #124, #119 D26/D31).
Every provider adapter under `chemenu.tasks` implements `TaskReader` and, if
it can, `TaskWriter` - two separate `Protocol`s rather than one, because #124's
own acceptance criteria requires exactly that: "ein Adapter kann den
Schreibpfad nicht anbieten, ohne dass der Lesepfad davon beruehrt wird". A
provider whose write path cannot exist (see `SuperProductivityWriter`) simply
does not implement `TaskWriter` - nothing here forces it to.
The read shape is fixed by what the weekly review (#119 D26, built in #125)
and `task list`/`task close` (#138) need and nothing more: which projects
exist and when they were created, every open item each one has - id, title,
and whether it is `WAITING` with a `follow_up_at` (#119 D9/D30 - the *only*
two machine-readable parts of a 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`) never sees a value this process
cached from before that step.
An item's own id (#138) is read-only data, like everything else here - it is
never stored by `wikitool`, only ever passed straight back into
`TaskWriter.close_item` within the same invocation. That keeps the "one name
is the only coupling" decision (`docs/knowledge-and-commitment.md` § "One
name, carrying the duties of an identifier") intact: no id-to-anything
mapping is ever written down, so there is nothing to keep in sync.
**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
from dataclasses import dataclass
from datetime import date
from typing import Optional, Protocol, Sequence
@dataclass(frozen=True)
class ProjectSummary:
"""One tracker project, as the review needs it: its name (the sole join
key with a `kb/gtd/` page, #119 D8) and when it was created."""
name: str
created: Optional[date]
@dataclass(frozen=True)
class WaitingItem:
"""One open item carrying the `WAITING` status (#119 D9/D30).
`id` is the provider's own item id (#138) - read-only, never guessed,
passed straight into `TaskWriter.close_item` when the review's
`waiting_overdue` (b) is confirmed. `title` is shown verbatim, person and
all - the review never parses it. `follow_up_at` is the one
machine-readable date, and it is deliberately **not** the item's due date
(#119 D9: "ausdruecklich nicht das 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.
"""
id: str
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 OpenItem:
"""One open item as `task list` (#138) needs it - id, title, and whether
it carries the `WAITING` status. Deliberately thinner than `WaitingItem`
(no `follow_up_at`): a waiting item still appears here, just without the
one field only the waiting-overdue check reads."""
id: str
title: str
waiting: bool
@dataclass(frozen=True)
class OpenItems:
"""A project's momentary open-loop count, the subset that is `WAITING`,
and the full list `task list` prints. `count` includes the waiting items
- it is "how many open items", not "how many open items that aren't
waiting". `items` and `waiting` overlap by design: a `WaitingItem` is
also present in `items`, since `task list` shows every open item
regardless of status."""
count: int
waiting: Sequence[WaitingItem]
items: Sequence[OpenItem]
@dataclass(frozen=True)
class SomedayItem:
"""One someday/maybe item: its id, title and when it last changed. `id`
is the provider's own item id (#138), passed into `TaskWriter.close_item`
when the review's `someday_stale` (b) is confirmed. `modified` feeds
check 5's staleness read (#119 D26)."""
id: str
title: str
modified: Optional[date]
def normalize_project_name(name: str) -> str:
"""The case- and whitespace-normalized form of a project name (#119 D8):
collapse internal whitespace, then casefold. Used both ways round - to
preflight a name against the read path before a create, and to check
whether a human's out-of-band creation (`HumanInterventionRequired.verify`)
actually landed - so the same normalization must decide both, or a name
that passes one check could fail the other."""
return " ".join(name.strip().split()).casefold()
class TaskReader(Protocol):
"""The read path every provider adapter must implement."""
def projects(self) -> list[ProjectSummary]:
"""Every project the tracker currently knows, in no particular
order."""
...
def open_items(self, project_name: str) -> OpenItems:
"""Open items for the project named `project_name` (matched
case-normalized, #119 D8). A project the tracker does not know
returns `OpenItems(count=0, waiting=(), items=())` - "no open items"
and "no such project" are not distinguished here, because check 3
(#119 D26) is what tells those apart, over the read path's
`projects()` list."""
...
def someday_items(self) -> list[SomedayItem]:
"""Every someday/maybe item the tracker currently holds, across all
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
(#124's own acceptance criteria: offering this must not touch the read
path's availability)."""
def create_project(self, name: str) -> None:
"""Create a tracker project named `name`, after checking `name` is
not already taken (case-normalized, #119 D8) via the read path.
Raises `chemenu.errors.ValidationError` if the name collides, or if
the provider is reachable but refuses for a reason a human cannot fix
by way of `chemenu.errors.HumanInterventionRequired` (e.g. the
provider app is simply not running). Raises
`chemenu.errors.HumanInterventionRequired` if this provider has no way
to create a project itself and a human must do it out of band - see
that class's docstring for the full contract, including `verify()`.
"""
...
def create_item(
self,
title: str,
*,
project_name: Optional[str],
waiting: bool = False,
follow_up_at: Optional[date] = None,
notes: Optional[str] = None,
) -> None:
"""Create one open item - a tracker `Posten`, never a kb/ page
(Gitea #132 D1). `title` is stored verbatim, exactly like
`WaitingItem.title` - never parsed.
`project_name=None` is the caller's own explicit choice of the
tracker's inbox (#132 D4 "Weg 3"), never a stand-in for "no project
was given" - the CLI's own `--inbox` flag is the only thing allowed
to produce it; an omitted `--project` is refused before this is ever
called. A `project_name` that is given must already exist
(case-normalized, #119 D8) - this never creates a project itself and
never searches or guesses one (#132 D6): `chemenu.errors.ValidationError`
if no such project exists.
`waiting`/`follow_up_at` set #119's own WAITING/`follow_up_at` pair
(D9/D30) - the same two machine-readable parts `WaitingItem` reads
back. Raises `ValidationError` if the provider can represent items at
all (it offers `TaskWriter`) but has no way to mark one WAITING right
now - e.g. Super Productivity's `waiting` tag does not exist yet and
tags cannot be created via its API (#132's own verified constraint):
an item is never created *without* the status it was asked for.
`notes` carries D5's freetext backref to a kb/ page - stored
verbatim, never parsed, exactly the posture `WaitingItem.title`
already has for the person named in it.
Unlike `create_project`, this never raises
`chemenu.errors.HumanInterventionRequired`: every provider offering
`TaskWriter` at all has been verified to have a real item-creation
call (#132 - the gap `create_project` hits, no project-creation
endpoint, does not exist on the item side).
"""
...
def close_item(self, item_id: str) -> None:
"""Mark the item `item_id` done - never delete it (Gitea #138). This
is the only closing write this stack ever makes: no "remove", no
"move the reminder forward". `item_id` is the provider's own id
(`WaitingItem.id`/`SomedayItem.id`/`OpenItem.id`), read fresh
immediately before the call and never guessed or looked up by title -
the tracker-side identity is opaque and provider-defined, unlike the
project name (#119 D8), which is why this takes an id rather than a
title the way `create_item` takes a project name.
Raises `chemenu.errors.ValidationError` if no item with this id
exists right now - nothing is written. Like `create_item`, never
raises `chemenu.errors.HumanInterventionRequired`: every provider
offering `TaskWriter` has a real per-item write call, the same gap
`create_project` alone hits.
"""
...
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
"""The project matching `name` case-normalized (#119 D8), or `None`.
Shared by a `TaskWriter`'s preflight collision check and by a
`HumanInterventionRequired.verify()` closure - both are the same
question, "does a project by this name exist right now", asked at two
different moments."""
target = normalize_project_name(name)
for project in reader.projects():
if normalize_project_name(project.name) == target:
return project
return None