"""Lecture d'un fichier importé (CSV ou XLSX) vers une table normalisée.""" import csv import io from dataclasses import dataclass from openpyxl import load_workbook _ENCODINGS = ("utf-8-sig", "utf-8", "cp1252", "latin-1") _DELIMITERS = (";", ",", "\t") _XLSX_MAGIC = b"PK\x03\x04" class ImportParseError(Exception): """Fichier illisible. `code` est une clé de traduction.""" def __init__(self, code: str, **params: object) -> None: super().__init__(code) self.code = code self.params = params @dataclass class ParsedTable: headers: list[str] rows: list[list[str]] def as_dicts(self) -> list[dict[str, str]]: return [ {h: v for h, v in zip(self.headers, row, strict=False)} for row in self.rows ] def parse_upload(filename: str, content: bytes) -> ParsedTable: if not content: raise ImportParseError("import.error.empty_file") name = (filename or "").lower() if content[:4] == _XLSX_MAGIC or name.endswith((".xlsx", ".xlsm")): return _parse_xlsx(content) return _parse_csv(content) def _decode(content: bytes) -> str: for enc in _ENCODINGS: try: return content.decode(enc) except UnicodeDecodeError: continue raise ImportParseError("import.error.encoding") def _sniff_delimiter(sample: str) -> str: lines = [ln for ln in sample.splitlines() if ln.strip()] header = lines[0] if lines else "" counts = {d: header.count(d) for d in _DELIMITERS} best = max(counts, key=lambda d: counts[d]) return best if counts[best] else "," def _fit(row: list[str], width: int) -> list[str]: if len(row) < width: return row + [""] * (width - len(row)) return row[:width] def _parse_csv(content: bytes) -> ParsedTable: text = _decode(content) delimiter = _sniff_delimiter(text[:4096]) reader = csv.reader(io.StringIO(text), delimiter=delimiter) rows = [[(cell or "").strip() for cell in row] for row in reader] rows = [row for row in rows if any(row)] if not rows: raise ImportParseError("import.error.empty_file") headers = rows[0] width = len(headers) return ParsedTable(headers=headers, rows=[_fit(r, width) for r in rows[1:]]) def _cell_to_str(value: object) -> str: if value is None: return "" if isinstance(value, str): return value.strip() if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value).strip() def _parse_xlsx(content: bytes) -> ParsedTable: try: workbook = load_workbook( io.BytesIO(content), read_only=True, data_only=True ) except Exception as exc: raise ImportParseError("import.error.unreadable_file") from exc worksheet = workbook.active if worksheet is None: workbook.close() raise ImportParseError("import.error.empty_file") rows = [ [_cell_to_str(cell) for cell in row] for row in worksheet.iter_rows(values_only=True) ] workbook.close() rows = [row for row in rows if any(row)] if not rows: raise ImportParseError("import.error.empty_file") headers = rows[0] width = len(headers) return ParsedTable(headers=headers, rows=[_fit(r, width) for r in rows[1:]])