Files changed: - CHANGES.md - VERSION - docs/how-to/README.md - docs/how-to/back-up-to-a-synced-folder.md - docs/how-to/collect-from-a-synced-folder.md - docs/how-to/export-guidelines.md - docs/how-to/export-to-a-synced-folder.md - docs/reference/commands/README.md - docs/reference/commands/exports.md - docs/reference/commands/git.md - docs/reference/commands/guideline-export.md - docs/reference/commands/instance-health.md - docs/reference/commands/raw-material-and-uploads.md - docs/reference/configuration.md - docs/reference/exit-codes.md - instructions/setup-instance.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/backup.py - tools/chemenu/cli_contract.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/export_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/raw/_common.py - tools/chemenu/commands/raw/accept.py - tools/chemenu/commands/raw/capture.py - tools/chemenu/config_shapes.py - tools/chemenu/guideline_export.py - tools/chemenu/page_export.py - tools/chemenu/reference_doc.py - tools/chemenu/repo_capture.py - tools/chemenu/tests/test_config_shapes.py - tools/chemenu/tests/test_export_run.py Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
556 lines
27 KiB
Python
556 lines
27 KiB
Python
"""`wikitool export guidelines` - the instance's guideline pages as one
|
|
generated `GUIDELINES.md`, printed, or written into the captured repositories
|
|
behind the Guideline Push Gate - and `wikitool export run`, the derived reading
|
|
copy of `kb/` in the folders the `export` section configures
|
|
(`chemenu/page_export.py`; no gate, since a target is a folder the user named).
|
|
|
|
The core - selection, rendering, the header - is `chemenu/guideline_export.py`;
|
|
the git write is `repo_capture.commit_file`/`push_commit`. This module decides
|
|
which captured repositories are targets, which of them take part, and when the
|
|
gate opens.
|
|
|
|
**Targets are the captured repositories, and nothing else.** The `repo` and
|
|
`ref` of every `_capture.json` under `raw/` - the one declaration that this
|
|
instance follows a repository, now read in both directions. A second list of
|
|
targets would be a second declaration of the same thing to drift.
|
|
|
|
**A repository takes part by carrying the file.** Only a branch whose root
|
|
`GUIDELINES.md` already starts with the guideline export header is written to;
|
|
a hand-written `GUIDELINES.md` is never replaced, and one whose header names a
|
|
different `instance=` belongs to that instance. A bare header line is the
|
|
opt-in.
|
|
|
|
**Why a gate, and why this shape.** A push into another repository is the most
|
|
irreversible thing the stack does, and a public target publishes the content -
|
|
the `instance=` URL included - the moment it lands. So it is held to the same
|
|
token form as the Mass-Update and Upload Review gates: the first run fetches,
|
|
checks and builds everything, pushes nothing and exits 42 with every target's
|
|
status and diff; the token digests exactly that set (URL, branch, old tip, new
|
|
blob), so a moved branch, a changed guideline or a new target asks again. The
|
|
Publish-Remote Gate is deliberately not applied: the targets are declared by
|
|
committed manifests already, and listing the same URLs in
|
|
`remotes.allowed_push_urls` would be the second declaration again.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
import shlex
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
from rich.markup import escape
|
|
|
|
from chemenu import cli_contract, config, guideline_export, page_export, repo_capture
|
|
from chemenu.commands._util import EXIT_NEEDS_CLEARANCE, console, fail, rel_path
|
|
from chemenu.errors import BackendError, ValidationError
|
|
from chemenu.search import filters
|
|
|
|
app = typer.Typer(help="Generate files from the wiki for other repositories and folders.")
|
|
|
|
GATE = "guideline-push"
|
|
|
|
|
|
@dataclass
|
|
class Target:
|
|
"""One `(repo, ref)` pair - several bundles captured from one branch are
|
|
one target. `error` is set when a manifest could not be read."""
|
|
repo: str
|
|
ref: str
|
|
bundles: list[str] = field(default_factory=list)
|
|
error: str = ""
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return f"{self.repo} {self.ref}"
|
|
|
|
|
|
@dataclass
|
|
class Plan:
|
|
target: Target
|
|
status: str # new | changed | unchanged | skipped
|
|
reason: str = ""
|
|
tip: str = ""
|
|
blob: str = ""
|
|
old: bytes = b""
|
|
|
|
@property
|
|
def writes(self) -> bool:
|
|
return self.status in ("new", "changed")
|
|
|
|
|
|
def _targets(only: list[Path]) -> list[Target]:
|
|
manifests = repo_capture.captured_manifests(config.RAW_DIR)
|
|
if only:
|
|
wanted = {p.resolve() for p in only}
|
|
known = {m.parent.resolve() for m in manifests}
|
|
unknown = sorted(rel_path(p) for p in only if p.resolve() not in known)
|
|
if unknown:
|
|
raise ValidationError(
|
|
f"--bundle {', '.join(unknown)}: not a captured bundle under raw/ (a directory "
|
|
f"holding {repo_capture.MANIFEST_NAME})."
|
|
)
|
|
manifests = [m for m in manifests if m.parent.resolve() in wanted]
|
|
targets: dict[tuple[str, str], Target] = {}
|
|
broken: list[Target] = []
|
|
for path in manifests:
|
|
bundle = rel_path(path.parent)
|
|
try:
|
|
manifest = repo_capture.read_manifest(path)
|
|
except ValidationError as exc:
|
|
broken.append(Target(bundle, "?", [bundle], f"manifest unreadable: {exc}"))
|
|
continue
|
|
target = targets.setdefault((manifest.repo, manifest.ref), Target(manifest.repo, manifest.ref))
|
|
target.bundles.append(bundle)
|
|
return [*targets.values(), *broken]
|
|
|
|
|
|
def _plan(target: Target, export: guideline_export.Export) -> Plan:
|
|
"""Where this target stands - fetched, never written to."""
|
|
if target.error:
|
|
return Plan(target, "skipped", target.error)
|
|
if repo_capture.is_tag_pattern(target.ref):
|
|
return Plan(target, "skipped", "captured by a tag pattern - a tag takes no commit")
|
|
try:
|
|
with repo_capture.cache_repo(target.repo) as repo:
|
|
resolved = repo_capture.resolve_ref(target.repo, target.ref, repo)
|
|
tip = repo_capture.fetch(target.repo, resolved.refname, repo)
|
|
existing = repo_capture.read_path(repo, tip, guideline_export.TARGET_PATH)
|
|
blob = repo_capture.blob_id(repo, export.data)
|
|
except BackendError as exc:
|
|
return Plan(target, "skipped", f"not reachable: {exc}")
|
|
except ValidationError as exc: # the branch does not exist
|
|
return Plan(target, "skipped", str(exc))
|
|
if existing is None:
|
|
return Plan(target, "skipped", f"no {guideline_export.TARGET_PATH} - not opted in", tip)
|
|
fields = guideline_export.guidelines_header(existing)
|
|
if fields is None:
|
|
return Plan(
|
|
target, "skipped",
|
|
f"{guideline_export.TARGET_PATH} has no export header - hand-written, never replaced", tip,
|
|
)
|
|
owner = fields.get("instance")
|
|
if owner is not None and owner != export.instance:
|
|
return Plan(target, "skipped", f"delivered by another instance ({owner})", tip)
|
|
if existing == export.data:
|
|
return Plan(target, "unchanged", tip=tip)
|
|
return Plan(target, "changed" if owner else "new", tip=tip, blob=blob, old=existing)
|
|
|
|
|
|
def confirm_token(plans: list[Plan]) -> str:
|
|
"""sha256 over every target the run would write - URL, branch, the tip it
|
|
builds on and the blob it writes - cut to 12 hex chars, the shape of
|
|
`git_publish.changeset_token`. The new commit's own id is not part of it:
|
|
`commit-tree` stamps the time, so it differs on every run."""
|
|
payload = json.dumps(
|
|
sorted([p.target.repo, p.target.ref, p.tip, p.blob] for p in plans if p.writes),
|
|
sort_keys=True,
|
|
)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
def _rerun(token: str, predicates: list[str], bundles: list[Path]) -> str:
|
|
parts = ["tools/wikitool", "export", "guidelines", "--confirm", token, "--push"]
|
|
for raw in predicates:
|
|
parts += ["--field", raw]
|
|
for bundle in bundles:
|
|
parts += ["--bundle", rel_path(bundle)]
|
|
return " ".join(shlex.quote(part) for part in parts)
|
|
|
|
|
|
def _diff(plan: Plan, export: guideline_export.Export) -> str:
|
|
old = plan.old.decode("utf-8", "replace").splitlines(keepends=True)
|
|
new = export.text.splitlines(keepends=True)
|
|
name = guideline_export.TARGET_PATH
|
|
return "".join(difflib.unified_diff(old, new, f"a/{name}", f"b/{name}"))
|
|
|
|
|
|
def _status_line(plan: Plan) -> str:
|
|
detail = f" ({plan.reason})" if plan.reason else ""
|
|
return f" {plan.target.label}: {plan.status}{detail}"
|
|
|
|
|
|
def _clearance(plans: list[Plan], export, token: str, rerun: str, stale: Optional[str]) -> str:
|
|
writes = [p for p in plans if p.writes]
|
|
lines = [
|
|
f"This run would push one commit changing only {guideline_export.TARGET_PATH} into "
|
|
f"{len(writes)} repositor{'y' if len(writes) == 1 else 'ies'}. A push there is visible at "
|
|
"once - publicly, for a public repository - and not cheaply reversible. Nothing was pushed.",
|
|
"",
|
|
]
|
|
if stale:
|
|
lines += [
|
|
f"The token you passed ({stale}) does not match this set - a branch moved, a guideline "
|
|
"changed, a target came or went, or the token was invented. Here is the current state.",
|
|
"",
|
|
]
|
|
lines += [
|
|
"THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.",
|
|
"Reproduce every target line and every diff below in your reply, and stop there. Run no "
|
|
"further commands in this turn.",
|
|
"",
|
|
f"TARGETS ({len(plans)}) - reproduce this in your reply:",
|
|
"",
|
|
*(_status_line(p) for p in plans),
|
|
]
|
|
for plan in writes:
|
|
lines += ["", f"DIFF {plan.target.label}:", "", _diff(plan, export).rstrip("\n")]
|
|
lines += [
|
|
"",
|
|
"Once they have replied approving exactly this, the line that pushes it is:",
|
|
"",
|
|
f" {rerun}",
|
|
"",
|
|
f"The token {token} covers each target's branch tip and the exact file; if either moves, "
|
|
"clearance is asked again.",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _write(plan: Plan, export: guideline_export.Export, identity: tuple[str, str]) -> tuple[str, bool]:
|
|
"""Commit and push one target. Returns its result line and whether it
|
|
counts as a failure (rejected, or a git error)."""
|
|
message = f"Update {guideline_export.TARGET_PATH} from {export.instance} at {export.commit[:12]}"
|
|
label = plan.target.label
|
|
try:
|
|
with repo_capture.cache_repo(plan.target.repo) as repo:
|
|
commit = repo_capture.commit_file(
|
|
repo, plan.tip, guideline_export.TARGET_PATH, export.data, message, identity
|
|
)
|
|
result = repo_capture.push_commit(plan.target.repo, repo, commit, plan.target.ref)
|
|
except BackendError as exc:
|
|
return f" {label}: failed ({exc})", True
|
|
if not result.accepted:
|
|
return f" {label}: rejected ({result.detail}) - the branch moved; run again", True
|
|
return f" {label}: written {plan.tip[:12]} -> {commit[:12]}", False
|
|
|
|
|
|
@app.command("guidelines")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="export guidelines",
|
|
summary="Render the guideline pages into one generated `GUIDELINES.md`, or push it into the "
|
|
"captured repositories that opted in (**Guideline Push Gate**).",
|
|
synopsis=(
|
|
cli_contract.Variant(
|
|
usage="export guidelines [--field <predicate> ...] [--kind/--subtype/--collection/"
|
|
"--tag <v>]",
|
|
notes="Prints the file to stdout; no network.",
|
|
),
|
|
cli_contract.Variant(
|
|
usage="export guidelines <predicates> --push [--bundle <raw-bundle> ...] "
|
|
"[--confirm TOKEN]",
|
|
notes="Writes it into the captured repositories, behind the gate.",
|
|
),
|
|
),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Per target - one commit pushed without force, or nothing; targets already "
|
|
"written stay written when a later one fails",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
network=cli_contract.Network.YES,
|
|
gates=(GATE,),
|
|
),
|
|
notes=(
|
|
"Selects pages with `search`'s own predicates and no text: `--field` (repeatable, AND), "
|
|
"`--kind`, `--subtype`, `--collection`, `--tag`. Which filter selects this instance's "
|
|
"guidelines is written down in `kb/CONVENTIONS.md`.",
|
|
"Renders mechanically: line 1 is `<!-- wikitool:export kind=guidelines instance=<origin "
|
|
"URL without userinfo, or local> commit=<newest commit touching a selected page> - "
|
|
"generated, do not edit by hand -->`; then each page sorted by title, as `# <title>` and "
|
|
"its text without frontmatter, the generated links and footnote blocks, citation markers "
|
|
"(`[^id]`, legacy `^[[...]]`), with `[[Title|Text]]` as `Text` and `[[Title]]` as "
|
|
"`Title`. A leading H1 of the page is replaced by the title heading. Code is never "
|
|
"rewritten. LF endings, no timestamp: the same pages give the same bytes.",
|
|
"Refuses before printing or pushing anything: no predicate, no match, an unknown field, "
|
|
"any page under `kb/` with unreadable frontmatter, uncommitted changes in `kb/` or "
|
|
"`types/` (untracked files included), or no git checkout.",
|
|
"`--push` targets every captured repository - each `(repo, ref)` from a `_capture.json` "
|
|
"under `raw/`, `--bundle` narrowing it. A target is skipped with its reason when its ref "
|
|
"rule is a tag pattern, it cannot be reached or has no such branch, its root has no "
|
|
"`GUIDELINES.md` (not opted in), its `GUIDELINES.md` has no export header (hand-written), "
|
|
"or the header names another `instance=`. A header line alone is the opt-in.",
|
|
"Guideline Push Gate: `--push` without a matching `--confirm` fetches and builds "
|
|
"everything, pushes nothing, and exits 42 with each target's status (`new`, `changed`, "
|
|
"`unchanged`, `skipped`), the diff for every target it would write, and the re-run line. "
|
|
"The token digests URL, branch, old tip and new blob of every target to write.",
|
|
"With a matching token, each target gets exactly one commit on the fetched tip changing "
|
|
"only `GUIDELINES.md`, authored as the instance checkout's `user.name`/`user.email`, "
|
|
"pushed without force; one line per target: `unchanged`, `written <old> -> <new>`, "
|
|
"`skipped (<reason>)`, `rejected (<reason>)` or `failed (<reason>)`.",
|
|
"Nothing to write (every target unchanged or skipped) ends with exit 0 and no gate. A "
|
|
"second run after a successful one finds every target unchanged.",
|
|
"The target repository's own `AGENTS.md` points at the file (Claude Code: "
|
|
"`@GUIDELINES.md`); that belongs to the repository, not to this command.",
|
|
),
|
|
failures=(
|
|
cli_contract.Failure(
|
|
cause="No predicate, no page matches, a malformed predicate or an unknown field",
|
|
reaction="Use the filter `kb/CONVENTIONS.md` names for this instance's guidelines, and "
|
|
"retry once",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A page under `kb/` has unreadable frontmatter, `kb/` or `types/` has "
|
|
"uncommitted changes, or the instance is not a git checkout",
|
|
reaction="Fix the named pages, or publish the changes first, then retry once",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="`--push` with no `user.name` or `user.email` in the instance checkout's git "
|
|
"configuration - checked before any target is fetched",
|
|
reaction="Show the message to the user; the identity is theirs to set, never yours",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="`--bundle` names no captured bundle under `raw/`, or `--bundle`/`--confirm` "
|
|
"was given without `--push`",
|
|
reaction="Fix the argument and retry once",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A target rejected the push (its branch moved since the fetch) or a git call "
|
|
"failed - the other targets were still served",
|
|
reaction="Show the lines to the user. A rejected target is served by running the "
|
|
"whole command again, which asks for clearance again",
|
|
),
|
|
cli_contract.Failure(
|
|
code=0,
|
|
cause="A target was skipped - tag rule, not reachable, not opted in, hand-written "
|
|
"file, another instance's file",
|
|
reaction="",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="Guideline Push Gate: `--push` without `--confirm`, or with a token that does "
|
|
"not match the current set of targets",
|
|
reaction=cli_contract.token_gate_reaction("--confirm"),
|
|
code=42,
|
|
),
|
|
),
|
|
examples=(
|
|
"tools/wikitool export guidelines --tag guideline",
|
|
"tools/wikitool export guidelines --tag guideline --push",
|
|
"tools/wikitool export guidelines --confirm <token> --push --field tags=guideline # "
|
|
"re-run after exit 42, once the user approved every diff",
|
|
),
|
|
never=(
|
|
"Never pass a `--confirm` token the user has not seen and approved.",
|
|
"Never edit a `GUIDELINES.md` in a target repository by hand to make it match - the "
|
|
"next export overwrites it; change the guideline page instead.",
|
|
),
|
|
see_also=(
|
|
"`wikitool search` - the same predicates, to preview the selection",
|
|
"`wikitool raw capture` - how a repository becomes a target",
|
|
"`instructions/gates.md` - the gate procedure",
|
|
),
|
|
))
|
|
def export_guidelines_command(
|
|
field_: Optional[list[str]] = typer.Option(
|
|
None, "--field", "-f",
|
|
help="Frontmatter predicate, repeatable (AND) - the same forms as `search --field`.",
|
|
),
|
|
kind: Optional[str] = typer.Option(None, "--kind", help="Shorthand for --field kind=<value>."),
|
|
subtype: Optional[str] = typer.Option(None, "--subtype", help="Shorthand for --field subtype=<value>."),
|
|
collection: Optional[str] = typer.Option(
|
|
None, "--collection", help="Shorthand for --field collection=<value>."
|
|
),
|
|
tag: Optional[str] = typer.Option(None, "--tag", help="Shorthand for --field tags=<value>."),
|
|
push: bool = typer.Option(
|
|
False, "--push", help="Write the file into the captured repositories (Guideline Push Gate)."
|
|
),
|
|
bundle: Optional[list[Path]] = typer.Option(
|
|
None, "--bundle", help="Only the repository of this captured bundle under raw/; repeatable."
|
|
),
|
|
confirm: Optional[str] = typer.Option(
|
|
None, "--confirm", help="The token from a prior exit 42, once a human has approved it."
|
|
),
|
|
):
|
|
"""Render the guideline pages into GUIDELINES.md - printed, or pushed into
|
|
the captured repositories that carry one."""
|
|
bundles = [b if b.is_absolute() else config.ROOT / b for b in (bundle or [])]
|
|
if not push and (bundles or confirm):
|
|
fail("--bundle and --confirm only apply with --push.")
|
|
raw = filters.raw_predicates(field_, kind, subtype, collection, tag)
|
|
try:
|
|
predicates = tuple(filters.parse_predicate(r) for r in raw)
|
|
export = guideline_export.build(predicates)
|
|
except ValidationError as exc:
|
|
fail(escape(str(exc)))
|
|
return
|
|
|
|
if not push:
|
|
typer.echo(export.text, nl=False)
|
|
return
|
|
|
|
identity = guideline_export.git_identity()
|
|
if identity is None:
|
|
fail(
|
|
"No git identity in this instance checkout - `git config user.name` and `git config "
|
|
"user.email` must both be set, since the commit in each target repository carries them. "
|
|
"Nothing was fetched or pushed."
|
|
)
|
|
return
|
|
try:
|
|
targets = _targets(bundles)
|
|
except ValidationError as exc:
|
|
fail(escape(str(exc)))
|
|
return
|
|
if not targets:
|
|
typer.echo("No captured repository under raw/ - nothing to write.")
|
|
return
|
|
|
|
plans = [_plan(t, export) for t in targets]
|
|
if not any(p.writes for p in plans):
|
|
typer.echo(f"{len(plans)} target(s), nothing to write:")
|
|
for plan in plans:
|
|
typer.echo(_status_line(plan))
|
|
return
|
|
|
|
token = confirm_token(plans)
|
|
if confirm != token:
|
|
console.print("[bold yellow]NEEDS USER CLEARANCE[/bold yellow] Guideline Push Gate")
|
|
typer.echo(_clearance(plans, export, token, _rerun(token, raw, bundles), confirm))
|
|
raise typer.Exit(code=EXIT_NEEDS_CLEARANCE)
|
|
|
|
failed = 0
|
|
typer.echo(f"{len(plans)} target(s):")
|
|
for plan in plans:
|
|
if not plan.writes:
|
|
typer.echo(_status_line(plan))
|
|
continue
|
|
line, bad = _write(plan, export, identity)
|
|
failed += bad
|
|
typer.echo(line)
|
|
written = sum(1 for p in plans if p.writes) - failed
|
|
typer.echo(f"{written} written, {failed} rejected or failed, {len(plans) - written - failed} "
|
|
"unchanged or skipped.")
|
|
if failed:
|
|
console.print(
|
|
f"[bold red]ERROR[/bold red] {failed} target(s) not written - what was written stays "
|
|
"written; run the command again for the rest."
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
@app.command("run")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="export run",
|
|
summary="Write the derived, read-only reading copy of `kb/` into the folders the `export` "
|
|
"section configures - the selected pages as Markdown, or converted by a program of this "
|
|
"checkout.",
|
|
synopsis=(
|
|
cli_contract.Variant(
|
|
usage="export run [<target> ...]",
|
|
notes="Every configured target, or the named ones. Needs an `export` section in "
|
|
f"`{config.LOCAL_SETTINGS_FILENAME}`; the user writes it by hand.",
|
|
),
|
|
),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Per file within a target (a temporary file in the target, then a move over its "
|
|
"place), the manifest last; targets are independent, so a failed target does not undo "
|
|
"another",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"An export is derived: regenerated from `kb/` on every run, never edited, never read back "
|
|
"into the wiki. Each target names its pages with an allowlist of `search --field` "
|
|
"predicates (`include`: a list of selections, AND within one, OR across them), a handler "
|
|
"(`markdown`, or `command` for the operator's converter) and the folder (`path`, outside "
|
|
"the instance). Nothing is exported that no selection names.",
|
|
"Every Markdown file starts with a frontmatter block whose `wikitool_export` line says "
|
|
"`kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit "
|
|
"touching a selected page>`, followed by `title`, `type`, `summary`, `modified` where the "
|
|
"page has them (`frontmatter: summary`, the default; `minimal` keeps only the marker, "
|
|
"`full` the whole frontmatter), then the page text. A file keeps its path below `kb/`. A "
|
|
"wikilink to an exported page becomes a relative Markdown link (`links: relative`, the "
|
|
"default) or plain text (`links: text`); a link to a page that is not exported is always "
|
|
"plain text. Footnote markers stay; code is never rewritten. LF endings, no timestamp: "
|
|
"the same pages give the same bytes.",
|
|
"Handler `command`: the Markdown is rendered into a staging directory, the program of "
|
|
"`export.commands` runs with no shell, stdin closed and a timeout of 300 s, and receives "
|
|
"the Markdown directory and an empty output directory as its last two arguments. On exit "
|
|
"0 the regular files in the output directory (recursively) become the target's content; "
|
|
"any other end leaves the target unchanged and prints the end of its stderr. The staging "
|
|
"directory is always removed.",
|
|
"The folder is only ever touched by what it holds in its manifest, `.wikitool-export.json`: "
|
|
"the files an earlier run wrote, each with its sha256. A run removes only manifest files "
|
|
"that are no longer exported (and folders it created, once empty), rewrites a manifest "
|
|
"file that is missing, leaves every other file byte for byte as it was, and writes "
|
|
"nothing - not even a modification time - for a file that did not change. A run that "
|
|
"changes nothing writes nothing at all.",
|
|
"Refuses a target, before writing anything into it: a non-empty folder with no manifest "
|
|
"(it is somebody's, not an export), a manifest file that was changed in the folder since "
|
|
"the last run (naming it), a foreign file where an exported one belongs, an empty "
|
|
"selection (it would delete the earlier export), an unknown field in a predicate. A "
|
|
"missing folder is created.",
|
|
"Refuses everything, before any target: uncommitted changes in `kb/` or `types/` "
|
|
"(untracked files included), no git checkout, a page under `kb/` with unreadable "
|
|
"frontmatter, an unknown target name, a broken `export` section.",
|
|
"Output per target: `new` (with the page titles), `changed`, `removed`, the count "
|
|
"`unchanged`, or `failed` with the reason. A page that newly enters a selection is "
|
|
"reported as new, not gated. Exit 0 when every target ran; exit 1 when any failed - the "
|
|
"others were still served.",
|
|
"A target with `after_publish: true` is also refreshed by every `publish` that "
|
|
"committed, `--no-push` included; a failure there is a warning, the commit stands, and "
|
|
"this command is the repeat.",
|
|
"`raw accept` refuses a file whose sha256 a target's manifest lists, and one that carries "
|
|
"the marker: an export never goes back into `raw/`.",
|
|
),
|
|
failures=(
|
|
cli_contract.Failure(
|
|
cause="No `export` section, an unknown target name, or a broken section (a predicate "
|
|
"that does not parse, a path inside the instance or in the way of another target, an "
|
|
"`intake` folder or the backup target)",
|
|
reaction="Show the message to the user; the section is theirs to write by hand, never "
|
|
"yours",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="`kb/` or `types/` has uncommitted changes, the instance is not a git checkout, "
|
|
"or a page under `kb/` has unreadable frontmatter",
|
|
reaction="Fix the named pages, or publish the changes first, then retry once",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A target failed: a foreign folder, a file changed in the target, an empty "
|
|
"selection, an unknown field, a converter that did not exit 0",
|
|
reaction="Show the target's reason to the user; the other targets were still served. "
|
|
"Never delete or edit files in a target folder to make a run pass",
|
|
),
|
|
),
|
|
examples=(
|
|
"tools/wikitool export run",
|
|
"tools/wikitool export run work-notes",
|
|
),
|
|
never=(
|
|
"Never edit a file in a target folder to change an export - the next run refuses or "
|
|
"overwrites it; change the page, or the target's `include`, instead.",
|
|
"Never put an export folder into `incoming/` or `raw/`.",
|
|
),
|
|
see_also=(
|
|
"`wikitool search` - the same predicates, to preview a selection",
|
|
"`wikitool backup run` - the same kind of folder, for this machine's own files",
|
|
"`docs/how-to/export-to-a-synced-folder.md`",
|
|
),
|
|
))
|
|
def export_run_command(
|
|
names: Optional[list[str]] = typer.Argument(
|
|
None, metavar="[TARGET]...", help="Targets to run; default: every configured target."
|
|
),
|
|
):
|
|
"""Write the reading copy of kb/ into the configured export folders."""
|
|
try:
|
|
targets = page_export.choose(page_export.require_config(), names or [])
|
|
results = page_export.run(targets)
|
|
except ValidationError as exc:
|
|
fail(escape(str(exc)))
|
|
return
|
|
failed = 0
|
|
for result in results:
|
|
for line in page_export.report_lines(result):
|
|
typer.echo(line)
|
|
failed += not result.ok
|
|
if failed:
|
|
console.print(f"[bold red]ERROR[/bold red] {failed} of {len(results)} target(s) failed - the "
|
|
"others were served; fix the reason above and run again.")
|
|
raise typer.Exit(code=1)
|