Skip to content

Commit f4529e7

Browse files
Return context-manager iterator from scandir() (#363)
Abandoning scandir() iteration before exhaustion left the SMB directory handle open until GC finalized the generator, and callers had no idiomatic way to release it sooner. Stdlib os.scandir() solves this by returning a context-manager iterator.
1 parent acea4a4 commit f4529e7

6 files changed

Lines changed: 186 additions & 48 deletions

File tree

src/smbclient/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
XATTR_REPLACE,
99
SMBDirEntry,
1010
SMBDirEntryInformation,
11+
SMBScandirIterator,
1112
SMBStatResult,
1213
SMBStatVolumeResult,
1314
copyfile,

src/smbclient/_os.py

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -666,23 +666,62 @@ def rmdir(path, **kwargs):
666666
_delete(SMBDirectoryIO, path, **kwargs)
667667

668668

669-
def scandir(path, search_pattern="*", **kwargs):
669+
class SMBScandirIterator:
670+
"""Iterator over SMB directory entries with ``with``-driven close.
671+
672+
Iterable directly and usable as a context manager whose exit releases
673+
the SMB directory handle.
674+
"""
675+
676+
__slots__ = ("_gen",)
677+
678+
def __init__(self, gen: t.Generator[SMBDirEntry, None, None]) -> None:
679+
self._gen = gen
680+
681+
def __iter__(self) -> SMBScandirIterator:
682+
return self
683+
684+
def __next__(self) -> SMBDirEntry:
685+
return next(self._gen)
686+
687+
def __enter__(self) -> SMBScandirIterator:
688+
return self
689+
690+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
691+
self.close()
692+
693+
def close(self) -> None:
694+
self._gen.close()
695+
696+
697+
def scandir(path: str, search_pattern: str = "*", **kwargs: t.Any) -> SMBScandirIterator:
670698
"""
671699
Return an iterator of DirEntry objects corresponding to the entries in the directory given by path. The entries are
672700
yielded in arbitrary order, and the special entries '.' and '..' are not included.
673701
702+
Mirrors stdlib ``os.scandir()``: the returned iterator also supports the context-manager protocol so callers can
703+
release the SMB directory handle deterministically:
704+
705+
with smbclient.scandir(path) as it:
706+
for entry in it:
707+
...
708+
674709
Using scandir() instead of listdir() can significantly increase the performance of code that also needs file type
675710
or file attribute information, because DirEntry objects expose this information if the SMB server provides it when
676711
scanning a directory. All DirEntry methods may perform a SMB request, but is_dir(), is_file(), is_symlink() usually
677712
only require a one system call unless the file or directory is a reparse point which requires 2 calls. See the
678713
Python documentation for how DirEntry is set up and the methods and attributes that are available.
679714
680715
:param path: The path to a directory to scan.
681-
:param search_pattern: THe search string to match against the names of directories or files. This pattern can use
716+
:param search_pattern: The search string to match against the names of directories or files. This pattern can use
682717
'*' as a wildcard for multiple chars and '?' as a wildcard for a single char. Does not support regex patterns.
683718
:param kwargs: Common SMB Session arguments for smbclient.
684-
:return: An iterator of DirEntry objects in the directory.
719+
:return: A context-manager iterator of DirEntry objects in the directory.
685720
"""
721+
return SMBScandirIterator(_scandir(path, search_pattern, **kwargs))
722+
723+
724+
def _scandir(path: str, search_pattern: str = "*", **kwargs: t.Any) -> t.Generator[SMBDirEntry, None, None]:
686725
connection_cache = kwargs.get("connection_cache", None)
687726
with SMBDirectoryIO(path, share_access="rwd", **kwargs) as fd:
688727
for raw_dir_info in fd.query_directory(search_pattern, FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION):
@@ -1023,26 +1062,27 @@ def walk(top, topdown=True, onerror=None, follow_symlinks=False, **kwargs):
10231062
dirs = []
10241063
files = []
10251064
bottom_up_dirs = []
1026-
while True:
1027-
try:
1065+
with scandir_gen:
1066+
while True:
10281067
try:
1029-
entry = next(scandir_gen)
1030-
except StopIteration:
1031-
break
1032-
except OSError as err:
1033-
if onerror is not None:
1034-
onerror(err)
1035-
return
1036-
1037-
if not entry.is_dir():
1038-
files.append(entry.name)
1039-
continue
1068+
try:
1069+
entry = next(scandir_gen)
1070+
except StopIteration:
1071+
break
1072+
except OSError as err:
1073+
if onerror is not None:
1074+
onerror(err)
1075+
return
1076+
1077+
if not entry.is_dir():
1078+
files.append(entry.name)
1079+
continue
10401080

1041-
dirs.append(entry.name)
1042-
if not topdown and (follow_symlinks or not entry.is_symlink()):
1043-
# Add the directory to the bottom up list which is recursively walked below, we exclude symlink dirs if
1044-
# follow_symlinks is False.
1045-
bottom_up_dirs.append(entry.path)
1081+
dirs.append(entry.name)
1082+
if not topdown and (follow_symlinks or not entry.is_symlink()):
1083+
# Add the directory to the bottom up list which is recursively walked below, we exclude symlink dirs
1084+
# if follow_symlinks is False.
1085+
bottom_up_dirs.append(entry.path)
10461086

10471087
walk_kwargs = {"topdown": topdown, "onerror": onerror, "follow_symlinks": follow_symlinks}
10481088
walk_kwargs.update(kwargs)

src/smbclient/shutil.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@ def copytree(
310310
:return: The dst path.
311311
"""
312312
if is_remote_path(src):
313-
dir_entries = list(scandir(src, **kwargs))
313+
with scandir(src, **kwargs) as scandir_gen:
314+
dir_entries = list(scandir_gen)
314315
else:
315316
dir_entries = list(os.scandir(src))
316317

@@ -419,34 +420,34 @@ def onerror(*args):
419420
onerror(islink, path, sys.exc_info())
420421
return
421422

422-
scandir_gen = scandir(path, **kwargs)
423-
while True:
424-
try:
425-
dir_entry = next(scandir_gen)
426-
except StopIteration:
427-
break
428-
except OSError:
429-
onerror(scandir, path, sys.exc_info())
430-
continue
431-
432-
# In case the entry is a directory symbolic link we need to remove the dir itself and not recurse down into
433-
# it with rmtree. Doing that would result in a symbolic link target having it's contents removed even if it's
434-
# outside the rmtree scope.
435-
if (
436-
dir_entry.is_symlink()
437-
and dir_entry.stat(follow_symlinks=False).st_file_attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY
438-
):
423+
with scandir(path, **kwargs) as scandir_gen:
424+
while True:
439425
try:
440-
rmdir(dir_entry.path, **kwargs)
426+
dir_entry = next(scandir_gen)
427+
except StopIteration:
428+
break
441429
except OSError:
442-
onerror(rmdir, dir_entry.path, sys.exc_info())
443-
elif dir_entry.is_dir():
444-
rmtree(dir_entry.path, ignore_errors, onerror, **kwargs)
445-
else:
446-
try:
447-
remove(dir_entry.path, **kwargs)
448-
except OSError:
449-
onerror(remove, dir_entry.path, sys.exc_info())
430+
onerror(scandir, path, sys.exc_info())
431+
continue
432+
433+
# In case the entry is a directory symbolic link we need to remove the dir itself and not recurse down into
434+
# it with rmtree. Doing that would result in a symbolic link target having it's contents removed even if
435+
# it's outside the rmtree scope.
436+
if (
437+
dir_entry.is_symlink()
438+
and dir_entry.stat(follow_symlinks=False).st_file_attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY
439+
):
440+
try:
441+
rmdir(dir_entry.path, **kwargs)
442+
except OSError:
443+
onerror(rmdir, dir_entry.path, sys.exc_info())
444+
elif dir_entry.is_dir():
445+
rmtree(dir_entry.path, ignore_errors, onerror, **kwargs)
446+
else:
447+
try:
448+
remove(dir_entry.path, **kwargs)
449+
except OSError:
450+
onerror(remove, dir_entry.path, sys.exc_info())
450451

451452
try:
452453
rmdir(path, **kwargs)

tests/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,32 @@
154154
)
155155

156156

157+
class StubScandirGen:
158+
"""Server-free stand-in for the _scandir() generator: injects a
159+
mid-iteration failure and records close() calls, so the caller
160+
close-on-exception tests need no SMB server."""
161+
162+
def __init__(self, closes, next_exc=None):
163+
self._closes = closes
164+
self._next_exc = next_exc
165+
166+
def __iter__(self):
167+
return self
168+
169+
def __next__(self):
170+
if self._next_exc is not None:
171+
raise self._next_exc
172+
raise StopIteration
173+
174+
def close(self):
175+
self._closes.append(True)
176+
177+
178+
@pytest.fixture
179+
def stub_scandir_gen():
180+
return StubScandirGen
181+
182+
157183
@pytest.fixture(scope="module")
158184
def smb_real():
159185
# for these tests to work the server at SMB_SERVER must support dialect

tests/test_smbclient_os.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,36 @@ def test_scandir_with_non_matching_pattern(smb_share):
13081308
assert list(smbclient.scandir(smb_share, search_pattern="nomatch_*")) == []
13091309

13101310

1311+
def test_scandir_as_context_manager(smb_share):
1312+
for filename in ["file1.txt", "file2.txt"]:
1313+
with smbclient.open_file(rf"{smb_share}\{filename}", mode="w") as fd:
1314+
fd.write("content")
1315+
1316+
# Full iteration inside the context manager yields every entry.
1317+
with smbclient.scandir(smb_share) as scandir_gen:
1318+
assert isinstance(scandir_gen, smbclient.SMBScandirIterator)
1319+
assert sorted(entry.name for entry in scandir_gen) == ["file1.txt", "file2.txt"]
1320+
1321+
# Abandoning iteration early still releases the handle on block exit: the
1322+
# iterator is finalised, so resuming it stops instead of yielding the rest.
1323+
it = smbclient.scandir(smb_share)
1324+
with it:
1325+
assert next(it).name in ("file1.txt", "file2.txt")
1326+
with pytest.raises(StopIteration):
1327+
next(it)
1328+
1329+
1330+
def test_scandir_iterator_contract():
1331+
it = smbclient.SMBScandirIterator(x for x in ["a", "b"])
1332+
with it as entered:
1333+
assert entered is it
1334+
assert next(it) == "a"
1335+
1336+
# __exit__ closed the underlying generator, so iteration is exhausted.
1337+
with pytest.raises(StopIteration):
1338+
next(it)
1339+
1340+
13111341
@pytest.mark.skipif(
13121342
os.name != "nt" and not os.environ.get("SMB_FORCE", False), reason="cannot create symlinks on Samba"
13131343
)
@@ -1997,6 +2027,19 @@ def test_walk_with_symlink_dont_follow(smb_share):
19972027
assert scanned_roots[src_dirname]["files"] == ["file.txt"]
19982028

19992029

2030+
def test_walk_closes_scandir_iterator_on_unhandled_exception(monkeypatch, stub_scandir_gen):
2031+
closes = []
2032+
monkeypatch.setattr(
2033+
"smbclient._os._scandir",
2034+
lambda *a, **kw: stub_scandir_gen(closes, RuntimeError("simulated mid-iter failure")),
2035+
)
2036+
2037+
with pytest.raises(RuntimeError, match="simulated mid-iter failure"):
2038+
list(smbclient.walk(r"\\server\share\dir"))
2039+
2040+
assert closes == [True]
2041+
2042+
20002043
def test_xattr_file(smb_share):
20012044
filename = "%s\\file.txt" % smb_share
20022045

tests/test_smbclient_shutil.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,3 +1607,30 @@ def _failing_islink(*args, **kwargs):
16071607
assert callback_args[0][0].__name__ == "islink"
16081608
assert callback_args[0][1] == fake_path
16091609
assert isinstance(callback_args[0][2][1], SMBOSError)
1610+
1611+
1612+
def test_rmtree_closes_scandir_iterator_on_unhandled_exception(monkeypatch, stub_scandir_gen):
1613+
closes = []
1614+
monkeypatch.setattr(
1615+
"smbclient._os._scandir",
1616+
lambda *a, **kw: stub_scandir_gen(closes, RuntimeError("simulated mid-iter failure")),
1617+
)
1618+
monkeypatch.setattr("smbclient.shutil.islink", lambda *a, **kw: False)
1619+
1620+
with pytest.raises(RuntimeError, match="simulated mid-iter failure"):
1621+
rmtree(r"\\server\share\dst")
1622+
1623+
assert closes == [True]
1624+
1625+
1626+
def test_copytree_closes_scandir_iterator_on_unhandled_exception(monkeypatch, stub_scandir_gen):
1627+
closes = []
1628+
monkeypatch.setattr(
1629+
"smbclient._os._scandir",
1630+
lambda *a, **kw: stub_scandir_gen(closes, RuntimeError("simulated mid-iter failure")),
1631+
)
1632+
1633+
with pytest.raises(RuntimeError, match="simulated mid-iter failure"):
1634+
copytree(r"\\server\share\src", r"\\server\share\dst")
1635+
1636+
assert closes == [True]

0 commit comments

Comments
 (0)