feat: prepare ARR for controlled public deployment
This commit is contained in:
143
monthly_reports/cli.py
Normal file
143
monthly_reports/cli.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Command-line entry point for deterministic database monthly reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from monthly_reports.contracts import ErrorCode, RESULT_SCHEMA_VERSION
|
||||
from monthly_reports.publishing import ArtifactToolBuilder, AtomicReportPublisher
|
||||
from monthly_reports.repository import DatabaseConfig, PostgresReportRepository, RepositoryError
|
||||
from monthly_reports.service import MonthlyReportService, RunRequest, write_run_result
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
|
||||
|
||||
|
||||
class RequestFailure(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class SafeArgumentParser(argparse.ArgumentParser):
|
||||
def error(self, message: str) -> None:
|
||||
raise RequestFailure(message)
|
||||
|
||||
|
||||
def _parse_month(value: str) -> Tuple[int, int]:
|
||||
match = MONTH_RE.fullmatch(value)
|
||||
if not match:
|
||||
raise RequestFailure("month must use YYYY-MM")
|
||||
year, month = int(match.group(1)), int(match.group(2))
|
||||
if month < 1 or month > 12:
|
||||
raise RequestFailure("month is invalid")
|
||||
return year, month
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise RequestFailure("as-of date must use YYYY-MM-DD") from None
|
||||
|
||||
|
||||
def _parser() -> SafeArgumentParser:
|
||||
parser = SafeArgumentParser(prog="monthly-reports")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
generate = subparsers.add_parser(
|
||||
"generate",
|
||||
help="generate or explicitly rerun one database-backed monthly report",
|
||||
)
|
||||
generate.add_argument("--month", required=True, help="report month in YYYY-MM")
|
||||
generate.add_argument("--as-of", required=True, help="latest included business date")
|
||||
generate.add_argument("--output-root", help="controlled output root inside the project")
|
||||
generate.add_argument(
|
||||
"--node-binary",
|
||||
help="Node.js executable; defaults to MONTHLY_REPORT_NODE_BINARY or PATH",
|
||||
)
|
||||
generate.add_argument(
|
||||
"--artifact-tool-module",
|
||||
help="absolute path to artifact_tool.mjs when package resolution is unavailable",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _failure(code: str, stage: str) -> dict:
|
||||
return {
|
||||
"schema_version": RESULT_SCHEMA_VERSION,
|
||||
"status": "failed",
|
||||
"warnings": [],
|
||||
"errors": [{"code": code, "stage": stage}],
|
||||
}
|
||||
|
||||
|
||||
def _emit(payload: dict) -> None:
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
try:
|
||||
args = _parser().parse_args(argv)
|
||||
year, month = _parse_month(args.month)
|
||||
as_of_date = _parse_date(args.as_of)
|
||||
request = RunRequest(year, month, as_of_date)
|
||||
request.validate()
|
||||
output_root = (
|
||||
Path(args.output_root).expanduser()
|
||||
if args.output_root
|
||||
else PROJECT_ROOT / "outputs" / "monthly_reports"
|
||||
)
|
||||
if not output_root.is_absolute():
|
||||
output_root = PROJECT_ROOT / output_root
|
||||
output_root = output_root.resolve()
|
||||
repository = PostgresReportRepository(DatabaseConfig.from_environment())
|
||||
builder = ArtifactToolBuilder(
|
||||
PROJECT_ROOT / "monthly_reports" / "xlsx" / "build_workbook.mjs",
|
||||
node_binary=args.node_binary,
|
||||
artifact_tool_module=(
|
||||
Path(args.artifact_tool_module).expanduser()
|
||||
if args.artifact_tool_module
|
||||
else None
|
||||
),
|
||||
)
|
||||
publisher = AtomicReportPublisher(PROJECT_ROOT, output_root)
|
||||
service = MonthlyReportService(
|
||||
repository,
|
||||
builder,
|
||||
publisher,
|
||||
output_root / ".staging",
|
||||
)
|
||||
result = service.run(request)
|
||||
result_path = (
|
||||
output_root
|
||||
/ f"{year:04d}"
|
||||
/ f"{month:02d}"
|
||||
/ "runs"
|
||||
/ f"monthly-report-{year:04d}-{month:02d}-{as_of_date.isoformat()}.result.json"
|
||||
)
|
||||
write_run_result(result_path, result)
|
||||
_emit(result.to_dict())
|
||||
return result.exit_code
|
||||
except (RequestFailure, ValueError):
|
||||
_emit(_failure(ErrorCode.REQUEST_INVALID, "request"))
|
||||
return 3
|
||||
except RepositoryError as error:
|
||||
code = (
|
||||
ErrorCode.REQUEST_INVALID
|
||||
if error.code == ErrorCode.REQUEST_INVALID
|
||||
else ErrorCode.DATABASE_FAILED
|
||||
)
|
||||
_emit(_failure(code, "configuration"))
|
||||
return 3 if code == ErrorCode.REQUEST_INVALID else 4
|
||||
except Exception:
|
||||
_emit(_failure(ErrorCode.INTERNAL_ERROR, "internal"))
|
||||
return 4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user