| """Small string-preserving table primitives for DataForge core paths. |
| |
| The CLI hot path should not need pandas just to profile or repair a CSV. |
| This module provides the narrow DataFrame-like surface that DataForge's |
| detectors, repairers, and verifier actually need. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import csv |
| import io |
| from collections.abc import Iterable, Iterator, Sequence |
| from pathlib import Path |
| from typing import Any, Protocol, cast, overload |
|
|
|
|
| class TableLike(Protocol): |
| """Protocol for the tabular surface consumed by DataForge core logic.""" |
|
|
| @property |
| def columns(self) -> Any: ... |
|
|
| @property |
| def index(self) -> Any: ... |
|
|
| @property |
| def at(self) -> Any: ... |
|
|
| def __getitem__(self, key: str) -> Any: ... |
|
|
| def copy(self, deep: bool = True) -> Any: ... |
|
|
| def to_csv( |
| self, |
| buffer: io.StringIO, |
| *, |
| index: bool = False, |
| lineterminator: str = "\n", |
| ) -> None: ... |
|
|
|
|
| class ColumnView(Sequence[str]): |
| """Read-only column view with the small API repairers expect.""" |
|
|
| def __init__(self, values: Sequence[str]) -> None: |
| self._values = values |
|
|
| def __iter__(self) -> Iterator[str]: |
| return iter(self._values) |
|
|
| def __len__(self) -> int: |
| return len(self._values) |
|
|
| @overload |
| def __getitem__(self, index: int) -> str: ... |
|
|
| @overload |
| def __getitem__(self, index: slice) -> Sequence[str]: ... |
|
|
| def __getitem__(self, index: int | slice) -> str | Sequence[str]: |
| return self._values[index] |
|
|
| def tolist(self) -> list[str]: |
| """Return a list copy, matching pandas Series enough for detectors.""" |
| return list(self._values) |
|
|
|
|
| class _AtIndexer: |
| """``table.at[row, column]`` getter/setter compatibility shim.""" |
|
|
| def __init__(self, table: Table) -> None: |
| self._table = table |
|
|
| def __getitem__(self, key: tuple[int, str]) -> str: |
| row, column = key |
| return self._table.cell(row, column) |
|
|
| def __setitem__(self, key: tuple[int, str], value: object) -> None: |
| row, column = key |
| self._table.set_cell(row, column, value) |
|
|
|
|
| class Table: |
| """In-memory CSV table with string-preserving cells.""" |
|
|
| def __init__(self, columns: Sequence[str], rows: Iterable[dict[str, object]]) -> None: |
| self._columns = [str(column) for column in columns] |
| self._rows: list[dict[str, str]] = [ |
| { |
| column: "" if row.get(column) is None else str(row.get(column, "")) |
| for column in self._columns |
| } |
| for row in rows |
| ] |
| self.at = _AtIndexer(self) |
|
|
| @property |
| def columns(self) -> list[str]: |
| """Return column names in CSV order.""" |
| return list(self._columns) |
|
|
| @property |
| def index(self) -> range: |
| """Return zero-based row positions.""" |
| return range(len(self._rows)) |
|
|
| @property |
| def empty(self) -> bool: |
| """Return whether the table has no rows.""" |
| return not self._rows |
|
|
| @overload |
| def __getitem__(self, key: str) -> ColumnView: ... |
|
|
| @overload |
| def __getitem__(self, key: list[str]) -> Table: ... |
|
|
| @overload |
| def __getitem__(self, key: tuple[str, ...]) -> Table: ... |
|
|
| def __getitem__(self, key: str | list[str] | tuple[str, ...]) -> ColumnView | Table: |
| if isinstance(key, str): |
| if key not in self._columns: |
| raise KeyError(key) |
| return ColumnView([row.get(key, "") for row in self._rows]) |
| columns = [str(column) for column in key] |
| for column in columns: |
| if column not in self._columns: |
| raise KeyError(column) |
| return Table( |
| columns, ({column: row.get(column, "") for column in columns} for row in self._rows) |
| ) |
|
|
| def __len__(self) -> int: |
| return len(self._rows) |
|
|
| def copy(self, deep: bool = True) -> Table: |
| """Return an independent table copy.""" |
| del deep |
| return Table(self._columns, (dict(row) for row in self._rows)) |
|
|
| def cell(self, row: int, column: str) -> str: |
| """Return a cell value.""" |
| if column not in self._columns: |
| raise KeyError(column) |
| return self._rows[row].get(column, "") |
|
|
| def set_cell(self, row: int, column: str, value: object) -> None: |
| """Set a cell value after validating the column.""" |
| if column not in self._columns: |
| raise KeyError(column) |
| self._rows[row][column] = "" if value is None else str(value) |
|
|
| def iter_records(self, columns: Sequence[str] | None = None) -> Iterator[dict[str, str]]: |
| """Yield row dictionaries in table order.""" |
| selected = self._columns if columns is None else [str(column) for column in columns] |
| for row in self._rows: |
| yield {column: row.get(column, "") for column in selected} |
|
|
| def to_dict(self, orient: str = "records") -> list[dict[str, str]]: |
| """Return records in the pandas-compatible orientation used by DataForge.""" |
| if orient != "records": |
| raise ValueError("Only orient='records' is supported.") |
| return list(self.iter_records()) |
|
|
| def to_csv( |
| self, buffer: io.StringIO, *, index: bool = False, lineterminator: str = "\n" |
| ) -> None: |
| """Write the table as CSV to a text buffer.""" |
| if index: |
| raise ValueError("Table.to_csv does not support index=True.") |
| writer = csv.DictWriter(buffer, fieldnames=self._columns, lineterminator=lineterminator) |
| writer.writeheader() |
| for row in self._rows: |
| writer.writerow({column: row.get(column, "") for column in self._columns}) |
|
|
|
|
| def read_csv(path: Path) -> Table: |
| """Read a CSV as a string-preserving ``Table``.""" |
| with path.open("r", encoding="utf-8-sig", newline="") as handle: |
| reader = csv.DictReader(handle) |
| columns = list(reader.fieldnames or []) |
| return Table(columns, reader) |
|
|
|
|
| def table_to_csv_bytes(table: TableLike) -> bytes: |
| """Serialize a table-like object to UTF-8 CSV bytes.""" |
| output = io.StringIO() |
| if isinstance(table, Table): |
| table.to_csv(output, index=False, lineterminator="\n") |
| else: |
| |
| table.to_csv(output, index=False, lineterminator="\n") |
| return output.getvalue().encode("utf-8") |
|
|
|
|
| def column_names(table: TableLike) -> list[str]: |
| """Return table column names as strings.""" |
| return [str(column) for column in table.columns] |
|
|
|
|
| def row_count(table: TableLike) -> int: |
| """Return the number of rows in a table-like object.""" |
| return len(table.index) |
|
|
|
|
| def column_values(table: TableLike, column: str) -> list[Any]: |
| """Return all values for one column.""" |
| values = table[column] |
| if hasattr(values, "tolist"): |
| return list(values.tolist()) |
| return list(values) |
|
|
|
|
| def cell_value(table: TableLike, row: int, column: str) -> str: |
| """Return a cell value as a string.""" |
| return str(table.at[row, column]) |
|
|
|
|
| def set_cell_value(table: TableLike, row: int, column: str, value: object) -> None: |
| """Set a cell value on a table-like object.""" |
| table.at[row, column] = value |
|
|
|
|
| def copy_table(table: TableLike) -> TableLike: |
| """Return a deep copy of a table-like object.""" |
| copied = table.copy(deep=True) |
| return cast(TableLike, copied) |
|
|