Срез 0 [4.1]: протокол HotFolder и LocalHotFolder (src/gateway/hotfolder.py), unit-тесты на tmp_path, отметка задачи
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
434ae51434
commit
6d1f42fe1b
0
api/src/gateway/__init__.py
Normal file
0
api/src/gateway/__init__.py
Normal file
101
api/src/gateway/hotfolder.py
Normal file
101
api/src/gateway/hotfolder.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, R-4, R-5).
|
||||
|
||||
Файл кладётся во входящий каталог как `<job_id>_<original_name>`, ответ ИС забирается из исходящего каталога по пути
|
||||
`<output>/<job_id>/<job_id>.json`. Имена каталогов (`Input`/`Output`) настраиваются, а не зашиты в код.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
_PATH_SEPARATORS = ("/", "\\")
|
||||
|
||||
|
||||
def _split_basename(name: str) -> str:
|
||||
"""Последний компонент пути с любыми разделителями (и POSIX, и Windows); пустая строка, если его нет."""
|
||||
return name.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def inbox_name(job_id: str, original_name: str) -> str:
|
||||
"""Имя входящего файла `<job_id>_<original_name>`.
|
||||
|
||||
`original_name` приходит из недоверенного запроса: берётся только последний компонент пути (без разделителей
|
||||
и `..`). Если после этого имени не осталось (пусто, `.`, `..`, NUL-байт) — `ValueError`.
|
||||
"""
|
||||
base = _split_basename(original_name)
|
||||
if base in ("", ".", "..") or "\x00" in base:
|
||||
raise ValueError(f"недопустимое имя файла: {original_name!r}")
|
||||
return f"{job_id}_{base}"
|
||||
|
||||
|
||||
class HotFolder(Protocol):
|
||||
"""Обмен файлами с ИС Сканирование через пару каталогов.
|
||||
|
||||
Методы синхронные: файловые операции и SMB блокирующие, воркер (asyncio) вызывает их через `asyncio.to_thread`.
|
||||
"""
|
||||
|
||||
def put(self, name: str, data: bytes) -> None:
|
||||
"""Атомарно положить файл `name` (простое имя, без пути) во входящий каталог. Имя собирает вызывающий
|
||||
(`inbox_name`). Если горячая папка недоступна — исключение (воркер превращает его в `RECOGNITION_FAILED`)."""
|
||||
...
|
||||
|
||||
def poll(self, job_id: str) -> bytes | None:
|
||||
"""Байты ответа ИС `<output>/<job_id>/<job_id>.json` или `None`, пока ответа нет."""
|
||||
...
|
||||
|
||||
def healthy(self) -> bool:
|
||||
"""Горячая папка доступна. Не бросает исключений."""
|
||||
...
|
||||
|
||||
|
||||
class LocalHotFolder:
|
||||
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
|
||||
|
||||
`root`, `input_dir`, `output_dir` и `answer_path` открыты намеренно (R-4): эмулятор ИС Сканирование работает с
|
||||
ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа).
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path | str, input_dir: str = "Input", output_dir: str = "Output") -> None:
|
||||
self.root: Path = Path(root)
|
||||
self.input_dir: Path = self.root / input_dir
|
||||
self.output_dir: Path = self.root / output_dir
|
||||
|
||||
def answer_path(self, job_id: str) -> Path:
|
||||
"""Путь ответа ИС: `<output_dir>/<job_id>/<job_id>.json`."""
|
||||
if job_id in ("", ".", "..") or any(sep in job_id for sep in _PATH_SEPARATORS) or "\x00" in job_id:
|
||||
raise ValueError(f"недопустимый job_id: {job_id!r}")
|
||||
return self.output_dir / job_id / f"{job_id}.json"
|
||||
|
||||
def put(self, name: str, data: bytes) -> None:
|
||||
"""Атомарная запись: `.<name>.part` в том же каталоге, затем `os.replace` (существующий файл заменяется).
|
||||
|
||||
Корень не создаётся (нет корня — `FileNotFoundError`), входящий каталог создаётся без родителей. При сбое
|
||||
временный файл удаляется.
|
||||
"""
|
||||
if name == "" or name.startswith(".") or "\x00" in name or any(sep in name for sep in _PATH_SEPARATORS):
|
||||
raise ValueError(f"недопустимое имя файла: {name!r}")
|
||||
self.input_dir.mkdir(exist_ok=True)
|
||||
part = self.input_dir / f".{name}.part"
|
||||
try:
|
||||
part.write_bytes(data)
|
||||
os.replace(part, self.input_dir / name)
|
||||
except BaseException:
|
||||
part.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def poll(self, job_id: str) -> bytes | None:
|
||||
"""Содержимое ответа или `None`, пока файла нет (в том числе если он исчез между проверкой и чтением)."""
|
||||
path = self.answer_path(job_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def healthy(self) -> bool:
|
||||
"""Корень существует и является каталогом."""
|
||||
try:
|
||||
return self.root.is_dir()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
209
api/tests/unit/test_local_hotfolder.py
Normal file
209
api/tests/unit/test_local_hotfolder.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
|
||||
|
||||
Быстрые тесты на `tmp_path`: атомарная запись входящего файла, опрос ответа ИС, проверка здоровья, валидация имён.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.gateway.hotfolder import HotFolder, LocalHotFolder, inbox_name
|
||||
|
||||
|
||||
def _job_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _write_answer(hf: LocalHotFolder, job_id: str, data: bytes) -> Path:
|
||||
path = hf.answer_path(job_id)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
class TestInboxName:
|
||||
def test_joins_job_id_and_name(self) -> None:
|
||||
job_id = _job_id()
|
||||
assert inbox_name(job_id, "aep_5.PDF") == f"{job_id}_aep_5.PDF"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("original", "expected"),
|
||||
[
|
||||
("../../etc/passwd", "passwd"),
|
||||
("dir/sub/report.pdf", "report.pdf"),
|
||||
("C:\\scans\\report.pdf", "report.pdf"),
|
||||
("/abs/report.pdf", "report.pdf"),
|
||||
("..\\..\\report.pdf", "report.pdf"),
|
||||
],
|
||||
)
|
||||
def test_sanitizes_to_basename(self, original: str, expected: str) -> None:
|
||||
assert inbox_name("job", original) == f"job_{expected}"
|
||||
|
||||
@pytest.mark.parametrize("original", ["", "/", "..", ".", "a/..", "dir/", "\\", "bad\x00name.pdf"])
|
||||
def test_rejects_unusable_names(self, original: str) -> None:
|
||||
with pytest.raises(ValueError, match="имя файла"):
|
||||
inbox_name("job", original)
|
||||
|
||||
def test_result_is_accepted_by_put(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
name = inbox_name(_job_id(), "../../scan.pdf")
|
||||
hf.put(name, b"x")
|
||||
assert (hf.input_dir / name).read_bytes() == b"x"
|
||||
|
||||
|
||||
class TestPaths:
|
||||
def test_default_layout(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
job_id = _job_id()
|
||||
assert hf.root == tmp_path
|
||||
assert hf.input_dir == tmp_path / "Input"
|
||||
assert hf.output_dir == tmp_path / "Output"
|
||||
assert hf.answer_path(job_id) == tmp_path / "Output" / job_id / f"{job_id}.json"
|
||||
|
||||
def test_custom_dir_names_and_str_root(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(str(tmp_path), input_dir="Входящие", output_dir="Исходящие")
|
||||
assert hf.root == tmp_path
|
||||
assert hf.input_dir == tmp_path / "Входящие"
|
||||
assert hf.output_dir == tmp_path / "Исходящие"
|
||||
assert hf.answer_path("abc") == tmp_path / "Исходящие" / "abc" / "abc.json"
|
||||
|
||||
@pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b"])
|
||||
def test_answer_path_rejects_unsafe_job_id(self, tmp_path: Path, job_id: str) -> None:
|
||||
with pytest.raises(ValueError, match="job_id"):
|
||||
LocalHotFolder(tmp_path).answer_path(job_id)
|
||||
|
||||
def test_implements_protocol(self, tmp_path: Path) -> None:
|
||||
hf: HotFolder = LocalHotFolder(tmp_path)
|
||||
assert hf.healthy() is True
|
||||
|
||||
|
||||
class TestPut:
|
||||
def test_writes_bytes_under_inbox_name(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
name = inbox_name(_job_id(), "aep_5.pdf")
|
||||
data = bytes(range(256)) * 10
|
||||
hf.put(name, data)
|
||||
assert (tmp_path / "Input" / name).read_bytes() == data
|
||||
|
||||
def test_leaves_no_part_files(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
name = inbox_name(_job_id(), "aep_5.pdf")
|
||||
hf.put(name, b"data")
|
||||
assert [p.name for p in hf.input_dir.iterdir()] == [name]
|
||||
|
||||
def test_creates_input_dir_only(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
assert not hf.input_dir.exists()
|
||||
hf.put("a.pdf", b"1")
|
||||
assert hf.input_dir.is_dir()
|
||||
assert not hf.output_dir.exists()
|
||||
|
||||
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "Input").mkdir()
|
||||
LocalHotFolder(tmp_path).put("a.pdf", b"1")
|
||||
assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1"
|
||||
|
||||
def test_missing_root_raises_and_creates_nothing(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "absent"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
LocalHotFolder(root).put("a.pdf", b"1")
|
||||
assert not root.exists()
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
hf.put("a.pdf", b"old")
|
||||
hf.put("a.pdf", b"new")
|
||||
assert (hf.input_dir / "a.pdf").read_bytes() == b"new"
|
||||
assert [p.name for p in hf.input_dir.iterdir()] == ["a.pdf"]
|
||||
|
||||
def test_empty_data_is_written(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
hf.put("empty.pdf", b"")
|
||||
assert (hf.input_dir / "empty.pdf").read_bytes() == b""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["", "../escape.pdf", "sub/a.pdf", "sub\\a.pdf", "/abs.pdf", ".hidden.pdf", ".a.pdf.part", "..", ".", "a\x00b"],
|
||||
)
|
||||
def test_rejects_invalid_names(self, tmp_path: Path, name: str) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
with pytest.raises(ValueError, match="имя файла"):
|
||||
hf.put(name, b"x")
|
||||
assert not hf.input_dir.exists()
|
||||
assert not (tmp_path.parent / "escape.pdf").exists()
|
||||
|
||||
def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
|
||||
def boom(src: object, dst: object) -> None:
|
||||
raise OSError("диск недоступен")
|
||||
|
||||
monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom)
|
||||
with pytest.raises(OSError, match="диск недоступен"):
|
||||
hf.put("a.pdf", b"data")
|
||||
assert list(hf.input_dir.iterdir()) == []
|
||||
|
||||
|
||||
class TestPoll:
|
||||
def test_none_until_answer_exists(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
job_id = _job_id()
|
||||
assert hf.poll(job_id) is None
|
||||
(tmp_path / "Output").mkdir()
|
||||
assert hf.poll(job_id) is None
|
||||
(tmp_path / "Output" / job_id).mkdir()
|
||||
assert hf.poll(job_id) is None
|
||||
|
||||
def test_returns_bytes_when_answer_appears(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
job_id = _job_id()
|
||||
payload = b'{"file_uid": "x", "data_ocr": []}'
|
||||
assert hf.poll(job_id) is None
|
||||
_write_answer(hf, job_id, payload)
|
||||
assert hf.poll(job_id) == payload
|
||||
|
||||
def test_other_job_is_unaffected(self, tmp_path: Path) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
job_id, other = _job_id(), _job_id()
|
||||
_write_answer(hf, other, b"{}")
|
||||
assert hf.poll(job_id) is None
|
||||
assert hf.poll(other) == b"{}"
|
||||
|
||||
def test_missing_root_is_none(self, tmp_path: Path) -> None:
|
||||
assert LocalHotFolder(tmp_path / "absent").poll(_job_id()) is None
|
||||
|
||||
def test_file_removed_between_exists_and_read_is_none(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
hf = LocalHotFolder(tmp_path)
|
||||
job_id = _job_id()
|
||||
path = _write_answer(hf, job_id, b"{}")
|
||||
|
||||
def vanished(self: Path) -> bytes:
|
||||
path.unlink()
|
||||
raise FileNotFoundError(str(self))
|
||||
|
||||
monkeypatch.setattr(Path, "read_bytes", vanished)
|
||||
assert hf.poll(job_id) is None
|
||||
|
||||
|
||||
class TestHealthy:
|
||||
def test_true_for_existing_dir(self, tmp_path: Path) -> None:
|
||||
assert LocalHotFolder(tmp_path).healthy() is True
|
||||
|
||||
def test_false_for_missing_root(self, tmp_path: Path) -> None:
|
||||
assert LocalHotFolder(tmp_path / "absent").healthy() is False
|
||||
|
||||
def test_false_for_file_root(self, tmp_path: Path) -> None:
|
||||
file_root = tmp_path / "file"
|
||||
file_root.write_bytes(b"x")
|
||||
assert LocalHotFolder(file_root).healthy() is False
|
||||
|
||||
def test_never_raises(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def boom(self: Path) -> bool:
|
||||
raise PermissionError("нет доступа")
|
||||
|
||||
monkeypatch.setattr(Path, "is_dir", boom)
|
||||
assert LocalHotFolder(tmp_path).healthy() is False
|
||||
@ -44,7 +44,7 @@
|
||||
|
||||
### Task 7: [4.1] `src/gateway/hotfolder.py`
|
||||
|
||||
- [ ] `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет `<uuid>_<name>`, poll возвращает `None` до появления ответа и байты после
|
||||
- [x] `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет `<uuid>_<name>`, poll возвращает `None` до появления ответа и байты после
|
||||
|
||||
### Task 8: [4.2] `src/gateway/smb.py`
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
|
||||
## 4. Горячие папки и эмулятор
|
||||
|
||||
- [ ] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py`
|
||||
- [x] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py`
|
||||
на `tmp_path`; проверка: put пишет `<uuid>_<name>`, poll возвращает `None` до появления ответа и байты после
|
||||
- [ ] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с
|
||||
подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user