#!/usr/bin/env python3 """Generate the human capability-fit report from its machine-readable input.""" from __future__ import annotations import argparse import os from pathlib import Path import sys import tempfile META_ROOT = Path(__file__).resolve().parents[2] ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments" RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release" for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT): if str(tools_root) not in sys.path: sys.path.insert(0, str(tools_root)) from govoplan_assessment.report_generator import ( # noqa: E402 AssessmentGenerationError, load_bounded_json, render_assessment_markdown, validate_report_input, ) DEFAULT_ASSESSMENT = META_ROOT / "docs" / "capability-fit-current.json" DEFAULT_SCHEMA = META_ROOT / "docs" / "capability-fit.schema.json" DEFAULT_OUTPUT = ( META_ROOT / "docs" / "evidence" / "snapshots" / "CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md" ) MAX_OUTPUT_BYTES = 16 * 1024 * 1024 def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Render a deterministic human report from one capability-fit JSON input." ) parser.add_argument("--assessment", type=Path, default=DEFAULT_ASSESSMENT) parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument( "--check", action="store_true", help="Fail when the output is missing or differs instead of writing it.", ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: assessment = load_bounded_json(args.assessment, label="assessment") schema = load_bounded_json(args.schema, label="assessment schema") validate_report_input(assessment=assessment, schema=schema) rendered = render_assessment_markdown(assessment) encoded = rendered.encode("utf-8") if len(encoded) > MAX_OUTPUT_BYTES: raise AssessmentGenerationError( f"Generated report exceeds the {MAX_OUTPUT_BYTES}-byte output limit" ) if args.check: try: current = args.output.read_bytes() except OSError: current = None if current != encoded: print( f"Capability-fit report is stale: {args.output}", file=sys.stderr, ) return 2 print(f"Capability-fit report is current: {args.output}") return 0 _atomic_write(args.output, encoded) print(f"Generated capability-fit report: {args.output}") return 0 except AssessmentGenerationError as exc: print(str(exc), file=sys.stderr) return 1 def _atomic_write(path: Path, content: bytes) -> None: if not path.parent.is_dir(): raise AssessmentGenerationError( f"Output parent directory does not exist: {path.parent}" ) if path.is_symlink(): raise AssessmentGenerationError("Output path must not be a symbolic link") descriptor = -1 temporary_name = "" try: descriptor, temporary_name = tempfile.mkstemp( prefix=".govoplan-fit-report-", suffix=".tmp", dir=path.parent, ) os.fchmod(descriptor, 0o644) with os.fdopen(descriptor, "wb", closefd=True) as handle: descriptor = -1 handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_name, path) temporary_name = "" except OSError as exc: raise AssessmentGenerationError( f"Could not write generated report atomically: {exc}" ) from exc finally: if descriptor >= 0: os.close(descriptor) if temporary_name: try: os.unlink(temporary_name) except FileNotFoundError: pass if __name__ == "__main__": raise SystemExit(main())