Files
chemenu/tools/chemenu/cli_contract.py
T
torben 5aae7fed1b
CI / verify (push) Successful in 1m12s
Release / release (push) Successful in 35s
tools: command records - NOTES always bullets, examples held by tests (#142)
Files changed:
- CHANGES.md
- VERSION
- tools/chemenu/cli_contract.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_docs_verify.py
2026-09-26 09:33:46 +02:00

509 lines
18 KiB
Python

"""One data record per `wikitool` command - the single source three views are
rendered from: `wikitool <cmd> -h` (the full record, plain text), the index
line (`wikitool -h` and the top of `tools/CONTRACT.md`), and the generated
`<!-- wikitool:commands -->` region of `tools/CONTRACT.md` itself.
A record is attached to its command function, in that function's own module,
via the `@record(...)` decorator - never centralised, so the contract sits
next to the code it describes. `GROUPS` is the one thing that stays central:
the `###`-level grouping and rendering order, unchanged from what
`tools/CONTRACT.md` carried before this module existed.
Phase 1 (Gitea #121) filled every record mechanically and word-for-word from
the two tables `tools/CONTRACT.md` used to carry. Phase 2 (Gitea #142)
rewrote them: NOTES as present-tense bullets, one `Failure` per cause,
EXAMPLES/NEVER/SEE ALSO filled, and "why" moved out to a code comment where
the behaviour is implemented. How a record is written is the `CommandRecord`
docstring's job. A sentence several records carry verbatim lives here once
(`token_gate_reaction`, ...), so the output repeats it and the source does
not.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional, TypeVar
_F = TypeVar("_F", bound=Callable)
class Effect(str, Enum):
READ = "read"
WRITE = "write"
class Idempotent(str, Enum):
YES = "yes"
NO = "no"
class Budget(str, Enum):
"""What the Iteration Budget Gate does with a call to this command.
`EXEMPT_WITHOUT_ARGS` is `version regrade`'s own shape: the bare listing
only reads, but any index argument writes `CHANGES.md` and is counted like
`version bump` - one command, two answers, depending on whether it was
called with arguments at all (see `run_budget.is_exempt`).
"""
COUNTED = "counted"
EXEMPT = "exempt"
EXEMPT_WITHOUT_ARGS = "exempt_without_args"
class Network(str, Enum):
YES = "yes"
NO = "no"
@dataclass(frozen=True)
class Variant:
"""One usage form of a command that takes more than one shape - `new`'s
`new <type-name>` vs `new entity` vs `new project`, `raw accept`'s plain
form vs `--replaces`. `notes` is empty unless the variant needs a sentence
of its own beyond what NOTES already says for the command as a whole."""
usage: str
notes: str = ""
@dataclass(frozen=True)
class Failure:
"""One cause of a non-success exit, and what the caller does about it.
EXIT STATUS renders one `<code> <cause>` line per entry, ON FAILURE one
`<cause> -> <reaction>` line - the cause is repeated on purpose, so each
ON FAILURE line reads on its own. `code` is 1 (validation error) or 42
(a gate needs clearance; only on a command whose `Properties.gates` is
non-empty), or 0 for an outcome a caller could mistake for a failure but
that is not one (an unreachable remote reported and skipped). An empty
`reaction` renders the EXIT STATUS line only.
`label` names the usage form a cause belongs to (`"new project"`) and is
rendered as a `<label>: ` prefix on both lines; empty when the command
has one form, or when the cause already says it."""
cause: str
reaction: str
code: int = 1
label: str = ""
def __post_init__(self) -> None:
if self.code not in (0, 1, 42):
raise ValueError(f"cli_contract: Failure.code must be 0, 1 or 42, not {self.code}")
@dataclass(frozen=True)
class Properties:
effect: Effect
idempotent: Idempotent
atomic: str
budget: Budget
network: Network = Network.NO
gates: tuple[str, ...] = ()
@dataclass(frozen=True)
class CommandRecord:
"""The man-page-shaped record for one command path (e.g. `"publish"`,
`"xref add"`). Empty `examples`, `never` and `see_also` render as absent,
not empty - see `render_text`.
How the prose is written - a record is read on its own, by an agent that
asked for exactly this command:
- `notes`: one bullet per behaviour, present tense. What the command does,
not why it was built that way - a "why" goes into a comment where the
behaviour is implemented, and history into `CHANGES.md` or nowhere.
- `failures`: one entry per cause, each with its own reaction. A rule
("do not retry", "show the output and stop") belongs in the reaction or
in `never`, never only in `notes`.
- `examples`: one to three copyable calls, the most common first; a gated
command also shows its re-run after exit 42.
- `never`: the prohibitions for the caller, one per line.
- `see_also`: related commands and the instruction that uses this one.
It is the only place another command may be named for context - a
behaviour this command shares with another is stated here in full,
not as "same as `X`"."""
path: str
summary: str
synopsis: tuple[Variant, ...]
properties: Properties
notes: tuple[str, ...]
failures: tuple[Failure, ...]
examples: tuple[str, ...] = ()
never: tuple[str, ...] = ()
see_also: tuple[str, ...] = ()
def __post_init__(self) -> None:
if isinstance(self.notes, str):
raise ValueError(
f"cli_contract: {self.path!r} notes must be a tuple of bullets, not one string"
)
if not self.properties.gates and any(f.code == 42 for f in self.failures):
raise ValueError(
f"cli_contract: {self.path!r} lists an exit-42 cause but declares no gate"
)
# ---------------------------------------------------------------------------
# Shared sentences - text more than one record carries word for word.
# ---------------------------------------------------------------------------
def token_gate_reaction(flag: str) -> str:
"""The ON FAILURE reaction to an exit-42 gate that is cleared by a token
(`--confirm`, `--confirm-rebase`): every such gate prints its evidence and
the exact re-run line, and refuses a token that does not match the state
it was issued for."""
return (
"Show the user the command's full output verbatim and stop. Once they have approved "
f"it, run the re-run line the output prints, which carries `{flag} <token>`. Without "
"that token, or with a wrong, invented or superseded one, it exits 42 again with the "
"current state"
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, CommandRecord] = {}
def record(rec: CommandRecord) -> Callable[[_F], _F]:
"""Attach `rec` to a command function and register it under `rec.path`.
Registering twice under the same path is refused rather than silently
overwritten - two decorators claiming the same command path is a copy-
paste mistake, not a legitimate case (a command with more than one usage
form gets more than one `Variant`/`Failure` *inside* one record, not two
records)."""
if rec.path in _REGISTRY:
raise ValueError(f"cli_contract: duplicate record for {rec.path!r}")
_REGISTRY[rec.path] = rec
def decorator(fn: _F) -> _F:
fn.__wikitool_contract__ = rec # type: ignore[attr-defined]
return fn
return decorator
def get(path: str) -> Optional[CommandRecord]:
return _REGISTRY.get(path)
def all_records() -> dict[str, CommandRecord]:
"""A copy of the registry, keyed by command path."""
return dict(_REGISTRY)
def reset_registry_for_tests() -> None:
"""Test-only escape hatch: clear the registry so a fixture module can
register its own records without colliding with the real CLI's. Nothing
in the shipped CLI calls this."""
_REGISTRY.clear()
# ---------------------------------------------------------------------------
# Groups - the `###`-level sections `tools/CONTRACT.md` renders, in order.
# ---------------------------------------------------------------------------
GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Pages", (
"new", "task new", "task list", "task close",
"touch", "rename", "rm", "move",
)),
("Links and citations", (
"xref add", "xref remove", "xref link-source",
"links show", "cite id", "cite add", "cite sync",
)),
("Catalog and log", (
"index rebuild", "log append", "log status",
)),
("Finding and checking", (
"lint", "search", "review",
)),
("Provenance", (
"sources coverage", "sources trace", "sources rebuild-index",
)),
("Raw material and uploads", (
"raw accept", "upload list", "upload show", "upload accept", "upload reject",
)),
("Git", (
"sync", "publish",
)),
("Workshop runs and session budget", (
"work new", "work close", "budget status", "budget reset",
)),
("Types, instructions and docs", (
"types list", "types describe",
"instructions sync", "instructions verify", "instructions list",
"docs verify", "docs toc", "docs contract",
)),
("Telemetry", (
"eval sessions", "eval score",
)),
("Distribution and versioning", (
"dist export", "dist upgrade",
"version show", "version check", "version notes",
"version bump", "version regrade", "version release",
)),
("Content migrations", (
"migrate list", "migrate status", "migrate verify", "migrate done", "migrate baseline",
)),
("Private instances", (
"upstream merge", "upstream verify",
)),
("Instance health", (
"doctor",
)),
)
GroupsType = tuple[tuple[str, tuple[str, ...]], ...]
def grouped_paths(groups: GroupsType = GROUPS) -> tuple[str, ...]:
"""Every command path named by `groups`, in rendering order."""
return tuple(path for _, paths in groups for path in paths)
def group_of(path: str, groups: GroupsType = GROUPS) -> Optional[str]:
for title, paths in groups:
if path in paths:
return title
return None
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
_SECTION_ORDER = (
"NAME", "SYNOPSIS", "PROPERTIES", "EXAMPLES", "OPTIONS",
"EXIT STATUS", "ON FAILURE", "NEVER", "NOTES", "SEE ALSO",
)
def _exit_codes(rec: CommandRecord) -> list[int]:
codes = {0} | {failure.code for failure in rec.failures}
if rec.properties.gates:
codes.add(42)
return sorted(codes)
def _labelled(failure: Failure, text: str) -> str:
return f"{failure.label}: {text}" if failure.label else text
def _notes_lines(notes: tuple[str, ...]) -> list[str]:
"""NOTES as rendered lines: one `- ` bullet per entry."""
return [f"- {note}" for note in notes]
def _idempotent_text(idempotent: Idempotent) -> str:
return "idempotent" if idempotent == Idempotent.YES else "non-idempotent"
def _budget_text(budget: Budget) -> str:
return {
Budget.COUNTED: "budget:counted",
Budget.EXEMPT: "budget:exempt",
Budget.EXEMPT_WITHOUT_ARGS: "budget:exempt_without_args",
}[budget]
def render_properties_lines(props: Properties) -> list[str]:
lines = [
f"effect {props.effect.value}",
f"idempotent {props.idempotent.value}",
f"atomic {props.atomic}",
f"budget {props.budget.value}",
f"network {props.network.value}",
]
if props.gates:
lines.append(f"gates {', '.join(props.gates)}")
return lines
def render_exit_status_lines(rec: CommandRecord) -> list[str]:
"""`0 success` first, then one line per `Failure` in code order (stable
within a code). A command with gates but no explicit exit-42 cause gets
one generic 42 line naming them."""
lines = ["0 success"]
for failure in sorted(rec.failures, key=lambda f: f.code):
lines.append(f"{str(failure.code).ljust(4)} {_labelled(failure, failure.cause)}")
if rec.properties.gates and not any(f.code == 42 for f in rec.failures):
gate_list = ", ".join(rec.properties.gates)
lines.append(f"42 needs clearance - {gate_list} (see AGENTS.md § Gates)")
return lines
def render_on_failure_lines(rec: CommandRecord) -> list[str]:
return [
_labelled(failure, f"{failure.cause} -> {failure.reaction}")
for failure in sorted(rec.failures, key=lambda f: f.code)
if failure.reaction
]
def render_text(rec: CommandRecord, options_text: str = "") -> str:
"""The full plain-text record, in NAME/SYNOPSIS/.../SEE ALSO order, for
`wikitool <path> -h`. `options_text` is Click's own rendered Options
block (already flag-formatted) for this command, spliced in between
EXAMPLES and EXIT STATUS - see `chemenu.cli` for how it is obtained.
Empty sections (EXAMPLES/NEVER/SEE ALSO, ON FAILURE with no reaction to
give, OPTIONS for a command with none) are omitted entirely rather than
printed empty."""
blocks: list[str] = []
blocks.append(f"NAME\n wikitool {rec.path} - {rec.summary}")
synopsis_lines = "\n".join(
f" wikitool {variant.usage}" + (f"\n {variant.notes}" if variant.notes else "")
for variant in rec.synopsis
)
blocks.append(f"SYNOPSIS\n{synopsis_lines}")
props_lines = "\n".join(f" {line}" for line in render_properties_lines(rec.properties))
blocks.append(f"PROPERTIES\n{props_lines}")
if rec.examples:
example_lines = "\n".join(f" {example}" for example in rec.examples)
blocks.append(f"EXAMPLES\n{example_lines}")
if options_text.strip():
blocks.append(f"OPTIONS\n{options_text.rstrip()}")
exit_lines = "\n".join(f" {line}" for line in render_exit_status_lines(rec))
blocks.append(f"EXIT STATUS\n{exit_lines}")
on_failure = render_on_failure_lines(rec)
if on_failure:
failure_lines = "\n".join(f" {line}" for line in on_failure)
blocks.append(f"ON FAILURE\n{failure_lines}")
if rec.never:
never_lines = "\n".join(f" - {n}" for n in rec.never)
blocks.append(f"NEVER\n{never_lines}")
notes_lines = "\n".join(f" {line}" for line in _notes_lines(rec.notes))
blocks.append(f"NOTES\n{notes_lines}")
if rec.see_also:
see_also_lines = "\n".join(f" - {s}" for s in rec.see_also)
blocks.append(f"SEE ALSO\n{see_also_lines}")
return "\n\n".join(blocks) + "\n"
def render_index_line(rec: CommandRecord, name_width: int = 15) -> str:
"""One `wikitool -h`/index line: name, typed properties, one-sentence
purpose - fixed-width columns so a `grep` and a human's eyes both work.
"""
exit_text = "exit:" + ",".join(str(code) for code in _exit_codes(rec))
columns = [
rec.path.ljust(name_width),
rec.properties.effect.value.ljust(6),
_idempotent_text(rec.properties.idempotent).ljust(15),
_budget_text(rec.properties.budget).ljust(28),
exit_text.ljust(12),
]
return "".join(columns) + rec.summary
def render_index(
records: Optional[dict[str, CommandRecord]] = None, groups: GroupsType = GROUPS
) -> str:
"""The full index, one line per command, in `groups` order."""
records = records if records is not None else all_records()
width = max((len(path) for path in records), default=15) + 1
lines = []
for path in grouped_paths(groups):
rec = records.get(path)
if rec is None:
continue
lines.append(render_index_line(rec, name_width=width))
return "\n".join(lines)
def render_markdown_section(rec: CommandRecord) -> str:
"""The `#### <path>` markdown form of one record, for the generated
region of `tools/CONTRACT.md`. Same section order and content as
`render_text`, minus OPTIONS (Click's own `--help` already carries the
flags; the generated markdown does not re-derive them)."""
lines = [f"#### `{rec.path}`", "", rec.summary, ""]
lines.append("**SYNOPSIS**")
lines.append("")
for variant in rec.synopsis:
note = f" - {variant.notes}" if variant.notes else ""
lines.append(f"- `wikitool {variant.usage}`{note}")
lines.append("")
lines.append("**PROPERTIES**")
lines.append("")
for line in render_properties_lines(rec.properties):
key, _, value = line.partition(" ")
lines.append(f"- {key}: {value.strip()}")
lines.append("")
if rec.examples:
lines.append("**EXAMPLES**")
lines.append("")
for example in rec.examples:
lines.append(f"- `{example}`")
lines.append("")
lines.append("**EXIT STATUS**")
lines.append("")
for line in render_exit_status_lines(rec):
lines.append(f"- {line}")
lines.append("")
on_failure = render_on_failure_lines(rec)
if on_failure:
lines.append("**ON FAILURE**")
lines.append("")
for line in on_failure:
lines.append(f"- {line}")
lines.append("")
if rec.never:
lines.append("**NEVER**")
lines.append("")
for n in rec.never:
lines.append(f"- {n}")
lines.append("")
lines.append("**NOTES**")
lines.append("")
lines.extend(_notes_lines(rec.notes))
lines.append("")
if rec.see_also:
lines.append("**SEE ALSO**")
lines.append("")
for s in rec.see_also:
lines.append(f"- {s}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def render_commands_region(
records: Optional[dict[str, CommandRecord]] = None, groups: GroupsType = GROUPS
) -> str:
"""The full `<!-- wikitool:commands -->` region body: the index, then
each `###` group with its commands' `#### <path>` records."""
records = records if records is not None else all_records()
parts = ["```", render_index(records, groups), "```", ""]
for title, paths in groups:
present = [p for p in paths if p in records]
if not present:
continue
parts.append(f"### {title}")
parts.append("")
for path in present:
parts.append(render_markdown_section(records[path]))
return "\n".join(parts).rstrip() + "\n"