Срез 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:
Andrey Epifancev 2026-10-04 02:48:42 +04:00
parent 434ae51434
commit 6d1f42fe1b
5 changed files with 312 additions and 2 deletions

View File

View 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

View 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

View File

@ -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`

View File

@ -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` модуль не импортируется