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
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.
640 lines
23 KiB
Python
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
|