tasks: CalDAV provider (Nextcloud Tasks/iOS), review reports unknown values; bump stops pointing at release (#139)
Files changed: - CHANGES.md - INSTALL.md - VERSION - instructions/dev/version-parts.md - instructions/gtd-weekly-review/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/review.py - tools/chemenu/tasks/__init__.py - tools/chemenu/tasks/caldav.py - tools/chemenu/tasks/config.py - tools/chemenu/tests/test_caldav.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_review.py - tools/requirements.txt
This commit is contained in:
1 parent
4446424e01
commit
6d53c55d0d
16 files changed
+1912
-14
No files matched your search
+3
-3
@@ -129,7 +129,7 @@ tools/wikitool <command> --help
|
||||
|---------|---------|
|
||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). Thresholds come from `.wikitool-tasks.json`, never from the schema. 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 `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at` at all, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip of waiting-overdue/unpaged-project for that item or project. Thresholds come from `.wikitool-tasks.json`, never from the schema. 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 `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
|
||||
### Provenance
|
||||
|
||||
@@ -219,7 +219,7 @@ tools/wikitool <command> --help
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also its configured `access` path's own state - `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` reports whether a backup file is ready; the *other* access path is never attempted and is not a finding - and neither ever `FAIL`s, an app that is simply not running is not a fault), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
||||
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also its configured `access` path's own state - `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` reports whether a backup file is ready; the *other* access path is never attempted and is not a finding - and neither ever `FAIL`s, an app that is simply not running is not a fault; for a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - also never a `FAIL`, only a broken config block is), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
||||
|
||||
## Design notes
|
||||
|
||||
@@ -313,7 +313,7 @@ is atomic, and whether a retry is safe.
|
||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||
|---------|--------------|---------|--------------|
|
||||
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
|
||||
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above: the provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it |
|
||||
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively - for `caldav` this is checked against every list in the account, not only the ones counted as projects), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above, and is `superproductivity`-only: that provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it. `caldav` never produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a valid, non-colliding name always creates the list itself |
|
||||
| `task new` | No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching no tracker project, `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist), or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call, made only once every precondition (the project's own id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a half-written item behind | Not transient; fix the argument, create the missing tracker project or tag first, or point at an `access: "api"` instance, then retry once. **Never exit 42** - unlike `new project`, every provider offering a write path at all has a real item-creation call, so there is no human-clearance step to wait on here |
|
||||
| `task list` | No `.wikitool-tasks.json` | Yes - read-only, nothing to leave half-written | Not transient; configure a tracker first, then retry once. A `--project` matching no tracker project is not an error here - see its Commands row |
|
||||
| `task close` | No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call; an unknown id is rejected by the provider itself (Super Productivity: `404 TASK_NOT_FOUND`) before anything is written | Not transient; fix the id (re-run `task list` or `review` to get a current one) or point at an `access: "api"` instance, then retry once. **Never exit 42**, same reasoning as `task new` |
|
||||
|
||||
@@ -430,7 +430,9 @@ def check_tasks_provider() -> Check:
|
||||
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.
|
||||
finding, since this instance never touches it. `caldav` (Gitea #139) has
|
||||
only one access mode - it always reports reachability and authentication
|
||||
against the server, the same never-FAIL posture.
|
||||
"""
|
||||
from chemenu import config
|
||||
from chemenu.errors import ValidationError
|
||||
@@ -477,6 +479,19 @@ def check_tasks_provider() -> Check:
|
||||
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
|
||||
)
|
||||
|
||||
if cfg.provider == "caldav":
|
||||
from chemenu.tasks import caldav as cd
|
||||
|
||||
try:
|
||||
cd_cfg = cd.CalDAVConfig.from_dict(cfg.provider_config)
|
||||
except ValidationError as exc:
|
||||
return Check(
|
||||
"tasks-provider", "FAIL", str(exc),
|
||||
f"Fix the 'caldav' section of {config.TASKS_CONFIG_FILENAME}",
|
||||
)
|
||||
state = cd.probe(cd_cfg)
|
||||
return Check("tasks-provider", "OK", f"caldav: {state}")
|
||||
|
||||
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
|
||||
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ def bump_command(
|
||||
success(
|
||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
||||
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
|
||||
f"publishing, and `version release` once the candidate is ready to ship."
|
||||
f"publishing."
|
||||
)
|
||||
|
||||
|
||||
|
||||
+31
-2
@@ -35,6 +35,17 @@ CHECK_UNPAGED_PROJECT = "unpaged_project"
|
||||
CHECK_NO_OPEN_LOOP = "no_open_loop"
|
||||
CHECK_SOMEDAY_STALE = "someday_stale"
|
||||
|
||||
# D3 (Gitea #139): a value a provider could not supply is reported as its own
|
||||
# finding, under checks 2 and 3 respectively, rather than silently skipped -
|
||||
# a real state both `superproductivity` (a task with neither `dueWithTime`
|
||||
# nor `dueDay`) and `caldav` (no server in the account returns
|
||||
# `DAV:creationdate`, so `created` falls back to the earliest item's own
|
||||
# `CREATED`, `None` for an empty project) can produce. Not separate entries
|
||||
# in `ALL_CHECKS`: each fires from within check 2's/check 3's own loop, so
|
||||
# the five-checks-run count is unaffected.
|
||||
CHECK_WAITING_NO_FOLLOWUP = "waiting_no_follow_up"
|
||||
CHECK_PROJECT_AGE_UNKNOWN = "project_age_unknown"
|
||||
|
||||
# The checks that need the full tracker project list (#119 D26 checks 1/2/3/4)
|
||||
# - `projects()` failing skips all four together, since none of them can be
|
||||
# answered from `someday_items()` alone.
|
||||
@@ -187,10 +198,19 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
checks_run.append(CHECK_STALLED)
|
||||
|
||||
# Check 2 - waiting-for overdue: any WAITING item whose follow_up_at
|
||||
# is older than the threshold (#119 D9/D30 - never the due date).
|
||||
# is older than the threshold (#119 D9/D30 - never the due date). A
|
||||
# WAITING item with no follow_up_at at all is its own finding (D3),
|
||||
# not a silent skip - the provider genuinely has nothing to judge
|
||||
# staleness against.
|
||||
for _key, (name, items) in open_items_by_key.items():
|
||||
for item in items.waiting:
|
||||
if item.follow_up_at is None:
|
||||
findings.append(Finding(
|
||||
CHECK_WAITING_NO_FOLLOWUP, name,
|
||||
f"'{item.title}' is WAITING but has no follow_up_at - cannot judge "
|
||||
"whether it is overdue.",
|
||||
item_id=item.id,
|
||||
))
|
||||
continue
|
||||
age_days = (today - item.follow_up_at).days
|
||||
if age_days > cfg.thresholds.stalled_waiting_days:
|
||||
@@ -204,9 +224,18 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
||||
|
||||
# Check 3 - tracker project with no kb/ page, older than the
|
||||
# threshold (#119 D26's noise brake: an age threshold, not a marker).
|
||||
# A project with no determinable creation date is its own finding
|
||||
# (D3), not a silent skip - check 3 cannot judge its age either way.
|
||||
for project in tracker_projects:
|
||||
key = normalize_project_name(project.name)
|
||||
if key in kb_projects or project.created is None:
|
||||
if key in kb_projects:
|
||||
continue
|
||||
if project.created is None:
|
||||
findings.append(Finding(
|
||||
CHECK_PROJECT_AGE_UNKNOWN, project.name,
|
||||
"No creation date available for this tracker project - cannot judge "
|
||||
"whether it needs a kb/ page yet.",
|
||||
))
|
||||
continue
|
||||
age_weeks = _weeks_between(project.created, today)
|
||||
if age_weeks > cfg.thresholds.unpaged_project_weeks:
|
||||
|
||||
@@ -35,6 +35,11 @@ def build_reader(cfg: TasksConfig) -> TaskReader:
|
||||
if sp_cfg.access == sp.ACCESS_API:
|
||||
return sp.SuperProductivityApiReader(sp_cfg)
|
||||
return sp.SuperProductivityReader(sp_cfg)
|
||||
if cfg.provider == "caldav":
|
||||
from chemenu.tasks import caldav as cd
|
||||
|
||||
cd_cfg = cd.CalDAVConfig.from_dict(cfg.provider_config)
|
||||
return cd.CalDAVReader(cd_cfg)
|
||||
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
|
||||
|
||||
|
||||
@@ -61,4 +66,9 @@ def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
|
||||
"instance (Gitea #133)."
|
||||
)
|
||||
return sp.SuperProductivityWriter(sp_cfg, reader)
|
||||
if cfg.provider == "caldav":
|
||||
from chemenu.tasks import caldav as cd
|
||||
|
||||
cd_cfg = cd.CalDAVConfig.from_dict(cfg.provider_config)
|
||||
return cd.CalDAVWriter(cd_cfg, reader)
|
||||
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
|
||||
@@ -0,0 +1,708 @@
|
||||
"""The CalDAV adapter (Gitea #139; #128's own six rules for a second adapter,
|
||||
#119 D1/D2/D8/D9/D30).
|
||||
|
||||
A standards-based second provider (RFC 4791/5545) so a private instance is
|
||||
not bound to Super Productivity - built against Nextcloud Tasks with iOS
|
||||
*Erinnerungen* as the mobile client, verified against a real account
|
||||
2026-09-25 (see #139's own test-run table). Unlike `superproductivity`
|
||||
(`chemenu.tasks.superproductivity`), there is only one access mode: CalDAV is
|
||||
always a live network call, so `source()` always answers `kind: "api"` and
|
||||
there is no `access` field to choose - the config section only ever needs one
|
||||
shape.
|
||||
|
||||
## Cut: what iOS is for, and what it is not (#139, Betreiber 2026-09-23)
|
||||
|
||||
**Maintained at the desk, checked off on the phone.** iOS's own weaknesses for
|
||||
a CalDAV list - no tags/categories editing, no subtasks, no flags, only one
|
||||
date - only affect *maintaining* an item, never marking it done, which is the
|
||||
only write this adapter ever asks a human to do through the phone.
|
||||
|
||||
## Mapping (#139's own table, fixed conventions rather than config variants)
|
||||
|
||||
- **Project** (`projects()`): a calendar collection whose
|
||||
`supported-calendar-component-set` is *exactly* `{VTODO}`, `displayname` =
|
||||
project name (D6, Variante A - one list per project, no nesting: RFC 4791
|
||||
forbids nested calendar collections and iOS list groups only exist for
|
||||
iCloud).
|
||||
- **Inbox**/**Someday**: two further fixed VTODO-only lists, named by
|
||||
`inbox_list`/`someday_list` in config - never counted as projects.
|
||||
- **Open item**: `STATUS` is neither `COMPLETED` nor `CANCELLED`.
|
||||
- **`WAITING`** (#119 D9/D30): `CATEGORIES` contains `waiting`
|
||||
case-insensitively - verified to survive every iOS action in the test run.
|
||||
- **`follow_up_at`**: `DTSTART`, *never* `DUE` (#119 D9, #135's meaning-not-name
|
||||
rule) - verified as exactly what iOS's own one settable date writes.
|
||||
- **`ProjectSummary.created`**: no CalDAV server in the test account returns
|
||||
`DAV:creationdate` on a calendar collection, so this is always the
|
||||
substitute value - the earliest `CREATED` among the project's own items, or
|
||||
`None` for an empty list. A `None` here is not silently skipped by
|
||||
`chemenu.review`'s check 3 (#139 D3) - it is reported as its own finding.
|
||||
- **`create_project`**: `MKCALENDAR`, automatic, no `HumanInterventionRequired`
|
||||
(unlike Super Productivity, the CalDAV protocol has a real
|
||||
collection-creation verb) - preflighted against *every* list in the
|
||||
account (D5), not only the ones counted as projects, so a name collision
|
||||
with an excluded or mixed list is still caught before it produces two
|
||||
same-named lists on the phone.
|
||||
- **`close_item`**: reads the current resource, changes only `STATUS`,
|
||||
`COMPLETED`, `PERCENT-COMPLETE`, `LAST-MODIFIED`, `DTSTAMP`, and writes it
|
||||
back with `If-Match: <etag>` - every other property (an unknown `X-`
|
||||
property, an alarm, `DTSTART`) survives untouched, and a changed resource
|
||||
(412) writes nothing and raises rather than retrying.
|
||||
- Nothing is ever deleted - no `DELETE` call exists in this module.
|
||||
|
||||
## Addresses only ever come from the server (#139's own acceptance criterion)
|
||||
|
||||
The configured `url` may be an alias - Nextcloud accepts a short username in
|
||||
the URL but answers with the canonical user id in every `href` it returns.
|
||||
So every address this module ever calls back into - a calendar's own href, an
|
||||
item's own href - is the exact `href` a `PROPFIND`/`REPORT` response handed
|
||||
back, resolved against the *request* URL via `urllib.parse.urljoin`, never
|
||||
built by hand from the configured `url` and a name.
|
||||
|
||||
## Existing lists in the account are ignored, not adopted (D5)
|
||||
|
||||
`exclude_lists` names pre-existing VTODO-only lists that are not projects: a
|
||||
project calendar is further required to carry *only* VTODO (D6's table) so a
|
||||
mixed calendar (VEVENT alongside VTODO) is never mistaken for one, with no
|
||||
config needed for that case. If a new project name collides with an ignored
|
||||
list, `create_project` refuses (see above) - the operator renames the
|
||||
existing list in Nextcloud by hand; this module never does that automatically.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
from xml.sax.saxutils import escape as _xml_escape
|
||||
|
||||
import icalendar
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.tasks.protocol import (
|
||||
OpenItem,
|
||||
OpenItems,
|
||||
ProjectSummary,
|
||||
ReadSource,
|
||||
SomedayItem,
|
||||
WaitingItem,
|
||||
normalize_project_name,
|
||||
)
|
||||
|
||||
NS_D = "DAV:"
|
||||
NS_C = "urn:ietf:params:xml:ns:caldav"
|
||||
|
||||
# The category that means "WAITING" (#119 D9/D30), matched case-insensitively -
|
||||
# this instance's own convention, not something CalDAV/RFC 5545 defines.
|
||||
WAITING_CATEGORY = "waiting"
|
||||
|
||||
SOURCE_KIND = "api"
|
||||
|
||||
_EXPECTED_CONFIG = (
|
||||
'{"url": "https://<host>/remote.php/dav/calendars/<user>/", "username": "<login>", '
|
||||
'"app_password": "...", "inbox_list": "Inbox", "someday_list": "Someday", '
|
||||
'"exclude_lists": ["..."]} - see INSTALL.md'
|
||||
)
|
||||
|
||||
_REQUIRED_STRING_FIELDS = ("url", "username", "app_password", "inbox_list", "someday_list")
|
||||
_KNOWN_FIELDS = frozenset({*_REQUIRED_STRING_FIELDS, "exclude_lists"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalDAVConfig:
|
||||
"""This provider's own section of `.wikitool-tasks.json`
|
||||
(`TasksConfig.provider_config`). `url` is normalized to always end in
|
||||
`/` so every later `urljoin` treats it as a directory, never a document -
|
||||
a bare `urljoin` on a slash-less base silently drops its last path
|
||||
segment."""
|
||||
|
||||
url: str
|
||||
username: str
|
||||
app_password: str
|
||||
inbox_list: str
|
||||
someday_list: str
|
||||
exclude_lists: tuple[str, ...]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "CalDAVConfig":
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(f"caldav config must be an object. Expected: {_EXPECTED_CONFIG}")
|
||||
|
||||
missing = [
|
||||
key for key in _REQUIRED_STRING_FIELDS
|
||||
if not isinstance(data.get(key), str) or not data[key]
|
||||
]
|
||||
if missing:
|
||||
raise ValidationError(
|
||||
f"caldav config is missing or has an empty {', '.join(missing)}. "
|
||||
f"Expected: {_EXPECTED_CONFIG}"
|
||||
)
|
||||
|
||||
extra = sorted(set(data) - _KNOWN_FIELDS)
|
||||
if extra:
|
||||
raise ValidationError(
|
||||
f"caldav config does not take {extra} - unexpected field(s). Expected: {_EXPECTED_CONFIG}"
|
||||
)
|
||||
|
||||
exclude_lists = data.get("exclude_lists", [])
|
||||
if not isinstance(exclude_lists, list) or not all(isinstance(x, str) for x in exclude_lists):
|
||||
raise ValidationError(
|
||||
f"caldav config: 'exclude_lists' must be a list of strings if given. "
|
||||
f"Expected: {_EXPECTED_CONFIG}"
|
||||
)
|
||||
|
||||
url = data["url"] if data["url"].endswith("/") else data["url"] + "/"
|
||||
return cls(
|
||||
url=url,
|
||||
username=data["username"],
|
||||
app_password=data["app_password"],
|
||||
inbox_list=data["inbox_list"],
|
||||
someday_list=data["someday_list"],
|
||||
exclude_lists=tuple(exclude_lists),
|
||||
)
|
||||
|
||||
|
||||
# --- the one HTTP/WebDAV transport ---------------------------------------------
|
||||
|
||||
class _DavClient:
|
||||
"""The one HTTP transport every read/write below goes through - a thin,
|
||||
loudly-failing wrapper over `urllib`, not a general WebDAV client. HTTP
|
||||
error responses (4xx/5xx) are returned, not raised: different callers
|
||||
need to branch on the exact status (a 404 on `GET` means "no such item",
|
||||
a 412 on `PUT` means "changed since read", both are ordinary outcomes a
|
||||
caller decides about) - only a transport failure (unreachable host, DNS,
|
||||
timeout) raises, since no caller can do anything useful with that beyond
|
||||
reporting it."""
|
||||
|
||||
def __init__(self, cfg: CalDAVConfig):
|
||||
self._cfg = cfg
|
||||
credentials = f"{cfg.username}:{cfg.app_password}".encode("utf-8")
|
||||
self._auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
|
||||
|
||||
def propfind(self, url: str, *, depth: int, body: bytes) -> tuple[int, dict[str, str], bytes]:
|
||||
return self._request(
|
||||
"PROPFIND", url, body=body, depth=depth,
|
||||
extra_headers={"Content-Type": 'application/xml; charset="utf-8"'},
|
||||
)
|
||||
|
||||
def report(self, url: str, *, depth: int, body: bytes) -> tuple[int, dict[str, str], bytes]:
|
||||
return self._request(
|
||||
"REPORT", url, body=body, depth=depth,
|
||||
extra_headers={"Content-Type": 'application/xml; charset="utf-8"'},
|
||||
)
|
||||
|
||||
def mkcalendar(self, url: str, body: bytes) -> tuple[int, dict[str, str], bytes]:
|
||||
return self._request(
|
||||
"MKCALENDAR", url, body=body,
|
||||
extra_headers={"Content-Type": 'application/xml; charset="utf-8"'},
|
||||
)
|
||||
|
||||
def put(
|
||||
self, url: str, body: bytes, *, if_none_match: Optional[str] = None,
|
||||
if_match: Optional[str] = None,
|
||||
) -> tuple[int, dict[str, str], bytes]:
|
||||
headers = {"Content-Type": 'text/calendar; charset="utf-8"'}
|
||||
if if_none_match is not None:
|
||||
headers["If-None-Match"] = if_none_match
|
||||
if if_match is not None:
|
||||
headers["If-Match"] = if_match
|
||||
return self._request("PUT", url, body=body, extra_headers=headers)
|
||||
|
||||
def get(self, url: str) -> tuple[int, dict[str, str], bytes]:
|
||||
return self._request("GET", url)
|
||||
|
||||
def _request(
|
||||
self, method: str, url: str, *, body: Optional[bytes] = None,
|
||||
depth: Optional[int] = None, extra_headers: Optional[dict[str, str]] = None,
|
||||
timeout: float = 15.0,
|
||||
) -> tuple[int, dict[str, str], bytes]:
|
||||
headers = {"Authorization": self._auth_header}
|
||||
if depth is not None:
|
||||
headers["Depth"] = str(depth)
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
return response.status, dict(response.headers), response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, dict(exc.headers or {}), exc.read()
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise ValidationError(
|
||||
f"caldav: {method} {url} failed - server not reachable ({exc})."
|
||||
) from exc
|
||||
|
||||
|
||||
# --- multistatus parsing ---------------------------------------------------------
|
||||
|
||||
def _multistatus_responses(body: bytes) -> list[ET.Element]:
|
||||
"""Every `<D:response>` in a WebDAV multistatus body, or a loud refusal -
|
||||
#139's own acceptance criterion: a reply this module does not recognize
|
||||
must fail loudly, never read as an empty result."""
|
||||
try:
|
||||
root = ET.fromstring(body)
|
||||
except ET.ParseError as exc:
|
||||
raise ValidationError(f"caldav: server response is not valid XML ({exc}).") from exc
|
||||
if root.tag != f"{{{NS_D}}}multistatus":
|
||||
raise ValidationError(
|
||||
f"caldav: expected a DAV multistatus response, got <{root.tag}> instead."
|
||||
)
|
||||
return root.findall(f"{{{NS_D}}}response")
|
||||
|
||||
|
||||
def _prop_ok(response_el: ET.Element) -> Optional[ET.Element]:
|
||||
"""The `<D:prop>` of the first `2xx` `<D:propstat>` in one response
|
||||
element, or `None` if every propstat failed."""
|
||||
for propstat in response_el.findall(f"{{{NS_D}}}propstat"):
|
||||
status_el = propstat.find(f"{{{NS_D}}}status")
|
||||
if status_el is None or not status_el.text:
|
||||
continue
|
||||
parts = status_el.text.split(" ")
|
||||
if len(parts) >= 2 and parts[1].startswith("2"):
|
||||
return propstat.find(f"{{{NS_D}}}prop")
|
||||
return None
|
||||
|
||||
|
||||
# --- listing calendars -----------------------------------------------------------
|
||||
|
||||
_PROPFIND_CALENDARS_BODY = b"""<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:displayname/>
|
||||
<C:supported-calendar-component-set/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
"""
|
||||
|
||||
_PROPFIND_SELF_BODY = b"""<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CalendarInfo:
|
||||
href: str
|
||||
displayname: str
|
||||
components: frozenset
|
||||
|
||||
|
||||
def _list_calendars(client: _DavClient, base_url: str) -> list[_CalendarInfo]:
|
||||
"""Every calendar collection directly under `base_url` (Depth: 1) - the
|
||||
account's full list, unfiltered. Callers decide which of these count as
|
||||
a "project" (see `_is_project_calendar`); `create_project`'s own
|
||||
collision preflight (D5) deliberately calls this instead of the filtered
|
||||
form, since a collision with an *excluded* list must be caught too."""
|
||||
status, _headers, body = client.propfind(base_url, depth=1, body=_PROPFIND_CALENDARS_BODY)
|
||||
if status != 207:
|
||||
raise ValidationError(
|
||||
f"caldav: listing calendars under {base_url} failed - expected HTTP 207 "
|
||||
f"Multi-Status, got {status}."
|
||||
)
|
||||
result: list[_CalendarInfo] = []
|
||||
for response_el in _multistatus_responses(body):
|
||||
href_el = response_el.find(f"{{{NS_D}}}href")
|
||||
if href_el is None or not href_el.text:
|
||||
continue
|
||||
prop = _prop_ok(response_el)
|
||||
if prop is None:
|
||||
continue
|
||||
resourcetype = prop.find(f"{{{NS_D}}}resourcetype")
|
||||
if resourcetype is None:
|
||||
continue
|
||||
type_tags = {child.tag for child in resourcetype}
|
||||
if f"{{{NS_C}}}calendar" not in type_tags:
|
||||
continue # not a calendar collection - e.g. the home-set itself
|
||||
displayname_el = prop.find(f"{{{NS_D}}}displayname")
|
||||
displayname = (displayname_el.text or "") if displayname_el is not None else ""
|
||||
comp_set_el = prop.find(f"{{{NS_C}}}supported-calendar-component-set")
|
||||
components = frozenset(
|
||||
(comp.get("name") or "").upper()
|
||||
for comp in comp_set_el
|
||||
if comp.tag == f"{{{NS_C}}}comp" and comp.get("name")
|
||||
) if comp_set_el is not None else frozenset()
|
||||
href = urljoin(base_url, href_el.text)
|
||||
result.append(_CalendarInfo(href=href, displayname=displayname, components=components))
|
||||
return result
|
||||
|
||||
|
||||
def _is_project_calendar(cal: _CalendarInfo, cfg: CalDAVConfig) -> bool:
|
||||
"""D6's own filter: VTODO-only (never VEVENT-mixed), and not one of the
|
||||
two fixed lists or an `exclude_lists` entry (D5), matched case-normalized
|
||||
the same way a project name is (#119 D8)."""
|
||||
if cal.components != frozenset({"VTODO"}):
|
||||
return False
|
||||
excluded = {
|
||||
normalize_project_name(name)
|
||||
for name in (cfg.inbox_list, cfg.someday_list, *cfg.exclude_lists)
|
||||
}
|
||||
return normalize_project_name(cal.displayname) not in excluded
|
||||
|
||||
|
||||
def _list_project_calendars(client: _DavClient, cfg: CalDAVConfig) -> list[_CalendarInfo]:
|
||||
return [cal for cal in _list_calendars(client, cfg.url) if _is_project_calendar(cal, cfg)]
|
||||
|
||||
|
||||
def _find_calendar(client: _DavClient, cfg: CalDAVConfig, name: str) -> Optional[_CalendarInfo]:
|
||||
"""Any calendar in the account matching `name` case-normalized -
|
||||
unfiltered, so this also finds the fixed inbox/someday lists."""
|
||||
target = normalize_project_name(name)
|
||||
for cal in _list_calendars(client, cfg.url):
|
||||
if normalize_project_name(cal.displayname) == target:
|
||||
return cal
|
||||
return None
|
||||
|
||||
|
||||
# --- reading VTODO resources -------------------------------------------------------
|
||||
|
||||
_REPORT_VTODOS_BODY = b"""<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VTODO"/>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _VTodoResource:
|
||||
href: str
|
||||
etag: Optional[str]
|
||||
todo: Any # icalendar.Todo
|
||||
|
||||
|
||||
def _fetch_vtodos(client: _DavClient, calendar_href: str) -> list[_VTodoResource]:
|
||||
"""Every VTODO resource in one calendar collection, open and closed alike
|
||||
- status filtering happens in Python (`_is_open`), not via a server-side
|
||||
filter, so this reads the same way regardless of which CalDAV server is
|
||||
behind `calendar_href`. A resource whose `calendar-data` does not parse
|
||||
as a VTODO fails loudly (#139's own acceptance criterion), never gets
|
||||
silently dropped."""
|
||||
status, _headers, body = client.report(calendar_href, depth=1, body=_REPORT_VTODOS_BODY)
|
||||
if status != 207:
|
||||
raise ValidationError(
|
||||
f"caldav: reading items from {calendar_href} failed - expected HTTP 207 "
|
||||
f"Multi-Status, got {status}."
|
||||
)
|
||||
result: list[_VTodoResource] = []
|
||||
for response_el in _multistatus_responses(body):
|
||||
href_el = response_el.find(f"{{{NS_D}}}href")
|
||||
if href_el is None or not href_el.text:
|
||||
continue
|
||||
prop = _prop_ok(response_el)
|
||||
if prop is None:
|
||||
continue
|
||||
data_el = prop.find(f"{{{NS_C}}}calendar-data")
|
||||
if data_el is None or not data_el.text:
|
||||
continue
|
||||
etag_el = prop.find(f"{{{NS_D}}}getetag")
|
||||
etag = etag_el.text if etag_el is not None else None
|
||||
try:
|
||||
calendar = icalendar.Calendar.from_ical(data_el.text)
|
||||
except (ValueError, IndexError) as exc:
|
||||
raise ValidationError(
|
||||
f"caldav: {href_el.text} under {calendar_href} is not a parsable VTODO ({exc})."
|
||||
) from exc
|
||||
todos = calendar.walk("VTODO")
|
||||
if not todos:
|
||||
raise ValidationError(
|
||||
f"caldav: {href_el.text} under {calendar_href} has no VTODO component."
|
||||
)
|
||||
href = urljoin(calendar_href, href_el.text)
|
||||
result.append(_VTodoResource(href=href, etag=etag, todo=todos[0]))
|
||||
return result
|
||||
|
||||
|
||||
def _prop_date(todo: Any, name: str) -> Optional[date]:
|
||||
prop = todo.get(name)
|
||||
if prop is None:
|
||||
return None
|
||||
value = getattr(prop, "dt", None)
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _summary(todo: Any) -> str:
|
||||
return str(todo.get("SUMMARY", ""))
|
||||
|
||||
|
||||
def _is_waiting(todo: Any) -> bool:
|
||||
prop = todo.get("CATEGORIES")
|
||||
if prop is None:
|
||||
return False
|
||||
cats = getattr(prop, "cats", None) or []
|
||||
return any(str(cat).strip().casefold() == WAITING_CATEGORY for cat in cats)
|
||||
|
||||
|
||||
def _is_open(todo: Any) -> bool:
|
||||
status = str(todo.get("STATUS", "NEEDS-ACTION")).strip().upper()
|
||||
return status not in ("COMPLETED", "CANCELLED")
|
||||
|
||||
|
||||
# --- write bodies ------------------------------------------------------------------
|
||||
|
||||
def _mkcalendar_body(name: str) -> bytes:
|
||||
return f"""<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>{_xml_escape(name)}</D:displayname>
|
||||
<C:supported-calendar-component-set>
|
||||
<C:comp name="VTODO"/>
|
||||
</C:supported-calendar-component-set>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>
|
||||
""".encode("utf-8")
|
||||
|
||||
|
||||
def _new_vtodo_ics(
|
||||
*, uid: str, title: str, waiting: bool, follow_up_at: Optional[date], notes: Optional[str],
|
||||
) -> bytes:
|
||||
calendar = icalendar.Calendar()
|
||||
calendar.add("prodid", "-//chemenu//caldav//EN")
|
||||
calendar.add("version", "2.0")
|
||||
todo = icalendar.Todo()
|
||||
todo.add("uid", uid)
|
||||
todo.add("summary", title)
|
||||
todo.add("status", "NEEDS-ACTION")
|
||||
now = datetime.now(timezone.utc)
|
||||
todo.add("dtstamp", now)
|
||||
todo.add("created", now)
|
||||
if notes:
|
||||
todo.add("description", notes)
|
||||
if waiting:
|
||||
todo.add("categories", [WAITING_CATEGORY])
|
||||
if follow_up_at is not None:
|
||||
todo.add("dtstart", follow_up_at) # a `date`, not `datetime` -> all-day
|
||||
calendar.add_component(todo)
|
||||
return calendar.to_ical()
|
||||
|
||||
|
||||
def _set_prop(component: Any, name: str, value: Any) -> None:
|
||||
"""Replace `component[name]` with exactly one value - `.add()` alone can
|
||||
duplicate a property that is already present, and `close_item` must leave
|
||||
every property it does not name completely alone (#139's own acceptance
|
||||
criterion), including this one having exactly the single value it sets."""
|
||||
if name in component:
|
||||
del component[name]
|
||||
component.add(name, value)
|
||||
|
||||
|
||||
# --- doctor probe --------------------------------------------------------------------
|
||||
|
||||
def probe(cfg: CalDAVConfig, *, timeout: float = 5.0) -> str:
|
||||
"""A short, human-readable reachability/auth line for `doctor` - the same
|
||||
posture `superproductivity.health` takes: never raises, provider
|
||||
reachability is reported but never fails the check."""
|
||||
client = _DavClient(cfg)
|
||||
try:
|
||||
status, _headers, _body = client.propfind(cfg.url, depth=0, body=_PROPFIND_SELF_BODY)
|
||||
except ValidationError as exc:
|
||||
return f"unreachable ({exc})"
|
||||
if status == 207:
|
||||
return "reachable, authenticated"
|
||||
if status in (401, 403):
|
||||
return f"reachable, authentication failed (HTTP {status})"
|
||||
return f"reachable, unexpected response (HTTP {status})"
|
||||
|
||||
|
||||
# --- TaskReader ------------------------------------------------------------------------
|
||||
|
||||
class CalDAVReader:
|
||||
"""`TaskReader` over CalDAV - always live, re-reading on every call like
|
||||
every other reader in this package."""
|
||||
|
||||
def __init__(self, cfg: CalDAVConfig):
|
||||
self._cfg = cfg
|
||||
self._client = _DavClient(cfg)
|
||||
|
||||
def projects(self) -> list[ProjectSummary]:
|
||||
result: list[ProjectSummary] = []
|
||||
for cal in _list_project_calendars(self._client, self._cfg):
|
||||
vtodos = _fetch_vtodos(self._client, cal.href)
|
||||
created_dates = [d for d in (_prop_date(v.todo, "CREATED") for v in vtodos) if d is not None]
|
||||
result.append(ProjectSummary(
|
||||
name=cal.displayname, created=min(created_dates) if created_dates else None,
|
||||
))
|
||||
return result
|
||||
|
||||
def open_items(self, project_name: str) -> OpenItems:
|
||||
target = normalize_project_name(project_name)
|
||||
cal = next(
|
||||
(c for c in _list_project_calendars(self._client, self._cfg)
|
||||
if normalize_project_name(c.displayname) == target),
|
||||
None,
|
||||
)
|
||||
if cal is None:
|
||||
return OpenItems(count=0, waiting=(), items=())
|
||||
|
||||
waiting: list[WaitingItem] = []
|
||||
all_items: list[OpenItem] = []
|
||||
count = 0
|
||||
for v in _fetch_vtodos(self._client, cal.href):
|
||||
if not _is_open(v.todo):
|
||||
continue
|
||||
count += 1
|
||||
title = _summary(v.todo)
|
||||
is_waiting = _is_waiting(v.todo)
|
||||
if is_waiting:
|
||||
waiting.append(WaitingItem(id=v.href, title=title, follow_up_at=_prop_date(v.todo, "DTSTART")))
|
||||
all_items.append(OpenItem(id=v.href, title=title, waiting=is_waiting))
|
||||
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
cal = _find_calendar(self._client, self._cfg, self._cfg.someday_list)
|
||||
if cal is None:
|
||||
raise ValidationError(
|
||||
f"caldav: configured someday_list {self._cfg.someday_list!r} was not found in "
|
||||
"this account."
|
||||
)
|
||||
items: list[SomedayItem] = []
|
||||
for v in _fetch_vtodos(self._client, cal.href):
|
||||
if not _is_open(v.todo):
|
||||
continue
|
||||
items.append(SomedayItem(
|
||||
id=v.href, title=_summary(v.todo), modified=_prop_date(v.todo, "LAST-MODIFIED"),
|
||||
))
|
||||
return items
|
||||
|
||||
def source(self) -> ReadSource:
|
||||
return ReadSource(kind=SOURCE_KIND, detail="live (CalDAV)")
|
||||
|
||||
|
||||
# --- TaskWriter ------------------------------------------------------------------------
|
||||
|
||||
class CalDAVWriter:
|
||||
"""`TaskWriter` over CalDAV. Unlike `SuperProductivityWriter`,
|
||||
`create_project` genuinely creates something - `MKCALENDAR` is a real
|
||||
collection-creation verb, so this never raises
|
||||
`chemenu.errors.HumanInterventionRequired`. `reader` is accepted for the
|
||||
same signature `chemenu.tasks.build_writer` uses for every provider, but
|
||||
is not consulted here: the writes below need a calendar's own `href`,
|
||||
which `chemenu.tasks.protocol.ProjectSummary` deliberately does not
|
||||
carry, so this reads calendars itself rather than through the generic
|
||||
protocol object."""
|
||||
|
||||
def __init__(self, cfg: CalDAVConfig, reader: Any = None): # noqa: ARG002 - see class docstring
|
||||
self._cfg = cfg
|
||||
self._client = _DavClient(cfg)
|
||||
|
||||
def create_project(self, name: str) -> None:
|
||||
"""`MKCALENDAR` under a fresh, opaque URL segment (a UUID, never the
|
||||
project name - see the module docstring's "Addresses only ever come
|
||||
from the server"). Preflighted against *every* list in the account
|
||||
(D5), not only the ones counted as projects."""
|
||||
collision = _find_calendar(self._client, self._cfg, name)
|
||||
if collision is not None:
|
||||
raise ValidationError(
|
||||
f"A list named '{collision.displayname}' (case-insensitively) already exists "
|
||||
"in this CalDAV account - nothing was created."
|
||||
)
|
||||
new_url = urljoin(self._cfg.url, uuid.uuid4().hex + "/")
|
||||
status, _headers, body = self._client.mkcalendar(new_url, _mkcalendar_body(name))
|
||||
if status not in (200, 201):
|
||||
raise ValidationError(
|
||||
f"caldav: MKCALENDAR for '{name}' failed (HTTP {status}): "
|
||||
f"{body[:300]!r}"
|
||||
)
|
||||
|
||||
def create_item(
|
||||
self,
|
||||
title: str,
|
||||
*,
|
||||
project_name: Optional[str],
|
||||
waiting: bool = False,
|
||||
follow_up_at: Optional[date] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
if project_name is None:
|
||||
cal = _find_calendar(self._client, self._cfg, self._cfg.inbox_list)
|
||||
if cal is None:
|
||||
raise ValidationError(
|
||||
f"caldav: configured inbox_list {self._cfg.inbox_list!r} was not found in "
|
||||
"this account."
|
||||
)
|
||||
else:
|
||||
cal = next(
|
||||
(c for c in _list_project_calendars(self._client, self._cfg)
|
||||
if normalize_project_name(c.displayname) == normalize_project_name(project_name)),
|
||||
None,
|
||||
)
|
||||
if cal is None:
|
||||
raise ValidationError(
|
||||
f"No project named '{project_name}' (case-insensitively) exists in this "
|
||||
"CalDAV account - this command does not create one (Gitea #132 D6). Run "
|
||||
"`wikitool new project` first, or pass --inbox."
|
||||
)
|
||||
|
||||
uid = str(uuid.uuid4())
|
||||
href = urljoin(cal.href, uid + ".ics")
|
||||
body = _new_vtodo_ics(uid=uid, title=title, waiting=waiting, follow_up_at=follow_up_at, notes=notes)
|
||||
status, _headers, response_body = self._client.put(href, body, if_none_match="*")
|
||||
if status not in (200, 201, 204):
|
||||
raise ValidationError(
|
||||
f"caldav: creating '{title}' failed (HTTP {status}): {response_body[:300]!r}"
|
||||
)
|
||||
|
||||
def close_item(self, item_id: str) -> None:
|
||||
"""`GET` the resource, change only `STATUS`/`COMPLETED`/
|
||||
`PERCENT-COMPLETE`/`LAST-MODIFIED`/`DTSTAMP`, `PUT` it back with
|
||||
`If-Match: <etag>` - see the module docstring. `item_id` is the
|
||||
resource's own href, exactly as a read call returned it (#139's own
|
||||
acceptance criterion on addresses)."""
|
||||
status, headers, body = self._client.get(item_id)
|
||||
if status == 404:
|
||||
raise ValidationError(f"caldav: no item exists at {item_id} - nothing was closed.")
|
||||
if status != 200:
|
||||
raise ValidationError(f"caldav: reading {item_id} failed (HTTP {status}).")
|
||||
etag = headers.get("ETag") or headers.get("etag")
|
||||
if not etag:
|
||||
raise ValidationError(
|
||||
f"caldav: {item_id} carries no ETag - cannot write it back safely without one."
|
||||
)
|
||||
try:
|
||||
calendar = icalendar.Calendar.from_ical(body)
|
||||
except (ValueError, IndexError) as exc:
|
||||
raise ValidationError(f"caldav: {item_id} is not a parsable VTODO ({exc}).") from exc
|
||||
todos = calendar.walk("VTODO")
|
||||
if not todos:
|
||||
raise ValidationError(f"caldav: {item_id} has no VTODO component.")
|
||||
todo = todos[0]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
_set_prop(todo, "STATUS", "COMPLETED")
|
||||
_set_prop(todo, "COMPLETED", now)
|
||||
_set_prop(todo, "PERCENT-COMPLETE", 100)
|
||||
_set_prop(todo, "LAST-MODIFIED", now)
|
||||
_set_prop(todo, "DTSTAMP", now)
|
||||
|
||||
put_status, _headers, response_body = self._client.put(
|
||||
item_id, calendar.to_ical(), if_match=etag,
|
||||
)
|
||||
if put_status == 412:
|
||||
raise ValidationError(
|
||||
f"caldav: {item_id} changed since it was read (ETag mismatch) - nothing was "
|
||||
"written. Re-run the review/list and try again with the current id."
|
||||
)
|
||||
if put_status not in (200, 204):
|
||||
raise ValidationError(
|
||||
f"caldav: closing {item_id} failed (HTTP {put_status}): {response_body[:300]!r}"
|
||||
)
|
||||
@@ -18,7 +18,7 @@ from chemenu.errors import ValidationError
|
||||
# typo in `provider` fails here, at the one place that knows the full list,
|
||||
# rather than surfacing later as an unhelpful "unknown provider" from whatever
|
||||
# code tried to dispatch on it.
|
||||
KNOWN_PROVIDERS = ("superproductivity",)
|
||||
KNOWN_PROVIDERS = ("superproductivity", "caldav")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
"""Tests for `chemenu.tasks.caldav` (Gitea #139).
|
||||
|
||||
Every test runs against a local stub HTTP server (`http.server`, a loopback
|
||||
socket only) - never a real CalDAV server, per #139's own acceptance
|
||||
criterion. Two kinds of stub are used: a *canned* server that answers fixed
|
||||
(method, path) pairs with a fixed response, for read-path tests where the
|
||||
exact request sequence is known in advance; and a small *stateful* server
|
||||
that actually tracks calendars/resources across requests, for the write-path
|
||||
tests (`create_project`/`create_item`/`close_item`) where a later read must
|
||||
see an earlier write.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import http.server
|
||||
import threading
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional
|
||||
from xml.sax.saxutils import escape as _xml_escape
|
||||
|
||||
import icalendar
|
||||
import pytest
|
||||
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.tasks import caldav as cd
|
||||
|
||||
TOKEN = ("me", "app-secret")
|
||||
|
||||
|
||||
def _auth_header() -> str:
|
||||
return "Basic " + base64.b64encode(f"{TOKEN[0]}:{TOKEN[1]}".encode()).decode("ascii")
|
||||
|
||||
|
||||
def _cfg(port: int, *, path: str = "/cal/", **overrides) -> cd.CalDAVConfig:
|
||||
data = {
|
||||
"url": f"http://127.0.0.1:{port}{path}",
|
||||
"username": TOKEN[0],
|
||||
"app_password": TOKEN[1],
|
||||
"inbox_list": "Inbox",
|
||||
"someday_list": "Someday",
|
||||
**overrides,
|
||||
}
|
||||
return cd.CalDAVConfig.from_dict(data)
|
||||
|
||||
|
||||
# --- fixture builders --------------------------------------------------------------
|
||||
|
||||
def _calendars_multistatus(entries: list[tuple[str, str, Optional[frozenset]]]) -> bytes:
|
||||
"""`entries`: (href, displayname, components-or-None). `None` means "not
|
||||
a calendar collection at all" (e.g. the home-set entry itself)."""
|
||||
responses = []
|
||||
for href, displayname, components in entries:
|
||||
if components is None:
|
||||
resourcetype = "<D:resourcetype><D:collection/></D:resourcetype>"
|
||||
comp_set = ""
|
||||
else:
|
||||
resourcetype = "<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"
|
||||
comps = "".join(f'<C:comp name="{c}"/>' for c in sorted(components))
|
||||
comp_set = f"<C:supported-calendar-component-set>{comps}</C:supported-calendar-component-set>"
|
||||
responses.append(f"""<D:response>
|
||||
<D:href>{_xml_escape(href)}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
{resourcetype}
|
||||
<D:displayname>{_xml_escape(displayname)}</D:displayname>
|
||||
{comp_set}
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>""")
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
{"".join(responses)}
|
||||
</D:multistatus>""".encode("utf-8")
|
||||
|
||||
|
||||
def _vtodos_multistatus(entries: list[tuple[str, str, bytes]]) -> bytes:
|
||||
"""`entries`: (href, etag, raw ics bytes)."""
|
||||
responses = []
|
||||
for href, etag, ics in entries:
|
||||
responses.append(f"""<D:response>
|
||||
<D:href>{_xml_escape(href)}</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>{_xml_escape(etag)}</D:getetag>
|
||||
<C:calendar-data>{_xml_escape(ics.decode("utf-8"))}</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>""")
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
{"".join(responses)}
|
||||
</D:multistatus>""".encode("utf-8")
|
||||
|
||||
|
||||
def _vtodo_ics(
|
||||
*, uid: str = "t1", summary: str = "Warte auf Angebot", status: str = "NEEDS-ACTION",
|
||||
categories: Optional[list[str]] = None, dtstart: Optional[date] = None,
|
||||
created: Optional[datetime] = None, due: Optional[date] = None,
|
||||
extra_lines: Optional[list[str]] = None,
|
||||
) -> bytes:
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("prodid", "-//fixture//")
|
||||
cal.add("version", "2.0")
|
||||
todo = icalendar.Todo()
|
||||
todo.add("uid", uid)
|
||||
todo.add("summary", summary)
|
||||
todo.add("status", status)
|
||||
if categories:
|
||||
todo.add("categories", categories)
|
||||
if dtstart is not None:
|
||||
todo.add("dtstart", dtstart)
|
||||
if created is not None:
|
||||
todo.add("created", created)
|
||||
if due is not None:
|
||||
todo.add("due", due)
|
||||
cal.add_component(todo)
|
||||
body = cal.to_ical()
|
||||
if extra_lines:
|
||||
# Inject raw lines (e.g. X-APPLE-SORT-ORDER, VALARM) right before END:VTODO,
|
||||
# the way a real resource written by iOS would carry them.
|
||||
text = body.decode("utf-8").replace("\r\n", "\n")
|
||||
marker = "END:VTODO"
|
||||
idx = text.index(marker)
|
||||
text = text[:idx] + "\n".join(extra_lines) + "\n" + text[idx:]
|
||||
body = text.encode("utf-8")
|
||||
return body
|
||||
|
||||
|
||||
# --- canned HTTP stub ---------------------------------------------------------------
|
||||
|
||||
def _make_canned_handler(routes: dict[tuple[str, str], tuple[int, dict, bytes]]):
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def _handle(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
if self.headers.get("Authorization") != _auth_header():
|
||||
self._reply(401, {}, b'{"error": "unauthorized"}')
|
||||
return
|
||||
key = (self.command, self.path)
|
||||
if key not in routes:
|
||||
self._reply(404, {}, b'{"error": "not found"}')
|
||||
return
|
||||
status, headers, body = routes[key]
|
||||
self._reply(status, headers, body)
|
||||
|
||||
def _reply(self, status, headers, body):
|
||||
self.send_response(status)
|
||||
for k, v in headers.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
do_PROPFIND = _handle
|
||||
do_REPORT = _handle
|
||||
do_GET = _handle
|
||||
do_PUT = _handle
|
||||
do_MKCALENDAR = _handle
|
||||
|
||||
def log_message(self, *args): # silence stderr noise during the test run
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _canned_server(routes: dict[tuple[str, str], tuple[int, dict, bytes]]):
|
||||
handler_cls = _make_canned_handler(routes)
|
||||
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)
|
||||
|
||||
|
||||
# --- stateful HTTP stub (write path) -------------------------------------------------
|
||||
|
||||
def _make_stateful_handler(state: dict):
|
||||
"""`state["calendars"]`: path -> {"displayname", "components", "resource_paths"}.
|
||||
`state["resources"]`: path -> {"etag": str, "body": bytes}. `state["created"]`
|
||||
collects every MKCALENDAR call's (path, displayname), so a test can assert
|
||||
on it without inspecting the calendars dict directly."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def _auth_ok(self) -> bool:
|
||||
return self.headers.get("Authorization") == _auth_header()
|
||||
|
||||
def _body(self) -> bytes:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
return self.rfile.read(length) if length else b""
|
||||
|
||||
def _forced(self) -> bool:
|
||||
"""`state["force_status"][method]`, if set, short-circuits the
|
||||
normal handling below with a fixed (status, headers, body) - for
|
||||
tests exercising an "unexpected response" branch that a real
|
||||
server would rarely produce (a 500, say) without simulating a
|
||||
whole broken deployment."""
|
||||
forced = state.get("force_status", {}).get(self.command)
|
||||
if forced is None:
|
||||
return False
|
||||
status, headers, body = forced
|
||||
self._reply(status, headers, body)
|
||||
return True
|
||||
|
||||
def do_PROPFIND(self): # noqa: N802
|
||||
if not self._auth_ok():
|
||||
self._reply(401, {}, b"")
|
||||
return
|
||||
self._body()
|
||||
if self._forced():
|
||||
return
|
||||
depth = self.headers.get("Depth", "0")
|
||||
if depth == "0":
|
||||
# Only the probe / a single-collection lookup uses depth 0 here.
|
||||
self._reply(207, {}, _calendars_multistatus([]))
|
||||
return
|
||||
entries = [
|
||||
(path, info["displayname"], info["components"])
|
||||
for path, info in state["calendars"].items()
|
||||
]
|
||||
self._reply(207, {"Content-Type": "application/xml"}, _calendars_multistatus(entries))
|
||||
|
||||
def do_REPORT(self): # noqa: N802
|
||||
if not self._auth_ok():
|
||||
self._reply(401, {}, b"")
|
||||
return
|
||||
self._body()
|
||||
info = state["calendars"].get(self.path)
|
||||
if info is None:
|
||||
self._reply(404, {}, b"")
|
||||
return
|
||||
entries = [
|
||||
(rpath, state["resources"][rpath]["etag"], state["resources"][rpath]["body"])
|
||||
for rpath in info["resource_paths"]
|
||||
]
|
||||
self._reply(207, {"Content-Type": "application/xml"}, _vtodos_multistatus(entries))
|
||||
|
||||
def do_MKCALENDAR(self): # noqa: N802
|
||||
if not self._auth_ok():
|
||||
self._reply(401, {}, b"")
|
||||
return
|
||||
body_bytes = self._body()
|
||||
if self._forced():
|
||||
return
|
||||
body = body_bytes.decode("utf-8")
|
||||
import re
|
||||
|
||||
match = re.search(r"<D:displayname>(.*?)</D:displayname>", body)
|
||||
displayname = match.group(1) if match else ""
|
||||
state["calendars"][self.path] = {
|
||||
"displayname": displayname, "components": frozenset({"VTODO"}), "resource_paths": set(),
|
||||
}
|
||||
state.setdefault("created", []).append((self.path, displayname))
|
||||
self._reply(201, {}, b"")
|
||||
|
||||
def do_PUT(self): # noqa: N802
|
||||
if not self._auth_ok():
|
||||
self._reply(401, {}, b"")
|
||||
return
|
||||
body = self._body()
|
||||
if self._forced():
|
||||
return
|
||||
existing = state["resources"].get(self.path)
|
||||
if_none_match = self.headers.get("If-None-Match")
|
||||
if_match = self.headers.get("If-Match")
|
||||
if if_none_match == "*" and existing is not None:
|
||||
self._reply(412, {}, b"")
|
||||
return
|
||||
if if_match is not None:
|
||||
if existing is None:
|
||||
self._reply(404, {}, b"")
|
||||
return
|
||||
if existing["etag"] != if_match:
|
||||
self._reply(412, {}, b"")
|
||||
return
|
||||
state.setdefault("etag_counter", 0)
|
||||
state["etag_counter"] += 1
|
||||
new_etag = f'"etag-{state["etag_counter"]}"'
|
||||
state["resources"][self.path] = {"etag": new_etag, "body": body}
|
||||
if existing is None:
|
||||
# Attach to whichever calendar's path is this resource's parent.
|
||||
parent = max(
|
||||
(p for p in state["calendars"] if self.path.startswith(p)),
|
||||
key=len, default=None,
|
||||
)
|
||||
if parent is not None:
|
||||
state["calendars"][parent]["resource_paths"].add(self.path)
|
||||
self._reply(201, {}, b"")
|
||||
else:
|
||||
self._reply(204, {}, b"")
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if not self._auth_ok():
|
||||
self._reply(401, {}, b"")
|
||||
return
|
||||
if self._forced():
|
||||
return
|
||||
resource = state["resources"].get(self.path)
|
||||
if resource is None:
|
||||
self._reply(404, {}, b"")
|
||||
return
|
||||
self._reply(200, {"ETag": resource["etag"]}, resource["body"])
|
||||
|
||||
def _reply(self, status, headers, body):
|
||||
self.send_response(status)
|
||||
for k, v in headers.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args): # silence stderr noise during the test run
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _stateful_server(state: dict):
|
||||
handler_cls = _make_stateful_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
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def _fresh_state() -> dict:
|
||||
return {"calendars": {}, "resources": {}, "created": []}
|
||||
|
||||
|
||||
# --- CalDAVConfig.from_dict -----------------------------------------------------------
|
||||
|
||||
def test_from_dict_requires_every_string_field():
|
||||
with pytest.raises(ValidationError, match="url"):
|
||||
cd.CalDAVConfig.from_dict({
|
||||
"username": "u", "app_password": "p", "inbox_list": "Inbox", "someday_list": "Someday",
|
||||
})
|
||||
|
||||
|
||||
def test_from_dict_rejects_unknown_fields():
|
||||
with pytest.raises(ValidationError, match="does not take"):
|
||||
cd.CalDAVConfig.from_dict({
|
||||
"url": "https://x/", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday", "backups_dir": "/x",
|
||||
})
|
||||
|
||||
|
||||
def test_from_dict_exclude_lists_must_be_a_list_of_strings():
|
||||
with pytest.raises(ValidationError, match="exclude_lists"):
|
||||
cd.CalDAVConfig.from_dict({
|
||||
"url": "https://x/", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday", "exclude_lists": "not-a-list",
|
||||
})
|
||||
|
||||
|
||||
def test_from_dict_exclude_lists_defaults_to_empty():
|
||||
cfg = cd.CalDAVConfig.from_dict({
|
||||
"url": "https://x/", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
})
|
||||
assert cfg.exclude_lists == ()
|
||||
|
||||
|
||||
def test_from_dict_normalizes_a_missing_trailing_slash():
|
||||
cfg = cd.CalDAVConfig.from_dict({
|
||||
"url": "https://x/cal", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
})
|
||||
assert cfg.url == "https://x/cal/"
|
||||
|
||||
|
||||
# --- projects() / open_items() / someday_items() -----------------------------------
|
||||
|
||||
def test_projects_lists_vtodo_only_calendars_and_excludes_fixed_lists():
|
||||
entries = [
|
||||
("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"})),
|
||||
("/cal/proj2/", "Kueche renovieren", frozenset({"VTODO"})),
|
||||
("/cal/mixed/", "Mixed Calendar", frozenset({"VTODO", "VEVENT"})),
|
||||
("/cal/inbox/", "Inbox", frozenset({"VTODO"})),
|
||||
("/cal/someday/", "Someday", frozenset({"VTODO"})),
|
||||
("/cal/other/", "Other Existing List", frozenset({"VTODO"})),
|
||||
("/cal/", "", None), # the home-set itself, not a calendar
|
||||
]
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])),
|
||||
("REPORT", "/cal/proj2/"): (207, {}, _vtodos_multistatus([])),
|
||||
}) as server:
|
||||
cfg = _cfg(server.server_address[1], exclude_lists=["Other Existing List"])
|
||||
reader = cd.CalDAVReader(cfg)
|
||||
names = {p.name for p in reader.projects()}
|
||||
assert names == {"Ship Chemenu 7.0", "Kueche renovieren"}
|
||||
|
||||
|
||||
def test_projects_created_is_the_earliest_item_created_or_none_for_an_empty_list():
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
ics1 = _vtodo_ics(uid="t1", created=datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
ics2 = _vtodo_ics(uid="t2", created=datetime(2026, 1, 15, tzinfo=timezone.utc))
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (
|
||||
207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics1), ("/cal/proj1/t2.ics", '"e2"', ics2)]),
|
||||
),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
project = reader.projects()[0]
|
||||
assert project.created == date(2026, 1, 15)
|
||||
|
||||
|
||||
def test_projects_created_is_none_for_an_empty_project():
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
assert reader.projects()[0].created is None
|
||||
|
||||
|
||||
def test_open_items_excludes_completed_and_cancelled():
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
open_ics = _vtodo_ics(uid="t1", status="NEEDS-ACTION")
|
||||
done_ics = _vtodo_ics(uid="t2", status="COMPLETED")
|
||||
cancelled_ics = _vtodo_ics(uid="t3", status="CANCELLED")
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([
|
||||
("/cal/proj1/t1.ics", '"e1"', open_ics),
|
||||
("/cal/proj1/t2.ics", '"e2"', done_ics),
|
||||
("/cal/proj1/t3.ics", '"e3"', cancelled_ics),
|
||||
])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
result = reader.open_items("ship CHEMENU 7.0")
|
||||
assert result.count == 1
|
||||
assert result.items[0].id.endswith("/cal/proj1/t1.ics")
|
||||
|
||||
|
||||
def test_open_items_unknown_project_is_empty_not_an_error():
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus([])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
result = reader.open_items("No Such Project")
|
||||
assert result.count == 0
|
||||
assert result.waiting == ()
|
||||
assert result.items == ()
|
||||
|
||||
|
||||
def test_open_items_waiting_is_read_from_categories_case_insensitively():
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
ics = _vtodo_ics(uid="t1", categories=["Waiting"], dtstart=date(2026, 3, 1))
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
assert len(result.waiting) == 1
|
||||
assert result.waiting[0].follow_up_at == date(2026, 3, 1)
|
||||
assert result.items[0].waiting is True
|
||||
|
||||
|
||||
def test_follow_up_at_is_dtstart_never_due():
|
||||
"""#119 D9/#135's meaning-not-name rule - a CalDAV DUE must never leak
|
||||
into follow_up_at even when set alongside DTSTART."""
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
ics = _vtodo_ics(uid="t1", categories=["waiting"], dtstart=date(2026, 3, 1), due=date(2026, 4, 1))
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
waiting = reader.open_items("Ship Chemenu 7.0").waiting[0]
|
||||
assert waiting.follow_up_at == date(2026, 3, 1)
|
||||
|
||||
|
||||
def test_waiting_item_with_no_dtstart_has_no_follow_up_at():
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
ics = _vtodo_ics(uid="t1", categories=["waiting"])
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
waiting = reader.open_items("Ship Chemenu 7.0").waiting[0]
|
||||
assert waiting.follow_up_at is None
|
||||
|
||||
|
||||
def test_someday_items_come_from_the_configured_someday_list():
|
||||
entries = [
|
||||
("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"})),
|
||||
("/cal/someday/", "Someday", frozenset({"VTODO"})),
|
||||
]
|
||||
ics = icalendar.Calendar()
|
||||
todo = icalendar.Todo()
|
||||
todo.add("uid", "s1")
|
||||
todo.add("summary", "Irgendwann Keller aufraeumen")
|
||||
todo.add("status", "NEEDS-ACTION")
|
||||
todo.add("last-modified", datetime(2026, 1, 15, tzinfo=timezone.utc))
|
||||
ics.add_component(todo)
|
||||
body = ics.to_ical()
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([("/cal/someday/s1.ics", '"e1"', body)])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
items = reader.someday_items()
|
||||
assert len(items) == 1
|
||||
assert items[0].title == "Irgendwann Keller aufraeumen"
|
||||
assert items[0].modified == date(2026, 1, 15)
|
||||
|
||||
|
||||
def test_someday_items_raises_if_the_configured_list_is_missing():
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus([])),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
with pytest.raises(ValidationError, match="Someday"):
|
||||
reader.someday_items()
|
||||
|
||||
|
||||
def test_source_is_always_live_caldav():
|
||||
with _canned_server({}) as server:
|
||||
source = cd.CalDAVReader(_cfg(server.server_address[1])).source()
|
||||
assert source.kind == "api"
|
||||
assert "CalDAV" in source.detail
|
||||
|
||||
|
||||
# --- addresses only ever come from the server (#139's own acceptance criterion) -----
|
||||
|
||||
def test_hrefs_from_a_different_user_id_than_the_configured_url_are_used_as_returned():
|
||||
"""Nextcloud accepts an alias in the configured URL but answers with the
|
||||
canonical user id in every href - the adapter must follow that, never
|
||||
glue the project name onto the *configured* url's own path."""
|
||||
entries = [("/dav/calendars/canonical-id/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
with _canned_server({
|
||||
("PROPFIND", "/dav/calendars/shortname/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/dav/calendars/canonical-id/proj1/"): (207, {}, _vtodos_multistatus([])),
|
||||
}) as server:
|
||||
cfg = _cfg(server.server_address[1], path="/dav/calendars/shortname/")
|
||||
reader = cd.CalDAVReader(cfg)
|
||||
result = reader.open_items("Ship Chemenu 7.0")
|
||||
assert result.count == 0 # the REPORT route above only answers if the right href was used
|
||||
|
||||
|
||||
# --- unrecognized responses fail loudly, never as an empty result -------------------
|
||||
|
||||
def test_non_multistatus_response_fails_loud():
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, b"not xml at all"),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
with pytest.raises(ValidationError):
|
||||
reader.projects()
|
||||
|
||||
|
||||
def test_wrong_http_status_fails_loud():
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (500, {}, b"boom"),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
with pytest.raises(ValidationError):
|
||||
reader.projects()
|
||||
|
||||
|
||||
def test_unparsable_vtodo_fails_loud_not_silent():
|
||||
entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))]
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (
|
||||
207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', b"NOT A VALID ICS")]),
|
||||
),
|
||||
}) as server:
|
||||
reader = cd.CalDAVReader(_cfg(server.server_address[1]))
|
||||
with pytest.raises(ValidationError):
|
||||
reader.open_items("Ship Chemenu 7.0")
|
||||
|
||||
|
||||
def test_unreachable_server_fails_loud():
|
||||
cfg = cd.CalDAVConfig.from_dict({
|
||||
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
})
|
||||
reader = cd.CalDAVReader(cfg)
|
||||
with pytest.raises(ValidationError):
|
||||
reader.projects()
|
||||
|
||||
|
||||
# --- probe() (doctor) ----------------------------------------------------------------
|
||||
|
||||
def test_probe_reports_reachable_and_authenticated():
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus([])),
|
||||
}) as server:
|
||||
assert cd.probe(_cfg(server.server_address[1])) == "reachable, authenticated"
|
||||
|
||||
|
||||
def test_probe_reports_authentication_failure():
|
||||
with _canned_server({}) as server: # no route -> every request 401s at the auth check
|
||||
state = cd.probe(_cfg(server.server_address[1], app_password="wrong"))
|
||||
assert "authentication failed" in state
|
||||
|
||||
|
||||
def test_probe_reports_unreachable():
|
||||
cfg = cd.CalDAVConfig.from_dict({
|
||||
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
})
|
||||
assert "unreachable" in cd.probe(cfg, timeout=0.5)
|
||||
|
||||
|
||||
# --- write path: create_project (D5, automatic, no HumanInterventionRequired) -------
|
||||
|
||||
def test_create_project_succeeds_and_uses_an_opaque_url_never_the_name():
|
||||
state = _fresh_state()
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
writer.create_project("Ship Chemenu 7.0")
|
||||
|
||||
assert len(state["created"]) == 1
|
||||
path, displayname = state["created"][0]
|
||||
assert displayname == "Ship Chemenu 7.0"
|
||||
assert "Ship" not in path and "chemenu" not in path.lower()
|
||||
assert state["calendars"][path]["components"] == frozenset({"VTODO"})
|
||||
|
||||
|
||||
def test_create_project_refuses_a_collision_against_any_list_in_the_account():
|
||||
"""D5's own acceptance criterion: the collision check runs against every
|
||||
list in the account, including one that would never count as a project -
|
||||
an excluded list, the inbox, the someday list, or a mixed calendar."""
|
||||
state = _fresh_state()
|
||||
state["calendars"]["/cal/excluded/"] = {
|
||||
"displayname": "Legacy List", "components": frozenset({"VTODO"}), "resource_paths": set(),
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(
|
||||
_cfg(server.server_address[1], exclude_lists=["Legacy List"]), reader=None,
|
||||
)
|
||||
with pytest.raises(ValidationError, match="Legacy List"):
|
||||
writer.create_project("legacy list")
|
||||
assert state["created"] == []
|
||||
|
||||
|
||||
# --- write path: create_item (Gitea #132 semantics, over CalDAV) --------------------
|
||||
|
||||
def test_create_item_posts_into_the_projects_own_href():
|
||||
state = _fresh_state()
|
||||
state["calendars"]["/cal/proj1/"] = {
|
||||
"displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), "resource_paths": set(),
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
writer.create_item("Rueckruf beim Kunden", project_name="ship CHEMENU 7.0")
|
||||
|
||||
resource_paths = state["calendars"]["/cal/proj1/"]["resource_paths"]
|
||||
assert len(resource_paths) == 1
|
||||
body = state["resources"][next(iter(resource_paths))]["body"]
|
||||
todo = icalendar.Calendar.from_ical(body).walk("VTODO")[0]
|
||||
assert str(todo["SUMMARY"]) == "Rueckruf beim Kunden"
|
||||
|
||||
|
||||
def test_create_item_refuses_an_unknown_project_and_writes_nothing():
|
||||
state = _fresh_state()
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="No project named"):
|
||||
writer.create_item("x", project_name="No Such Project")
|
||||
assert state["resources"] == {}
|
||||
|
||||
|
||||
def test_create_item_inbox_route_uses_the_configured_inbox_list():
|
||||
state = _fresh_state()
|
||||
state["calendars"]["/cal/inbox/"] = {
|
||||
"displayname": "Inbox", "components": frozenset({"VTODO"}), "resource_paths": set(),
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
writer.create_item("Beleg ablegen", project_name=None)
|
||||
|
||||
assert len(state["calendars"]["/cal/inbox/"]["resource_paths"]) == 1
|
||||
|
||||
|
||||
def test_create_item_sets_categories_and_dtstart_and_description():
|
||||
state = _fresh_state()
|
||||
state["calendars"]["/cal/proj1/"] = {
|
||||
"displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), "resource_paths": set(),
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
writer.create_item(
|
||||
"Nachfassen beim Elektriker", project_name="Ship Chemenu 7.0",
|
||||
waiting=True, follow_up_at=date(2026, 4, 1), notes="Source - Kundenmail",
|
||||
)
|
||||
|
||||
resource_path = next(iter(state["calendars"]["/cal/proj1/"]["resource_paths"]))
|
||||
todo = icalendar.Calendar.from_ical(state["resources"][resource_path]["body"]).walk("VTODO")[0]
|
||||
assert [str(c) for c in todo["CATEGORIES"].cats] == ["waiting"]
|
||||
assert todo["DTSTART"].dt == date(2026, 4, 1)
|
||||
assert str(todo["DESCRIPTION"]) == "Source - Kundenmail"
|
||||
|
||||
|
||||
# --- write path: close_item (Gitea #138 semantics, over CalDAV) ---------------------
|
||||
|
||||
def test_close_item_changes_only_the_five_named_fields():
|
||||
state = _fresh_state()
|
||||
ics = _vtodo_ics(
|
||||
uid="t1", summary="x", dtstart=date(2026, 3, 1),
|
||||
extra_lines=["X-APPLE-SORT-ORDER:12345", "BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER:-PT15M", "END:VALARM"],
|
||||
)
|
||||
state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics}
|
||||
state["calendars"]["/cal/proj1/"] = {
|
||||
"displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}),
|
||||
"resource_paths": {"/cal/proj1/t1.ics"},
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
writer.close_item(item_id)
|
||||
|
||||
updated = icalendar.Calendar.from_ical(state["resources"]["/cal/proj1/t1.ics"]["body"])
|
||||
todo = updated.walk("VTODO")[0]
|
||||
assert str(todo["STATUS"]) == "COMPLETED"
|
||||
assert int(todo["PERCENT-COMPLETE"]) == 100
|
||||
assert "COMPLETED" in todo
|
||||
# Untouched:
|
||||
assert todo["DTSTART"].dt == date(2026, 3, 1)
|
||||
assert str(todo["X-APPLE-SORT-ORDER"]) == "12345"
|
||||
alarms = updated.walk("VALARM")
|
||||
assert len(alarms) == 1
|
||||
assert str(alarms[0]["TRIGGER"].to_ical().decode()) == "-PT15M"
|
||||
|
||||
|
||||
def test_close_item_conflicting_etag_writes_nothing_and_raises():
|
||||
state = _fresh_state()
|
||||
ics = _vtodo_ics(uid="t1")
|
||||
state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics}
|
||||
state["calendars"]["/cal/proj1/"] = {
|
||||
"displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}),
|
||||
"resource_paths": {"/cal/proj1/t1.ics"},
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
# A concurrent change bumps the etag between the GET and the PUT this
|
||||
# writer will do - simulate it by mutating state directly.
|
||||
original_body = state["resources"]["/cal/proj1/t1.ics"]["body"]
|
||||
|
||||
real_get = writer._client.get
|
||||
|
||||
def _get_then_conflict(url):
|
||||
result = real_get(url)
|
||||
state["resources"]["/cal/proj1/t1.ics"]["etag"] = '"e2-changed-concurrently"'
|
||||
return result
|
||||
|
||||
writer._client.get = _get_then_conflict
|
||||
with pytest.raises(ValidationError, match="changed since"):
|
||||
writer.close_item(item_id)
|
||||
|
||||
assert state["resources"]["/cal/proj1/t1.ics"]["body"] == original_body
|
||||
|
||||
|
||||
def test_close_item_unknown_id_refuses_and_writes_nothing():
|
||||
state = _fresh_state()
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/no-such.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="no item"):
|
||||
writer.close_item(item_id)
|
||||
assert state["resources"] == {}
|
||||
|
||||
|
||||
def test_close_item_never_sends_delete():
|
||||
"""No `DELETE` route exists on the stateful stub at all - if `close_item`
|
||||
ever sent one, the request would 501 (BaseHTTPRequestHandler's default
|
||||
for an undefined `do_*`) and the test below would fail loudly instead of
|
||||
silently passing, which is the point: this locks in that no such call
|
||||
happens by construction (Gitea #138 E7)."""
|
||||
state = _fresh_state()
|
||||
ics = _vtodo_ics(uid="t1")
|
||||
state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics}
|
||||
state["calendars"]["/cal/proj1/"] = {
|
||||
"displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}),
|
||||
"resource_paths": {"/cal/proj1/t1.ics"},
|
||||
}
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
writer.close_item(item_id)
|
||||
|
||||
assert "/cal/proj1/t1.ics" in state["resources"] # still there - marked done, not removed
|
||||
|
||||
|
||||
# --- write path: unexpected server responses fail loudly ----------------------------
|
||||
|
||||
def test_create_project_unexpected_mkcalendar_status_fails_loud():
|
||||
state = _fresh_state()
|
||||
state["force_status"] = {"MKCALENDAR": (500, {}, b"boom")}
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="MKCALENDAR"):
|
||||
writer.create_project("New Project")
|
||||
|
||||
|
||||
def test_create_item_unexpected_put_status_fails_loud():
|
||||
state = _fresh_state()
|
||||
state["calendars"]["/cal/proj1/"] = {
|
||||
"displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), "resource_paths": set(),
|
||||
}
|
||||
state["force_status"] = {"PUT": (500, {}, b"boom")}
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="creating"):
|
||||
writer.create_item("x", project_name="Ship Chemenu 7.0")
|
||||
|
||||
|
||||
def test_create_item_refuses_when_the_configured_inbox_list_is_missing():
|
||||
state = _fresh_state()
|
||||
with _stateful_server(state) as server:
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="Inbox"):
|
||||
writer.create_item("x", project_name=None)
|
||||
|
||||
|
||||
def test_close_item_unexpected_get_status_fails_loud():
|
||||
state = _fresh_state()
|
||||
state["force_status"] = {"GET": (500, {}, b"boom")}
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="reading"):
|
||||
writer.close_item(item_id)
|
||||
|
||||
|
||||
def test_close_item_unparsable_resource_fails_loud():
|
||||
state = _fresh_state()
|
||||
state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": b"NOT A VALID ICS"}
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
with pytest.raises(ValidationError, match="not a parsable VTODO"):
|
||||
writer.close_item(item_id)
|
||||
assert state["resources"]["/cal/proj1/t1.ics"]["body"] == b"NOT A VALID ICS"
|
||||
|
||||
|
||||
def test_close_item_unexpected_put_status_fails_loud():
|
||||
state = _fresh_state()
|
||||
ics = _vtodo_ics(uid="t1")
|
||||
state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics}
|
||||
with _stateful_server(state) as server:
|
||||
item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics"
|
||||
writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None)
|
||||
|
||||
real_get = writer._client.get
|
||||
|
||||
def _get_then_force_put_error(url):
|
||||
result = real_get(url)
|
||||
state["force_status"] = {"PUT": (500, {}, b"boom")}
|
||||
return result
|
||||
|
||||
writer._client.get = _get_then_force_put_error
|
||||
with pytest.raises(ValidationError, match="closing"):
|
||||
writer.close_item(item_id)
|
||||
@@ -413,6 +413,39 @@ def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
|
||||
assert "not reachable" in detail
|
||||
|
||||
|
||||
def test_tasks_provider_fails_on_a_bad_caldav_section(instance):
|
||||
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
||||
'{"schema": 1, "provider": "caldav", "thresholds": '
|
||||
'{"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}, '
|
||||
'"caldav": {}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "FAIL"
|
||||
|
||||
|
||||
def test_tasks_provider_ok_but_names_caldav_unreachable_when_configured(instance):
|
||||
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
||||
json.dumps({
|
||||
"schema": 1, "provider": "caldav",
|
||||
"thresholds": {
|
||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
||||
"someday_stale_months": 5,
|
||||
},
|
||||
"caldav": {
|
||||
"url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p",
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "OK"
|
||||
detail = _detail(checks, "tasks-provider")
|
||||
assert "caldav" in detail
|
||||
assert "unreachable" in detail
|
||||
|
||||
|
||||
def test_missing_generated_file_fails(instance):
|
||||
config.LOG_FILE.unlink()
|
||||
checks = doctor.run_doctor()
|
||||
|
||||
@@ -24,12 +24,21 @@ from chemenu.frontmatter_io import write_page
|
||||
from chemenu.tests.conftest import use_shipped_type_specs
|
||||
from chemenu.review import (
|
||||
CHECK_NO_OPEN_LOOP,
|
||||
CHECK_PROJECT_AGE_UNKNOWN,
|
||||
CHECK_SOMEDAY_STALE,
|
||||
CHECK_STALLED,
|
||||
CHECK_UNPAGED_PROJECT,
|
||||
CHECK_WAITING_NO_FOLLOWUP,
|
||||
CHECK_WAITING_OVERDUE,
|
||||
run_review,
|
||||
)
|
||||
from chemenu.tests.test_caldav import (
|
||||
TOKEN as _CALDAV_TOKEN,
|
||||
_calendars_multistatus,
|
||||
_canned_server,
|
||||
_vtodo_ics,
|
||||
_vtodos_multistatus,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -81,6 +90,21 @@ def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None =
|
||||
)
|
||||
|
||||
|
||||
def _write_caldav_tasks_config(root: Path, *, url: str, thresholds: dict | None = None) -> None:
|
||||
(root / ".wikitool-tasks.json").write_text(
|
||||
json.dumps({
|
||||
"schema": 1,
|
||||
"provider": "caldav",
|
||||
"thresholds": thresholds or THRESHOLDS,
|
||||
"caldav": {
|
||||
"url": url, "username": _CALDAV_TOKEN[0], "app_password": _CALDAV_TOKEN[1],
|
||||
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _project_page(root: Path, name: str, state: str, *, area: str = "haus") -> None:
|
||||
write_page(
|
||||
root / "kb" / "gtd" / area / f"{name}.md",
|
||||
@@ -217,6 +241,117 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
|
||||
assert not any(f.check == CHECK_NO_OPEN_LOOP for f in report.findings)
|
||||
|
||||
|
||||
# --- D3 (Gitea #139): a value the provider cannot supply is its own finding --------
|
||||
|
||||
|
||||
def test_d3_sp_waiting_with_no_follow_up_at_is_its_own_finding_not_a_silent_skip(tmp_path):
|
||||
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"]}} # no dueWithTime, no dueDay
|
||||
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)
|
||||
findings = [f for f in report.findings if f.project == "Kueche renovieren"]
|
||||
assert any(f.check == CHECK_WAITING_NO_FOLLOWUP for f in findings)
|
||||
assert not any(f.check == CHECK_WAITING_OVERDUE for f in findings)
|
||||
assert report.complete
|
||||
|
||||
|
||||
def test_d3_sp_project_with_no_created_date_is_its_own_finding_not_a_silent_skip(tmp_path):
|
||||
projects = {"p1": {"id": "p1", "title": "No Page Yet", "taskIds": [], "backlogTaskIds": []}}
|
||||
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)
|
||||
findings = [f for f in report.findings if f.project == "No Page Yet"]
|
||||
assert any(f.check == CHECK_PROJECT_AGE_UNKNOWN for f in findings)
|
||||
assert not any(f.check == CHECK_UNPAGED_PROJECT for f in findings)
|
||||
assert report.complete
|
||||
|
||||
|
||||
def test_d3_caldav_waiting_with_no_follow_up_at_is_its_own_finding(tmp_path):
|
||||
entries = [
|
||||
("/cal/proj1/", "Kueche renovieren", frozenset({"VTODO"})),
|
||||
("/cal/someday/", "Someday", frozenset({"VTODO"})),
|
||||
]
|
||||
ics = _vtodo_ics(uid="t1", summary="Warte auf Angebot", categories=["waiting"]) # no dtstart
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])),
|
||||
("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([])),
|
||||
}) as server:
|
||||
_write_caldav_tasks_config(tmp_path, url=f"http://127.0.0.1:{server.server_address[1]}/cal/")
|
||||
_project_page(tmp_path, "Kueche renovieren", "active")
|
||||
|
||||
report = run_review(tmp_path, today=TODAY)
|
||||
|
||||
findings = [f for f in report.findings if f.project == "Kueche renovieren"]
|
||||
assert any(f.check == CHECK_WAITING_NO_FOLLOWUP for f in findings)
|
||||
assert not any(f.check == CHECK_WAITING_OVERDUE for f in findings)
|
||||
assert report.complete
|
||||
|
||||
|
||||
def test_d3_caldav_project_with_no_determinable_created_date_is_its_own_finding(tmp_path):
|
||||
"""No CalDAV server in #139's own test account ever returns
|
||||
`DAV:creationdate` on a calendar - an empty project is the concrete case
|
||||
where the substitute value (earliest item `CREATED`) has nothing to work
|
||||
from."""
|
||||
entries = [
|
||||
("/cal/proj1/", "No Page Yet", frozenset({"VTODO"})),
|
||||
("/cal/someday/", "Someday", frozenset({"VTODO"})),
|
||||
]
|
||||
with _canned_server({
|
||||
("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)),
|
||||
("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])),
|
||||
("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([])),
|
||||
}) as server:
|
||||
_write_caldav_tasks_config(tmp_path, url=f"http://127.0.0.1:{server.server_address[1]}/cal/")
|
||||
(tmp_path / "kb" / "gtd").mkdir(parents=True)
|
||||
|
||||
report = run_review(tmp_path, today=TODAY)
|
||||
|
||||
findings = [f for f in report.findings if f.project == "No Page Yet"]
|
||||
assert any(f.check == CHECK_PROJECT_AGE_UNKNOWN for f in findings)
|
||||
assert not any(f.check == CHECK_UNPAGED_PROJECT for f in findings)
|
||||
assert report.complete
|
||||
|
||||
|
||||
def test_d3_text_and_json_output_agree(tmp_path, monkeypatch):
|
||||
from chemenu import config
|
||||
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
|
||||
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False, "tagIds": ["tag-wait"]}}
|
||||
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")
|
||||
|
||||
text_result = runner.invoke(app, ["review"])
|
||||
json_result = runner.invoke(app, ["review", "--json"])
|
||||
assert text_result.exit_code == 0
|
||||
assert json_result.exit_code == 0
|
||||
|
||||
payload = json.loads(json_result.output)
|
||||
json_pairs = {(f["check"], f["project"]) for f in payload["findings"]}
|
||||
|
||||
import re
|
||||
|
||||
text_pairs = {
|
||||
(m.group(1), m.group(2))
|
||||
for m in re.finditer(r"^\[(\S+)\] ([^:]+):", text_result.output, re.MULTILINE)
|
||||
}
|
||||
assert json_pairs == text_pairs
|
||||
assert (CHECK_WAITING_NO_FOLLOWUP, "Kueche renovieren") in json_pairs
|
||||
|
||||
|
||||
# --- check 4: kb/ page with no open loop -------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@ typer>=0.12
|
||||
rich>=13.0
|
||||
jsonschema>=4.18
|
||||
referencing>=0.30
|
||||
icalendar>=6.0
|
||||
Reference in new issue
Block a user