From 43ee753d7426b1ae1cace7e4fe3921f8e5f416da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 15:49:27 +0400 Subject: [PATCH] =?UTF-8?q?refactor(parser):=20BookParser=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BA=D0=BD=D0=B8=D0=B3=D0=B8;=20OsvParser=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=B9=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=9E=D0=A1=D0=92;=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=20=D0=B4=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=B9?= =?UTF-8?q?=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Листы-продолжения форм (STATEMENT_CONTINUATION) идут в конвейер форм, а не в разбор ОСВ (R-5). Refs #14 Co-Authored-By: Claude Sonnet Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- README.md | 2 +- api/src/parser/__init__.py | 17 +- api/src/parser/book.py | 144 ++++++++++++++ api/src/parser/osv.py | 135 ++----------- api/tests/fixtures/test_rules_coverage.py | 11 +- ...test_osv_parser.py => test_book_parser.py} | 180 ++++++++++++++---- api/tests/unit/parser/test_cli.py | 4 +- api/tests/unit/parser/test_read_limits.py | 15 +- api/tests/unit/test_parser_factory.py | 10 +- 9 files changed, 337 insertions(+), 181 deletions(-) create mode 100644 api/src/parser/book.py rename api/tests/unit/parser/{test_osv_parser.py => test_book_parser.py} (77%) diff --git a/README.md b/README.md index fb8b90b..29e92ac 100644 --- a/README.md +++ b/README.md @@ -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`**. ### Лимиты diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py index 68d8c05..bae4153 100644 --- a/api/src/parser/__init__.py +++ b/api/src/parser/__init__.py @@ -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, + ) diff --git a/api/src/parser/book.py b/api/src/parser/book.py new file mode 100644 index 0000000..4892964 --- /dev/null +++ b/api/src/parser/book.py @@ -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), + ) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index bac75ca..98ef895 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -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), - ) diff --git a/api/tests/fixtures/test_rules_coverage.py b/api/tests/fixtures/test_rules_coverage.py index 9c71680..e7ea8bb 100644 --- a/api/tests/fixtures/test_rules_coverage.py +++ b/api/tests/fixtures/test_rules_coverage.py @@ -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] diff --git a/api/tests/unit/parser/test_osv_parser.py b/api/tests/unit/parser/test_book_parser.py similarity index 77% rename from api/tests/unit/parser/test_osv_parser.py rename to api/tests/unit/parser/test_book_parser.py index 1b59caa..98c71f8 100644 --- a/api/tests/unit/parser/test_osv_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -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 diff --git a/api/tests/unit/parser/test_cli.py b/api/tests/unit/parser/test_cli.py index 04e5fd2..0b37b5d 100644 --- a/api/tests/unit/parser/test_cli.py +++ b/api/tests/unit/parser/test_cli.py @@ -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 diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py index 412b783..c26bc98 100644 --- a/api/tests/unit/parser/test_read_limits.py +++ b/api/tests/unit/parser/test_read_limits.py @@ -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 diff --git a/api/tests/unit/test_parser_factory.py b/api/tests/unit/test_parser_factory.py index 93c4d77..421b722 100644 --- a/api/tests/unit/test_parser_factory.py +++ b/api/tests/unit/test_parser_factory.py @@ -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 - значения из окружения теста