"""Сборка `Sheet` из текстовой «картинки» для юнит-тестов детекторов, шапки, иерархии и классификации. Лист описывается списком строк сверху вниз (первый элемент — строка 1), строка — списком ячеек слева направо (первый элемент — колонка 1). Так тест читается как сама ведомость: sheet = sheet_from_rows( [ ["ООО Ромашка"], ["Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."], [], [M("Счет", 2), M("Сальдо на начало", 2), M("Обороты за период", 2), M("Сальдо на конец", 2)], [M("Контрагенты", 2), "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), M("60", 2), 100, None, 50, 30, 120], [L(1), M("ООО Поставщик", 2), C("100", bold=True)], ] ) Значения ячеек: - `None` или `""` — пустая ячейка (в `Sheet.cells` не попадает); - `str` — текстовая ячейка как есть, **включая строку из пробелов** (`" "`): так тесты проверяют, что детекторы считают её пустой, даже если читатель её сохранил (Review Focus 5); текст «1 234,00» остаётся текстом; - `int` и `Decimal` — числовая ячейка (`Decimal`); `float` запрещён (деньги — `Decimal`, Global Constraints); - `datetime.date` — дата, `bool` — логическое; - `C(value, bold=..., indent=...)` — ячейка с оформлением (жирность, отступ) для каскада иерархии; - `M(value, width, height=1)` — объединённая ячейка: значение в левой верхней, диапазон `width` колонок вправо и `height` строк вниз. В своей строке `M` занимает `width` позиций — следующий элемент списка идёт в колонку правее диапазона. В нижних строках диапазона на месте покрытых колонок пишите `None` (непустое значение там — ошибка сборки: в Excel под merged-диапазоном значений нет); - `L(level)` — только первым элементом строки: outline level Excel этой строки (0 — без группировки); сам `L` колонку не занимает. Разреженная строка — `at({1: "60", 5: 100, 17: 120})`: значения по номерам колонок, остальное пусто (удобно, когда суммы стоят в далёких колонках). Её можно предварить уровнем: `[L(1), *at({...})]`. `n_rows`/`n_cols` — по длине списка строк и самой длинной строке (с учётом ширины `M`), как габариты листа у читателя. """ import dataclasses import datetime from collections.abc import Sequence from dataclasses import dataclass from decimal import Decimal from src.parser.grid import Cell, MergedRange, Sheet __all__ = ["C", "L", "M", "at", "sheet_from_rows"] DEFAULT_SHEET_NAME = "Лист_1" @dataclass(frozen=True) class C: """Ячейка с оформлением: жирность и отступ (`alignment.indent` или ведущие пробелы у читателя).""" value: object bold: bool = False indent: int = 0 @dataclass(frozen=True) class M: """Объединённая ячейка: значение (или `C`) и размер диапазона в колонках и строках.""" value: object width: int height: int = 1 def __post_init__(self) -> None: if self.width < 1 or self.height < 1: raise ValueError(f"размер merged-диапазона должен быть ≥ 1, получено {self.width}x{self.height}") @dataclass(frozen=True) class L: """Outline level строки (первый элемент строки).""" level: int def at(values: dict[int, object]) -> list[object]: """Строка по номерам колонок (1-based): `at({1: "60", 5: 100})` == `["60", None, None, None, 100]`.""" if not values: return [] if min(values) < 1: raise ValueError("номера колонок 1-based") return [values.get(col) for col in range(1, max(values) + 1)] def _make_cell(value: object) -> Cell | None: bold, indent = False, 0 if isinstance(value, C): value, bold, indent = value.value, value.bold, value.indent if value is None or value == "": return None if isinstance(value, bool): return Cell(value, "bool", bold=bold, indent=indent) if isinstance(value, float): raise TypeError(f"float {value!r} в тестовой сетке: используйте int или Decimal") if isinstance(value, int | Decimal): return Cell(Decimal(value), "number", bold=bold, indent=indent) if isinstance(value, datetime.date): return Cell(value, "date", bold=bold, indent=indent) if isinstance(value, str): return Cell(value, "text", bold=bold, indent=indent) raise TypeError(f"неподдерживаемое значение ячейки {value!r}") def sheet_from_rows(rows: Sequence[Sequence[object]], name: str = DEFAULT_SHEET_NAME) -> Sheet: """`Sheet` по «картинке» строк (см. docstring модуля); номера строк и колонок — 1-based, как в Excel.""" cells: dict[tuple[int, int], Cell] = {} merged: list[MergedRange] = [] covered: set[tuple[int, int]] = set() # ячейки под merged-диапазонами, кроме левой верхней outline: dict[int, int] = {} n_cols = 0 for row_number, row_items in enumerate(rows, start=1): items = list(row_items) if items and isinstance(items[0], L): if items[0].level: outline[row_number] = items[0].level items = items[1:] col = 1 for item in items: if isinstance(item, L): raise ValueError(f"строка {row_number}: L(level) допустим только первым элементом") value, width, height = (item.value, item.width, item.height) if isinstance(item, M) else (item, 1, 1) cell = _make_cell(value) if (row_number, col) in covered: if cell is not None: raise ValueError(f"ячейка ({row_number}, {col}) лежит под merged-диапазоном, значение недопустимо") elif cell is not None: cells[(row_number, col)] = cell if isinstance(item, M) and item.width * item.height > 1: span = MergedRange(row_number, col, row_number + height - 1, col + width - 1) merged.append(span) covered.update( (r, c) for r in range(span.top, span.bottom + 1) for c in range(span.left, span.right + 1) if (r, c) != (span.top, span.left) ) col += width n_cols = max(n_cols, col - 1) return Sheet( name=name, cells=cells, merged=tuple(merged), row_outline=outline, n_rows=len(rows), n_cols=n_cols, ) def with_formats(sheet: Sheet, formats: dict[tuple[int, int], str]) -> Sheet: """Копия листа, где числовым ячейкам (row, col) проставлен числовой формат (#42).""" cells = dict(sheet.cells) for position, number_format in formats.items(): cells[position] = dataclasses.replace(cells[position], number_format=number_format) return dataclasses.replace(sheet, cells=cells)