fix(docs): self-consistent, hermetic library export (closes #623)
All checks were successful
CI / lint (push) Successful in 35s
CI / notebooks-smoke (push) Successful in 1m25s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 58s
Infra CI / zotero (push) Successful in 18s
Infra CI / docs (push) Successful in 17s
Infra CI / api (push) Successful in 1m9s
Infra CI / llm (push) Successful in 45s
Infra CI / mc (push) Successful in 13s
Deploy / report (push) Successful in 13s
CI / test (push) Successful in 14m14s

The exporter read tags in a second query after the minutes-long item
hydration; a tag edit landing in between (P37 controller tag ops during
#618's suite run) produced a library.json whose item tags were missing
from the tag list, failing test_tags_in_items_match_tag_list on every
run until regeneration. The tag list is now derived from the serialized
items themselves (self-consistent by construction) and the output file
is swapped in atomically.

Tests no longer assert on live data/bib.sqlite: TestExportLibrary
exports a fixture store via new STACK_BIB_DB/STACK_LIBRARY_JSON
overrides — the live-store contract is the docs build's own concern.

Co-designed with a peer session that landed the derived-tags exporter.
This commit is contained in:
kert
2026-08-14 15:10:48 -04:00
parent e91ea1cb7c
commit f34600749a
2 changed files with 109 additions and 85 deletions

View File

@@ -4,6 +4,14 @@ Reads ``data/bib.sqlite`` and writes ``docs/static/library.json``
with items, collections, and tags. Gracefully writes empty JSON if with items, collections, and tags. Gracefully writes empty JSON if
the database is missing. the database is missing.
The tag list is derived from the serialized items rather than queried
separately, so the export is self-consistent even if a writer mutates
tags during the minutes-long item scan (#623), and the output file is
swapped in atomically so concurrent readers never see a partial write.
``STACK_BIB_DB`` / ``STACK_LIBRARY_JSON`` override the default paths
(used by the hermetic tests in tests/docs/test_docs_build.py).
Usage:: Usage::
uv run python docs/scripts/export_library.py uv run python docs/scripts/export_library.py
@@ -12,20 +20,28 @@ Usage::
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
from collections import Counter
from pathlib import Path from pathlib import Path
BIB_DB = Path("data/bib.sqlite") BIB_DB = Path(os.environ.get("STACK_BIB_DB", "data/bib.sqlite"))
OUT_PATH = Path("docs/static/library.json") OUT_PATH = Path(os.environ.get("STACK_LIBRARY_JSON", "docs/static/library.json"))
EMPTY = {"items": [], "collections": [], "tags": []}
def _write(data: dict) -> None:
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = OUT_PATH.with_name(OUT_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, indent=2, default=str))
os.replace(tmp, OUT_PATH)
def main() -> None: def main() -> None:
if not BIB_DB.exists(): if not BIB_DB.exists():
print(f"bib.sqlite not found at {BIB_DB}, writing empty library.json") print(f"bib.sqlite not found at {BIB_DB}, writing empty library.json")
OUT_PATH.parent.mkdir(parents=True, exist_ok=True) _write(EMPTY)
OUT_PATH.write_text(
json.dumps({"items": [], "collections": [], "tags": []}, indent=2)
)
return return
# Import here so griffe stage doesn't need bib deps # Import here so griffe stage doesn't need bib deps
@@ -34,16 +50,13 @@ def main() -> None:
from bib.store import Store from bib.store import Store
except ImportError as exc: except ImportError as exc:
print(f"bib deps not available ({exc}), writing empty library.json") print(f"bib deps not available ({exc}), writing empty library.json")
OUT_PATH.parent.mkdir(parents=True, exist_ok=True) _write(EMPTY)
OUT_PATH.write_text(
json.dumps({"items": [], "collections": [], "tags": []}, indent=2)
)
return return
store = Store(str(BIB_DB)) store = Store(str(BIB_DB))
con = store._con()
items = store.list_items() items = store.list_items()
raw_collections = store.list_collections() raw_collections = store.list_collections()
tags = store.list_tags()
# item_count from list_collections() is direct-only, which is what # item_count from list_collections() is direct-only, which is what
# the library page filter uses (i.collections.includes(key)). # the library page filter uses (i.collections.includes(key)).
@@ -51,9 +64,9 @@ def main() -> None:
collections = [c for c in raw_collections if c["item_count"] > 0] collections = [c for c in raw_collections if c["item_count"] > 0]
serialized_items = [] serialized_items = []
tag_counts: Counter[str] = Counter()
for item in items: for item in items:
abstract = item.abstract or "" creator_rows = con.execute(
creator_rows = store._con().execute(
"SELECT c.last_name, c.first_name FROM item_creators ic " "SELECT c.last_name, c.first_name FROM item_creators ic "
"JOIN creators c ON ic.creator_id = c.id " "JOIN creators c ON ic.creator_id = c.id "
"WHERE ic.item_id = (SELECT id FROM items WHERE key = ?) " "WHERE ic.item_id = (SELECT id FROM items WHERE key = ?) "
@@ -66,6 +79,7 @@ def main() -> None:
else r["last_name"] else r["last_name"]
for r in creator_rows for r in creator_rows
] ]
tag_counts.update(item.tags)
serialized_items.append( serialized_items.append(
{ {
"key": item.key, "key": item.key,
@@ -73,24 +87,17 @@ def main() -> None:
"title": item.title, "title": item.title,
"url": item.url, "url": item.url,
"date_published": item.date_published, "date_published": item.date_published,
"abstract": abstract, "abstract": item.abstract or "",
"tags": item.tags, "tags": item.tags,
"collections": item.collections, "collections": item.collections,
"institution": item.institution, "institution": item.institution,
"creators": creators, "creators": creators,
} }
) )
store.close()
data = { tags = [{"name": n, "count": c} for n, c in sorted(tag_counts.items())]
"items": serialized_items, _write({"items": serialized_items, "collections": collections, "tags": tags})
"collections": collections,
"tags": tags,
}
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(json.dumps(data, indent=2, default=str))
if serialized_items:
assert len(serialized_items) > 0, "library.json has no items"
print(f"Wrote {len(serialized_items)} items to {OUT_PATH}") print(f"Wrote {len(serialized_items)} items to {OUT_PATH}")

View File

@@ -1,8 +1,14 @@
"""Integration tests for the docs build pipeline.""" """Integration tests for the docs build pipeline.
TestExportLibrary is hermetic (#623): it exports a small fixture store
via STACK_BIB_DB/STACK_LIBRARY_JSON overrides instead of asserting on
the live, constantly-mutating data/bib.sqlite.
"""
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import subprocess import subprocess
import sys import sys
@@ -11,9 +17,7 @@ from pathlib import Path
import pytest import pytest
_ROOT = Path(__file__).resolve().parents[2] _ROOT = Path(__file__).resolve().parents[2]
BIB_DB = _ROOT / "data/bib.sqlite"
EXPORT_SCRIPT = _ROOT / "docs/scripts/export_library.py" EXPORT_SCRIPT = _ROOT / "docs/scripts/export_library.py"
LIBRARY_JSON = _ROOT / "docs/static/library.json"
DOCS_DIR = _ROOT / "docs/docs" DOCS_DIR = _ROOT / "docs/docs"
INTRO_MD = DOCS_DIR / "intro.md" INTRO_MD = DOCS_DIR / "intro.md"
@@ -21,46 +25,54 @@ INTRO_MD = DOCS_DIR / "intro.md"
class TestExportLibrary: class TestExportLibrary:
"""Tests for docs/scripts/export_library.py.""" """Tests for docs/scripts/export_library.py."""
def test_export_runs_without_error(self): @pytest.fixture
def exported(self, tmp_path):
"""Export a 2-item fixture store; return the parsed payload."""
from bib.item import Item
from bib.store import Store
db = tmp_path / "bib.sqlite"
store = Store(str(db), storage_dir=tmp_path / "storage")
store.create(
Item(
item_type="rule",
title="CY2027 PFS proposed rule",
url="https://example.gov/nprm",
abstract="A" * 400,
tags=["module:pfs", "year:2027"],
)
)
store.create(
Item(
item_type="source",
title="Comment letter",
url="https://example.gov/comment",
tags=["module:pfs", "docket:CMS-2026-2377"],
)
)
store.close()
out = tmp_path / "library.json"
result = subprocess.run( result = subprocess.run(
[sys.executable, str(EXPORT_SCRIPT)], [sys.executable, str(EXPORT_SCRIPT)],
capture_output=True, capture_output=True,
text=True, text=True,
cwd=_ROOT,
env={
**os.environ,
"STACK_BIB_DB": str(db),
"STACK_LIBRARY_JSON": str(out),
},
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
return json.loads(out.read_text())
def test_library_json_has_items(self): def test_export_writes_fixture_items(self, exported):
"""library.json should have >0 items when bib.sqlite has data.""" assert len(exported["items"]) == 2
if not BIB_DB.exists(): titles = {i["title"] for i in exported["items"]}
pytest.skip("bib.sqlite not available") assert titles == {"CY2027 PFS proposed rule", "Comment letter"}
# Verify bib.sqlite is a real database with items
import sqlite3
try: def test_item_has_required_fields(self, exported):
con = sqlite3.connect(str(BIB_DB))
count = con.execute("SELECT count(*) FROM items").fetchone()[0]
con.close()
except (sqlite3.OperationalError, sqlite3.DatabaseError):
pytest.skip("bib.sqlite is not a valid database")
if count == 0:
pytest.skip("bib.sqlite has no items")
# Run the export
subprocess.run([sys.executable, str(EXPORT_SCRIPT)], check=True)
data = json.loads(LIBRARY_JSON.read_text())
assert len(data["items"]) > 0, "library.json has no items"
assert len(data["tags"]) > 0, "library.json has no tags"
def test_item_has_required_fields(self):
"""Each item must have all required fields."""
if not LIBRARY_JSON.exists():
import pytest
pytest.skip("library.json not generated")
data = json.loads(LIBRARY_JSON.read_text())
if not data["items"]:
import pytest
pytest.skip("no items in library.json")
required = { required = {
"key", "key",
"item_type", "item_type",
@@ -71,38 +83,43 @@ class TestExportLibrary:
"collections", "collections",
"creators", "creators",
} }
for item in data["items"][:10]: # spot check first 10 for item in exported["items"]:
missing = required - set(item.keys()) missing = required - set(item.keys())
assert not missing, f"Item {item.get('key', '?')} missing fields: {missing}" assert not missing, f"Item {item.get('key', '?')} missing fields: {missing}"
def test_abstracts_not_truncated(self): def test_abstracts_not_truncated(self, exported):
"""Abstracts should not be truncated to 300 chars.""" by_title = {i["title"]: i for i in exported["items"]}
if not LIBRARY_JSON.exists(): assert by_title["CY2027 PFS proposed rule"]["abstract"] == "A" * 400
import pytest
pytest.skip("library.json not generated") def test_tags_match_item_tags(self, exported):
data = json.loads(LIBRARY_JSON.read_text()) """The tag list is exactly the tags on serialized items, with
# Find items with long abstracts counts — self-consistent by construction, so a tag edit landing
truncated = [ mid-export can no longer orphan item tags (#623)."""
i["key"] assert exported["tags"] == [
for i in data["items"] {"name": "docket:CMS-2026-2377", "count": 1},
if i["abstract"].endswith("...") and len(i["abstract"]) == 300 {"name": "module:pfs", "count": 2},
{"name": "year:2027", "count": 1},
] ]
assert not truncated, f"Found truncated abstracts: {truncated[:5]}"
def test_tags_in_items_match_tag_list(self): def test_missing_db_writes_empty(self, tmp_path):
"""Every tag referenced by items should appear in the tags list.""" out = tmp_path / "library.json"
if not LIBRARY_JSON.exists(): result = subprocess.run(
import pytest [sys.executable, str(EXPORT_SCRIPT)],
capture_output=True,
pytest.skip("library.json not generated") text=True,
data = json.loads(LIBRARY_JSON.read_text()) cwd=_ROOT,
tag_names = {t["name"] for t in data["tags"]} env={
item_tags = set() **os.environ,
for item in data["items"]: "STACK_BIB_DB": str(tmp_path / "nope.sqlite"),
item_tags.update(item["tags"]) "STACK_LIBRARY_JSON": str(out),
orphans = item_tags - tag_names },
assert not orphans, f"Tags on items but not in tags list: {orphans}" )
assert result.returncode == 0, result.stderr
assert json.loads(out.read_text()) == {
"items": [],
"collections": [],
"tags": [],
}
class TestIntroLinks: class TestIntroLinks: