From 9b8b8ea727de302af9a4bdc7112f8830165cc345 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 16:29:53 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=E2=80=94?= =?UTF-8?q?=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D1=8B=20=D1=87=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20xlsx=20(I-1),=20=D0=BB=D0=BE=D0=B3=20trace?= =?UTF-8?q?back=20(I-2),=20ACCOUNT=5FRE=20(M-1),=20=D0=98=D0=9D=D0=9D/?= =?UTF-8?q?=D0=9A=D0=9F=D0=9F=20(M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - I-1 (R-23): readers/_limits.py — сумма ZipInfo.file_size ≤ 10 × PARSE_MAX_BYTES (параметр read_book от OsvParser), потоковый скан до 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 3 +- README.md | 24 ++- api/scripts/golden_update.py | 8 +- api/src/parser/classify.py | 10 +- api/src/parser/detect.py | 49 +++-- api/src/parser/numbers.py | 18 +- api/src/parser/osv.py | 22 +- api/src/parser/readers/__init__.py | 47 ++++- api/src/parser/readers/_limits.py | 134 ++++++++++++ api/tests/fixtures_helpers.py | 27 +-- api/tests/golden_helpers.py | 25 ++- api/tests/unit/parser/test_checks.py | 26 +++ api/tests/unit/parser/test_classify.py | 32 +++ api/tests/unit/parser/test_detect.py | 105 +++++++++- api/tests/unit/parser/test_numbers.py | 12 +- api/tests/unit/parser/test_osv_parser.py | 70 ++++++- api/tests/unit/parser/test_read_limits.py | 241 ++++++++++++++++++++++ api/tests/unit/test_fixtures_helpers.py | 20 ++ api/tests/unit/test_golden_sync.py | 46 +++++ 19 files changed, 845 insertions(+), 74 deletions(-) create mode 100644 api/src/parser/readers/_limits.py create mode 100644 api/tests/unit/parser/test_read_limits.py diff --git a/CLAUDE.md b/CLAUDE.md index a4c256e..06274b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/README.md b/README.md index 934e21d..d98d488 100644 --- a/README.md +++ b/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 — потоковым сканом `` до 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=<путь>`, по умолчанию diff --git a/api/scripts/golden_update.py b/api/scripts/golden_update.py index be3b386..b66b0e4 100644 --- a/api/scripts/golden_update.py +++ b/api/scripts/golden_update.py @@ -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 diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 4a66d93..3cb56ea 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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 diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 3dd1441..64f9ce4 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -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): diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 4d6e4af..abea1fe 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -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 diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index ecc9e53..4eba4aa 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -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 diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py index b974e6a..ede5298 100644 --- a/api/src/parser/readers/__init__.py +++ b/api/src/parser/readers/__init__.py @@ -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})") diff --git a/api/src/parser/readers/_limits.py b/api/src/parser/readers/_limits.py new file mode 100644 index 0000000..f34a9c5 --- /dev/null +++ b/api/src/parser/readers/_limits.py @@ -0,0 +1,134 @@ +"""Лимиты фазы чтения (ruling R-23): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения выделит память. + +Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный +объём: xlsx в 5 КБ с одним `` заставляет 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: элементы `` в `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` + диапазонов `` потока 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) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index f546d45..007db5b 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -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)) diff --git a/api/tests/golden_helpers.py b/api/tests/golden_helpers.py index 80a891c..2d48721 100644 --- a/api/tests/golden_helpers.py +++ b/api/tests/golden_helpers.py @@ -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) diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index a5a71d8..5910693 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -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 = [ diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 72aeaaf..e310b52 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -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( diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index 6318a82..9cfe023 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -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) # дедлайн в лимите не меняет результат diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index 93ee6bb..0440c7f 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -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 diff --git a/api/tests/unit/parser/test_osv_parser.py b/api/tests/unit/parser/test_osv_parser.py index f17ab44..b3cf5e8 100644 --- a/api/tests/unit/parser/test_osv_parser.py +++ b/api/tests/unit/parser/test_osv_parser.py @@ -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 = "Нач. остаток" diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py new file mode 100644 index 0000000..3232f81 --- /dev/null +++ b/api/tests/unit/parser/test_read_limits.py @@ -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'' % len(refs) + cells + b"" + + def patch(name: str, content: bytes) -> bytes: + return content.replace(b"", b"" + 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: + """Пространство имён с префиксом (``) — тот же элемент; 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'1' + b'' + b'' + ) + 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'' + 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'' + + 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'' + + 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] == ["ОСВ"] diff --git a/api/tests/unit/test_fixtures_helpers.py b/api/tests/unit/test_fixtures_helpers.py index 1ac9782..ec30268 100644 --- a/api/tests/unit/test_fixtures_helpers.py +++ b/api/tests/unit/test_fixtures_helpers.py @@ -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)) diff --git a/api/tests/unit/test_golden_sync.py b/api/tests/unit/test_golden_sync.py index 61b6df7..2f8595f 100644 --- a/api/tests/unit/test_golden_sync.py +++ b/api/tests/unit/test_golden_sync.py @@ -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