raw accept: Datums-Shard statt Typverzeichnis, fidelity/authority am Drop-Punkt (Teil 1/3, #67)
CI / verify (push) Successful in 52s
Release / release (push) Successful in 35s

Files changed:
- .gitignore
- CHANGES.md
- VERSION
- instructions/bootstrap.md
- instructions/wiki-ingest/SKILL.md
- kb/CONTRACT.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/touch.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_provenance.py
- tools/chemenu/tests/test_raw_cmd.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/type_resolver.py
- types/source.md
- types/source.schema.yaml
This commit is contained in:
torben committed 2026-09-08 21:42:27 +02:00
1 parent f2a093bc8b
commit f4353ccfb3
25 files changed
+1167 -344

No files matched your search

+8 -16
View File
@@ -133,14 +133,6 @@ def _is_coverage_output(filename: str) -> bool:
# reconstructs it afterwards, unlike the marker-block content below.
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
# single file each, nothing else from their directory. `kb/` is excluded here
# - it is a content stage too, but it has collections underneath it, so its
@@ -416,14 +408,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
if source.is_file():
plan[relative] = _read_planned_file(source, relative)
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("")
# `raw/` and `incoming/` are both flat now (Gitea #67 removes type
# subdirectories from the addressing scheme entirely - a file's location
# under `raw/` is a date shard computed by `raw accept`, never a hand-picked
# type). `.gitignore` (also exported, see ROOT_FILES) excludes `incoming/`
# 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["raw/.gitkeep"] = PlannedFile("")
plan["incoming/.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"))
+3 -37
View File
@@ -38,7 +38,6 @@ 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.")
@@ -113,8 +112,9 @@ REQUIRED_IGNORE_CANARIES = (
# 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",
# backstop a few lines above. Flat since Gitea #67 - incoming/ no longer
# has type subdirectories, so the probe sits directly in it.
"incoming/probe.pdf",
)
REQUIRED_TRACKED_PATHS = (
"reports/CONTRACT.md",
@@ -165,11 +165,6 @@ 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', ...}.
@@ -329,34 +324,6 @@ 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 = [
@@ -625,7 +592,6 @@ 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()
+27
View File
@@ -298,6 +298,33 @@ def new_page_command(
frontmatter = _build_frontmatter(type_path, schema, today, explicit)
# Capture fields (Gitea #67, e.g. `fidelity`/`authority` on a source page)
# are deliberately absent from `required:` - putting them there would make
# every existing instance's source pages stop validating, a
# boundary-crossing change (version-parts.md). The requirement is instead
# enforced here, in the tool, exactly like `source_type`'s no-default
# refusal (#66) reads from the schema alone: without a `default:` and
# omitted from `explicit`, a capture field simply never lands in
# `frontmatter`, so its absence has to be caught before it is silently
# written as a page with no capture record at all.
try:
capture_fields = resolver.get_capture_fields(type_path)
except ValueError as exc:
fail(str(exc))
missing_capture = [f for f in capture_fields if not frontmatter.get(f)]
if missing_capture:
fail(
f"Type {type_path} requires capture field(s) {', '.join(missing_capture)} - pass them "
"explicitly, e.g. --set fidelity=verbatim --set authority=reporting. "
"new does not guess them (see raw/CONTRACT.md)."
)
guessed_unknown = [f for f in capture_fields if frontmatter.get(f) == "unknown"]
if guessed_unknown:
fail(
f"Capture field(s) {', '.join(guessed_unknown)} cannot be set to 'unknown' here - that "
"value is backfill-only, written only by `wikitool touch` on a page predating this rule."
)
target_dir = _target_dir(type_path, frontmatter)
_type_spec, template = _load_type_or_fail(type_path, target_dir)
_validate_or_fail(frontmatter, type_path, target_dir)
+248 -82
View File
@@ -1,34 +1,61 @@
"""`wikitool raw accept` - promote one or more files from `incoming/` into
`raw/`, with the destination computed rather than chosen by hand (Gitea #58).
`raw/`, with the destination computed rather than chosen by hand (Gitea #58),
sharded by the accept date rather than by a hand-picked type (Gitea #67).
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:
A human no longer classifies a file at all: `incoming/` is flat, and a
subdirectory dropped under it (an old `incoming/documents/` habit, a script
that still writes one) is accepted and ignored rather than inspected -
promoting `raw/` from a routing decision to an address computed purely from
*when* the file was accepted:
- **Single file, no bundle.** One file promoted alone lands as
`raw/<type>/<name>` - no directory of its own.
`raw/<YYYY>/<MM>/<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.
the same call land under `raw/<YYYY>/<MM>/<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.
to more than one, the file it already had is folded into a bundle at its
own parent directory - `raw/<its-existing-location>/<stem>/` - not at
today's shard, so a bundle never mixes an old capture date with today's
(Gitea #67 decision, "Datums-Shard" § "Bündelort").
Existing files under `raw/` are never moved by this change (Gitea #67
"Altbestand bleibt stehen"): `raw/articles/`, `raw/documents/`, `raw/notes/`
and `raw/assets/` keep whatever they already held, and stay valid promotion
targets for `--replaces`.
**Every promotion now also carries `--fidelity` and `--authority`** (Gitea
#67): how faithfully the material was captured, and what it is entitled to
claim about its subject. Neither has a default and neither may be `unknown`
here - that value is backfill-only, written only by `wikitool touch` on a
page predating this rule. Given `--page`, both are written straight onto the
target page (once - fill-once, like `touch`, see `touch._capture_field_or_fail`);
without `--page` there is no page yet to write them onto (`wiki-ingest`
creates it afterwards), so this command instead prints the exact
`wikitool new source --set fidelity=... --set authority=...` follow-up line,
and `new source` itself refuses to scaffold a source page without both.
`--replaces` is the one path where both become *optional*: passing them there
is the sanctioned way to correct an already-set capture value on a later
edition, the same way `touch`'s own refusal points back to `--replaces`.
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).
**Stem uniqueness across the whole of `raw/`** (Gitea #64, widened by #67):
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 anywhere under `raw/` - file stems and
bundle directory names alike, at whichever level directly holds files - 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). It used to be scoped to one type
directory; #67 removes type directories from the addressing scheme entirely,
so uniqueness now has to span old-style flat directories and the new date
shard together, or `--replaces` on a bygone stem would be ambiguous across
shards.
**`--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
@@ -37,6 +64,8 @@ and names both routes rather than choosing one (Gitea #64 decision 2).
"""
from __future__ import annotations
import datetime
import re
from pathlib import Path
from typing import Optional
@@ -44,13 +73,20 @@ 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 citing_pages, source_pages_by_raw_file, source_raw_files
from chemenu.type_resolver import resolver
app = typer.Typer(help="Promote raw material out of incoming/ into raw/.")
# The type this command always promotes into eventually - hardcoded rather
# than derived from --page, because there is no page yet in the common case
# (see module docstring): the follow-up hint and the capture-field/enum
# validation below both need *a* type to read from, and `source` is the only
# one `raw accept` has ever scaffolded towards.
_SOURCE_TYPE_PATH = "types/source.md"
def _incoming_dir() -> Path:
return config.ROOT / "incoming"
@@ -60,9 +96,24 @@ 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."""
def _shard_dir(today: Optional[datetime.date] = None) -> Path:
"""`raw/<YYYY>/<MM>/`, computed from the accept date - never from a count,
so it can never rebalance and break a `[^cite-id]` anchor (Gitea #67).
"""
d = today or datetime.date.today()
return config.RAW_DIR / f"{d.year:04d}" / f"{d.month:02d}"
def _validate_under_incoming(path: Path, incoming: Path) -> None:
"""`path` must sit directly in `incoming/`, or exactly one level below it.
Unlike before #67, that one optional level carries no meaning any more -
it is accepted and ignored, kept only so an old `incoming/<type>/` habit
or script does not have to change to keep working (the MINOR condition
named in Gitea #67 "Versionsteil"). Nesting deeper than that is still
refused: it was never meaningful and silently accepting it would hide a
typo'd path.
"""
try:
rel = path.relative_to(incoming)
except ValueError:
@@ -70,37 +121,58 @@ def _classify(path: Path, incoming: Path) -> str:
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) < 1:
fail(f"incoming/{rel} names no file.")
if len(rel.parts) > 2:
fail(f"incoming/{rel} is nested below its type directory - place it directly in incoming/{sub}/.")
return sub
fail(
f"incoming/{rel} is nested more than one level below incoming/ - place it "
"directly in incoming/, or in at most one subdirectory of it (the "
"subdirectory itself is ignored, see raw/CONTRACT.md)."
)
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."""
_YEAR_DIR = re.compile(r"\d{4}")
def _occupied_stems(raw_dir: Path) -> dict[str, Path]:
"""Every name occupied anywhere under `raw/`: file stems and bundle
directory names alike, at whichever level directly holds files.
Distinguishes the pre-#67 flat layout (`raw/<name>/<file-or-bundle>`)
from the #67 date shard (`raw/<YYYY>/<MM>/<file-or-bundle>`) structurally,
by whether a top-level directory name is a 4-digit year - not from a
hardcoded list of legacy type names, so this keeps working unchanged if a
legacy directory is ever renamed by hand.
"""
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
if not raw_dir.is_dir():
return occupied
for top in raw_dir.iterdir():
if not top.is_dir():
continue # raw/CONTRACT.md
if _YEAR_DIR.fullmatch(top.name):
for month in top.iterdir():
if month.is_dir():
occupied.update(_leaf_group_entries(month))
else:
occupied.update(_leaf_group_entries(top))
return occupied
def _stem_collision_message(sub: str, claimed_name: str, holder: Path) -> str:
def _leaf_group_entries(group_dir: Path) -> dict[str, Path]:
"""The addressable entries directly inside one leaf-group directory
(a legacy type dir, or one `raw/<YYYY>/<MM>/`): a lone file occupies its
stem, a bundle directory occupies its own name."""
return {entry.stem if entry.is_file() else entry.name: entry for entry in group_dir.iterdir()}
def _stem_collision_message(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'
f'{rel_path(holder)} already claims the stem "{claimed_name}" under raw/.\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"
@@ -110,10 +182,53 @@ def _stem_collision_message(sub: str, claimed_name: str, holder: Path) -> str:
)
def _replace(files: list[Path], replaces: Path, page: Optional[str], dry_run: bool) -> None:
def _capture_choices() -> tuple[list[str], list[str]]:
"""Allowed `--fidelity`/`--authority` values, straight from the schema
(single source of truth) - `unknown` excluded, since it is backfill-only
and neither flag may write it (Gitea #67)."""
fidelity = [v for v in resolver.get_enum(_SOURCE_TYPE_PATH, "fidelity") if v != "unknown"]
authority = [v for v in resolver.get_enum(_SOURCE_TYPE_PATH, "authority") if v != "unknown"]
return fidelity, authority
def _check_capture_value(field: str, value: Optional[str], allowed: list[str]) -> None:
if value is None:
return
if value == "unknown" or value not in allowed:
fail(
f"--{field} must be one of: {', '.join(allowed)}. 'unknown' is backfill-only - only "
"`wikitool touch` may write it, on a page predating this rule."
)
def _overwrite_capture_fields_on_page(page, fidelity: Optional[str], authority: Optional[str]) -> bool:
"""Write `fidelity`/`authority` onto `page.frontmatter`, unconditionally -
only called from `_replace`, the one sanctioned way to correct an
already-set capture value (Gitea #67). Returns whether anything changed,
so the caller only writes the page back when it needs to."""
changed = False
for field, value in (("fidelity", fidelity), ("authority", authority)):
if value is not None and page.frontmatter.get(field) != value:
page.frontmatter[field] = value
changed = True
return changed
def _replace(
files: list[Path],
replaces: Path,
page: Optional[str],
fidelity: Optional[str],
authority: 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).
`--fidelity`/`--authority` are optional here, and are the one sanctioned
way to correct an already-set capture value (Gitea #67) - passed, they
overwrite the owning page's value; omitted, the page's capture fields are
left exactly as they were.
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
@@ -124,24 +239,22 @@ def _replace(files: list[Path], replaces: Path, page: Optional[str], dry_run: bo
if len(files) != 1:
fail("--replaces takes exactly one incoming file - a replacement is one file for one file.")
fidelity_choices, authority_choices = _capture_choices()
_check_capture_value("fidelity", fidelity, fidelity_choices)
_check_capture_value("authority", authority, authority_choices)
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())
_validate_under_incoming(incoming_path, _incoming_dir())
target = _resolve(replaces)
try:
target_rel = target.relative_to(config.RAW_DIR)
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 "
@@ -157,13 +270,25 @@ def _replace(files: list[Path], replaces: Path, page: Optional[str], dry_run: bo
f"({', '.join(owners)}). Resolve the multiple ownership first (see `wikitool sources coverage`)."
)
owner_page = pages.get(owners[0]) if owners else None
will_update_capture = owner_page is not None and _overwrite_capture_fields_on_page(
owner_page, fidelity, authority
)
if dry_run:
typer.echo(f"[dry-run] would replace {rel_path(target)} with {rel_path(incoming_path)}. No files written.")
if will_update_capture:
typer.echo(
f"[dry-run] would set fidelity={fidelity!r}, authority={authority!r} on '{owners[0]}'"
)
return
target.unlink()
incoming_path.rename(target)
if will_update_capture:
write_page(owner_page.path, owner_page.frontmatter, owner_page.body)
if not owners:
success(
f"Replaced {rel_path(target)} (previous version stays in git history). "
@@ -188,7 +313,20 @@ def _replace(files: list[Path], replaces: Path, page: Optional[str], dry_run: bo
def raw_accept_command(
files: list[Path] = typer.Argument(
...,
help="One or more files under incoming/<type>/, all belonging to the same source",
help="One or more files under incoming/, all belonging to the same source",
),
fidelity: Optional[str] = typer.Option(
None,
"--fidelity",
help="How faithful the capture is (see `wikitool types describe source`). Required unless "
"--replaces is given, where it is the optional, sanctioned way to correct an already-set value",
),
authority: Optional[str] = typer.Option(
None,
"--authority",
help="What the material may claim about its subject (see `wikitool types describe source`). "
"Required unless --replaces is given, where it is the optional, sanctioned way to correct "
"an already-set value",
),
page: Optional[str] = typer.Option(
None,
@@ -205,7 +343,7 @@ def raw_accept_command(
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
(date shard, 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.")
@@ -213,27 +351,30 @@ def raw_accept_command(
incoming = _incoming_dir()
if replaces is not None:
_replace(files, replaces, page, dry_run)
_replace(files, replaces, page, fidelity, authority, dry_run)
return
fidelity_choices, authority_choices = _capture_choices()
if fidelity is None or authority is None:
missing = ", ".join(n for n, v in (("--fidelity", fidelity), ("--authority", authority)) if v is None)
fail(
f"{missing} required - raw accept does not guess how faithful a capture is or what it "
f"may claim. --fidelity: {', '.join(fidelity_choices)}. --authority: {', '.join(authority_choices)}."
)
_check_capture_value("fidelity", fidelity, fidelity_choices)
_check_capture_value("authority", authority, authority_choices)
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()
_validate_under_incoming(path, incoming)
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] = []
@@ -256,12 +397,6 @@ def raw_accept_command(
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
@@ -278,36 +413,46 @@ def raw_accept_command(
)
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
if existing_raw_paths:
# Growing a bundle out of a single already-promoted file (Gitea
# #67 decision): the bundle forms at that file's own parent
# directory, never at today's shard - the file's capture date is
# whatever it always was, and a bundle mixing an old and a new
# shard would have no single correct address.
primary = existing_raw_paths[0]
bundle_dir = primary.parent / primary.stem
else:
primary = resolved[0]
bundle_dir = _shard_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)
dst = (bundle_dir / new_path.name) if bundle_dir is not None else (_shard_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.")
# 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.
# Stem uniqueness across raw/ (Gitea #64, widened by #67): the name this
# call is about to claim - 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)
occupied = _occupied_stems(config.RAW_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))
fail(_stem_collision_message(claimed_name, holder))
moving_existing = [src for src, _dst in moves if src in existing_raw_paths]
if moving_existing:
@@ -325,6 +470,19 @@ def raw_accept_command(
"Resolve the multiple ownership first (see `wikitool sources coverage`)."
)
if target_page is not None:
current_fidelity = target_page.frontmatter.get("fidelity")
current_authority = target_page.frontmatter.get("authority")
for field, value, current in (
("fidelity", fidelity, current_fidelity),
("authority", authority, current_authority),
):
if current not in (None, "") and current != value:
fail(
f"'{page}' already has {field}={current!r} - a capture field is fixed once. "
"Use `raw accept --replaces` to correct it."
)
if dry_run:
for src, dst in moves:
typer.echo(f"[dry-run] would move {rel_path(src)} -> {rel_path(dst)}")
@@ -332,6 +490,7 @@ def raw_accept_command(
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 set fidelity={fidelity!r}, authority={authority!r} on '{page}'")
typer.echo(f"[dry-run] would move {len(moves)} file(s). No files written.")
return
@@ -344,6 +503,8 @@ def raw_accept_command(
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]
target_page.frontmatter["fidelity"] = fidelity
target_page.frontmatter["authority"] = authority
write_page(target_page.path, target_page.frontmatter, target_page.body)
success(
f"Promoted {len(resolved)} file(s); updated raw_files: on '{page}' "
@@ -352,7 +513,12 @@ def raw_accept_command(
return
promoted = ", ".join(rel_path(moved_map[p]) for p in resolved)
raw_files_arg = ",".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."
f"Promoted {len(resolved)} file(s) to {promoted}.\n"
" Next:\n"
" tools/wikitool new source --name \"<Title>\" \\\n"
f" --set raw_files={raw_files_arg} \\\n"
f" --set fidelity={fidelity} --set authority={authority} \\\n"
" --set source_type=<category>"
)
+20
View File
@@ -128,6 +128,23 @@ def _settable_or_fail(field: str, schema: Optional[Dict[str, Any]], type_path: s
return properties[field]
def _capture_field_or_fail(field: str, value: Any, frontmatter: Dict[str, Any]) -> None:
"""Refuse to overwrite a capture field (Gitea #67) that already carries a
value - fill-once, not a denylist entry: `UNSETTABLE` would also forbid
the *first* write, which is exactly the write the backfill needs. A
capture field is fixed at `raw accept`/`new source` time; the only
sanctioned way to change an already-set value is a new edition of the
raw material (`raw accept --replaces`), never a second `touch`.
"""
current = frontmatter.get(field)
if current not in (None, "") and current != value:
fail(
f"`{field}` is a capture field: fixed once, at `raw accept`/`new source` time, and "
f"already reads {current!r}. A corrected capture is a new edition of the source, not "
f"a touch -> `wikitool raw accept --replaces <raw path> <incoming file>`."
)
def _apply_set(frontmatter: Dict[str, Any], field: str, value: Any) -> Optional[str]:
if frontmatter.get(field) == value:
return None
@@ -233,6 +250,7 @@ def touch_command(
try:
schema = resolver.get_schema(type_path, page.path)
capture_fields = set(resolver.get_capture_fields(type_path, page.path))
except ValueError as exc:
fail(str(exc))
@@ -290,6 +308,8 @@ def touch_command(
parsed = parse_set_fields(values, schema, flag=flag)
for field, value in parsed.items():
_settable_or_fail(field, schema, type_path)
if field in capture_fields:
_capture_field_or_fail(field, value, frontmatter)
change = apply(frontmatter, field, value)
touched.add(field)
if change: