feat(hotfolder): SMB — rename без замены и cleanup под флагом (D-1, D-2)

This commit is contained in:
Andrey Epifancev 2026-10-07 01:21:51 +04:00
parent 5c2d89bef3
commit c00823c673
2 changed files with 142 additions and 32 deletions

View File

@ -4,9 +4,10 @@ r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOL
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается во входящий
`\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json` каталога
`\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`).
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается (rename без замены)
во входящий `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json`
каталога `\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора
(`SettleTracker`). Очистка (D-2) удаляет ответы, входящий файл и временные файлы при `SCAN_CLEANUP=true`.
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
по `errno == ENOENT`.
"""
@ -65,8 +66,9 @@ def build_unc_root(host: str, path: str) -> str:
class SmbHotFolder:
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + replace в `Input`, `Tmp` и `Input`
создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`)."""
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + rename без замены в `Input`,
`Tmp` и `Input` создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации
(`SettleTracker`). Очистка (D-2) под флагом `SCAN_CLEANUP`."""
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
self,
@ -100,9 +102,10 @@ class SmbHotFolder:
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
def put(self, name: str, data: bytes) -> None:
"""Записать `.<name>.part` в служебный каталог и переименовать в `<input>\\<name>` (`smbclient.replace`:
существующий файл заменяется). Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется
(ошибка удаления не маскирует исходную). Нет корня — `SMBOSError` (ENOENT) от первого `mkdir`.
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены:
назначение есть — файл считается доставленным, `.part` остаётся (удаляется только при включённой очистке).
Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется (ошибка удаления не маскирует
исходную). Нет корня — `SMBOSError` (ENOENT) от первого `mkdir`.
"""
validate_name(name)
self._ensure_dir(self.tmp_dir)
@ -111,10 +114,15 @@ class SmbHotFolder:
try:
with smbclient.open_file(part, mode="wb", **self._credentials) as fd:
fd.write(data)
smbclient.replace(part, ntpath.join(self.input_dir, name), **self._credentials)
smbclient.rename(part, ntpath.join(self.input_dir, name), **self._credentials)
except OSError as err:
self._drop_part(part)
ntstatus = getattr(err, "ntstatus", None)
if _has_errno(err, errno.EEXIST, FileExistsError) or ntstatus == NtStatus.STATUS_OBJECT_NAME_COLLISION:
return
raise
except BaseException:
with contextlib.suppress(Exception):
smbclient.remove(part, **self._credentials)
self._drop_part(part)
raise
def poll(self, job_id: str) -> list[AnswerFile] | None:
@ -156,9 +164,42 @@ class SmbHotFolder:
"""Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
return len(self._settle)
def _drop_part(self, path: str) -> None:
"""Удалить временный файл при ошибке, только если очистка включена (D-2)."""
if self.cleanup_enabled:
with contextlib.suppress(Exception):
smbclient.remove(path, **self._credentials)
def cleanup(self, job_id: str, name: str) -> bool:
"""Очистка SMB — заглушка на Task 11 (логика в Task 12)."""
return False
"""Удалить следы задания: ответ из `output`, входящий файл, временные файлы. Возвращает `False`, если
очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются."""
if not self.cleanup_enabled:
return False
validate_name(name)
folder = self.answer_dir(job_id)
try:
entries = list(smbclient.scandir(folder, **self._credentials))
except OSError as err:
is_enoent = _has_errno(err, errno.ENOENT, FileNotFoundError)
is_enotdir = _has_errno(err, errno.ENOTDIR, NotADirectoryError)
if not is_enoent and not is_enotdir:
raise
else:
for entry in entries:
if entry.is_file():
smbclient.remove(ntpath.join(folder, entry.name), **self._credentials)
smbclient.rmdir(folder, **self._credentials)
for path in (
ntpath.join(self.input_dir, name),
ntpath.join(self.input_dir, f"{name}.done"),
ntpath.join(self.tmp_dir, f".{name}.part"),
):
try:
smbclient.remove(path, **self._credentials)
except OSError as err:
if not _has_errno(err, errno.ENOENT, FileNotFoundError):
raise
return True
def healthy(self) -> bool:
"""Корень доступен и является каталогом (`smbclient.listdir` проходит). Не бросает исключений."""

View File

@ -80,12 +80,23 @@ class FakeSmbClient:
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, src)
os.replace(local_src, self._local(dst))
def rename(self, src: str, dst: str, **kwargs: Any) -> None:
self._record("rename", (src, dst), kwargs)
target = self._local(dst)
if target.exists():
raise FileExistsError(dst)
os.rename(self._local(src), target)
def remove(self, path: str, **kwargs: Any) -> None:
self._record("remove", (path,), kwargs)
local = self._local(path)
if not local.is_file():
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path)
local.unlink()
try:
os.unlink(self._local(path))
except FileNotFoundError:
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) from None
def rmdir(self, path: str, **kwargs: Any) -> None:
self._record("rmdir", (path,), kwargs)
os.rmdir(self._local(path))
def scandir(self, path: str, **kwargs: Any) -> list[SimpleNamespace]:
"""Как `smbclient.scandir`: элементы с `name`, `is_file()` и `smb_info.end_of_file` (размер из листинга)."""
@ -206,7 +217,7 @@ class TestPaths:
class TestPut:
def test_writes_part_in_tmp_then_replaces_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
def test_writes_part_in_tmp_then_renames_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "aep_5.PDF")
data = bytes(range(256)) * 10
_hotfolder().put(name, data)
@ -215,7 +226,7 @@ class TestPut:
("mkdir", (TMP,), {}),
("mkdir", (INPUT,), {}),
("open_file", (part, "wb"), {}),
("replace", (part, f"{INPUT}\\{name}"), {}),
("rename", (part, f"{INPUT}\\{name}"), {}),
]
assert (share_root / "Input" / name).read_bytes() == data
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
@ -225,14 +236,14 @@ class TestPut:
"""OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`."""
name = "a.pdf"
seen: dict[str, list[str]] = {}
real_replace = fake.replace
real_rename = fake.rename
def spy(src: str, dst: str, **kwargs: Any) -> None:
seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir())
seen["tmp"] = sorted(p.name for p in (share_root / "Tmp").iterdir())
real_replace(src, dst, **kwargs)
real_rename(src, dst, **kwargs)
fake.replace = spy # type: ignore[method-assign]
fake.rename = spy # type: ignore[method-assign]
_hotfolder().put(name, b"data")
assert seen == {"input": [], "tmp": [".a.pdf.part"]}
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
@ -283,7 +294,7 @@ class TestPut:
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir", "open_file", "replace"]
assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir", "open_file", "rename"]
assert fake.calls[3] == ("isdir", (INPUT,), {})
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
@ -304,12 +315,12 @@ class TestPut:
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_overwrites_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None:
def test_no_replace_on_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
hf.put("a.pdf", b"old")
hf.put("a.pdf", b"new")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"new"
assert [p.name for p in (share_root / "Input").iterdir()] == ["a.pdf"]
assert (share_root / "Input" / "a.pdf").read_bytes() == b"old"
assert (share_root / "Tmp" / ".a.pdf.part").read_bytes() == b"new"
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder().put("empty.pdf", b"")
@ -349,27 +360,85 @@ class TestPut:
_hotfolder().put(name, b"x")
assert fake.calls == []
def test_cleans_part_file_on_replace_failure(self, fake: FakeSmbClient, share_root: Path) -> None:
fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
def test_cleans_part_file_on_rename_failure(self, fake: FakeSmbClient, share_root: Path) -> None:
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(OSError):
_hotfolder().put("a.pdf", b"data")
_hotfolder(cleanup=True).put("a.pdf", b"data")
part = f"{TMP}\\.a.pdf.part"
assert fake.calls[-1] == ("remove", (part,), {})
assert list((share_root / "Input").iterdir()) == []
assert list((share_root / "Tmp").iterdir()) == []
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["remove"] = SMBException("соединение потеряно")
with pytest.raises(SMBOSError):
_hotfolder().put("a.pdf", b"data")
assert fake.funcs() == ["mkdir", "mkdir", "open_file", "replace", "remove"]
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert fake.funcs() == ["mkdir", "mkdir", "open_file", "rename", "remove"]
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 4
class TestPutWithoutReplace:
def test_uses_rename_not_replace(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
hf.put("job.pdf", b"x")
names = [call[0] for call in fake.calls]
assert "rename" in names and "replace" not in names
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"x"
assert not fake._local(TMP + "\\.job.pdf.part").exists()
def test_existing_destination_kept_and_part_left(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
hf.put("job.pdf", b"first")
hf.put("job.pdf", b"second")
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second"
class TestCleanup:
def test_disabled_is_noop(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()
hf.put("job.pdf", b"x")
before = len(fake.calls)
assert hf.cleanup("job", "job.pdf") is False
assert len(fake.calls) == before
def test_enabled_removes_everything(self, fake: FakeSmbClient) -> None:
share_root = fake.base / HOST / "share" / "scan"
share_root.mkdir(parents=True)
clock = FakeClock()
hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
job_id = str(uuid.uuid4())
hf.put(f"{job_id}.pdf", b"x")
answer = fake._local(OUTPUT + f"\\{job_id}")
answer.mkdir(parents=True)
(answer / f"{job_id}.json").write_bytes(b"{}")
(answer / "readme.txt").write_bytes(b"keep")
assert hf.cleanup(job_id, f"{job_id}.pdf") is True
assert not answer.exists() and not fake._local(INPUT + f"\\{job_id}.pdf").exists()
assert [c[0] for c in fake.calls].count("rmdir") == 1
def test_missing_is_fine(self, fake: FakeSmbClient) -> None:
share_root = fake.base / HOST / "share" / "scan"
share_root.mkdir(parents=True)
clock = FakeClock()
hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
assert hf.cleanup(str(uuid.uuid4()), "nothing.pdf") is True
def test_access_denied_propagates(self, fake: FakeSmbClient) -> None:
share_root = fake.base / HOST / "share" / "scan"
share_root.mkdir(parents=True)
clock = FakeClock()
hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
hf.put("job.pdf", b"x")
fake.fail["remove"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT + "\\job.pdf")
with pytest.raises(SMBOSError):
hf.cleanup("job", "job.pdf")
class TestPoll:
def test_none_until_answer_dir_has_json(self, fake: FakeSmbClient, share_root: Path) -> None:
hf = _hotfolder()