feat: интерфейс парсера (Parser, ParseOutcome), NullParser с NOT_IMPLEMENTED, get_parser
Заглушка Среза 0 не читает входные байты; отметки Task 7 / 4.1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b7af0d1824
commit
84b5404215
56
api/src/parser/__init__.py
Normal file
56
api/src/parser/__init__.py
Normal file
@ -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()
|
||||
49
api/tests/unit/test_null_parser.py
Normal file
49
api/tests/unit/test_null_parser.py
Normal file
@ -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)
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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)`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user