"""ARR-owned execution of the frozen Opera daily processor.""" from __future__ import annotations import subprocess from dataclasses import dataclass from datetime import date from pathlib import Path from typing import Dict, Mapping, Optional from arr_ingestion.contracts import IngestionError from arr_ingestion.validation import ProcessorPolicy, strict_json_file @dataclass(frozen=True) class LocalProcessingOutput: status: str business_date: Optional[date] artifacts: Mapping[str, Path] exit_code: int @dataclass(frozen=True) class LocalDailyProcessor: """Run one isolated deterministic process and expose only its artifact paths.""" policy: ProcessorPolicy timeout_seconds: int = 240 def __post_init__(self) -> None: if ( not isinstance(self.timeout_seconds, int) or isinstance(self.timeout_seconds, bool) or not 10 <= self.timeout_seconds <= 900 ): raise ValueError("local processor timeout is invalid") if not (self.policy.skill_root / "scripts" / "process_daily.py").is_file(): raise ValueError("ARR daily processor is unavailable") def run( self, source_xml: Path, output_dir: Path, *, manual_overrides: Optional[Path] = None, review_job_id: Optional[str] = None, review_case_id: Optional[str] = None, manual_override_sha256: Optional[str] = None, ) -> LocalProcessingOutput: if not source_xml.is_file(): raise IngestionError("SOURCE_NOT_FOUND", "registered source XML is unavailable") manual_arguments = ( manual_overrides, review_job_id, review_case_id, manual_override_sha256, ) if any(value is not None for value in manual_arguments): if ( manual_overrides is None or not manual_overrides.is_file() or not review_job_id or not review_case_id or not manual_override_sha256 ): raise IngestionError( "MANUAL_OVERRIDE_INVALID", "frozen manual-price overrides are unavailable", ) output_dir.mkdir(parents=True, exist_ok=False, mode=0o700) result_path = output_dir / "result.json" structured_path = output_dir / "structured-result.json" command = [ self.policy.python_binary, str((self.policy.skill_root / "scripts" / "process_daily.py").resolve()), "--xml", str(source_xml.resolve()), "--output-dir", str(output_dir.resolve()), "--result-json", str(result_path.resolve()), "--structured-result-json", str(structured_path.resolve()), ] if manual_overrides is not None: command.extend( ( "--manual-override-json", str(manual_overrides.resolve()), "--review-job-id", str(review_job_id), "--review-case-id", str(review_case_id), "--manual-override-sha256", str(manual_override_sha256), ) ) try: completed = subprocess.run( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=self.timeout_seconds, ) except subprocess.TimeoutExpired: raise IngestionError( "PROCESSOR_TIMEOUT", "deterministic XML processing exceeded its time limit", retryable=True, ) from None except OSError: raise IngestionError( "PROCESSOR_UNAVAILABLE", "deterministic XML processing could not start", retryable=True, ) from None result = strict_json_file(result_path, "processor result") structured = strict_json_file(structured_path, "structured processor result") status = result.get("status") if ( status not in {"success", "failed", "review_required"} or structured.get("status") != status or (completed.returncode == 0) != (status in {"success", "review_required"}) ): raise IngestionError( "PROCESSOR_RESULT_INVALID", "processor exit status and result artifacts do not agree", ) raw_date = result.get("business_date") business_date: Optional[date] if raw_date is None: business_date = None elif isinstance(raw_date, str): try: business_date = date.fromisoformat(raw_date) except ValueError: raise IngestionError( "PROCESSOR_RESULT_INVALID", "processor business date is invalid" ) from None else: raise IngestionError( "PROCESSOR_RESULT_INVALID", "processor business date is invalid" ) outputs = result.get("outputs") if not isinstance(outputs, dict): raise IngestionError( "PROCESSOR_RESULT_INVALID", "processor output manifest is invalid" ) artifacts: Dict[str, Path] = { "result_json": result_path, "structured_result_json": structured_path, } if status == "success": artifacts["daily_report"] = self._output_path( output_dir, outputs.get("daily_report"), ".xlsx" ) if outputs.get("exception_report") is not None or business_date is None: raise IngestionError( "PROCESSOR_RESULT_INVALID", "successful processor output is incomplete" ) elif status == "review_required": if ( business_date is None or outputs.get("daily_report") is not None or outputs.get("exception_report") is not None ): raise IngestionError( "PROCESSOR_RESULT_INVALID", "review processor output is inconsistent" ) else: artifacts["exception_report"] = self._output_path( output_dir, outputs.get("exception_report"), ".xlsx" ) if outputs.get("daily_report") is not None: raise IngestionError( "PROCESSOR_RESULT_INVALID", "failed processor output is inconsistent" ) return LocalProcessingOutput( status=str(status), business_date=business_date, artifacts=artifacts, exit_code=completed.returncode, ) @staticmethod def _output_path(output_dir: Path, value: object, suffix: str) -> Path: if ( not isinstance(value, str) or not value or Path(value).name != value or not value.lower().endswith(suffix) ): raise IngestionError( "PROCESSOR_RESULT_INVALID", "processor output filename is invalid" ) candidate = (output_dir / value).resolve() if candidate.parent != output_dir.resolve() or not candidate.is_file(): raise IngestionError( "PROCESSOR_RESULT_INVALID", "processor output artifact is unavailable" ) return candidate