"""Row reading pipeline — raw file rows → dicts → typed ContractRows. Layers: FileReader yields list[str] rows from any format (CSV, XLSX, etc.) DictRowIterator uses first row as headers, yields dict[str, str] ContractRowIterator converts dicts to typed ContractRow instances RowReader factory that wires the pipeline from a file path """ import logging import os from collections.abc import Iterable, Iterator from dataclasses import dataclass from typing import Protocol, runtime_checkable from pydantic import ValidationError as PydanticValidationError from infra.readers import CsvFileReader, FileReader from schemas import REQUIRED_COLUMNS, ContractRow logger = logging.getLogger(__name__) @dataclass(frozen=True) class RowValidationError: """A row that failed Pydantic validation during parsing.""" raw_data: dict[str, str] error: str class MissingColumnsError(Exception): """Raised when required CSV columns are absent.""" def __init__(self, missing: list[str]): self.missing = missing super().__init__(f'Missing required CSV columns: {", ".join(missing)}') class DictRowIterator: """Converts raw row lists into dicts using the first row as headers. Consumes the header row eagerly at construction so fieldnames are available immediately. Yields (1-based index, dict) pairs. """ def __init__(self, rows: Iterator[list[str]]): self._rows = rows header = next(rows, None) self._fieldnames: list[str] = [h.strip() for h in header] if header else [] @property def fieldnames(self) -> list[str]: """Column headers. Available immediately after construction.""" return self._fieldnames def __iter__(self) -> Iterator[tuple[int, dict[str, str]]]: for idx, values in enumerate(self._rows, start=1): row = { self._fieldnames[i]: (values[i] if i < len(values) else '') for i in range(len(self._fieldnames)) } yield idx, row class ContractRowIterator: """Converts dict rows into typed ContractRow instances. Yields (1-based index, ContractRow | RowValidationError) pairs. Validation failures are surfaced as RowValidationError rather than silently dropped, so callers can track them in results. Headers are validated eagerly by RowReader.read(), not here. """ def __init__(self, dict_iter: DictRowIterator): self._dict_iter = dict_iter @property def fieldnames(self) -> list[str]: """Column headers from the underlying DictRowIterator.""" return self._dict_iter.fieldnames def __iter__(self) -> Iterator[tuple[int, ContractRow | RowValidationError]]: for idx, raw_row in self._dict_iter: try: yield idx, ContractRow(**raw_row) except PydanticValidationError as e: logger.warning(f'Row {idx}: skipped (validation error: {e})') yield idx, RowValidationError(raw_data=raw_row, error=str(e)) @runtime_checkable class RowIterable(Protocol): """Protocol for an iterable of parsed rows with column metadata.""" @property def fieldnames(self) -> list[str]: ... def __iter__(self) -> Iterator[tuple[int, ContractRow | RowValidationError]]: ... @runtime_checkable class RowReaderProtocol(Protocol): """Protocol for row reader factories.""" def read(self, file_path: str) -> RowIterable: ... class UnsupportedFormatError(Exception): """Raised when the file extension has no registered reader.""" def __init__(self, ext: str, supported: list[str]): self.ext = ext super().__init__( f"Unsupported file format '{ext}'. " f'Supported: {", ".join(sorted(supported))}' ) class RowReader: """Factory that wires FileReader → DictRowIterator → ContractRowIterator. File readers are registered by extension. The default extension is used when the file has no extension or an unrecognized one. Args: readers: Mapping of file extension (e.g. '.csv') to FileReader. default: Default extension to use as fallback. Must be a key in readers if provided. If omitted, defaults to the first key in readers. Raises: ValueError: If default is given but not in readers. """ def __init__( self, readers: dict[str, FileReader], default: str | None = None, ): if default is not None and default not in readers: raise ValueError( f"default '{default}' is not a registered extension. " f'Registered: {", ".join(sorted(readers))}' ) self._readers = readers self._default = ( default if default is not None else (next(iter(readers)) if readers else None) ) def read(self, file_path: str) -> ContractRowIterator: """Create a ContractRowIterator for the given file. Validates headers eagerly — MissingColumnsError is raised here, not during iteration. """ reader = self._reader_for(file_path) raw_rows = reader.read(file_path) dict_iter = DictRowIterator(raw_rows) self._validate_headers(dict_iter.fieldnames) return ContractRowIterator(dict_iter) @staticmethod def _validate_headers(headers: list[str]) -> None: """Check that all required columns are present.""" if not headers: raise MissingColumnsError([col.value for col in REQUIRED_COLUMNS]) header_set = set(headers) missing = [col.value for col in REQUIRED_COLUMNS if col.value not in header_set] if missing: raise MissingColumnsError(missing) def _reader_for(self, file_path: str) -> FileReader: ext = os.path.splitext(file_path)[1].lower() if ext and ext in self._readers: return self._readers[ext] if self._default is not None and self._default in self._readers: return self._readers[self._default] raise UnsupportedFormatError(ext or '(none)', list(self._readers.keys()))