Files
chemenu/tools/chemenu/commands/touch.py
T
torben 24cd221b21
CI / verify (push) Successful in 55s
Release / release (push) Successful in 39s
fix: stale wiki/ path literals nach kb/ nachgezogen, mit Test-Guard gegen die naechste Umbenennung
Files changed:
- CHANGES.md
- VERSION
- kb/entities/projects/Chemenu.md
- kb/log.md
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/provenance_cmd.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/xref.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_log_append.py
- tools/chemenu/tests/test_source_hygiene.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/type_resolver.py
- tools/wikitool
- types/type-spec.md
- types/type-spec.schema.yaml
2026-09-17 08:59:25 +02:00

324 lines
14 KiB
Python

"""`wikitool touch` - update the self-describing frontmatter fields of a page.
`modified:`, `summary:` and `provenance:` describe the page itself rather than
its relationships, so they were the one part of frontmatter the skills still
told the LLM to edit by hand - a carve-out in the otherwise absolute "never
hand-write frontmatter" rule. Bumping a date and rewriting a one-line summary
are mechanical, so they belong here: the field name is chosen from the type's
own schema (`modified` for entity/concept, `date` for source), and the result
is schema-validated before it is written.
Only `modified:` is bumped automatically. A source's `date:` is the publication
date of the material itself, not a record of when we last edited the page, so it
changes only on an explicit `--date`.
"""
from __future__ import annotations
import datetime
from typing import Any, Dict, Optional
import typer
# Hard, non-optional dependency - see type_resolver.py's import comment.
from jsonschema import Draft202012Validator, FormatChecker
from chemenu import config
from chemenu.commands._util import (
check_raw_files_exist,
fail,
parse_set_fields,
rel_path,
success,
)
from chemenu.frontmatter_io import normalize_dates
from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages
from chemenu.type_resolver import resolver
# Ordered by preference: whichever the page's schema declares is the one that
# records "when was this page's content last confirmed?".
DATE_FIELDS = ("modified", "date")
# Fields `--set` refuses, each with the command that owns it instead. This is a
# denylist rather than an allowlist on purpose: an allowlist is a second copy of
# the schema, and the copy is the one that drifts - a field added to a type-spec
# would silently stay unwritable until someone remembered to widen the list.
# Everything the schema declares is settable unless there is a reason here.
UNSETTABLE = {
"type": (
"changing it changes the page's schema *and* the directory it belongs in - "
"see instructions/page-lifecycle.md"
),
"related": "page-reference field - use `wikitool xref add` / `xref remove`",
"sources": (
"page-reference field - written from the other side by "
"`wikitool xref link-source`, or cleared with `xref remove`"
),
"entities": (
"page-reference field - use `wikitool xref link-source --source <this page> "
"--entities <titles>`, which writes both directions; `xref remove` clears one"
),
"concepts": (
"page-reference field - use `wikitool xref link-source --source <this page> "
"--entities <titles>`, which writes both directions; `xref remove` clears one"
),
}
def _date_field(schema: Optional[Dict[str, Any]], frontmatter: Dict[str, Any]) -> Optional[str]:
properties = (schema or {}).get("properties", {})
for field in DATE_FIELDS:
if field in properties or field in frontmatter:
return field
return None
def _parse_date(text: str) -> datetime.date:
"""`--date` as a real date, or a refusal naming the expected shape."""
try:
return datetime.date.fromisoformat(text)
except ValueError:
fail(f"--date must be YYYY-MM-DD, got '{text}'.")
def validate_fields(
frontmatter: Dict[str, Any], schema: Optional[Dict[str, Any]], fields: set[str]
) -> Optional[str]:
"""Validate only the fields this command is writing.
Whole-document validation would refuse to bump `modified:` on a page that
is invalid for some unrelated, pre-existing reason - which is exactly the
page most in need of maintenance. Errors whose path points outside the
touched fields (missing required fields elsewhere, legacy extra keys) are
left for `wikitool lint` to report.
"""
if schema is None:
return None
validator = Draft202012Validator(schema, format_checker=FormatChecker())
messages = [
error.message
for error in validator.iter_errors(normalize_dates(frontmatter))
if error.path and error.path[0] in fields
]
return "; ".join(messages) if messages else None
def _settable_or_fail(field: str, schema: Optional[Dict[str, Any]], type_path: str) -> Dict[str, Any]:
"""Refuse a field this command must not write, and return its subschema.
Two refusals, deliberately worded differently. A field on `UNSETTABLE` is
writable in principle but belongs to another command, so the message names
that command. A field the schema does not declare is not a routing problem
but a typo or a wrong page type, so the message lists what this page
actually has - the value is knowing that `tag` should have been `tags`.
"""
if field in UNSETTABLE:
fail(f"`{field}` cannot be set with --set: {UNSETTABLE[field]}")
properties = (schema or {}).get("properties", {})
if field not in properties:
settable = sorted(set(properties) - set(UNSETTABLE))
fail(
f"Type {type_path} declares no field '{field}'.\n"
f" Settable fields for this page: {', '.join(settable) or '(none)'}"
)
return properties[field]
def _capture_field_or_fail(field: str, value: Any, frontmatter: Dict[str, Any]) -> None:
"""Refuse to overwrite a capture field (Gitea #67) that already carries a
value - fill-once, not a denylist entry: `UNSETTABLE` would also forbid
the *first* write, which is exactly the write the backfill needs. A
capture field is fixed at `raw accept`/`new source` time; the only
sanctioned way to change an already-set value is a new edition of the
raw material (`raw accept --replaces`), never a second `touch`.
"""
current = frontmatter.get(field)
if current not in (None, "") and current != value:
fail(
f"`{field}` is a capture field: fixed once, at `raw accept`/`new source` time, and "
f"already reads {current!r}. A corrected capture is a new edition of the source, not "
f"a touch -> `wikitool raw accept --replaces <raw path> <incoming file>`."
)
def _apply_set(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]:
if frontmatter.get(field) == value:
return None
before = frontmatter.get(field)
frontmatter[field] = value
return f"{field}: {before!r} -> {value!r}"
def _apply_add(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]:
"""Append list elements not already present, preserving order."""
if not isinstance(value, list):
fail(f"--add works on array fields only; '{field}' is not one. Use --set.")
current = list(frontmatter.get(field) or [])
added = [item for item in value if item not in current]
if not added:
return None
frontmatter[field] = current + added
return f"{field}: added {', '.join(repr(i) for i in added)}"
def _apply_remove(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]:
"""Drop list elements, reporting the ones that were not there.
Removing something absent succeeds rather than failing - `xref remove` is
idempotent for the same reason, and a repair command that refuses to run
twice is a repair command nobody dares script. But it is *reported*: a
silent no-op is how a mistyped element name looks exactly like a successful
removal.
"""
if not isinstance(value, list):
fail(f"--remove works on array fields only; '{field}' is not one. Use --set.")
current = list(frontmatter.get(field) or [])
present = [item for item in value if item in current]
absent = [item for item in value if item not in current]
if absent:
typer.echo(f" {field}: not present, nothing removed: {', '.join(repr(i) for i in absent)}")
if not present:
return None
frontmatter[field] = [item for item in current if item not in present]
return f"{field}: removed {', '.join(repr(i) for i in present)}"
def touch_command(
page_title: str = typer.Option(..., "--page", help="Exact page title, e.g. 'Docker Cheatsheet'"),
summary: Optional[str] = typer.Option(None, "--summary", help="Replace the page's 1-line summary"),
provenance: Optional[str] = typer.Option(
None, "--provenance", help="Replace the page's provenance marker (sourced|general|mixed)"
),
date: Optional[str] = typer.Option(
None, "--date",
help="Date to record (YYYY-MM-DD). `modified:` defaults to today; a source's "
"`date:` is its publication date and changes only when given here.",
),
set_fields: Optional[list[str]] = typer.Option(
None,
"--set",
help="Replace a frontmatter field, repeatable: --set tags=a,b. Array values split on "
"commas (escape a literal one as \\,); repeating --set for one array field appends "
"within this call. Page-reference fields belong to `xref`, not here",
),
add_fields: Optional[list[str]] = typer.Option(
None,
"--add",
help="Append elements to an array field without naming the whole list: --add tags=x. "
"Elements already present are left alone",
),
remove_fields: Optional[list[str]] = typer.Option(
None,
"--remove",
help="Drop elements from an array field: --remove tags=x. Removing an absent element "
"succeeds and says so",
),
no_date: bool = typer.Option(
False, "--no-date", help="Only change the given fields; leave the modified/date field alone"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview the new frontmatter instead of writing"),
):
"""Bump a page's `modified:` date and optionally rewrite its other frontmatter fields.
`--summary`/`--provenance` are shorthands for the two fields worth their
own flag; `--set`/`--add`/`--remove` reach every other field the page's
type declares. Before they existed, a field `new` wrote
once - `tags:`, `raw_files:` - could never be corrected: `touch` did not
know it, hand-editing frontmatter is what the tool exists to prevent, and
deleting the page to recreate it breaks every reference already pointing at
it. A mistyped `--set tags=` at creation was therefore permanent, and `new`
is not idempotent, so the window to get it right was exactly one command.
"""
pages = load_kb_pages(config.KB_DIR)
page = pages.get(page_title)
if page is None:
fail(f"No page titled '{page_title}' found under kb/. Create it first with `wikitool new ...`.")
type_path = page.frontmatter.get("type")
if not type_path:
fail(f"Page '{page_title}' has no `type:` frontmatter - fix it before touching it.")
try:
schema = resolver.get_schema(type_path, page.path)
capture_fields = set(resolver.get_capture_fields(type_path, page.path))
except ValueError as exc:
fail(str(exc))
frontmatter = dict(page.frontmatter)
changes: list[str] = []
touched: set[str] = set()
if not no_date:
field = _date_field(schema, frontmatter)
if field is None:
fail(f"Type {type_path} declares no modified/date field - pass --no-date to skip it.")
# `modified:` is ours to bump - it records when we last touched the page.
# `date:` is not: on a source it is the source material's own publication
# date, a fact about the world that today's date is simply wrong for.
# Auto-bumping it silently replaced a raw file's real date with the day
# the summary happened to be rewritten, and left the page contradicting
# the `**Datum:**` line in its own body. It is still writable, but only
# when the caller says so with an explicit `--date`.
if field == "date" and date is None:
touched.discard(field)
else:
# A `datetime.date`, not a string: that is what `yaml.safe_load`
# yields for every page already on disk, and writing anything else
# made an unchanged date compare unequal to itself - so `touch`
# reported a change on every run, and `dump_frontmatter` had to
# guess whether to quote what it was handed.
new_date = _parse_date(date) if date else datetime.date.today()
touched.add(field)
if frontmatter.get(field) != new_date:
frontmatter[field] = new_date
changes.append(f"{field}: {page.frontmatter.get(field)} -> {new_date.isoformat()}")
if summary is not None:
frontmatter["summary"] = summary
touched.add("summary")
changes.append("summary updated")
if provenance is not None:
frontmatter["provenance"] = provenance
touched.add("provenance")
changes.append(f"provenance: {page.frontmatter.get('provenance')} -> {provenance}")
# --set/--add/--remove last, so an explicit field always wins over the
# shorthand flags rather than depending on option order.
for flag, values, apply in (
("--set", set_fields, _apply_set),
("--add", add_fields, _apply_add),
("--remove", remove_fields, _apply_remove),
):
parsed = parse_set_fields(values, schema, flag=flag)
for field, value in parsed.items():
_settable_or_fail(field, schema, type_path)
if field in capture_fields:
_capture_field_or_fail(field, value, frontmatter)
change = apply(frontmatter, field, value)
touched.add(field)
if change:
changes.append(change)
# Filesystem check, not a data-shape one, so the schema cannot carry it -
# and `touch` writes this field now, so it owes the same check `new` does.
if "raw_files" in touched:
check_raw_files_exist(frontmatter.get("raw_files"))
error = validate_fields(frontmatter, schema, touched)
if error:
fail(f"Invalid value for type {type_path}: {error}")
if not changes:
success(f"'{page_title}' already up to date; nothing to change.")
return
for change in changes:
typer.echo(f" {change}")
if dry_run:
typer.echo("No files written (--dry-run).")
return
write_page(page.path, frontmatter, page.body)
success(f"Touched {rel_path(page.path)}")