feat: generate evidence-based fit assessments
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user