Files
stack/tests/lead/test_release_build.py
kert bf9485e634
Some checks failed
CI / lint (push) Successful in 1m25s
CI / notebooks-smoke (push) Successful in 2m12s
Deploy / changes (push) Successful in 22s
CI / test (push) Has been cancelled
Deploy / report (push) Has been cancelled
Deploy / notebooks (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / bcda (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / notebooks (push) Has started running
feat(lead): stack lead release build/verify — BagIt + RO-Crate review releases
Each LEAD living-review update becomes a BagIt 1.0 bag (sha512
manifests, Payload-Oxum, bag-info with the OSF registration DOI and git
commit) holding the protocol (OSF registration 8wq3m, verbatim and
rendered), the evidence ledger tables, human screening decisions and
PRISMA flow counts, included/screened bibliography as CSL-JSON, the
theme vocabulary, and a source-only code archive (src, config,
dev/scripts, specs — no third-party documents). RO-Crate 1.2 metadata
describes every payload file with its sha256 and records the build.
Reviewer email addresses are replaced with reviewer-N. Builds are
deterministic for the same inputs; verify checks manifests, Oxum and
the crate. Stdlib only, no new dependencies.
2026-11-11 15:09:46 -05:00

640 lines
23 KiB
Python

"""`stack lead release build` / `verify`: a BagIt bag with RO-Crate metadata."""
from __future__ import annotations
import dataclasses
import hashlib
import json
import os
import sqlite3
import subprocess
import tarfile
from pathlib import Path
import duckdb
import polars as pl
import pytest
from typer.testing import CliRunner
from cli import app
from lead.ledger.store import append, open_ledger
from lead.release import Inputs, build_release, verify_release
from lead.release.bagit import read_bag_info
from lead.release.protocol import normalize
from lead.review.store import Store
runner = CliRunner()
EMAIL = "reviewer@example.org"
REGISTRATION = normalize(
{
"id": "8wq3m",
"attributes": {
"title": "LEAD evidence chassis",
"description": "Protocol for a living review.",
"date_registered": "2026-11-01T03:36:24.703072",
"registration_supplement": "Generalized Systematic Review Registration",
"registration_responses": {"376-8": "Living review."},
},
},
[
{
"attributes": {
"index": 0,
"block_type": "page-heading",
"display_text": "Methods",
}
},
{
"attributes": {
"index": 1,
"block_type": "question-label",
"display_text": "Type of review",
"schema_block_group_key": "g",
}
},
{
"attributes": {
"index": 2,
"block_type": "long-text-input",
"registration_response_key": "376-8",
"schema_block_group_key": "g",
}
},
],
)
# ── fixtures ─────────────────────────────────────────────────────
def _git(*args: str, cwd: Path) -> str:
# A pre-commit hook exports GIT_DIR / GIT_INDEX_FILE; without dropping
# them every scratch-repo command would land in the real repo.
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(
GIT_AUTHOR_NAME="t",
GIT_AUTHOR_EMAIL="t@example.invalid",
GIT_COMMITTER_NAME="t",
GIT_COMMITTER_EMAIL="t@example.invalid",
GIT_AUTHOR_DATE="2026-11-01T00:00:00Z",
GIT_COMMITTER_DATE="2026-11-01T00:00:00Z",
)
return subprocess.run(
["git", "-c", "core.fsync=none", *args],
cwd=cwd,
env=env,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _repo(root: Path) -> Path:
repo = root / "repo"
repo.mkdir()
_git("init", "-q", "-b", "main", cwd=repo)
(repo / "README.md").write_text("stack\n")
(repo / "src").mkdir()
(repo / "src" / "app.py").write_text("print('lead')\n")
(repo / "hw" / "docs").mkdir(parents=True)
(repo / "hw" / "docs" / "vendor-manual.pdf").write_bytes(b"%PDF third-party")
_git("add", "-A", cwd=repo)
_git("commit", "-q", "-m", "base", cwd=repo)
return repo
def _ledger(root: Path) -> Path:
path = root / "ledger.duckdb"
con = open_ledger(str(path))
try:
append(
con,
"claim",
{
"kind": "precursor",
"subject": "readmission",
"target_id": "Q.ACR",
"tiers": "literature",
"content_hash": "c1",
"actor": EMAIL,
"created_at": "2026-11-01T00:00:00Z",
},
)
append(
con,
"search",
{
"project": "lead-history",
"database": "pubmed",
"query": "LEAD[tiab]",
"hit_count": 3,
"content_hash": "s1",
"actor": "stack",
"created_at": "2026-11-01T00:00:00Z",
},
)
finally:
con.close()
return path
def _labels(root: Path) -> Path:
# Store creates the label table; rows go in one unsynced transaction
# (Store commits, and so fsyncs, per row).
path = root / "review" / "labels.sqlite"
Store(path)
con = sqlite3.connect(path)
try:
con.execute("PRAGMA synchronous=OFF")
con.executemany(
"""INSERT INTO label (zotero_key, project, stage, pass, decision,
reason, rationale, actor, created_at)
VALUES (?, 'lead-history', 'title_abstract', 1, 'include', '',
'CMS methodology', ?, '2026-11-04T23:59:36Z')""",
[(key, EMAIL) for key in ("AAAA2222", "BBBB3333", "GGGG8888")],
)
con.commit()
finally:
con.close()
return path
_ZOTERO_DDL = """
CREATE TABLE itemTypes (itemTypeID INTEGER PRIMARY KEY, typeName TEXT);
CREATE TABLE items (itemID INTEGER PRIMARY KEY, itemTypeID INT, key TEXT);
CREATE TABLE tags (tagID INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE itemTags (itemID INT, tagID INT, type INT DEFAULT 0);
CREATE TABLE fields (fieldID INTEGER PRIMARY KEY, fieldName TEXT);
CREATE TABLE itemDataValues (valueID INTEGER PRIMARY KEY, value);
CREATE TABLE itemData (itemID INT, fieldID INT, valueID INT);
CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT,
lastName TEXT, fieldMode INT);
CREATE TABLE creatorTypes (creatorTypeID INTEGER PRIMARY KEY, creatorType TEXT);
CREATE TABLE itemCreators (itemID INT, creatorID INT, creatorTypeID INT,
orderIndex INT);
"""
def _zotero(root: Path) -> Path:
path = root / "zotero.sqlite"
con = sqlite3.connect(path)
try:
con.execute("PRAGMA synchronous=OFF")
con.execute("PRAGMA journal_mode=MEMORY")
con.executescript(_ZOTERO_DDL)
types = ["journalArticle", "report", "note", "statute"]
con.executemany(
"INSERT INTO itemTypes VALUES (?, ?)", list(enumerate(types, start=1))
)
fields = ["title", "date", "DOI", "url", "nameOfAct", "dateEnacted"]
con.executemany(
"INSERT INTO fields VALUES (?, ?)", list(enumerate(fields, start=1))
)
con.executemany(
"INSERT INTO creatorTypes VALUES (?, ?)", [(1, "author"), (2, "editor")]
)
tag_ids: dict[str, int] = {}
value_ids: dict[str, int] = {}
def item(item_id, key, type_name, tags, data, creators=()):
con.execute(
"INSERT INTO items VALUES (?, ?, ?)",
(item_id, types.index(type_name) + 1, key),
)
for name in tags:
tag_id = tag_ids.setdefault(name, len(tag_ids) + 1)
con.execute("INSERT OR IGNORE INTO tags VALUES (?, ?)", (tag_id, name))
con.execute("INSERT INTO itemTags VALUES (?, ?, 0)", (item_id, tag_id))
for field, value in data.items():
value_id = value_ids.setdefault(value, len(value_ids) + 1)
con.execute(
"INSERT OR IGNORE INTO itemDataValues VALUES (?, ?)",
(value_id, value),
)
con.execute(
"INSERT INTO itemData VALUES (?, ?, ?)",
(item_id, fields.index(field) + 1, value_id),
)
for order, (first, last, mode, ctype) in enumerate(creators):
cur = con.execute(
"INSERT INTO creators (firstName, lastName, fieldMode) "
"VALUES (?, ?, ?)",
(first, last, mode),
)
con.execute(
"INSERT INTO itemCreators VALUES (?, ?, ?, ?)",
(item_id, cur.lastrowid, ctype, order),
)
item(
1,
"AAAA2222",
"journalArticle",
["project:lead-history", "stage:4-included", "found-in:pubmed"],
{"title": "Alpha study", "date": "2020-03-00 March 2020", "DOI": "10.1/a"},
[("Jane", "Smith", 0, 1), ("Ed", "Itor", 0, 2)],
)
item(
2,
"BBBB3333",
"report",
["project:lead-history"],
{"title": "Beta report", "url": "https://example.org/b", "date": "2019"},
[("", "CMS", 1, 1)],
)
item(
3,
"CCCC4444",
"statute",
[
"project:lead-limits",
"stage:2-title-abstract",
"screen:exclude",
"screen:reason:off-topic",
],
{"nameOfAct": "Gamma Act", "dateEnacted": "2010-03-23 2010-03-23"},
)
item(4, "DDDD5555", "journalArticle", ["project:lead-scan"], {"title": "Delta"})
item(5, "EEEE6666", "note", ["project:lead-history", "module:prisma"], {})
item(
6,
"FFFF7777",
"journalArticle",
["project:skin-subs", "stage:4-included"],
{"title": "Not LEAD"},
)
con.commit()
finally:
con.close()
return path
def _vocab(root: Path) -> Path:
path = root / "themes.yaml"
path.write_text("# vocab\nversion: 3\nthemes:\n - slug: work-rvu\n")
return path
@pytest.fixture(scope="module")
def inputs(tmp_path_factory: pytest.TempPathFactory) -> Inputs:
"""Built once per module: the build only reads them."""
src = tmp_path_factory.mktemp("src")
return Inputs(
ledger=_ledger(src),
labels=_labels(src),
zotero=_zotero(src),
vocab=_vocab(src),
repo=_repo(src),
registration=REGISTRATION,
)
def _build(inputs: Inputs, out: Path, **kw):
kw.setdefault("version", "0.1")
kw.setdefault("date", "2026-11-11")
kw.setdefault("note", "First release.")
return build_release(out=out, inputs=inputs, **kw)
def _sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
# ── build ────────────────────────────────────────────────────────
def test_build_writes_a_valid_bag(inputs, tmp_path):
result = _build(inputs, tmp_path / "out")
bag = tmp_path / "out" / "lead-review-v0.1"
assert result.path == bag
assert verify_release(bag) == []
payload = sorted(
p.relative_to(bag).as_posix() for p in (bag / "data").rglob("*") if p.is_file()
)
sha = _git("rev-parse", "HEAD", cwd=inputs.repo)
assert result.commit == sha
assert payload == sorted(
[
"data/bibliography/included.csl.json",
"data/bibliography/screened.csl.json",
f"data/code/stack-{sha}.tar.gz",
*[
f"data/ledger/{t}.parquet"
for t in (
"claim",
"decision",
"exogenous",
"extraction",
"feature",
"finding",
"run",
"search",
"source",
)
],
"data/protocol/protocol.md",
"data/protocol/registration-8wq3m.json",
"data/ro-crate-metadata.json",
"data/screening/decisions.parquet",
"data/screening/prisma-flow.json",
"data/tags/vocab-v3.yaml",
]
)
assert result.files == len(payload)
assert result.bytes == sum((bag / p).stat().st_size for p in payload)
assert sorted(p.name for p in bag.iterdir()) == [
"bag-info.txt",
"bagit.txt",
"data",
"manifest-sha512.txt",
"tagmanifest-sha512.txt",
]
def test_build_does_not_touch_its_inputs(inputs, tmp_path):
files = [inputs.ledger, inputs.labels, inputs.zotero, inputs.vocab]
before = {p: (_sha(p), p.stat().st_mtime_ns) for p in files}
siblings = {p.parent: sorted(x.name for x in p.parent.iterdir()) for p in files}
_build(inputs, tmp_path / "out")
assert {p: (_sha(p), p.stat().st_mtime_ns) for p in files} == before
assert {d: sorted(x.name for x in d.iterdir()) for d in siblings} == siblings
def test_bag_info_fields(inputs, tmp_path):
result = _build(inputs, tmp_path / "out", source_org="LEAD review team")
info = dict(read_bag_info(result.path))
assert info["Source-Organization"] == "LEAD review team"
assert info["External-Identifier"] == "doi:10.17605/OSF.IO/8WQ3M"
assert info["Bag-Group-Identifier"] == "lead-living-review"
assert info["Bagging-Date"] == "2026-11-11"
assert info["Version"] == "0.1"
assert info["Git-Commit"] == result.commit
assert info["Internal-Sender-Description"] == "First release."
assert info["Payload-Oxum"] == f"{result.bytes}.{result.files}"
assert "Bag-Size" in info
def test_build_is_deterministic_except_for_the_date(inputs, tmp_path):
a = _build(inputs, tmp_path / "a").path
b = _build(inputs, tmp_path / "b").path
assert (a / "manifest-sha512.txt").read_text() == (
b / "manifest-sha512.txt"
).read_text()
assert (a / "tagmanifest-sha512.txt").read_text() == (
b / "tagmanifest-sha512.txt"
).read_text()
c = _build(inputs, tmp_path / "c", date="2026-12-01").path
left = (a / "manifest-sha512.txt").read_text().splitlines()
right = (c / "manifest-sha512.txt").read_text().splitlines()
changed = sorted({line.split()[1] for line in set(left) ^ set(right)})
assert changed == ["data/ro-crate-metadata.json"]
def test_protocol_files(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
reg = json.loads((bag / "data/protocol/registration-8wq3m.json").read_text())
assert reg == REGISTRATION
text = (bag / "data/protocol/protocol.md").read_text()
assert "### Type of review\n\nLiving review.\n" in text
def test_ledger_tables_are_exported_with_email_actors_aliased(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
claim = pl.read_parquet(bag / "data/ledger/claim.parquet")
assert claim["subject"].to_list() == ["readmission"]
assert claim["actor"].to_list() == ["reviewer-1"]
search = pl.read_parquet(bag / "data/ledger/search.parquet")
assert search["actor"].to_list() == ["stack"]
assert search["hit_count"].to_list() == [3]
empty = pl.read_parquet(bag / "data/ledger/finding.parquet")
assert empty.height == 0 and "claim_id" in empty.columns
def test_screening_decisions_and_flow(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
decisions = pl.read_parquet(bag / "data/screening/decisions.parquet")
assert decisions.columns == [
"label_id",
"zotero_key",
"project",
"stage",
"pass",
"decision",
"reason",
"rationale",
"actor",
"created_at",
]
assert decisions["zotero_key"].to_list() == ["AAAA2222", "BBBB3333", "GGGG8888"]
assert set(decisions["actor"]) == {"reviewer-1"}
raw = b"".join(p.read_bytes() for p in (bag / "data").rglob("*") if p.is_file())
assert EMAIL.encode() not in raw
flow = json.loads((bag / "data/screening/prisma-flow.json").read_text())
history = flow["projects"]["lead-history"]
assert history["identified"] == 2
assert history["screened"] == 1
assert history["included"] == 1
assert history["identified_by_source"] == {"pubmed": 1}
limits = flow["projects"]["lead-limits"]
assert limits["excluded_stage2"] == 1
assert limits["excluded_stage2_reasons"] == {"off-topic": 1}
assert sorted(flow["projects"]) == [
"lead-external",
"lead-history",
"lead-limits",
"lead-precursors",
"lead-scan",
]
assert flow["reviews"]["scoping"]["identified"] == 4
assert flow["reviews"]["systematic"]["identified"] == 0
def test_bibliography_is_csl_json(inputs, tmp_path):
result = _build(inputs, tmp_path / "out")
bag = result.path
included = json.loads((bag / "data/bibliography/included.csl.json").read_text())
assert included == [
{
"id": "AAAA2222",
"type": "article-journal",
"title": "Alpha study",
"author": [{"family": "Smith", "given": "Jane"}],
"issued": {"date-parts": [[2020, 3]]},
"DOI": "10.1/a",
}
]
screened = json.loads((bag / "data/bibliography/screened.csl.json").read_text())
assert [item["id"] for item in screened] == ["AAAA2222", "BBBB3333", "CCCC4444"]
beta = screened[1]
assert beta["type"] == "report"
assert beta["author"] == [{"literal": "CMS"}]
assert beta["URL"] == "https://example.org/b"
assert beta["issued"] == {"date-parts": [[2019]]}
gamma = screened[2]
assert gamma["type"] == "legislation"
assert gamma["title"] == "Gamma Act"
assert gamma["issued"] == {"date-parts": [[2010, 3, 23]]}
assert any("GGGG8888" in w for w in result.warnings)
def test_tags_and_code(inputs, tmp_path):
inputs = dataclasses.replace(inputs, repo=_repo(tmp_path))
result = _build(inputs, tmp_path / "out")
bag = result.path
assert (bag / "data/tags/vocab-v3.yaml").read_bytes() == inputs.vocab.read_bytes()
archive = bag / f"data/code/stack-{result.commit}.tar.gz"
with tarfile.open(archive) as tar:
names = tar.getnames()
# Only the code that builds a release; third-party documents tracked
# elsewhere in the repo (hw/docs manuals, books) never ship in a bag.
assert f"stack-{result.commit[:12]}/src/app.py" in names
assert not any("vendor-manual.pdf" in n for n in names)
assert result.dirty is False
(inputs.repo / "src" / "app.py").write_text("changed\n")
assert _build(inputs, tmp_path / "dirty").dirty is True
def test_crate_records_the_build(inputs, tmp_path):
result = _build(inputs, tmp_path / "out")
crate = json.loads((result.path / "data/ro-crate-metadata.json").read_text())
graph = {e["@id"]: e for e in crate["@graph"]}
root = graph["./"]
assert root["version"] == "0.1"
assert root["datePublished"] == "2026-11-11"
assert root["isBasedOn"] == {"@id": "https://doi.org/10.17605/OSF.IO/8WQ3M"}
assert {"@id": "screening/decisions.parquet"} in root["hasPart"]
action = next(e for e in crate["@graph"] if e["@type"] == "CreateAction")
assert action["name"] == "stack lead release build --version 0.1"
assert action["result"] == {"@id": "./"}
stack = graph[action["instrument"][0]["@id"]]
assert stack["version"] == result.commit
inputs_described = [graph[o["@id"]]["name"] for o in action["object"]]
assert any("ledger" in name for name in inputs_described)
assert any("Zotero" in name for name in inputs_described)
def test_build_refuses_to_overwrite_without_force(inputs, tmp_path):
_build(inputs, tmp_path / "out")
with pytest.raises(FileExistsError):
_build(inputs, tmp_path / "out")
again = _build(inputs, tmp_path / "out", force=True, note="Rebuilt.")
assert dict(read_bag_info(again.path))["Internal-Sender-Description"] == "Rebuilt."
assert [p.name for p in (tmp_path / "out").iterdir()] == ["lead-review-v0.1"]
@pytest.mark.parametrize("version", ["1", "v0.1", "0.1.2", ""])
def test_build_rejects_a_version_that_is_not_major_minor(inputs, tmp_path, version):
with pytest.raises(ValueError):
_build(inputs, tmp_path / "out", version=version)
# ── verify ───────────────────────────────────────────────────────
def test_verify_reports_bag_and_crate_problems(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
(bag / "data/tags/vocab-v3.yaml").write_text("version: 4\n")
(bag / "data/notes.txt").write_text("stray\n")
problems = verify_release(bag)
assert any("data/tags/vocab-v3.yaml" in p and "sha512" in p for p in problems)
assert any(
p.startswith("ro-crate:") and "tags/vocab-v3.yaml" in p and "sha256" in p
for p in problems
)
assert any("data/notes.txt" in p and "manifest" in p for p in problems)
assert any(p.startswith("ro-crate:") and "notes.txt" in p for p in problems)
def test_verify_reports_a_bag_without_a_crate(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
(bag / "data/ro-crate-metadata.json").unlink()
problems = verify_release(bag)
assert "ro-crate: ro-crate-metadata.json is missing" in problems
def test_verify_reports_a_path_that_is_not_a_bag(tmp_path):
assert verify_release(tmp_path / "nope") == [
f"{tmp_path / 'nope'} is not a directory"
]
# ── CLI ──────────────────────────────────────────────────────────
def _cli_args(inputs: Inputs, out: Path, reg: Path) -> list[str]:
return [
"lead",
"release",
"build",
"--version",
"0.1",
"--out",
str(out),
"--date",
"2026-11-11",
"--note",
"First release.",
"--registration",
str(reg),
"--ledger",
str(inputs.ledger),
"--labels",
str(inputs.labels),
"--zotero",
str(inputs.zotero),
"--vocab",
str(inputs.vocab),
"--repo",
str(inputs.repo),
]
def test_cli_build_then_verify(inputs, tmp_path):
reg = tmp_path / "registration.json"
reg.write_text(json.dumps(REGISTRATION))
out = tmp_path / "out"
result = runner.invoke(app, _cli_args(inputs, out, reg))
assert result.exit_code == 0, result.output
bag = out / "lead-review-v0.1"
assert str(bag) in result.output
assert "GGGG8888" in result.output
ok = runner.invoke(app, ["lead", "release", "verify", str(bag)])
assert ok.exit_code == 0, ok.output
assert "ok" in ok.output
again = runner.invoke(app, _cli_args(inputs, out, reg))
assert again.exit_code != 0
assert "--force" in again.output
def test_cli_verify_lists_problems_and_fails(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
(bag / "data/protocol/protocol.md").write_text("edited\n")
result = runner.invoke(app, ["lead", "release", "verify", str(bag)])
assert result.exit_code == 1
assert "data/protocol/protocol.md" in result.output
assert "problem" in result.output
def test_cli_build_rejects_a_bad_date(inputs, tmp_path):
reg = tmp_path / "registration.json"
reg.write_text(json.dumps(REGISTRATION))
args = _cli_args(inputs, tmp_path / "out", reg)
args[args.index("2026-11-11")] = "11/11/2026"
result = runner.invoke(app, args)
assert result.exit_code != 0
def test_parquet_reads_back_in_duckdb(inputs, tmp_path):
bag = _build(inputs, tmp_path / "out").path
con = duckdb.connect()
try:
n = con.execute(
"SELECT count(*) FROM read_parquet(?)",
[str(bag / "data/screening/decisions.parquet")],
).fetchone()[0]
finally:
con.close()
assert n == 3