Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
154 lines
7.6 KiB
Python
154 lines
7.6 KiB
Python
"""Оркестратор книги (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),
|
||
)
|