feat(hotfolder): SMB — rename без замены и cleanup под флагом (D-1, D-2)
This commit is contained in:
parent
5c2d89bef3
commit
c00823c673
@ -4,9 +4,10 @@ r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOL
|
|||||||
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
|
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
|
||||||
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
|
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
|
||||||
|
|
||||||
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается во входящий
|
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается (rename без замены)
|
||||||
`\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json` каталога
|
во входящий `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json`
|
||||||
`\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`).
|
каталога `\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора
|
||||||
|
(`SettleTracker`). Очистка (D-2) удаляет ответы, входящий файл и временные файлы при `SCAN_CLEANUP=true`.
|
||||||
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
|
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
|
||||||
по `errno == ENOENT`.
|
по `errno == ENOENT`.
|
||||||
"""
|
"""
|
||||||
@ -65,8 +66,9 @@ def build_unc_root(host: str, path: str) -> str:
|
|||||||
|
|
||||||
class SmbHotFolder:
|
class SmbHotFolder:
|
||||||
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
|
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
|
||||||
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + replace в `Input`, `Tmp` и `Input`
|
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + rename без замены в `Input`,
|
||||||
создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`)."""
|
`Tmp` и `Input` создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации
|
||||||
|
(`SettleTracker`). Очистка (D-2) под флагом `SCAN_CLEANUP`."""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
|
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
|
||||||
self,
|
self,
|
||||||
@ -100,9 +102,10 @@ class SmbHotFolder:
|
|||||||
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
|
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
|
||||||
|
|
||||||
def put(self, name: str, data: bytes) -> None:
|
def put(self, name: str, data: bytes) -> None:
|
||||||
"""Записать `.<name>.part` в служебный каталог и переименовать в `<input>\\<name>` (`smbclient.replace`:
|
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены:
|
||||||
существующий файл заменяется). Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется
|
назначение есть — файл считается доставленным, `.part` остаётся (удаляется только при включённой очистке).
|
||||||
(ошибка удаления не маскирует исходную). Нет корня — `SMBOSError` (ENOENT) от первого `mkdir`.
|
Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется (ошибка удаления не маскирует
|
||||||
|
исходную). Нет корня — `SMBOSError` (ENOENT) от первого `mkdir`.
|
||||||
"""
|
"""
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
self._ensure_dir(self.tmp_dir)
|
self._ensure_dir(self.tmp_dir)
|
||||||
@ -111,10 +114,15 @@ class SmbHotFolder:
|
|||||||
try:
|
try:
|
||||||
with smbclient.open_file(part, mode="wb", **self._credentials) as fd:
|
with smbclient.open_file(part, mode="wb", **self._credentials) as fd:
|
||||||
fd.write(data)
|
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:
|
except BaseException:
|
||||||
with contextlib.suppress(Exception):
|
self._drop_part(part)
|
||||||
smbclient.remove(part, **self._credentials)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def poll(self, job_id: str) -> list[AnswerFile] | None:
|
def poll(self, job_id: str) -> list[AnswerFile] | None:
|
||||||
@ -156,9 +164,42 @@ class SmbHotFolder:
|
|||||||
"""Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
|
"""Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
|
||||||
return len(self._settle)
|
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:
|
def cleanup(self, job_id: str, name: str) -> bool:
|
||||||
"""Очистка SMB — заглушка на Task 11 (логика в Task 12)."""
|
"""Удалить следы задания: ответ из `output`, входящий файл, временные файлы. Возвращает `False`, если
|
||||||
return 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:
|
def healthy(self) -> bool:
|
||||||
"""Корень доступен и является каталогом (`smbclient.listdir` проходит). Не бросает исключений."""
|
"""Корень доступен и является каталогом (`smbclient.listdir` проходит). Не бросает исключений."""
|
||||||
|
|||||||
@ -80,12 +80,23 @@ class FakeSmbClient:
|
|||||||
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, src)
|
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, src)
|
||||||
os.replace(local_src, self._local(dst))
|
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:
|
def remove(self, path: str, **kwargs: Any) -> None:
|
||||||
self._record("remove", (path,), kwargs)
|
self._record("remove", (path,), kwargs)
|
||||||
local = self._local(path)
|
try:
|
||||||
if not local.is_file():
|
os.unlink(self._local(path))
|
||||||
raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path)
|
except FileNotFoundError:
|
||||||
local.unlink()
|
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]:
|
def scandir(self, path: str, **kwargs: Any) -> list[SimpleNamespace]:
|
||||||
"""Как `smbclient.scandir`: элементы с `name`, `is_file()` и `smb_info.end_of_file` (размер из листинга)."""
|
"""Как `smbclient.scandir`: элементы с `name`, `is_file()` и `smb_info.end_of_file` (размер из листинга)."""
|
||||||
@ -206,7 +217,7 @@ class TestPaths:
|
|||||||
|
|
||||||
|
|
||||||
class TestPut:
|
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")
|
name = inbox_name(_job_id(), "aep_5.PDF")
|
||||||
data = bytes(range(256)) * 10
|
data = bytes(range(256)) * 10
|
||||||
_hotfolder().put(name, data)
|
_hotfolder().put(name, data)
|
||||||
@ -215,7 +226,7 @@ class TestPut:
|
|||||||
("mkdir", (TMP,), {}),
|
("mkdir", (TMP,), {}),
|
||||||
("mkdir", (INPUT,), {}),
|
("mkdir", (INPUT,), {}),
|
||||||
("open_file", (part, "wb"), {}),
|
("open_file", (part, "wb"), {}),
|
||||||
("replace", (part, f"{INPUT}\\{name}"), {}),
|
("rename", (part, f"{INPUT}\\{name}"), {}),
|
||||||
]
|
]
|
||||||
assert (share_root / "Input" / name).read_bytes() == data
|
assert (share_root / "Input" / name).read_bytes() == data
|
||||||
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
|
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
|
||||||
@ -225,14 +236,14 @@ class TestPut:
|
|||||||
"""OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`."""
|
"""OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`."""
|
||||||
name = "a.pdf"
|
name = "a.pdf"
|
||||||
seen: dict[str, list[str]] = {}
|
seen: dict[str, list[str]] = {}
|
||||||
real_replace = fake.replace
|
real_rename = fake.rename
|
||||||
|
|
||||||
def spy(src: str, dst: str, **kwargs: Any) -> None:
|
def spy(src: str, dst: str, **kwargs: Any) -> None:
|
||||||
seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir())
|
seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir())
|
||||||
seen["tmp"] = sorted(p.name for p in (share_root / "Tmp").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")
|
_hotfolder().put(name, b"data")
|
||||||
assert seen == {"input": [], "tmp": [".a.pdf.part"]}
|
assert seen == {"input": [], "tmp": [".a.pdf.part"]}
|
||||||
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
|
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)
|
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
|
||||||
_hotfolder().put("a.pdf", b"1")
|
_hotfolder().put("a.pdf", b"1")
|
||||||
assert (share_root / "Input" / "a.pdf").read_bytes() == 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,), {})
|
assert fake.calls[3] == ("isdir", (INPUT,), {})
|
||||||
|
|
||||||
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
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 excinfo.value is denied
|
||||||
assert fake.funcs() == ["mkdir", "isdir"]
|
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 = _hotfolder()
|
||||||
hf.put("a.pdf", b"old")
|
hf.put("a.pdf", b"old")
|
||||||
hf.put("a.pdf", b"new")
|
hf.put("a.pdf", b"new")
|
||||||
assert (share_root / "Input" / "a.pdf").read_bytes() == b"new"
|
assert (share_root / "Input" / "a.pdf").read_bytes() == b"old"
|
||||||
assert [p.name for p in (share_root / "Input").iterdir()] == ["a.pdf"]
|
assert (share_root / "Tmp" / ".a.pdf.part").read_bytes() == b"new"
|
||||||
|
|
||||||
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
_hotfolder().put("empty.pdf", b"")
|
_hotfolder().put("empty.pdf", b"")
|
||||||
@ -349,27 +360,85 @@ class TestPut:
|
|||||||
_hotfolder().put(name, b"x")
|
_hotfolder().put(name, b"x")
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
|
|
||||||
def test_cleans_part_file_on_replace_failure(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_cleans_part_file_on_rename_failure(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)
|
||||||
with pytest.raises(OSError):
|
with pytest.raises(OSError):
|
||||||
_hotfolder().put("a.pdf", b"data")
|
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||||
part = f"{TMP}\\.a.pdf.part"
|
part = f"{TMP}\\.a.pdf.part"
|
||||||
assert fake.calls[-1] == ("remove", (part,), {})
|
assert fake.calls[-1] == ("remove", (part,), {})
|
||||||
assert list((share_root / "Input").iterdir()) == []
|
assert list((share_root / "Input").iterdir()) == []
|
||||||
assert list((share_root / "Tmp").iterdir()) == []
|
assert list((share_root / "Tmp").iterdir()) == []
|
||||||
|
|
||||||
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
|
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("соединение потеряно")
|
fake.fail["remove"] = SMBException("соединение потеряно")
|
||||||
with pytest.raises(SMBOSError):
|
with pytest.raises(SMBOSError):
|
||||||
_hotfolder().put("a.pdf", b"data")
|
_hotfolder(cleanup=True).put("a.pdf", b"data")
|
||||||
assert fake.funcs() == ["mkdir", "mkdir", "open_file", "replace", "remove"]
|
assert fake.funcs() == ["mkdir", "mkdir", "open_file", "rename", "remove"]
|
||||||
|
|
||||||
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
|
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
|
||||||
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 4
|
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:
|
class TestPoll:
|
||||||
def test_none_until_answer_dir_has_json(self, fake: FakeSmbClient, share_root: Path) -> None:
|
def test_none_until_answer_dir_has_json(self, fake: FakeSmbClient, share_root: Path) -> None:
|
||||||
hf = _hotfolder()
|
hf = _hotfolder()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user