Files
chemenu/tools/chemenu/frontmatter_io.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

194 lines
7.5 KiB
Python

"""Read/write markdown files with YAML frontmatter, matching the formatting
conventions already used across wiki/ (inline flow-style lists, unquoted
dates, two-decimal confidence values).
We deliberately avoid a generic yaml.dump() for the frontmatter block because
PyYAML's default block-style output does not match the existing convention
(e.g. `tags: [a, b, c]` on one line). Instead we serialize each top-level key
explicitly, preserving dict insertion order.
"""
from __future__ import annotations
import datetime
import re
from pathlib import Path
from typing import Any
import yaml
FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.DOTALL)
def read_page(path: Path) -> tuple[dict[str, Any], str]:
"""Return (frontmatter_dict, body) for a markdown file. If the file has no
frontmatter block, returns ({}, full_text).
Deliberately permissive: malformed YAML degrades to an empty dict so bulk
operations never crash on one bad page. Use `frontmatter_error()` (which
`wikitool lint` does) to surface those pages instead of losing them
silently.
"""
text = path.read_text(encoding="utf-8")
match = FRONTMATTER_RE.match(text)
if not match:
return {}, text
fm_text, body = match.group(1), match.group(2)
try:
frontmatter = yaml.safe_load(fm_text) or {}
except yaml.YAMLError:
frontmatter = {}
if not isinstance(frontmatter, dict):
frontmatter = {}
return frontmatter, body
def frontmatter_error(path: Path) -> str | None:
"""Return a human-readable reason why `path`'s frontmatter can't be used,
or None if it parses into a dict.
This is the strict counterpart to `read_page()`: without it, a page whose
YAML is malformed (or whose frontmatter block is missing entirely) reads
back as `{}` and then quietly slips past every frontmatter-driven check.
"""
text = path.read_text(encoding="utf-8")
match = FRONTMATTER_RE.match(text)
if not match:
return "no `---` frontmatter block"
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError as exc:
reason = str(exc).splitlines()[0] if str(exc) else exc.__class__.__name__
return f"invalid YAML frontmatter: {reason}"
if parsed is None:
return "empty frontmatter block"
if not isinstance(parsed, dict):
return f"frontmatter is {type(parsed).__name__}, expected a mapping"
return None
def _format_scalar(value: Any, flow: bool = False) -> str:
"""Render one frontmatter value. `flow` says it is going inside a `[...]`
sequence, where more characters are indicators than at document level."""
if value is None:
return '""'
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, float):
return f"{value:.2f}"
if isinstance(value, int):
return str(value)
if isinstance(value, (datetime.date, datetime.datetime)):
return value.isoformat()
text = str(value)
needs_quoting = (
text == ""
or text[0] in "[{'\"#&*!|>%@`"
or text.strip() != text
or ": " in text
or not _round_trips_as_string(text, flow=flow)
)
if needs_quoting:
return _quote(text)
return text
def _quote(text: str) -> str:
"""Render `text` as a quoted YAML scalar valid in any context.
The dumper is asked for a one-element flow sequence and the brackets are
taken off again, rather than a bare scalar: a bare plain scalar comes back
with a `...` document-end marker attached, which is correct YAML for a
document and nonsense inside a `[...]` list. Going through the library
either way keeps the escaping rules where they belong - the same reason
`_round_trips_as_string` asks the loader instead of listing cases.
"""
dumped = yaml.safe_dump([text], default_flow_style=True, width=10**9).strip()
return dumped[1:-1].strip()
def _round_trips_as_string(text: str, flow: bool = False) -> bool:
"""Whether writing `text` unquoted would read back as the same string.
A string that merely *looks* like another YAML type comes back as that type:
`"1945"` written bare is an int on the next read, and a schema expecting a
string then rejects a page nothing visibly changed. The same holds for
floats, YAML 1.1's `yes`/`no`/`on`/`off` booleans, `null`, and forms like
`0x1F` or `1_000`.
Asking the loader instead of listing the cases is deliberate: the reader and
the writer then agree by construction, and a resolver rule this code never
heard of cannot drift out from under it.
Dates need no exception here. A date field holds a `datetime.date`, which
`_format_scalar` renders bare before ever reaching this function - see
`normalize_dates` for the other half of that contract.
`flow` asks the question in the context list values are actually written
in. At document level a comma is an ordinary character, so the probe says
"safe to write bare"; inside the `[...]` this file emits for every list it
is an indicator, and the value silently comes back as two elements. That is
how a `raw_files:` entry naming a file with a comma in its name lost half
of itself on write - after `--set` had parsed it correctly.
"""
probe, expected = (f"[{text}]", [text]) if flow else (text, text)
try:
return yaml.safe_load(probe) == expected
except yaml.YAMLError:
# Unparseable bare - quoting is exactly the fix.
return False
def normalize_dates(frontmatter: dict[str, Any]) -> dict[str, Any]:
"""A copy of `frontmatter` with every `datetime.date` rendered as an ISO
string, at the top level and one list deep.
Frontmatter carries dates as `datetime.date` - that is what `yaml.safe_load`
produces for a bare `2026-08-29`, what `_format_scalar` writes back bare, and
therefore what the whole corpus stores. The schemas nonetheless declare those
fields `type: string`, so anything validating raw frontmatter has to convert
first.
This lives here rather than beside one validator because there are two, and
only one of them used to do it: `TypeResolver.validate_frontmatter` (behind
`lint`) normalized, while `touch`'s `validate_fields` did not. The gap was
invisible only because `touch` happened to write date *strings*.
"""
normalized: dict[str, Any] = {}
for key, value in frontmatter.items():
if isinstance(value, datetime.date):
normalized[key] = value.isoformat()
elif isinstance(value, list):
normalized[key] = [
item.isoformat() if isinstance(item, datetime.date) else item for item in value
]
else:
normalized[key] = value
return normalized
def _format_list(items: list[Any]) -> str:
if not items:
return "[]"
return "[" + ", ".join(_format_scalar(v, flow=True) for v in items) + "]"
def dump_frontmatter(frontmatter: dict[str, Any]) -> str:
lines = []
for key, value in frontmatter.items():
if isinstance(value, list):
lines.append(f"{key}: {_format_list(value)}")
else:
lines.append(f"{key}: {_format_scalar(value)}")
return "\n".join(lines)
def write_page(path: Path, frontmatter: dict[str, Any], body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fm_text = dump_frontmatter(frontmatter)
if not body.startswith("\n"):
body = "\n" + body
content = f"---\n{fm_text}\n---{body}"
if not content.endswith("\n"):
content += "\n"
path.write_text(content, encoding="utf-8")