Files
stack/tests/lead/test_release_bagit.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

151 lines
4.7 KiB
Python

"""BagIt 1.0 writer and validator for LEAD review releases (RFC 8493)."""
from __future__ import annotations
import hashlib
from pathlib import Path
from lead.release.bagit import (
decode_path,
encode_path,
read_bag_info,
validate_bag,
write_bag,
)
INFO = [
("External-Identifier", "doi:10.17605/OSF.IO/8WQ3M"),
("Bag-Group-Identifier", "lead-living-review"),
("Bagging-Date", "2026-11-11"),
("Version", "0.1"),
]
def _payload(bag: Path) -> None:
data = bag / "data"
(data / "ledger").mkdir(parents=True)
(data / "a.txt").write_text("alpha\n")
(data / "ledger" / "claim.parquet").write_bytes(b"\x00\x01\x02")
def test_write_bag_writes_the_four_tag_files(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
octets, streams = write_bag(bag, INFO)
assert (octets, streams) == (9, 2)
assert (bag / "bagit.txt").read_text() == (
"BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
)
manifest = (bag / "manifest-sha512.txt").read_text().splitlines()
digest = hashlib.sha512(b"alpha\n").hexdigest()
assert manifest == [
f"{digest} data/a.txt",
f"{hashlib.sha512(bytes([0, 1, 2])).hexdigest()} data/ledger/claim.parquet",
]
tags = [
line.split()[1]
for line in (bag / "tagmanifest-sha512.txt").read_text().splitlines()
]
assert tags == ["bag-info.txt", "bagit.txt", "manifest-sha512.txt"]
def test_bag_info_adds_size_and_oxum_after_the_bagging_date(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
labels = [label for label, _value in read_bag_info(bag)]
assert labels == [
"External-Identifier",
"Bag-Group-Identifier",
"Bagging-Date",
"Bag-Size",
"Payload-Oxum",
"Version",
]
assert dict(read_bag_info(bag))["Payload-Oxum"] == "9.2"
assert dict(read_bag_info(bag))["Bag-Size"] == "9 B"
def test_a_long_bag_info_value_folds_and_reads_back_whole(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
note = " ".join(["the ledger gained claims and the vocabulary moved"] * 4)
write_bag(bag, [*INFO, ("Internal-Sender-Description", note)])
text = (bag / "bag-info.txt").read_text()
assert all(len(line) <= 79 for line in text.splitlines())
assert dict(read_bag_info(bag))["Internal-Sender-Description"] == note
assert validate_bag(bag) == []
def test_a_fresh_bag_is_valid(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
assert validate_bag(bag) == []
def test_validate_reports_a_changed_payload_file(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
(bag / "data" / "a.txt").write_text("alpha!\n")
problems = validate_bag(bag)
assert any("data/a.txt" in p and "sha512" in p for p in problems)
assert any("Payload-Oxum" in p for p in problems)
def test_validate_reports_unlisted_and_missing_payload_files(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
(bag / "data" / "extra.txt").write_text("x")
(bag / "data" / "ledger" / "claim.parquet").unlink()
problems = validate_bag(bag)
assert any(
"data/extra.txt" in p and "not in manifest-sha512.txt" in p for p in problems
)
assert any("data/ledger/claim.parquet" in p and "missing" in p for p in problems)
def test_validate_reports_a_bad_bagit_txt(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
(bag / "bagit.txt").write_text("BagIt-Version: one\n")
problems = validate_bag(bag)
assert any(p.startswith("bagit.txt") for p in problems)
def test_validate_reports_a_missing_bagit_txt_and_manifest(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
problems = validate_bag(bag)
assert any("bagit.txt is missing" in p for p in problems)
assert any("manifest-sha512.txt is missing" in p for p in problems)
def test_validate_reports_an_edited_tag_file(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
info = bag / "bag-info.txt"
info.write_text(info.read_text().replace("0.1", "0.2"))
problems = validate_bag(bag)
assert problems == ["bag-info.txt: sha512 in tagmanifest-sha512.txt does not match"]
def test_validate_reports_a_manifest_path_outside_data(tmp_path):
bag = tmp_path / "bag"
_payload(bag)
write_bag(bag, INFO)
manifest = bag / "manifest-sha512.txt"
manifest.write_text(manifest.read_text() + f"{'0' * 128} ../escape.txt\n")
problems = validate_bag(bag)
assert any("../escape.txt" in p for p in problems)
def test_path_encoding_round_trips_percent_and_newlines():
raw = "data/100%\nodd\r.txt"
assert encode_path(raw) == "data/100%25%0Aodd%0D.txt"
assert decode_path(encode_path(raw)) == raw