import io import os import tempfile import zipfile from abc import ABC, abstractmethod from typing import Any, Callable, NamedTuple from xlsxwriter import Workbook from .fields import REPORT_FIELDS MAX_OUTPUT_ROW_COUNT = 999_900 XLSX_SHEET_PREFIX = "Royalties" class Report(NamedTuple): report_id: int collaborator_id: int collaborator_name: str vendor_id: int total: str currency: str filename: str report_run_uuid: str number_format: str created_date: tuple contract_totals: dict[str, str] class ReportWriter(ABC): field_cast_map: dict[str, Callable] extension: str @abstractmethod def write_row(self, cells): pass @abstractmethod def finalise(self): pass def cast(self, key: str, val: Any): if val == "\\N": return "" if not self.field_cast_map.get(key): return val return self.field_cast_map[key](val) def __init__(self, report: Report): self.report = report self.row_count = 0 file_descriptor, self.output_filename = tempfile.mkstemp() # We only need the filename, file will be opened by subclass implementation os.close(file_descriptor) class CsvReportWriter(ReportWriter): extension = "zip" def __init__(self, report: Report): super().__init__(report) self.output_zip = zipfile.ZipFile(self.output_filename, "w") self.file_count = 1 self.new_page() self.field_cast_map = { "report_date": lambda d: d.isoformat(), "total": self.format_decimal, "royalty_basis": self.format_decimal, "collaborator_share": self.format_decimal, "collaborator_split": self.format_decimal, "quantity": lambda q: int(float(q)), } def format_decimal(self, number: str | float): NUMBER_FORMAT_US = "us" formatted = f"{float(number):.6f}" if self.report.number_format != NUMBER_FORMAT_US: formatted = formatted.replace(".", ",") return formatted def write_row(self, cells): # If we've exceeded the max row count for the csv, we need # to close it and open a new one for writing. if self.row_count >= MAX_OUTPUT_ROW_COUNT: self.file_count += 1 # Close the text wrapper: this flushes buffered rows into the zip # member and closes it. self.output_csv_text.close() self.new_page() self.row_count = 0 self.write_row(REPORT_FIELDS.values()) parts = [] for cell in cells: escaped_string = str(cell).replace('"', '\\"') parts.append(f'"{escaped_string}"\t') self.row_count += 1 if self.row_count < MAX_OUTPUT_ROW_COUNT: parts.append("\n") # Write the whole row in a single call and do NOT flush per row. self.output_csv_text.write("".join(parts)) def finalise(self): # Close the text wrapper (flushes buffered rows into the zip member and # closes it) before closing the archive. self.output_csv_text.close() self.output_zip.close() # Reset the stream position ready for uploading def new_page(self): filename = f"{self.report.filename}_{self.file_count}.xls" output_csv_metadata = zipfile.ZipInfo(filename, date_time=self.report.created_date) output_csv_metadata.compress_type = zipfile.ZIP_DEFLATED self.output_csv = self.output_zip.open(output_csv_metadata, "w") self.output_csv_text = io.TextIOWrapper(self.output_csv, encoding="utf-8") class XlsxReportWriter(ReportWriter): extension = "xlsx" def __init__(self, report: Report): super().__init__(report) # Create a workbook and add a worksheet. self.workbook = Workbook(self.output_filename, {"constant_memory": True}) self.sheet_count = 1 self.output_sheet = self.workbook.add_worksheet(self.sheet_name()) self.field_cast_map = { "total": float, "royalty_basis": float, "collaborator_share": float, "collaborator_split": float, "quantity": lambda qty: int(float(qty)), } def sheet_name(self): return f"{XLSX_SHEET_PREFIX} {self.sheet_count}" def write_row(self, cells): # If we've exceeded the max row count for the sheet, we need # to open a new one for writing and switch to it. if self.row_count >= MAX_OUTPUT_ROW_COUNT: self.sheet_count += 1 self.output_sheet = self.workbook.add_worksheet(self.sheet_name()) self.row_count = 0 self.write_row(REPORT_FIELDS.values()) self.output_sheet.write_row(self.row_count, 0, list(cells)) self.row_count += 1 def finalise(self): self.workbook.close()