mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Dan's review on #1546: - Stream the workbook to a /tmp temp file and S3Client.upload_file it, instead of render_workbook -> bytes -> put_object. render_workbook now saves straight to a path; the orchestrator renders to a temp file, multipart-uploads it, and always cleans it up. Restores ADR-0065's "never an in-memory BytesIO" decision (the OOM path at the 100k-row cap). - Move the raw `SELECT ... FROM scenario` out of the Lambda handler into ScenarioNamesPostgresRepository, so the handler stays composition-only and all SQL lives in repositories/. - current_sap_points goes through _float, matching its Optional[float] read-model type and the sibling numeric facts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
64 lines
2.4 KiB
Python
64 lines
2.4 KiB
Python
"""Renders the Scenario Export workbook (ADR-0065).
|
|
|
|
One ``.xlsx`` with one sheet per Scenario (model A2), each sheet's rows shaped by
|
|
``domain.scenario_export`` and painted with the Monday-style branding in
|
|
``infrastructure.xlsx.branding``. Programmatic openpyxl (no template) so a
|
|
dynamic number of scenario sheets is handled natively.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Sequence
|
|
|
|
from openpyxl import Workbook
|
|
|
|
from domain.scenario_export.scenario_sheet import ExportSheet
|
|
from infrastructure.xlsx.branding import style_sheet
|
|
|
|
# Excel forbids these in a sheet title and caps titles at 31 characters.
|
|
_ILLEGAL_SHEET_CHARS = re.compile(r"[:\\/?*\[\]]")
|
|
_MAX_SHEET_NAME = 31
|
|
|
|
|
|
def _safe_sheet_name(name: str, used: set[str]) -> str:
|
|
"""An Excel-legal, unique sheet name: illegal characters stripped, capped at
|
|
31 chars, and suffixed on collision (two scenarios can share a name)."""
|
|
clean = _ILLEGAL_SHEET_CHARS.sub("", name or "scenario").strip() or "scenario"
|
|
clean = clean[:_MAX_SHEET_NAME]
|
|
base, index = clean, 1
|
|
while clean in used:
|
|
suffix = f" ({index})"
|
|
clean = base[: _MAX_SHEET_NAME - len(suffix)] + suffix
|
|
index += 1
|
|
used.add(clean)
|
|
return clean
|
|
|
|
|
|
def render_workbook(
|
|
sheets: Sequence[tuple[str, ExportSheet]], destination_path: str
|
|
) -> None:
|
|
"""Render the scenario sheets into a single branded ``.xlsx`` at
|
|
``destination_path``. Each tuple is a Scenario name and its shaped sheet;
|
|
sheet order follows the input (the requested ``scenario_ids`` order).
|
|
|
|
Saves straight to disk (``Workbook.save(path)``) so the caller can
|
|
``S3Client.upload_file`` it with multipart — the workbook is never also held
|
|
as an in-memory ``bytes`` copy (ADR-0065; the OOM path the 4 GB sizing was
|
|
against at the 100k-row cap). The further memory win is openpyxl
|
|
``write_only`` mode; that switch would live at this seam.
|
|
"""
|
|
workbook = Workbook()
|
|
default = workbook.active
|
|
if default is not None:
|
|
workbook.remove(default)
|
|
|
|
used_names: set[str] = set()
|
|
for name, sheet in sheets:
|
|
worksheet = workbook.create_sheet(title=_safe_sheet_name(name, used_names))
|
|
worksheet.append(list(sheet.columns))
|
|
for row in sheet.rows:
|
|
worksheet.append([row.get(column, "") for column in sheet.columns])
|
|
style_sheet(worksheet)
|
|
|
|
workbook.save(destination_path)
|