readers: числовой формат числовых ячеек в Cell (#42)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3eae1dcf70
commit
0c65e92ffb
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
75
api/tests/unit/parser/test_reader_number_format.py
Normal file
75
api/tests/unit/parser/test_reader_number_format.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user