144 lines
4.7 KiB
Python
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())
|