Files
stack/dev/scripts/vendor_bcda_app.py
kert 7e53d9360a chore(bcda): vendor CMSgov/bcda-app + bcda.cms.gov v3 artefacts sparsely, flatten the v3 data dictionary into crosswalk seeds, weekly drift workflow
dev/vendor/MANIFEST.toml pins bcda-app@7decdcc (v3 handler + tests, request
validation middleware, OperationOutcome writer, v3 Postman collection, gzip
fixtures, sandbox config, LICENSE) and the bcda.cms.gov v3 data dictionary,
v2 dictionary and official v3 sample ndjson. vendor_bcda_app.py --verify |
--check | --sync (stdlib only); the bcda-vendor workflow runs --check weekly
and files an issue on drift — nothing auto-commits, a payload change upstream
is something a human reads before the flattener is re-pointed.

gen_bcda_v3_crosswalk.py writes dev/seeds/bcda_v3_data_dictionary.csv
(fhirPath, IDR sourceColumn, ccwMapping, cclfMapping per v3 field),
bcda_v3_cclf_crosswalk.csv (293 CCLF elements -> v3 field) and
bcda_v3_cclf_absent.csv (52 CCLF fields v3 does not carry); the CCLF sheet
has label/name swapped on data rows and is normalised. Tests assert the
committed seeds match the vendored workbook.

refs #1139
2026-10-25 13:08:05 -04:00

257 lines
9.5 KiB
Python

"""Sparse vendoring of BCDA upstream artefacts (CMSgov/bcda-app + bcda.cms.gov).
The ``bcda`` module is tested against what CMS actually ships, not
against our reading of it: the v3 handler and its tests, the request
validation that decides which headers and parameters are legal, the
OperationOutcome writer, the v3 Postman smoke collection, the gzip
download fixtures, and the official v3 sample ndjson + data dictionary.
That is a few hundred kilobytes of a 50 MB repository, so this is a
manifest, not a submodule.
Manifest: ``dev/vendor/MANIFEST.toml``
[[source]]
name = "bcda-app"
kind = "github"
repo = "CMSgov/bcda-app"
ref = "main"
commit = "<sha the files were last synced from>"
files = ["swagger.json", ...]
[[source]]
name = "bcda-site"
kind = "https"
base = "https://bcda.cms.gov/assets/downloads/"
files = ["BCDA_v3_Data_Dictionary.xlsx", ...]
[sha256]
"bcda-app/swagger.json" = "..." # local file digests
Usage::
uv run python dev/scripts/vendor_bcda_app.py --check # drift? exit 1
uv run python dev/scripts/vendor_bcda_app.py --sync # refresh + rewrite manifest
uv run python dev/scripts/vendor_bcda_app.py --verify # local tree matches manifest (offline)
``--check`` compares upstream against the manifest without touching
the tree: for GitHub it resolves ``ref`` to a commit and diffs the blob
shas of the listed paths against the pinned commit's tree; for plain
HTTPS it downloads and compares sha256. The weekly ``bcda-vendor``
workflow runs ``--check`` and files an issue on drift — a payload
change upstream is something a human should read before the
flattener is re-pointed, so nothing is auto-committed.
Standard library only: this runs on a bare runner before ``uv sync``.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import tomllib
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
VENDOR = ROOT / "dev" / "vendor"
MANIFEST = VENDOR / "MANIFEST.toml"
UA = {"User-Agent": "stack-vendor-bcda/1.0 (+https://git.fhirworx.io/homelab/stack)"}
# ── I/O ─────────────────────────────────────────────────────────
def _get(url: str, *, accept: str | None = None) -> bytes:
headers = dict(UA)
if accept:
headers["Accept"] = accept
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S310 — https only
return resp.read()
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _git_blob_sha(data: bytes) -> str:
"""Git's blob object id for ``data`` (what the trees API reports)."""
header = f"blob {len(data)}\0".encode()
return hashlib.sha1(header + data).hexdigest() # noqa: S324 — git's own id
def load_manifest(path: Path | None = None) -> dict:
return tomllib.loads((path or MANIFEST).read_text())
def dump_manifest(m: dict, path: Path | None = None) -> None:
"""Write the manifest back. Hand-rolled TOML: two array-of-table
sources plus one flat string table — no dependency needed."""
lines = [
"# Sparse vendoring manifest — regenerate with",
"# uv run python dev/scripts/vendor_bcda_app.py --sync",
"# Check for upstream drift (no changes written) with --check.",
"",
]
for src in m["source"]:
lines.append("[[source]]")
for key in ("name", "kind", "repo", "ref", "commit", "base", "license"):
if key in src:
lines.append(f'{key} = "{src[key]}"')
lines.append("files = [")
for f in src["files"]:
lines.append(f' "{f}",')
lines.append("]")
lines.append("")
lines.append("[sha256]")
for key in sorted(m.get("sha256", {})):
lines.append(f'"{key}" = "{m["sha256"][key]}"')
lines.append("")
(path or MANIFEST).write_text("\n".join(lines))
# ── GitHub source ───────────────────────────────────────────────
def _gh_json(url: str) -> dict:
return json.loads(_get(url, accept="application/vnd.github+json"))
def _gh_head_commit(repo: str, ref: str) -> str:
return _gh_json(f"https://api.github.com/repos/{repo}/commits/{ref}")["sha"]
def _gh_tree(repo: str, commit: str) -> dict[str, str]:
"""``{path: blob_sha}`` for every blob at ``commit``."""
data = _gh_json(
f"https://api.github.com/repos/{repo}/git/trees/{commit}?recursive=1"
)
if data.get("truncated"):
raise RuntimeError(f"{repo}@{commit}: tree listing truncated")
return {e["path"]: e["sha"] for e in data["tree"] if e["type"] == "blob"}
def _gh_raw(repo: str, commit: str, path: str) -> bytes:
return _get(f"https://raw.githubusercontent.com/{repo}/{commit}/{path}")
# ── Operations ──────────────────────────────────────────────────
def verify(m: dict) -> list[str]:
"""Offline: every manifest file exists locally with the recorded sha256."""
problems = []
for src in m["source"]:
for f in src["files"]:
key = f"{src['name']}/{f}"
local = VENDOR / key
if not local.exists():
problems.append(f"missing {key}")
continue
want = m.get("sha256", {}).get(key)
got = _sha256(local.read_bytes())
if want != got:
problems.append(f"sha256 mismatch {key}: manifest {want} local {got}")
return problems
def check(m: dict) -> list[str]:
"""Online: what upstream changed relative to the manifest."""
drift: list[str] = []
for src in m["source"]:
if src["kind"] == "github":
head = _gh_head_commit(src["repo"], src["ref"])
if head == src.get("commit"):
continue
pinned = _gh_tree(src["repo"], src["commit"])
current = _gh_tree(src["repo"], head)
for f in src["files"]:
if f not in current:
drift.append(f"{src['name']}: {f} removed upstream ({head[:12]})")
elif pinned.get(f) != current[f]:
drift.append(
f"{src['name']}: {f} changed ({src['commit'][:12]} → {head[:12]})"
)
# v3 handler directory gaining a file we don't track is worth
# a look too — the point of vendoring the tests is to see them.
watched = {Path(f).parent.as_posix() for f in src["files"] if "/" in f}
for path in current:
parent = Path(path).parent.as_posix()
if (
parent in watched
and path not in pinned
and path not in src["files"]
):
drift.append(f"{src['name']}: new upstream file {path}")
elif src["kind"] == "https":
for f in src["files"]:
key = f"{src['name']}/{f}"
got = _sha256(_get(src["base"] + f))
if got != m.get("sha256", {}).get(key):
drift.append(f"{src['name']}: {f} changed (sha256 {got[:12]})")
else: # pragma: no cover — manifest is ours
raise ValueError(f"unknown source kind {src['kind']!r}")
return drift
def sync(m: dict) -> list[str]:
"""Download every manifest file from upstream HEAD; rewrite the manifest."""
changed: list[str] = []
shas = dict(m.get("sha256", {}))
for src in m["source"]:
if src["kind"] == "github":
commit = _gh_head_commit(src["repo"], src["ref"])
src["commit"] = commit
fetch = lambda f, _s=src, _c=commit: _gh_raw(_s["repo"], _c, f) # noqa: E731
else:
fetch = lambda f, _s=src: _get(_s["base"] + f) # noqa: E731
for f in src["files"]:
key = f"{src['name']}/{f}"
data = fetch(f)
digest = _sha256(data)
local = VENDOR / key
if shas.get(key) != digest or not local.exists():
local.parent.mkdir(parents=True, exist_ok=True)
local.write_bytes(data)
changed.append(key)
shas[key] = digest
m["sha256"] = shas
dump_manifest(m)
return changed
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
mode = ap.add_mutually_exclusive_group(required=True)
mode.add_argument(
"--check", action="store_true", help="report upstream drift (exit 1)"
)
mode.add_argument("--sync", action="store_true", help="refresh files + manifest")
mode.add_argument("--verify", action="store_true", help="offline tree == manifest")
args = ap.parse_args(argv)
m = load_manifest()
if args.verify:
problems = verify(m)
for p in problems:
print(p)
print("vendor tree OK" if not problems else f"{len(problems)} problem(s)")
return 1 if problems else 0
if args.check:
drift = check(m)
for d in drift:
print(d)
print("no upstream drift" if not drift else f"{len(drift)} upstream change(s)")
return 1 if drift else 0
changed = sync(m)
for c in changed:
print(f"updated {c}")
print(f"synced; {len(changed)} file(s) changed")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())