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:
Andrey Epifancev 2026-10-05 16:29:53 +04:00
parent 9526a1266b
commit 9b8b8ea727
19 changed files with 845 additions and 74 deletions

View File

@ -79,7 +79,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
кооперативный, → `TIMEOUT`) — `core/config.py`.
кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют
лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23).
**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы
в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`).

View File

@ -113,7 +113,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| Модуль | Что делает |
|---|---|
| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение |
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») |
| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
@ -121,7 +121,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень |
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение → лист за листом → статус и метрики |
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
| `cli.py` | разбор одного файла из командной строки (см. «Запуск на файле») |
@ -141,10 +141,10 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF) |
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB либо размер больше `PARSE_MAX_BYTES` |
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение` |
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
@ -185,10 +185,22 @@ groups:
| Настройка | По умолчанию | Что делает |
|---|---|---|
| `PARSE_MAX_BYTES` | 50 МиБ | файл больше → `UNSUPPORTED_FORMAT`, книга не открывается |
| `PARSE_TIMEOUT_SECONDS` | 120 | кооперативный таймаут между листами, стратегиями иерархии и блоками строк → `TIMEOUT`; зависание внутри библиотеки чтения он не прерывает — там работает лимит размера |
| `PARSE_TIMEOUT_SECONDS` | 120 | кооперативный таймаут: проверяется между листами, в циклах по всему листу (поиск заголовка таблицы, дополнительные колонки, строки таблицы, стратегии иерархии) → `TIMEOUT`. Чтение книги библиотекой (openpyxl/xlrd/pyxlsb) он **не** прерывает |
Фазу чтения таймаут не ограничивает, а лимит размера файла не ограничивает распакованный объём, поэтому до вызова
библиотеки чтения действуют лимиты чтения (`api/src/parser/readers/_limits.py`, ruling R-23) → `UNSUPPORTED_FORMAT`:
| Константа | Значение | Что ограничивает |
|---|---|---|
| `ZIP_UNCOMPRESSED_LIMIT_FACTOR` | 10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × `PARSE_MAX_BYTES` (реальные книги распаковываются в 1,4–7,7 раза) |
| `MERGED_AREA_LIMIT` | 200 000 ячеек | суммарная площадь объединённых ячеек книги: xlsx — потоковым сканом `<mergeCell ref>` до openpyxl, любой формат — после чтения, до индекса merged (реальный максимум 10 090) |
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
**Известные ограничения.** Чтение не защищено от XML-бомб (entity expansion в частях xlsx): `defusedxml` не подключён —
новая зависимость требует решения платформы (OQ в `../docs/context/07-open-questions.md`). Других лимитов памяти фазы
чтения, кроме перечисленных, нет: книга в пределах лимитов читается библиотекой целиком.
## Тесты на реальных данных и golden-снимки
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию

View File

@ -3,7 +3,8 @@
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором).
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для
которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются.
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
@ -50,11 +51,14 @@ def main(argv: Sequence[str] | None = None) -> int:
mode = "режим --check, ничего не записано" if options.check else "снимки записаны"
print(
f"файлов: {len(files)}; без изменений: {report.unchanged}; новых: {len(report.created)}; "
f"изменено: {len(report.changed)}; лишних снимков: {len(report.orphans)} ({mode})",
f"изменено: {len(report.changed)}; лишних снимков: {len(report.orphans)}; сохранено без исходного каталога: "
f"{len(report.protected)} ({mode})",
file=sys.stderr,
)
for orphan in report.orphans:
print(f"лишний снимок{'' if options.check else ' удалён'}: {orphan}", file=sys.stderr)
for kept in report.protected:
print(f"снимок сохранён (нет исходного каталога): {kept}", file=sys.stderr)
return EXIT_DIFFERS if options.check and report.differs else EXIT_OK

View File

