refactor(parser): BookParser — оркестратор книги; OsvParser — конвейер листа ОСВ; заглушка форм до конвейера

Листы-продолжения форм (STATEMENT_CONTINUATION) идут в конвейер форм, а не в разбор ОСВ (R-5).

Refs #14

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 15:49:27 +04:00
parent bc96f2ff5d
commit 43ee753d74
9 changed files with 337 additions and 181 deletions

View File

@ -195,7 +195,7 @@ groups:
и положите файл в `../fixtures/osv/` (или синтетику в `tests/synthetic/generate.py`), затем `make golden-update`.
Этот сценарий проверяют `test_default_dictionary_does_not_know_the_new_synonym` (до правки) и
`test_new_synonym_parses_without_code_changes` (после правки временной копии словарей) в `tests/unit/parser/test_osv_parser.py`.
`test_new_synonym_parses_without_code_changes` (после правки временной копии словарей) в `tests/unit/parser/test_book_parser.py`.
Принцип: **новый формат = фикстура + синоним, не новый `if`**.
### Лимиты

View File

@ -1,7 +1,8 @@
"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка».
Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат.
Реализация по умолчанию — `OsvParser` (`osv.py`, Срез 2a); `NullParser` (заглушка Среза 0) остаётся для тестов воркера.
Реализация по умолчанию — `BookParser` (`book.py`; листы ОСВ — `osv.py`); `NullParser` (заглушка Среза 0)
остаётся для тестов воркера.
`parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его
через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий.
@ -61,16 +62,24 @@ class NullParser:
def get_parser(settings: Settings | None = None, *, rules_dir: Path = DEFAULT_RULES_DIR) -> Parser:
"""Фабрика парсера для воркера: `OsvParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` /
"""Фабрика парсера для воркера: `BookParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` /
`PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`).
Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом
и ключом, сервис не стартует, а не падает на первом файле.
"""
# `osv.py` импортирует `ParseOutcome` из этого пакета: импорт на уровне модуля был бы циклическим
# `book.py` и `osv.py` импортируют `ParseOutcome` из этого пакета: импорт на уровне модуля был бы циклическим
from src.parser.book import BookParser, SkippingStatementParser # noqa: PLC0415 - цикл импорта book → src.parser
from src.parser.osv import OsvParser # noqa: PLC0415 - цикл импорта osv → src.parser
fields = Settings.model_fields
max_bytes = fields["PARSE_MAX_BYTES"].default if settings is None else settings.PARSE_MAX_BYTES
timeout_seconds = fields["PARSE_TIMEOUT_SECONDS"].default if settings is None else settings.PARSE_TIMEOUT_SECONDS
return OsvParser(load_rules(rules_dir), max_bytes=max_bytes, timeout_seconds=timeout_seconds)
rules = load_rules(rules_dir)
return BookParser(
rules,
osv=OsvParser(rules),
statements=SkippingStatementParser(),
max_bytes=max_bytes,
timeout_seconds=timeout_seconds,
)

144
api/src/parser/book.py Normal file
View File

@ -0,0 +1,144 @@
"""Оркестратор книги (design D-1): лимит размера → чтение → дедлайн → вид листа → конвейеры ОСВ и форм → статус.
Карта исходов та же, что была у `OsvParser.parse` (D-6 Среза 2a): нет документов ОСВ и форм → `INVALID_DOCUMENT`;
лист ОСВ без заголовка/колонок → `INVALID_FORMAT`; книга не открывается → `UNREADABLE`; сигнатура/лимиты →
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; прочее → `RECOGNITION_FAILED`. Нарушение контрольных сумм ОСВ или
соотношений формы хотя бы в одном документе → `needs_check`. Документы: ОСВ в порядке листов, затем формы.
"""
import time
from collections.abc import Callable, Sequence
from typing import Protocol
from loguru import logger
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument
from src.parser import ParseOutcome
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind, classify_sheet
from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError
from src.parser.grid import Sheet
from src.parser.osv import OsvParser, data_rows, sheet_line, unique
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__ = ["BookParser", "SkippingStatementParser", "StatementSheetParser"]
_MS_IN_SECOND = 1000
STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATION})
class StatementSheetParser(Protocol):
def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline
) -> tuple[list[StatementDocument], list[str], bool]: ...
class SkippingStatementParser:
"""Поведение Среза 2a: листы форм пропускаются с предупреждением. Заменяется `StatementParser` (задача 12)."""
def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline
) -> tuple[list[StatementDocument], list[str], bool]:
return [], [sheet_line(sheet, "skipped: statement (slice 2b)") for sheet, _ in sheets], True
class BookParser:
def __init__( # noqa: PLR0913 - все зависимости оркестратора именованные
self,
rules: Rules,
*,
osv: OsvParser,
statements: StatementSheetParser,
max_bytes: int,
timeout_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._rules = rules
self._osv = osv
self._statements = statements
self._max_bytes = max_bytes
self._timeout_seconds = timeout_seconds
self._clock = clock
@property
def max_bytes(self) -> int:
return self._max_bytes
@property
def timeout_seconds(self) -> float:
return self._timeout_seconds
def parse(self, data: bytes, file_type: FileType) -> ParseOutcome:
started = self._clock()
warnings: list[str] = []
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:
reason = one_line_reason(exc)
logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason)
return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started)
rows = sum(
data_rows(document) if isinstance(document, OsvDocument) else len(document.lines) for document in documents
)
metrics = Metrics(duration_ms=self._elapsed_ms(started), rows=rows, warnings=warnings)
return ParseOutcome(
status=ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK, documents=tuple(documents), metrics=metrics
)
def _parse_book(
self, data: bytes, file_type: FileType, warnings: list[str]
) -> tuple[list[OsvDocument | StatementDocument], bool]:
if len(data) > self._max_bytes:
raise UnsupportedFormatError(
f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: "
"книга не открывается"
)
deadline = Deadline(self._timeout_seconds, self._clock)
sheets, read_warnings = read_book(
data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes
)
warnings.extend(unique(read_warnings))
osv_documents: list[OsvDocument] = []
statement_sheets: list[tuple[Sheet, SheetKind]] = []
ok = True
for sheet in sheets:
deadline.check(f"лист «{sheet.name}»")
kind = classify_sheet(sheet, self._rules)
if kind in STATEMENT_KINDS:
statement_sheets.append((sheet, kind))
continue
if kind is SheetKind.OTHER:
warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы"))
continue
document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline)
osv_documents.append(document)
warnings.extend(sheet_warnings)
ok = ok and sheet_ok
statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets(
statement_sheets, deadline
)
warnings.extend(statement_warnings)
documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents]
if not documents:
raise InvalidDocumentError(f"в книге нет листов ОСВ или форм: листов просмотрено {len(sheets)}")
return documents, ok and statements_ok
def _elapsed_ms(self, started: float) -> int:
return max(0, round((self._clock() - started) * _MS_IN_SECOND))
def _error(self, code: ErrorCode, message: str, warnings: list[str], started: float) -> ParseOutcome:
fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)}
if warnings:
fields["warnings"] = warnings
return ParseOutcome(
status=ResultStatus.ERROR,
error=MessageError(code=code, message=" ".join(message.split())),
metrics=Metrics.model_validate(fields),
)

View File

@ -1,54 +1,37 @@
"""Оркестратор разбора ОСВ: байты книги → документы, метрики, статус (design D-6, D-8; spec «Листы ОСВ находятся по
содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время»).
"""Конвейер листа ОСВ: лист → документ `OsvDocument`, предупреждения, признак «контрольные суммы сошлись».
Порядок: лимит размера → чтение книги по сигнатуре → для каждого листа вид по содержимому (ОСВ / форма ББ-ОФР / прочее)
→ лист ОСВ: заголовок таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные
суммы §1.4. Листы форм и прочие пропускаются с предупреждением; имя листа только подставляется в `locator` и
сообщения, поиск по нему не ведётся.
Карта исходов (D-6): нет листов ОСВ → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок/иерархии → `INVALID_FORMAT`
(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB, размер больше лимита или превышены
лимиты чтения (распакованный zip > `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`, площадь объединённых ячеек, R-23) →
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой
`Тип: сообщение` (стек — в лог loguru уровня ERROR; `UNREADABLE` — WARNING без стека). Нарушение §1.4 хотя бы на
одном листе → `needs_check` всего результата.
Книгу (лимиты, чтение, дедлайн, вид листа, статус и карта ошибок) разбирает `book.BookParser`. Здесь: заголовок
таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные суммы §1.4. Имя листа
только подставляется в `locator` и сообщения, поиск по нему не ведётся.
"""
import time
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Iterable, Sequence
from dataclasses import replace
from loguru import logger
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
from src.contract import OsvDocument
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser import ParseOutcome
from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
from src.parser.detect import TableHeader, find_table_header
from src.parser.errors import InvalidFormatError
from src.parser.grid import Sheet, column_letter
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, 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"]
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn"
NO_DATA_ROWS_WARNING = "нет строк данных"
_MS_IN_SECOND = 1000
def _sheet_line(sheet: Sheet, text: str) -> str:
def sheet_line(sheet: Sheet, text: str) -> str:
return f"sheet={sheet.name} {text}"
def _unique(items: Iterable[str]) -> list[str]:
def unique(items: Iterable[str]) -> list[str]:
"""Без повторов, в порядке первого появления."""
return list(dict.fromkeys(items))
@ -124,7 +107,7 @@ def _is_ok(checks: OsvChecks) -> bool:
)
def _data_rows(document: OsvDocument) -> int:
def data_rows(document: OsvDocument) -> int:
return sum(1 for row in document.rows if row.group_type != "TOTAL")
@ -135,53 +118,10 @@ def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> Invalid
class OsvParser:
"""Парсер ОСВ по умолчанию: словари `rules`, лимит размера `max_bytes`, таймаут `timeout_seconds` (D-8).
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
`parse` не бросает исключений: любой сбой — `ParseOutcome(status=error)` с кодом контракта.
"""
def __init__(
self,
rules: Rules,
*,
max_bytes: int,
timeout_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> None:
def __init__(self, rules: Rules) -> None:
self._rules = rules
self._max_bytes = max_bytes
self._timeout_seconds = timeout_seconds
self._clock = clock
@property
def max_bytes(self) -> int:
return self._max_bytes
@property
def timeout_seconds(self) -> float:
return self._timeout_seconds
def parse(self, data: bytes, file_type: FileType) -> ParseOutcome:
started = self._clock()
warnings: list[str] = []
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, а не исключение в воркер
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),
warnings=warnings,
)
status = ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK
return ParseOutcome(status=status, documents=tuple(documents), metrics=metrics)
def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]:
"""Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак
@ -207,44 +147,14 @@ class OsvParser:
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
checks, check_warnings = run_checks(classified)
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
warnings = [_sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
if not any(item.group_type != "TOTAL" for item in classified):
warnings.append(NO_DATA_ROWS_WARNING)
warnings.extend(check_warnings)
return document, _unique(warnings), _is_ok(checks)
return document, unique(warnings), _is_ok(checks)
# --- внутреннее ---
def _parse_book(self, data: bytes, file_type: FileType, warnings: list[str]) -> tuple[list[OsvDocument], bool]:
if len(data) > self._max_bytes:
raise UnsupportedFormatError(
f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: книга не "
"открывается"
)
deadline = Deadline(self._timeout_seconds, self._clock)
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
for sheet in sheets:
deadline.check(f"лист «{sheet.name}»")
kind = classify_sheet(sheet, self._rules)
if kind is SheetKind.STATEMENT:
warnings.append(_sheet_line(sheet, "skipped: statement (slice 2b)"))
continue
if kind is SheetKind.OTHER:
warnings.append(_sheet_line(sheet, "skipped: нет признаков ОСВ"))
continue
document, sheet_warnings, sheet_ok = self.parse_sheet(sheet, deadline)
documents.append(document)
warnings.extend(sheet_warnings)
ok = ok and sheet_ok
if not documents:
raise InvalidDocumentError(f"в книге нет листов ОСВ: листов просмотрено {len(sheets)}")
return documents, ok
@staticmethod
def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
*,
@ -264,16 +174,3 @@ class OsvParser:
rows=[_row(item) for item in rows],
checks=checks,
)
def _elapsed_ms(self, started: float) -> int:
return max(0, round((self._clock() - started) * _MS_IN_SECOND))
def _error(self, code: ErrorCode, message: str, warnings: list[str], started: float) -> ParseOutcome:
metrics_fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)}
if warnings:
metrics_fields["warnings"] = warnings
return ParseOutcome(
status=ResultStatus.ERROR,
error=MessageError(code=code, message=" ".join(message.split())),
metrics=Metrics.model_validate(metrics_fields),
)

View File

@ -20,6 +20,7 @@ from fixtures_helpers import osv_dkb_files, osv_fixture_files, synthetic_files
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
from src.parser import get_parser
from src.parser.book import BookParser, SkippingStatementParser
from src.parser.osv import OsvParser
from src.parser.rules import DEFAULT_RULES_DIR, Rules, load_rules
@ -34,8 +35,14 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
counter.install(monkeypatch)
rules = load_rules(DEFAULT_RULES_DIR)
base = get_parser()
assert isinstance(base, OsvParser)
parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)
assert isinstance(base, BookParser)
parser = BookParser(
rules,
osv=OsvParser(rules),
statements=SkippingStatementParser(),
max_bytes=base.max_bytes,
timeout_seconds=base.timeout_seconds,
)
files = [*osv_fixture_files(fixtures_dir), *osv_dkb_files(fixtures_dir), *synthetic_files(fixtures_dir)]
for file in files:
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]

View File

@ -1,4 +1,4 @@
"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
"""Оркестратор `BookParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
статус», «Форматы, лимиты, время», «Словари — данные, не код»).
Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая
@ -21,11 +21,15 @@ 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.contract import ErrorCode, OsvDocument, ResultStatus, StatementDocument
from src.contract.result import StatementHeader
from src.parser import ParseOutcome, hierarchy
from src.parser.book import BookParser, SkippingStatementParser, StatementSheetParser
from src.parser.classify import SUM_FIELDS
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind
from src.parser.errors import ParseTimeoutError
from src.parser.grid import Sheet
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
@ -53,12 +57,12 @@ def rules() -> Rules:
def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
"""Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом)."""
book = openpyxl.Workbook()
default = book.active
workbook = openpyxl.Workbook()
default = workbook.active
assert default is not None
book.remove(default)
workbook.remove(default)
for name, rows in sheets.items():
sheet = book.create_sheet(name)
sheet = workbook.create_sheet(name)
for row_index, cells in enumerate(rows, start=1):
col = 1
for value in cells:
@ -79,7 +83,7 @@ def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
sheet.cell(row_index, col, value)
col += 1
buffer = io.BytesIO()
book.save(buffer)
workbook.save(buffer)
return buffer.getvalue()
@ -122,14 +126,22 @@ def statement() -> list[Cells]:
return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]]
def parser(
def book(
rules: Rules,
*,
statements: StatementSheetParser | None = None,
max_bytes: int = MAX_BYTES,
timeout_seconds: float = TIMEOUT_SECONDS,
clock: Callable[[], float] = time.monotonic,
) -> OsvParser:
return OsvParser(rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock)
) -> BookParser:
return BookParser(
rules,
osv=OsvParser(rules),
statements=statements or SkippingStatementParser(),
max_bytes=max_bytes,
timeout_seconds=timeout_seconds,
clock=clock,
)
def only_document(outcome: ParseOutcome) -> OsvDocument:
@ -148,7 +160,7 @@ def warnings_of(outcome: ParseOutcome) -> list[str]:
def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
assert outcome.status is ResultStatus.DONE
assert outcome.error is None
@ -209,7 +221,7 @@ def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None:
"""Spec «Испорченная сумма»: `closing_debit` контрагента +1 → тождество в его строке и сумма родителя."""
spoiled = (60, None, 20, 10, 71, None)
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX")
assert outcome.status is ResultStatus.NEEDS_CHECK
checks = only_document(outcome).checks
@ -226,7 +238,7 @@ def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None:
def test_missing_total_needs_check(rules: Rules) -> None:
"""R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`."""
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX")
assert outcome.status is ResultStatus.NEEDS_CHECK
checks = only_document(outcome).checks
@ -237,7 +249,7 @@ def test_missing_total_needs_check(rules: Rules) -> None:
def test_empty_osv_is_done_with_total_only(rules: Rules) -> None:
"""Spec «Пустая ОСВ» (R-13): заголовок и сразу «Итого» без сумм."""
outcome = parser(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX")
assert outcome.status is ResultStatus.DONE
document = only_document(outcome)
@ -254,7 +266,7 @@ def test_number_warnings_carry_row_numbers(rules: Rules) -> None:
rows = mini_osv()
rows[CONTRACTOR_ROW - 1] = data(1, "ООО Подрядчик", (40, None, 30, 20, 50, "н/д"), inn="7707654321")
outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
assert outcome.status is ResultStatus.DONE
assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome))
@ -291,7 +303,7 @@ def code_name_osv(*, total_label: str | None = None) -> list[Cells]:
def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX")
document = only_document(outcome)
header = document.header
@ -310,7 +322,7 @@ def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) ->
def test_code_name_columns_rows_are_account_and_children_by_label_column(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX")
outcome = book(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX")
document = only_document(outcome)
assert [(row.row_value, row.level, row.group_type) for row in document.rows] == [
@ -324,7 +336,7 @@ def test_code_name_columns_rows_are_account_and_children_by_label_column(rules:
def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX")
document = only_document(outcome)
assert document.rows[-1].group_type == "TOTAL"
@ -338,20 +350,20 @@ def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None
def test_statement_sheet_is_skipped_and_osv_parsed(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX")
assert outcome.status is ResultStatus.DONE
assert only_document(outcome).locator == "sheet:ОСВ 60"
assert warnings_of(outcome) == ["sheet=Баланс skipped: statement (slice 2b)", "sheet=ОСВ 60 hierarchy=outline"]
assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline", "sheet=Баланс skipped: statement (slice 2b)"]
assert_valid_result(outcome)
def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
"""Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings."""
other: list[Cells] = [["Справка о дебиторской задолженности"], ["Контрагент", "Сумма"], ["ООО Лютик", 5]]
book = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()})
content = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()})
outcome = parser(rules).parse(book, "XLSX")
outcome = book(rules).parse(content, "XLSX")
assert outcome.status is ResultStatus.DONE
assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
@ -366,9 +378,9 @@ def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None:
spoiled = (60, None, 20, 10, 71, None)
book = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)})
content = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)})
outcome = parser(rules).parse(book, "XLSX")
outcome = book(rules).parse(content, "XLSX")
assert outcome.status is ResultStatus.NEEDS_CHECK
assert len(outcome.documents) == 2 # noqa: PLR2004 - два листа ОСВ
@ -379,7 +391,7 @@ def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None:
def test_book_without_osv_is_invalid_document(rules: Rules) -> None:
"""Spec «Нет ОСВ»: сообщение называет число просмотренных листов."""
outcome = parser(rules).parse(xlsx({"Баланс": statement(), "Данные": [["a", "b"], [1, 2]]}), "XLSX")
outcome = book(rules).parse(xlsx({"Баланс": statement(), "Данные": [["a", "b"], [1, 2]]}), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
@ -391,7 +403,7 @@ def test_book_without_osv_is_invalid_document(rules: Rules) -> None:
def test_osv_sheet_without_table_header_is_invalid_format(rules: Rules) -> None:
rows: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]]
outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
@ -409,7 +421,7 @@ def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step(
monkeypatch.setattr(hierarchy, "STRATEGIES", (("nothing", nothing),))
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.INVALID_FORMAT
@ -419,10 +431,10 @@ def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step(
def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> None:
"""Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор."""
book = xlsx({"ОСВ 60": mini_osv()})
content = xlsx({"ОСВ 60": mini_osv()})
over = parser(rules, max_bytes=len(book) - 1).parse(book, "XLSX")
exact = parser(rules, max_bytes=len(book)).parse(book, "XLSX")
over = book(rules, max_bytes=len(content) - 1).parse(content, "XLSX")
exact = book(rules, max_bytes=len(content)).parse(content, "XLSX")
assert over.status is ResultStatus.ERROR
assert over.error is not None
@ -432,21 +444,21 @@ def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> N
def test_tiny_limit_rejects_any_file(rules: Rules) -> None:
outcome = parser(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX")
outcome = book(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
def test_unknown_signature_is_unsupported_format(rules: Rules) -> None:
outcome = parser(rules).parse(b"%PDF-1.7 not a workbook", "XLSX")
outcome = book(rules).parse(b"%PDF-1.7 not a workbook", "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
def test_broken_zip_is_unreadable(rules: Rules) -> None:
outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
outcome = book(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNREADABLE
@ -455,7 +467,7 @@ def test_broken_zip_is_unreadable(rules: Rules) -> None:
def test_deadline_exceeded_is_timeout(rules: Rules) -> None:
clock = itertools.count(step=CLOCK_STEP)
outcome = parser(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(
outcome = book(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(
xlsx({"ОСВ 60": mini_osv()}), "XLSX"
)
@ -471,7 +483,7 @@ def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, mo
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
@ -498,7 +510,7 @@ def test_unexpected_exception_is_logged_with_traceback(
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
@ -514,7 +526,7 @@ def test_unexpected_exception_is_logged_with_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")
outcome = book(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNREADABLE
@ -525,7 +537,7 @@ def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, lo
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")
outcome = book(rules).parse(b"%PDF-1.7", "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
@ -538,7 +550,7 @@ def test_header_search_is_interrupted_by_deadline(rules: Rules) -> None:
deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__)
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
parser(rules).parse_sheet(sheet, deadline)
OsvParser(rules).parse_sheet(sheet, deadline)
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
@ -560,9 +572,9 @@ def rules_with_new_opening_synonym(tmp_path: Path) -> Path:
def test_new_synonym_parses_without_code_changes(tmp_path: Path, rules: Rules) -> None:
book = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)})
content = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)})
outcome = parser(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(book, "XLSX")
outcome = book(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(content, "XLSX")
assert outcome.status is ResultStatus.DONE
document = only_document(outcome)
@ -577,7 +589,7 @@ def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None:
необязательно, R-12), колонки начала уходят в `extra`, `opening_*` = null, и тождество сальдо не сходится в
строках с ненулевым началом → `needs_check` (фактическое поведение, зафиксировано тестом).
"""
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX")
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX")
assert outcome.status is ResultStatus.NEEDS_CHECK
document = only_document(outcome)
@ -589,3 +601,87 @@ def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None:
checks = document.checks
assert checks is not None
assert checks.balance_identity_violations == [ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW]
# --- Формы (задача 7, оркестрация книги) ---
BALANCE = [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]]
CONTINUATION = [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]]
class _FakeStatements:
"""Возвращает по одному документу DISCLOSURE на лист формы и запоминает, что получил."""
def __init__(self) -> None:
self.received: list[tuple[str, SheetKind]] = []
def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline
) -> tuple[list[StatementDocument], list[str], bool]:
self.received = [(sheet.name, kind) for sheet, kind in sheets]
documents = [
StatementDocument(
document_type="DISCLOSURE",
locator=f"sheet:{sheet.name}",
header=StatementHeader(),
columns=[],
lines=[],
)
for sheet, _ in sheets
]
return documents, [f"sheet={sheet.name} statements-fake" for sheet, _ in sheets], True
def test_statement_sheets_go_to_statement_parser_in_sheet_order(rules: Rules) -> None:
fake = _FakeStatements()
content = xlsx({"ББ": BALANCE, "Лист_1": mini_osv(), "с.2": CONTINUATION})
outcome = book(rules, statements=fake).parse(content, "XLSX")
assert fake.received == [("ББ", SheetKind.STATEMENT), ("с.2", SheetKind.STATEMENT_CONTINUATION)]
assert [type(document).__name__ for document in outcome.documents] == [
"OsvDocument",
"StatementDocument",
"StatementDocument",
]
assert outcome.status is ResultStatus.DONE
assert "sheet=ББ statements-fake" in (outcome.metrics.warnings if outcome.metrics else [])
def test_skipping_parser_keeps_slice_2a_behaviour(rules: Rules) -> None:
outcome = book(rules).parse(xlsx({"ББ": BALANCE, "Лист_1": mini_osv()}), "XLSX")
assert len(outcome.documents) == 1
assert "sheet=ББ skipped: statement (slice 2b)" in warnings_of(outcome)
def test_statement_continuation_is_not_parsed_as_osv(rules: Rules) -> None:
"""R-5: лист-продолжение формы идёт в конвейер форм (здесь заглушка), а не в разбор ОСВ."""
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(), "с.2": CONTINUATION}), "XLSX")
assert outcome.status is ResultStatus.DONE
assert only_document(outcome).locator == "sheet:ОСВ 60"
assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline", "sheet=с.2 skipped: statement (slice 2b)"]
def test_book_without_osv_and_forms_is_invalid_document(rules: Rules) -> None:
outcome = book(rules).parse(xlsx({"Справка": [["Наименование", "Сумма"], ["Аренда", 100]]}), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
assert outcome.error.code is ErrorCode.INVALID_DOCUMENT
assert "нет листов ОСВ или форм" in outcome.error.message
def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None:
class _NotOk(_FakeStatements):
def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline
) -> tuple[list[StatementDocument], list[str], bool]:
documents, warnings, _ = super().parse_sheets(sheets, deadline)
return documents, warnings, False
outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": BALANCE}), "XLSX")
assert outcome.status is ResultStatus.NEEDS_CHECK
assert len(outcome.documents) == 1

View File

@ -1,7 +1,7 @@
"""CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»).
Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования.
Синтетические книги собираются openpyxl в памяти (`test_osv_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
Синтетические книги собираются openpyxl в памяти (`test_book_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
запуск как модуля через subprocess (маркер `fixtures`).
"""
@ -14,7 +14,7 @@ from typing import Any
import pytest
from result_contract import schema_errors
from test_osv_parser import mini_osv, xlsx
from test_book_parser import mini_osv, xlsx
from src.contract import RecognitionResult
from src.parser import cli

View File

@ -14,6 +14,7 @@ import openpyxl
import pytest
from src.contract import ErrorCode, ResultStatus
from src.parser.book import BookParser, SkippingStatementParser
from src.parser.errors import UnreadableError, UnsupportedFormatError
from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
from src.parser.osv import OsvParser
@ -122,6 +123,12 @@ def test_huge_merged_range_is_rejected_before_openpyxl() -> None:
assert str(MERGED_AREA_LIMIT) in caught.value.message
def _book(rules: Rules, max_bytes: int) -> BookParser:
return BookParser(
rules, osv=OsvParser(rules), statements=SkippingStatementParser(), max_bytes=max_bytes, timeout_seconds=120
)
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_many_small_merged_ranges_are_summed() -> None:
per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик
@ -134,9 +141,7 @@ def test_many_small_merged_ranges_are_summed() -> None:
@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"
)
outcome = _book(rules, 50 * 1024 * 1024).parse(_xlsx_with_merges("A1:Z200000"), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
@ -534,9 +539,7 @@ def test_zip_expanding_beyond_limit_is_rejected() -> None:
@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"
)
outcome = _book(rules, SMALL_MAX_BYTES).parse(_xlsx_with_zeros(ZEROS_BYTES), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT

View File

@ -1,6 +1,6 @@
"""Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»).
`get_parser()` по умолчанию отдаёт `OsvParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
`get_parser()` по умолчанию отдаёт `BookParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера:
`NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.
"""
@ -15,7 +15,7 @@ import pytest
from src.contract import ErrorCode, FileType, MessageError, ResultStatus
from src.core.config import Settings
from src.parser import NullParser, ParseOutcome, Parser, get_parser
from src.parser.osv import OsvParser
from src.parser.book import BookParser
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE
DEFAULT_MAX_BYTES = 50 * 1024 * 1024
@ -46,10 +46,10 @@ def test_null_parser_returns_not_implemented_without_touching_input(file_type: F
assert outcome.metrics is None
def test_get_parser_returns_osv_parser_with_default_limits() -> None:
def test_get_parser_returns_book_parser_with_default_limits() -> None:
parser = get_parser()
assert isinstance(parser, OsvParser)
assert isinstance(parser, BookParser)
assert isinstance(parser, Parser)
assert (parser.max_bytes, parser.timeout_seconds) == (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_SECONDS)
@ -59,7 +59,7 @@ def test_get_parser_takes_limits_from_settings(minimal_settings: Callable[..., S
parser = get_parser(settings)
assert isinstance(parser, OsvParser)
assert isinstance(parser, BookParser)
assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста