refactor(parser): BookParser — оркестратор книги; OsvParser — конвейер листа ОСВ; заглушка форм до конвейера
Листы-продолжения форм (STATEMENT_CONTINUATION) идут в конвейер форм, а не в разбор ОСВ (R-5). Refs #14 Co-Authored-By: Claude Sonnet <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
parent
bc96f2ff5d
commit
43ee753d74
@ -195,7 +195,7 @@ groups:
|
|||||||
|
|
||||||
и положите файл в `../fixtures/osv/` (или синтетику в `tests/synthetic/generate.py`), затем `make golden-update`.
|
и положите файл в `../fixtures/osv/` (или синтетику в `tests/synthetic/generate.py`), затем `make golden-update`.
|
||||||
Этот сценарий проверяют `test_default_dictionary_does_not_know_the_new_synonym` (до правки) и
|
Этот сценарий проверяют `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`**.
|
Принцип: **новый формат = фикстура + синоним, не новый `if`**.
|
||||||
|
|
||||||
### Лимиты
|
### Лимиты
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка».
|
"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка».
|
||||||
|
|
||||||
Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат.
|
Парсер не знает о 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) вызывает его
|
`parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его
|
||||||
через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий.
|
через `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:
|
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`).
|
`PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`).
|
||||||
|
|
||||||
Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом
|
Словари читаются и валидируются здесь, один раз при старте (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
|
from src.parser.osv import OsvParser # noqa: PLC0415 - цикл импорта osv → src.parser
|
||||||
|
|
||||||
fields = Settings.model_fields
|
fields = Settings.model_fields
|
||||||
max_bytes = fields["PARSE_MAX_BYTES"].default if settings is None else settings.PARSE_MAX_BYTES
|
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
|
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,
|
||||||
|
)
|
||||||
|
|||||||
144
api/src/parser/book.py
Normal file
144
api/src/parser/book.py
Normal file
@ -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),
|
||||||
|
)
|
||||||
@ -1,54 +1,37 @@
|
|||||||
"""Оркестратор разбора ОСВ: байты книги → документы, метрики, статус (design D-6, D-8; spec «Листы ОСВ находятся по
|
"""Конвейер листа ОСВ: лист → документ `OsvDocument`, предупреждения, признак «контрольные суммы сошлись».
|
||||||
содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время»).
|
|
||||||
|
|
||||||
Порядок: лимит размера → чтение книги по сигнатуре → для каждого листа вид по содержимому (ОСВ / форма ББ-ОФР / прочее)
|
Книгу (лимиты, чтение, дедлайн, вид листа, статус и карта ошибок) разбирает `book.BookParser`. Здесь: заголовок
|
||||||
→ лист ОСВ: заголовок таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные
|
таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные суммы §1.4. Имя листа
|
||||||
суммы §1.4. Листы форм и прочие пропускаются с предупреждением; имя листа только подставляется в `locator` и
|
только подставляется в `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` всего результата.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
from collections.abc import Iterable, Sequence
|
||||||
from collections.abc import Callable, Iterable, Sequence
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
from loguru import logger
|
from src.contract import OsvDocument
|
||||||
|
|
||||||
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
|
|
||||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||||
from src.parser import ParseOutcome
|
|
||||||
from src.parser.checks import run_checks
|
from src.parser.checks import run_checks
|
||||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
|
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
|
||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
from src.parser.detect import TableHeader, find_table_header
|
||||||
from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
|
from src.parser.errors import InvalidFormatError
|
||||||
from src.parser.grid import Sheet, column_letter
|
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.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header
|
||||||
from src.parser.hierarchy import resolve_levels
|
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
|
from src.parser.rules import Rules
|
||||||
|
|
||||||
__all__ = ["OsvParser"]
|
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
|
||||||
|
|
||||||
LOCATOR_PREFIX = "sheet:"
|
LOCATOR_PREFIX = "sheet:"
|
||||||
INN_COLUMN_KEY = "inn"
|
INN_COLUMN_KEY = "inn"
|
||||||
NO_DATA_ROWS_WARNING = "нет строк данных"
|
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}"
|
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))
|
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")
|
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:
|
class OsvParser:
|
||||||
"""Парсер ОСВ по умолчанию: словари `rules`, лимит размера `max_bytes`, таймаут `timeout_seconds` (D-8).
|
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
|
||||||
|
|
||||||
`parse` не бросает исключений: любой сбой — `ParseOutcome(status=error)` с кодом контракта.
|
def __init__(self, rules: Rules) -> None:
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
rules: Rules,
|
|
||||||
*,
|
|
||||||
max_bytes: int,
|
|
||||||
timeout_seconds: float,
|
|
||||||
clock: Callable[[], float] = time.monotonic,
|
|
||||||
) -> None:
|
|
||||||
self._rules = rules
|
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]:
|
def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]:
|
||||||
"""Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак
|
"""Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак
|
||||||
@ -207,44 +147,14 @@ class OsvParser:
|
|||||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||||
checks, check_warnings = run_checks(classified)
|
checks, check_warnings = run_checks(classified)
|
||||||
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
|
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):
|
if not any(item.group_type != "TOTAL" for item in classified):
|
||||||
warnings.append(NO_DATA_ROWS_WARNING)
|
warnings.append(NO_DATA_ROWS_WARNING)
|
||||||
warnings.extend(check_warnings)
|
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
|
@staticmethod
|
||||||
def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
|
def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
|
||||||
*,
|
*,
|
||||||
@ -264,16 +174,3 @@ class OsvParser:
|
|||||||
rows=[_row(item) for item in rows],
|
rows=[_row(item) for item in rows],
|
||||||
checks=checks,
|
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),
|
|
||||||
)
|
|
||||||
|
|||||||
11
api/tests/fixtures/test_rules_coverage.py
vendored
11
api/tests/fixtures/test_rules_coverage.py
vendored
@ -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 rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
|
||||||
|
|
||||||
from src.parser import get_parser
|
from src.parser import get_parser
|
||||||
|
from src.parser.book import BookParser, SkippingStatementParser
|
||||||
from src.parser.osv import OsvParser
|
from src.parser.osv import OsvParser
|
||||||
from src.parser.rules import DEFAULT_RULES_DIR, Rules, load_rules
|
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)
|
counter.install(monkeypatch)
|
||||||
rules = load_rules(DEFAULT_RULES_DIR)
|
rules = load_rules(DEFAULT_RULES_DIR)
|
||||||
base = get_parser()
|
base = get_parser()
|
||||||
assert isinstance(base, OsvParser)
|
assert isinstance(base, BookParser)
|
||||||
parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)
|
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)]
|
files = [*osv_fixture_files(fixtures_dir), *osv_dkb_files(fixtures_dir), *synthetic_files(fixtures_dir)]
|
||||||
for file in files:
|
for file in files:
|
||||||
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
|
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
|
"""Оркестратор `BookParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
|
||||||
статус», «Форматы, лимиты, время», «Словари — данные, не код»).
|
статус», «Форматы, лимиты, время», «Словари — данные, не код»).
|
||||||
|
|
||||||
Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая
|
Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая
|
||||||
@ -21,11 +21,15 @@ from gridbuilder import L, M, sheet_from_rows
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from result_contract import assert_valid_result
|
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 import ParseOutcome, hierarchy
|
||||||
|
from src.parser.book import BookParser, SkippingStatementParser, StatementSheetParser
|
||||||
from src.parser.classify import SUM_FIELDS
|
from src.parser.classify import SUM_FIELDS
|
||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
|
from src.parser.detect import SheetKind
|
||||||
from src.parser.errors import ParseTimeoutError
|
from src.parser.errors import ParseTimeoutError
|
||||||
|
from src.parser.grid import Sheet
|
||||||
from src.parser.hierarchy import BodyRow
|
from src.parser.hierarchy import BodyRow
|
||||||
from src.parser.osv import OsvParser
|
from src.parser.osv import OsvParser
|
||||||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
|
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:
|
def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
|
||||||
"""Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом)."""
|
"""Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом)."""
|
||||||
book = openpyxl.Workbook()
|
workbook = openpyxl.Workbook()
|
||||||
default = book.active
|
default = workbook.active
|
||||||
assert default is not None
|
assert default is not None
|
||||||
book.remove(default)
|
workbook.remove(default)
|
||||||
for name, rows in sheets.items():
|
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):
|
for row_index, cells in enumerate(rows, start=1):
|
||||||
col = 1
|
col = 1
|
||||||
for value in cells:
|
for value in cells:
|
||||||
@ -79,7 +83,7 @@ def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
|
|||||||
sheet.cell(row_index, col, value)
|
sheet.cell(row_index, col, value)
|
||||||
col += 1
|
col += 1
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
book.save(buffer)
|
workbook.save(buffer)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
@ -122,14 +126,22 @@ def statement() -> list[Cells]:
|
|||||||
return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]]
|
return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]]
|
||||||
|
|
||||||
|
|
||||||
def parser(
|
def book(
|
||||||
rules: Rules,
|
rules: Rules,
|
||||||
*,
|
*,
|
||||||
|
statements: StatementSheetParser | None = None,
|
||||||
max_bytes: int = MAX_BYTES,
|
max_bytes: int = MAX_BYTES,
|
||||||
timeout_seconds: float = TIMEOUT_SECONDS,
|
timeout_seconds: float = TIMEOUT_SECONDS,
|
||||||
clock: Callable[[], float] = time.monotonic,
|
clock: Callable[[], float] = time.monotonic,
|
||||||
) -> OsvParser:
|
) -> BookParser:
|
||||||
return OsvParser(rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock)
|
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:
|
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:
|
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.status is ResultStatus.DONE
|
||||||
assert outcome.error is None
|
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 → тождество в его строке и сумма родителя."""
|
"""Spec «Испорченная сумма»: `closing_debit` контрагента +1 → тождество в его строке и сумма родителя."""
|
||||||
spoiled = (60, None, 20, 10, 71, None)
|
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
|
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||||
checks = only_document(outcome).checks
|
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:
|
def test_missing_total_needs_check(rules: Rules) -> None:
|
||||||
"""R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`."""
|
"""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
|
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||||
checks = only_document(outcome).checks
|
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:
|
def test_empty_osv_is_done_with_total_only(rules: Rules) -> None:
|
||||||
"""Spec «Пустая ОСВ» (R-13): заголовок и сразу «Итого» без сумм."""
|
"""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
|
assert outcome.status is ResultStatus.DONE
|
||||||
document = only_document(outcome)
|
document = only_document(outcome)
|
||||||
@ -254,7 +266,7 @@ def test_number_warnings_carry_row_numbers(rules: Rules) -> None:
|
|||||||
rows = mini_osv()
|
rows = mini_osv()
|
||||||
rows[CONTRACTOR_ROW - 1] = data(1, "ООО Подрядчик", (40, None, 30, 20, 50, "н/д"), inn="7707654321")
|
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 outcome.status is ResultStatus.DONE
|
||||||
assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome))
|
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:
|
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)
|
document = only_document(outcome)
|
||||||
header = document.header
|
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:
|
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)
|
document = only_document(outcome)
|
||||||
assert [(row.row_value, row.level, row.group_type) for row in document.rows] == [
|
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:
|
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)
|
document = only_document(outcome)
|
||||||
assert document.rows[-1].group_type == "TOTAL"
|
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:
|
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 outcome.status is ResultStatus.DONE
|
||||||
assert only_document(outcome).locator == "sheet:ОСВ 60"
|
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)
|
assert_valid_result(outcome)
|
||||||
|
|
||||||
|
|
||||||
def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
|
def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
|
||||||
"""Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings."""
|
"""Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings."""
|
||||||
other: list[Cells] = [["Справка о дебиторской задолженности"], ["Контрагент", "Сумма"], ["ООО Лютик", 5]]
|
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 outcome.status is ResultStatus.DONE
|
||||||
assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
|
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:
|
def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None:
|
||||||
spoiled = (60, None, 20, 10, 71, 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 outcome.status is ResultStatus.NEEDS_CHECK
|
||||||
assert len(outcome.documents) == 2 # noqa: PLR2004 - два листа ОСВ
|
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:
|
def test_book_without_osv_is_invalid_document(rules: Rules) -> None:
|
||||||
"""Spec «Нет ОСВ»: сообщение называет число просмотренных листов."""
|
"""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.status is ResultStatus.ERROR
|
||||||
assert outcome.error is not None
|
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:
|
def test_osv_sheet_without_table_header_is_invalid_format(rules: Rules) -> None:
|
||||||
rows: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]]
|
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.status is ResultStatus.ERROR
|
||||||
assert outcome.error is not None
|
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),))
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.INVALID_FORMAT
|
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:
|
def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> None:
|
||||||
"""Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор."""
|
"""Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор."""
|
||||||
book = xlsx({"ОСВ 60": mini_osv()})
|
content = xlsx({"ОСВ 60": mini_osv()})
|
||||||
|
|
||||||
over = parser(rules, max_bytes=len(book) - 1).parse(book, "XLSX")
|
over = book(rules, max_bytes=len(content) - 1).parse(content, "XLSX")
|
||||||
exact = parser(rules, max_bytes=len(book)).parse(book, "XLSX")
|
exact = book(rules, max_bytes=len(content)).parse(content, "XLSX")
|
||||||
|
|
||||||
assert over.status is ResultStatus.ERROR
|
assert over.status is ResultStatus.ERROR
|
||||||
assert over.error is not None
|
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:
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_signature_is_unsupported_format(rules: Rules) -> None:
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||||
|
|
||||||
|
|
||||||
def test_broken_zip_is_unreadable(rules: Rules) -> None:
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.UNREADABLE
|
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:
|
def test_deadline_exceeded_is_timeout(rules: Rules) -> None:
|
||||||
clock = itertools.count(step=CLOCK_STEP)
|
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"
|
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),))
|
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.status is ResultStatus.ERROR
|
||||||
assert outcome.error is not None
|
assert outcome.error is not None
|
||||||
@ -498,7 +510,7 @@ def test_unexpected_exception_is_logged_with_traceback(
|
|||||||
|
|
||||||
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
|
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:
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.UNREADABLE
|
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:
|
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 is not None
|
||||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
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__)
|
deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__)
|
||||||
|
|
||||||
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
|
with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
|
||||||
parser(rules).parse_sheet(sheet, deadline)
|
OsvParser(rules).parse_sheet(sheet, deadline)
|
||||||
|
|
||||||
|
|
||||||
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
|
# --- Словари — данные (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:
|
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
|
assert outcome.status is ResultStatus.DONE
|
||||||
document = only_document(outcome)
|
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, и тождество сальдо не сходится в
|
необязательно, R-12), колонки начала уходят в `extra`, `opening_*` = null, и тождество сальдо не сходится в
|
||||||
строках с ненулевым началом → `needs_check` (фактическое поведение, зафиксировано тестом).
|
строках с ненулевым началом → `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
|
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||||
document = only_document(outcome)
|
document = only_document(outcome)
|
||||||
@ -589,3 +601,87 @@ def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None:
|
|||||||
checks = document.checks
|
checks = document.checks
|
||||||
assert checks is not None
|
assert checks is not None
|
||||||
assert checks.balance_identity_violations == [ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW]
|
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
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»).
|
"""CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»).
|
||||||
|
|
||||||
Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования.
|
Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования.
|
||||||
Синтетические книги собираются openpyxl в памяти (`test_osv_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
|
Синтетические книги собираются openpyxl в памяти (`test_book_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
|
||||||
запуск как модуля через subprocess (маркер `fixtures`).
|
запуск как модуля через subprocess (маркер `fixtures`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from result_contract import schema_errors
|
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.contract import RecognitionResult
|
||||||
from src.parser import cli
|
from src.parser import cli
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import openpyxl
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.contract import ErrorCode, ResultStatus
|
from src.contract import ErrorCode, ResultStatus
|
||||||
|
from src.parser.book import BookParser, SkippingStatementParser
|
||||||
from src.parser.errors import UnreadableError, UnsupportedFormatError
|
from src.parser.errors import UnreadableError, UnsupportedFormatError
|
||||||
from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
|
from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
|
||||||
from src.parser.osv import OsvParser
|
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
|
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")
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
def test_many_small_merged_ranges_are_summed() -> None:
|
def test_many_small_merged_ranges_are_summed() -> None:
|
||||||
per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик
|
per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик
|
||||||
@ -134,9 +141,7 @@ def test_many_small_merged_ranges_are_summed() -> None:
|
|||||||
|
|
||||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None:
|
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(
|
outcome = _book(rules, 50 * 1024 * 1024).parse(_xlsx_with_merges("A1:Z200000"), "XLSX")
|
||||||
_xlsx_with_merges("A1:Z200000"), "XLSX"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert outcome.status is ResultStatus.ERROR
|
assert outcome.status is ResultStatus.ERROR
|
||||||
assert outcome.error is not None
|
assert outcome.error is not None
|
||||||
@ -534,9 +539,7 @@ def test_zip_expanding_beyond_limit_is_rejected() -> None:
|
|||||||
|
|
||||||
@pytest.mark.usefixtures("openpyxl_forbidden")
|
@pytest.mark.usefixtures("openpyxl_forbidden")
|
||||||
def test_parser_passes_ten_times_max_bytes_as_zip_limit(rules: Rules) -> None:
|
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(
|
outcome = _book(rules, SMALL_MAX_BYTES).parse(_xlsx_with_zeros(ZEROS_BYTES), "XLSX")
|
||||||
_xlsx_with_zeros(ZEROS_BYTES), "XLSX"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert outcome.error is not None
|
assert outcome.error is not None
|
||||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»).
|
"""Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»).
|
||||||
|
|
||||||
`get_parser()` по умолчанию отдаёт `OsvParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
|
`get_parser()` по умолчанию отдаёт `BookParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
|
||||||
падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера:
|
падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера:
|
||||||
`NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.
|
`NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.
|
||||||
"""
|
"""
|
||||||
@ -15,7 +15,7 @@ import pytest
|
|||||||
from src.contract import ErrorCode, FileType, MessageError, ResultStatus
|
from src.contract import ErrorCode, FileType, MessageError, ResultStatus
|
||||||
from src.core.config import Settings
|
from src.core.config import Settings
|
||||||
from src.parser import NullParser, ParseOutcome, Parser, get_parser
|
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
|
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE
|
||||||
|
|
||||||
DEFAULT_MAX_BYTES = 50 * 1024 * 1024
|
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
|
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()
|
parser = get_parser()
|
||||||
|
|
||||||
assert isinstance(parser, OsvParser)
|
assert isinstance(parser, BookParser)
|
||||||
assert isinstance(parser, Parser)
|
assert isinstance(parser, Parser)
|
||||||
assert (parser.max_bytes, parser.timeout_seconds) == (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_SECONDS)
|
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)
|
parser = get_parser(settings)
|
||||||
|
|
||||||
assert isinstance(parser, OsvParser)
|
assert isinstance(parser, BookParser)
|
||||||
assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста
|
assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user