from __future__ import annotations from collections.abc import Iterable, Iterator from dataclasses import dataclass from .parsers import CSVParser def _unescape_dumpling_csv_char(char: str) -> str: if char == "0": return "\0" if char == "r": return "\r" if char == "n": return "\n" return char _SENTINEL = "\ue000" _ESCAPED_SENTINEL = f"{_SENTINEL}s" _ESCAPED_NEWLINE = f"{_SENTINEL}n" _ESCAPED_CARRIAGE_RETURN = f"{_SENTINEL}r" _ESCAPED_NUL = f"{_SENTINEL}0" def _append_adapted_char(parts: list[str], char: str) -> None: if char == _SENTINEL: parts.append(_ESCAPED_SENTINEL) elif char == "\n": parts.append(_ESCAPED_NEWLINE) elif char == "\r": parts.append(_ESCAPED_CARRIAGE_RETURN) elif char == "\0": parts.append(_ESCAPED_NUL) else: parts.append(char) def _encode_sentinels(value: str) -> str: return value.replace(_SENTINEL, _ESCAPED_SENTINEL) def _adapt_line(line: str) -> str: if not line: return '""' if "\\" not in line or '"' not in line: return _encode_sentinels(line) if _SENTINEL in line else line if _SENTINEL not in line and _line_has_no_quoted_escape(line): return line parts: list[str] = [] field_empty = True in_quotes = False quoted_field = False i = 0 length = len(line) while i < length: char = line[i] if in_quotes: if char == "\\": i += 1 if i >= length: parts.append("\\") break escaped = _unescape_dumpling_csv_char(line[i]) if escaped == '"': parts.append('""') else: _append_adapted_char(parts, escaped) field_empty = False elif char == '"': if i + 1 < length and line[i + 1] == '"': parts.append('""') i += 1 field_empty = False else: parts.append('"') in_quotes = False else: _append_adapted_char(parts, char) field_empty = False else: if char == ",": parts.append(char) field_empty = True quoted_field = False elif char == '"' and field_empty and not quoted_field: parts.append(char) in_quotes = True quoted_field = True else: _append_adapted_char(parts, char) field_empty = False i += 1 if in_quotes: raise ValueError("CSV row has an unclosed quoted field") return "".join(parts) def _line_has_no_quoted_escape(line: str) -> bool: in_quotes = False quoted_field = False field_empty = True i = 0 length = len(line) while i < length: char = line[i] if in_quotes: if char == "\\": return False if char == '"': if i + 1 < length and line[i + 1] == '"': i += 1 field_empty = False else: in_quotes = False else: field_empty = False else: if char == ",": field_empty = True quoted_field = False elif char == '"' and field_empty and not quoted_field: in_quotes = True quoted_field = True else: field_empty = False i += 1 if in_quotes: raise ValueError("CSV row has an unclosed quoted field") return True def _restore_value(value: str) -> str: if _SENTINEL not in value: return value parts: list[str] = [] i = 0 length = len(value) while i < length: char = value[i] if char != _SENTINEL or i + 1 >= length: parts.append(char) i += 1 continue code = value[i + 1] if code == "s": parts.append(_SENTINEL) i += 2 elif code == "n": parts.append("\n") i += 2 elif code == "r": parts.append("\r") i += 2 elif code == "0": parts.append("\0") i += 2 else: parts.append(char) i += 1 return "".join(parts) def _restore_row(row: list[str]) -> list[str]: return [_restore_value(value) for value in row] def _strip_line_ending(line: str) -> str: if line.endswith("\n"): line = line[:-1] if line.endswith("\r"): line = line[:-1] return line def _adapt_lines(handle: Iterable[str]) -> Iterator[str]: for line in handle: yield _adapt_line(_strip_line_ending(line)) @dataclass(frozen=True) class DumplingBackslashCSVParser: parser: CSVParser def iter_rows(self, handle: Iterable[str]) -> Iterator[list[str]]: try: for row in self.parser.iter_rows(_adapt_lines(handle)): yield _restore_row(row) except ValueError as exc: raise self.parser.error_type(f"{self.parser.context} CSV row has an unclosed quoted field") from exc __all__ = ["DumplingBackslashCSVParser"]