Files
rfc-app/backend/app/metadata.py
T
Ben Stull f05ee59763 v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:24:27 -07:00

218 lines
7.7 KiB
Python

"""§22.4a configurable collection metadata — sidecar storage + dual-read.
SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md.
Entry metadata is collection-configured and stored in a per-entry sidecar,
`<slug>.meta.yaml`, with the `.md` body kept as pure prose (INV-2). This module
is the storage/compat layer:
- the **dual-read** parser (`read_entry`) — read the sidecar if present, else
legacy top-of-document frontmatter, with identical resulting records
(INV-6);
- sidecar (de)serialization that preserves unknown / forward-compat keys
(INV-7), reusing `entry`'s canonical field semantics;
- lenient parsing that never hard-fails a read — a malformed sidecar surfaces
a flag, not an exception (INV-3).
The collection `fields:` schema and per-field validation are SLICE-2; faceted
filtering and the edit UIs are later slices. This module interprets no field
values — it only moves metadata between git and in-memory `Entry` records.
"""
from __future__ import annotations
from typing import Any
import yaml
from . import entry as entry_mod
from .entry import Entry
SIDECAR_SUFFIX = ".meta.yaml"
# ----- filename helpers -----
def sidecar_name(slug: str) -> str:
"""The sidecar filename for an entry whose markdown is `<slug>.md`."""
return f"{slug}{SIDECAR_SUFFIX}"
def is_sidecar(name: str) -> bool:
return name.endswith(SIDECAR_SUFFIX)
def slug_of_sidecar(name: str) -> str:
"""The entry stem for a `<slug>.meta.yaml` filename."""
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
# ----- metadata <-> sidecar -----
def metadata_dict(entry: Entry) -> dict[str, Any]:
"""The full metadata mapping for an entry (known fields + `extra`)."""
return entry_mod.to_frontmatter_dict(entry)
def sidecar_yaml(entry: Entry) -> str:
"""Render an entry's metadata as standalone `<slug>.meta.yaml` text."""
return yaml.safe_dump(
metadata_dict(entry), sort_keys=False, default_flow_style=False
)
def parse_sidecar(text: str) -> tuple[dict[str, Any], bool]:
"""Parse sidecar YAML leniently → `(values, malformed)`.
`malformed` is True when the text is not a YAML mapping (a list, a scalar,
or a YAML syntax error). An empty / whitespace-only sidecar is an empty
mapping, not malformed. Never raises (INV-3).
"""
try:
raw = yaml.safe_load(text)
except yaml.YAMLError:
return {}, True
if raw is None:
return {}, False
if not isinstance(raw, dict):
return {}, True
return raw, False
# ----- frontmatter stripping (INV-2) -----
def strip_frontmatter(md_text: str) -> str:
"""Return the prose body of a `.md`, dropping a leading `---…---` block.
A migrated entry's `.md` is body-only and passes through unchanged. A
not-yet-migrated `.md` still carrying frontmatter yields just its body, so
the dual-read body is the same either way.
"""
match = entry_mod.FRONTMATTER_RE.match(md_text)
if not match:
return md_text
return match.group(2).lstrip("\n")
# ----- dual-read (INV-6) -----
def read_entry(
md_text: str, sidecar_text: str | None, *, fallback_slug: str | None = None
) -> tuple[Entry, bool]:
"""Read an entry from its `.md` and optional sidecar → `(Entry, malformed)`.
- **Sidecar present and well-formed (non-empty):** metadata comes from the
sidecar; the body is the `.md` stripped of any leading frontmatter. The
sidecar is the source of truth (INV-1) and wins over stale `.md`
frontmatter.
- **Sidecar present but malformed:** the entry still loads from the legacy
`.md` frontmatter (if any) and is flagged `malformed` (INV-3).
- **Sidecar present but empty:** it has nothing to override with, so fall
back to the `.md` frontmatter (not flagged).
- **No sidecar:** the legacy path — parse the `.md` frontmatter (INV-6).
`fallback_slug` (typically the filename stem) backstops the entry's slug
whenever the metadata source lacks one — so a degenerate sidecar never
yields a slug-less record the caller has to silently drop (INV-3).
"""
def _with_slug(entry: Entry) -> Entry:
if not entry.slug and fallback_slug:
entry.slug = fallback_slug
return entry
if sidecar_text is None:
return _with_slug(entry_mod.parse(md_text)), False
values, malformed = parse_sidecar(sidecar_text)
if malformed or not values:
# Malformed or empty sidecar: load from the legacy .md so the entry
# still loads; flag only when the sidecar was actually malformed.
try:
entry = entry_mod.parse(md_text)
except ValueError:
entry = entry_mod.from_frontmatter({}, strip_frontmatter(md_text))
return _with_slug(entry), malformed
body = strip_frontmatter(md_text)
return _with_slug(entry_mod.from_frontmatter(values, body)), False
# ----- frontmatter -> sidecar migration (PUC-5) -----
async def migrate_collection(
gitea: Any,
*,
org: str,
repo: str,
subfolder: str = "",
actor: Any = None,
branch: str = "main",
) -> dict[str, Any]:
"""Migrate a collection's legacy-frontmatter entries to sidecars.
Walks `<subfolder>/rfcs`; for each `<slug>.md` that has legacy frontmatter
and **no** `<slug>.meta.yaml` sibling yet, it stages two file changes —
create the sidecar (the entry's metadata, unknown keys preserved, INV-7)
and rewrite the `.md` to body-only (INV-2) — and commits all of them in a
single ChangeFiles commit (§6.5: one commit per collection).
Idempotent: an entry that already has a sidecar is skipped; a second run
with nothing left to migrate makes no commit. Returns
`{"migrated": [...], "skipped": [...], "committed": bool}`.
"""
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
listing = await gitea.list_dir(org, repo, rfcs_dir, ref=branch)
names = {f.get("name") for f in listing if f.get("type") == "file"}
ops: list[dict[str, Any]] = []
migrated: list[str] = []
skipped: list[str] = []
for f in listing:
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
continue
slug = f["name"][:-len(".md")]
if sidecar_name(slug) in names:
skipped.append(slug) # already migrated
continue
result = await gitea.read_file(org, repo, f["path"], ref=branch)
if not result:
continue
text, sha = result
try:
e = entry_mod.parse(text)
except ValueError:
# No frontmatter to lift (e.g. an already-clean body without a
# sidecar) — nothing to migrate; leave it untouched.
skipped.append(slug)
continue
body = strip_frontmatter(text)
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
ops.append({
"operation": "create",
"path": f"{rfcs_dir}/{sidecar_name(slug)}",
"content": sidecar_yaml(e),
})
ops.append({
"operation": "update",
"path": f["path"],
"content": new_md,
"sha": sha,
})
migrated.append(slug)
committed = False
if ops:
n = len(migrated)
message = f"Migrate {n} entr{'y' if n == 1 else 'ies'} to metadata sidecars (§22.4a)"
kwargs: dict[str, Any] = {}
if actor is not None:
kwargs = {
"author_name": actor.display_name,
"author_email": actor.email or f"{actor.gitea_login}@users.noreply",
}
await gitea.change_files(
org, repo, files=ops, message=message, branch=branch, **kwargs
)
committed = True
return {"migrated": migrated, "skipped": skipped, "committed": committed}