154 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Оркестратор книги (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),
)