raw accept: Stem-Eindeutigkeit im Typverzeichnis erzwingen, --replaces als einziger Weg daran vorbei (schliesst #64)
CI / verify (push) Successful in 55s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- VERSION
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/tests/test_raw_cmd.py
This commit is contained in:
torben committed 2026-09-05 08:51:41 +02:00
1 parent 36d2128f29
commit 0b3c496fff
7 files changed
+454 -7

No files matched your search

+142 -1
View File
@@ -20,6 +20,20 @@ that is this command's job:
Multi-owner raw files (`provenance.duplicate_raw_file_owners`) are refused
rather than silently moved: relocating a file another page also claims would
break that page's `raw_files:` without it ever being consulted.
**Stem uniqueness at `raw/<type>/` level** (Gitea #64) closes the gap this
leaves open: without it, a second, unrelated source whose primary file happens
not to collide on the exact filename slips silently into an existing bundle,
because the per-file `dst.exists()` check above never looks at the bundle
directory itself. The set of names occupied at `raw/<type>/` level - file
stems and bundle directory names alike - must stay unique; `_occupied_stems()`
and the check built on it enforce that, while still allowing a call to grow a
bundle it already owns (via `--page`, or by continuing an existing bundle).
**`--replaces <raw-path>`** is the only sanctioned way past that rule: whether
a new file is a later edition of an existing source or a second, separate one
is a human's decision, never the tool's or an agent's, so the command refuses
and names both routes rather than choosing one (Gitea #64 decision 2).
"""
from __future__ import annotations
@@ -33,7 +47,7 @@ from chemenu.commands._util import fail, rel_path, success
from chemenu.commands.dist_cmd import RAW_SUBDIRS
from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import source_pages_by_raw_file, source_raw_files
from chemenu.provenance import citing_pages, source_pages_by_raw_file, source_raw_files
app = typer.Typer(help="Promote raw material out of incoming/ into raw/.")
@@ -70,6 +84,106 @@ def _classify(path: Path, incoming: Path) -> str:
return sub
def _occupied_stems(raw_sub_dir: Path) -> dict[str, Path]:
"""Every name occupied at `raw/<type>/` level: file stems and bundle
directory names alike, one level below `raw_sub_dir` only."""
occupied: dict[str, Path] = {}
if raw_sub_dir.is_dir():
for entry in raw_sub_dir.iterdir():
occupied[entry.stem if entry.is_file() else entry.name] = entry
return occupied
def _stem_collision_message(sub: str, claimed_name: str, holder: Path) -> str:
example_target = holder
if holder.is_dir():
children = sorted(holder.iterdir())
example_target = children[0] if children else holder
return (
f'{rel_path(holder)} already claims the stem "{claimed_name}" in raw/{sub}/.\n'
" These are two different intents and only you can tell them apart:\n"
f" Same source, new edition -> tools/wikitool raw accept "
f"--replaces {rel_path(example_target)} <incoming file>\n"
" A second, separate source -> rename it in incoming/ (add a distinguishing "
"suffix) and accept it normally\n"
" raw accept does not guess which one this is."
)
def _replace(files: list[Path], replaces: Path, page: Optional[str], dry_run: bool) -> None:
"""`raw accept --replaces <target> <incoming file>` - overwrite an
existing raw/ file wholesale with a later edition, in place, with
`raw_files:` on every source page left untouched (Gitea #64 decision 2).
Every check below runs before any write, so a failure leaves both the
target and the incoming file exactly as they were - the invariant the
issue names for this destructive step.
"""
if page is not None:
fail("--replaces and --page cannot be combined - a replacement never changes raw_files:.")
if len(files) != 1:
fail("--replaces takes exactly one incoming file - a replacement is one file for one file.")
incoming_path = _resolve(files[0])
if not incoming_path.is_file():
fail(f"{rel_path(incoming_path)} does not exist or is not a file.")
incoming_sub = _classify(incoming_path, _incoming_dir())
target = _resolve(replaces)
try:
target_rel = target.relative_to(config.RAW_DIR)
except ValueError:
fail(f"--replaces target {rel_path(target)} does not lie under raw/.")
if not target.is_file():
fail(f"--replaces target {rel_path(target)} does not exist or is not a file.")
target_sub = target_rel.parts[0]
if target_sub != incoming_sub:
fail(
f"--replaces target is under raw/{target_sub}/, but {rel_path(incoming_path)} is "
f"under incoming/{incoming_sub}/ - a replacement stays within one type directory."
)
if target.name != incoming_path.name:
fail(
f"--replaces target {rel_path(target)} has a different filename than "
f"{rel_path(incoming_path)} - a rename is not part of a replacement (see `raw rename`, #16)."
)
pages = load_kb_pages(config.KB_DIR)
by_raw = source_pages_by_raw_file(pages)
owners = sorted(set(by_raw.get(rel_path(target), [])))
if len(owners) > 1:
fail(
f"Cannot replace {rel_path(target)}: more than one source page claims it "
f"({', '.join(owners)}). Resolve the multiple ownership first (see `wikitool sources coverage`)."
)
if dry_run:
typer.echo(f"[dry-run] would replace {rel_path(target)} with {rel_path(incoming_path)}. No files written.")
return
target.unlink()
incoming_path.rename(target)
if not owners:
success(
f"Replaced {rel_path(target)} (previous version stays in git history). "
"No source page covers this file - see `wikitool sources coverage`."
)
return
source_title = owners[0]
citers = citing_pages(pages, source_title)
typer.echo(f"Replaced {rel_path(target)} (previous version stays in git history).")
typer.echo("These pages were compiled against the previous version and may now be stale:")
typer.echo(f" [[{source_title}]]")
if citers:
for citer in citers:
typer.echo(f" cited by: [[{citer}]]")
else:
typer.echo(" cited by: (nothing yet)")
success("Review them in this same run: the replacement and their update belong in one commit.")
@app.command("accept")
def raw_accept_command(
files: list[Path] = typer.Argument(
@@ -82,6 +196,12 @@ def raw_accept_command(
help="Extend this existing source page's raw_files: with the promoted file(s), "
"folding in its already-promoted file if this raises it past one",
),
replaces: Optional[Path] = typer.Option(
None,
"--replaces",
help="Overwrite this existing raw/ file in place with the single incoming/ file passed "
"alongside it - the only sanctioned way past the stem-uniqueness rule below",
),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
):
"""Promote file(s) from incoming/ into raw/, computing the destination
@@ -91,6 +211,11 @@ def raw_accept_command(
fail("Pass at least one file to promote.")
incoming = _incoming_dir()
if replaces is not None:
_replace(files, replaces, page, dry_run)
return
resolved = [_resolve(f) for f in files]
for path in resolved:
@@ -168,6 +293,22 @@ def raw_accept_command(
if dst.exists():
fail(f"Cannot promote: {rel_path(dst)} already exists.")
# Stem uniqueness at raw/<type>/ level (Gitea #64): the name this call is
# about to claim there - the bundle's name, or the lone file's stem when no
# bundle forms - must not already belong to something this call does not
# itself own. "Owns" means: one of the page's already-registered raw files
# (the pitfall from the module docstring - a single file growing into a
# bundle of its own name momentarily still occupies that name), or, once a
# bundle already has >=2 registered files, the bundle directory itself.
claimed_name = bundle_dir.name if bundle_dir is not None else resolved[0].stem
occupied = _occupied_stems(raw_sub_dir)
owned = set(existing_raw_paths)
if len(existing_raw_paths) >= 2:
owned.add(bundle_dir)
holder = occupied.get(claimed_name)
if holder is not None and holder not in owned:
fail(_stem_collision_message(sub, claimed_name, holder))
moving_existing = [src for src, _dst in moves if src in existing_raw_paths]
if moving_existing:
by_raw = source_pages_by_raw_file(pages)