Files
govoplan/tools/assessments/generate-capability-fit-report.py
T

127 lines
4.1 KiB
Python

#!/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())