From f7ce9f4cdc21d9ef34eb9dd81d9054a96352d299 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 01:01:09 +0400 Subject: [PATCH] =?UTF-8?q?feat(hotfolder):=20=D0=B8=D0=BC=D1=8F=20.,=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=8B,=20cleanup?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20SCAN=5FCLEANUP=20(D-1,=20D-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inbox_name: возвращает . (суффикс в нижнем регистре, 1–16 символов [0-9a-z]) - put: атомарная запись через os.link (без замены), запасной путь os.replace при EPERM/EXDEV/ENOTSUP - cleanup: удаляет ответ, входящий файл, временные файлы при флаге cleanup_enabled - LocalHotFolder: параметр cleanup: bool = False, атрибут cleanup_enabled - SmbHotFolder: параметр cleanup: bool = False (заглушка, логика в Task 12) - create_hotfolder: передаёт cleanup=settings.SCAN_CLEANUP обоим конструкторам - SpyHotFolder: метод cleanup для шпионажа за очисткой - Удалены: MAX_PART_NAME_BYTES, _fit_name, _MAX_EXTENSION_CHARS, _utf8_len (имя всегда короткое) - Тесты: TestInboxName с новым форматом, TestPutWithoutReplace (без замены), TestCleanup (включена/выключена), cleanup_enabled в фабрике Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/gateway/__init__.py | 2 + api/src/gateway/hotfolder.py | 127 +++++++------- api/src/gateway/smb.py | 6 + api/tests/unit/test_hotfolder_factory.py | 7 + api/tests/unit/test_local_hotfolder.py | 210 +++++++++-------------- api/tests/worker_helpers.py | 4 + 6 files changed, 165 insertions(+), 191 deletions(-) diff --git a/api/src/gateway/__init__.py b/api/src/gateway/__init__.py index 4902baa..f6e785b 100644 --- a/api/src/gateway/__init__.py +++ b/api/src/gateway/__init__.py @@ -20,6 +20,7 @@ def create_hotfolder(settings: Settings) -> HotFolder: settings.SCAN_OUTPUT_DIR, settings.SCAN_TMP_DIR, settle_seconds=settings.SCAN_SETTLE_SECONDS, + cleanup=settings.SCAN_CLEANUP, ) if mode == "smb": from src.gateway.smb import SmbHotFolder # noqa: PLC0415 — ленивый импорт: в режиме local smb не нужен (R-6) @@ -34,5 +35,6 @@ def create_hotfolder(settings: Settings) -> HotFolder: login=smb.SMB_LOGIN, password=smb.SMB_PASSWORD, settle_seconds=settings.SCAN_SETTLE_SECONDS, + cleanup=settings.SCAN_CLEANUP, ) raise ValueError(f"неизвестный SCAN_HOTFOLDER_MODE: {mode!r}") diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index f16e64d..498ea62 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -1,15 +1,20 @@ -"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, R-4, R-5). +"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5). -Файл кладётся во входящий каталог как `_`: сначала временный `.<имя>.part` в служебном каталоге -`Tmp` (на той же ФС/шаре), затем атомарный rename во входящий (OQ-79). Ответ ИС — набор файлов каталога +Файл кладётся во входящий каталог как `.` (D-1): сначала временный `.<имя>.part` в служебном каталоге +`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога `//`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78). `poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`/`Tmp`) настраиваются, а не зашиты в код. + +Очистка (D-2) удаляет следы задания: ответ, входящий файл, временные файлы — при `SCAN_CLEANUP=true`. """ import contextlib +import errno import os +import re +import shutil import threading import time from collections import OrderedDict @@ -22,11 +27,8 @@ _PATH_SEPARATORS = ("/", "\\") # Недопустимые в именах файлов Windows/SMB символы (кроме разделителей пути) и управляющие 0x00-0x1F (с NUL) _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x20)) _FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS) -# Предел длины временного имени `._<имя>.part` в байтах UTF-8: ext4 — 255 байт, SMB/NTFS — 255 единиц UTF-16 -# (их не больше, чем байт UTF-8); 15 байт — запас -MAX_PART_NAME_BYTES = 240 -# Суффикс после последней точки считается расширением и сохраняется при укорачивании, если он не длиннее этого -_MAX_EXTENSION_CHARS = 16 +# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z] +_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}") def validate_name(name: str) -> None: @@ -53,53 +55,16 @@ def _split_basename(name: str) -> str: def inbox_name(job_id: str, original_name: str) -> str: - """Имя входящего файла `_`. - - `original_name` приходит из недоверенного запроса («как загрузил пользователь», контракт требует только - непустую строку): берётся последний компонент пути (без разделителей и `..`), символы, недопустимые в имени файла - Windows/SMB, и управляющие символы заменяются на `_`, завершающие точки и пробелы (их отбрасывает Windows) - убираются. Пробелы внутри имени и кириллица сохраняются. Результат всегда проходит `validate_name`, если `job_id` - допустим. `ValueError` — только если имени не осталось (пусто, `.`, `..`, одни точки/пробелы). - - Длинное имя укорачивается (T7.3): основа обрезается по символам с конца, пока `.<результат>.part` не уложится - в `MAX_PART_NAME_BYTES` байт UTF-8; расширение (суффикс после последней точки до `_MAX_EXTENSION_CHARS` символов) - сохраняется. Короткое имя не меняется. - """ + """Имя входящего файла `.` (раскладка FileWorker, D-1): расширение — суффикс после последней точки + последнего компонента `original_name`, в нижнем регистре, 1–16 букв/цифр ASCII; ИС различает форматы по нему. + Нет расширения или оно недопустимо — `ValueError`.""" + validate_job_id(job_id) base = _split_basename(original_name) - if base in (".", ".."): - raise ValueError(f"недопустимое имя файла: {original_name!r}") - base = "".join("_" if char in _ILLEGAL_CHARS else char for char in base).rstrip(". ") - if not base: - raise ValueError(f"недопустимое имя файла: {original_name!r}") - return f"{job_id}_{_fit_name(job_id, base)}" - - -def _utf8_len(text: str) -> int: - # surrogatepass: одиночный суррогат (схема запроса их не пропускает) не роняет подсчёт длины - return len(text.encode("utf-8", "surrogatepass")) - - -def _fit_name(job_id: str, base: str) -> str: - """Основа имени, при которой `._<основа>.part` занимает не больше `MAX_PART_NAME_BYTES` байт UTF-8.""" - budget = MAX_PART_NAME_BYTES - _utf8_len(f".{job_id}_.part") - if _utf8_len(base) <= budget: - return base dot = base.rfind(".") - extension = base[dot:] if 0 < dot and len(base) - dot - 1 <= _MAX_EXTENSION_CHARS else "" - stem = base[: len(base) - len(extension)] - limit = budget - _utf8_len(extension) - used = 0 - kept = 0 - for char in stem: # по code points: многобайтный символ не разрезается - used += _utf8_len(char) - if used > limit: - break - kept += 1 - stem = stem[:kept] - if not extension: - # без расширения имя заканчивается основой: завершающие точки и пробелы отбрасывает Windows - stem = stem.rstrip(". ") - return (stem or "_") + extension + extension = base[dot + 1 :].lower() if dot >= 0 else "" + if not extension or _EXTENSION_RE.fullmatch(extension) is None: + raise ValueError(f"у файла {original_name!r} нет допустимого расширения") + return f"{job_id}.{extension}" @dataclass(frozen=True) @@ -180,8 +145,8 @@ class HotFolder(Protocol): def put(self, name: str, data: bytes) -> None: """Атомарно положить файл `name` (простое имя, без пути) во входящий каталог: временный файл — в служебном - каталоге `Tmp`, затем rename. Имя собирает вызывающий (`inbox_name`). Если горячая папка недоступна — - исключение (воркер превращает его в `RECOGNITION_FAILED`).""" + каталоге `Tmp`, затем жёсткая ссылка (без замены). Имя собирает вызывающий (`inbox_name`). Если горячая папка + недоступна — исключение (воркер превращает его в `RECOGNITION_FAILED`).""" ... def poll(self, job_id: str) -> list[AnswerFile] | None: @@ -190,6 +155,11 @@ class HotFolder(Protocol): реализация помнит снимок набора между вызовами.""" ... + def cleanup(self, job_id: str, name: str) -> bool: + """Удалить следы задания (ответ, входящий файл, временные файлы), если очистка включена (`SCAN_CLEANUP`); + иначе ничего не трогать и вернуть `False`. Ошибки удаления поднимаются.""" + ... + def healthy(self) -> bool: """Горячая папка доступна. Не бросает исключений.""" ... @@ -201,6 +171,7 @@ class LocalHotFolder: `root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа). `settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`). + `cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`. """ def __init__( # noqa: PLR0913 — имена трёх каталогов + параметры готовности набора @@ -212,12 +183,14 @@ class LocalHotFolder: *, settle_seconds: float = 2, clock: MonotonicFn = time.monotonic, + cleanup: bool = False, ) -> None: self.root: Path = Path(root) self.input_dir: Path = self.root / input_dir self.output_dir: Path = self.root / output_dir self.tmp_dir: Path = self.root / tmp_dir self._settle = SettleTracker(settle_seconds, clock) + self.cleanup_enabled = cleanup def answer_dir(self, job_id: str) -> Path: """Каталог ответа ИС: `/`.""" @@ -229,24 +202,50 @@ class LocalHotFolder: return self.answer_dir(job_id) / f"{job_id}.json" def put(self, name: str, data: bytes) -> None: - """Атомарная запись: `..part` в служебном каталоге `tmp_dir`, затем `os.replace` в `input_dir/` - (существующий файл заменяется; оба каталога на одной ФС — rename атомарен). - - Корень не создаётся (нет корня — `FileNotFoundError`), служебный и входящий каталоги создаются без родителей. - При сбое временный файл удаляется (ошибка удаления не маскирует исходную). - """ + """Атомарная запись без замены: `..part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/` и + удаление `.part`. Назначение уже есть — файл считается доставленным (повтор задания), `.part` остаётся, если + очистка выключена. Корень не создаётся (нет корня — `FileNotFoundError`).""" validate_name(name) self.tmp_dir.mkdir(exist_ok=True) self.input_dir.mkdir(exist_ok=True) part = self.tmp_dir / f".{name}.part" + target = self.input_dir / name try: part.write_bytes(data) - os.replace(part, self.input_dir / name) + os.link(part, target) # без замены: существующее назначение даёт FileExistsError + except FileExistsError: + self._drop_part(part) + return + except OSError as err: + # ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): EPERM/EXDEV/ENOTSUP — запасной путь + # через replace с проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок) + if err.errno in (errno.EPERM, errno.EXDEV, errno.ENOTSUP) and not target.exists(): + os.replace(part, target) + return + self._drop_part(part) + raise except BaseException: - # ошибка удаления временного файла не должна маскировать исходную (T7.5) + self._drop_part(part) + raise + part.unlink(missing_ok=True) # завершение успешной записи, а не «очистка следов»: флаг не действует + + def _drop_part(self, part: Path) -> None: + if self.cleanup_enabled: with contextlib.suppress(OSError): part.unlink(missing_ok=True) - raise + + def cleanup(self, job_id: str, name: str) -> bool: + if not self.cleanup_enabled: + return False + folder = self.answer_dir(job_id) + if folder.is_dir(): + shutil.rmtree(folder) + for path in (self.input_dir / name, self.input_dir / f"{name}.done", self.tmp_dir / f".{name}.part"): + try: + os.unlink(path) + except FileNotFoundError: + continue + return True def poll(self, job_id: str) -> list[AnswerFile] | None: """Готовый набор `*.json` каталога ответа или `None` (нет корня/каталога/json-файлов, набор ещё diff --git a/api/src/gateway/smb.py b/api/src/gateway/smb.py index 1db65c9..e5a9dc0 100644 --- a/api/src/gateway/smb.py +++ b/api/src/gateway/smb.py @@ -80,6 +80,7 @@ class SmbHotFolder: password: str = "", settle_seconds: float = 2, clock: MonotonicFn = time.monotonic, + cleanup: bool = False, ) -> None: self.root: str = build_unc_root(host, path) self.input_dir: str = ntpath.join(self.root, input_dir) @@ -87,6 +88,7 @@ class SmbHotFolder: self.tmp_dir: str = ntpath.join(self.root, tmp_dir) self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {} self._settle = SettleTracker(settle_seconds, clock) + self.cleanup_enabled = cleanup def answer_dir(self, job_id: str) -> str: r"""UNC-путь каталога ответа ИС: `\`.""" @@ -154,6 +156,10 @@ class SmbHotFolder: """Число заданий с ненастоявшимся набором (для тестов и диагностики).""" return len(self._settle) + def cleanup(self, job_id: str, name: str) -> bool: + """Очистка SMB — заглушка на Task 11 (логика в Task 12).""" + return False + def healthy(self) -> bool: """Корень доступен и является каталогом (`smbclient.listdir` проходит). Не бросает исключений.""" try: diff --git a/api/tests/unit/test_hotfolder_factory.py b/api/tests/unit/test_hotfolder_factory.py index d88c7f5..44262d2 100644 --- a/api/tests/unit/test_hotfolder_factory.py +++ b/api/tests/unit/test_hotfolder_factory.py @@ -51,6 +51,13 @@ def test_local_mode_builds_local_hotfolder(tmp_path: Path) -> None: assert hf.healthy() is True +def test_cleanup_flag_from_settings(tmp_path: Path) -> None: + settings_without = _local_settings(tmp_path) + settings_with = _local_settings(tmp_path).model_copy(update={"SCAN_CLEANUP": True}) + assert create_hotfolder(settings_without).cleanup_enabled is False + assert create_hotfolder(settings_with).cleanup_enabled is True + + def test_smb_mode_builds_smb_hotfolder() -> None: from src.gateway.smb import SmbHotFolder # noqa: PLC0415 — импорт смб-модуля только в этом тесте diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index 9c319c3..35673c6 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -11,7 +11,7 @@ from typing import Any import pytest -from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name, validate_name +from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name def _job_id() -> str: @@ -41,129 +41,86 @@ def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClo class TestInboxName: - def test_joins_job_id_and_name(self) -> None: + def test_job_id_and_lowercase_extension(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/", "\\", "...", ". .", " ", "dir/. "]) - def test_rejects_unusable_names(self, original: str) -> None: - with pytest.raises(ValueError, match="имя файла"): - inbox_name("job", original) - - @pytest.mark.parametrize( - ("original", "expected"), - [ - ("a:b?.pdf", "a_b_.pdf"), - ("Отчёт: 2024.pdf", "Отчёт_ 2024.pdf"), - ('Баланс "итог".pdf', "Баланс _итог_.pdf"), - ("форма?.pdf", "форма_.pdf"), - ("a|b*.pdf", "a_b_c__.pdf"), - ("C:report.pdf", "C_report.pdf"), - ("a\x01b.pdf", "a_b.pdf"), - ("bad\x00name.pdf", "bad_name.pdf"), - ("tab\tname.pdf", "tab_name.pdf"), - ], - ) - def test_replaces_chars_illegal_on_windows(self, original: str, expected: str) -> None: - """Имя от пользователя законно на Linux/S3/macOS: заменяем, а не отвергаем (контракт: только minLength 1).""" - assert inbox_name("job", original) == f"job_{expected}" - - @pytest.mark.parametrize( - ("original", "expected"), - [("report.pdf.", "report.pdf"), ("report.pdf . ", "report.pdf"), ("report .pdf", "report .pdf")], - ) - def test_strips_trailing_dots_and_spaces(self, original: str, expected: str) -> None: - assert inbox_name("job", original) == f"job_{expected}" - - def test_keeps_spaces_and_cyrillic(self) -> None: - job_id = _job_id() - assert inbox_name(job_id, "Отчёт за 2024 год (копия).PDF") == f"{job_id}_Отчёт за 2024 год (копия).PDF" + assert inbox_name(job_id, "aep_5.PDF") == f"{job_id}.pdf" + assert inbox_name(job_id, "dir/sub/Отчёт за 2024.Jpeg") == f"{job_id}.jpeg" + assert inbox_name(job_id, "C:\\scans\\report.pdf") == f"{job_id}.pdf" @pytest.mark.parametrize( "original", - [ - "a:b?.pdf", - 'Баланс "итог".pdf', - "Отчёт за 2024 год (копия).PDF", - ".hidden.pdf", - "..pdf", - "x\x00\x1f<>|*?.pdf. ", - "C:\\dir\\..\\скан.png", - " leading.pdf", - ], + ["scan", "", ".", "..", "report.", "a/..", "x.a:b", "x." + "p" * 17, "name.тар"], ) - def test_result_always_passes_validate_name_and_put(self, tmp_path: Path, original: str) -> None: + def test_rejects_names_without_safe_extension(self, original: str) -> None: + with pytest.raises(ValueError, match="расширени"): + inbox_name("job", original) + + +class TestPutWithoutReplace: + def test_existing_destination_is_kept(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) - name = inbox_name(_job_id(), original) - validate_name(name) - hf.put(name, b"x") - assert (hf.input_dir / name).read_bytes() == b"x" + hf.put("job.pdf", b"first") + hf.put("job.pdf", b"second") + assert (hf.input_dir / "job.pdf").read_bytes() == b"first" + assert (hf.tmp_dir / ".job.pdf.part").read_bytes() == b"second" # .part остаётся: удаление только под флагом - def test_result_is_accepted_by_put(self, tmp_path: Path) -> None: + def test_existing_destination_part_removed_when_cleanup_enabled(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, cleanup=True) + hf.put("job.pdf", b"first") + hf.put("job.pdf", b"second") + assert (hf.input_dir / "job.pdf").read_bytes() == b"first" + assert not (hf.tmp_dir / ".job.pdf.part").exists() + + def test_no_temp_files_in_input(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" + hf.put("job.pdf", b"x") + assert [p.name for p in hf.input_dir.iterdir()] == ["job.pdf"] + assert not (hf.tmp_dir / ".job.pdf.part").exists() # после успешной публикации .part нет -class TestInboxNameLength: - """T7.3: длинное имя укорачивается так, чтобы `._<имя>.part` занимал не больше 240 байт UTF-8 (ext4 — - 255 байт, SMB/NTFS — 255 единиц UTF-16), расширение (до 16 символов после последней точки) сохраняется.""" +class TestCleanup: + def _fill(self, hf: LocalHotFolder, job_id: str) -> None: + hf.put(f"{job_id}.pdf", b"x") + (hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"") + (hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"orphan") + _write_answer(hf, job_id, b"{}") + _write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json") + (hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep") - @staticmethod - def _part_bytes(name: str) -> int: - return len(f".{name}.part".encode()) - - def test_long_cyrillic_name_is_trimmed_and_put(self, tmp_path: Path) -> None: + def test_disabled_touches_nothing(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) job_id = _job_id() - name = inbox_name(job_id, "я" * 200 + ".pdf") - assert self._part_bytes(name) <= 240 - assert name.startswith(f"{job_id}_яя") - assert name.endswith("я.pdf") - hf.put(name, b"x") - assert (hf.input_dir / name).read_bytes() == b"x" + self._fill(hf, job_id) + assert hf.cleanup(job_id, f"{job_id}.pdf") is False + assert (hf.input_dir / f"{job_id}.pdf").exists() and hf.answer_dir(job_id).is_dir() + assert (hf.tmp_dir / f".{job_id}.pdf.part").exists() - def test_short_name_is_unchanged(self) -> None: + def test_enabled_removes_job_files(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, cleanup=True) job_id = _job_id() - original = "Отчёт за 2024 год (копия).PDF" - assert inbox_name(job_id, original) == f"{job_id}_{original}" + self._fill(hf, job_id) + assert hf.cleanup(job_id, f"{job_id}.pdf") is True + assert not (hf.input_dir / f"{job_id}.pdf").exists() + assert not (hf.input_dir / f"{job_id}.pdf.done").exists() + assert not (hf.tmp_dir / f".{job_id}.pdf.part").exists() + assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt + assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка - def test_name_at_limit_is_unchanged(self) -> None: + def test_missing_files_are_fine(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, cleanup=True) + assert hf.cleanup(_job_id(), "nothing.pdf") is True + + def test_permission_error_propagates(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + hf = LocalHotFolder(tmp_path, cleanup=True) job_id = _job_id() - original = "a" * (240 - len(f".{job_id}_.part") - 4) + ".pdf" - name = inbox_name(job_id, original) - assert name == f"{job_id}_{original}" - assert self._part_bytes(name) == 240 + self._fill(hf, job_id) - def test_long_name_without_extension(self, tmp_path: Path) -> None: - name = inbox_name(_job_id(), "б" * 300) - assert self._part_bytes(name) <= 240 - assert name.endswith("б") - LocalHotFolder(tmp_path).put(name, b"x") + def deny(path: Any, *args: Any, **kwargs: Any) -> None: + raise PermissionError(f"нет прав: {path}") - def test_long_suffix_is_not_treated_as_extension(self) -> None: - name = inbox_name(_job_id(), "ж" * 150 + "." + "x" * 17) - assert self._part_bytes(name) <= 240 - assert not name.endswith("x" * 17) - - def test_four_byte_chars_are_not_split(self) -> None: - name = inbox_name(_job_id(), "\U0001f600" * 100 + ".png") - assert self._part_bytes(name) <= 240 - assert name.endswith("\U0001f600.png") + monkeypatch.setattr(os, "unlink", deny) + with pytest.raises(PermissionError): + hf.cleanup(job_id, f"{job_id}.pdf") class TestPaths: @@ -202,38 +159,42 @@ class TestPaths: def test_implements_protocol(self, tmp_path: Path) -> None: hf: HotFolder = LocalHotFolder(tmp_path) assert hf.healthy() is True + assert hasattr(hf, "cleanup") 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") + job_id = _job_id() + 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") + job_id = _job_id() + name = inbox_name(job_id, "aep_5.pdf") hf.put(name, b"data") assert [p.name for p in hf.input_dir.iterdir()] == [name] assert list(hf.tmp_dir.iterdir()) == [] def test_part_is_written_in_tmp_not_in_input(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """OQ-79: во время записи во входящем каталоге нет временного файла — он в `Tmp/`, затем rename в `Input`.""" + """OQ-79: во время записи во входящем каталоге нет временного файла — он в `Tmp/`, затем link в `Input`.""" hf = LocalHotFolder(tmp_path) - name = inbox_name(_job_id(), "aep_5.pdf") + job_id = _job_id() + name = inbox_name(job_id, "aep_5.pdf") seen: dict[str, Any] = {} - real_replace = os.replace + real_link = os.link def spy(src: Any, dst: Any) -> None: seen["src"], seen["dst"] = Path(src), Path(dst) seen["input"] = sorted(p.name for p in hf.input_dir.iterdir()) seen["tmp"] = sorted(p.name for p in hf.tmp_dir.iterdir()) seen["part"] = Path(src).read_bytes() - real_replace(src, dst) + real_link(src, dst) - monkeypatch.setattr("src.gateway.hotfolder.os.replace", spy) + monkeypatch.setattr("src.gateway.hotfolder.os.link", spy) hf.put(name, b"data") assert seen == { "src": hf.tmp_dir / f".{name}.part", @@ -277,13 +238,6 @@ class TestPut: 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"") @@ -312,20 +266,22 @@ class TestPut: with pytest.raises(ValueError, match="имя файла"): LocalHotFolder(tmp_path).put(name, b"x") - def test_accepts_spaces_cyrillic_and_inbox_names(self, tmp_path: Path) -> None: + def test_accepts_inbox_names(self, tmp_path: Path) -> None: hf = LocalHotFolder(tmp_path) - names = [inbox_name(_job_id(), "Отчёт за 2024 год (копия).PDF"), inbox_name(_job_id(), "aep_8.pdf"), "a b.pdf"] + job_id_1 = _job_id() + job_id_2 = _job_id() + names = [inbox_name(job_id_1, "Отчёт за 2024 год (копия).PDF"), inbox_name(job_id_2, "aep_8.pdf")] for name in names: hf.put(name, b"x") assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names) def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - hf = LocalHotFolder(tmp_path) + hf = LocalHotFolder(tmp_path, cleanup=True) - def boom(src: object, dst: object) -> None: + def boom(src: Any, dst: Any) -> None: raise OSError("диск недоступен") - monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom) + monkeypatch.setattr("src.gateway.hotfolder.os.link", boom) with pytest.raises(OSError, match="диск недоступен"): hf.put("a.pdf", b"data") assert list(hf.input_dir.iterdir()) == [] @@ -334,16 +290,16 @@ class TestPut: def test_cleanup_failure_does_not_mask_original_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """T7.5: ошибка удаления `.part` подавляется, наружу выходит исходное исключение записи.""" + """Ошибка удаления `.part` подавляется, наружу выходит исходное исключение записи.""" hf = LocalHotFolder(tmp_path) - def boom(src: object, dst: object) -> None: + def boom(src: Any, dst: Any) -> None: raise OSError("диск недоступен") def unlink_fails(self: Path, missing_ok: bool = False) -> None: raise PermissionError("удаление .part запрещено") - monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom) + monkeypatch.setattr("src.gateway.hotfolder.os.link", boom) monkeypatch.setattr(Path, "unlink", unlink_fails) with pytest.raises(OSError, match="диск недоступен"): hf.put("a.pdf", b"data") diff --git a/api/tests/worker_helpers.py b/api/tests/worker_helpers.py index 790f4bf..63fac4f 100644 --- a/api/tests/worker_helpers.py +++ b/api/tests/worker_helpers.py @@ -178,6 +178,10 @@ class SpyHotFolder: return step return self.inner.poll(job_id) + def cleanup(self, job_id: str, name: str) -> bool: + self.events.append(("cleanup", name)) + return self.inner.cleanup(job_id, name) + def healthy(self) -> bool: return self.inner.healthy()