feat(hotfolder): имя <job_id>.<ext>, запись без замены, cleanup под SCAN_CLEANUP (D-1, D-2)

- inbox_name: возвращает <job_id>.<ext> (суффикс в нижнем регистре, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 01:01:09 +04:00
parent ebbeff58bd
commit f7ce9f4cdc
6 changed files with 165 additions and 191 deletions

View File

@ -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}")

View File

@ -1,15 +1,20 @@
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, R-4, R-5).
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
Файл кладётся во входящий каталог как `<job_id>_<original_name>`: сначала временный `.<имя>.part` в служебном каталоге
`Tmp` (на той же ФС/шаре), затем атомарный rename во входящий (OQ-79). Ответ ИС — набор файлов каталога
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1): сначала временный `.<имя>.part` в служебном каталоге
`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.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)
# Предел длины временного имени `.<job_id>_<имя>.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:
"""Имя входящего файла `<job_id>_<original_name>`.
`original_name` приходит из недоверенного запроса («как загрузил пользователь», контракт требует только
непустую строку): берётся последний компонент пути (без разделителей и `..`), символы, недопустимые в имени файла
Windows/SMB, и управляющие символы заменяются на `_`, завершающие точки и пробелы (их отбрасывает Windows)
убираются. Пробелы внутри имени и кириллица сохраняются. Результат всегда проходит `validate_name`, если `job_id`
допустим. `ValueError` — только если имени не осталось (пусто, `.`, `..`, одни точки/пробелы).
Длинное имя укорачивается (T7.3): основа обрезается по символам с конца, пока `.<результат>.part` не уложится
в `MAX_PART_NAME_BYTES` байт UTF-8; расширение (суффикс после последней точки до `_MAX_EXTENSION_CHARS` символов)
сохраняется. Короткое имя не меняется.
"""
"""Имя входящего файла `<job_id>.<ext>` (раскладка 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:
"""Основа имени, при которой `.<job_id>_<основа>.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:
"""Каталог ответа ИС: `<output_dir>/<job_id>`."""
@ -229,24 +202,50 @@ class LocalHotFolder:
return self.answer_dir(job_id) / f"{job_id}.json"
def put(self, name: str, data: bytes) -> None:
"""Атомарная запись: `.<name>.part` в служебном каталоге `tmp_dir`, затем `os.replace` в `input_dir/<name>`
(существующий файл заменяется; оба каталога на одной ФС — rename атомарен).
Корень не создаётся (нет корня — `FileNotFoundError`), служебный и входящий каталоги создаются без родителей.
При сбое временный файл удаляется (ошибка удаления не маскирует исходную).
"""
"""Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и
удаление `.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-файлов, набор ещё

View File

@ -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-путь каталога ответа ИС: `<output_dir>\<job_id>`."""
@ -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:

View File

@ -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 — импорт смб-модуля только в этом тесте

View File

@ -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<c>*.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: длинное имя укорачивается так, чтобы `.<job_id>_<имя>.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")

View File

@ -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()