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 и 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`). Вложенные поля задаются через двойное подчёркивание.
|
||||
|
||||
@ -83,6 +83,23 @@ def result_key(file_id: object, job_id: object) -> str:
|
||||
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):
|
||||
"""Запрос не прошёл контракт: `SCHEMA_MISMATCH`."""
|
||||
|
||||
@ -378,16 +395,8 @@ class RecognitionWorker:
|
||||
key = result_key(request.file_id, request.job_id)
|
||||
try:
|
||||
metrics = self._metrics(outcome.metrics, started)
|
||||
result = RecognitionResult.model_validate(
|
||||
{
|
||||
"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),
|
||||
}
|
||||
result = build_result(
|
||||
outcome, job_id=request.job_id, file_id=request.file_id, service=self._service, metrics=metrics
|
||||
)
|
||||
await self._storage.put_json(request.s3.bucket, key, result.to_wire())
|
||||
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 раньше результата», «Повторный запрос во время обработки» (здесь — после
|
||||
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
|
||||
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия».
|
||||
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
|
||||
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO).
|
||||
|
||||
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
|
||||
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
|
||||
@ -31,12 +32,21 @@ from jsonschema import Draft202012Validator, FormatChecker
|
||||
from miniopy_async import Minio
|
||||
from nats.aio.client import Client as NATS
|
||||
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
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
||||
|
||||
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
|
||||
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
|
||||
MESSAGE_VALIDATOR = Draft202012Validator(
|
||||
@ -52,6 +62,7 @@ MESSAGE_TIMEOUT = 10.0 # одно сообщение сервиса (с зап
|
||||
QUIET_WINDOW = 1.5
|
||||
SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers UNREADABLE"
|
||||
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`
|
||||
|
||||
@ -147,13 +158,30 @@ class Uploads:
|
||||
def __init__(self, client: Minio) -> None:
|
||||
self.client = client
|
||||
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"]
|
||||
data = io.BytesIO(SOURCE_BYTES)
|
||||
await self.client.put_object(bucket, key, data, len(SOURCE_BYTES), content_type=XLSX_CONTENT_TYPE)
|
||||
await self._ensure_bucket(bucket)
|
||||
await self.client.put_object(bucket, key, io.BytesIO(data), len(data), content_type=content_type)
|
||||
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
|
||||
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")
|
||||
assert "2.0" in body["error"]["message"]
|
||||
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.nats import NatsConnection, NatsPublishError
|
||||
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]
|
||||
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()
|
||||
|
||||
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 по статусу)
|
||||
- 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).
|
||||
- [ ] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
|
||||
- [ ] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
|
||||
- [x] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
|
||||
- [x] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
|
||||
- [x] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
-> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
|
||||
статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
|
||||
сумма → `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"`, файл результата проходит схему.
|
||||
README: раздел «Запуск на файле» (`python -m src.parser.cli <файл>` — печать результата JSON; добавить CLI).
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user