feat: raw accept - incoming/ als abgeleiteter Rohablage-Eingang (schliesst #58)
Files changed: - .gitignore - CHANGES.md - README.md - VERSION - docs/pipeline-rationale.md - instructions/bootstrap.md - instructions/wiki-ingest/SKILL.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_raw_cmd.py
This commit is contained in:
1 parent
1f0ad7f9f3
commit
36d2128f29
16 files changed
+753
-46
No files matched your search
@@ -135,6 +135,10 @@ INSTRUCTIONS_EXCLUDE_DIRS = {"dev"}
|
||||
|
||||
# Fixed by raw/CONTRACT.md's routing table, unlike kb/'s areas (which are
|
||||
# organic - see kb/CONTRACT.md - so `export` does not manufacture them).
|
||||
# `docs verify` (check_raw_subdirs) holds the table to this tuple in both
|
||||
# directions, and `incoming/` (raw/CONTRACT.md "Getting a file in",
|
||||
# raw_cmd.py) mirrors it as the set of type subdirectories a human may drop a
|
||||
# file into - so this is the one place all three read the list from.
|
||||
RAW_SUBDIRS = ("articles", "documents", "notes", "assets")
|
||||
|
||||
# Stage contracts that are not collections and carry no pages: copied as a
|
||||
@@ -414,6 +418,12 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
|
||||
for sub in RAW_SUBDIRS:
|
||||
plan[f"raw/{sub}/.gitkeep"] = PlannedFile("")
|
||||
# `incoming/` mirrors raw/'s type subdirectories (raw/CONTRACT.md
|
||||
# "Getting a file in") - seeded the same way, though `.gitignore`
|
||||
# (also exported, see ROOT_FILES) excludes the whole directory again
|
||||
# once the instance is a git repo, which is why bootstrap.md re-creates
|
||||
# it for a plain clone that never had this export step at all.
|
||||
plan[f"incoming/{sub}/.gitkeep"] = PlannedFile("")
|
||||
|
||||
plan["kb/log.md"] = PlannedFile((DIST_TEMPLATES_DIR / "log.md").read_text(encoding="utf-8"))
|
||||
plan["CHANGES.md"] = PlannedFile((DIST_TEMPLATES_DIR / "CHANGES.md").read_text(encoding="utf-8"))
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import Optional
|
||||
import typer
|
||||
|
||||
from chemenu import config, conventions, kb_collections, version as version_mod
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
|
||||
app = typer.Typer(help="Verify documentation that mirrors the code or repo layout.")
|
||||
@@ -108,6 +109,12 @@ REQUIRED_IGNORE_CANARIES = (
|
||||
"ENVIRONMENT.md",
|
||||
"tools/coverage.xml",
|
||||
"tools/htmlcov/index.html",
|
||||
# The ingest inbox (Gitea #58, raw/CONTRACT.md "Getting a file in"). Unlike
|
||||
# raw/ itself, a file here must never be committed - promotion via
|
||||
# `wikitool raw accept` is what makes it immutable, not the drop - so this
|
||||
# is the one canary in this tuple asserting the *opposite* of raw/'s own
|
||||
# backstop a few lines above.
|
||||
"incoming/documents/probe.pdf",
|
||||
)
|
||||
REQUIRED_TRACKED_PATHS = (
|
||||
"reports/CONTRACT.md",
|
||||
@@ -158,6 +165,11 @@ LEGACY_TYPE_RE = re.compile(r"^type:\s*(entity|concept|source|comparison)\s*$",
|
||||
# First backticked cell of a markdown table row, e.g. "| `xref add --a ...` | ... |"
|
||||
TABLE_CELL_RE = re.compile(r"^\|\s*`([^`]+)`", re.MULTILINE)
|
||||
|
||||
# A raw/CONTRACT.md routing-table cell naming a bare type subdirectory, e.g.
|
||||
# "| `articles/` | ... |" - deliberately narrower than TABLE_CELL_RE, which
|
||||
# would also match a command example elsewhere on the page.
|
||||
RAW_DIR_CELL_RE = re.compile(r"^\|\s*`([a-zA-Z0-9_-]+)/`\s*\|", re.MULTILINE)
|
||||
|
||||
|
||||
def registered_commands() -> set[str]:
|
||||
"""Every command path the CLI exposes, e.g. {'new', 'xref add', ...}.
|
||||
@@ -317,6 +329,34 @@ def check_stack_required_types() -> list[str]:
|
||||
return issues
|
||||
|
||||
|
||||
def documented_raw_subdirs(text: str) -> list[str]:
|
||||
return [match.group(1) for match in RAW_DIR_CELL_RE.finditer(text)]
|
||||
|
||||
|
||||
def check_raw_subdirs() -> list[str]:
|
||||
"""`raw/CONTRACT.md`'s routing table and `dist_cmd.RAW_SUBDIRS` must name
|
||||
the same set of type subdirectories (Gitea #58) - the table is meant to
|
||||
read as behaviour derived from the tuple, not as a second place the list
|
||||
could drift (AGENTS.md invariant 8). Skipped if the contract itself is
|
||||
missing; `check_collection_contracts` already reports that.
|
||||
"""
|
||||
contract_path = config.ROOT / "raw" / "CONTRACT.md"
|
||||
if not contract_path.exists():
|
||||
return []
|
||||
documented = set(documented_raw_subdirs(contract_path.read_text(encoding="utf-8")))
|
||||
declared = set(dist_cmd.RAW_SUBDIRS)
|
||||
issues = [
|
||||
f"raw/CONTRACT.md's routing table is missing `{missing}/` - dist_cmd.RAW_SUBDIRS names it"
|
||||
for missing in sorted(declared - documented)
|
||||
]
|
||||
issues += [
|
||||
f"raw/CONTRACT.md's routing table lists `{extra}/`, but dist_cmd.RAW_SUBDIRS does not - "
|
||||
"the two must name the same set"
|
||||
for extra in sorted(documented - declared)
|
||||
]
|
||||
return issues
|
||||
|
||||
|
||||
def check_legacy_type_blocks() -> list[str]:
|
||||
issues = []
|
||||
guarded = [
|
||||
@@ -585,6 +625,7 @@ def verify():
|
||||
check_cli_readme()
|
||||
+ check_readmes_have_no_command_table()
|
||||
+ check_collection_contracts()
|
||||
+ check_raw_subdirs()
|
||||
+ check_legacy_type_blocks()
|
||||
+ check_ignored_content()
|
||||
+ check_version_changelog()
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""`wikitool raw accept` - promote one or more files from `incoming/` into
|
||||
`raw/`, with the destination computed rather than chosen by hand (Gitea #58).
|
||||
|
||||
A human classifies a file only by which type subdirectory of `incoming/` they
|
||||
drop it into - `incoming/articles/`, `incoming/documents/`, `incoming/notes/`,
|
||||
`incoming/assets/`, mirroring raw/CONTRACT.md's routing table. Everything past
|
||||
that is this command's job:
|
||||
|
||||
- **Single file, no bundle.** One file promoted alone lands as
|
||||
`raw/<type>/<name>` - no directory of its own.
|
||||
- **Bundle from the second file on.** Several files of one source promoted in
|
||||
the same call land under `raw/<type>/<stem>/`, named after the first file's
|
||||
stem.
|
||||
- **Growing an existing single file into a bundle.** `--page` extends an
|
||||
existing source page's `raw_files:`. If that raises the page from one file
|
||||
to more than one, the file it already had is folded into the new bundle
|
||||
alongside the ones just promoted, in the same call - at no point does
|
||||
`raw_files:` point at a path that does not exist.
|
||||
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config
|
||||
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
|
||||
|
||||
app = typer.Typer(help="Promote raw material out of incoming/ into raw/.")
|
||||
|
||||
|
||||
def _incoming_dir() -> Path:
|
||||
return config.ROOT / "incoming"
|
||||
|
||||
|
||||
def _resolve(raw: Path) -> Path:
|
||||
return raw if raw.is_absolute() else config.ROOT / raw
|
||||
|
||||
|
||||
def _classify(path: Path, incoming: Path) -> str:
|
||||
"""The type subdirectory `path` (already resolved, absolute) declares by
|
||||
where it sits under `incoming/`, or fail with the reason it doesn't."""
|
||||
try:
|
||||
rel = path.relative_to(incoming)
|
||||
except ValueError:
|
||||
fail(
|
||||
f"{rel_path(path)} is not under incoming/ - `raw accept` only promotes files "
|
||||
"from there. See raw/CONTRACT.md."
|
||||
)
|
||||
allowed = ", ".join(f"incoming/{s}/" for s in RAW_SUBDIRS)
|
||||
if len(rel.parts) < 2:
|
||||
fail(
|
||||
f"incoming/{rel} declares no type - place it inside one of {allowed} instead "
|
||||
"of directly in incoming/."
|
||||
)
|
||||
sub = rel.parts[0]
|
||||
if sub not in RAW_SUBDIRS:
|
||||
fail(f"incoming/{rel} lies under an unknown type directory 'incoming/{sub}/'. Allowed: {allowed}.")
|
||||
if len(rel.parts) > 2:
|
||||
fail(f"incoming/{rel} is nested below its type directory - place it directly in incoming/{sub}/.")
|
||||
return sub
|
||||
|
||||
|
||||
@app.command("accept")
|
||||
def raw_accept_command(
|
||||
files: list[Path] = typer.Argument(
|
||||
...,
|
||||
help="One or more files under incoming/<type>/, all belonging to the same source",
|
||||
),
|
||||
page: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--page",
|
||||
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",
|
||||
),
|
||||
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
|
||||
(type directory, bundle or not, bundle name) instead of taking it as an
|
||||
argument. See raw/CONTRACT.md "Getting a file in: incoming/"."""
|
||||
if not files:
|
||||
fail("Pass at least one file to promote.")
|
||||
|
||||
incoming = _incoming_dir()
|
||||
resolved = [_resolve(f) for f in files]
|
||||
|
||||
for path in resolved:
|
||||
if not path.is_file():
|
||||
fail(f"{rel_path(path)} does not exist or is not a file.")
|
||||
|
||||
subs = {_classify(path, incoming) for path in resolved}
|
||||
if len(subs) > 1:
|
||||
allowed = ", ".join(sorted(f"incoming/{s}/" for s in subs))
|
||||
fail(f"All files in one `raw accept` call must share one type directory; got {allowed}.")
|
||||
sub = subs.pop()
|
||||
|
||||
names = [path.name for path in resolved]
|
||||
if len(names) != len(set(names)):
|
||||
fail("Two files share a filename; rename one before promoting.")
|
||||
|
||||
raw_sub_dir = config.RAW_DIR / sub
|
||||
|
||||
pages = None
|
||||
target_page = None
|
||||
existing_raw_paths: list[Path] = []
|
||||
if page is not None:
|
||||
pages = load_kb_pages(config.KB_DIR)
|
||||
target_page = pages.get(page)
|
||||
if target_page is None:
|
||||
fail(f"No page titled '{page}' found under wiki/. Create it first, or omit --page.")
|
||||
existing_rel = source_raw_files(target_page)
|
||||
if not existing_rel:
|
||||
fail(
|
||||
f"'{page}' has no raw_files: yet - omit --page and run "
|
||||
"`wikitool new source --set raw_files=...` for a page's first raw file."
|
||||
)
|
||||
existing_raw_paths = [config.ROOT / p for p in existing_rel]
|
||||
missing = [p for p in existing_raw_paths if not p.is_file()]
|
||||
if missing:
|
||||
fail(
|
||||
f"'{page}' claims raw file(s) that do not exist on disk: "
|
||||
f"{', '.join(rel_path(p) for p in missing)}. Fix raw_files: (see `sources coverage`) "
|
||||
"before promoting more."
|
||||
)
|
||||
existing_subs = {p.relative_to(config.RAW_DIR).parts[0] for p in existing_raw_paths}
|
||||
if existing_subs != {sub}:
|
||||
fail(
|
||||
f"'{page}' already claims file(s) under {', '.join(sorted(f'raw/{s}/' for s in existing_subs))}, "
|
||||
f"not raw/{sub}/. A bundle is one type directory; promote separately."
|
||||
)
|
||||
|
||||
# A bundle directory forms once two or more files belong to the source
|
||||
# (Gitea #58 decision 3): from the second file on, never before. Whenever
|
||||
# --page targets an existing page it always has >=1 raw file already
|
||||
# (types/source.md requires raw_files:), so bundling always applies there.
|
||||
total = len(existing_raw_paths) + len(resolved)
|
||||
bundle_dir: Optional[Path] = None
|
||||
if len(existing_raw_paths) >= 2:
|
||||
parents = {p.parent for p in existing_raw_paths}
|
||||
if len(parents) != 1:
|
||||
fail(
|
||||
f"'{page}' raw_files: are not all in one directory - fix them by hand first "
|
||||
"(see `sources coverage`)."
|
||||
)
|
||||
bundle_dir = parents.pop()
|
||||
elif total >= 2:
|
||||
primary = existing_raw_paths[0] if existing_raw_paths else resolved[0]
|
||||
bundle_dir = raw_sub_dir / primary.stem
|
||||
|
||||
moves: list[tuple[Path, Path]] = []
|
||||
for existing in existing_raw_paths:
|
||||
if bundle_dir is not None and existing.parent != bundle_dir:
|
||||
moves.append((existing, bundle_dir / existing.name))
|
||||
for new_path in resolved:
|
||||
dst = (bundle_dir / new_path.name) if bundle_dir is not None else (raw_sub_dir / new_path.name)
|
||||
moves.append((new_path, dst))
|
||||
|
||||
for _src, dst in moves:
|
||||
if dst.exists():
|
||||
fail(f"Cannot promote: {rel_path(dst)} already exists.")
|
||||
|
||||
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)
|
||||
conflicts = [
|
||||
(rel_path(src), sorted(set(by_raw.get(rel_path(src), [])) - {page}))
|
||||
for src in moving_existing
|
||||
]
|
||||
conflicts = [(p, owners) for p, owners in conflicts if owners]
|
||||
if conflicts:
|
||||
listed = "\n".join(f" - {p}: also claimed by {', '.join(o)}" for p, o in conflicts)
|
||||
fail(
|
||||
"Cannot bundle: the following already-covered raw file(s) have more than one "
|
||||
f"owner, so moving them would break the other page(s)' raw_files::\n{listed}\n"
|
||||
"Resolve the multiple ownership first (see `wikitool sources coverage`)."
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
for src, dst in moves:
|
||||
typer.echo(f"[dry-run] would move {rel_path(src)} -> {rel_path(dst)}")
|
||||
if target_page is not None:
|
||||
moved_map = dict(moves)
|
||||
final = [moved_map.get(p, p) for p in existing_raw_paths] + [moved_map[p] for p in resolved]
|
||||
typer.echo(f"[dry-run] would set raw_files: on '{page}' to {[rel_path(p) for p in final]}")
|
||||
typer.echo(f"[dry-run] would move {len(moves)} file(s). No files written.")
|
||||
return
|
||||
|
||||
for src, dst in moves:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
src.rename(dst)
|
||||
typer.echo(f" moved {rel_path(src)} -> {rel_path(dst)}")
|
||||
|
||||
moved_map = dict(moves)
|
||||
if target_page is not None:
|
||||
final = [moved_map.get(p, p) for p in existing_raw_paths] + [moved_map[p] for p in resolved]
|
||||
target_page.frontmatter["raw_files"] = [rel_path(p) for p in final]
|
||||
write_page(target_page.path, target_page.frontmatter, target_page.body)
|
||||
success(
|
||||
f"Promoted {len(resolved)} file(s); updated raw_files: on '{page}' "
|
||||
f"({len(final)} file(s) total)."
|
||||
)
|
||||
return
|
||||
|
||||
promoted = ", ".join(rel_path(moved_map[p]) for p in resolved)
|
||||
success(
|
||||
f"Promoted {len(resolved)} file(s) to {promoted}. "
|
||||
"Run `wikitool new source --set raw_files=...` (or `touch --set` on an existing page) next."
|
||||
)
|
||||
Reference in new issue
Block a user