Files changed: - .gitattributes - CHANGES.md - VERSION - raw/CONTRACT.md - tools/README.md - tools/chemenu/commands/_util.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/eval_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/index_build.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/log_append.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/provenance_cmd.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/upstream_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/commands/work_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/filelock.py - tools/chemenu/frontmatter_io.py - tools/chemenu/kb_scan.py - tools/chemenu/kb_state.py - tools/chemenu/lint_core.py - tools/chemenu/prerequisites.py - tools/chemenu/provenance.py - tools/chemenu/search/base.py - tools/chemenu/search/ripgrep.py - tools/chemenu/telemetry/writer.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_portability.py - tools/chemenu/tests/test_search.py - tools/chemenu/tests/test_trace_ingest.py - tools/chemenu/type_resolver.py - tools/chemenu/upload.py - tools/chemenu/version.py - tools/run_wikitool.py - tools/trace_ingest.py
661 lines
31 KiB
Python
661 lines
31 KiB
Python
"""`wikitool raw accept` - promote one or more files from `incoming/` into
|
|
`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 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/<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/<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 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 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
|
|
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
|
|
|
|
import datetime
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from chemenu import cli_contract, config
|
|
from chemenu.commands._util import check_path_budget, fail, rel_path, success
|
|
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"
|
|
|
|
|
|
def _resolve(raw: Path) -> Path:
|
|
return raw if raw.is_absolute() else config.ROOT / raw
|
|
|
|
|
|
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:
|
|
fail(
|
|
f"{rel_path(path)} is not under incoming/ - `raw accept` only promotes files "
|
|
"from there. See raw/CONTRACT.md."
|
|
)
|
|
if len(rel.parts) < 1:
|
|
fail(f"incoming/{rel.as_posix()} names no file.")
|
|
if len(rel.parts) > 2:
|
|
fail(
|
|
f"incoming/{rel.as_posix()} 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)."
|
|
)
|
|
|
|
|
|
_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 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 _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}" 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"
|
|
" 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 _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
|
|
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.")
|
|
|
|
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.")
|
|
_validate_under_incoming(incoming_path, _incoming_dir())
|
|
|
|
target = _resolve(replaces)
|
|
try:
|
|
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.")
|
|
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`)."
|
|
)
|
|
|
|
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). "
|
|
"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.")
|
|
|
|
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="raw accept",
|
|
summary="Promote one or more files from `incoming/` into `raw/`.",
|
|
synopsis=(
|
|
cli_contract.Variant(
|
|
usage='raw accept <file> [<file> ...] --fidelity <v> --authority <v> '
|
|
'[--page "<Title>"] [--dry-run]',
|
|
notes="Promote one or more files from `incoming/` into today's `raw/<YYYY>/<MM>/` "
|
|
"shard",
|
|
),
|
|
cli_contract.Variant(
|
|
usage='raw accept <file> --replaces <raw-path> [--fidelity <v>] [--authority <v>] '
|
|
"[--dry-run]",
|
|
notes="Overwrite one existing raw file in place with a new edition",
|
|
),
|
|
),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.NO,
|
|
atomic="`raw accept`: No - one filesystem move per file, then (with `--page`) one page "
|
|
"write. `raw accept --replaces`: No - one `unlink()` + one `rename()`, plus (if "
|
|
"`--fidelity`/`--authority` was given) one page write",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"Promotes files from `incoming/` into `raw/<YYYY>/<MM>/`, computed from the accept "
|
|
"date rather than chosen by hand. A subdirectory under `incoming/` is tolerated and "
|
|
"ignored, not inspected - `raw/` does not address by type.",
|
|
"One file promoted alone lands with no directory of its own; several files in one call "
|
|
"nest under `raw/<YYYY>/<MM>/<stem>/`, named after the first file's stem.",
|
|
"`--fidelity`/`--authority` are required on a plain accept (`types describe source` "
|
|
"lists the values); `unknown` is refused - it is backfill-only.",
|
|
"`--page \"<Title>\"` additionally extends that existing source page's `raw_files:` in "
|
|
"the same call and writes both capture fields onto it - refused if it already carries a "
|
|
"different value, since a capture field is fixed once.",
|
|
"If `--page` raises the page past one file, its already-promoted file is folded into a "
|
|
"bundle at *its own* parent directory, not today's shard, so a bundle never mixes an "
|
|
"old and a new capture date - after checking that file has no other owner.",
|
|
"Every name occupied anywhere under `raw/` - file stems and bundle directory names "
|
|
"alike, old type directories and date shards together - stays unique: a promote whose "
|
|
"target name already belongs to something this call does not itself own is refused, "
|
|
"naming `--replaces` and renaming in `incoming/` as the two routes, without "
|
|
"recommending either.",
|
|
"`--replaces <raw-path>` overwrites that file in place with the single incoming file "
|
|
"(same filename required), leaves every page's `raw_files:` untouched and writes no "
|
|
"`kb/` page; the previous edition survives only in `git log --follow <raw-path>`.",
|
|
"With `--replaces`, `--fidelity`/`--authority` are optional, and passing one overwrites "
|
|
"the owning page's already-set value - the one path the fixed-once rule does not "
|
|
"block.",
|
|
"`--replaces` refuses a target with more than one owning source page; with none, it "
|
|
"replaces anyway and says so. It cannot be combined with `--page` or with more than one "
|
|
"incoming file.",
|
|
"`--replaces` prints the source page (if any) and its citing pages, so their update "
|
|
"lands in the same commit as the replacement.",
|
|
"A file already at its computed destination is what \"already exists\" reports, not a "
|
|
"partial prior run to resume - safe to retry as-is once a cause is fixed.",
|
|
"`--dry-run` reports the moves without making them.",
|
|
),
|
|
failures=(
|
|
cli_contract.Failure(
|
|
label="raw accept",
|
|
cause="A file does not exist, is not under `incoming/`, or is nested more than one "
|
|
"level below it; two files in one call share a filename; a target path already "
|
|
"exists; or a target path would be over the path budget (160 UTF-16 code units "
|
|
"below the instance root)",
|
|
reaction="Fix the named argument and retry once. For a path over the budget, "
|
|
"rename the file in `incoming/` to something shorter - the refusal comes before "
|
|
"anything moves, so `incoming/` and `raw/` are unchanged",
|
|
),
|
|
cli_contract.Failure(
|
|
label="raw accept",
|
|
cause="`--fidelity`/`--authority` is missing, or names `unknown` or a value outside "
|
|
"the schema's enum",
|
|
reaction="Pass both with a valid value, then retry once",
|
|
),
|
|
cli_contract.Failure(
|
|
label="raw accept",
|
|
cause="The target name is already occupied anywhere under `raw/` by something the "
|
|
"call does not own",
|
|
reaction="Not fixed by retrying: the refusal names `--replaces` (same source, new "
|
|
"edition) and renaming in `incoming/` (a separate source) as the two routes, and "
|
|
"neither is the tool's to pick. Show the message to the user and wait",
|
|
),
|
|
cli_contract.Failure(
|
|
label="raw accept",
|
|
cause="`--page` names an unknown page or one with no `raw_files:` yet, an existing "
|
|
"`raw_files:` entry is missing on disk, a file to be moved has more than one owning "
|
|
"page, or `--page` would overwrite an already-set `fidelity`/`authority` with a "
|
|
"different value",
|
|
reaction="Fix the named argument and retry once; a different capture value on an "
|
|
"existing page is a new edition - `--replaces`",
|
|
),
|
|
cli_contract.Failure(
|
|
label="raw accept --replaces",
|
|
cause="More than one incoming file, or `--page` also given",
|
|
reaction="A replacement is one file for one file - fix the call and retry once",
|
|
),
|
|
cli_contract.Failure(
|
|
label="raw accept --replaces",
|
|
cause="The incoming file does not exist or is not under `incoming/` (or is nested "
|
|
"more than one level below it), its filename differs from the target's, or the "
|
|
"target does not lie under `raw/` or does not exist",
|
|
reaction="Fix the named argument and retry once - every check runs before the "
|
|
"filesystem is touched, so both files are exactly as they were",
|
|
),
|
|
cli_contract.Failure(
|
|
label="raw accept --replaces",
|
|
cause="`--fidelity`/`--authority` names `unknown` or a value outside the schema's "
|
|
"enum, or the target has more than one owning source page",
|
|
reaction="Fix the named argument and retry once; nothing was touched",
|
|
),
|
|
),
|
|
examples=(
|
|
"tools/wikitool raw accept incoming/docker-cheatsheet.md --fidelity verbatim "
|
|
"--authority reporting",
|
|
'tools/wikitool raw accept incoming/part-2.md --fidelity verbatim --authority reporting '
|
|
'--page "Source - Docker Cheatsheet"',
|
|
"tools/wikitool raw accept incoming/cluster.md --replaces raw/documents/cluster.md",
|
|
),
|
|
never=(
|
|
"Never choose the destination under `raw/` by hand, and never move a file into `raw/` "
|
|
"yourself.",
|
|
"Never pick between `--replaces` and renaming on your own initiative after a "
|
|
"name-occupied refusal - the user tells the two intents apart.",
|
|
),
|
|
see_also=(
|
|
"`raw/CONTRACT.md` \"Getting a file in: incoming/\" - the rules and why",
|
|
"`wikitool types describe source` - the capture field values",
|
|
"`wikitool new source` - the source page for a promoted file",
|
|
),
|
|
))
|
|
@app.command("accept")
|
|
def raw_accept_command(
|
|
files: list[Path] = typer.Argument(
|
|
...,
|
|
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,
|
|
"--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",
|
|
),
|
|
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
|
|
(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.")
|
|
|
|
incoming = _incoming_dir()
|
|
|
|
if replaces is not None:
|
|
_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.")
|
|
_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.")
|
|
|
|
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 kb/. 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."
|
|
)
|
|
|
|
# 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:
|
|
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 (_shard_dir() / new_path.name)
|
|
moves.append((new_path, dst))
|
|
|
|
for _src, dst in moves:
|
|
check_path_budget(
|
|
dst,
|
|
"The name comes from the file in incoming/: rename it there to something shorter "
|
|
"and accept it again.",
|
|
)
|
|
if dst.exists():
|
|
fail(f"Cannot promote: {rel_path(dst)} already exists.")
|
|
|
|
# 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(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(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)
|
|
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 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)}")
|
|
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 set fidelity={fidelity!r}, authority={authority!r} on '{page}'")
|
|
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]
|
|
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}' "
|
|
f"({len(final)} file(s) total)."
|
|
)
|
|
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}.\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>"
|
|
)
|