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; не ослаблять и не расширять без обсуждения.
|
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
|
||||||
|
|
||||||
**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
|
**Настройки:** `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"`; копия схемы
|
**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы
|
||||||
в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`).
|
в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`).
|
||||||
|
|||||||
24
README.md
24
README.md
@ -113,7 +113,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| Модуль | Что делает |
|
| Модуль | Что делает |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
|
| `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` с файлом и ключом при старте |
|
| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
|
||||||
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
|
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
|
||||||
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
|
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
|
||||||
@ -121,7 +121,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
||||||
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
||||||
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень |
|
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень |
|
||||||
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение → лист за листом → статус и метрики |
|
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
|
||||||
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
|
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
|
||||||
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
|
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
|
||||||
| `cli.py` | разбор одного файла из командной строки (см. «Запуск на файле») |
|
| `cli.py` | разбор одного файла из командной строки (см. «Запуск на файле») |
|
||||||
@ -141,10 +141,10 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
|
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
|
||||||
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
|
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
|
||||||
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
|
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
|
||||||
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF) |
|
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
|
||||||
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB либо размер больше `PARSE_MAX_BYTES` |
|
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
|
||||||
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
|
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
|
||||||
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение` |
|
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
|
||||||
|
|
||||||
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
||||||
(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
|
(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
|
||||||
@ -185,10 +185,22 @@ groups:
|
|||||||
| Настройка | По умолчанию | Что делает |
|
| Настройка | По умолчанию | Что делает |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PARSE_MAX_BYTES` | 50 МиБ | файл больше → `UNSUPPORTED_FORMAT`, книга не открывается |
|
| `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 с).
|
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
|
||||||
|
|
||||||
|
**Известные ограничения.** Чтение не защищено от XML-бомб (entity expansion в частях xlsx): `defusedxml` не подключён —
|
||||||
|
новая зависимость требует решения платформы (OQ в `../docs/context/07-open-questions.md`). Других лимитов памяти фазы
|
||||||
|
чтения, кроме перечисленных, нет: книга в пределах лимитов читается библиотекой целиком.
|
||||||
|
|
||||||
## Тесты на реальных данных и golden-снимки
|
## Тесты на реальных данных и golden-снимки
|
||||||
|
|
||||||
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
|
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
|
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
|
||||||
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
|
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
|
||||||
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
|
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
|
||||||
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором).
|
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для
|
||||||
|
которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются.
|
||||||
|
|
||||||
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
|
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
|
||||||
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
|
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
|
||||||
@ -50,11 +51,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
mode = "режим --check, ничего не записано" if options.check else "снимки записаны"
|
mode = "режим --check, ничего не записано" if options.check else "снимки записаны"
|
||||||
print(
|
print(
|
||||||
f"файлов: {len(files)}; без изменений: {report.unchanged}; новых: {len(report.created)}; "
|
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,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
for orphan in report.orphans:
|
for orphan in report.orphans:
|
||||||
print(f"лишний снимок{'' if options.check else ' удалён'}: {orphan}", file=sys.stderr)
|
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
|
return EXIT_DIFFERS if options.check and report.differs else EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
нераспознанные колонки таблицы (`extra`).
|
нераспознанные колонки таблицы (`extra`).
|
||||||
|
|
||||||
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
||||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним. Буквенные сегменты субсчёта (R-16) оставляют известный
|
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
||||||
|
многострочное, M-1). Буквенные сегменты субсчёта (R-16) оставляют известный
|
||||||
остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт.
|
остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -42,8 +43,11 @@ __all__ = [
|
|||||||
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
||||||
# 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается:
|
# 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается:
|
||||||
# «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен,
|
# «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен,
|
||||||
# R-18). Группа 1 — код как в файле (`account_code_raw`)
|
# R-18). Группа 1 — код как в файле (`account_code_raw`), группа 2 — название.
|
||||||
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$")
|
# Флаги (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 = "."
|
_SEGMENT_SEPARATOR = "."
|
||||||
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
|
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
|
||||||
DEADLINE_CHECK_EVERY_ROWS = 500
|
DEADLINE_CHECK_EVERY_ROWS = 500
|
||||||
|
|||||||
@ -14,11 +14,14 @@ from enum import StrEnum
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.errors import InvalidFormatError
|
from src.parser.errors import InvalidFormatError
|
||||||
from src.parser.grid import Cell, Sheet, column_letter
|
from src.parser.grid import Cell, Sheet, column_letter
|
||||||
from src.parser.rules import Pattern, Rules, SumGroup, normalize_text
|
from src.parser.rules import Pattern, Rules, SumGroup, normalize_text
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DEADLINE_CHECK_EVERY_ROWS",
|
||||||
|
"INN_LENGTHS",
|
||||||
"ColumnMap",
|
"ColumnMap",
|
||||||
"SheetKind",
|
"SheetKind",
|
||||||
"TableHeader",
|
"TableHeader",
|
||||||
@ -45,8 +48,6 @@ INN_SHARE_PERCENT = 80
|
|||||||
PERCENT = 100
|
PERCENT = 100
|
||||||
INN_MIN_VALUES = 3
|
INN_MIN_VALUES = 3
|
||||||
INN_LENGTHS = frozenset({10, 12})
|
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)
|
# (spec «Допустимы: отсутствие группы «начало»», ruling R-12)
|
||||||
REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
|
REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
|
||||||
@ -54,6 +55,8 @@ REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
|
|||||||
MIN_GROUPS = 2
|
MIN_GROUPS = 2
|
||||||
# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
|
# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
|
||||||
_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
|
_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
|
||||||
|
# Кооперативный дедлайн (design D-8, R-23): проверка каждые N строк в циклах по всему листу
|
||||||
|
DEADLINE_CHECK_EVERY_ROWS = 500
|
||||||
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
|
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
|
||||||
_NORMALIZE_CACHE_SIZE = 16_384
|
_NORMALIZE_CACHE_SIZE = 16_384
|
||||||
|
|
||||||
@ -207,14 +210,21 @@ def _missing_groups_message(sheet: Sheet, found: set[SumGroup]) -> str:
|
|||||||
return f"лист «{sheet.name}»: не найден заголовок таблицы ОСВ — нет групп сумм {missing}"
|
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` с группами, недостающими в лучшей строке-кандидате.
|
пропускается. Не нашлось — `InvalidFormatError` с группами, недостающими в лучшей строке-кандидате.
|
||||||
"""
|
"""
|
||||||
best: set[SumGroup] = set()
|
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)
|
cells = _group_cells(sheet, row, rules)
|
||||||
groups = {cell.group for cell in cells}
|
groups = {cell.group for cell in cells}
|
||||||
if REQUIRED_GROUPS <= groups:
|
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:
|
def inn_from_cell(cell: Cell) -> str | None:
|
||||||
"""ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None`
|
"""ИНН из ячейки: ровно 10 или 12 цифр, иначе `None`
|
||||||
(строгое правило: им детектор решает, что колонка — колонка ИНН).
|
(строгое правило: им детектор решает по значениям, что колонка — колонка ИНН).
|
||||||
|
|
||||||
Извлечение значения из ячеек уже выбранной колонки (в том числе из текста с обрамлением, «ИНН 3528052718») —
|
Извлечение значения из ячеек уже выбранной колонки (текст с обрамлением «ИНН 3528052718», число с потерянным
|
||||||
`numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные текстовые колонки
|
ведущим нулём) — `numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные
|
||||||
(номера договоров, телефоны) начнут выглядеть как колонка ИНН.
|
текстовые колонки (номера договоров, телефоны) начнут выглядеть как колонка ИНН, а числовая колонка КПП (9 цифр)
|
||||||
|
после дополнения нулём — как колонка ИНН с потерянными нулями (R-24).
|
||||||
|
|
||||||
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты; если при записи числом
|
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты, цифры как есть: 9/11
|
||||||
потерян ведущий ноль (ИНН регионов 01–09: 9 или 11 цифр), он восстанавливается. Текст из 9/11 цифр не дополняется:
|
цифр числом здесь не дополняются (ruling R-24).
|
||||||
в тексте ноль не теряется.
|
|
||||||
"""
|
"""
|
||||||
value = cell.value
|
value = cell.value
|
||||||
if isinstance(value, Decimal):
|
if isinstance(value, Decimal):
|
||||||
if not value.is_finite() or value != value.to_integral_value() or value < 0:
|
if not value.is_finite() or value != value.to_integral_value() or value < 0:
|
||||||
return None
|
return None
|
||||||
digits = str(int(value))
|
digits = str(int(value))
|
||||||
if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO:
|
|
||||||
digits = digits.zfill(len(digits) + 1)
|
|
||||||
else:
|
else:
|
||||||
digits = cell.text
|
digits = cell.text
|
||||||
return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None
|
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
|
return labels
|
||||||
|
|
||||||
|
|
||||||
def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader:
|
def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> TableHeader:
|
||||||
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного."""
|
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного.
|
||||||
group_row, group_cells = _find_group_row(sheet, rules)
|
|
||||||
|
`deadline` проверяется в циклах по всему листу (поиск строки групп, сбор дополнительных колонок); без него —
|
||||||
|
не проверяется (чистая функция для тестов).
|
||||||
|
"""
|
||||||
|
group_row, group_cells = _find_group_row(sheet, rules, deadline)
|
||||||
found = {cell.group for cell in group_cells}
|
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}
|
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, {}, [])
|
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())
|
occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set())
|
||||||
extra_cols: dict[int, str] = {}
|
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):
|
if is_header_repeat(sheet, row, partial, rules):
|
||||||
continue
|
continue
|
||||||
for col, _ in sheet.row_cells(row):
|
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"]
|
__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
|
||||||
|
|
||||||
MONEY_Q = Decimal("0.01")
|
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")
|
_ZERO = Decimal("0.00")
|
||||||
|
|
||||||
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
|
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
|
||||||
@ -101,7 +104,9 @@ def to_money(cell: Cell) -> MoneyParse:
|
|||||||
def inn_digits(cell: Cell) -> str | None:
|
def inn_digits(cell: Cell) -> str | None:
|
||||||
"""ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `counterparty_inn` (Task 8 вызывает её).
|
"""ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `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»): в тексте должна быть
|
2. Текстовая ячейка с обрамлением («ИНН 352819549855», «ИНН: 3528052718», «3528 052718»): в тексте должна быть
|
||||||
ровно одна запись цифр — группы, разделённые только пробелом/NBSP/узким пробелом (T7.4); группы склеиваются
|
ровно одна запись цифр — группы, разделённые только пробелом/NBSP/узким пробелом (T7.4); группы склеиваются
|
||||||
и принимаются, если цифр ровно 10 или 12. Цифры, разделённые иным знаком («№ 12 от 01.02.2025»,
|
и принимаются, если цифр ровно 10 или 12. Цифры, разделённые иным знаком («№ 12 от 01.02.2025»,
|
||||||
@ -114,6 +119,8 @@ def inn_digits(cell: Cell) -> str | None:
|
|||||||
strict = inn_from_cell(cell)
|
strict = inn_from_cell(cell)
|
||||||
if strict is not None:
|
if strict is not None:
|
||||||
return strict
|
return strict
|
||||||
|
if cell.kind == "number":
|
||||||
|
return _restored_leading_zero(cell)
|
||||||
if cell.kind != "text":
|
if cell.kind != "text":
|
||||||
return None
|
return None
|
||||||
found = _SINGLE_DIGIT_RUN.match(cell.text)
|
found = _SINGLE_DIGIT_RUN.match(cell.text)
|
||||||
@ -121,3 +128,12 @@ def inn_digits(cell: Cell) -> str | None:
|
|||||||
return None
|
return None
|
||||||
digits = _DIGIT_GROUP_SEPARATOR.sub("", found.group(1))
|
digits = _DIGIT_GROUP_SEPARATOR.sub("", found.group(1))
|
||||||
return digits if len(digits) in INN_LENGTHS else None
|
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`
|
Карта исходов (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` одной строкой
|
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой
|
||||||
`Тип: сообщение`. Нарушение §1.4 хотя бы на одном листе → `needs_check` всего результата.
|
`Тип: сообщение` (стек — в лог loguru уровня ERROR; `UNREADABLE` — WARNING без стека). Нарушение §1.4 хотя бы на
|
||||||
|
одном листе → `needs_check` всего результата.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
|
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
|
||||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||||
from src.parser import ParseOutcome
|
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.hierarchy import resolve_levels
|
||||||
from src.parser.readers import read_book
|
from src.parser.readers import read_book
|
||||||
from src.parser.readers._common import one_line_reason
|
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
|
from src.parser.rules import Rules
|
||||||
|
|
||||||
__all__ = ["OsvParser"]
|
__all__ = ["OsvParser"]
|
||||||
@ -144,9 +149,14 @@ class OsvParser:
|
|||||||
try:
|
try:
|
||||||
documents, ok = self._parse_book(data, file_type, warnings)
|
documents, ok = self._parse_book(data, file_type, warnings)
|
||||||
except ParserError as exc:
|
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)
|
return self._error(exc.code, exc.message, warnings, started)
|
||||||
except Exception as exc: # D-6: непредвиденное — RECOGNITION_FAILED, а не исключение в воркер
|
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(
|
metrics = Metrics(
|
||||||
duration_ms=self._elapsed_ms(started),
|
duration_ms=self._elapsed_ms(started),
|
||||||
rows=sum(_data_rows(document) for document in documents),
|
rows=sum(_data_rows(document) for document in documents),
|
||||||
@ -162,7 +172,7 @@ class OsvParser:
|
|||||||
rules = self._rules
|
rules = self._rules
|
||||||
step = "заголовок таблицы"
|
step = "заголовок таблицы"
|
||||||
try:
|
try:
|
||||||
header = find_table_header(sheet, rules)
|
header = find_table_header(sheet, rules, deadline)
|
||||||
step = "шапка документа"
|
step = "шапка документа"
|
||||||
doc = parse_doc_header(sheet, header, rules)
|
doc = parse_doc_header(sheet, header, rules)
|
||||||
schema, _ = build_group_schema(sheet, header, rules)
|
schema, _ = build_group_schema(sheet, header, rules)
|
||||||
@ -193,7 +203,9 @@ class OsvParser:
|
|||||||
"открывается"
|
"открывается"
|
||||||
)
|
)
|
||||||
deadline = Deadline(self._timeout_seconds, self._clock)
|
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))
|
warnings.extend(_unique(read_warnings))
|
||||||
documents: list[OsvDocument] = []
|
documents: list[OsvDocument] = []
|
||||||
ok = True
|
ok = True
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве есть `xl/workbook.bin`); OLE2 (D0 CF 11 E0 …) → XLS (BIFF8).
|
PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве есть `xl/workbook.bin`); OLE2 (D0 CF 11 E0 …) → XLS (BIFF8).
|
||||||
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
|
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
|
||||||
исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
|
исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
|
||||||
|
|
||||||
|
Лимиты фазы чтения (`_limits.py`, ruling R-23) проверяются до библиотеки чтения: распакованный размер zip
|
||||||
|
(`max_uncompressed_bytes`, по умолчанию `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` из настроек по умолчанию)
|
||||||
|
и площадь объединённых ячеек xlsx; после чтения — площадь объединённых ячеек любого формата.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@ -10,14 +14,21 @@ import zipfile
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from src.contract import FileType
|
from src.contract import FileType
|
||||||
|
from src.core.config import Settings
|
||||||
from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError
|
from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError
|
||||||
from src.parser.grid import Sheet
|
from src.parser.grid import Sheet
|
||||||
from src.parser.readers._common import one_line_reason
|
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.xls import read_xls
|
||||||
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
|
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
|
||||||
from src.parser.readers.xlsx import read_xlsx
|
from src.parser.readers.xlsx import read_xlsx
|
||||||
|
|
||||||
__all__ = ["read_book"]
|
__all__ = ["default_uncompressed_limit", "read_book"]
|
||||||
|
|
||||||
ZIP_SIGNATURE = b"PK\x03\x04"
|
ZIP_SIGNATURE = b"PK\x03\x04"
|
||||||
OLE2_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
|
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]]:
|
def default_uncompressed_limit() -> int:
|
||||||
"""Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb)."""
|
"""Лимит распакованного zip без явного параметра: `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` по умолчанию."""
|
||||||
detected = _detect(data, file_type)
|
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]
|
content, reader = _READERS[detected]
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if detected != file_type:
|
if detected != file_type:
|
||||||
@ -44,19 +68,26 @@ def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list
|
|||||||
raise
|
raise
|
||||||
except Exception as exc: # библиотеки чтения бросают что угодно: zipfile, KeyError, struct.error, XLRDError…
|
except Exception as exc: # библиотеки чтения бросают что угодно: zipfile, KeyError, struct.error, XLRDError…
|
||||||
raise UnreadableError(f"книга не читается ({detected}): {one_line_reason(exc)}") from exc
|
raise UnreadableError(f"книга не читается ({detected}): {one_line_reason(exc)}") from exc
|
||||||
|
check_merged_area(sheets)
|
||||||
if detected == "XLSB":
|
if detected == "XLSB":
|
||||||
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
|
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
|
||||||
return sheets, warnings
|
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):
|
if data.startswith(OLE2_SIGNATURE):
|
||||||
return "XLS"
|
return "XLS"
|
||||||
if data.startswith(ZIP_SIGNATURE):
|
if data.startswith(ZIP_SIGNATURE):
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||||
names = set(archive.namelist())
|
check_zip_uncompressed_size(archive, uncompressed_limit)
|
||||||
except (zipfile.BadZipFile, OSError, EOFError) as exc:
|
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
|
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})")
|
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"
|
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
||||||
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
||||||
_SYNTHETIC = "synthetic"
|
_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:
|
def resolve_fixtures_dir() -> Path:
|
||||||
@ -35,26 +44,18 @@ def synthetic_skip_reason() -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def osv_fixture_files(root: Path) -> list[Path]:
|
def osv_fixture_files(root: Path) -> list[Path]:
|
||||||
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы."""
|
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic, скрытых и lock-файлов; отсортированы."""
|
||||||
osv = root / "osv"
|
osv = root / "osv"
|
||||||
if not osv.is_dir():
|
if not osv.is_dir():
|
||||||
return []
|
return []
|
||||||
synthetic = osv / _SYNTHETIC
|
synthetic = osv / _SYNTHETIC
|
||||||
return sorted(
|
return sorted(path for path in osv.rglob("*") if _is_workbook(path, osv) and synthetic not in path.parents)
|
||||||
path
|
|
||||||
for path in osv.rglob("*")
|
|
||||||
if path.is_file() and path.suffix.lower() in OSV_EXTENSIONS and synthetic not in path.parents
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def synthetic_files(root: Path) -> list[Path]:
|
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
|
directory = root / "osv" / _SYNTHETIC
|
||||||
if not directory.is_dir():
|
if not directory.is_dir():
|
||||||
return []
|
return []
|
||||||
return sorted(
|
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
|
||||||
path
|
|
||||||
for path in directory.iterdir()
|
|
||||||
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in OSV_EXTENSIONS
|
|
||||||
)
|
|
||||||
|
|||||||
@ -40,6 +40,8 @@ __all__ = [
|
|||||||
SNAPSHOT_DIR = "expected_osv_parse"
|
SNAPSHOT_DIR = "expected_osv_parse"
|
||||||
GOLDEN_DIR = "golden"
|
GOLDEN_DIR = "golden"
|
||||||
OSV_DIR = "osv"
|
OSV_DIR = "osv"
|
||||||
|
SYNTHETIC_DIR = "synthetic"
|
||||||
|
REAL_PART = "real"
|
||||||
JSON_INDENT = 2
|
JSON_INDENT = 2
|
||||||
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
||||||
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
|
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)
|
created: list[Path] = field(default_factory=list)
|
||||||
changed: list[Path] = field(default_factory=list)
|
changed: list[Path] = field(default_factory=list)
|
||||||
orphans: 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
|
unchanged: int = 0
|
||||||
diffs: list[str] = field(default_factory=list)
|
diffs: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@ -122,10 +125,15 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
|
|||||||
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
|
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
|
||||||
|
|
||||||
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
|
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
|
||||||
|
Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`) и остальные (реальные
|
||||||
|
файлы `osv/`). Если в `files` нет ни одного файла части (каталог отсутствует или пуст — например, fixtures без
|
||||||
|
синтетики), её снимки не лишние: они попадают в `report.protected` и не удаляются (T12.3).
|
||||||
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
|
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
|
||||||
"""
|
"""
|
||||||
report = SyncReport()
|
report = SyncReport()
|
||||||
expected: set[Path] = set()
|
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:
|
for file in files:
|
||||||
target = golden_path(fixtures_root, file)
|
target = golden_path(fixtures_root, file)
|
||||||
expected.add(target)
|
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")
|
target.write_text(current, encoding="utf-8")
|
||||||
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
|
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
|
||||||
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
|
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
|
||||||
if stale not in expected:
|
if stale in expected:
|
||||||
report.orphans.append(stale.relative_to(fixtures_root))
|
continue
|
||||||
if write:
|
if _source_part(stale.relative_to(snapshots)) not in present_parts:
|
||||||
stale.unlink()
|
report.protected.append(stale.relative_to(fixtures_root))
|
||||||
|
continue
|
||||||
|
report.orphans.append(stale.relative_to(fixtures_root))
|
||||||
|
if write:
|
||||||
|
stale.unlink()
|
||||||
return report
|
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:
|
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
|
||||||
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
|
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
|
||||||
snapshot = golden_path(fixtures_root, file)
|
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]
|
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:
|
def test_expanded_parent_balance_must_match_by_columns() -> None:
|
||||||
"""Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно."""
|
"""Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно."""
|
||||||
rows = [
|
rows = [
|
||||||
|
|||||||
@ -169,6 +169,38 @@ def test_account_re_residual_false_positive_documented() -> None:
|
|||||||
assert ACCOUNT_RE.match("12.ООО") is not 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:
|
def test_letter_subaccount_is_account_with_children(rules: Rules) -> None:
|
||||||
"""R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор."""
|
"""R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор."""
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
|
|||||||
@ -8,7 +8,9 @@ from decimal import Decimal
|
|||||||
import pytest
|
import pytest
|
||||||
from gridbuilder import L, M, at, sheet_from_rows
|
from gridbuilder import L, M, at, sheet_from_rows
|
||||||
|
|
||||||
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.detect import (
|
from src.parser.detect import (
|
||||||
|
DEADLINE_CHECK_EVERY_ROWS,
|
||||||
ColumnMap,
|
ColumnMap,
|
||||||
SheetKind,
|
SheetKind,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@ -18,8 +20,9 @@ from src.parser.detect import (
|
|||||||
inn_from_cell,
|
inn_from_cell,
|
||||||
is_header_repeat,
|
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.grid import Cell, Sheet
|
||||||
|
from src.parser.numbers import inn_digits
|
||||||
from src.parser.rules import Rules, load_rules
|
from src.parser.rules import Rules, load_rules
|
||||||
|
|
||||||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||||
@ -669,8 +672,10 @@ def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("value", "expected"),
|
("value", "expected"),
|
||||||
[
|
[
|
||||||
(Decimal("123456789"), "0123456789"), # ИНН региона 01–09 числом: ведущий ноль потерян
|
# R-24: правило детектора не дополняет 9/11 цифр нулём — иначе числовая колонка КПП (9 цифр) сошла бы за
|
||||||
(Decimal("12345678901"), "012345678901"),
|
# колонку ИНН; ведущий ноль восстанавливает только извлечение `numbers.inn_digits` из выбранной колонки
|
||||||
|
(Decimal("123456789"), None),
|
||||||
|
(Decimal("12345678901"), None),
|
||||||
(Decimal("3523023093"), "3523023093"),
|
(Decimal("3523023093"), "3523023093"),
|
||||||
(Decimal("770123456789"), "770123456789"),
|
(Decimal("770123456789"), "770123456789"),
|
||||||
(Decimal("3523023093.0"), "3523023093"),
|
(Decimal("3523023093.0"), "3523023093"),
|
||||||
@ -682,25 +687,76 @@ def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
|
|||||||
("не указан", 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]
|
cell = Cell(value, "number") if isinstance(value, Decimal) else Cell(value, "text") # type: ignore[arg-type]
|
||||||
|
|
||||||
assert inn_from_cell(cell) == expected
|
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]] = [
|
rows: list[list[object]] = [
|
||||||
[TITLE],
|
[TITLE],
|
||||||
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
|
||||||
["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||||
]
|
]
|
||||||
for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
|
for index, kpp in enumerate([770101001, 352801001, 500101001, 771401001]):
|
||||||
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
|
rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: 1, 8: 1})])
|
||||||
|
|
||||||
header = find_table_header(sheet_from_rows(rows), rules)
|
header = find_table_header(sheet_from_rows(rows), rules)
|
||||||
|
|
||||||
assert [header.inn_col] == [2]
|
assert header.inn_col is None
|
||||||
assert header.extra_cols == {}
|
assert header.extra_cols == {2: "B"}
|
||||||
|
|
||||||
|
|
||||||
def test_too_few_inn_like_values_do_not_make_an_inn_column(rules: Rules) -> None:
|
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.collapsed == frozenset({"closing"})
|
||||||
assert (header.columns.closing_debit, header.columns.closing_credit) == (6, None)
|
assert (header.columns.closing_debit, header.columns.closing_credit) == (6, None)
|
||||||
assert [header.right_edge] == [6]
|
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"),
|
||||||
(text(" 352819549855 "), "352819549855"),
|
(text(" 352819549855 "), "352819549855"),
|
||||||
(num("352819549855"), "352819549855"),
|
(num("352819549855"), "352819549855"),
|
||||||
(num("123456789"), "0123456789"), # потерян ведущий ноль: общее правило detect.inn_from_cell
|
(num("123456789"), "0123456789"), # потерян ведущий ноль: восстанавливает извлечение (R-24)
|
||||||
(text("123"), None),
|
(text("123"), None),
|
||||||
(num("123"), None),
|
(num("123"), None),
|
||||||
(text("ИНН 352819549855"), "352819549855"), # R-15: цифры из обрамляющего текста
|
(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:
|
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:
|
for cell in cells:
|
||||||
assert detect.inn_from_cell(cell) is not None
|
assert detect.inn_from_cell(cell) is not None
|
||||||
assert inn_digits(cell) == detect.inn_from_cell(cell)
|
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:
|
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
|
||||||
assert detect.inn_from_cell(text("ИНН 352819549855")) is None
|
assert detect.inn_from_cell(text("ИНН 352819549855")) is None
|
||||||
|
|||||||
@ -9,19 +9,23 @@ import io
|
|||||||
import itertools
|
import itertools
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Sequence
|
import traceback
|
||||||
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
import pytest
|
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 result_contract import assert_valid_result
|
||||||
|
|
||||||
from src.contract import ErrorCode, OsvDocument, ResultStatus
|
from src.contract import ErrorCode, OsvDocument, ResultStatus
|
||||||
from src.parser import ParseOutcome, hierarchy
|
from src.parser import ParseOutcome, hierarchy
|
||||||
from src.parser.classify import SUM_FIELDS
|
from src.parser.classify import SUM_FIELDS
|
||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
|
from src.parser.errors import ParseTimeoutError
|
||||||
from src.parser.hierarchy import BodyRow
|
from src.parser.hierarchy import BodyRow
|
||||||
from src.parser.osv import OsvParser
|
from src.parser.osv import OsvParser
|
||||||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
|
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: сбой стратегии вторая строка"
|
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 «Новый синоним без релиза кода») ---
|
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
|
||||||
|
|
||||||
NEW_OPENING = "Нач. остаток"
|
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"]
|
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:
|
def test_skip_reason_mentions_env_and_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
missing = tmp_path / "нет-такого"
|
missing = tmp_path / "нет-такого"
|
||||||
monkeypatch.setenv(FIXTURES_ENV, str(missing))
|
monkeypatch.setenv(FIXTURES_ENV, str(missing))
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""`sync_snapshots` и `scripts/golden_update.py`: запись, сравнение, diff, лишние снимки (на временном каталоге)."""
|
"""`sync_snapshots` и `scripts/golden_update.py`: запись, сравнение, diff, лишние снимки (на временном каталоге)."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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()
|
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:
|
def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None:
|
||||||
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
|
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
|
||||||
file = files_of(root)[0]
|
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(["--check"]) == 1
|
||||||
assert golden_update.main([]) == 0
|
assert golden_update.main([]) == 0
|
||||||
assert golden_update.main(["--check"]) == 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 / "нет"))
|
monkeypatch.setenv("ARM_KA_FIXTURES", str(root / "нет"))
|
||||||
capsys.readouterr()
|
capsys.readouterr()
|
||||||
assert golden_update.main([]) == golden_update.EXIT_NO_FIXTURES
|
assert golden_update.main([]) == golden_update.EXIT_NO_FIXTURES
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user