fix: дедлайн при классификации листов и тесты путей отказа форм (#54)

I-1: plan_sheets проверяет дедлайн на каждом листе (регрессия против main).
I-2: тесты исключения пакета на кандидате (RECOGNITION_FAILED с
предупреждениями файла), TIMEOUT в адаптере и конвейере, RulesError в get_parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 22:04:37 +04:00
parent be5ad8ce86
commit 2fce78080b
5 changed files with 76 additions and 5 deletions

View File

@ -143,7 +143,7 @@ class BookParser:
if read.no_codepage: if read.no_codepage:
warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules))) warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules)))
sheets = read.sheets sheets = read.sheets
plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules) plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline)
osv_names = {sheet.name for sheet in plan.osv} osv_names = {sheet.name for sheet in plan.osv}
other_names = {sheet.name for sheet in plan.other} other_names = {sheet.name for sheet in plan.other}
reader_notes = _reader_notes(read) reader_notes = _reader_notes(read)

View File

@ -53,7 +53,9 @@ class SheetPlan:
other: tuple[Sheet, ...] other: tuple[Sheet, ...]
def plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules) -> SheetPlan: def plan_sheets(
sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline
) -> SheetPlan:
"""Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа """Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа
формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после».""" формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после»."""
osv: list[Sheet] = [] osv: list[Sheet] = []
@ -61,6 +63,7 @@ def plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules:
other: list[Sheet] = [] other: list[Sheet] = []
last_type: DocumentType | None = None # тип последнего блока последнего листа формы; None — формы «сразу до» нет last_type: DocumentType | None = None # тип последнего блока последнего листа формы; None — формы «сразу до» нет
for sheet in sheets: for sheet in sheets:
deadline.check(f"лист «{sheet.name}»: вид листа")
if is_osv(sheet): if is_osv(sheet):
osv.append(sheet) osv.append(sheet)
last_type = None last_type = None

View File

@ -138,11 +138,11 @@ def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) ->
def is_osv(sheet: Sheet) -> bool: def is_osv(sheet: Sheet) -> bool:
return sheet.name == "ОСВ" return sheet.name == "ОСВ"
plan = plan_sheets([ofr, weak], is_osv, rules) plan = plan_sheets([ofr, weak], is_osv, rules, NO_DEADLINE)
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)] assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)]
plan = plan_sheets([osv, weak], is_osv, rules) plan = plan_sheets([osv, weak], is_osv, rules, NO_DEADLINE)
assert (plan.osv, plan.statements, plan.other) == ((osv,), (), (weak,)) assert (plan.osv, plan.statements, plan.other) == ((osv,), (), (weak,))
plan = plan_sheets([ofr, notes, weak], is_osv, rules) plan = plan_sheets([ofr, notes, weak], is_osv, rules, NO_DEADLINE)
assert [s.name for s in plan.other] == ["Примечания", "2400"] assert [s.name for s in plan.other] == ["Примечания", "2400"]

View File

@ -536,6 +536,61 @@ def test_deadline_exceeded_is_timeout(rules: Rules) -> None:
assert "превышен лимит" in outcome.error.message assert "превышен лимит" in outcome.error.message
def test_sheet_kind_classification_is_interrupted_by_deadline(rules: Rules) -> None:
"""I-1: классификация листов (`plan_sheets`) проверяет дедлайн на каждом листе, книга из «прочего» — TIMEOUT."""
notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]]
clock = itertools.count(step=CLOCK_STEP)
content = xlsx({"Лист 1": notes, "Лист 2": notes, "Лист 3": notes})
outcome = book(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(content, "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.TIMEOUT
assert "вид листа" in outcome.error.message
def test_package_exception_on_candidate_is_recognition_failed_with_collected_warnings(
rules: Rules, monkeypatch: pytest.MonkeyPatch
) -> None:
"""I-2(а), P4-11: исключение пакета на кандидате — ошибка книги, предупреждения файла до него сохраняются."""
def broken(candidate: object, statement_rules: object) -> object:
raise ValueError("сбой пакета\nвторая строка")
monkeypatch.setattr("src.parser.statements.pipeline.normalize_candidate", broken)
notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]]
outcome = book(rules).parse(xlsx({"Примечания": notes, "ББ": BALANCE_ROWS}), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
assert outcome.error.message == "ValueError: сбой пакета вторая строка"
assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [("SHEET_OTHER", "sheet:Примечания")]
def test_deadline_on_forms_path_is_timeout_in_adapter(rules: Rules) -> None:
"""I-2(б): дедлайн истекает на сборке кандидатов адаптера (после классификации листа)."""
ticks = itertools.chain([0.0] * 3, itertools.repeat(1e9)) # старт, создание дедлайна, вид листа
outcome = book(rules, timeout_seconds=100, clock=lambda: next(ticks)).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.TIMEOUT
assert "форма" in outcome.error.message
def test_deadline_on_forms_path_is_timeout_in_pipeline(rules: Rules) -> None:
"""I-2(б): дедлайн истекает перед нормализацией кандидата в `StatementPipeline`."""
ticks = itertools.chain([0.0] * 4, itertools.repeat(1e9)) # + проверка адаптера
outcome = book(rules, timeout_seconds=100, clock=lambda: next(ticks)).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.TIMEOUT
assert "форма «sheet:ББ»" in outcome.error.message
def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None: def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None:
def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
raise ValueError("сбой стратегии\nвторая строка") raise ValueError("сбой стратегии\nвторая строка")

View File

@ -35,6 +35,7 @@ from src.contract import (
from src.core.config import NATSSettings from src.core.config import NATSSettings
from src.core.nats import NatsConnection, NatsPublishError from src.core.nats import NatsConnection, NatsPublishError
from src.core.worker import RecognitionWorker, result_key from src.core.worker import RecognitionWorker, result_key
from arm_statements import RulesError
from src.parser import NullParser, ParseOutcome, get_parser, require_statements_version from src.parser import NullParser, ParseOutcome, get_parser, require_statements_version
API_DIR = Path(__file__).resolve().parents[2] API_DIR = Path(__file__).resolve().parents[2]
@ -935,3 +936,15 @@ def test_require_statements_version_reads_the_marker(tmp_path: Path) -> None:
assert require_statements_version(marker) == "abc1234" assert require_statements_version(marker) == "abc1234"
assert require_statements_version() # копия пакета в репозитории размечена sync-statements assert require_statements_version() # копия пакета в репозитории размечена sync-statements
def test_get_parser_wraps_rules_error_into_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""I-2(в): ошибка словарей пакета при старте — `RuntimeError`, сервис не стартует."""
def broken() -> object:
raise RulesError("битый словарь форм")
monkeypatch.setattr("src.parser.default_rules", broken)
with pytest.raises(RuntimeError, match="битый словарь форм"):
get_parser()