diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py new file mode 100644 index 0000000..6722288 --- /dev/null +++ b/api/src/parser/__init__.py @@ -0,0 +1,56 @@ +"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка». + +Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат. +Срез 0 — только заглушка `NullParser`; реализация Среза 2 подменяет фабрику `get_parser`, воркер не меняется. + +`parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его +через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий. +""" + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument + +__all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser"] + +NOT_IMPLEMENTED_MESSAGE = "разбор XLS/XLSX/XLSB реализуется в Срезе 2" + + +@dataclass(frozen=True, slots=True) +class ParseOutcome: + """Итог разбора. Правила — как у файла результата контракта: `done | needs_check` требуют документов, + `error` — `error {code, message}`. + """ + + status: ResultStatus + documents: tuple[OsvDocument | StatementDocument, ...] = () + metrics: Metrics | None = None + error: MessageError | None = None + + def __post_init__(self) -> None: + if self.status is ResultStatus.ERROR: + if self.error is None: + raise ValueError("при status = error обязательно поле error") + elif not self.documents: + raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") + + +@runtime_checkable +class Parser(Protocol): + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: ... + + +class NullParser: + """Заглушка Среза 0: всегда `error NOT_IMPLEMENTED`; входные байты не читает.""" + + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + return ParseOutcome( + status=ResultStatus.ERROR, + error=MessageError(code=ErrorCode.NOT_IMPLEMENTED, message=NOT_IMPLEMENTED_MESSAGE), + ) + + +def get_parser() -> Parser: + """Фабрика парсера для воркера. Срез 2 заменяет возвращаемую реализацию.""" + return NullParser() diff --git a/api/tests/unit/test_null_parser.py b/api/tests/unit/test_null_parser.py new file mode 100644 index 0000000..22aec10 --- /dev/null +++ b/api/tests/unit/test_null_parser.py @@ -0,0 +1,49 @@ +"""Парсер-заглушка Среза 0 (design D-3): `NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.""" + +from typing import Any, get_args + +import pytest + +from src.contract import ErrorCode, FileType, MessageError, ResultStatus +from src.parser import NullParser, ParseOutcome, Parser, get_parser + + +class UntouchableBytes(bytes): + """Байты, любое обращение к содержимому которых — ошибка теста.""" + + def _fail(self, *_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("NullParser прикоснулся к входным байтам") + + __iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment] + + +@pytest.mark.parametrize("file_type", get_args(FileType)) +def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None: + data = UntouchableBytes(b"content") + + outcome = NullParser().parse(data, file_type) + + assert outcome.status is ResultStatus.ERROR + assert outcome.error == MessageError( + code=ErrorCode.NOT_IMPLEMENTED, + message="разбор XLS/XLSX/XLSB реализуется в Срезе 2", + ) + assert outcome.documents == () + assert outcome.metrics is None + + +def test_get_parser_returns_null_parser() -> None: + parser = get_parser() + + assert isinstance(parser, NullParser) + assert isinstance(parser, Parser) + + +def test_outcome_error_requires_error_payload() -> None: + with pytest.raises(ValueError, match="error"): + ParseOutcome(status=ResultStatus.ERROR) + + +def test_outcome_success_requires_documents() -> None: + with pytest.raises(ValueError, match="documents"): + ParseOutcome(status=ResultStatus.DONE) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 1f20205..d92efee 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -43,7 +43,7 @@ ### Task 7: [4.1] `src/parser/__init__.py` -- [ ] `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты +- [x] `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты ### Task 8: [4.2] `src/core/clients.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 892335c..10d821a 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -32,7 +32,7 @@ ## 4. Воркер и парсер-заглушка -- [ ] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; +- [x] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты - [ ] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`,