From 621c0b2dc4b024ebb1b2574a5892979d4689985a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 14:51:01 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=B2=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=80=D0=BA=D0=B5=D1=80=D0=B5,=20CLI=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=B0,=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 63 ++++++ api/src/core/worker.py | 29 ++- api/src/parser/cli.py | 133 +++++++++++++ api/tests/integration/test_roundtrip.py | 69 ++++++- api/tests/unit/parser/test_cli.py | 185 ++++++++++++++++++ api/tests/unit/test_worker.py | 31 ++- .../plans/2026-10-05-slice-2a-osv-parser.md | 6 +- openspec/changes/slice-2a-osv-parser/tasks.md | 2 +- 8 files changed, 498 insertions(+), 20 deletions(-) create mode 100644 api/src/parser/cli.py create mode 100644 api/tests/unit/parser/test_cli.py diff --git a/README.md b/README.md index c4872c4..b4d8bfa 100644 --- a/README.md +++ b/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`). Вложенные поля задаются через двойное подчёркивание. diff --git a/api/src/core/worker.py b/api/src/core/worker.py index 83bb91e..997ccf6 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.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: diff --git a/api/src/parser/cli.py b/api/src/parser/cli.py new file mode 100644 index 0000000..60a061d --- /dev/null +++ b/api/src/parser/cli.py @@ -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()) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 339ef76..4d2a51b 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -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) diff --git a/api/tests/unit/parser/test_cli.py b/api/tests/unit/parser/test_cli.py new file mode 100644 index 0000000..52d65b1 --- /dev/null +++ b/api/tests/unit/parser/test_cli.py @@ -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" diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 46a27eb..8d43762 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -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" diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 0d36bd5..5e57e2b 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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 разбора файла, интеграционный сценарий`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 5e76989..e987753 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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).