@ -10,7 +10,8 @@
нераспознанные колонки таблицы (`extra`).
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним. Буквенные сегменты субсчёта (R-16) оставляют известный
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
многострочное, M-1). Буквенные сегменты субсчёта (R-16) оставляют известный
остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт.
"""
@ -42,8 +43,11 @@ __all__ = [
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
# 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается:
# «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен,
# R-18). Группа 1 — код как в файле (`account_code_raw`)
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$")
# R-18). Группа 1 — код как в файле (`account_code_raw`), группа 2 — название.
# Флаги (M-1): re.ASCII — `\d` только 0–9 (арабско-индийские «٦٠» и полноширинные цифры — не код счёта; явные диапазоны
# `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки
# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
_SEGMENT_SEPARATOR = "."
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
DEADLINE_CHECK_EVERY_ROWS = 500

View File

@ -14,11 +14,14 @@ from enum import StrEnum
from functools import lru_cache
from typing import Literal
from src.parser.deadline import Deadline
from src.parser.errors import InvalidFormatError
from src.parser.grid import Cell, Sheet, column_letter
from src.parser.rules import Pattern, Rules, SumGroup, normalize_text
__all__ = [
"DEADLINE_CHECK_EVERY_ROWS",
"INN_LENGTHS",
"ColumnMap",
"SheetKind",
"TableHeader",
@ -45,8 +48,6 @@ INN_SHARE_PERCENT = 80
PERCENT = 100
INN_MIN_VALUES = 3
INN_LENGTHS = frozenset({10, 12})
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
# Строка групп сумм обязана содержать «обороты» и «сальдо на конец»; «сальдо на начало» может отсутствовать
# (spec «Допустимы: отсутствие группы «начало»», ruling R-12)
REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
@ -54,6 +55,8 @@ REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
MIN_GROUPS = 2
# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
# Кооперативный дедлайн (design D-8, R-23): проверка каждые N строк в циклах по всему листу
DEADLINE_CHECK_EVERY_ROWS = 500
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
_NORMALIZE_CACHE_SIZE = 16_384
@ -207,14 +210,21 @@ def _missing_groups_message(sheet: Sheet, found: set[SumGroup]) -> str:
return f"лист «{sheet.name}»: не найден заголовок таблицы ОСВ — нет групп сумм {missing}"
def _find_group_row(sheet: Sheet, rules: Rules) -> tuple[int, list[_GroupCell]]:
def _check_deadline(deadline: Deadline | None, index: int, where: str) -> None:
"""`deadline.check(where)` на каждой `DEADLINE_CHECK_EVERY_ROWS`-й итерации цикла (с первой); без дедлайна — нет."""
if deadline is not None and index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check(where)
def _find_group_row(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> tuple[int, list[_GroupCell]]:
"""Первая сверху строка с «оборотами» и «сальдо на конец» («начало» — по возможности).
Строка, где нет одной из обязательных групп (например, преамбула «Сальдо на начало: …» | «Сальдо на конец: …»),
пропускается. Не нашлось — `InvalidFormatError` с группами, недостающими в лучшей строке-кандидате.
"""
best: set[SumGroup] = set()
for row in range(1, sheet.n_rows + 1):
for index, row in enumerate(range(1, sheet.n_rows + 1)):
_check_deadline(deadline, index, f"лист «{sheet.name}»: поиск заголовка таблицы")
cells = _group_cells(sheet, row, rules)
groups = {cell.group for cell in cells}
if REQUIRED_GROUPS <= groups:
@ -332,24 +342,22 @@ def _text_col(sheet: Sheet, first_row: int, last_row: int, first_sum: int) -> in
def inn_from_cell(cell: Cell) -> str | None:
"""ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None`
(строгое правило: им детектор решает, что колонка — колонка ИНН).
"""ИНН из ячейки: ровно 10 или 12 цифр, иначе `None`
(строгое правило: им детектор решает по значениям, что колонка — колонка ИНН).
Извлечение значения из ячеек уже выбранной колонки (в том числе из текста с обрамлением, «ИНН 3528052718») —
`numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные текстовые колонки
(номера договоров, телефоны) начнут выглядеть как колонка ИНН.
Извлечение значения из ячеек уже выбранной колонки (текст с обрамлением «ИНН 3528052718», число с потерянным
ведущим нулём) — `numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные
текстовые колонки (номера договоров, телефоны) начнут выглядеть как колонка ИНН, а числовая колонка КПП (9 цифр)
после дополнения нулём — как колонка ИНН с потерянными нулями (R-24).
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты; если при записи числом
потерян ведущий ноль (ИНН регионов 01–09: 9 или 11 цифр), он восстанавливается. Текст из 9/11 цифр не дополняется:
в тексте ноль не теряется.
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты, цифры как есть: 9/11
цифр числом здесь не дополняются (ruling R-24).
"""
value = cell.value
if isinstance(value, Decimal):
if not value.is_finite() or value != value.to_integral_value() or value < 0:
return None
digits = str(int(value))
if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO:
digits = digits.zfill(len(digits) + 1)
else:
digits = cell.text
return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None
@ -393,9 +401,13 @@ def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int
return labels
def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader:
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного."""
group_row, group_cells = _find_group_row(sheet, rules)
def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> TableHeader:
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного.
`deadline` проверяется в циклах по всему листу (поиск строки групп, сбор дополнительных колонок); без него —
не проверяется (чистая функция для тестов).
"""
group_row, group_cells = _find_group_row(sheet, rules, deadline)
found = {cell.group for cell in group_cells}
by_group = {group: _columns_of_group(sheet, group_row, group_cells, group, rules) for group in found}
@ -437,7 +449,8 @@ def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader:
partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, [])
occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set())
extra_cols: dict[int, str] = {}
for row in _data_rows(sheet, last_row):
for position, row in enumerate(_data_rows(sheet, last_row)):
_check_deadline(deadline, position, f"лист «{sheet.name}»: дополнительные колонки")
if is_header_repeat(sheet, row, partial, rules):
continue
for col, _ in sheet.row_cells(row):

View File

@ -19,6 +19,9 @@ from src.parser.grid import Cell
__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
MONEY_Q = Decimal("0.01")
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
# извлечении из выбранной колонки ИНН, ruling R-24)
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
_ZERO = Decimal("0.00")
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
@ -101,7 +104,9 @@ def to_money(cell: Cell) -> MoneyParse:
def inn_digits(cell: Cell) -> str | None:
"""ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `counterparty_inn` (Task 8 вызывает её).
1. Строгое правило детектора `detect.inn_from_cell` (10/12 цифр; число с потерянным ведущим нулём дополняется).
1. Строгое правило детектора `detect.inn_from_cell` (ровно 10/12 цифр); целое число из 9/11 цифр — ИНН региона
01–09, записанный числом с потерянным ведущим нулём: дополняется нулём слева. Дополнение — только здесь, при
извлечении из колонки, уже выбранной по заголовку «ИНН» или по строгому правилу значений (ruling R-24).
2. Текстовая ячейка с обрамлением («ИНН 352819549855», «ИНН: 3528052718», «3528 052718»): в тексте должна быть
ровно одна запись цифр — группы, разделённые только пробелом/NBSP/узким пробелом (T7.4); группы склеиваются
и принимаются, если цифр ровно 10 или 12. Цифры, разделённые иным знаком («№ 12 от 01.02.2025»,
@ -114,6 +119,8 @@ def inn_digits(cell: Cell) -> str | None:
strict = inn_from_cell(cell)
if strict is not None:
return strict
if cell.kind == "number":
return _restored_leading_zero(cell)
if cell.kind != "text":
return None
found = _SINGLE_DIGIT_RUN.match(cell.text)
@ -121,3 +128,12 @@ def inn_digits(cell: Cell) -> str | None:
return None
digits = _DIGIT_GROUP_SEPARATOR.sub("", found.group(1))
return digits if len(digits) in INN_LENGTHS else None
def _restored_leading_zero(cell: Cell) -> str | None:
"""Целое неотрицательное число из 9/11 цифр → 10/12 цифр с ведущим нулём; иначе `None`."""
value = cell.value
if not isinstance(value, Decimal) or not value.is_finite() or value != value.to_integral_value() or value < 0:
return None
digits = str(int(value))
return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None

View File

@ -7,14 +7,18 @@
сообщения, поиск по нему не ведётся.
Карта исходов (D-6): нет листов ОСВ → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок/иерархии → `INVALID_FORMAT`
(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB или размер больше лимита →
(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB, размер больше лимита или превышены
лимиты чтения (распакованный zip > `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`, площадь объединённых ячеек, R-23) →
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой
`Тип: сообщение`. Нарушение §1.4 хотя бы на одном листе → `needs_check` всего результата.
`Тип: сообщение` (стек — в лог loguru уровня ERROR; `UNREADABLE` — WARNING без стека). Нарушение §1.4 хотя бы на
одном листе → `needs_check` всего результата.
"""
import time
from collections.abc import Callable, Iterable
from loguru import logger
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser import ParseOutcome
@ -28,6 +32,7 @@ from src.parser.header import DocHeader, build_group_schema, parse_doc_header
from src.parser.hierarchy import resolve_levels
from src.parser.readers import read_book
from src.parser.readers._common import one_line_reason
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
from src.parser.rules import Rules
__all__ = ["OsvParser"]
@ -144,9 +149,14 @@ class OsvParser:
try:
documents, ok = self._parse_book(data, file_type, warnings)
except ParserError as exc:
if exc.code is ErrorCode.UNREADABLE:
logger.warning("Книга не читается: {reason}", reason=exc.message)
return self._error(exc.code, exc.message, warnings, started)
except Exception as exc: # D-6: непредвиденное — RECOGNITION_FAILED, а не исключение в воркер
return self._error(ErrorCode.RECOGNITION_FAILED, one_line_reason(exc), warnings, started)
reason = one_line_reason(exc)
# Ответ — одна строка; стек нужен разработчику, а воркер логирует только код ошибки (I-2)
logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason)
return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started)
metrics = Metrics(
duration_ms=self._elapsed_ms(started),
rows=sum(_data_rows(document) for document in documents),
@ -162,7 +172,7 @@ class OsvParser:
rules = self._rules
step = "заголовок таблицы"
try:
header = find_table_header(sheet, rules)
header = find_table_header(sheet, rules, deadline)
step = "шапка документа"
doc = parse_doc_header(sheet, header, rules)
schema, _ = build_group_schema(sheet, header, rules)
@ -193,7 +203,9 @@ class OsvParser:
"открывается"
)
deadline = Deadline(self._timeout_seconds, self._clock)
sheets, read_warnings = read_book(data, file_type)
sheets, read_warnings = read_book(
data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes
)
warnings.extend(_unique(read_warnings))
documents: list[OsvDocument] = []
ok = True

View File

@ -3,6 +3,10 @@
PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве есть `xl/workbook.bin`); OLE2 (D0 CF 11 E0 …) → XLS (BIFF8).
Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
Лимиты фазы чтения (`_limits.py`, ruling R-23) проверяются до библиотеки чтения: распакованный размер zip
(`max_uncompressed_bytes`, по умолчанию `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` из настроек по умолчанию)
и площадь объединённых ячеек xlsx; после чтения — площадь объединённых ячеек любого формата.
"""
import io
@ -10,14 +14,21 @@ import zipfile
from collections.abc import Callable
from src.contract import FileType
from src.core.config import Settings
from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError
from src.parser.grid import Sheet
from src.parser.readers._common import one_line_reason
from src.parser.readers._limits import (
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
check_merged_area,
check_xlsx_merged_area,
check_zip_uncompressed_size,
)
from src.parser.readers.xls import read_xls
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
from src.parser.readers.xlsx import read_xlsx
__all__ = ["read_book"]
__all__ = ["default_uncompressed_limit", "read_book"]
ZIP_SIGNATURE = b"PK\x03\x04"
OLE2_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
@ -31,9 +42,22 @@ _READERS: dict[FileType, tuple[str, Callable[[bytes], tuple[Sheet, ...]]]] = {
}
def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list[str]]:
"""Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb)."""
detected = _detect(data, file_type)
def default_uncompressed_limit() -> int:
"""Лимит распакованного zip без явного параметра: `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` по умолчанию."""
max_bytes: int = Settings.model_fields["PARSE_MAX_BYTES"].default
return ZIP_UNCOMPRESSED_LIMIT_FACTOR * max_bytes
def read_book(
data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None
) -> tuple[tuple[Sheet, ...], list[str]]:
"""Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb).
`max_uncompressed_bytes` — лимит распакованного zip (xlsx/xlsb); `OsvParser` передаёт
`ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`. Превышение лимитов чтения — `UnsupportedFormatError`.
"""
limit = default_uncompressed_limit() if max_uncompressed_bytes is None else max_uncompressed_bytes
detected = _detect(data, file_type, limit)
content, reader = _READERS[detected]
warnings: list[str] = []
if detected != file_type:
@ -44,19 +68,26 @@ def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list
raise
except Exception as exc: # библиотеки чтения бросают что угодно: zipfile, KeyError, struct.error, XLRDError…
raise UnreadableError(f"книга не читается ({detected}): {one_line_reason(exc)}") from exc
check_merged_area(sheets)
if detected == "XLSB":
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
return sheets, warnings
def _detect(data: bytes, file_type: FileType) -> FileType:
def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType:
"""Формат по сигнатуре; для zip — заодно лимиты распакованного размера и (xlsx) объединённых ячеек."""
if data.startswith(OLE2_SIGNATURE):
return "XLS"
if data.startswith(ZIP_SIGNATURE):
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
names = set(archive.namelist())
except (zipfile.BadZipFile, OSError, EOFError) as exc:
check_zip_uncompressed_size(archive, uncompressed_limit)
detected: FileType = "XLSB" if XLSB_WORKBOOK_PART in set(archive.namelist()) else "XLSX"
if detected == "XLSX":
check_xlsx_merged_area(archive)
except ParserError:
raise
except Exception as exc: # zipfile, zlib.error, NotImplementedError (метод сжатия), RuntimeError (пароль)…
raise UnreadableError(f"повреждённый zip-архив: {one_line_reason(exc)}") from exc
return "XLSB" if XLSB_WORKBOOK_PART in names else "XLSX"
return detected
raise UnsupportedFormatError(f"содержимое файла не XLS/XLSX/XLSB (file_type={file_type})")

View 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)

View File

@ -6,6 +6,15 @@ from pathlib import Path
FIXTURES_ENV = "ARM_KA_FIXTURES"
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
_SYNTHETIC = "synthetic"
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
_IGNORED_PREFIXES = (".", "~$")
def _is_workbook(path: Path, base: Path) -> bool:
"""Файл книги Excel под `base`: расширение xls/xlsx/xlsb, ни файл, ни каталоги на пути — не скрытые и не lock."""
if not path.is_file() or path.suffix.lower() not in OSV_EXTENSIONS:
return False
return not any(part.startswith(_IGNORED_PREFIXES) for part in path.relative_to(base).parts)
def resolve_fixtures_dir() -> Path:
@ -35,26 +44,18 @@ def synthetic_skip_reason() -> str | None:
def osv_fixture_files(root: Path) -> list[Path]:
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic; отсортированы."""
"""Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic, скрытых и lock-файлов; отсортированы."""
osv = root / "osv"
if not osv.is_dir():
return []
synthetic = osv / _SYNTHETIC
return sorted(
path
for path in osv.rglob("*")
if path.is_file() and path.suffix.lower() in OSV_EXTENSIONS and synthetic not in path.parents
)
return sorted(path for path in osv.rglob("*") if _is_workbook(path, osv) and synthetic not in path.parents)
def synthetic_files(root: Path) -> list[Path]:
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые и пояснения вроде `README.md` не считаются);
отсортированы."""
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
считаются); отсортированы."""
directory = root / "osv" / _SYNTHETIC
if not directory.is_dir():
return []
return sorted(
path
for path in directory.iterdir()
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in OSV_EXTENSIONS
)
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))

View File

@ -40,6 +40,8 @@ __all__ = [
SNAPSHOT_DIR = "expected_osv_parse"
GOLDEN_DIR = "golden"
OSV_DIR = "osv"
SYNTHETIC_DIR = "synthetic"
REAL_PART = "real"
JSON_INDENT = 2
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
@ -110,6 +112,7 @@ class SyncReport:
created: list[Path] = field(default_factory=list)
changed: list[Path] = field(default_factory=list)
orphans: list[Path] = field(default_factory=list)
protected: list[Path] = field(default_factory=list) # снимки без исходного каталога: не удаляются (T12.3)
unchanged: int = 0
diffs: list[str] = field(default_factory=list)
@ -122,10 +125,15 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
"""Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`) и остальные (реальные
файлы `osv/`). Если в `files` нет ни одного файла части (каталог отсутствует или пуст — например, fixtures без
синтетики), её снимки не лишние: они попадают в `report.protected` и не удаляются (T12.3).
Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
"""
report = SyncReport()
expected: set[Path] = set()
files = list(files)
present_parts = {_source_part(file.relative_to(fixtures_root / OSV_DIR)) for file in files}
for file in files:
target = golden_path(fixtures_root, file)
expected.add(target)
@ -146,13 +154,22 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
target.write_text(current, encoding="utf-8")
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
if stale not in expected:
report.orphans.append(stale.relative_to(fixtures_root))
if write:
stale.unlink()
if stale in expected:
continue
if _source_part(stale.relative_to(snapshots)) not in present_parts:
report.protected.append(stale.relative_to(fixtures_root))
continue
report.orphans.append(stale.relative_to(fixtures_root))
if write:
stale.unlink()
return report
def _source_part(relative: Path) -> str:
"""Часть дерева по пути относительно `osv/` (или каталога снимков): `synthetic` или `real`."""
return SYNTHETIC_DIR if relative.parts and relative.parts[0] == SYNTHETIC_DIR else REAL_PART
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
snapshot = golden_path(fixtures_root, file)

View File

@ -144,6 +144,32 @@ def test_collapsed_parent_with_wrong_net_is_violation() -> None:
assert parent_sums(rows) == [12]
def test_collapsed_parent_on_the_opposite_side_is_violation() -> None:
"""T9.1 (R-21): свёрнутое сальдо родителя на противоположной стороне (16 Кт при нетто детей +16 Дт) — нарушение:
нетто сравнивается со знаком, совпадения модуля мало."""
rows = [
account(12, (None, "16", "134", "111", "39", None)),
row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
]
assert parent_sums(rows) == [12]
def test_zero_parent_with_children_netting_to_zero_is_accepted() -> None:
"""T9.1 (R-21, осознанное поведение): родитель (0, 0) при детях (X Дт, X Кт) — без нарушения: нулевое сальдо
родителя — свёрнутое (не больше одной ненулевой стороны), нетто детей X − X = 0. Остаточный риск описан в спеке
(§1.4): потерянная пара сальдо у родителя этой проверкой не ловится."""
rows = [
account(12, ("0", "0", "134", "111", "23", None)),
row(13, ("50", None, "131", "111", "70", None), level=1, parent=12, group_type="ACCOUNT"),
row(22, (None, "50", "3", None, None, "47"), level=1, parent=12, group_type="ACCOUNT"),
]
assert balance_identity(rows) == []
assert parent_sums(rows) == []
def test_expanded_parent_balance_must_match_by_columns() -> None:
"""Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно."""
rows = [

View File

@ -169,6 +169,38 @@ def test_account_re_residual_false_positive_documented() -> None:
assert ACCOUNT_RE.match("12.ООО") is not None
def test_account_re_accepts_multiline_name_after_comma() -> None:
"""M-1: многострочная ячейка «60.01, Расчеты\nс поставщиками» — счёт 60.01 (re.DOTALL), а не контрагент."""
found = ACCOUNT_RE.match("60.01, Расчеты\nс поставщиками")
assert found is not None
assert found.group(1) == "60.01"
assert found.group(2) == "Расчеты\nс поставщиками"
@pytest.mark.parametrize("text", ["٦٠.01", "60.٠١", "60", "60.01\nРасчеты"])
def test_account_re_accepts_only_ascii_digits(text: str) -> None:
"""M-1: re.ASCII — `\\d` только 0–9 (арабско-индийские и полноширинные цифры — не код счёта); без запятой
перевод строки после кода не делает строку счётом."""
assert ACCOUNT_RE.match(text) is None
def test_multiline_account_row_keeps_children_group_types(rules: Rules) -> None:
"""M-1: многострочное имя субсчёта не сдвигает group_type детей (контрагент остаётся контрагентом)."""
sheet = osv(
r(0, "60", S100),
r(1, "60.01, Расчеты\nс поставщиками", S100),
r(2, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого", S100),
)
parsed = parse(sheet, rules)
subaccount = parsed.one("60.01, Расчеты\nс поставщиками")
assert (subaccount.group_type, subaccount.account_code_raw, subaccount.body.account_rank) == ("ACCOUNT", "60.01", 2)
assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY"
def test_letter_subaccount_is_account_with_children(rules: Rules) -> None:
"""R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор."""
sheet = osv(

View File

@ -8,7 +8,9 @@ from decimal import Decimal
import pytest
from gridbuilder import L, M, at, sheet_from_rows
from src.parser.deadline import Deadline
from src.parser.detect import (
DEADLINE_CHECK_EVERY_ROWS,
ColumnMap,
SheetKind,
TableHeader,
@ -18,8 +20,9 @@ from src.parser.detect import (
inn_from_cell,
is_header_repeat,
)
from src.parser.errors import InvalidFormatError
from src.parser.errors import InvalidFormatError, ParseTimeoutError
from src.parser.grid import Cell, Sheet
from src.parser.numbers import inn_digits
from src.parser.rules import Rules, load_rules
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
@ -669,8 +672,10 @@ def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
@pytest.mark.parametrize(
("value", "expected"),
[
(Decimal("123456789"), "0123456789"), # ИНН региона 01–09 числом: ведущий ноль потерян
(Decimal("12345678901"), "012345678901"),
# R-24: правило детектора не дополняет 9/11 цифр нулём — иначе числовая колонка КПП (9 цифр) сошла бы за
# колонку ИНН; ведущий ноль восстанавливает только извлечение `numbers.inn_digits` из выбранной колонки
(Decimal("123456789"), None),
(Decimal("12345678901"), None),
(Decimal("3523023093"), "3523023093"),
(Decimal("770123456789"), "770123456789"),
(Decimal("3523023093.0"), "3523023093"),
@ -682,25 +687,76 @@ def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
("не указан", None),
],
)
def test_inn_from_cell_pads_numeric_inn_that_lost_leading_zero(value: object, expected: str | None) -> None:
def test_inn_from_cell_is_strict_and_does_not_pad(value: object, expected: str | None) -> None:
cell = Cell(value, "number") if isinstance(value, Decimal) else Cell(value, "text") # type: ignore[arg-type]
assert inn_from_cell(cell) == expected
def test_inn_column_of_numbers_with_lost_leading_zeros(rules: Rules) -> None:
def _numeric_inn_rows(inn_title: str | None) -> list[list[object]]:
"""ИНН записаны числами, у трёх из четырёх потерян ведущий ноль (9 цифр); `inn_title` — заголовок колонки."""
rows: list[list[object]] = [
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", inn_title, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
return rows
def test_inn_column_of_numbers_with_lost_leading_zeros_found_by_header(rules: Rules) -> None:
"""T4.3 + R-24: колонка с заголовком «ИНН» выбирается по заголовку; значения дополняются нулём при извлечении."""
sheet = sheet_from_rows(_numeric_inn_rows("ИНН"))
header = find_table_header(sheet, rules)
assert [header.inn_col] == [2]
assert header.extra_cols == {}
data = range(header.last_row + 1, sheet.n_rows + 1)
assert [inn_digits(sheet.cell(row, 2)) for row in data] == ["0123456789", "0234567890", "0345678901", "7701234567"]
def test_numeric_column_with_lost_leading_zeros_without_header_is_not_inn(rules: Rules) -> None:
"""R-24: без заголовка «ИНН» колонка из 9/10-значных чисел — не колонка ИНН (по значениям нужно ≥ 80 % ровно
10/12 цифр), она уходит в extra."""
header = find_table_header(sheet_from_rows(_numeric_inn_rows(None)), rules)
assert header.inn_col is None
assert header.extra_cols == {2: "B"}
def test_numeric_kpp_column_is_not_taken_for_inn_column(rules: Rules) -> None:
"""M-3: числовой КПП (9 цифр) левее текстовой колонки ИНН, обе без заголовка: ИНН — колонка с 10/12 цифрами,
КПП не выбирается (дополнение нулём превратило бы КПП в «0» + КПП)."""
rows: list[list[object]] = [
[TITLE],
["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
pairs = [(770101001, "7701234567"), (352801001, "3528052718"), (500101001, "5001012345"), (771401001, "7714012345")]
for index, (kpp, inn) in enumerate(pairs):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: inn, 4: 1, 9: 1})])
header = find_table_header(sheet_from_rows(rows), rules)
assert [header.inn_col] == [3]
assert header.extra_cols == {2: "B"}
def test_numeric_kpp_column_alone_is_not_an_inn_column(rules: Rules) -> None:
rows: list[list[object]] = [
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
for index, kpp in enumerate([770101001, 352801001, 500101001, 771401001]):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: 1, 8: 1})])
header = find_table_header(sheet_from_rows(rows), rules)
assert [header.inn_col] == [2]
assert header.extra_cols == {}
assert header.inn_col is None
assert header.extra_cols == {2: "B"}
def test_too_few_inn_like_values_do_not_make_an_inn_column(rules: Rules) -> None:
@ -737,3 +793,34 @@ def test_vertically_merged_collapsed_title_does_not_reach_junk_to_the_right(rule
assert header.columns.collapsed == frozenset({"closing"})
assert (header.columns.closing_debit, header.columns.closing_credit) == (6, None)
assert [header.right_edge] == [6]
# --- Кооперативный дедлайн в долгих циклах (R-23, I-1) ---
class _ClockExpiringAfter:
"""Часы-заглушка: первые `calls` обращений — время 0, дальше — далеко за лимитом."""
def __init__(self, calls: int) -> None:
self._left = calls
def __call__(self) -> float:
self._left -= 1
return 0.0 if self._left >= 0 else 1e9
def test_group_row_search_checks_deadline(rules: Rules) -> None:
sheet = sheet_from_rows([[f"строка {index}"] for index in range(2 * DEADLINE_CHECK_EVERY_ROWS)])
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
find_table_header(sheet, rules, Deadline(1, clock=_ClockExpiringAfter(1)))
with pytest.raises(InvalidFormatError): # без дедлайна — прежнее поведение
find_table_header(sheet, rules)
def test_extra_columns_scan_checks_deadline(rules: Rules) -> None:
# Создание дедлайна и одна проверка при поиске строки групп — в лимите; сбор extra — уже нет
with pytest.raises(ParseTimeoutError, match="дополнительные колонки"):
find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(2)))
header = find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(10**6)))
assert header == find_table_header(_rb_like(), rules) # дедлайн в лимите не меняет результат

View File

@ -193,7 +193,7 @@ def test_too_large_text_is_none_with_warning() -> None:
(text("352819549855"), "352819549855"),
(text(" 352819549855 "), "352819549855"),
(num("352819549855"), "352819549855"),
(num("123456789"), "0123456789"), # потерян ведущий ноль: общее правило detect.inn_from_cell
(num("123456789"), "0123456789"), # потерян ведущий ноль: восстанавливает извлечение (R-24)
(text("123"), None),
(num("123"), None),
(text("ИНН 352819549855"), "352819549855"), # R-15: цифры из обрамляющего текста
@ -221,11 +221,19 @@ def test_inn_digits(cell: Cell, expected: str | None) -> None:
def test_inn_digits_is_superset_of_strict_detector_rule() -> None:
cells = (text("3528052718"), text(" 352819549855 "), num("123456789"), num("3528052718.0"), num("352819549855"))
cells = (text("3528052718"), text(" 352819549855 "), num("3528052718.0"), num("352819549855"))
for cell in cells:
assert detect.inn_from_cell(cell) is not None
assert inn_digits(cell) == detect.inn_from_cell(cell)
def test_only_extraction_restores_lost_leading_zero() -> None:
"""R-24: ведущий ноль числа из 9/11 цифр восстанавливает извлечение из уже выбранной колонки ИНН, а не правило
детектора, которым колонка выбирается по значениям (иначе числовой КПП сошёл бы за ИНН)."""
for cell, padded in ((num("123456789"), "0123456789"), (num("12345678901"), "012345678901")):
assert detect.inn_from_cell(cell) is None
assert inn_digits(cell) == padded
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
assert detect.inn_from_cell(text("ИНН 352819549855")) is None

View File

@ -9,19 +9,23 @@ import io
import itertools
import shutil
import time
from collections.abc import Callable, Sequence
import traceback
from collections.abc import Callable, Iterator, Sequence
from decimal import Decimal
from pathlib import Path
from typing import Any
import openpyxl
import pytest
from gridbuilder import L, M
from gridbuilder import L, M, sheet_from_rows
from loguru import logger
from result_contract import assert_valid_result
from src.contract import ErrorCode, OsvDocument, ResultStatus
from src.parser import ParseOutcome, hierarchy
from src.parser.classify import SUM_FIELDS
from src.parser.deadline import Deadline
from src.parser.errors import ParseTimeoutError
from src.parser.hierarchy import BodyRow
from src.parser.osv import OsvParser
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
@ -401,6 +405,68 @@ def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, mo
assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
@pytest.fixture
def log_records() -> Iterator[list[Any]]:
"""Записи loguru, выпущенные за время теста."""
records: list[Any] = []
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
yield records
logger.remove(handler_id)
def test_unexpected_exception_is_logged_with_traceback(
rules: Rules, monkeypatch: pytest.MonkeyPatch, log_records: list[Any]
) -> None:
"""I-2: ответ прежний (RECOGNITION_FAILED одной строкой), но стек непредвиденной ошибки уходит в лог."""
def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
raise ValueError("сбой стратегии\nвторая строка")
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
(record,) = [record for record in log_records if record["level"].name == "ERROR"]
assert "Непредвиденная ошибка разбора" in record["message"]
assert "ValueError: сбой стратегии вторая строка" in record["message"]
exception = record["exception"]
assert exception is not None
assert exception.type is ValueError
assert exception.traceback is not None
assert "broken" in "".join(traceback.format_tb(exception.traceback))
def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, log_records: list[Any]) -> None:
outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNREADABLE
(record,) = [record for record in log_records if record["level"].name == "WARNING"]
assert outcome.error.message in record["message"]
assert record["exception"] is None
assert not [record for record in log_records if record["level"].name == "ERROR"]
def test_expected_errors_other_than_unreadable_are_not_logged(rules: Rules, log_records: list[Any]) -> None:
outcome = parser(rules).parse(b"%PDF-1.7", "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
assert not [record for record in log_records if record["level"].no >= logger.level("WARNING").no]
def test_header_search_is_interrupted_by_deadline(rules: Rules) -> None:
"""R-23 (в): дедлайн из `parse_sheet` доходит до поиска заголовка таблицы — цикла по всему листу."""
sheet = sheet_from_rows([[TITLE], *([["строка"]] * 1000)])
deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__)
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
parser(rules).parse_sheet(sheet, deadline)
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
NEW_OPENING = "Нач. остаток"

View 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] == ["ОСВ"]

View File

@ -45,6 +45,26 @@ def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None:
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
def test_hidden_and_excel_lock_files_are_ignored(tmp_path: Path) -> None:
"""T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры."""
osv = tmp_path / "osv"
(osv / "rb" / ".cache").mkdir(parents=True)
(osv / "synthetic").mkdir()
for path in (
osv / "rb" / "b.xlsx",
osv / "rb" / "~$b.xlsx",
osv / "rb" / ".b.xlsx",
osv / "rb" / ".cache" / "c.xlsx",
osv / "synthetic" / "s.xlsx",
osv / "synthetic" / "~$s.xlsx",
osv / "synthetic" / ".s.xls",
):
path.write_bytes(b"")
assert osv_fixture_files(tmp_path) == [osv / "rb" / "b.xlsx"]
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
def test_skip_reason_mentions_env_and_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
missing = tmp_path / "нет-такого"
monkeypatch.setenv(FIXTURES_ENV, str(missing))

View File

@ -1,5 +1,6 @@
"""`sync_snapshots` и `scripts/golden_update.py`: запись, сравнение, diff, лишние снимки (на временном каталоге)."""
import shutil
import sys
from pathlib import Path
@ -91,6 +92,46 @@ def test_orphan_snapshot_is_reported_and_removed_on_write(root: Path) -> None:
assert not orphan.exists()
def test_snapshots_are_kept_when_their_source_directory_is_absent(root: Path) -> None:
"""T12.3: без `osv/synthetic` (или без реальных файлов) снимки этой части — не «лишние»: запись их не удаляет,
сверка о них не сообщает как о расхождении, но перечисляет как сохранённые."""
parser = get_parser()
sync_snapshots(root, files_of(root), parser, write=True)
synthetic_snapshots = sorted((root / "golden" / "expected_osv_parse" / "synthetic").glob("*.json"))
real_snapshot = root / "golden" / "expected_osv_parse" / "rb" / "2024-Q1" / "сч 60.json"
real_snapshot.parent.mkdir(parents=True)
real_snapshot.write_text("{}\n", encoding="utf-8")
shutil.rmtree(root / "osv" / "synthetic")
report = sync_snapshots(root, [], parser, write=True)
assert report.orphans == []
assert not report.differs
assert report.protected == sorted(
[*(path.relative_to(root) for path in synthetic_snapshots), real_snapshot.relative_to(root)]
)
assert all(path.is_file() for path in synthetic_snapshots)
assert real_snapshot.is_file()
def test_orphan_is_removed_only_within_a_present_source_directory(root: Path) -> None:
"""Реальных файлов нет, синтетика есть: лишний синтетический снимок удаляется, реальные снимки сохраняются."""
parser = get_parser()
real_snapshot = root / "golden" / "expected_osv_parse" / "rb" / "сч 60.json"
real_snapshot.parent.mkdir(parents=True)
real_snapshot.write_text("{}\n", encoding="utf-8")
orphan = root / "golden" / "expected_osv_parse" / "synthetic" / "s99_gone.json"
orphan.parent.mkdir(parents=True)
orphan.write_text("{}\n", encoding="utf-8")
report = sync_snapshots(root, files_of(root), parser, write=True)
assert report.orphans == [orphan.relative_to(root)]
assert report.protected == [real_snapshot.relative_to(root)]
assert not orphan.exists()
assert real_snapshot.is_file()
def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None:
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
file = files_of(root)[0]
@ -127,6 +168,11 @@ def test_script_check_exit_codes(
assert golden_update.main(["--check"]) == 1
assert golden_update.main([]) == 0
assert golden_update.main(["--check"]) == 0
shutil.rmtree(root / "osv" / "synthetic")
capsys.readouterr()
assert golden_update.main([]) == 0
assert "снимок сохранён (нет исходного каталога)" in capsys.readouterr().err
assert (root / "golden" / "expected_osv_parse" / "synthetic" / "s01_variant2_header.json").is_file()
monkeypatch.setenv("ARM_KA_FIXTURES", str(root / "нет"))
capsys.readouterr()
assert golden_update.main([]) == golden_update.EXIT_NO_FIXTURES