Files
chemenu/tools/chemenu/commands/version_cmd.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

277 lines
10 KiB
Python

"""`wikitool version` - report, bump, and check the stack's version.
Three jobs that all hang off one number (see `chemenu/version.py` for what
that number means):
- `version show` answers "which stack is this instance running", offline, from
`VERSION` plus the release stamp `dist export` writes.
- `version bump` moves it, and writes the changelog *heading* that has to
accompany the move - the same structure-by-tool/prose-by-author split as
`new`. `docs verify` then holds the two together.
- `version check` is the one command in `wikitool` that makes a network call.
It is deliberately its own command: nothing else reaches for it implicitly,
it needs no key, it times out, and a feed that cannot be reached is reported
as an error rather than silently answered as "up to date".
"""
from __future__ import annotations
import json as _json
from typing import Optional
import typer
from chemenu import config, version as version_mod
from chemenu.commands._util import console, fail, rel_path, success, today_iso
from chemenu.version import Version, VersionError
app = typer.Typer(
help="Report, bump, and check the stack version (see tools/CONTRACT.md).",
invoke_without_command=True,
)
@app.callback()
def version_callback(ctx: typer.Context) -> None:
"""Bare `wikitool version` is a convenience alias for `version show`."""
if ctx.invoked_subcommand is None:
show_command(json_out=False)
def _describe_origin(stamp: Optional[dict]) -> str:
if not stamp:
return "development tree (no release stamp)"
parts = []
exported = stamp.get("exported_at")
if exported:
parts.append(f"exported {exported}")
commit = str(stamp.get("source_commit") or "")
if commit:
parts.append(f"from commit {commit[:12]}")
repo = stamp.get("source_repo")
if repo:
parts.append(str(repo))
return "distribution: " + ", ".join(parts) if parts else "distribution"
@app.command("show")
def show_command(
json_out: bool = typer.Option(False, "--json", help="Print the version and stamp as JSON"),
):
"""Print this instance's stack version and where it came from. Read-only,
offline, and exempt from the Iteration Budget Gate."""
try:
current = version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
if json_out:
typer.echo(
_json.dumps(
{
"version": str(current),
"compat_key": list(current.compat_key),
"stamp": stamp,
"update_url": version_mod.update_url(stamp),
},
indent=2,
)
)
return
console.print(f"[bold]{current}[/bold] ({_describe_origin(stamp)})")
if stamp and stamp.get("release_url"):
console.print(f"release: {stamp['release_url']}")
@app.command("check")
def check_command(
url: Optional[str] = typer.Option(
None, "--url", help="Release feed to ask (default: the stamp's, else the built-in origin)"
),
timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for the feed"),
json_out: bool = typer.Option(False, "--json", help="Print the result as JSON"),
):
"""Ask the origin's release feed whether a newer stack exists.
The only networked command in `wikitool`. Exits 1 if the feed cannot be
reached or does not answer with a release - an unreachable feed is not the
same answer as "up to date", and must never be reported as one."""
import os
try:
current = version_mod.read_version()
stamp = version_mod.read_stamp()
except VersionError as exc:
fail(str(exc))
return
feed = url or version_mod.update_url(stamp)
token = os.environ.get(version_mod.UPDATE_TOKEN_ENV, "").strip() or None
try:
latest, release_url, published = version_mod.fetch_latest_release(feed, token, timeout)
except VersionError as exc:
fail(str(exc))
return
status = version_mod.UpdateStatus(
local=current,
latest=latest,
state=version_mod.compare(current, latest),
release_url=release_url,
published_at=published,
)
if json_out:
typer.echo(
_json.dumps(
{
"local": str(status.local),
"latest": str(status.latest),
"state": status.state,
"requires_migration": status.state == "migration",
"release_url": status.release_url,
"published_at": status.published_at,
"feed": feed,
},
indent=2,
)
)
return
color = {"current": "green", "ahead": "yellow", "update": "cyan", "migration": "bold yellow"}
console.print(f"[{color[status.state]}]{status.headline}[/{color[status.state]}]")
if status.release_url:
console.print(f"release: {status.release_url}")
if status.state in ("update", "migration"):
console.print(
"Applying it is a separate, manual step - see INSTALL.md "
"§ 'Eine Instanz aktualisieren'."
)
@app.command("notes")
def notes_command(
version: Optional[str] = typer.Option(
None, "--version", help="Which entry to print (default: this tree's VERSION)"
),
):
"""Print one version's `CHANGES.md` entry, for use as release notes.
Mechanical extraction, so the release workflow never has to parse markdown
in shell."""
try:
wanted = Version.parse(version) if version else version_mod.read_version()
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - there are no release notes to print")
return
section = version_mod.changes_section(changes.read_text(encoding="utf-8"), wanted)
if section is None:
fail(
f"{version_mod.CHANGES_FILENAME} has no entry for {wanted} - "
f"run `wikitool version bump` before releasing, or write the entry"
)
return
typer.echo(section, nl=False)
@app.command("bump")
def bump_command(
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
no_migration: Optional[str] = typer.Option(
None,
"--no-migration",
help="Why this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
):
"""Raise the stack version and open its `CHANGES.md` entry.
Writes `VERSION` and inserts the entry's heading, date and author - the
entry's body stays the author's to write, the same way `new` produces
frontmatter and leaves the prose. `docs verify` afterwards enforces that
the two agree, so a bump with no entry cannot reach a release.
A bump that crosses the compatibility boundary additionally requires a
migration document for the new version, or `--no-migration "<reason>"`.
An instance learning that it must migrate, with nothing telling it how, is
the gap this closes."""
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
if len(selected) != 1:
fail("Pass exactly one of --major / --minor / --patch")
return
if not title.strip():
fail("--title must not be empty - it becomes the changelog entry's heading")
return
try:
current = version_mod.read_version()
new_version = current.bumped(selected[0])
except VersionError as exc:
fail(str(exc))
return
changes = version_mod.changes_file()
if not changes.is_file():
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
return
text = changes.read_text(encoding="utf-8")
existing = version_mod.top_changes_version(text)
if existing is not None and existing >= new_version:
fail(
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
f"than {new_version} - bump past it, or fix the changelog"
)
return
author = config.default_author() or "unknown"
crossing = new_version.compat_key != current.compat_key
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
if crossing and not no_migration:
from chemenu import kb_state
if not any(m.target == new_version for m in kb_state.load_migrations()):
fail(
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
f"instance must migrate - but no migration document targets {new_version}.\n"
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md "
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
f're-run with --no-migration "<reason>".'
)
return
if no_migration and not crossing:
fail(
f"--no-migration only applies to a bump that crosses the compatibility boundary; "
f"{current} -> {new_version} does not."
)
return
if dry_run:
success(f"Dry run: {current} -> {new_version}{boundary}. Nothing written.")
return
version_mod.write_version(new_version)
changes.write_text(
version_mod.insert_changes_entry(
text, new_version, today_iso(), title.strip(), author,
no_migration_reason=no_migration.strip() if no_migration else None,
),
encoding="utf-8",
)
success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened "
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing."
)