Andrey Epifancev b5a3b480b2 feat(parser): модель листа Sheet и читатели xlsx/xls/xlsb
grid.py: Cell, MergedRange, Sheet (1-based, только непустые ячейки, ленивые
индексы строк и merged; диапазоны больше 10 000 ячеек проверяются перебором),
column_letter для диагностики. errors.py: ParserError и коды контракта.
readers: read_book определяет формат по сигнатуре (zip → XLSX/XLSB по
xl/workbook.bin, OLE2 → XLS), предупреждает о несовпадении с file_type;
ошибки библиотек → UnreadableError одной строкой. Числа — Decimal(str(value)),
bool отдельно, даты как datetime; xlsb без outline/merged с предупреждением.
Тесты: синтетическая книга openpyxl, подмена file_type, мусор/обрезанные
файлы, xlsb через подменённый pyxlsb; 66 реальных ОСВ читаются < 5 с с
outline и merged; xls «62 за 3 квартал 2024»: строка 15 — outline 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 12:11:16 +04:00

94 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Модель листа `Sheet`: merged-диапазоны, порядок ячеек строки, пустота, буквы колонок для диагностики."""
import datetime
from decimal import Decimal
import pytest
from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, column_letter
def _sheet(
cells: dict[tuple[int, int], Cell],
merged: tuple[MergedRange, ...] = (),
row_outline: dict[int, int] | None = None,
) -> Sheet:
n_rows = max((row for row, _ in cells), default=0)
n_cols = max((col for _, col in cells), default=0)
return Sheet(name="ОСВ", cells=cells, merged=merged, row_outline=row_outline or {}, n_rows=n_rows, n_cols=n_cols)
def test_text_at_inside_merged_range_takes_origin_value() -> None:
sheet = _sheet(
{(1, 2): Cell("Сальдо на начало периода", "text"), (2, 2): Cell("Дебет", "text")},
merged=(MergedRange(top=1, left=2, bottom=1, right=3),),
)
assert sheet.text_at(1, 3) == "Сальдо на начало периода"
assert sheet.text_at(1, 2) == "Сальдо на начало периода"
assert sheet.cell(1, 3) is EMPTY_CELL
assert sheet.merged_origin(1, 3) == (1, 2)
assert sheet.merged_span(1, 3) == MergedRange(1, 2, 1, 3)
assert sheet.text_at(2, 3) == ""
assert sheet.merged_origin(2, 3) == (2, 3)
assert sheet.merged_span(2, 3) is None
def test_large_merged_range_is_resolved_without_cell_index() -> None:
# Диапазон на весь столбец: индекс «ячейка → диапазон» для него не строится, поиск всё равно работает
big = MergedRange(top=1, left=1, bottom=1_048_576, right=16_384)
sheet = _sheet({(1, 1): Cell("Шапка", "text")}, merged=(big,))
assert sheet.merged_origin(500_000, 10_000) == (1, 1)
assert sheet.text_at(1_048_576, 16_384) == "Шапка"
def test_row_cells_are_ordered_by_column_and_skip_blank_text() -> None:
sheet = _sheet(
{
(3, 5): Cell(Decimal("10.5"), "number"),
(3, 1): Cell("60.01", "text"),
(3, 3): Cell(" ", "text"),
(4, 1): Cell("62", "text"),
}
)
assert sheet.row_cells(3) == [(1, Cell("60.01", "text")), (5, Cell(Decimal("10.5"), "number"))]
assert sheet.row_cells(7) == []
def test_row_of_spaces_is_empty() -> None:
sheet = _sheet({(2, 1): Cell(" ", "text"), (2, 4): Cell("\xa0 ", "text"), (3, 1): Cell(False, "bool")})
assert sheet.is_row_empty(2)
assert sheet.is_row_empty(10)
assert not sheet.is_row_empty(3)
def test_cell_text_strips_and_treats_spaces_as_empty() -> None:
assert Cell(" ", "text").text == ""
assert Cell(" Контрагент ", "text").text == "Контрагент"
assert Cell(None, "empty").text == ""
assert EMPTY_CELL.text == ""
assert Cell(Decimal("1250.00"), "number").text == "1250.00"
assert Cell(datetime.date(2024, 9, 30), "date").text == "2024-09-30"
def test_outline_defaults_to_zero() -> None:
sheet = _sheet({(1, 1): Cell("62", "text")}, row_outline={2: 1, 3: 2})
assert [sheet.outline(row) for row in (1, 2, 3)] == [0, 1, 2]
@pytest.mark.parametrize(
("index", "letter"),
[(1, "A"), (26, "Z"), (27, "AA"), (52, "AZ"), (703, "AAA"), (16_384, "XFD")],
)
def test_column_letter(index: int, letter: str) -> None:
assert column_letter(index) == letter
def test_column_letter_rejects_non_positive_index() -> None:
with pytest.raises(ValueError, match="1-based"):
column_letter(0)