"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка». Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат. Реализация по умолчанию — `BookParser` (`book.py`; листы ОСВ — `osv.py`); `NullParser` (заглушка Среза 0) остаётся для тестов воркера. `parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий. """ from dataclasses import dataclass from pathlib import Path from typing import Protocol, runtime_checkable from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument from src.core.config import Settings from src.parser.rules import DEFAULT_RULES_DIR, load_rules __all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser"] NOT_IMPLEMENTED_MESSAGE = "разбор XLS/XLSX/XLSB реализуется в Срезе 2" @dataclass(frozen=True, slots=True) class ParseOutcome: """Итог разбора. Правила — как у файла результата контракта: `done | needs_check` требуют документов, `error` — `error {code, message}`. """ status: ResultStatus documents: tuple[OsvDocument | StatementDocument, ...] = () metrics: Metrics | None = None error: MessageError | None = None def __post_init__(self) -> None: if self.status is ResultStatus.ERROR: if self.error is None: raise ValueError("при status = error обязательно поле error") elif not self.documents: raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") @runtime_checkable class Parser(Protocol): """Разбор файла: байты + тип → `ParseOutcome`. Ожидаемые сбои разбора (не тот формат, нечитаемый лист и т. п.) возвращаются исходом `status = error` с `error {code, message}`; непредвиденное исключение воркер превращает в `UNKNOWN_ERROR` (design D-5). """ def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: ... class NullParser: """Заглушка Среза 0: всегда `error NOT_IMPLEMENTED`; входные байты не читает.""" def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: return ParseOutcome( status=ResultStatus.ERROR, error=MessageError(code=ErrorCode.NOT_IMPLEMENTED, message=NOT_IMPLEMENTED_MESSAGE), ) def get_parser(settings: Settings | None = None, *, rules_dir: Path = DEFAULT_RULES_DIR) -> Parser: """Фабрика парсера для воркера: `BookParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` / `PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`). Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом и ключом, сервис не стартует, а не падает на первом файле. """ # `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 rules = load_rules(rules_dir) return BookParser( rules, osv=OsvParser(rules), statements=SkippingStatementParser(), max_bytes=max_bytes, timeout_seconds=timeout_seconds, )