readers: числовой формат числовых ячеек в Cell (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 17:07:15 +04:00
parent 3eae1dcf70
commit 0c65e92ffb
6 changed files with 97 additions and 4 deletions

View File

@ -30,6 +30,9 @@ class Cell:
kind: CellKind kind: CellKind
bold: bool = False bold: bool = False
indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста
# Числовой формат ячейки: только у числовых ячеек с форматом не General (xlsx, xls; xlsb формат не отдаёт).
# Используется только разбором ББ/ОФР; ОСВ его игнорирует
number_format: str | None = None
@property @property
def text(self) -> str: def text(self) -> str:

View File

@ -15,13 +15,14 @@ def leading_spaces(text: str) -> int:
return len(text) - len(stripped) if stripped else 0 return len(text) - len(stripped) if stripped else 0
def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | None: def make_cell(value: object, *, bold: bool = False, indent: int = 0, number_format: str | None = None) -> Cell | None:
"""Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает). """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает).
Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»); Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»);
bool проверяется раньше int. bool проверяется раньше int.
Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне
хранения, а не только в аксессорах `Sheet`. Время без даты — текстом. хранения, а не только в аксессорах `Sheet`. Время без даты — текстом.
`number_format` сохраняется только у числовых ячеек и только если он непустой и не General.
""" """
if value is None or value == "": if value is None or value == "":
return None return None
@ -31,7 +32,8 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N
# xls хранит любое число как float: «2370012036.0» — артефакт чтения, в файле целое (OQ-162) # xls хранит любое число как float: «2370012036.0» — артефакт чтения, в файле целое (OQ-162)
value = int(value) value = int(value)
if isinstance(value, int | float): if isinstance(value, int | float):
return Cell(Decimal(str(value)), "number", bold=bold, indent=indent) shown = number_format if number_format and number_format.strip().lower() != "general" else None
return Cell(Decimal(str(value)), "number", bold=bold, indent=indent, number_format=shown)
if isinstance(value, datetime.date): # datetime.datetime — подкласс date if isinstance(value, datetime.date): # datetime.datetime — подкласс date
return Cell(value, "date", bold=bold, indent=indent) return Cell(value, "date", bold=bold, indent=indent)
text = value if isinstance(value, str) else str(value) text = value if isinstance(value, str) else str(value)

View File

@ -34,6 +34,7 @@ def _sheet(book: Any, sheet: Any) -> Sheet:
value, value,
bold=bool(book.font_list[xf.font_index].bold), bold=bool(book.font_list[xf.font_index].bold),
indent=int(xf.alignment.indent_level or 0), indent=int(xf.alignment.indent_level or 0),
number_format=_number_format(book, xf),
) )
if cell is not None: if cell is not None:
cells[(rowx + 1, colx + 1)] = cell cells[(rowx + 1, colx + 1)] = cell
@ -66,3 +67,9 @@ def _value(book: Any, ctype: int, raw: Any) -> object:
except (xlrd.xldate.XLDateError, ValueError, OverflowError): except (xlrd.xldate.XLDateError, ValueError, OverflowError):
return raw # серийный номер вне диапазона дат — остаётся числом, как в файле return raw # серийный номер вне диапазона дат — остаётся числом, как в файле
return raw return raw
def _number_format(book: Any, xf: Any) -> str | None:
"""Строка числового формата XF; неизвестный ключ формата — None (как General)."""
fmt = book.format_map.get(xf.format_key)
return str(fmt.format_str) if fmt is not None else None

View File

@ -1,4 +1,5 @@
"""Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged и шрифт библиотека не отдаёт.""" """Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged, шрифт и
числовой формат (Cell.number_format) библиотека не отдаёт."""
import io import io
from typing import Any from typing import Any

View File

@ -24,7 +24,12 @@ def _sheet(ws: Any) -> Sheet:
# `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь # `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь
# прямоугольник листа # прямоугольник листа
for source in ws._cells.values(): for source in ws._cells.values():
cell = make_cell(source.value, bold=bool(source.font.bold), indent=int(source.alignment.indent or 0)) cell = make_cell(
source.value,
bold=bool(source.font.bold),
indent=int(source.alignment.indent or 0),
number_format=source.number_format,
)
if cell is not None: if cell is not None:
cells[(source.row, source.column)] = cell cells[(source.row, source.column)] = cell
merged = tuple( merged = tuple(

View File

@ -0,0 +1,75 @@
"""Числовой формат числовых ячеек в `Cell.number_format` (#42): xlsx синтетикой, xls — реальным файлом из fixtures."""
import datetime
import io
from pathlib import Path
import openpyxl
import pytest
from src.parser.readers import read_book
from src.parser.readers import xlsb as xlsb_reader
from src.parser.readers._common import make_cell
from tests.unit.parser.test_readers import _FakeXlsbBook, _zip_with
FILLED_CELLS = 6
UNAGRANDE_XLS = "statements-xlsx/unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls"
def _xlsx() -> bytes:
book = openpyxl.Workbook()
ws = book.active
assert ws is not None
ws["A1"] = 10741981390
ws["A1"].number_format = "#,##0,"
ws["A2"] = 586260.26
ws["A2"].number_format = "0,"
ws["A3"] = 5
ws["A4"] = 7
ws["A4"].number_format = "General"
ws["A5"] = "текст"
ws["A5"].number_format = "0,"
ws["A6"] = datetime.datetime(2025, 9, 30)
ws["A6"].number_format = "dd.mm.yyyy"
out = io.BytesIO()
book.save(out)
return out.getvalue()
def test_xlsx_number_format_is_kept_for_numbers_only() -> None:
sheets, _ = read_book(_xlsx(), "XLSX")
sheet = sheets[0]
assert sheet.cell(1, 1).number_format == "#,##0,"
assert sheet.cell(2, 1).number_format == "0,"
assert sheet.cell(3, 1).number_format is None # General по умолчанию
assert sheet.cell(4, 1).number_format is None # явный General
assert sheet.cell(5, 1).number_format is None # текст
assert sheet.cell(6, 1).kind == "date"
assert sheet.cell(6, 1).number_format is None # дата
assert len(sheet.cells) == FILLED_CELLS # разреженность: Cell только у непустых
def test_make_cell_number_format_rules() -> None:
assert make_cell(1.5, number_format="GENERAL").number_format is None # type: ignore[union-attr]
assert make_cell(1.5, number_format="").number_format is None # type: ignore[union-attr]
assert make_cell(1.5, number_format="0,").number_format == "0," # type: ignore[union-attr]
assert make_cell("x", number_format="0,").number_format is None # type: ignore[union-attr]
assert make_cell(datetime.datetime(2025, 1, 1), number_format="0,").number_format is None # type: ignore[union-attr]
@pytest.mark.fixtures
def test_xls_number_format_is_read(fixtures_dir: Path) -> None:
sheets, _ = read_book((fixtures_dir / UNAGRANDE_XLS).read_bytes(), "XLS")
formats = {cell.number_format for sheet in sheets for cell in sheet.cells.values() if cell.kind == "number"}
assert {"#,##0,", "0,", '[=0]"-"'} <= formats
assert all(cell.number_format is None for sheet in sheets for cell in sheet.cells.values() if cell.kind != "number")
def test_xlsb_cells_have_no_number_format(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(xlsb_reader.pyxlsb, "open_workbook", lambda _stream: _FakeXlsbBook())
sheets, _ = read_book(_zip_with("xl/workbook.bin"), "XLSB")
assert all(cell.number_format is None for cell in sheets[0].cells.values())