Files
wyndham-ARR/monthly_reports/cli.py
2026-07-29 16:38:05 +08:00

144 lines
4.7 KiB
Python

"""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())