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:
parent
ebbeff58bd
commit
f7ce9f4cdc
@ -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}")
|
||||
|
||||
@ -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-файлов, набор ещё
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 — импорт смб-модуля только в этом тесте
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user