Files
stack/dev/scripts/gen_readme.py
kert bd8cda1931
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m14s
CI / skinny-install (bib) (push) Successful in 5m47s
CI / skinny-install (api) (push) Successful in 37s
CI / skinny-install (bcda) (push) Successful in 37s
CI / skinny-install (bls) (push) Successful in 40s
CI / skinny-install (ccw) (push) Successful in 41s
CI / skinny-install (cli) (push) Successful in 42s
CI / skinny-install (cms) (push) Successful in 39s
CI / skinny-install (conf) (push) Successful in 41s
CI / skinny-install (opps) (push) Successful in 39s
CI / skinny-install (perf) (push) Successful in 40s
CI / skinny-install (pfs) (push) Successful in 50s
CI / skinny-install (rex) (push) Successful in 33s
Deploy / build-scan-report (push) Failing after 12m6s
Infra CI / notebooks (push) Successful in 22s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Successful in 11s
Infra CI / api (push) Successful in 12s
Infra CI / mc (push) Successful in 22s
CI / lint-test (push) Failing after 41m1s
remove: scrub all woodpecker references from codebase
Clean 17 files across src/, tests/, dev/, stack.toml, deploy.sh:
- api/auth/provision.py: remove WoodpeckerClient, provision_woodpecker,
  _get_woodpecker_token, woodpecker field from ProvisionResult
- api/auth/manifest.py: remove woodpecker from CREDENTIALS + Provisioner
- api/diag: remove woodpecker log fetching
- sem/hooks.py: remove woodpecker sync step
- stack.toml: remove [services.woodpecker] config
- deploy.sh: remove woodpecker deploy steps
- dev/scripts: remove woodpecker from config gen, secrets, readme
- tests: remove all woodpecker assertions and test cases

Zero woodpecker references remain in the codebase.
2026-04-18 18:51:21 -04:00

300 lines
9.6 KiB
Python

"""Generate README.md from README.md.j2 using live codebase data.
Collects counts, tables, and metadata from the actual codebase:
- Service count and names from compose.yml
- Module list from pyproject.toml optional-dependencies
- Pipeline registry from src/aco/pipe/
- Test count via pytest --collect-only
- Credential count from api.auth.manifest
- Network list from compose.yml
- Workflow list from .gitea/workflows/
Usage::
uv run python dev/scripts/gen_readme.py # generate README.md
uv run python dev/scripts/gen_readme.py --check # verify up-to-date
uv run python dev/scripts/gen_readme.py --dry-run # print to stdout
"""
from __future__ import annotations
import argparse
import ast
import subprocess
import sys
import tomllib
from pathlib import Path
import yaml
from jinja2 import Environment, FileSystemLoader
ROOT = Path(__file__).resolve().parent.parent.parent
# ── Data collectors ──────────────────────────────────────────────────
def _collect_services() -> dict:
"""Parse compose.yml for service and network info."""
compose = yaml.safe_load((ROOT / "compose.yml").read_text())
services = sorted(compose.get("services", {}).keys())
networks = sorted(compose.get("networks", {}).keys())
return {"names": services, "count": len(services), "networks": networks}
def _collect_modules() -> dict:
"""Parse pyproject.toml for module list."""
with open(ROOT / "pyproject.toml", "rb") as f:
pyp = tomllib.load(f)
opt_deps = pyp["project"]["optional-dependencies"]
cloud = {"aws", "gcp", "azure"}
aggregates = {"all", "lake"}
modules = sorted(k for k in opt_deps if k not in cloud and k not in aggregates)
build_modules = (
pyp.get("tool", {})
.get("uv", {})
.get("build-backend", {})
.get("module-name", [])
)
return {
"names": modules,
"count": len(modules),
"cloud": sorted(cloud & set(opt_deps)),
"aggregates": sorted(aggregates & set(opt_deps)),
"build_modules": build_modules,
}
def _collect_pipelines() -> dict:
"""Import pipeline registry and collect metadata."""
sys.path.insert(0, str(ROOT / "src"))
from aco.pipe import registry
# Fallback descriptions for pipelines without module docstrings
fallback_docs = {
"ahrq_measures": "AHRQ quality indicators",
"cclf": "CCLF file parsing",
"claims_preprocessing": "Claim matching, encounter IDs",
"cms_quality_measures": "CMS ACO quality metrics",
"core": "Encounters, patients, providers",
"data_quality": "Validation and completeness",
"hcc_suspecting": "HCC risk adjustment",
"input_layer": "Raw CCLF/BCDA ingestion",
"main": "Full pipeline orchestration",
"opps": "Outpatient payment system",
"pharmacy": "Part D drug utilization",
"provider_attribution": "Provider-patient assignment",
"quality_measures": "Composite quality scores",
"readmissions": "30-day readmission rates",
}
pipelines = []
for name in sorted(registry.keys()):
p = registry[name]
doc = ""
# Try to get the pipeline module's docstring
mod_path = ROOT / "src" / "aco" / "pipe" / f"{name}.py"
if mod_path.exists():
try:
tree = ast.parse(mod_path.read_text())
raw = ast.get_docstring(tree) or ""
doc = raw.split("\n")[0].rstrip(".")
except SyntaxError:
pass
# Use fallback if docstring is empty
if not doc:
doc = fallback_docs.get(name, "")
pipelines.append(
{
"name": name,
"steps": len(p.exprs),
"doc": doc,
}
)
total_steps = sum(p["steps"] for p in pipelines)
return {
"list": pipelines,
"count": len(pipelines),
"total_steps": total_steps,
}
def _collect_tests() -> dict:
"""Run pytest --collect-only to get test count."""
result = subprocess.run(
["uv", "run", "python", "-m", "pytest", "tests/", "--collect-only", "-q"],
capture_output=True,
text=True,
cwd=str(ROOT),
timeout=60,
)
# Last line: "12291 tests collected in 3.03s"
for line in result.stdout.strip().splitlines():
if "test" in line and "collected" in line:
count = int(line.split()[0])
return {"count": count, "formatted": f"{count:,}"}
return {"count": 0, "formatted": "?"}
def _collect_credentials() -> dict:
"""Count credentials from auth manifest."""
sys.path.insert(0, str(ROOT / "src"))
try:
from api.auth.manifest import CREDENTIALS
return {"count": len(CREDENTIALS)}
except ImportError:
return {"count": 0}
def _collect_workflows() -> dict:
"""List CI workflow files."""
workflow_dirs = [
ROOT / ".gitea" / "workflows",
ROOT / ".github" / "workflows",
]
workflows = []
for d in workflow_dirs:
if d.is_dir():
for f in sorted(d.glob("*.yml")):
workflows.append({"name": f.name, "path": str(f.relative_to(ROOT))})
return {"list": workflows, "count": len(workflows)}
def _collect_src_tree() -> list[dict]:
"""Build the src/ module listing with descriptions."""
descriptions = {
"aco": "ACO analytics (express, pipe, table, lake, load)",
"api": "FastAPI server + auth + diag",
"bcda": "BCDA FHIR R4 client",
"bib": "Zotero bibliography store, tags, metadata",
"bls": "BLS data",
"ccw": "CCW data dictionary",
"cli": "CLI entry point (typer)",
"cms": "CMS public data tables",
"conf": "Config loader, storage abstraction, table base",
"opps": "Outpatient Prospective Payment System",
"perf": "Pipeline telemetry (OpenTelemetry)",
"pfs": "Physician Fee Schedule",
"rex": "REX fixed-width file processing",
"sem": "Semantic coverage orchestration",
}
src = ROOT / "src"
modules = []
for d in sorted(src.iterdir()):
if (
d.is_dir()
and not d.name.startswith(("_", "."))
and not d.name.endswith(".egg-info")
):
modules.append(
{
"name": d.name,
"desc": descriptions.get(d.name, ""),
}
)
return modules
def collect_all() -> dict:
"""Collect all dynamic data for template rendering."""
services = _collect_services()
modules = _collect_modules()
pipelines = _collect_pipelines()
tests = _collect_tests()
credentials = _collect_credentials()
workflows = _collect_workflows()
src_modules = _collect_src_tree()
return {
"services": services,
"modules": modules,
"pipelines": pipelines,
"tests": tests,
"credentials": credentials,
"workflows": workflows,
"src_modules": src_modules,
}
# ── Rendering ────────────────────────────────────────────────────────
def render(data: dict) -> str:
"""Render README.md.j2 with collected data."""
env = Environment(
loader=FileSystemLoader(str(ROOT)),
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["backtick"] = lambda s: f"`{s}`"
template = env.get_template("README.md.j2")
return template.render(**data)
# ── CLI ──────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="verify README.md matches template output (exit 1 if stale)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print rendered output to stdout instead of writing",
)
args = parser.parse_args()
data = collect_all()
rendered = render(data)
if args.dry_run:
print(rendered, end="")
return 0
readme = ROOT / "README.md"
if args.check:
current = readme.read_text(encoding="utf-8")
if current == rendered:
print("README.md is up-to-date.")
return 0
else:
# Show first differing line
cur_lines = current.splitlines()
ren_lines = rendered.splitlines()
for i, (a, b) in enumerate(zip(cur_lines, ren_lines), 1):
if a != b:
print(f"README.md is stale (first diff at line {i}):")
print(f" current: {a[:100]}")
print(f" expected: {b[:100]}")
break
else:
len_diff = len(cur_lines) - len(ren_lines)
print(f"README.md is stale (length differs by {len_diff} lines)")
print("\nRun: uv run python dev/scripts/gen_readme.py")
return 1
readme.write_text(rendered, encoding="utf-8")
print(
f"README.md generated ({data['services']['count']} services, "
f"{data['pipelines']['count']} pipelines, "
f"{data['tests']['formatted']} tests, "
f"{data['modules']['count']} modules)"
)
return 0
if __name__ == "__main__":
sys.exit(main())