Files
chemenu/tools/chemenu/commands/migrate_cmd.py
T
torben d29d400dd3
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s
feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
Files changed:
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- README.md
- VERSION
- docs/version-model.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/kb_state.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-03 22:19:41 +02:00

470 lines
18 KiB
Python

"""`wikitool migrate` - content migrations: what this instance still owes, and
whether a bulk rewrite broke anything.
Five commands around two facts. `.wikitool-kb.json` (see `chemenu/kb_state.py`)
records what shape the content is in, so the chain of outstanding migrations is
computed rather than guessed. `migrate verify` compares the corpus against a git
revision on the invariants a migration must not change (see
`chemenu/corpus_diff.py`).
**There is no `migrate run`.** An `assisted` migration is a procedure an agent
carries out page by page; the tool keeps the books and checks the result. A
`run` would claim an ability that does not exist - it arrives when mechanical
primitives do.
"""
from __future__ import annotations
import json as _json
import subprocess
from pathlib import Path
from typing import Optional
import typer
from chemenu import config, corpus_diff, kb_scan, kb_state, version as version_mod
from chemenu.commands._util import console, fail, rel_path, success, today_iso
from chemenu.frontmatter_io import read_page
from chemenu.page import Page
from chemenu.version import Version, VersionError
app = typer.Typer(help="Content migrations: outstanding chain, bookkeeping, and verification.")
# --- shared state loading --------------------------------------------------
def _versions() -> tuple[Version, Optional[Version]]:
"""(stack version, kb version).
An unreadable VERSION or a corrupt state file exits 1 here (via `fail`,
which raises); a *missing* kb version returns None, because that is a
state each command explains in its own words rather than an error."""
try:
return version_mod.read_version(), kb_state.read_kb_version()
except VersionError as exc:
fail(str(exc))
raise # unreachable: fail() raises typer.Exit
# --- migrate list ----------------------------------------------------------
@app.command("list")
def list_command(
json_out: bool = typer.Option(False, "--json", help="Print the migrations as JSON"),
):
"""List every migration document, oldest target first. Read-only."""
migrations = kb_state.load_migrations()
if json_out:
typer.echo(
_json.dumps(
[
{
"name": m.name,
"migrates_to": str(m.target),
"migration_kind": m.kind,
"obligation": m.obligation,
"description": m.description,
"path": m.relative_path,
}
for m in migrations
],
indent=2,
)
)
return
if not migrations:
success(f"No migration documents under {rel_path(kb_state.migrations_dir())}.")
return
for migration in migrations:
console.print(
f"[bold]{migration.target}[/bold] {migration.name} "
f"({migration.kind}, {migration.obligation})"
)
if migration.description:
console.print(f" {migration.description}")
# --- migrate status --------------------------------------------------------
def _report_offers(
offered: list["kb_state.Migration"], divergent: Optional[list[str]]
) -> None:
"""Print the optional half of `status`, above the outstanding chain.
Deliberately never affects the exit code and never says "outstanding". An
offer is the stack proposing a better default for a file the instance owns;
an instance that keeps its own version is in a correct state, not a late
one. Mixing the two is how the message that actually matters - your content
no longer fits your machinery - stops being read.
"""
if not offered:
return
console.print(
f"[cyan]{len(offered)} optional upgrade(s) available[/cyan] - none of them block:"
)
for migration in offered:
console.print(f" {migration.target} {migration.name} ({migration.kind})")
if migration.description:
console.print(f" {migration.description}")
console.print(f" {migration.relative_path}")
if divergent is None:
console.print(
" [dim]This tree carries no release stamp, so which of your files still match "
"what you were given cannot be answered here.[/dim]"
)
return
if divergent:
console.print(
f" [dim]{len(divergent)} file(s) differ from the release you installed - those are "
"yours to reconcile by hand rather than overwrite:[/dim]"
)
for relative in divergent[:10]:
console.print(f" [dim]{relative}[/dim]")
if len(divergent) > 10:
console.print(f" [dim]... and {len(divergent) - 10} more[/dim]")
else:
console.print(
" [dim]No file differs from the release you installed, so an offer can be taken "
"by copying.[/dim]"
)
@app.command("status")
def status_command(
json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"),
):
"""Show the migrations this instance still owes, in the order they run.
Read-only. Exit 1 only when the KB version is undeclared - that is a
question the tool refuses to answer by guessing."""
stack, kb_version = _versions()
migrations = kb_state.load_migrations()
if kb_version is None:
if json_out:
typer.echo(
_json.dumps(
{"stack_version": str(stack), "kb_version": None, "pending": None}, indent=2
)
)
fail(
f"{kb_state.KB_STATE_FILENAME} is missing - this instance has never declared what "
f"shape its content is in, and guessing would be wrong exactly when it matters.\n"
f"Declare it once: `wikitool migrate baseline <version>` (use {stack} if this "
f"instance's content has never been migrated behind its machinery)."
)
return
pending = kb_state.chain(migrations, kb_version, stack.base)
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
divergent = kb_state.divergent_files()
if json_out:
typer.echo(
_json.dumps(
{
"stack_version": str(stack),
"kb_version": str(kb_version),
"pending": [
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
for m in pending
],
"offered": [
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
for m in offered
],
"divergent_files": divergent,
},
indent=2,
)
)
return
console.print(f"stack {stack}, content {kb_version}")
_report_offers(offered, divergent)
if not pending:
if kb_version < stack:
console.print(
f"[green]Nothing outstanding[/green] - no migration targets the range "
f"({kb_version}, {stack}]."
)
else:
console.print("[green]Nothing outstanding[/green] - content matches the machinery.")
return
console.print(f"[cyan]{len(pending)} migration(s) outstanding, in this order:[/cyan]")
for position, migration in enumerate(pending, start=1):
console.print(f" {position}. {migration.target} {migration.name} ({migration.kind})")
if migration.description:
console.print(f" {migration.description}")
console.print(f" {migration.relative_path}")
console.print(
f"\nRun the first one, then record it: `wikitool migrate done {pending[0].target}`.\n"
"The procedure is instructions/migrate-corpus.md."
)
# --- migrate done / baseline ----------------------------------------------
@app.command("done")
def done_command(
version: str = typer.Argument(..., help="The migration's target version, e.g. 1.4.0"),
pages: Optional[int] = typer.Option(None, "--pages", help="How many pages it touched"),
dry_run: bool = typer.Option(False, "--dry-run", help="Report without writing"),
):
"""Record one migration as applied, advancing the KB version to its target.
Refuses any version that is not the *next* link in the chain: skipping a
migration is how a corpus ends up in a shape no version describes, and an
interrupted multi-step upgrade has to be resumable rather than guessable.
An `offered` migration is recorded but does not move the version, and no
ordering rule applies to it - it is not a link in the chain. The record is
the only thing that distinguishes an offer someone took from one they
ignored, precisely because the version stays put."""
stack, kb_version = _versions()
if kb_version is None:
fail(
f"{kb_state.KB_STATE_FILENAME} is missing - run `wikitool migrate baseline <version>` "
"before recording a migration."
)
return
try:
target = Version.parse(version)
except VersionError as exc:
fail(str(exc))
return
migrations = kb_state.load_migrations()
state = kb_state.read_kb_state() or {}
# An offer is recorded but does not advance the version: it is not a link in
# the chain, so there is no ordering rule to check and nothing to skip. The
# ledger is what makes it stop being offered - without that record there
# would be no way to tell a taken offer from an ignored one, because
# `kb_version` deliberately does not move.
offered = {m.name: m for m in migrations if not m.is_required}
taken = next((m for m in offered.values() if str(m.target) == version), None)
if taken is not None:
if taken.name in kb_state.applied_names(state):
success(f"{taken.name} is already recorded as taken. Nothing to do.")
return
if dry_run:
success(f"Dry run: would record the optional {taken.name}. Nothing written.")
return
applied = list(state.get("applied") or [])
entry = {"migration": taken.name, "at": today_iso(), "obligation": kb_state.OFFERED}
if pages is not None:
entry["pages"] = pages
applied.append(entry)
kb_state.write_kb_state(kb_version, applied)
success(
f"Recorded the optional {taken.name}. Content stays at {kb_version} - an offer "
"changes a file you own, not the shape of your content."
)
return
expected = kb_state.next_link(migrations, kb_version, stack.base)
if expected is None:
fail(
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
f"migration targets the range in between."
)
return
if expected.target != target:
fail(
f"{target} is not the next migration. The chain from {kb_version} continues with "
f"{expected.target} ({expected.name}) - applying them out of order leaves the corpus "
f"in a shape no version describes.\nRun `wikitool migrate status` to see the order."
)
return
applied = list(state.get("applied") or [])
entry = {"migration": expected.name, "at": today_iso()}
if pages is not None:
entry["pages"] = pages
applied.append(entry)
if dry_run:
success(f"Dry run: content {kb_version} -> {target} ({expected.name}). Nothing written.")
return
kb_state.write_kb_state(target, applied)
remaining = kb_state.chain(migrations, target, stack.base)
success(
f"Content is now {target} ({expected.name}). "
+ (
f"{len(remaining)} migration(s) still outstanding - next is {remaining[0].target}."
if remaining
else "Nothing outstanding."
)
)
@app.command("baseline")
def baseline_command(
version: str = typer.Argument(..., help="The shape this instance's content is already in"),
force: bool = typer.Option(
False, "--force", help="Overwrite an existing declaration (not a substitute for `done`)"
),
):
"""Declare the KB version once, for an instance that never had one.
Only for a tree predating `.wikitool-kb.json`. Advancing the version after
a migration is `migrate done`, which checks the chain; this command does
not, which is why it refuses to overwrite silently."""
try:
target = Version.parse(version)
except VersionError as exc:
fail(str(exc))
return
try:
existing = kb_state.read_kb_version()
except VersionError as exc:
fail(str(exc))
return
if existing is not None and not force:
fail(
f"This instance already declares content version {existing}. Use "
f"`wikitool migrate done <version>` to advance it after a migration, or --force "
f"if the declaration itself is wrong."
)
return
state = kb_state.read_kb_state() or {}
kb_state.write_kb_state(target, list(state.get("applied") or []))
success(f"Content version declared as {target}.")
# --- migrate verify --------------------------------------------------------
def _git_show(rev: str, relative: str) -> Optional[str]:
result = subprocess.run(
["git", "show", f"{rev}:{relative}"],
cwd=config.ROOT,
capture_output=True,
text=True,
)
return result.stdout if result.returncode == 0 else None
def _paths_at(rev: str) -> Optional[list[str]]:
result = subprocess.run(
["git", "ls-tree", "-r", "--name-only", "-z", rev, "--", "kb"],
cwd=config.ROOT,
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
# The same page/not-a-page rule the working tree is read with. Answering it
# differently on the two sides reported every COLLECTION.md and INDEX.md as
# a page that had since disappeared.
return [
path
for path in result.stdout.split("\0")
if path.startswith("kb/") and kb_scan.is_page_path(path[len("kb/"):])
]
def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
"""Page shapes as of `rev`, keyed by repo-relative path."""
import tempfile
shapes: dict[str, corpus_diff.PageShape] = {}
paths = _paths_at(rev)
if paths is None:
fail(f"`git show {rev}` failed - is {rev} a revision in this repository?")
return shapes
with tempfile.TemporaryDirectory() as tmp:
for relative in paths:
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
continue
text = _git_show(rev, relative)
if text is None:
continue
# read_page owns frontmatter parsing (and its error contract), so the
# historical blob is materialised under its real filename - the stem
# is the page title, which PageShape compares.
scratch = Path(tmp) / Path(relative).name
scratch.write_text(text, encoding="utf-8")
try:
frontmatter, body = read_page(scratch)
except Exception: # noqa: BLE001 - an unparseable historical page is not this tool's error
continue
shapes[relative] = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
return shapes
def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
shapes: dict[str, corpus_diff.PageShape] = {}
for path in kb_scan.iter_kb_pages(config.KB_DIR):
relative = path.relative_to(config.ROOT).as_posix()
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
continue
try:
frontmatter, body = read_page(path)
except Exception: # noqa: BLE001 - lint reports unreadable frontmatter
continue
shapes[relative] = corpus_diff.PageShape.of(Page(path, frontmatter, body))
return shapes
@app.command("verify")
def verify_command(
from_rev: str = typer.Option(..., "--from", help="Git revision to compare against, e.g. HEAD"),
path: Optional[list[str]] = typer.Option(
None, "--path", help="Limit to a subtree, repeatable (e.g. kb/concepts)"
),
expect_body_change: bool = typer.Option(
False, "--expect-body-change", help="Also report pages whose body did not change at all"
),
json_out: bool = typer.Option(False, "--json", help="Print the diff as JSON"),
fail_on_error: bool = typer.Option(
False, "--fail-on-error", help="Exit 1 if any invariant changed"
),
):
"""Compare kb/ against a git revision on the invariants a content migration
must not change: wikilink and citation *counts*, footnote definitions, H1,
and structural frontmatter.
Not migration-specific - worth running after any bulk rewrite. `lint` cannot
answer this: it reads one revision, so a reference that went missing is
invisible to it."""
wanted = {p.rstrip("/") for p in (path or [])}
before = _shapes_at_revision(from_rev, wanted)
after = _shapes_now(wanted)
diff = corpus_diff.compare(before, after, expect_body_change=expect_body_change)
if json_out:
typer.echo(
_json.dumps(
{
"from": from_rev,
"compared": diff.compared,
"added": diff.added,
"removed": diff.removed,
"findings": [
{"path": f.path, "kind": f.kind, "detail": f.detail} for f in diff.findings
],
},
indent=2,
)
)
else:
typer.echo(corpus_diff.render_report(diff, from_rev))
if diff.findings and fail_on_error:
raise typer.Exit(code=1)