"""CSV output and OutputSink protocol for result writing.""" import csv import logging import os from pathlib import Path from typing import Protocol, runtime_checkable from schemas import ResultColumn, ResultStatus from schemas.results import ErrorRow, SkippedRow logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Low-level CSV writers # --------------------------------------------------------------------------- def write_success_csv( success_rows: list[dict], output_file: str, fieldnames: list[str], append: bool = False, ): """Write successful contract creations to CSV.""" mode = 'a' if append else 'w' file_exists = append and os.path.exists(output_file) output_fieldnames = [ResultColumn.CONTRACT_ID] + list(fieldnames) with open(output_file, mode, newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=output_fieldnames, extrasaction='ignore') if not file_exists: writer.writeheader() writer.writerows(success_rows) def write_failures_csv( errors: list[ErrorRow], skipped: list[SkippedRow], output_file: str, fieldnames: list[str], ): """Write failures and skipped rows to CSV.""" output_fieldnames = [ ResultColumn.STATUS, ResultColumn.REASON, ] + list(fieldnames) with open(output_file, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=output_fieldnames, extrasaction='ignore') writer.writeheader() for error in errors: output_row = { ResultColumn.STATUS: ResultStatus.ERROR, ResultColumn.REASON: error.result.error or 'Unknown error', **error.data, } writer.writerow(output_row) for skip in skipped: output_row = { ResultColumn.STATUS: ResultStatus.SKIPPED, ResultColumn.REASON: skip.reason, **skip.data, } writer.writerow(output_row) # --------------------------------------------------------------------------- # OutputSink protocol and implementations # --------------------------------------------------------------------------- @runtime_checkable class OutputSink(Protocol): """Protocol for writing processing results. Lifecycle: open() → add_success() → close(). The sink owns its internal buffer and flush policy. """ def open(self, fieldnames: list[str]) -> None: """Set the column schema. Must be called before add_success().""" ... def add_success(self, row: dict) -> None: """Buffer a success row. May auto-flush when the buffer is full.""" ... def write_failures(self, errors: list[ErrorRow], skipped: list[SkippedRow]) -> None: """Write failure/skipped rows.""" ... def close(self) -> None: """Flush remaining buffered rows and finalize output.""" ... class NullOutputSink: """No-op output sink — used when no output files are configured.""" def open(self, fieldnames: list[str]) -> None: pass def add_success(self, row: dict) -> None: pass def write_failures(self, errors: list[ErrorRow], skipped: list[SkippedRow]) -> None: pass def close(self) -> None: pass class CsvOutputSink: """Writes results to CSV. Buffers success rows internally and flushes every ``flush_interval`` rows. Determines append mode automatically from file existence. Args: success_csv: Path for success output CSV. flush_interval: Number of success rows to buffer before flushing. """ def __init__( self, success_csv: str, flush_interval: int = 10, ): self._success_csv = success_csv self._flush_interval = flush_interval self._fieldnames: list[str] | None = None self._buffer: list[dict] = [] self._append = os.path.exists(success_csv) and os.path.getsize(success_csv) > 0 def open(self, fieldnames: list[str]) -> None: self._fieldnames = fieldnames def add_success(self, row: dict) -> None: if self._fieldnames is None: raise RuntimeError('open() must be called before add_success()') self._buffer.append(row) if len(self._buffer) >= self._flush_interval: self._flush() def write_failures(self, errors: list[ErrorRow], skipped: list[SkippedRow]) -> None: if self._fieldnames is None: raise RuntimeError('open() must be called before write_failures()') p = Path(self._success_csv) failures_csv = str(p.with_stem(f'{p.stem}_failures')) write_failures_csv(errors, skipped, failures_csv, self._fieldnames) total = len(errors) + len(skipped) logger.info(f'[CSV] Wrote {total} failures to: {failures_csv}') def close(self) -> None: self._flush() def _flush(self) -> None: if not self._buffer: return write_success_csv( self._buffer, self._success_csv, self._fieldnames, append=self._append, ) logger.info(f'[PROGRESS] Success CSV updated ({len(self._buffer)} new)') self._buffer.clear() self._append = True