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`.
|
||||
Этот сценарий проверяют `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`**.
|
||||
|
||||
### Лимиты
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
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`, предупреждения, признак «контрольные суммы сошлись».
|
||||
|
||||
Порядок: лимит размера → чтение книги по сигнатуре → для каждого листа вид по содержимому (ОСВ / форма ББ-ОФР / прочее)
|
||||
→ лист ОСВ: заголовок таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные
|
||||
суммы §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),
|
||||
)
|
||||
|
||||
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 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]
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 - значения из окружения теста
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user