Andrey Epifancev c6a768dc70 fix(parser): ячейки из одних пробелов не хранятся в Sheet.cells (T2.1)
make_cell возвращает None для строки, у которой strip() пуст (пробелы, NBSP и
прочие Unicode-пробелы): «пробелы = пусто» на уровне хранения, sheet.cell даёт
EMPTY_CELL. Тесты: пробельные ячейки xlsx/xlsb отсутствуют в cells, строка из
пробелов пуста, merged-диапазон с пробельным origin даёт "".

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

192 lines
6.7 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.

"""Читатели книг: синтетическая книга 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
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")