"""Оркестратор книги (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.osv_warnings import render_legacy from src.parser.readers import read_book_detected from src.parser.readers._common import one_line_reason from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR from src.parser.rules import Rules from src.parser.statements.detect import is_weak_continuation __all__ = ["BookParser", "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, *, formats_unreadable: bool = False ) -> tuple[list[StatementDocument], list[str], bool]: ... 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) read = read_book_detected( data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes ) sheets = read.sheets warnings.extend(unique(read.warnings)) osv_documents: list[OsvDocument] = [] statement_sheets: list[tuple[Sheet, SheetKind]] = [] ok = True previous_is_statement = False # N-2: слабое продолжение — только сразу после листа формы 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)) previous_is_statement = True continue if kind is SheetKind.OTHER and previous_is_statement and is_weak_continuation(sheet, self._rules): kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами сразу после листа формы statement_sheets.append((sheet, kind)) continue previous_is_statement = False if kind is SheetKind.OTHER: warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы")) continue try: result = self._osv.parse_sheet(sheet, deadline) except ParserError: # T7.1: ответ с ошибкой называет листы форм, которые из-за неё остались неразобранными warnings.extend( sheet_line(form_sheet, f"форма не разобрана: книга завершилась ошибкой листа ОСВ «{sheet.name}»") for form_sheet, _ in statement_sheets ) raise osv_documents.append(result.document) warnings.extend(render_legacy(sheet.name, result.notes)) ok = ok and result.ok statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets( statement_sheets, deadline, formats_unreadable=read.detected == "XLSB" ) 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), )