"""Artifact-tool adapter and recoverable atomic monthly-report publication.""" from __future__ import annotations import hashlib import json import os import shutil import subprocess import tempfile import uuid from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Mapping, Optional from monthly_reports.contracts import ( RESULT_SCHEMA_VERSION, XLSX_MIME, ErrorCode, MonthlyReport, ) from monthly_reports.repository import ( FileMetadata, ReportRepository, RepositoryError, ReservedReport, ) class BuildError(RuntimeError): def __init__(self, code: str, safe_message: str): super().__init__(safe_message) self.code = code self.safe_message = safe_message class PublicationError(RuntimeError): def __init__(self, code: str, safe_message: str): super().__init__(safe_message) self.code = code self.safe_message = safe_message @dataclass(frozen=True) class BuiltWorkbook: path: Path sha256: str byte_size: int summary: Mapping[str, Any] @dataclass(frozen=True) class PublicationOutcome: latest_path: Path latest_result_path: Path archive_path: Path result_path: Path artifact: FileMetadata result_json: FileMetadata def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _private_json(path: Path, payload: Mapping[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, sort_keys=True, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) except Exception: try: os.close(descriptor) except OSError: pass raise class ArtifactToolBuilder: def __init__( self, builder_script: Path, node_binary: Optional[str] = None, artifact_tool_module: Optional[Path] = None, timeout_seconds: int = 180, ) -> None: configured = (node_binary or os.environ.get("MONTHLY_REPORT_NODE_BINARY", "")).strip() self._node_binary = configured or shutil.which("node") or "" self._builder_script = builder_script.resolve() self._artifact_tool_module = ( artifact_tool_module.resolve() if artifact_tool_module else None ) self._timeout_seconds = timeout_seconds def build(self, report: MonthlyReport, work_dir: Path) -> BuiltWorkbook: if not self._node_binary or not self._builder_script.is_file(): raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "the monthly XLSX builder runtime is unavailable", ) work_dir.mkdir(parents=True, exist_ok=True, mode=0o700) os.chmod(work_dir, 0o700) payload_path = work_dir / "workbook-payload.json" output_path = work_dir / report.filename preview_dir = work_dir / "previews" summary_path = work_dir / "workbook-summary.json" _private_json(payload_path, report.to_workbook_payload()) environment = os.environ.copy() if self._artifact_tool_module is not None: environment["MONTHLY_REPORT_ARTIFACT_TOOL_MODULE"] = str( self._artifact_tool_module ) try: completed = subprocess.run( [ self._node_binary, str(self._builder_script), str(payload_path), str(output_path), str(preview_dir), str(summary_path), ], cwd=self._builder_script.parent, capture_output=True, text=True, timeout=self._timeout_seconds, env=environment, check=False, ) except (OSError, subprocess.TimeoutExpired): raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "the monthly XLSX builder did not complete", ) from None if completed.returncode != 0: raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "the monthly XLSX builder rejected the generated payload", ) try: summary = json.loads(summary_path.read_text(encoding="utf-8")) expected_names = [channel.worksheet for channel in report.channels] expected_rows = [len(channel.rows) for channel in report.channels] if ( summary.get("status") != "success" or summary.get("schema_version") != RESULT_SCHEMA_VERSION or summary.get("filename") != report.filename or summary.get("report_year") != report.report_year or summary.get("report_month") != report.report_month or summary.get("as_of_date") != report.as_of_date.isoformat() or summary.get("sheet_names") != expected_names or summary.get("row_counts") != expected_rows or summary.get("formula_count") != report.row_count or summary.get("preview_count") != len(report.channels) or not isinstance(summary.get("semantic_sha256"), str) or len(summary["semantic_sha256"]) != 64 or not output_path.is_file() or output_path.stat().st_size <= 0 ): raise ValueError("builder summary mismatch") except (OSError, ValueError, TypeError, json.JSONDecodeError, KeyError): raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "the monthly XLSX builder result could not be validated", ) from None os.chmod(output_path, 0o600) return BuiltWorkbook( path=output_path, sha256=sha256_file(output_path), byte_size=output_path.stat().st_size, summary=summary, ) class AtomicReportPublisher: def __init__(self, project_root: Path, output_root: Path) -> None: self._project_root = project_root.resolve() self._output_root = output_root.resolve() try: self._output_root.relative_to(self._project_root) except ValueError: raise ValueError("output_root must be inside project_root") from None @staticmethod def _fsync_parent(path: Path) -> None: descriptor = os.open(path.parent, os.O_RDONLY) try: os.fsync(descriptor) finally: os.close(descriptor) @classmethod def _atomic_copy(cls, source: Path, target: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = target.parent / f".{target.name}.tmp-{uuid.uuid4().hex}" try: shutil.copyfile(source, temporary) os.chmod(temporary, 0o600) with temporary.open("rb") as handle: os.fsync(handle.fileno()) os.replace(temporary, target) cls._fsync_parent(target) finally: temporary.unlink(missing_ok=True) @classmethod def _atomic_json(cls, target: Path, payload: Mapping[str, Any]) -> None: target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) temporary = target.parent / f".{target.name}.tmp-{uuid.uuid4().hex}" try: _private_json(temporary, payload) os.replace(temporary, target) cls._fsync_parent(target) finally: temporary.unlink(missing_ok=True) @classmethod def _install_once(cls, source: Path, target: Path, expected_sha256: str) -> bool: if target.exists(): if sha256_file(target) != expected_sha256: raise PublicationError( ErrorCode.PUBLISH_FAILED, "an immutable monthly archive path contains different content", ) return False cls._atomic_copy(source, target) return True def _storage_key(self, path: Path) -> str: try: return path.resolve().relative_to(self._project_root).as_posix() except ValueError: raise PublicationError( ErrorCode.PUBLISH_FAILED, "a monthly artifact escaped the controlled project root", ) from None @staticmethod def _success_result( report: MonthlyReport, reservation: ReservedReport, artifact_storage_key: str, artifact_sha256: str, semantic_sha256: str, ) -> Dict[str, Any]: return { "schema_version": RESULT_SCHEMA_VERSION, "status": "success", "report_year": report.report_year, "report_month": report.report_month, "as_of_date": report.as_of_date.isoformat(), "report_version_id": reservation.report_version_id, "version_no": reservation.version_no, "artifact": { "filename": report.filename, "storage_key": artifact_storage_key, "sha256": artifact_sha256, "semantic_sha256": semantic_sha256, }, "row_count": report.row_count, "channel_manifest": [ { "worksheet": worksheet, "worksheet_order": order, "row_count": row_count, } for worksheet, order, row_count in report.channel_manifest ], "daily_version_count": len(report.daily_versions), "warnings": [], "errors": [], } def publish( self, report: MonthlyReport, reservation: ReservedReport, built: BuiltWorkbook, repository: ReportRepository, work_dir: Path, ) -> PublicationOutcome: month_root = self._output_root / f"{report.report_year:04d}" / f"{report.report_month:02d}" version_root = month_root / "archive" / f"v{reservation.version_no:04d}" archive_path = version_root / report.filename result_path = version_root / "result.json" latest_path = month_root / "latest.xlsx" latest_result_path = month_root / "latest.result.json" workbook_backup = work_dir / "previous-latest.xlsx" result_backup = work_dir / "previous-latest.result.json" latest_existed = latest_path.is_file() latest_result_existed = latest_result_path.is_file() archive_created = False result_created = False latest_replaced = False latest_result_replaced = False try: month_root.mkdir(parents=True, exist_ok=True, mode=0o700) if latest_existed: shutil.copyfile(latest_path, workbook_backup) os.chmod(workbook_backup, 0o600) if latest_result_existed: shutil.copyfile(latest_result_path, result_backup) os.chmod(result_backup, 0o600) archive_created = self._install_once(built.path, archive_path, built.sha256) artifact_storage_key = self._storage_key(archive_path) result_payload = self._success_result( report, reservation, artifact_storage_key, built.sha256, str(built.summary.get("semantic_sha256", "")), ) if result_path.exists(): expected = json.dumps( result_payload, ensure_ascii=False, sort_keys=True, indent=2, ) + "\n" if result_path.read_text(encoding="utf-8") != expected: raise PublicationError( ErrorCode.PUBLISH_FAILED, "an immutable monthly result path contains different content", ) else: self._atomic_json(result_path, result_payload) result_created = True self._atomic_copy(built.path, latest_path) latest_replaced = True self._atomic_json(latest_result_path, result_payload) latest_result_replaced = True artifact = FileMetadata( file_kind="monthly_xlsx", original_filename=report.filename, storage_key=artifact_storage_key, sha256=built.sha256, byte_size=built.byte_size, mime_type=XLSX_MIME, ) result_json = FileMetadata( file_kind="result_json", original_filename=result_path.name, storage_key=self._storage_key(result_path), sha256=sha256_file(result_path), byte_size=result_path.stat().st_size, mime_type="application/json", ) repository.activate_report( reservation, artifact, result_json, str(built.summary.get("semantic_sha256", "")), ) return PublicationOutcome( latest_path=latest_path, latest_result_path=latest_result_path, archive_path=archive_path, result_path=result_path, artifact=artifact, result_json=result_json, ) except Exception as error: if latest_result_replaced: if latest_result_existed and result_backup.is_file(): self._atomic_copy(result_backup, latest_result_path) else: latest_result_path.unlink(missing_ok=True) if latest_replaced: if latest_existed and workbook_backup.is_file(): self._atomic_copy(workbook_backup, latest_path) else: latest_path.unlink(missing_ok=True) if result_created: result_path.unlink(missing_ok=True) if archive_created: archive_path.unlink(missing_ok=True) code = getattr(error, "code", ErrorCode.PUBLISH_FAILED) safe_message = getattr(error, "safe_message", "monthly report publication failed") try: repository.mark_failed(reservation, code, safe_message) except Exception: pass if isinstance(error, PublicationError): raise if isinstance(error, RepositoryError): raise PublicationError(error.code, error.safe_message) from None raise PublicationError( ErrorCode.PUBLISH_FAILED, "monthly report publication failed", ) from None def private_staging_directory(parent: Path): parent.mkdir(parents=True, exist_ok=True, mode=0o700) return tempfile.TemporaryDirectory(prefix="monthly-report-", dir=parent)