feat: add Excel export functionality with support for metadata and customizable sheets
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from enum import Enum
|
||||
from io import StringIO
|
||||
from typing import Any, Callable, Iterable, Iterator, Sequence
|
||||
from io import BytesIO, StringIO
|
||||
from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence
|
||||
|
||||
from openpyxl import Workbook
|
||||
CSVValueFormatter = Callable[[Any], str]
|
||||
Accessor = Callable[[Any], Any]
|
||||
|
||||
@@ -18,6 +19,9 @@ __all__ = [
|
||||
"default_scenario_columns",
|
||||
"stream_projects_to_csv",
|
||||
"stream_scenarios_to_csv",
|
||||
"ExcelExporter",
|
||||
"export_projects_to_excel",
|
||||
"export_scenarios_to_excel",
|
||||
"default_formatter",
|
||||
"format_datetime_utc",
|
||||
"format_date_iso",
|
||||
@@ -34,13 +38,13 @@ class CSVExportColumn:
|
||||
formatter: CSVValueFormatter | None = None
|
||||
required: bool = False
|
||||
|
||||
def resolve_accessor(self) -> Accessor:
|
||||
if isinstance(self.accessor, str):
|
||||
return _coerce_accessor(self.accessor)
|
||||
return self.accessor
|
||||
_accessor: Accessor = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "_accessor", _coerce_accessor(self.accessor))
|
||||
|
||||
def value_for(self, entity: Any) -> Any:
|
||||
accessor = self.resolve_accessor()
|
||||
accessor = object.__getattribute__(self, "_accessor")
|
||||
try:
|
||||
return accessor(entity)
|
||||
except Exception: # pragma: no cover - defensive safeguard
|
||||
@@ -85,8 +89,7 @@ class CSVExporter:
|
||||
def _format_row(self, record: Any) -> list[str]:
|
||||
formatted: list[str] = []
|
||||
for column in self._columns:
|
||||
accessor = column.resolve_accessor()
|
||||
raw_value = accessor(record)
|
||||
raw_value = column.value_for(record)
|
||||
formatter = column.formatter or default_formatter
|
||||
formatted.append(formatter(raw_value))
|
||||
return formatted
|
||||
@@ -216,6 +219,114 @@ def format_decimal(value: Any) -> str:
|
||||
return default_formatter(value)
|
||||
|
||||
|
||||
class ExcelExporter:
|
||||
"""Produce Excel workbooks via write-only streaming."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
columns: Sequence[CSVExportColumn],
|
||||
*,
|
||||
sheet_name: str = "Export",
|
||||
workbook_title: str | None = None,
|
||||
include_header: bool = True,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
metadata_sheet_name: str = "Metadata",
|
||||
) -> None:
|
||||
if not columns:
|
||||
raise ValueError(
|
||||
"At least one column is required for Excel export.")
|
||||
self._columns: tuple[CSVExportColumn, ...] = tuple(columns)
|
||||
self._sheet_name = sheet_name or "Export"
|
||||
self._include_header = include_header
|
||||
self._metadata = dict(metadata) if metadata else None
|
||||
self._metadata_sheet_name = metadata_sheet_name or "Metadata"
|
||||
self._workbook = Workbook(write_only=True)
|
||||
if workbook_title:
|
||||
self._workbook.properties.title = workbook_title
|
||||
|
||||
def export(self, records: Iterable[Any]) -> bytes:
|
||||
sheet = self._workbook.create_sheet(title=self._sheet_name)
|
||||
if self._include_header:
|
||||
sheet.append([column.header for column in self._columns])
|
||||
|
||||
for record in records:
|
||||
sheet.append(self._format_row(record))
|
||||
|
||||
self._append_metadata_sheet()
|
||||
return self._finalize()
|
||||
|
||||
def _format_row(self, record: Any) -> list[Any]:
|
||||
row: list[Any] = []
|
||||
for column in self._columns:
|
||||
raw_value = column.value_for(record)
|
||||
formatter = column.formatter or default_formatter
|
||||
row.append(formatter(raw_value))
|
||||
return row
|
||||
|
||||
def _append_metadata_sheet(self) -> None:
|
||||
if not self._metadata:
|
||||
return
|
||||
|
||||
sheet_name = self._metadata_sheet_name
|
||||
existing = set(self._workbook.sheetnames)
|
||||
if sheet_name in existing:
|
||||
index = 1
|
||||
while True:
|
||||
candidate = f"{sheet_name}_{index}"
|
||||
if candidate not in existing:
|
||||
sheet_name = candidate
|
||||
break
|
||||
index += 1
|
||||
|
||||
meta_ws = self._workbook.create_sheet(title=sheet_name)
|
||||
meta_ws.append(["Key", "Value"])
|
||||
for key, value in self._metadata.items():
|
||||
meta_ws.append([
|
||||
str(key),
|
||||
"" if value is None else str(value),
|
||||
])
|
||||
|
||||
def _finalize(self) -> bytes:
|
||||
buffer = BytesIO()
|
||||
self._workbook.save(buffer)
|
||||
buffer.seek(0)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def export_projects_to_excel(
|
||||
projects: Iterable[Any],
|
||||
*,
|
||||
columns: Sequence[CSVExportColumn] | None = None,
|
||||
sheet_name: str = "Projects",
|
||||
workbook_title: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> bytes:
|
||||
exporter = ExcelExporter(
|
||||
columns or default_project_columns(),
|
||||
sheet_name=sheet_name,
|
||||
workbook_title=workbook_title,
|
||||
metadata=metadata,
|
||||
)
|
||||
return exporter.export(projects)
|
||||
|
||||
|
||||
def export_scenarios_to_excel(
|
||||
scenarios: Iterable[Any],
|
||||
*,
|
||||
columns: Sequence[CSVExportColumn] | None = None,
|
||||
sheet_name: str = "Scenarios",
|
||||
workbook_title: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> bytes:
|
||||
exporter = ExcelExporter(
|
||||
columns or default_scenario_columns(),
|
||||
sheet_name=sheet_name,
|
||||
workbook_title=workbook_title,
|
||||
metadata=metadata,
|
||||
)
|
||||
return exporter.export(scenarios)
|
||||
|
||||
|
||||
def _coerce_accessor(accessor: Accessor | str) -> Accessor:
|
||||
if callable(accessor):
|
||||
return accessor
|
||||
|
||||
Reference in New Issue
Block a user