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
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:
@@ -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}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user