From bd293c17018d7e348495bafdca02348cf1c34775 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:01:45 -0500 Subject: [PATCH] feat(content-build): scaffold package + Markdown yaml-block parser --- tools/content_build/__init__.py | 0 tools/content_build/errors.py | 12 +++++ tools/content_build/parse.py | 40 ++++++++++++++++ tools/content_build/tests/__init__.py | 0 tools/content_build/tests/test_parse.py | 61 +++++++++++++++++++++++++ tools/requirements-dev.txt | 3 ++ 6 files changed, 116 insertions(+) create mode 100644 tools/content_build/__init__.py create mode 100644 tools/content_build/errors.py create mode 100644 tools/content_build/parse.py create mode 100644 tools/content_build/tests/__init__.py create mode 100644 tools/content_build/tests/test_parse.py create mode 100644 tools/requirements-dev.txt diff --git a/tools/content_build/__init__.py b/tools/content_build/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/content_build/errors.py b/tools/content_build/errors.py new file mode 100644 index 0000000..b6c7ff5 --- /dev/null +++ b/tools/content_build/errors.py @@ -0,0 +1,12 @@ +"""The single failure type. Every validation/parse problem raises BuildError so +the CLI can print a uniform 'BUILD FAILED: [] ()'.""" + + +class BuildError(Exception): + def __init__(self, message: str, source: str = "", entry_id: str = ""): + self.message = message + self.source = source + self.entry_id = entry_id + loc = f" [{entry_id}]" if entry_id else "" + src = f" ({source})" if source else "" + super().__init__(f"{message}{loc}{src}") diff --git a/tools/content_build/parse.py b/tools/content_build/parse.py new file mode 100644 index 0000000..8e850e6 --- /dev/null +++ b/tools/content_build/parse.py @@ -0,0 +1,40 @@ +"""Markdown bible -> raw yaml-block entries. Knows only fences + YAML; nothing +about the schema's meaning. A block is opened by a line that is exactly ```yaml +(after strip) and closed by a line that is exactly ```.""" + +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from .errors import BuildError + + +@dataclass +class RawEntry: + data: dict + source: str # "duncarrow.md:53" + + +def parse_bible(path: Path) -> list[RawEntry]: + lines = path.read_text().splitlines() + entries: list[RawEntry] = [] + i, n = 0, len(lines) + while i < n: + if lines[i].strip() == "```yaml": + fence_line = i + 1 # 1-based line of the opening fence + i += 1 + block: list[str] = [] + while i < n and lines[i].strip() != "```": + block.append(lines[i]) + i += 1 + if i >= n: + raise BuildError("unterminated ```yaml block", + source=f"{path.name}:{fence_line}") + data = yaml.safe_load("\n".join(block)) + if not isinstance(data, dict): + raise BuildError("yaml block is not a mapping", + source=f"{path.name}:{fence_line}") + entries.append(RawEntry(data=data, source=f"{path.name}:{fence_line}")) + i += 1 + return entries diff --git a/tools/content_build/tests/__init__.py b/tools/content_build/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/content_build/tests/test_parse.py b/tools/content_build/tests/test_parse.py new file mode 100644 index 0000000..f90ff32 --- /dev/null +++ b/tools/content_build/tests/test_parse.py @@ -0,0 +1,61 @@ +from pathlib import Path + +import pytest + +from content_build.errors import BuildError +from content_build.parse import parse_bible + +BIBLE = """# A bible + +Some prose that must be ignored. + +```yaml +id: town.a +type: town +status: canon +secrecy: 0 +related: [] +body: > + Town A. +``` + +More prose. + +```yaml +id: rumor.x +type: rumor +status: canon +secrecy: 1 +related: [town.a] +body: > + A rumor. +``` +""" + + +def _write(tmp_path: Path, text: str) -> Path: + p = tmp_path / "bible.md" + p.write_text(text) + return p + + +def test_extracts_yaml_blocks_and_ignores_prose(tmp_path): + entries = parse_bible(_write(tmp_path, BIBLE)) + assert [e.data["id"] for e in entries] == ["town.a", "rumor.x"] + + +def test_source_carries_filename_and_line(tmp_path): + entries = parse_bible(_write(tmp_path, BIBLE)) + assert entries[0].source.startswith("bible.md:") + + +def test_unterminated_block_raises(tmp_path): + bad = "```yaml\nid: town.a\n" # no closing fence + with pytest.raises(BuildError): + parse_bible(_write(tmp_path, bad)) + + +def test_non_mapping_block_raises(tmp_path): + bad = "```yaml\n- just\n- a\n- list\n```\n" + with pytest.raises(BuildError): + parse_bible(_write(tmp_path, bad)) diff --git a/tools/requirements-dev.txt b/tools/requirements-dev.txt new file mode 100644 index 0000000..aa110ca --- /dev/null +++ b/tools/requirements-dev.txt @@ -0,0 +1,3 @@ +# Build-tool dev/CI deps. NOT installed into the api proxy runtime. +PyYAML==6.0.3 +pytest==8.3.2