663 lines
25 KiB
Python
663 lines
25 KiB
Python
"""Workbook build adapter and recoverable atomic publication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
|
|
from openpyxl import Workbook, load_workbook
|
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
from openpyxl.worksheet.worksheet import Worksheet
|
|
|
|
from company_reports.contracts import CompanyReport, ErrorCode, RESULT_SCHEMA_VERSION
|
|
from company_reports.repository import (
|
|
FileMetadata,
|
|
ReportRepository,
|
|
RepositoryError,
|
|
ReservedReport,
|
|
)
|
|
|
|
|
|
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
REPORT_HEADERS = (
|
|
"ARRIVAL",
|
|
"DEPARTURE",
|
|
"NIGHTS",
|
|
"BLOCK_CODE",
|
|
"RES_COMMENT",
|
|
"Booking Room",
|
|
"Total Booking Price",
|
|
)
|
|
BODY_FONT = "222222"
|
|
DUPLICATE_FILL = "FFF2CC"
|
|
DUPLICATE_FONT = "9C6500"
|
|
REVIEW_FILL = "FCE4D6"
|
|
REVIEW_FONT = "C65911"
|
|
HEADER_FILL = "FFFFFF"
|
|
|
|
|
|
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:
|
|
current_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 = 120,
|
|
) -> None:
|
|
_ = (node_binary, artifact_tool_module, timeout_seconds)
|
|
self._builder_script = builder_script.resolve()
|
|
|
|
@staticmethod
|
|
def _safe_text(value: Any) -> str:
|
|
text = str(value or "")
|
|
return "'" + text if text[:1] in {"=", "+", "-", "@"} else text
|
|
|
|
@staticmethod
|
|
def _date_key(value: Any) -> str:
|
|
if isinstance(value, datetime):
|
|
return value.date().isoformat()
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
if isinstance(value, str) and len(value) >= 10:
|
|
return value[:10]
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _cell_text(value: Any) -> str:
|
|
return "" if value is None else str(value)
|
|
|
|
@staticmethod
|
|
def _semantic_sha256(payload: Mapping[str, Any]) -> str:
|
|
encoded = json.dumps(
|
|
{
|
|
"headers": payload["headers"],
|
|
"periods": payload["periods"],
|
|
},
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
def _write_sheet(
|
|
self,
|
|
worksheet: Worksheet,
|
|
period: Any,
|
|
) -> None:
|
|
worksheet.append(list(REPORT_HEADERS))
|
|
for row in period.rows:
|
|
worksheet.append(
|
|
[
|
|
row.arrival,
|
|
row.departure,
|
|
row.nights,
|
|
self._safe_text(row.block_code),
|
|
self._safe_text(row.res_comment),
|
|
self._safe_text(row.booking_room),
|
|
self._safe_text(row.total_booking_price),
|
|
]
|
|
)
|
|
|
|
worksheet.column_dimensions["A"].width = 13
|
|
worksheet.column_dimensions["B"].width = 13
|
|
worksheet.column_dimensions["C"].width = 9
|
|
worksheet.column_dimensions["D"].width = 20
|
|
worksheet.column_dimensions["E"].width = 25
|
|
worksheet.column_dimensions["F"].width = 34
|
|
worksheet.column_dimensions["G"].width = 48
|
|
worksheet.freeze_panes = "A2"
|
|
worksheet.auto_filter.ref = f"A1:G{max(1, worksheet.max_row)}"
|
|
|
|
thin_bottom = Border(
|
|
bottom=Side(style="thin", color="7F7F7F"),
|
|
)
|
|
header_fill = PatternFill("solid", fgColor=HEADER_FILL)
|
|
duplicate_fill = PatternFill("solid", fgColor=DUPLICATE_FILL)
|
|
review_fill = PatternFill("solid", fgColor=REVIEW_FILL)
|
|
for cell in worksheet[1]:
|
|
cell.fill = header_fill
|
|
cell.font = Font(name="Arial", size=10, bold=True, color="000000")
|
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
cell.border = thin_bottom
|
|
worksheet.row_dimensions[1].height = 24
|
|
|
|
for row_index in range(2, worksheet.max_row + 1):
|
|
worksheet.row_dimensions[row_index].height = 30
|
|
for cell in worksheet[row_index]:
|
|
cell.font = Font(name="Arial", size=10, color=BODY_FONT)
|
|
cell.alignment = Alignment(vertical="center")
|
|
for column in ("A", "B", "C", "D"):
|
|
worksheet[f"{column}{row_index}"].alignment = Alignment(
|
|
horizontal="center",
|
|
vertical="center",
|
|
)
|
|
for column in ("E", "F", "G"):
|
|
worksheet[f"{column}{row_index}"].alignment = Alignment(
|
|
horizontal="left",
|
|
vertical="center",
|
|
wrap_text=True,
|
|
)
|
|
worksheet[f"A{row_index}"].number_format = "yyyy-mm-dd"
|
|
worksheet[f"B{row_index}"].number_format = "yyyy-mm-dd"
|
|
|
|
for offset, source_row in enumerate(period.rows, start=2):
|
|
if source_row.duplicate_group:
|
|
for column in range(1, 8):
|
|
worksheet.cell(offset, column).fill = duplicate_fill
|
|
worksheet[f"E{offset}"].font = Font(
|
|
name="Arial",
|
|
size=10,
|
|
bold=True,
|
|
color=DUPLICATE_FONT,
|
|
)
|
|
if source_row.multi_price_review:
|
|
worksheet[f"G{offset}"].fill = review_fill
|
|
worksheet[f"G{offset}"].font = Font(
|
|
name="Arial",
|
|
size=10,
|
|
bold=True,
|
|
color=REVIEW_FONT,
|
|
)
|
|
|
|
def _validate_workbook(self, output_path: Path, report: CompanyReport) -> int:
|
|
workbook = load_workbook(output_path, data_only=False)
|
|
expected_names = [period.sheet_name for period in report.periods]
|
|
if workbook.sheetnames != expected_names:
|
|
raise ValueError("worksheet names do not match")
|
|
formula_count = 0
|
|
for period in report.periods:
|
|
worksheet = workbook[period.sheet_name]
|
|
expected_rows = len(period.rows) + 1
|
|
if worksheet.max_row != expected_rows or worksheet.max_column != 7:
|
|
raise ValueError("worksheet dimensions do not match")
|
|
headers = [worksheet.cell(1, column).value for column in range(1, 8)]
|
|
if headers != list(REPORT_HEADERS):
|
|
raise ValueError("worksheet headers do not match")
|
|
for row_index, row in enumerate(period.rows, start=2):
|
|
expected = [
|
|
row.arrival.isoformat(),
|
|
row.departure.isoformat(),
|
|
row.nights,
|
|
self._safe_text(row.block_code),
|
|
self._safe_text(row.res_comment),
|
|
self._safe_text(row.booking_room),
|
|
self._safe_text(row.total_booking_price),
|
|
]
|
|
actual = [
|
|
self._date_key(worksheet.cell(row_index, 1).value),
|
|
self._date_key(worksheet.cell(row_index, 2).value),
|
|
worksheet.cell(row_index, 3).value,
|
|
self._cell_text(worksheet.cell(row_index, 4).value),
|
|
self._cell_text(worksheet.cell(row_index, 5).value),
|
|
self._cell_text(worksheet.cell(row_index, 6).value),
|
|
self._cell_text(worksheet.cell(row_index, 7).value),
|
|
]
|
|
if actual != expected:
|
|
raise ValueError("worksheet values do not match")
|
|
for cells in worksheet.iter_rows():
|
|
for cell in cells:
|
|
if cell.data_type == "f":
|
|
formula_count += 1
|
|
if formula_count != 0:
|
|
raise ValueError("workbook must contain no formulas")
|
|
return formula_count
|
|
|
|
def build(self, report: CompanyReport, work_dir: Path) -> BuiltWorkbook:
|
|
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"
|
|
try:
|
|
payload = report.to_workbook_payload()
|
|
_private_json(payload_path, payload)
|
|
preview_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
workbook = Workbook()
|
|
for index, period in enumerate(report.periods):
|
|
worksheet = workbook.active if index == 0 else workbook.create_sheet()
|
|
worksheet.title = period.sheet_name
|
|
self._write_sheet(worksheet, period)
|
|
workbook.save(output_path)
|
|
os.chmod(output_path, 0o600)
|
|
formula_count = self._validate_workbook(output_path, report)
|
|
expected_rows = [len(period.rows) for period in report.periods]
|
|
expected_names = [period.sheet_name for period in report.periods]
|
|
summary = {
|
|
"status": "success",
|
|
"schema_version": payload["schema_version"],
|
|
"company": report.company,
|
|
"filename": report.filename,
|
|
"sheet_names": expected_names,
|
|
"row_counts": expected_rows,
|
|
"formula_count": formula_count,
|
|
"semantic_sha256": self._semantic_sha256(payload),
|
|
"preview_count": 0,
|
|
"byte_size": output_path.stat().st_size,
|
|
}
|
|
if (
|
|
summary.get("status") != "success"
|
|
or summary.get("company") != report.company
|
|
or summary.get("filename") != report.filename
|
|
or summary.get("sheet_names") != expected_names
|
|
or summary.get("row_counts") != expected_rows
|
|
or summary.get("formula_count") != 0
|
|
or not isinstance(summary.get("semantic_sha256"), str)
|
|
or len(summary.get("semantic_sha256")) != 64
|
|
or not output_path.is_file()
|
|
or output_path.stat().st_size <= 0
|
|
):
|
|
raise ValueError("summary mismatch")
|
|
_private_json(summary_path, summary)
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
raise BuildError(
|
|
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
|
"the XLSX builder result could not be validated",
|
|
) from None
|
|
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 archive path already 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,
|
|
"an artifact path escaped the controlled project root",
|
|
) from None
|
|
|
|
@staticmethod
|
|
def _is_regular_file(path: Path) -> bool:
|
|
return path.is_file() and not path.is_symlink()
|
|
|
|
@staticmethod
|
|
def _is_sha256(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, str)
|
|
and len(value) == 64
|
|
and all(character in "0123456789abcdef" for character in value)
|
|
)
|
|
|
|
def _existing_publication_metadata(
|
|
self,
|
|
report: CompanyReport,
|
|
reservation: ReservedReport,
|
|
built: BuiltWorkbook,
|
|
archive_path: Path,
|
|
result_path: Path,
|
|
) -> Optional[Tuple[FileMetadata, FileMetadata]]:
|
|
archive_present = archive_path.exists() or archive_path.is_symlink()
|
|
result_present = result_path.exists() or result_path.is_symlink()
|
|
if not archive_present and not result_present:
|
|
return None
|
|
if not self._is_regular_file(archive_path) or not self._is_regular_file(
|
|
result_path
|
|
):
|
|
raise PublicationError(
|
|
ErrorCode.PUBLISH_FAILED,
|
|
"an existing company report publication is incomplete",
|
|
)
|
|
|
|
semantic_sha256 = built.summary.get("semantic_sha256")
|
|
if not self._is_sha256(semantic_sha256):
|
|
raise PublicationError(
|
|
ErrorCode.PUBLISH_FAILED,
|
|
"the generated company report semantic identity is invalid",
|
|
)
|
|
|
|
try:
|
|
result_payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
if not isinstance(result_payload, dict):
|
|
raise ValueError("result JSON must be an object")
|
|
artifact_payload = result_payload.get("artifact")
|
|
if not isinstance(artifact_payload, dict):
|
|
raise ValueError("result JSON artifact identity is missing")
|
|
existing_sha256 = artifact_payload.get("sha256")
|
|
if not self._is_sha256(existing_sha256):
|
|
raise ValueError("result JSON artifact hash is invalid")
|
|
archive_sha256 = sha256_file(archive_path)
|
|
if archive_sha256 != existing_sha256:
|
|
raise ValueError("archive and result JSON hashes differ")
|
|
|
|
artifact_storage_key = self._storage_key(archive_path)
|
|
expected_payload = self._success_result(
|
|
report,
|
|
reservation,
|
|
artifact_storage_key,
|
|
existing_sha256,
|
|
semantic_sha256,
|
|
)
|
|
if result_payload != expected_payload:
|
|
raise ValueError("result JSON identity differs")
|
|
|
|
artifact = FileMetadata(
|
|
file_kind="company_ten_day_xlsx",
|
|
original_filename=report.filename,
|
|
storage_key=artifact_storage_key,
|
|
sha256=existing_sha256,
|
|
byte_size=archive_path.stat().st_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",
|
|
)
|
|
artifact.validate()
|
|
result_json.validate()
|
|
return artifact, result_json
|
|
except (OSError, UnicodeError, TypeError, ValueError, RepositoryError):
|
|
raise PublicationError(
|
|
ErrorCode.PUBLISH_FAILED,
|
|
"an existing company report publication identity is invalid",
|
|
) from None
|
|
|
|
def _archive_untracked_current(self, current_path: Path, archive_root: Path) -> None:
|
|
if not current_path.is_file():
|
|
return
|
|
current_sha = sha256_file(current_path)
|
|
legacy_path = archive_root / "legacy" / current_sha[:16] / current_path.name
|
|
self._install_once(current_path, legacy_path, current_sha)
|
|
|
|
@staticmethod
|
|
def _success_result(
|
|
report: CompanyReport,
|
|
reservation: ReservedReport,
|
|
artifact_storage_key: str,
|
|
artifact_sha256: str,
|
|
semantic_sha256: str,
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"schema_version": RESULT_SCHEMA_VERSION,
|
|
"status": "success",
|
|
"company": report.company,
|
|
"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,
|
|
"period_row_counts": {
|
|
period.key: len(period.rows) for period in report.periods
|
|
},
|
|
"daily_version_count": len(report.daily_versions),
|
|
"booking_version_count": len(report.booking_versions),
|
|
"warnings": [
|
|
{
|
|
"code": warning.code,
|
|
"stage": warning.stage,
|
|
"period": warning.period,
|
|
"record_ids": list(warning.record_ids),
|
|
}
|
|
for warning in report.warnings
|
|
],
|
|
"errors": [],
|
|
}
|
|
|
|
def publish(
|
|
self,
|
|
report: CompanyReport,
|
|
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}"
|
|
)
|
|
archive_root = month_root / "archive"
|
|
version_root = archive_root / f"v{reservation.version_no:04d}"
|
|
current_path = month_root / report.filename
|
|
archive_path = version_root / report.filename
|
|
result_path = version_root / f"{report.company}-v{reservation.version_no:04d}.result.json"
|
|
backup_path = work_dir / "previous-current.xlsx"
|
|
current_existed = current_path.is_file()
|
|
archive_created = False
|
|
result_created = False
|
|
current_replaced = False
|
|
|
|
try:
|
|
month_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
|
|
existing = self._existing_publication_metadata(
|
|
report,
|
|
reservation,
|
|
built,
|
|
archive_path,
|
|
result_path,
|
|
)
|
|
if existing is not None:
|
|
artifact, result_json = existing
|
|
if not current_existed:
|
|
self._atomic_copy(archive_path, current_path)
|
|
current_replaced = True
|
|
repository.activate_report(reservation, artifact, result_json)
|
|
return PublicationOutcome(
|
|
current_path=current_path,
|
|
archive_path=archive_path,
|
|
result_path=result_path,
|
|
artifact=artifact,
|
|
result_json=result_json,
|
|
)
|
|
|
|
self._archive_untracked_current(current_path, archive_root)
|
|
if current_existed:
|
|
shutil.copyfile(current_path, backup_path)
|
|
os.chmod(backup_path, 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 result path already contains different content",
|
|
)
|
|
else:
|
|
self._atomic_json(result_path, result_payload)
|
|
result_created = True
|
|
|
|
self._atomic_copy(built.path, current_path)
|
|
current_replaced = True
|
|
artifact = FileMetadata(
|
|
file_kind="company_ten_day_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)
|
|
return PublicationOutcome(
|
|
current_path=current_path,
|
|
archive_path=archive_path,
|
|
result_path=result_path,
|
|
artifact=artifact,
|
|
result_json=result_json,
|
|
)
|
|
except Exception as error:
|
|
if current_replaced:
|
|
if current_existed and backup_path.is_file():
|
|
self._atomic_copy(backup_path, current_path)
|
|
else:
|
|
current_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", "company 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,
|
|
"company report publication failed",
|
|
) from None
|
|
|
|
|
|
def private_staging_directory(parent: Path):
|
|
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
return tempfile.TemporaryDirectory(prefix="company-report-", dir=parent)
|