"""Читатели книг: синтетическая книга openpyxl в памяти → `Sheet`; определение формата по сигнатуре; ошибки чтения.""" import datetime import io import zipfile from collections.abc import Iterator from decimal import Decimal from types import TracebackType from typing import NamedTuple import openpyxl import pytest from openpyxl.styles import Alignment, Font from src.contract import ErrorCode from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError from src.parser.grid import EMPTY_CELL, Cell, MergedRange from src.parser.readers import read_book from src.parser.readers import xlsb as xlsb_reader from src.parser.readers._common import make_cell def _synthetic_xlsx() -> bytes: """Две страницы: шапка с merged-заголовками, outline 0/1/2, bold, отступ, число, дата, bool, пустые строки, ячейки из пробелов и NBSP, merged-диапазон с пробельным origin.""" book = openpyxl.Workbook() osv = book.active assert osv is not None osv.title = "ОСВ" osv["A1"] = "ООО Ромашка" osv["A1"].font = Font(bold=True) osv["A3"] = "Счет" osv.merge_cells("A3:A4") osv["B3"] = "Сальдо на начало периода" osv.merge_cells("B3:C3") osv["B4"] = "Дебет" osv["C4"] = "Кредит" osv["A5"] = "62" osv["A5"].font = Font(bold=True) osv["B5"] = 1500 osv["A6"] = "62.01" osv["A6"].alignment = Alignment(indent=2) osv["B6"] = 1500.25 osv.row_dimensions[6].outline_level = 1 osv["A7"] = " Контрагент" osv["B7"] = datetime.datetime(2024, 9, 30) osv.row_dimensions[7].outline_level = 2 osv["A9"] = " " osv["B9"] = "\xa0" osv["D3"] = " " osv.merge_cells("D3:E3") osv["A10"] = "Итого" osv["C10"] = True other = book.create_sheet("Прочее") other["A1"] = "x" buffer = io.BytesIO() book.save(buffer) return buffer.getvalue() def test_xlsx_reader_builds_sheet_model() -> None: sheets, warnings = read_book(_synthetic_xlsx(), "XLSX") assert warnings == [] assert [sheet.name for sheet in sheets] == ["ОСВ", "Прочее"] osv = sheets[0] assert osv.cell(1, 1) == Cell("ООО Ромашка", "text", bold=True) assert osv.cell(5, 1) == Cell("62", "text", bold=True) assert osv.cell(5, 2) == Cell(Decimal("1500"), "number") assert osv.cell(6, 1) == Cell("62.01", "text", indent=2) assert osv.cell(6, 2) == Cell(Decimal("1500.25"), "number") assert osv.cell(7, 1) == Cell(" Контрагент", "text", indent=3) assert osv.cell(7, 2) == Cell(datetime.datetime(2024, 9, 30), "date") assert osv.cell(10, 3) == Cell(True, "bool") assert osv.cell(9, 1) is EMPTY_CELL assert osv.cell(9, 2) is EMPTY_CELL assert (9, 1) not in osv.cells assert (9, 2) not in osv.cells assert osv.is_row_empty(8) assert osv.is_row_empty(9) assert osv.text_at(3, 5) == "" assert osv.merged_origin(3, 5) == (3, 4) assert set(osv.merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3), MergedRange(3, 4, 3, 5)} assert osv.text_at(4, 1) == "Счет" assert osv.text_at(3, 3) == "Сальдо на начало периода" assert [osv.outline(row) for row in range(1, 11)] == [0, 0, 0, 0, 0, 1, 2, 0, 0, 0] assert (osv.n_rows, osv.n_cols) == (10, 5) assert all(cell.kind != "empty" for cell in osv.cells.values()) assert sheets[1].row_cells(1) == [(1, Cell("x", "text"))] def test_file_type_mismatch_reads_by_signature_with_warning() -> None: sheets, warnings = read_book(_synthetic_xlsx(), "XLS") assert [sheet.name for sheet in sheets] == ["ОСВ", "Прочее"] assert warnings == ["file_type=XLS, но содержимое OOXML: прочитано как XLSX"] def test_garbage_bytes_are_unsupported_format() -> None: with pytest.raises(UnsupportedFormatError) as caught: read_book(b"%PDF-1.7 not a workbook", "XLSX") assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT assert isinstance(caught.value, ParserError) def test_truncated_zip_is_unreadable_with_one_line_reason() -> None: data = _synthetic_xlsx() with pytest.raises(UnreadableError) as caught: read_book(data[: len(data) // 2], "XLSX") assert caught.value.code is ErrorCode.UNREADABLE assert caught.value.message assert "\n" not in caught.value.message def test_truncated_biff_is_unreadable() -> None: with pytest.raises(UnreadableError): read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS") def _zip_with(name: str) -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: archive.writestr(name, b"\x00") return buffer.getvalue() class _XlsbCell(NamedTuple): r: int c: int v: object class _FakeXlsbSheet: def __init__(self, rows: list[list[_XlsbCell]]) -> None: self._rows = rows def __enter__(self) -> "_FakeXlsbSheet": return self def __exit__(self, *exc: object) -> None: return None def rows(self, sparse: bool = False) -> Iterator[list[_XlsbCell]]: assert sparse yield from self._rows class _FakeXlsbBook: sheets = ("Лист1",) def __enter__(self) -> "_FakeXlsbBook": return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None ) -> None: return None def get_sheet(self, name: str) -> _FakeXlsbSheet: assert name == "Лист1" return _FakeXlsbSheet( [ [_XlsbCell(0, 0, "Счет"), _XlsbCell(0, 1, None), _XlsbCell(0, 2, " ")], [_XlsbCell(2, 0, "62.01"), _XlsbCell(2, 1, 0.1), _XlsbCell(2, 2, False)], ] ) def test_xlsb_signature_is_read_by_pyxlsb_without_outline(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(xlsb_reader.pyxlsb, "open_workbook", lambda _stream: _FakeXlsbBook()) sheets, warnings = read_book(_zip_with("xl/workbook.bin"), "XLSB") assert warnings == ["xlsb: outline level недоступен"] (sheet,) = sheets assert sheet.name == "Лист1" assert sheet.cell(1, 1) == Cell("Счет", "text") assert sheet.cell(1, 3) is EMPTY_CELL assert (1, 3) not in sheet.cells assert sheet.cell(3, 2) == Cell(Decimal("0.1"), "number") assert sheet.cell(3, 3) == Cell(False, "bool") assert sheet.merged == () assert sheet.row_outline == {} assert (sheet.n_rows, sheet.n_cols) == (3, 3) def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None: with pytest.raises(UnreadableError): read_book(_zip_with("hello.txt"), "XLSX") @pytest.mark.parametrize( ("value", "expected_text"), [ (2370012036.0, "2370012036"), # xls хранит число как float: «.0» — артефакт чтения (OQ-162, #32) (501604329883.0, "501604329883"), (-15.0, "-15"), (1234.56, "1234.56"), (7706808138, "7706808138"), ], ) def test_integral_float_is_read_without_fraction(value: float, expected_text: str) -> None: cell = make_cell(value) assert cell is not None assert (cell.kind, cell.text) == ("number", expected_text) assert cell.value == Decimal(str(value))