feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий
CLI `python -m src.parser.cli <файл> [--file-type]` печатает JSON результата (коды 0/1/2 по статусу, 3 — файл/использование); сборка RecognitionResult выделена из воркера в чистую `build_result` без изменения логики; тесты воркера на реальном OsvParser, интеграционный сценарий «Реальная ОСВ через воркер», README «Запуск на файле». Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
cf532a325b
commit
621c0b2dc4
63
README.md
63
README.md
@ -116,6 +116,69 @@ curl -s localhost:8000/healthcheck
|
|||||||
Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS
|
Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS
|
||||||
восстанавливается автоматически (бесконечное автопереподключение).
|
восстанавливается автоматически (бесконечное автопереподключение).
|
||||||
|
|
||||||
|
## Запуск на файле
|
||||||
|
|
||||||
|
Разбор одного файла без NATS и S3 — из каталога `api/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XLS|XLSX|XLSB]
|
||||||
|
```
|
||||||
|
|
||||||
|
Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его
|
||||||
|
переопределяет, а для неизвестного расширения обязателен. `ENV_FILE=.env.test` нужен, потому что импорт пакета `src`
|
||||||
|
читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше).
|
||||||
|
|
||||||
|
В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под
|
||||||
|
`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
|
||||||
|
экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` —
|
||||||
|
фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`,
|
||||||
|
а код и сообщение ошибки дублируются в stderr.
|
||||||
|
|
||||||
|
| Код возврата | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `0` | `done` |
|
||||||
|
| `1` | `needs_check` — нарушены контрольные суммы (§1.4) |
|
||||||
|
| `2` | `error` — разбор вернул ошибку (`error.code`, `error.message`) |
|
||||||
|
| `3` | файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без `--file-type`, недопустимое значение `--file-type`); у `argparse` код 2 по умолчанию, здесь он занят статусом `error` |
|
||||||
|
|
||||||
|
Пример (начало вывода на `ОСВ_66 за 2024.xls` из `fixtures`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "1.1",
|
||||||
|
"job_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"file_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"service": {
|
||||||
|
"name": "tabular-parser",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"status": "done",
|
||||||
|
"metrics": {
|
||||||
|
"duration_ms": 2,
|
||||||
|
"rows": 9,
|
||||||
|
"warnings": [
|
||||||
|
"sheet=Лист_1 hierarchy=outline"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"document_type": "OSV",
|
||||||
|
"locator": "sheet:Лист_1",
|
||||||
|
"header": {
|
||||||
|
"organization_name": "АО \"Русский бисквит\"",
|
||||||
|
"organization_inn": null,
|
||||||
|
"account_code_raw": "66",
|
||||||
|
"period_text": "2024 г.",
|
||||||
|
"period_year": 2024,
|
||||||
|
"period_quarter": "Q4",
|
||||||
|
"is_cumulative": true,
|
||||||
|
"data_type": "BU",
|
||||||
|
"currency": "RUB",
|
||||||
|
"header_variant": 1
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
Настройки читает `pydantic-settings` (`api/src/core/config.py`). Вложенные поля задаются через двойное подчёркивание.
|
Настройки читает `pydantic-settings` (`api/src/core/config.py`). Вложенные поля задаются через двойное подчёркивание.
|
||||||
|
|||||||
@ -83,6 +83,23 @@ def result_key(file_id: object, job_id: object) -> str:
|
|||||||
return f"results/{file_id}/{job_id}/result.json"
|
return f"results/{file_id}/{job_id}/result.json"
|
||||||
|
|
||||||
|
|
||||||
|
def build_result(
|
||||||
|
outcome: ParseOutcome, *, job_id: object, file_id: object, service: ServiceInfo, metrics: Metrics | None
|
||||||
|
) -> RecognitionResult:
|
||||||
|
"""Файл результата контракта из успешного исхода (`done | needs_check`); общий для воркера и CLI разбора файла."""
|
||||||
|
return RecognitionResult.model_validate(
|
||||||
|
{
|
||||||
|
"schema_version": CONTRACT_VERSION,
|
||||||
|
"job_id": job_id,
|
||||||
|
"file_id": file_id,
|
||||||
|
"service": service,
|
||||||
|
"status": ResultStatus(outcome.status),
|
||||||
|
"metrics": metrics,
|
||||||
|
"documents": list(outcome.documents),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvalidRequestError(Exception):
|
class InvalidRequestError(Exception):
|
||||||
"""Запрос не прошёл контракт: `SCHEMA_MISMATCH`."""
|
"""Запрос не прошёл контракт: `SCHEMA_MISMATCH`."""
|
||||||
|
|
||||||
@ -378,16 +395,8 @@ class RecognitionWorker:
|
|||||||
key = result_key(request.file_id, request.job_id)
|
key = result_key(request.file_id, request.job_id)
|
||||||
try:
|
try:
|
||||||
metrics = self._metrics(outcome.metrics, started)
|
metrics = self._metrics(outcome.metrics, started)
|
||||||
result = RecognitionResult.model_validate(
|
result = build_result(
|
||||||
{
|
outcome, job_id=request.job_id, file_id=request.file_id, service=self._service, metrics=metrics
|
||||||
"schema_version": CONTRACT_VERSION,
|
|
||||||
"job_id": request.job_id,
|
|
||||||
"file_id": request.file_id,
|
|
||||||
"service": self._service,
|
|
||||||
"status": ResultStatus(outcome.status),
|
|
||||||
"metrics": metrics,
|
|
||||||
"documents": list(outcome.documents),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
await self._storage.put_json(request.s3.bucket, key, result.to_wire())
|
await self._storage.put_json(request.s3.bucket, key, result.to_wire())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
133
api/src/parser/cli.py
Normal file
133
api/src/parser/cli.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""Разбор одного файла из командной строки: `python -m src.parser.cli <файл> [--file-type XLSX|XLS|XLSB]` (из `api/`).
|
||||||
|
|
||||||
|
В stdout — JSON файла результата контракта v1.1 (тот же, что воркер кладёт в S3 под `results/…/result.json`; сборка
|
||||||
|
— `core.worker.build_result`) для `done | needs_check`, либо `{"status": "error", "error": {...}}` для ошибки разбора.
|
||||||
|
JSON детерминирован по форме: `ensure_ascii=False`, `indent=2`, порядок полей — порядок модели (`sort_keys` нет);
|
||||||
|
суммы — строки. `job_id`/`file_id` — фиксированные нулевые заглушки, `service` — `tabular-parser` с версией по
|
||||||
|
умолчанию. Метрика `duration_ms` — фактическая, поэтому от запуска к запуску меняется.
|
||||||
|
|
||||||
|
Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его
|
||||||
|
переопределяет. Код возврата:
|
||||||
|
|
||||||
|
- 0 — `done`;
|
||||||
|
- 1 — `needs_check` (нарушены контрольные суммы §1.4);
|
||||||
|
- 2 — `error` (результат разбора — ошибка; код и сообщение — в stdout и в stderr);
|
||||||
|
- 3 — файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без `--file-type`,
|
||||||
|
недопустимое значение `--file-type`): у argparse код 2 по умолчанию, но он занят статусом `error`, поэтому
|
||||||
|
ошибки использования переопределены на 3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, NoReturn
|
||||||
|
|
||||||
|
from src.contract import FileType, ResultStatus, ServiceInfo
|
||||||
|
from src.core.config import Settings
|
||||||
|
from src.core.worker import build_result
|
||||||
|
from src.parser import ParseOutcome, get_parser
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
EXIT_DONE = 0
|
||||||
|
EXIT_NEEDS_CHECK = 1
|
||||||
|
EXIT_ERROR = 2
|
||||||
|
EXIT_USAGE = 3
|
||||||
|
|
||||||
|
STUB_ID = uuid.UUID(int=0)
|
||||||
|
FILE_TYPES: tuple[FileType, ...] = ("XLS", "XLSX", "XLSB")
|
||||||
|
JSON_INDENT = 2
|
||||||
|
|
||||||
|
|
||||||
|
class _Parser(argparse.ArgumentParser):
|
||||||
|
"""Ошибки использования — код 3 (код 2 занят статусом `error`)."""
|
||||||
|
|
||||||
|
def error(self, message: str) -> NoReturn:
|
||||||
|
self.print_usage(sys.stderr)
|
||||||
|
self.exit(EXIT_USAGE, f"{self.prog}: ошибка: {message}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_argument_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = _Parser(
|
||||||
|
prog="python -m src.parser.cli",
|
||||||
|
description="Разбор XLS/XLSX/XLSB (ОСВ): JSON файла результата — в stdout.",
|
||||||
|
epilog="Коды возврата: 0 — done, 1 — needs_check, 2 — error, 3 — файл не найден / ошибка использования.",
|
||||||
|
)
|
||||||
|
parser.add_argument("path", type=Path, metavar="файл", help="путь к файлу XLS/XLSX/XLSB")
|
||||||
|
parser.add_argument(
|
||||||
|
"--file-type",
|
||||||
|
choices=FILE_TYPES,
|
||||||
|
help="тип файла; по умолчанию — по расширению файла",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _file_type(path: Path, explicit: FileType | None) -> FileType | None:
|
||||||
|
if explicit is not None:
|
||||||
|
return explicit
|
||||||
|
extension = path.suffix.lstrip(".").upper()
|
||||||
|
return next((file_type for file_type in FILE_TYPES if file_type == extension), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_payload(outcome: ParseOutcome) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {"status": ResultStatus.ERROR.value}
|
||||||
|
if outcome.error is not None:
|
||||||
|
payload["error"] = outcome.error.model_dump(mode="json", exclude_unset=True)
|
||||||
|
if outcome.metrics is not None:
|
||||||
|
payload["metrics"] = outcome.metrics.model_dump(mode="json", exclude_unset=True)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=JSON_INDENT) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace | int:
|
||||||
|
try:
|
||||||
|
return _build_argument_parser().parse_args(argv)
|
||||||
|
except SystemExit as exit_request: # --help (0) и ошибки использования (3)
|
||||||
|
return exit_request.code if isinstance(exit_request.code, int) else EXIT_USAGE
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Разбирает файл и печатает JSON; возвращает код возврата процесса."""
|
||||||
|
arguments = _parse_arguments(argv)
|
||||||
|
if isinstance(arguments, int):
|
||||||
|
return arguments
|
||||||
|
path: Path = arguments.path
|
||||||
|
file_type = _file_type(path, arguments.file_type)
|
||||||
|
if file_type is None:
|
||||||
|
print(
|
||||||
|
f"неизвестное расширение файла {path.suffix!r}: укажите тип явно, --file-type {{{','.join(FILE_TYPES)}}}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return EXIT_USAGE
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"файл не прочитан: {path}: {exc.strerror or type(exc).__name__}", file=sys.stderr)
|
||||||
|
return EXIT_USAGE
|
||||||
|
|
||||||
|
outcome = get_parser().parse(data, file_type)
|
||||||
|
if outcome.status is ResultStatus.ERROR:
|
||||||
|
error = outcome.error
|
||||||
|
print(f"{error.code.value}: {error.message}" if error else "ошибка разбора", file=sys.stderr)
|
||||||
|
sys.stdout.write(_dump(_error_payload(outcome)))
|
||||||
|
return EXIT_ERROR
|
||||||
|
|
||||||
|
service = ServiceInfo.model_validate(
|
||||||
|
{"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default}
|
||||||
|
)
|
||||||
|
result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics)
|
||||||
|
sys.stdout.write(_dump(result.to_wire()))
|
||||||
|
return EXIT_DONE if outcome.status is ResultStatus.DONE else EXIT_NEEDS_CHECK
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||||
|
if reconfigure is not None:
|
||||||
|
reconfigure(encoding="utf-8") # вывод не зависит от локали терминала
|
||||||
|
raise SystemExit(main())
|
||||||
@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
|
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
|
||||||
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
|
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
|
||||||
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия».
|
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
|
||||||
|
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO).
|
||||||
|
|
||||||
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
|
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
|
||||||
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
|
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
|
||||||
@ -31,12 +32,21 @@ from jsonschema import Draft202012Validator, FormatChecker
|
|||||||
from miniopy_async import Minio
|
from miniopy_async import Minio
|
||||||
from nats.aio.client import Client as NATS
|
from nats.aio.client import Client as NATS
|
||||||
from nats.aio.msg import Msg
|
from nats.aio.msg import Msg
|
||||||
|
from result_contract import schema_errors
|
||||||
|
|
||||||
from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT, request_msg_id
|
from src.contract import (
|
||||||
|
NATS_MSG_ID_HEADER,
|
||||||
|
RESULT_SUBJECT,
|
||||||
|
TABULAR_REQUEST_SUBJECT,
|
||||||
|
RecognitionResult,
|
||||||
|
request_msg_id,
|
||||||
|
)
|
||||||
from src.core.config import get_settings
|
from src.core.config import get_settings
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
||||||
|
|
||||||
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
|
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
|
||||||
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
|
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
|
||||||
MESSAGE_VALIDATOR = Draft202012Validator(
|
MESSAGE_VALIDATOR = Draft202012Validator(
|
||||||
@ -52,6 +62,7 @@ MESSAGE_TIMEOUT = 10.0 # одно сообщение сервиса (с зап
|
|||||||
QUIET_WINDOW = 1.5
|
QUIET_WINDOW = 1.5
|
||||||
SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers UNREADABLE"
|
SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers UNREADABLE"
|
||||||
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
XLS_CONTENT_TYPE = "application/vnd.ms-excel"
|
||||||
|
|
||||||
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
|
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
|
||||||
|
|
||||||
@ -147,13 +158,30 @@ class Uploads:
|
|||||||
def __init__(self, client: Minio) -> None:
|
def __init__(self, client: Minio) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
self.keys: list[tuple[str, str]] = []
|
self.keys: list[tuple[str, str]] = []
|
||||||
|
self.buckets: set[str] = set()
|
||||||
|
|
||||||
async def put(self, request: dict[str, Any]) -> None:
|
async def put(
|
||||||
|
self, request: dict[str, Any], data: bytes = SOURCE_BYTES, content_type: str = XLSX_CONTENT_TYPE
|
||||||
|
) -> None:
|
||||||
bucket, key = request["s3"]["bucket"], request["s3"]["key"]
|
bucket, key = request["s3"]["bucket"], request["s3"]["key"]
|
||||||
data = io.BytesIO(SOURCE_BYTES)
|
await self._ensure_bucket(bucket)
|
||||||
await self.client.put_object(bucket, key, data, len(SOURCE_BYTES), content_type=XLSX_CONTENT_TYPE)
|
await self.client.put_object(bucket, key, io.BytesIO(data), len(data), content_type=content_type)
|
||||||
self.keys.append((bucket, key))
|
self.keys.append((bucket, key))
|
||||||
|
|
||||||
|
async def _ensure_bucket(self, bucket: str) -> None:
|
||||||
|
"""`NoSuchBucket` (OQ-80): на чистом MinIO бакета нет — тест создаёт его сам, compose не трогаем."""
|
||||||
|
if bucket not in self.buckets:
|
||||||
|
if not await self.client.bucket_exists(bucket):
|
||||||
|
await self.client.make_bucket(bucket)
|
||||||
|
self.buckets.add(bucket)
|
||||||
|
|
||||||
|
async def read(self, bucket: str, key: str) -> bytes:
|
||||||
|
response = await self.client.get_object(bucket, key)
|
||||||
|
try:
|
||||||
|
return await response.read()
|
||||||
|
finally:
|
||||||
|
response.release()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def uploads() -> AsyncIterator[Uploads]:
|
async def uploads() -> AsyncIterator[Uploads]:
|
||||||
@ -254,3 +282,34 @@ async def test_unsupported_schema_version_gives_single_schema_mismatch_without_a
|
|||||||
body = _assert_error(await core.next_message(request), request, "SCHEMA_MISMATCH")
|
body = _assert_error(await core.next_message(request), request, "SCHEMA_MISMATCH")
|
||||||
assert "2.0" in body["error"]["message"]
|
assert "2.0" in body["error"]["message"]
|
||||||
await core.assert_quiet(request)
|
await core.assert_quiet(request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
async def test_real_osv_through_worker_gives_done_and_valid_result_file(
|
||||||
|
core: Core, uploads: Uploads, fixtures_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""«Реальная ОСВ через воркер»: реальный XLS в MinIO → ack → `done`, `result_schema_version 1.1`, файл по схеме."""
|
||||||
|
request = _new_request(key_name="osv_66_2024.xls")
|
||||||
|
request["file_type"] = "XLS"
|
||||||
|
await uploads.put(request, (fixtures_dir / REAL_OSV_66).read_bytes(), XLS_CONTENT_TYPE)
|
||||||
|
|
||||||
|
await core.publish(request)
|
||||||
|
_assert_ack(await core.next_message(request), request)
|
||||||
|
body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}")
|
||||||
|
|
||||||
|
assert body["status"] == "done", body
|
||||||
|
assert body["result_schema_version"] == "1.1"
|
||||||
|
assert body["metrics"]["duration_ms"] >= 0
|
||||||
|
result_ref = body["result_s3"]
|
||||||
|
assert result_ref["bucket"] == request["s3"]["bucket"]
|
||||||
|
assert result_ref["key"] == f"results/{request['file_id']}/{request['job_id']}/result.json"
|
||||||
|
uploads.keys.append((result_ref["bucket"], result_ref["key"])) # результат тоже убирается после теста
|
||||||
|
|
||||||
|
stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"]))
|
||||||
|
assert schema_errors(stored) == []
|
||||||
|
assert RecognitionResult.model_validate(stored).to_wire() == stored
|
||||||
|
assert stored["status"] == "done"
|
||||||
|
assert stored["job_id"] == request["job_id"]
|
||||||
|
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
|
||||||
|
assert stored["documents"][0]["header"]["account_code_raw"] == "66"
|
||||||
|
await core.assert_quiet(request)
|
||||||
|
|||||||
185
api/tests/unit/parser/test_cli.py
Normal file
185
api/tests/unit/parser/test_cli.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""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`; реальный файл —
|
||||||
|
запуск как модуля через subprocess (маркер `fixtures`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from result_contract import schema_errors
|
||||||
|
from test_osv_parser import mini_osv, xlsx
|
||||||
|
|
||||||
|
from src.contract import RecognitionResult
|
||||||
|
from src.parser import cli
|
||||||
|
|
||||||
|
API_DIR = Path(__file__).resolve().parents[3]
|
||||||
|
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
||||||
|
SPOILED = (60, None, 20, 10, 71, None)
|
||||||
|
EXIT_DONE, EXIT_NEEDS_CHECK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2, 3
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, name: str, data: bytes) -> Path:
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_bytes(data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, str, str]:
|
||||||
|
code = cli.main(list(argv))
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
return code, captured.out, captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_done_book_returns_0_and_prints_result_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
code, out, _err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
assert code == EXIT_DONE
|
||||||
|
payload: dict[str, Any] = json.loads(out)
|
||||||
|
assert payload["status"] == "done"
|
||||||
|
assert payload["schema_version"] == "1.1"
|
||||||
|
assert schema_errors(payload) == []
|
||||||
|
assert RecognitionResult.model_validate(payload).to_wire() == payload
|
||||||
|
assert payload["documents"][0]["header"]["account_code_raw"] == "60"
|
||||||
|
assert "ОСВ 60" in out # не ASCII-экранирование
|
||||||
|
assert out.endswith("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_is_indented_and_keeps_model_field_order(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
_code, out, _err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
assert out.startswith('{\n "schema_version": "1.1",\n')
|
||||||
|
keys = list(json.loads(out))
|
||||||
|
assert keys != sorted(keys) # sort_keys не используется: порядок полей модели значим для golden
|
||||||
|
|
||||||
|
|
||||||
|
def test_money_is_printed_as_strings(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
_code, out, _err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
row = json.loads(out)["documents"][0]["rows"][1]
|
||||||
|
assert row["opening_debit"] == "60.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_check_book_returns_1(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "spoiled.xlsx", xlsx({"ОСВ 60": mini_osv(SPOILED)}))
|
||||||
|
|
||||||
|
code, out, _err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
assert code == EXIT_NEEDS_CHECK
|
||||||
|
payload = json.loads(out)
|
||||||
|
assert payload["status"] == "needs_check"
|
||||||
|
assert schema_errors(payload) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_outcome_returns_2_with_error_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "broken.xlsx", b"PK\x03\x04 broken zip")
|
||||||
|
|
||||||
|
code, out, err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
assert code == EXIT_ERROR
|
||||||
|
payload = json.loads(out)
|
||||||
|
assert payload["status"] == "error"
|
||||||
|
assert payload["error"]["code"] == "UNREADABLE"
|
||||||
|
assert payload["error"]["message"]
|
||||||
|
assert "UNREADABLE" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
code, out, err = _run(capsys, str(tmp_path / "nope.xlsx"))
|
||||||
|
|
||||||
|
assert code == EXIT_USAGE
|
||||||
|
assert out == ""
|
||||||
|
assert "nope.xlsx" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_instead_of_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
code, out, _err = _run(capsys, str(tmp_path))
|
||||||
|
|
||||||
|
assert code == EXIT_USAGE
|
||||||
|
assert out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_extension_without_file_type_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
code, out, err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
assert code == EXIT_USAGE
|
||||||
|
assert out == ""
|
||||||
|
assert "--file-type" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_file_type_overrides_extension(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
code, out, _err = _run(capsys, str(path), "--file-type", "XLSX")
|
||||||
|
|
||||||
|
assert code == EXIT_DONE
|
||||||
|
assert json.loads(out)["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_is_case_insensitive(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "OSV.XLSX", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
code, _out, _err = _run(capsys, str(path))
|
||||||
|
|
||||||
|
assert code == EXIT_DONE
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_file_type_value_returns_3_not_argparse_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
code, out, err = _run(capsys, str(path), "--file-type", "PDF")
|
||||||
|
|
||||||
|
assert code == EXIT_USAGE
|
||||||
|
assert out == ""
|
||||||
|
assert "PDF" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_arguments_returns_3(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
code, out, _err = _run(capsys)
|
||||||
|
|
||||||
|
assert code == EXIT_USAGE
|
||||||
|
assert out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_is_deterministic_except_duration(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
|
||||||
|
|
||||||
|
first = json.loads(_run(capsys, str(path))[1])
|
||||||
|
second = json.loads(_run(capsys, str(path))[1])
|
||||||
|
|
||||||
|
for payload in (first, second):
|
||||||
|
payload["metrics"].pop("duration_ms")
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
def test_module_run_on_real_file_prints_valid_json(fixtures_dir: Path) -> None:
|
||||||
|
"""`python -m src.parser.cli <файл>` из `api/`: stdout — JSON результата по схеме, код возврата 0."""
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-m", "src.parser.cli", str(fixtures_dir / REAL_OSV_66)],
|
||||||
|
cwd=API_DIR,
|
||||||
|
env={**os.environ, "ENV_FILE": ".env.test"}, # импорт пакета `src` читает настройки сервиса
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert completed.returncode == EXIT_DONE, completed.stderr.decode("utf-8", "replace")
|
||||||
|
payload = json.loads(completed.stdout.decode("utf-8"))
|
||||||
|
assert payload["status"] == "done"
|
||||||
|
assert schema_errors(payload) == []
|
||||||
|
assert payload["documents"][0]["header"]["account_code_raw"] == "66"
|
||||||
@ -33,7 +33,7 @@ from src.contract import (
|
|||||||
from src.core.config import NATSSettings
|
from src.core.config import NATSSettings
|
||||||
from src.core.nats import NatsConnection, NatsPublishError
|
from src.core.nats import NatsConnection, NatsPublishError
|
||||||
from src.core.worker import RecognitionWorker, result_key
|
from src.core.worker import RecognitionWorker, result_key
|
||||||
from src.parser import NullParser, ParseOutcome
|
from src.parser import NullParser, ParseOutcome, get_parser
|
||||||
|
|
||||||
API_DIR = Path(__file__).resolve().parents[2]
|
API_DIR = Path(__file__).resolve().parents[2]
|
||||||
CONTRACTS_DIR = API_DIR / "contracts"
|
CONTRACTS_DIR = API_DIR / "contracts"
|
||||||
@ -825,3 +825,32 @@ async def test_malformed_request_for_done_job_is_dropped(env: Env) -> None:
|
|||||||
await env.worker.join()
|
await env.worker.join()
|
||||||
|
|
||||||
assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
|
assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- реальный парсер в воркере ------------------------------------------------------------------------------------
|
||||||
|
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
async def test_worker_with_real_osv_parser_gives_done_and_valid_result_file(fixtures_dir: Path) -> None:
|
||||||
|
"""Spec «Реальная ОСВ через воркер»: настоящий `OsvParser` (`get_parser`) + байты реальной ОСВ 66 без NATS/S3."""
|
||||||
|
environment = Env(get_parser())
|
||||||
|
environment.storage.objects[(BUCKET, SOURCE_KEY)] = (fixtures_dir / REAL_OSV_66).read_bytes()
|
||||||
|
environment.worker.start()
|
||||||
|
try:
|
||||||
|
await environment.worker.on_request(_msg(_request(file_type="XLS")))
|
||||||
|
await environment.worker.join()
|
||||||
|
finally:
|
||||||
|
await environment.worker.stop(timeout=1)
|
||||||
|
|
||||||
|
assert environment.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
|
||||||
|
envelope = environment.bodies()[1]
|
||||||
|
_assert_message_valid(envelope)
|
||||||
|
assert envelope["status"] == "done"
|
||||||
|
assert envelope["result_schema_version"] == "1.1"
|
||||||
|
assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY}
|
||||||
|
stored = environment.storage.json_objects[(BUCKET, RESULT_KEY)]
|
||||||
|
assert [error.message for error in VALIDATORS["result"].iter_errors(stored)] == []
|
||||||
|
assert RecognitionResult.model_validate(stored).to_wire() == stored
|
||||||
|
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
|
||||||
|
assert stored["documents"][0]["header"]["account_code_raw"] == "66"
|
||||||
|
|||||||
@ -368,9 +368,9 @@ def get_parser(settings: Settings | None = None) -> Parser
|
|||||||
- Create: `api/src/parser/cli.py` (`python -m src.parser.cli <файл> [--file-type XLSX]` → JSON результата в stdout, код возврата 0/1/2 по статусу)
|
- Create: `api/src/parser/cli.py` (`python -m src.parser.cli <файл> [--file-type XLSX]` → JSON результата в stdout, код возврата 0/1/2 по статусу)
|
||||||
- Modify: `api/tests/unit/test_worker.py` (новый тест с реальным `OsvParser` и байтами `rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`), `api/tests/integration/test_roundtrip.py` (сценарий «Реальная ОСВ через воркер»: файл в MinIO, запрос `XLS`, конверт `done`, `result_schema_version "1.1"`, файл результата по схеме, `account_code_raw "66"`), `README.md` (раздел «Запуск на файле»)
|
- Modify: `api/tests/unit/test_worker.py` (новый тест с реальным `OsvParser` и байтами `rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`), `api/tests/integration/test_roundtrip.py` (сценарий «Реальная ОСВ через воркер»: файл в MinIO, запрос `XLS`, конверт `done`, `result_schema_version "1.1"`, файл результата по схеме, `account_code_raw "66"`), `README.md` (раздел «Запуск на файле»)
|
||||||
|
|
||||||
- [ ] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
|
- [x] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
|
||||||
- [ ] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
|
- [x] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
|
||||||
- [ ] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
|
- [x] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
-> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
|
-> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
|
||||||
статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
|
статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
|
||||||
сумма → `needs_check` с номерами строк. `fixtures`-тест (property): все 66 — `done`, инварианты, корень = счёт шапки.
|
сумма → `needs_check` с номерами строк. `fixtures`-тест (property): все 66 — `done`, инварианты, корень = счёт шапки.
|
||||||
- [ ] 3.2 Интеграция с воркером: `test_worker` на реальном `OsvParser` с байтами `ОСВ_66 за 2024.xls` (unit, без NATS) и
|
- [x] 3.2 Интеграция с воркером: `test_worker` на реальном `OsvParser` с байтами `ОСВ_66 за 2024.xls` (unit, без NATS) и
|
||||||
интеграционный roundtrip (NATS + MinIO): конверт `done`, `result_schema_version = "1.1"`, файл результата проходит схему.
|
интеграционный roundtrip (NATS + MinIO): конверт `done`, `result_schema_version = "1.1"`, файл результата проходит схему.
|
||||||
README: раздел «Запуск на файле» (`python -m src.parser.cli <файл>` — печать результата JSON; добавить CLI).
|
README: раздел «Запуск на файле» (`python -m src.parser.cli <файл>` — печать результата JSON; добавить CLI).
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user