From 6d1f42fe1b83c353bfd8537e77ad77af493b8eef Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:48:42 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[4.1]:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20HotFolder=20=D0=B8?= =?UTF-8?q?=20LocalHotFolder=20(src/gateway/hotfolder.py),=20unit-=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B0=20tmp=5Fpath,=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/gateway/__init__.py | 0 api/src/gateway/hotfolder.py | 101 +++++++++ api/tests/unit/test_local_hotfolder.py | 209 ++++++++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 5 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 api/src/gateway/__init__.py create mode 100644 api/src/gateway/hotfolder.py create mode 100644 api/tests/unit/test_local_hotfolder.py diff --git a/api/src/gateway/__init__.py b/api/src/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py new file mode 100644 index 0000000..685c173 --- /dev/null +++ b/api/src/gateway/hotfolder.py @@ -0,0 +1,101 @@ +"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, R-4, R-5). + +Файл кладётся во входящий каталог как `_`, ответ ИС забирается из исходящего каталога по пути +`//.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: + """Имя входящего файла `_`. + + `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: + """Байты ответа ИС `//.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: + """Путь ответа ИС: `//.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: + """Атомарная запись: `..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 diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py new file mode 100644 index 0000000..ed3e258 --- /dev/null +++ b/api/tests/unit/test_local_hotfolder.py @@ -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 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 06756e7..cb4fb80 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -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 пишет `_`, poll возвращает `None` до появления ответа и байты после +- [x] `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет `_`, poll возвращает `None` до появления ответа и байты после ### Task 8: [4.2] `src/gateway/smb.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 51a456a..861c3be 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -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 пишет `_`, poll возвращает `None` до появления ответа и байты после - [ ] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется