127 lines
4.1 KiB
Python
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())
|