Files
stack/docs/scripts/export_library.py
kert f34600749a
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
fix(docs): self-consistent, hermetic library export (closes #623)
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.
2026-08-14 15:10:48 -04:00

106 lines
3.5 KiB
Python

"""Export bib.Store contents to static JSON for the library browser.
Reads ``data/bib.sqlite`` and writes ``docs/static/library.json``
with items, collections, and tags. Gracefully writes empty JSON if
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::
uv run python docs/scripts/export_library.py
"""
from __future__ import annotations
import json
import os
import sys
from collections import Counter
from pathlib import Path
BIB_DB = Path(os.environ.get("STACK_BIB_DB", "data/bib.sqlite"))
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:
if not BIB_DB.exists():
print(f"bib.sqlite not found at {BIB_DB}, writing empty library.json")
_write(EMPTY)
return
# Import here so griffe stage doesn't need bib deps
sys.path.insert(0, str(Path("src").resolve()))
try:
from bib.store import Store
except ImportError as exc:
print(f"bib deps not available ({exc}), writing empty library.json")
_write(EMPTY)
return
store = Store(str(BIB_DB))
con = store._con()
items = store.list_items()
raw_collections = store.list_collections()
# item_count from list_collections() is direct-only, which is what
# the library page filter uses (i.collections.includes(key)).
# Drop collections with 0 direct items — they produce empty results.
collections = [c for c in raw_collections if c["item_count"] > 0]
serialized_items = []
tag_counts: Counter[str] = Counter()
for item in items:
creator_rows = con.execute(
"SELECT c.last_name, c.first_name FROM item_creators ic "
"JOIN creators c ON ic.creator_id = c.id "
"WHERE ic.item_id = (SELECT id FROM items WHERE key = ?) "
"ORDER BY ic.sort_order",
(item.key,),
).fetchall()
creators = [
f"{r['last_name']}, {r['first_name']}"
if r["first_name"]
else r["last_name"]
for r in creator_rows
]
tag_counts.update(item.tags)
serialized_items.append(
{
"key": item.key,
"item_type": item.item_type,
"title": item.title,
"url": item.url,
"date_published": item.date_published,
"abstract": item.abstract or "",
"tags": item.tags,
"collections": item.collections,
"institution": item.institution,
"creators": creators,
}
)
store.close()
tags = [{"name": n, "count": c} for n, c in sorted(tag_counts.items())]
_write({"items": serialized_items, "collections": collections, "tags": tags})
print(f"Wrote {len(serialized_items)} items to {OUT_PATH}")
if __name__ == "__main__":
main()