fix: финальное ревью — лимиты чтения xlsx (I-1), лог traceback (I-2), ACCOUNT_RE (M-1), ИНН/КПП (M-3)
- I-1 (R-23): readers/_limits.py — сумма ZipInfo.file_size ≤ 10 × PARSE_MAX_BYTES (параметр read_book от OsvParser), потоковый скан <mergeCell ref> до openpyxl и площадь merged после чтения любого формата (MERGED_AREA_LIMIT = 200 000; реальный максимум 10 090) → UNSUPPORTED_FORMAT; дедлайн в циклах detect (поиск строки групп, дополнительные колонки); README «Лимиты» исправлен, «Известные ограничения» (defusedxml). - I-2: OsvParser логирует стек непредвиденной ошибки (loguru ERROR), UNREADABLE — WARNING без стека. - M-1: ACCOUNT_RE с re.ASCII | re.DOTALL (многострочное название после запятой, только цифры 0–9). - M-3 (R-24): правило детектора ИНН не дополняет 9/11 цифр нулём; дополнение — только при извлечении (numbers.inn_digits) из колонки, выбранной по заголовку или по строгому правилу. - T9.1: тесты R-21 (свёрнутый родитель на противоположной стороне; (0,0) при детях (X, X)). - T1.1: хелперы фикстур пропускают скрытые файлы/каталоги и lock-файлы Excel (~$…). - T12.3: golden-update не удаляет снимки части без исходного каталога (osv/synthetic или реальные файлы). Golden-снимки не изменились (82/82). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
9526a1266b
commit
9b8b8ea727
@ -79,7 +79,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
||||
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
|
||||
|
||||
**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
|
||||
кооперативный, → `TIMEOUT`) — `core/config.py`.
|
||||
кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют
|
||||
лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23).
|
||||
|
||||
**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы
|
||||
в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`).
|
||||
|
||||
24
README.md
24
README.md
@ -113,7 +113,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
||||
| Модуль | Что делает |
|
||||
|---|---|
|
||||
| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
|
||||
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение |
|
||||
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») |
|
||||
| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
|
||||
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
|
||||
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
|
||||
@ -121,7 +121,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
||||
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
||||
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
||||
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень |
|
||||
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение → лист за листом → статус и метрики |
|
||||
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
|
||||
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
|
||||
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
|
||||
| `cli.py` | разбор одного файла из командной строки (см. «Запуск на файле») |
|
||||
@ -141,10 +141,10 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
||||
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
|
||||
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
|
||||
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
|
||||
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF) |
|
||||
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB либо размер больше `PARSE_MAX_BYTES` |
|
||||
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
|
||||
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
|
||||
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
|
||||
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение` |
|
||||
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
|
||||
|
||||
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
||||
(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
|
||||
@ -185,10 +185,22 @@ groups:
|
||||
| Настройка | По умолчанию | Что делает |
|
||||
|---|---|---|
|
||||
| `PARSE_MAX_BYTES` | 50 МиБ | файл больше → `UNSUPPORTED_FORMAT`, книга не открывается |
|
||||
| `PARSE_TIMEOUT_SECONDS` | 120 | кооперативный таймаут между листами, стратегиями иерархии и блоками строк → `TIMEOUT`; зависание внутри библиотеки чтения он не прерывает — там работает лимит размера |
|
||||
| `PARSE_TIMEOUT_SECONDS` | 120 | кооперативный таймаут: проверяется между листами, в циклах по всему листу (поиск заголовка таблицы, дополнительные колонки, строки таблицы, стратегии иерархии) → `TIMEOUT`. Чтение книги библиотекой (openpyxl/xlrd/pyxlsb) он **не** прерывает |
|
||||
|
||||
Фазу чтения таймаут не ограничивает, а лимит размера файла не ограничивает распакованный объём, поэтому до вызова
|
||||
библиотеки чтения действуют лимиты чтения (`api/src/parser/readers/_limits.py`, ruling R-23) → `UNSUPPORTED_FORMAT`:
|
||||
|
||||
| Константа | Значение | Что ограничивает |
|
||||
|---|---|---|
|
||||
| `ZIP_UNCOMPRESSED_LIMIT_FACTOR` | 10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × `PARSE_MAX_BYTES` (реальные книги распаковываются в 1,4–7,7 раза) |
|
||||
| `MERGED_AREA_LIMIT` | 200 000 ячеек | суммарная площадь объединённых ячеек книги: xlsx — потоковым сканом `<mergeCell ref>` до openpyxl, любой формат — после чтения, до индекса merged (реальный максимум 10 090) |
|
||||
|
||||
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
|
||||
|
||||
**Известные ограничения.** Чтение не защищено от XML-бомб (entity expansion в частях xlsx): `defusedxml` не подключён —
|
||||
новая зависимость требует решения платформы (OQ в `../docs/context/07-open-questions.md`). Других лимитов памяти фазы
|
||||
чтения, кроме перечисленных, нет: книга в пределах лимитов читается библиотекой целиком.
|
||||
|
||||
## Тесты на реальных данных и golden-снимки
|
||||
|
||||
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
|
||||
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
|
||||
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
|
||||
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором).
|
||||
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для
|
||||
которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются.
|
||||
|
||||
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
|
||||
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
|
||||
@ -50,11 +51,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
mode = "режим --check, ничего не записано" if options.check else "снимки записаны"
|
||||
print(
|
||||
f"файлов: {len(files)}; без изменений: {report.unchanged}; новых: {len(report.created)}; "
|
||||
f"изменено: {len(report.changed)}; лишних снимков: {len(report.orphans)} ({mode})",
|
||||
f"изменено: {len(report.changed)}; лишних снимков: {len(report.orphans)}; сохранено без исходного каталога: "
|
||||
f"{len(report.protected)} ({mode})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for orphan in report.orphans:
|
||||
print(f"лишний снимок{'' if options.check else ' удалён'}: {orphan}", file=sys.stderr)
|
||||
for kept in report.protected:
|
||||
print(f"снимок сохранён (нет исходного каталога): {kept}", file=sys.stderr)
|
||||
return EXIT_DIFFERS if options.check and report.differs else EXIT_OK
|
||||
|
||||
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
нераспознанные колонки таблицы (`extra`).
|
||||
|
||||
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним. Буквенные сегменты субсчёта (R-16) оставляют известный
|
||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
||||
многострочное, M-1). Буквенные сегменты субсчёта (R-16) оставляют известный
|
||||
остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт.
|
||||
"""
|
||||
|
||||
@ -42,8 +43,11 @@ __all__ = [
|
||||
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
||||
# 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается:
|
||||
# «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен,
|
||||
# R-18). Группа 1 — код как в файле (`account_code_raw`)
|
||||
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$")
|
||||
# R-18). Группа 1 — код как в файле (`account_code_raw`), группа 2 — название.
|
||||
# Флаги (M-1): re.ASCII — `\d` только 0–9 (арабско-индийские «٦٠» и полноширинные цифры — не код счёта; явные диапазоны
|
||||
# `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки
|
||||
# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
|
||||
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
|
||||
_SEGMENT_SEPARATOR = "."
|
||||
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
|
||||
DEADLINE_CHECK_EVERY_ROWS = 500
|
||||
|
||||
@ -14,11 +14,14 @@ from enum import StrEnum
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.errors import InvalidFormatError
|
||||
from src.parser.grid import Cell, Sheet, column_letter
|
||||
from src.parser.rules import Pattern, Rules, SumGroup, normalize_text
|
||||
|
||||
__all__ = [
|
||||
"DEADLINE_CHECK_EVERY_ROWS",
|
||||
"INN_LENGTHS",
|
||||
"ColumnMap",
|
||||
"SheetKind",
|
||||
"TableHeader",
|
||||
@ -45,8 +48,6 @@ INN_SHARE_PERCENT = 80
|
||||
PERCENT = 100
|
||||
INN_MIN_VALUES = 3
|
||||
INN_LENGTHS = frozenset({10, 12})
|
||||
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева
|
||||
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
|
||||
# Строка групп сумм обязана содержать «обороты» и «сальдо на конец»; «сальдо на начало» может отсутствовать
|
||||
# (spec «Допустимы: отсутствие группы «начало»», ruling R-12)
|
||||
REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
|
||||
@ -54,6 +55,8 @@ REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
|
||||
MIN_GROUPS = 2
|
||||
# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
|
||||
_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
|
||||
# Кооперативный дедлайн (design D-8, R-23): проверка каждые N строк в циклах по всему листу
|
||||
DEADLINE_CHECK_EVERY_ROWS = 500
|
||||
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
|
||||
_NORMALIZE_CACHE_SIZE = 16_384
|
||||
|
||||
@ -207,14 +210,21 @@ def _missing_groups_message(sheet: Sheet, found: set[SumGroup]) -> str:
|
||||
return f"лист «{sheet.name}»: не найден заголовок таблицы ОСВ — нет групп сумм {missing}"
|
||||
|
||||
|
||||
def _find_group_row(sheet: Sheet, rules: Rules) -> tuple[int, list[_GroupCell]]:
|
||||
def _check_deadline(deadline: Deadline | None, index: int, where: str) -> None:
|
||||
"""`deadline.check(where)` на каждой `DEADLINE_CHECK_EVERY_ROWS`-й итерации цикла (с первой); без дедлайна — нет."""
|
||||
if deadline is not None and index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||
deadline.check(where)
|
||||
|
||||
|
||||
def _find_group_row(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> tuple[int, list[_GroupCell]]:
|
||||
"""Первая сверху строка с «оборотами» и «сальдо на конец» («начало» — по возможности).
|
||||
|
||||
Строка, где нет одной из обязательных групп (например, преамбула «Сальдо на начало: …» | «Сальдо на конец: …»),
|
||||
пропускается. Не нашлось — `InvalidFormatError` с группами, недостающими в лучшей строке-кандидате.
|
||||
"""
|
||||
best: set[SumGroup] = set()
|
||||
for row in range(1, sheet.n_rows + 1):
|
||||
for index, row in enumerate(range(1, sheet.n_rows + 1)):
|
||||
_check_deadline(deadline, index, f"лист «{sheet.name}»: поиск заголовка таблицы")
|
||||
cells = _group_cells(sheet, row, rules)
|
||||
groups = {cell.group for cell in cells}
|
||||
if REQUIRED_GROUPS <= groups:
|
||||
@ -332,24 +342,22 @@ def _text_col(sheet: Sheet, first_row: int, last_row: int, first_sum: int) -> in
|
||||
|
||||
|
||||
def inn_from_cell(cell: Cell) -> str | None:
|
||||
"""ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None`
|
||||
(строгое правило: им детектор решает, что колонка — колонка ИНН).
|
||||
"""ИНН из ячейки: ровно 10 или 12 цифр, иначе `None`
|
||||
(строгое правило: им детектор решает по значениям, что колонка — колонка ИНН).
|
||||
|
||||
Извлечение значения из ячеек уже выбранной колонки (в том числе из текста с обрамлением, «ИНН 3528052718») —
|
||||
`numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные текстовые колонки
|
||||
(номера договоров, телефоны) начнут выглядеть как колонка ИНН.
|
||||
Извлечение значения из ячеек уже выбранной колонки (текст с обрамлением «ИНН 3528052718», число с потерянным
|
||||
ведущим нулём) — `numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные
|
||||
текстовые колонки (номера договоров, телефоны) начнут выглядеть как колонка ИНН, а числовая колонка КПП (9 цифр)
|
||||
после дополнения нулём — как колонка ИНН с потерянными нулями (R-24).
|
||||
|
||||
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты; если при записи числом
|
||||
потерян ведущий ноль (ИНН регионов 01–09: 9 или 11 цифр), он восстанавливается. Текст из 9/11 цифр не дополняется:
|
||||
в тексте ноль не теряется.
|
||||
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты, цифры как есть: 9/11
|
||||
цифр числом здесь не дополняются (ruling R-24).
|
||||
"""
|
||||
value = cell.value
|
||||
if isinstance(value, Decimal):
|
||||
if not value.is_finite() or value != value.to_integral_value() or value < 0:
|
||||
return None
|
||||
digits = str(int(value))
|
||||
if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO:
|
||||
digits = digits.zfill(len(digits) + 1)
|
||||
else:
|
||||
digits = cell.text
|
||||
return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None
|
||||
@ -393,9 +401,13 @@ def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int
|
||||
return labels
|
||||
|
||||
|
||||
def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader:
|
||||
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного."""
|
||||
group_row, group_cells = _find_group_row(sheet, rules)
|
||||
def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> TableHeader:
|
||||
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного.
|
||||
|
||||
`deadline` проверяется в циклах по всему листу (поиск строки групп, сбор дополнительных колонок); без него —
|
||||
не проверяется (чистая функция для тестов).
|
||||
"""
|
||||
group_row, group_cells = _find_group_row(sheet, rules, deadline)
|
||||
found = {cell.group for cell in group_cells}
|
||||
by_group = {group: _columns_of_group(sheet, group_row, group_cells, group, rules) for group in found}
|
||||
|
||||
@ -437,7 +449,8 @@ def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader:
|
||||
partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, [])
|
||||
occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set())
|
||||
extra_cols: dict[int, str] = {}
|
||||
for row in _data_rows(sheet, last_row):
|
||||
for position, row in enumerate(_data_rows(sheet, last_row)):
|
||||
_check_deadline(deadline, position, f"лист «{sheet.name}»: дополнительные колонки")
|
||||
if is_header_repeat(sheet, row, partial, rules):
|
||||
continue
|
||||
for col, _ in sheet.row_cells(row):
|
||||
|
||||
@ -19,6 +19,9 @@ from src.parser.grid import Cell
|
||||
__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
|
||||
|
||||
MONEY_Q = Decimal("0.01")
|
||||
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
|
||||
# извлечении из выбранной колонки ИНН, ruling R-24)
|
||||
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
|
||||
_ZERO = Decimal("0.00")
|
||||
|
||||
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
|
||||
@ -101,7 +104,9 @@ def to_money(cell: Cell) -> MoneyParse:
|
||||
def inn_digits(cell: Cell) -> str | None:
|
||||
"""ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `counterparty_inn` (Task 8 вызывает её).
|
||||
|
||||
1. Строгое правило детектора `detect.inn_from_cell` (10/12 цифр; число с потерянным ведущим нулём дополняется).
|
||||
1. Строгое правило детектора `detect.inn_from_cell` (ровно 10/12 цифр); целое число из 9/11 цифр — ИНН региона
|
||||
01–09, записанный числом с потерянным ведущим нулём: дополняется нулём слева. Дополнение — только здесь, при
|
||||
извлечении из колонки, уже выбранной по заголовку «ИНН» или по строгому правилу значений (ruling R-24).
|
||||
2. Текстовая ячейка с обрамлением («ИНН 352819549855», «ИНН: 3528052718», «3528 052718»): в тексте должна быть
|
||||
ровно одна запись цифр — группы, разделённые только пробелом/NBSP/узким пробелом (T7.4); группы склеиваются
|
||||
и принимаются, если цифр ровно 10 или 12. Цифры, разделённые иным знаком («№ 12 от 01.02.2025»,
|
||||
@ -114,6 +119,8 @@ def inn_digits(cell: Cell) -> str | None:
|
||||
strict = inn_from_cell(cell)
|
||||
if strict is not None:
|
||||
return strict
|
||||
if cell.kind == "number":
|
||||
return _restored_leading_zero(cell)
|
||||
if cell.kind != "text":
|
||||
return None
|
||||
found = _SINGLE_DIGIT_RUN.match(cell.text)
|
||||
@ -121,3 +128,12 @@ def inn_digits(cell: Cell) -> str | None:
|
||||
return None
|
||||
digits = _DIGIT_GROUP_SEPARATOR.sub("", found.group(1))
|
||||
return digits if len(digits) in INN_LENGTHS else None
|
||||
|
||||
|
||||
def _restored_leading_zero(cell: Cell) -> str | None:
|
||||
"""Целое неотрицательное число из 9/11 цифр → 10/12 цифр с ведущим нулём; иначе `None`."""
|
||||
value = cell.value
|
||||
if not isinstance(value, Decimal) or not value.is_finite() or value != value.to_integral_value() or value < 0:
|
||||
return None
|
||||
digits = str(int(value))
|
||||
return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None
|
||||
|
||||
@ -7,14 +7,18 @@
|
||||
сообщения, поиск по нему не ведётся.
|
||||
|
||||
Карта исходов (D-6): нет листов ОСВ → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок/иерархии → `INVALID_FORMAT`
|
||||
(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB или размер больше лимита →
|
||||
(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB, размер больше лимита или превышены
|
||||
лимиты чтения (распакованный zip > `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`, площадь объединённых ячеек, R-23) →
|
||||
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой
|
||||
`Тип: сообщение`. Нарушение §1.4 хотя бы на одном листе → `needs_check` всего результата.
|
||||
`Тип: сообщение` (стек — в лог loguru уровня ERROR; `UNREADABLE` — WARNING без стека). Нарушение §1.4 хотя бы на
|
||||
одном листе → `needs_check` всего результата.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
|
||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||
from src.parser import ParseOutcome
|
||||
@ -28,6 +32,7 @@ from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.hierarchy import resolve_levels
|
||||
from src.parser.readers import read_book
|
||||
from src.parser.readers._common import one_line_reason
|
||||
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
|
||||
from src.parser.rules import Rules
|
||||
|
||||
__all__ = ["OsvParser"]
|
||||
@ -144,9 +149,14 @@ class OsvParser:
|
||||
try:
|
||||
documents, ok = self._parse_book(data, file_type, warnings)
|
||||
except ParserError as exc:
|
||||
if exc.code is ErrorCode.UNREADABLE:
|
||||
logger.warning("Книга не читается: {reason}", reason=exc.message)
|
||||
return self._error(exc.code, exc.message, warnings, started)
|
||||
except Exception as exc: # D-6: непредвиденное — RECOGNITION_FAILED, а не исключение в воркер
|
||||
return self._error(ErrorCode.RECOGNITION_FAILED, one_line_reason(exc), warnings, started)
|
||||
reason = one_line_reason(exc)
|
||||
# Ответ — одна строка; стек нужен разработчику, а воркер логирует только код ошибки (I-2)
|
||||
logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason)
|
||||
return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started)
|
||||
metrics = Metrics(
|
||||
duration_ms=self._elapsed_ms(started),
|
||||
rows=sum(_data_rows(document) for document in documents),
|
||||
@ -162,7 +172,7 @@ class OsvParser:
|
||||
rules = self._rules
|
||||
step = "заголовок таблицы"
|
||||
try:
|
||||
header = find_table_header(sheet, rules)
|
||||
header = find_table_header(sheet, rules, deadline)
|
||||
step = "шапка документа"
|
||||
doc = parse_doc_header(sheet, header, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
@ -193,7 +203,9 @@ class OsvParser:
|
||||
"открывается"
|
||||
)
|
||||
deadline = Deadline(self._timeout_seconds, self._clock)
|
||||
sheets, read_warnings = read_book(data, file_type)
|
||||
sheets, read_warnings = read_book(
|
||||
data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes
|
||||
)
|
||||
warnings.extend(_unique(read_warnings))
|
||||
documents: list[OsvDocument] = []
|
||||
ok = True
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве есть `xl/workbook.bin`); OLE2 (D0 CF 11 E0 …) → XLS (BIFF8).
|
||||
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
|
||||
исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
|
||||
|
||||
Лимиты фазы чтения (`_limits.py`, ruling R-23) проверяются до библиотеки чтения: распакованный размер zip
|
||||
(`max_uncompressed_bytes`, по умолчанию `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` из настроек по умолчанию)
|
||||
и площадь объединённых ячеек xlsx; после чтения — площадь объединённых ячеек любого формата.
|
||||
"""
|
||||
|
||||
import io
|
||||
@ -10,14 +14,21 @@ import zipfile
|
||||
from collections.abc import Callable
|
||||
|
||||
from src.contract import FileType
|
||||
from src.core.config import Settings
|
||||
from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.readers._common import one_line_reason
|
||||
from src.parser.readers._limits import (
|
||||
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
||||
check_merged_area,
|
||||
check_xlsx_merged_area,
|
||||
check_zip_uncompressed_size,
|
||||
)
|
||||
from src.parser.readers.xls import read_xls
|
||||
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
|
||||
from src.parser.readers.xlsx import read_xlsx
|
||||
|
||||
__all__ = ["read_book"]
|
||||
__all__ = ["default_uncompressed_limit", "read_book"]
|
||||
|
||||
ZIP_SIGNATURE = b"PK\x03\x04"
|
||||
OLE2_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
|
||||
@ -31,9 +42,22 @@ _READERS: dict[FileType, tuple[str, Callable[[bytes], tuple[Sheet, ...]]]] = {
|
||||
}
|
||||
|
||||
|
||||
def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list[str]]:
|
||||
"""Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb)."""
|
||||
detected = _detect(data, file_type)
|
||||
def default_uncompressed_limit() -> int:
|
||||
"""Лимит распакованного zip без явного параметра: `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` по умолчанию."""
|
||||
max_bytes: int = Settings.model_fields["PARSE_MAX_BYTES"].default
|
||||
return ZIP_UNCOMPRESSED_LIMIT_FACTOR * max_bytes
|
||||
|
||||
|
||||
def read_book(
|
||||
data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None
|
||||
) -> tuple[tuple[Sheet, ...], list[str]]:
|
||||
"""Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb).
|
||||
|
||||
`max_uncompressed_bytes` — лимит распакованного zip (xlsx/xlsb); `OsvParser` передаёт
|
||||
`ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`. Превышение лимитов чтения — `UnsupportedFormatError`.
|
||||
"""
|
||||
limit = default_uncompressed_limit() if max_uncompressed_bytes is None else max_uncompressed_bytes
|
||||
detected = _detect(data, file_type, limit)
|
||||
content, reader = _READERS[detected]
|
||||
warnings: list[str] = []
|
||||
if detected != file_type:
|
||||
@ -44,19 +68,26 @@ def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list
|
||||
raise
|
||||
except Exception as exc: # библиотеки чтения бросают что угодно: zipfile, KeyError, struct.error, XLRDError…
|
||||
raise UnreadableError(f"книга не читается ({detected}): {one_line_reason(exc)}") from exc
|
||||
check_merged_area(sheets)
|
||||
if detected == "XLSB":
|
||||
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
|
||||
return sheets, warnings
|
||||
|
||||
|
||||
def _detect(data: bytes, file_type: FileType) -> FileType:
|
||||
def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType:
|
||||
"""Формат по сигнатуре; для zip — заодно лимиты распакованного размера и (xlsx) объединённых ячеек."""
|
||||
if data.startswith(OLE2_SIGNATURE):
|
||||
return "XLS"
|
||||
if data.startswith(ZIP_SIGNATURE):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
names = set(archive.namelist())
|
||||
except (zipfile.BadZipFile, OSError, EOFError) as exc:
|
||||
check_zip_uncompressed_size(archive, uncompressed_limit)
|
||||
detected: FileType = "XLSB" if XLSB_WORKBOOK_PART in set(archive.namelist()) else "XLSX"
|
||||
if detected == "XLSX":
|
||||
check_xlsx_merged_area(archive)
|
||||
except ParserError:
|
||||
raise
|
||||
except Exception as exc: # zipfile, zlib.error, NotImplementedError (метод сжатия), RuntimeError (пароль)…
|
||||
raise UnreadableError(f"повреждённый zip-архив: {one_line_reason(exc)}") from exc
|
||||
return "XLSB" if XLSB_WORKBOOK_PART in names else "XLSX"
|
||||
return detected
|
||||
raise UnsupportedFormatError(f"содержимое файла не XLS/XLSX/XLSB (file_type={file_type})")
|
||||
|
||||
134
api/src/parser/readers/_limits.py
Normal file
134
api/src/parser/readers/_limits.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""Лимиты фазы чтения (ruling R-23): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память.
|
||||
|
||||
Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
|
||||
объём: xlsx в 5 КБ с одним `<mergeCell ref="A1:Z200000"/>` заставляет openpyxl создать 5,2 млн объектов ячеек
|
||||
(24 с и 1,4 ГБ). Поэтому до чтения:
|
||||
|
||||
1. zip (xlsx/xlsb): сумма объявленных несжатых размеров записей (`ZipInfo.file_size`) — не больше
|
||||
`ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES`. `zipfile` не отдаёт из записи больше объявленного размера, так
|
||||
что объявленная сумма ограничивает и фактическую распаковку (openpyxl и pyxlsb читают через `zipfile`).
|
||||
2. xlsx: элементы `<mergeCell ref="…">` в `xl/worksheets/*.xml` сканируются потоково, кусками по байтам (без
|
||||
XML-парсера и без загрузки части целиком); суммарная площадь диапазонов книги — не больше `MERGED_AREA_LIMIT`.
|
||||
3. После чтения любого формата — та же сумма площадей по `Sheet.merged`: индекс `Sheet` раскладывает диапазоны по
|
||||
ячейкам, а xls хранит диапазон в 8 байтах (тысячи средних диапазонов дали бы десятки миллионов записей индекса).
|
||||
|
||||
Защиты от XML-бомб (entity expansion, `defusedxml`) здесь нет: новая зависимость — решение платформы (README,
|
||||
«Известные ограничения»).
|
||||
"""
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Iterable
|
||||
from typing import IO
|
||||
|
||||
from src.parser.errors import UnsupportedFormatError
|
||||
from src.parser.grid import Sheet
|
||||
|
||||
__all__ = [
|
||||
"MERGED_AREA_LIMIT",
|
||||
"ZIP_UNCOMPRESSED_LIMIT_FACTOR",
|
||||
"check_merged_area",
|
||||
"check_xlsx_merged_area",
|
||||
"check_zip_uncompressed_size",
|
||||
"merged_area_of_xml",
|
||||
]
|
||||
|
||||
# Распакованный zip — не больше 10 × PARSE_MAX_BYTES; реальные книги распаковываются в 1,4–7,7 раза
|
||||
ZIP_UNCOMPRESSED_LIMIT_FACTOR = 10
|
||||
# Суммарная площадь объединённых ячеек книги (ячеек). Реальный максимум — 10 090 (xls, 3 444 диапазона), у xlsx —
|
||||
# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
|
||||
MERGED_AREA_LIMIT = 200_000
|
||||
_SCAN_CHUNK_BYTES = 64 * 1024
|
||||
# Элемент mergeCell длиннее этого — отказ: раздутый атрибут не должен прятать диапазон от сканера
|
||||
_MERGE_TAG_MAX_BYTES = 64 * 1024
|
||||
_WORKSHEET_PART = re.compile(r"^xl/worksheets/[^/]+\.xml$")
|
||||
_MERGE_TAG_START = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?mergeCell\b")
|
||||
_MERGE_TAG = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?mergeCell\b([^<>]*)>")
|
||||
_REF = re.compile(rb"""\bref\s*=\s*["']\s*\$?([A-Za-z]{1,3})\$?(\d{1,7})(?::\$?([A-Za-z]{1,3})\$?(\d{1,7}))?\s*["']""")
|
||||
_ALPHABET_SIZE = 26
|
||||
|
||||
|
||||
def _merged_limit_error(limit: int) -> UnsupportedFormatError:
|
||||
return UnsupportedFormatError(
|
||||
f"превышен лимит объединённых ячеек: площадь объединённых диапазонов книги больше {limit} ячеек "
|
||||
"(MERGED_AREA_LIMIT): книга не открывается"
|
||||
)
|
||||
|
||||
|
||||
def check_zip_uncompressed_size(archive: zipfile.ZipFile, limit: int) -> None:
|
||||
"""Сумма объявленных несжатых размеров записей архива не больше `limit` байт."""
|
||||
total = sum(info.file_size for info in archive.infolist())
|
||||
if total > limit:
|
||||
raise UnsupportedFormatError(
|
||||
f"распакованный размер zip-архива {total} байт больше лимита {limit} байт "
|
||||
f"({ZIP_UNCOMPRESSED_LIMIT_FACTOR} × PARSE_MAX_BYTES): книга не открывается"
|
||||
)
|
||||
|
||||
|
||||
def _column_index(letters: bytes) -> int:
|
||||
index = 0
|
||||
for letter in letters.upper():
|
||||
index = index * _ALPHABET_SIZE + letter - ord("A") + 1
|
||||
return index
|
||||
|
||||
|
||||
def _ref_area(attributes: bytes) -> int:
|
||||
"""Площадь диапазона из атрибутов элемента mergeCell; нераспознанный `ref` — 0 (его отвергнет библиотека)."""
|
||||
found = _REF.search(attributes)
|
||||
if found is None:
|
||||
return 0
|
||||
first_col, first_row, last_col, last_row = found.groups()
|
||||
last_col = last_col or first_col
|
||||
last_row = last_row or first_row
|
||||
rows = abs(int(last_row) - int(first_row)) + 1
|
||||
cols = abs(_column_index(last_col) - _column_index(first_col)) + 1
|
||||
return rows * cols
|
||||
|
||||
|
||||
def merged_area_of_xml(stream: IO[bytes], *, limit: int, chunk_size: int = _SCAN_CHUNK_BYTES, start: int = 0) -> int:
|
||||
"""Площадь `start` + диапазонов `<mergeCell ref>` потока XML листа; больше `limit` — `UnsupportedFormatError`
|
||||
сразу, не дочитывая поток.
|
||||
|
||||
Поток читается кусками; незакрытый тег на границе куска переносится в следующий. В XML знак «<» вне разметки
|
||||
экранируется, поэтому последний «<» без «>» после него — начало незаконченного тега.
|
||||
"""
|
||||
area = start
|
||||
tail = b""
|
||||
while chunk := stream.read(chunk_size):
|
||||
buffer = tail + chunk
|
||||
cut = buffer.rfind(b"<")
|
||||
if cut != -1 and buffer.find(b">", cut) == -1:
|
||||
body, tail = buffer[:cut], buffer[cut:]
|
||||
else:
|
||||
body, tail = buffer, b""
|
||||
for found in _MERGE_TAG.finditer(body):
|
||||
area += _ref_area(found.group(1))
|
||||
if area > limit:
|
||||
raise _merged_limit_error(limit)
|
||||
if len(tail) > _MERGE_TAG_MAX_BYTES:
|
||||
if _MERGE_TAG_START.match(tail):
|
||||
raise UnsupportedFormatError(
|
||||
f"превышен лимит объединённых ячеек: элемент mergeCell длиннее {_MERGE_TAG_MAX_BYTES} байт: "
|
||||
"книга не открывается"
|
||||
)
|
||||
tail = b"" # длинный тег другого элемента: его продолжение без «<» не содержит mergeCell
|
||||
return area
|
||||
|
||||
|
||||
def check_xlsx_merged_area(archive: zipfile.ZipFile, limit: int = MERGED_AREA_LIMIT) -> None:
|
||||
"""Суммарная площадь объединённых ячеек всех листов xlsx — не больше `limit` (до вызова openpyxl)."""
|
||||
area = 0
|
||||
for name in archive.namelist():
|
||||
if _WORKSHEET_PART.match(name):
|
||||
with archive.open(name) as stream:
|
||||
area = merged_area_of_xml(stream, limit=limit, start=area)
|
||||
|
||||
|
||||
def check_merged_area(sheets: Iterable[Sheet], limit: int = MERGED_AREA_LIMIT) -> None:
|
||||
"""Суммарная площадь `Sheet.merged` прочитанной книги — не больше `limit` (до построения индекса merged)."""
|
||||
area = 0
|
||||
for sheet in sheets:
|
||||
for span in sheet.merged:
|
||||
area += span.area
|
||||
if area > limit:
|
||||
raise _merged_limit_error(limit)
|
||||
@ -6,6 +6,15 @@ from pathlib import Path
|
||||
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
||||
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
||||
_SYNTHETIC = "synthetic"
|
||||
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
|
||||
_IGNORED_PREFIXES = (".", "~$")
|
||||
|
||||
|
||||
def _is_workbook(path: Path, base: Path) -> bool:
|
||||
"""Файл книги Excel под `base`: расширение xls/xlsx/xlsb, ни файл, ни каталоги на пути — не скрытые и не lock."""
|
||||
if not path.is_file() or path.suffix.lower() not in OSV_EXTENSIONS:
|
||||
return False
|
||||
return not any(part.startswith(_IGNORED_PREFIXES) for part in path.relative_to(base).parts)
|
||||
|
||||
|
||||
def resolve_fixtures_dir() -> Path:
|
||||
@ -35,26 +44,18 @@ def synthetic_skip_reason() -> str | None:
|
||||
|
||||
|
||||
def osv_fixture_files(root: Path) -> list[Path]:
|
||||
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы."""
|
||||
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic, скрытых и lock-файлов; отсортированы."""
|
||||
osv = root / "osv"
|
||||
if not osv.is_dir():
|
||||
return []
|
||||
synthetic = osv / _SYNTHETIC
|
||||
return sorted(
|
||||
path
|
||||
for path in osv.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in OSV_EXTENSIONS and synthetic not in path.parents
|
||||
)
|
||||
return sorted(path for path in osv.rglob("*") if _is_workbook(path, osv) and synthetic not in path.parents)
|
||||
|
||||
|
||||
def synthetic_files(root: Path) -> list[Path]:
|
||||
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые и пояснения вроде `README.md` не считаются);
|
||||
отсортированы."""
|
||||
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
|
||||
считаются); отсортированы."""
|
||||
directory = root / "osv" / _SYNTHETIC
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in directory.iterdir()
|
||||
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in OSV_EXTENSIONS
|
||||
)
|
||||
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
|
||||
|
||||
@ -40,6 +40,8 @@ __all__ = [
|
||||
SNAPSHOT_DIR = "expected_osv_parse"
|
||||
GOLDEN_DIR = "golden"
|
||||
OSV_DIR = "osv"
|
||||
SYNTHETIC_DIR = "synthetic"
|
||||
REAL_PART = "real"
|
||||
JSON_INDENT = 2
|
||||
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
||||
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
|
||||
@ -110,6 +112,7 @@ class SyncReport:
|
||||
created: list[Path] = field(default_factory=list)
|
||||
changed: list[Path] = field(default_factory=list)
|
||||
orphans: list[Path] = field(default_factory=list)
|
||||
protected: list[Path] = field(default_factory=list) # снимки без исходного каталога: не удаляются (T12.3)
|
||||
unchanged: int = 0
|
||||
diffs: list[str] = field(default_factory=list)
|
||||
|
||||
@ -122,10 +125,15 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
|
||||
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
|
||||
|
||||
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
|
||||
Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`) и остальные (реальные
|
||||
файлы `osv/`). Если в `files` нет ни одного файла части (каталог отсутствует или пуст — например, fixtures без
|
||||
синтетики), её снимки не лишние: они попадают в `report.protected` и не удаляются (T12.3).
|
||||
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
|
||||
"""
|
||||
report = SyncReport()
|
||||
expected: set[Path] = set()
|
||||
files = list(files)
|
||||
present_parts = {_source_part(file.relative_to(fixtures_root / OSV_DIR)) for file in files}
|
||||
for file in files:
|
||||
target = golden_path(fixtures_root, file)
|
||||
expected.add(target)
|
||||
@ -146,13 +154,22 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
|
||||
target.write_text(current, encoding="utf-8")
|
||||
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
|
||||
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
|
||||
if stale not in expected:
|
||||
report.orphans.append(stale.relative_to(fixtures_root))
|
||||
if write:
|
||||
stale.unlink()
|
||||
if stale in expected:
|
||||
continue
|
||||
if _source_part(stale.relative_to(snapshots)) not in present_parts:
|
||||
report.protected.append(stale.relative_to(fixtures_root))
|
||||
continue
|
||||
report.orphans.append(stale.relative_to(fixtures_root))
|
||||
if write:
|
||||
stale.unlink()
|
||||
return report
|
||||
|
||||
|
||||
def _source_part(relative: Path) -> str:
|
||||
"""Часть дерева по пути относительно `osv/` (или каталога снимков): `synthetic` или `real`."""
|
||||
return SYNTHETIC_DIR if relative.parts and relative.parts[0] == SYNTHETIC_DIR else REAL_PART
|
||||
|
||||
|
||||
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
|
||||
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
|
||||
snapshot = golden_path(fixtures_root, file)
|
||||
|
||||
@ -144,6 +144,32 @@ def test_collapsed_parent_with_wrong_net_is_violation() -> None:
|
||||
assert parent_sums(rows) == [12]
|
||||
|
||||
|
||||
def test_collapsed_parent_on_the_opposite_side_is_violation() -> None:
|
||||
"""T9.1 (R-21): свёрнутое сальдо родителя на противоположной стороне (16 Кт при нетто детей +16 Дт) — нарушение:
|
||||
нетто сравнивается со знаком, совпадения модуля мало."""
|
||||
rows = [
|
||||
account(12, (None, "16", "134", "111", "39", None)),
|
||||
row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||
row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == [12]
|
||||
|
||||
|
||||
def test_zero_parent_with_children_netting_to_zero_is_accepted() -> None:
|
||||
"""T9.1 (R-21, осознанное поведение): родитель (0, 0) при детях (X Дт, X Кт) — без нарушения: нулевое сальдо
|
||||
родителя — свёрнутое (не больше одной ненулевой стороны), нетто детей X − X = 0. Остаточный риск описан в спеке
|
||||
(§1.4): потерянная пара сальдо у родителя этой проверкой не ловится."""
|
||||
rows = [
|
||||
account(12, ("0", "0", "134", "111", "23", None)),
|
||||
row(13, ("50", None, "131", "111", "70", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||
row(22, (None, "50", "3", None, None, "47"), level=1, parent=12, group_type="ACCOUNT"),
|
||||
]
|
||||
|
||||
assert balance_identity(rows) == []
|
||||
assert parent_sums(rows) == []
|
||||
|
||||
|
||||
def test_expanded_parent_balance_must_match_by_columns() -> None:
|
||||
"""Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно."""
|
||||
rows = [
|
||||
|
||||
@ -169,6 +169,38 @@ def test_account_re_residual_false_positive_documented() -> None:
|
||||
assert ACCOUNT_RE.match("12.ООО") is not None
|
||||
|
||||
|
||||
def test_account_re_accepts_multiline_name_after_comma() -> None:
|
||||
"""M-1: многострочная ячейка «60.01, Расчеты\nс поставщиками» — счёт 60.01 (re.DOTALL), а не контрагент."""
|
||||
found = ACCOUNT_RE.match("60.01, Расчеты\nс поставщиками")
|
||||
|
||||
assert found is not None
|
||||
assert found.group(1) == "60.01"
|
||||
assert found.group(2) == "Расчеты\nс поставщиками"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["٦٠.01", "60.٠١", "60", "60.01\nРасчеты"])
|
||||
def test_account_re_accepts_only_ascii_digits(text: str) -> None:
|
||||
"""M-1: re.ASCII — `\\d` только 0–9 (арабско-индийские и полноширинные цифры — не код счёта); без запятой
|
||||
перевод строки после кода не делает строку счётом."""
|
||||
assert ACCOUNT_RE.match(text) is None
|
||||
|
||||
|
||||
def test_multiline_account_row_keeps_children_group_types(rules: Rules) -> None:
|
||||
"""M-1: многострочное имя субсчёта не сдвигает group_type детей (контрагент остаётся контрагентом)."""
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "60.01, Расчеты\nс поставщиками", S100),
|
||||
r(2, "ООО Поставщик", S100, inn="7701234567"),
|
||||
r(None, "Итого", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
subaccount = parsed.one("60.01, Расчеты\nс поставщиками")
|
||||
assert (subaccount.group_type, subaccount.account_code_raw, subaccount.body.account_rank) == ("ACCOUNT", "60.01", 2)
|
||||
assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY"
|
||||
|
||||
|
||||
def test_letter_subaccount_is_account_with_children(rules: Rules) -> None:
|
||||
"""R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор."""
|
||||
sheet = osv(
|
||||
|
||||
@ -8,7 +8,9 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from gridbuilder import L, M, at, sheet_from_rows
|
||||
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import (
|
||||
DEADLINE_CHECK_EVERY_ROWS,
|
||||
ColumnMap,
|
||||
SheetKind,
|
||||
TableHeader,
|
||||
@ -18,8 +20,9 @@ from src.parser.detect import (
|
||||
inn_from_cell,
|
||||
is_header_repeat,
|
||||
)
|
||||
from src.parser.errors import InvalidFormatError
|
||||
from src.parser.errors import InvalidFormatError, ParseTimeoutError
|
||||
from src.parser.grid import Cell, Sheet
|
||||
from src.parser.numbers import inn_digits
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||
@ -669,8 +672,10 @@ def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(Decimal("123456789"), "0123456789"), # ИНН региона 01–09 числом: ведущий ноль потерян
|
||||
(Decimal("12345678901"), "012345678901"),
|
||||
# R-24: правило детектора не дополняет 9/11 цифр нулём — иначе числовая колонка КПП (9 цифр) сошла бы за
|
||||
# колонку ИНН; ведущий ноль восстанавливает только извлечение `numbers.inn_digits` из выбранной колонки
|
||||
(Decimal("123456789"), None),
|
||||
(Decimal("12345678901"), None),
|
||||
(Decimal("3523023093"), "3523023093"),
|
||||
(Decimal("770123456789"), "770123456789"),
|
||||
(Decimal("3523023093.0"), "3523023093"),
|
||||
@ -682,25 +687,76 @@ def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
|
||||
("не указан", None),
|
||||
],
|
||||
)
|
||||
def test_inn_from_cell_pads_numeric_inn_that_lost_leading_zero(value: object, expected: str | None) -> None:
|
||||
def test_inn_from_cell_is_strict_and_does_not_pad(value: object, expected: str | None) -> None:
|
||||
cell = Cell(value, "number") if isinstance(value, Decimal) else Cell(value, "text") # type: ignore[arg-type]
|
||||
|
||||
assert inn_from_cell(cell) == expected
|
||||
|
||||
|
||||
def test_inn_column_of_numbers_with_lost_leading_zeros(rules: Rules) -> None:
|
||||
def _numeric_inn_rows(inn_title: str | None) -> list[list[object]]:
|
||||
"""ИНН записаны числами, у трёх из четырёх потерян ведущий ноль (9 цифр); `inn_title` — заголовок колонки."""
|
||||
rows: list[list[object]] = [
|
||||
[TITLE],
|
||||
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||
["Контрагенты", inn_title, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
]
|
||||
for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
|
||||
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
|
||||
return rows
|
||||
|
||||
|
||||
def test_inn_column_of_numbers_with_lost_leading_zeros_found_by_header(rules: Rules) -> None:
|
||||
"""T4.3 + R-24: колонка с заголовком «ИНН» выбирается по заголовку; значения дополняются нулём при извлечении."""
|
||||
sheet = sheet_from_rows(_numeric_inn_rows("ИНН"))
|
||||
|
||||
header = find_table_header(sheet, rules)
|
||||
|
||||
assert [header.inn_col] == [2]
|
||||
assert header.extra_cols == {}
|
||||
data = range(header.last_row + 1, sheet.n_rows + 1)
|
||||
assert [inn_digits(sheet.cell(row, 2)) for row in data] == ["0123456789", "0234567890", "0345678901", "7701234567"]
|
||||
|
||||
|
||||
def test_numeric_column_with_lost_leading_zeros_without_header_is_not_inn(rules: Rules) -> None:
|
||||
"""R-24: без заголовка «ИНН» колонка из 9/10-значных чисел — не колонка ИНН (по значениям нужно ≥ 80 % ровно
|
||||
10/12 цифр), она уходит в extra."""
|
||||
header = find_table_header(sheet_from_rows(_numeric_inn_rows(None)), rules)
|
||||
|
||||
assert header.inn_col is None
|
||||
assert header.extra_cols == {2: "B"}
|
||||
|
||||
|
||||
def test_numeric_kpp_column_is_not_taken_for_inn_column(rules: Rules) -> None:
|
||||
"""M-3: числовой КПП (9 цифр) левее текстовой колонки ИНН, обе без заголовка: ИНН — колонка с 10/12 цифрами,
|
||||
КПП не выбирается (дополнение нулём превратило бы КПП в «0» + КПП)."""
|
||||
rows: list[list[object]] = [
|
||||
[TITLE],
|
||||
["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
]
|
||||
pairs = [(770101001, "7701234567"), (352801001, "3528052718"), (500101001, "5001012345"), (771401001, "7714012345")]
|
||||
for index, (kpp, inn) in enumerate(pairs):
|
||||
rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: inn, 4: 1, 9: 1})])
|
||||
|
||||
header = find_table_header(sheet_from_rows(rows), rules)
|
||||
|
||||
assert [header.inn_col] == [3]
|
||||
assert header.extra_cols == {2: "B"}
|
||||
|
||||
|
||||
def test_numeric_kpp_column_alone_is_not_an_inn_column(rules: Rules) -> None:
|
||||
rows: list[list[object]] = [
|
||||
[TITLE],
|
||||
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||
["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
]
|
||||
for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
|
||||
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
|
||||
for index, kpp in enumerate([770101001, 352801001, 500101001, 771401001]):
|
||||
rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: 1, 8: 1})])
|
||||
|
||||
header = find_table_header(sheet_from_rows(rows), rules)
|
||||
|
||||
assert [header.inn_col] == [2]
|
||||
assert header.extra_cols == {}
|
||||
assert header.inn_col is None
|
||||
assert header.extra_cols == {2: "B"}
|
||||
|
||||
|
||||
def test_too_few_inn_like_values_do_not_make_an_inn_column(rules: Rules) -> None:
|
||||
@ -737,3 +793,34 @@ def test_vertically_merged_collapsed_title_does_not_reach_junk_to_the_right(rule
|
||||
assert header.columns.collapsed == frozenset({"closing"})
|
||||
assert (header.columns.closing_debit, header.columns.closing_credit) == (6, None)
|
||||
assert [header.right_edge] == [6]
|
||||
|
||||
|
||||
# --- Кооперативный дедлайн в долгих циклах (R-23, I-1) ---
|
||||
|
||||
|
||||
class _ClockExpiringAfter:
|
||||
"""Часы-заглушка: первые `calls` обращений — время 0, дальше — далеко за лимитом."""
|
||||
|
||||
def __init__(self, calls: int) -> None:
|
||||
self._left = calls
|
||||
|
||||
def __call__(self) -> float:
|
||||
self._left -= 1
|
||||
return 0.0 if self._left >= 0 else 1e9
|
||||
|
||||
|
||||
def test_group_row_search_checks_deadline(rules: Rules) -> None:
|
||||
sheet = sheet_from_rows([[f"строка {index}"] for index in range(2 * DEADLINE_CHECK_EVERY_ROWS)])
|
||||
|
||||
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
|
||||
find_table_header(sheet, rules, Deadline(1, clock=_ClockExpiringAfter(1)))
|
||||
with pytest.raises(InvalidFormatError): # без дедлайна — прежнее поведение
|
||||
find_table_header(sheet, rules)
|
||||
|
||||
|
||||
def test_extra_columns_scan_checks_deadline(rules: Rules) -> None:
|
||||
# Создание дедлайна и одна проверка при поиске строки групп — в лимите; сбор extra — уже нет
|
||||
with pytest.raises(ParseTimeoutError, match="дополнительные колонки"):
|
||||
find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(2)))
|
||||
header = find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(10**6)))
|
||||
assert header == find_table_header(_rb_like(), rules) # дедлайн в лимите не меняет результат
|
||||
|
||||
@ -193,7 +193,7 @@ def test_too_large_text_is_none_with_warning() -> None:
|
||||
(text("352819549855"), "352819549855"),
|
||||
(text(" 352819549855 "), "352819549855"),
|
||||
(num("352819549855"), "352819549855"),
|
||||
(num("123456789"), "0123456789"), # потерян ведущий ноль: общее правило detect.inn_from_cell
|
||||
(num("123456789"), "0123456789"), # потерян ведущий ноль: восстанавливает извлечение (R-24)
|
||||
(text("123"), None),
|
||||
(num("123"), None),
|
||||
(text("ИНН 352819549855"), "352819549855"), # R-15: цифры из обрамляющего текста
|
||||
@ -221,11 +221,19 @@ def test_inn_digits(cell: Cell, expected: str | None) -> None:
|
||||
|
||||
|
||||
def test_inn_digits_is_superset_of_strict_detector_rule() -> None:
|
||||
cells = (text("3528052718"), text(" 352819549855 "), num("123456789"), num("3528052718.0"), num("352819549855"))
|
||||
cells = (text("3528052718"), text(" 352819549855 "), num("3528052718.0"), num("352819549855"))
|
||||
for cell in cells:
|
||||
assert detect.inn_from_cell(cell) is not None
|
||||
assert inn_digits(cell) == detect.inn_from_cell(cell)
|
||||
|
||||
|
||||
def test_only_extraction_restores_lost_leading_zero() -> None:
|
||||
"""R-24: ведущий ноль числа из 9/11 цифр восстанавливает извлечение из уже выбранной колонки ИНН, а не правило
|
||||
детектора, которым колонка выбирается по значениям (иначе числовой КПП сошёл бы за ИНН)."""
|
||||
for cell, padded in ((num("123456789"), "0123456789"), (num("12345678901"), "012345678901")):
|
||||
assert detect.inn_from_cell(cell) is None
|
||||
assert inn_digits(cell) == padded
|
||||
|
||||
|
||||
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
|
||||
assert detect.inn_from_cell(text("ИНН 352819549855")) is None
|
||||
|
||||
@ -9,19 +9,23 @@ import io
|
||||
import itertools
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
import traceback
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from gridbuilder import L, M
|
||||
from gridbuilder import L, M, sheet_from_rows
|
||||
from loguru import logger
|
||||
from result_contract import assert_valid_result
|
||||
|
||||
from src.contract import ErrorCode, OsvDocument, ResultStatus
|
||||
from src.parser import ParseOutcome, hierarchy
|
||||
from src.parser.classify import SUM_FIELDS
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.errors import ParseTimeoutError
|
||||
from src.parser.hierarchy import BodyRow
|
||||
from src.parser.osv import OsvParser
|
||||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
|
||||
@ -401,6 +405,68 @@ def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, mo
|
||||
assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_records() -> Iterator[list[Any]]:
|
||||
"""Записи loguru, выпущенные за время теста."""
|
||||
records: list[Any] = []
|
||||
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||
yield records
|
||||
logger.remove(handler_id)
|
||||
|
||||
|
||||
def test_unexpected_exception_is_logged_with_traceback(
|
||||
rules: Rules, monkeypatch: pytest.MonkeyPatch, log_records: list[Any]
|
||||
) -> None:
|
||||
"""I-2: ответ прежний (RECOGNITION_FAILED одной строкой), но стек непредвиденной ошибки уходит в лог."""
|
||||
|
||||
def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
||||
raise ValueError("сбой стратегии\nвторая строка")
|
||||
|
||||
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
|
||||
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
|
||||
assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
|
||||
(record,) = [record for record in log_records if record["level"].name == "ERROR"]
|
||||
assert "Непредвиденная ошибка разбора" in record["message"]
|
||||
assert "ValueError: сбой стратегии вторая строка" in record["message"]
|
||||
exception = record["exception"]
|
||||
assert exception is not None
|
||||
assert exception.type is ValueError
|
||||
assert exception.traceback is not None
|
||||
assert "broken" in "".join(traceback.format_tb(exception.traceback))
|
||||
|
||||
|
||||
def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, log_records: list[Any]) -> None:
|
||||
outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNREADABLE
|
||||
(record,) = [record for record in log_records if record["level"].name == "WARNING"]
|
||||
assert outcome.error.message in record["message"]
|
||||
assert record["exception"] is None
|
||||
assert not [record for record in log_records if record["level"].name == "ERROR"]
|
||||
|
||||
|
||||
def test_expected_errors_other_than_unreadable_are_not_logged(rules: Rules, log_records: list[Any]) -> None:
|
||||
outcome = parser(rules).parse(b"%PDF-1.7", "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert not [record for record in log_records if record["level"].no >= logger.level("WARNING").no]
|
||||
|
||||
|
||||
def test_header_search_is_interrupted_by_deadline(rules: Rules) -> None:
|
||||
"""R-23 (в): дедлайн из `parse_sheet` доходит до поиска заголовка таблицы — цикла по всему листу."""
|
||||
sheet = sheet_from_rows([[TITLE], *([["строка"]] * 1000)])
|
||||
deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__)
|
||||
|
||||
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
|
||||
parser(rules).parse_sheet(sheet, deadline)
|
||||
|
||||
|
||||
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
|
||||
|
||||
NEW_OPENING = "Нач. остаток"
|
||||
|
||||
241
api/tests/unit/parser/test_read_limits.py
Normal file
241
api/tests/unit/parser/test_read_limits.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""Лимиты фазы чтения (ruling R-23, находка I-1 финального ревью): распакованный размер zip-архива и суммарная
|
||||
площадь объединённых ячеек проверяются до библиотеки чтения — бомба из нескольких килобайт не доходит до openpyxl.
|
||||
|
||||
Книги собираются в памяти: в fixtures ничего не пишется.
|
||||
"""
|
||||
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
from src.contract import ErrorCode, ResultStatus
|
||||
from src.parser.errors import UnsupportedFormatError
|
||||
from src.parser.grid import MergedRange, Sheet
|
||||
from src.parser.osv import OsvParser
|
||||
from src.parser.readers import _READERS, read_book
|
||||
from src.parser.readers._limits import (
|
||||
MERGED_AREA_LIMIT,
|
||||
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
|
||||
check_merged_area,
|
||||
merged_area_of_xml,
|
||||
)
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
FAST_SECONDS = 1.0
|
||||
SHEET_PART = "xl/worksheets/sheet1.xml"
|
||||
ZEROS_BYTES = 10 * 1024 * 1024 # «десятки МБ» нулей сжимаются в ~10 КБ
|
||||
BOMB_MAX_FILE_BYTES = 10_000 # бомба — несколько килобайт файла
|
||||
SMALL_MAX_BYTES = 100_000 # лимит размера файла в тесте: распакованный лимит = 10 × 100 000 байт
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
def _plain_xlsx() -> bytes:
|
||||
book = openpyxl.Workbook()
|
||||
sheet = book.active
|
||||
assert sheet is not None
|
||||
sheet.title = "ОСВ"
|
||||
sheet["A1"] = "Оборотно-сальдовая ведомость по счету 60"
|
||||
buffer = io.BytesIO()
|
||||
book.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rewrite(data: bytes, patch: Callable[[str, bytes], bytes], extra: dict[str, bytes] | None = None) -> bytes:
|
||||
"""Копия zip-архива книги: содержимое частей через `patch(имя, байты)` и дополнительные части `extra`."""
|
||||
source = zipfile.ZipFile(io.BytesIO(data))
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target:
|
||||
for info in source.infolist():
|
||||
target.writestr(info.filename, patch(info.filename, source.read(info.filename)))
|
||||
for name, content in (extra or {}).items():
|
||||
target.writestr(name, content)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _xlsx_with_merges(*refs: str, tag: str = "mergeCell") -> bytes:
|
||||
cells = "".join(f'<{tag} ref="{ref}"/>' for ref in refs).encode()
|
||||
block = b'<mergeCells count="%d">' % len(refs) + cells + b"</mergeCells>"
|
||||
|
||||
def patch(name: str, content: bytes) -> bytes:
|
||||
return content.replace(b"</sheetData>", b"</sheetData>" + block) if name == SHEET_PART else content
|
||||
|
||||
return _rewrite(_plain_xlsx(), patch)
|
||||
|
||||
|
||||
def _xlsx_with_zeros(size: int) -> bytes:
|
||||
return _rewrite(_plain_xlsx(), lambda _name, content: content, {"xl/media/zeros.bin": bytes(size)})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки)."""
|
||||
|
||||
def forbidden(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise AssertionError("openpyxl.load_workbook вызван: лимит должен сработать до чтения книги")
|
||||
|
||||
monkeypatch.setattr(openpyxl, "load_workbook", forbidden)
|
||||
|
||||
|
||||
# --- Площадь объединённых ячеек (xlsx) ---
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_huge_merged_range_is_rejected_before_openpyxl() -> None:
|
||||
data = _xlsx_with_merges("A1:Z200000") # 5,2 млн ячеек в нескольких килобайтах файла
|
||||
assert len(data) < BOMB_MAX_FILE_BYTES
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(UnsupportedFormatError) as caught:
|
||||
read_book(data, "XLSX")
|
||||
|
||||
assert time.monotonic() - started < FAST_SECONDS
|
||||
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "лимит объединённых ячеек" in caught.value.message
|
||||
assert str(MERGED_AREA_LIMIT) in caught.value.message
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_many_small_merged_ranges_are_summed() -> None:
|
||||
per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик
|
||||
count = MERGED_AREA_LIMIT // per_range + 1
|
||||
refs = [f"B{start}:B{start + per_range - 1}" for start in range(1, count * per_range, per_range)]
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
read_book(_xlsx_with_merges(*refs), "XLSX")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_prefixed_merge_element_is_counted() -> None:
|
||||
"""Пространство имён с префиксом (`<x:mergeCell …>`) — тот же элемент; openpyxl его прочитал бы."""
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
read_book(_xlsx_with_merges("A1:Z200000", tag="x:mergeCell"), "XLSX")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None:
|
||||
outcome = OsvParser(rules, max_bytes=50 * 1024 * 1024, timeout_seconds=120).parse(
|
||||
_xlsx_with_merges("A1:Z200000"), "XLSX"
|
||||
)
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
|
||||
|
||||
def test_normal_merges_are_read() -> None:
|
||||
sheets, _ = read_book(_xlsx_with_merges("A3:A4", "B3:C3"), "XLSX")
|
||||
|
||||
assert set(sheets[0].merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chunk_size", [1, 7, 64, 65_536])
|
||||
def test_scan_is_independent_of_chunk_boundaries(chunk_size: int) -> None:
|
||||
xml = (
|
||||
b'<worksheet><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>'
|
||||
b'<mergeCells count="4"><mergeCell ref="A1:B2"/><mergeCell ref="C3"/>'
|
||||
b'<x:mergeCell ref = "$D$1:$D$10" /><mergeCell ref="AA10:AB11"/></mergeCells></worksheet>'
|
||||
)
|
||||
expected = 4 + 1 + 10 + 4
|
||||
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=chunk_size) == expected
|
||||
|
||||
|
||||
def test_scan_limit_is_inclusive() -> None:
|
||||
xml = b'<mergeCells><mergeCell ref="A1:J10"/></mergeCells>'
|
||||
area = 100
|
||||
|
||||
assert merged_area_of_xml(io.BytesIO(xml), limit=area) == area
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
merged_area_of_xml(io.BytesIO(xml), limit=area - 1)
|
||||
|
||||
|
||||
def test_scan_ignores_merge_cells_container_and_other_elements() -> None:
|
||||
xml = b'<mergeCells count="999999"/><mergeCellsX ref="A1:Z99999"/><c r="A1:Z99999"/>'
|
||||
|
||||
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=5) == 0
|
||||
|
||||
|
||||
def test_overlong_merge_element_is_rejected() -> None:
|
||||
"""Раздутый атрибут не прячет диапазон от сканера: слишком длинный элемент mergeCell — отказ, а не пропуск."""
|
||||
xml = b'<mergeCells><mergeCell pad="' + b"x" * 200_000 + b'" ref="A1:Z200000"/></mergeCells>'
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=4096)
|
||||
|
||||
|
||||
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
|
||||
|
||||
|
||||
def _sheet_with(*spans: MergedRange) -> Sheet:
|
||||
return Sheet(name="Лист", cells={}, merged=spans, row_outline={}, n_rows=1, n_cols=1)
|
||||
|
||||
|
||||
def test_merged_area_of_read_sheets_is_limited() -> None:
|
||||
"""xlrd хранит merged списком (8 байт на диапазон), но индекс `Sheet` раскладывает диапазоны по ячейкам:
|
||||
тысячи средних диапазонов в xls дали бы десятки миллионов записей. Та же сумма площадей — после чтения.
|
||||
"""
|
||||
half = MergedRange(1, 1, MERGED_AREA_LIMIT // 2, 1)
|
||||
check_merged_area([_sheet_with(half), _sheet_with(half)], limit=MERGED_AREA_LIMIT)
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
check_merged_area([_sheet_with(half), _sheet_with(half, MergedRange(1, 2, 1, 2))], limit=MERGED_AREA_LIMIT)
|
||||
|
||||
|
||||
def test_xls_reader_result_is_checked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
huge = _sheet_with(MergedRange(1, 1, MERGED_AREA_LIMIT + 1, 1))
|
||||
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (huge,)))
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
|
||||
read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
|
||||
|
||||
|
||||
# --- Распакованный размер zip ---
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_zip_expanding_beyond_limit_is_rejected() -> None:
|
||||
data = _xlsx_with_zeros(ZEROS_BYTES)
|
||||
assert len(data) < SMALL_MAX_BYTES
|
||||
|
||||
with pytest.raises(UnsupportedFormatError) as caught:
|
||||
read_book(data, "XLSX", max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES)
|
||||
|
||||
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "распакованный размер" in caught.value.message
|
||||
assert str(ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES) in caught.value.message
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||
def test_parser_passes_ten_times_max_bytes_as_zip_limit(rules: Rules) -> None:
|
||||
outcome = OsvParser(rules, max_bytes=SMALL_MAX_BYTES, timeout_seconds=120).parse(
|
||||
_xlsx_with_zeros(ZEROS_BYTES), "XLSX"
|
||||
)
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "распакованный размер" in outcome.error.message
|
||||
|
||||
|
||||
def test_zip_limit_applies_to_xlsb_too() -> None:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("xl/workbook.bin", bytes(ZEROS_BYTES))
|
||||
|
||||
with pytest.raises(UnsupportedFormatError, match="распакованный размер"):
|
||||
read_book(buffer.getvalue(), "XLSB", max_uncompressed_bytes=SMALL_MAX_BYTES)
|
||||
|
||||
|
||||
def test_zip_within_limit_is_read() -> None:
|
||||
data = _xlsx_with_zeros(1000)
|
||||
|
||||
sheets, _ = read_book(data, "XLSX", max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES)
|
||||
|
||||
assert [sheet.name for sheet in sheets] == ["ОСВ"]
|
||||
@ -45,6 +45,26 @@ def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None:
|
||||
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
|
||||
|
||||
|
||||
def test_hidden_and_excel_lock_files_are_ignored(tmp_path: Path) -> None:
|
||||
"""T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры."""
|
||||
osv = tmp_path / "osv"
|
||||
(osv / "rb" / ".cache").mkdir(parents=True)
|
||||
(osv / "synthetic").mkdir()
|
||||
for path in (
|
||||
osv / "rb" / "b.xlsx",
|
||||
osv / "rb" / "~$b.xlsx",
|
||||
osv / "rb" / ".b.xlsx",
|
||||
osv / "rb" / ".cache" / "c.xlsx",
|
||||
osv / "synthetic" / "s.xlsx",
|
||||
osv / "synthetic" / "~$s.xlsx",
|
||||
osv / "synthetic" / ".s.xls",
|
||||
):
|
||||
path.write_bytes(b"")
|
||||
|
||||
assert osv_fixture_files(tmp_path) == [osv / "rb" / "b.xlsx"]
|
||||
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
|
||||
|
||||
|
||||
def test_skip_reason_mentions_env_and_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
missing = tmp_path / "нет-такого"
|
||||
monkeypatch.setenv(FIXTURES_ENV, str(missing))
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""`sync_snapshots` и `scripts/golden_update.py`: запись, сравнение, diff, лишние снимки (на временном каталоге)."""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@ -91,6 +92,46 @@ def test_orphan_snapshot_is_reported_and_removed_on_write(root: Path) -> None:
|
||||
assert not orphan.exists()
|
||||
|
||||
|
||||
def test_snapshots_are_kept_when_their_source_directory_is_absent(root: Path) -> None:
|
||||
"""T12.3: без `osv/synthetic` (или без реальных файлов) снимки этой части — не «лишние»: запись их не удаляет,
|
||||
сверка о них не сообщает как о расхождении, но перечисляет как сохранённые."""
|
||||
parser = get_parser()
|
||||
sync_snapshots(root, files_of(root), parser, write=True)
|
||||
synthetic_snapshots = sorted((root / "golden" / "expected_osv_parse" / "synthetic").glob("*.json"))
|
||||
real_snapshot = root / "golden" / "expected_osv_parse" / "rb" / "2024-Q1" / "сч 60.json"
|
||||
real_snapshot.parent.mkdir(parents=True)
|
||||
real_snapshot.write_text("{}\n", encoding="utf-8")
|
||||
shutil.rmtree(root / "osv" / "synthetic")
|
||||
|
||||
report = sync_snapshots(root, [], parser, write=True)
|
||||
|
||||
assert report.orphans == []
|
||||
assert not report.differs
|
||||
assert report.protected == sorted(
|
||||
[*(path.relative_to(root) for path in synthetic_snapshots), real_snapshot.relative_to(root)]
|
||||
)
|
||||
assert all(path.is_file() for path in synthetic_snapshots)
|
||||
assert real_snapshot.is_file()
|
||||
|
||||
|
||||
def test_orphan_is_removed_only_within_a_present_source_directory(root: Path) -> None:
|
||||
"""Реальных файлов нет, синтетика есть: лишний синтетический снимок удаляется, реальные снимки сохраняются."""
|
||||
parser = get_parser()
|
||||
real_snapshot = root / "golden" / "expected_osv_parse" / "rb" / "сч 60.json"
|
||||
real_snapshot.parent.mkdir(parents=True)
|
||||
real_snapshot.write_text("{}\n", encoding="utf-8")
|
||||
orphan = root / "golden" / "expected_osv_parse" / "synthetic" / "s99_gone.json"
|
||||
orphan.parent.mkdir(parents=True)
|
||||
orphan.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
report = sync_snapshots(root, files_of(root), parser, write=True)
|
||||
|
||||
assert report.orphans == [orphan.relative_to(root)]
|
||||
assert report.protected == [real_snapshot.relative_to(root)]
|
||||
assert not orphan.exists()
|
||||
assert real_snapshot.is_file()
|
||||
|
||||
|
||||
def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None:
|
||||
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
|
||||
file = files_of(root)[0]
|
||||
@ -127,6 +168,11 @@ def test_script_check_exit_codes(
|
||||
assert golden_update.main(["--check"]) == 1
|
||||
assert golden_update.main([]) == 0
|
||||
assert golden_update.main(["--check"]) == 0
|
||||
shutil.rmtree(root / "osv" / "synthetic")
|
||||
capsys.readouterr()
|
||||
assert golden_update.main([]) == 0
|
||||
assert "снимок сохранён (нет исходного каталога)" in capsys.readouterr().err
|
||||
assert (root / "golden" / "expected_osv_parse" / "synthetic" / "s01_variant2_header.json").is_file()
|
||||
monkeypatch.setenv("ARM_KA_FIXTURES", str(root / "нет"))
|
||||
capsys.readouterr()
|
||||
assert golden_update.main([]) == golden_update.EXIT_NO_FIXTURES
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user