Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-smbprotocol for openSUSE:Factory checked in at 2026-07-08 17:38:48 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-smbprotocol (Old) and /work/SRC/openSUSE:Factory/.python-smbprotocol.new.1982 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-smbprotocol" Wed Jul 8 17:38:48 2026 rev:26 rq:1364480 version:1.17.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-smbprotocol/python-smbprotocol.changes 2026-04-04 19:09:49.903064263 +0200 +++ /work/SRC/openSUSE:Factory/.python-smbprotocol.new.1982/python-smbprotocol.changes 2026-07-08 17:41:52.932645825 +0200 @@ -1,0 +2,25 @@ +Tue Jul 7 14:04:54 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 1.17.0 + * Drop support for Python 3.9, minimum verison if now 3.10 + * Added the hostname_override kwarg to smbprotocol.Session that + can override the hostname used in the SPN for authentication. + * Correctly perform a DFS referral lookup in the query_directory + on a directory. + * Fix the FlagField str representation when the flag_type + contains a dunder attribute with an int value. + * Added some extra flags from MS-SMB2 that have been added + recently. + * Fix Connection.client_security_mode to be + SMB2_NEGOTIATE_SIGNING_REQUIRED when server reports signing is + required. + * Solve numerous issues with smbclient.rmtree and ignore_errors + not working with various exceptions. + * Make smbclient.scandir return a context manager that can close + SMB resources. + * Handle STATUS_NO_SUCH_FILE during smbclient.scandir when using + a pattern that matches no file in the specified path. + * Improve various threading race conditions and deadlocks in the + underlying client handler. + +------------------------------------------------------------------- Old: ---- python-smbprotocol-1.16.1.tar.gz New: ---- python-smbprotocol-1.17.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-smbprotocol.spec ++++++ --- /var/tmp/diff_new_pack.E7BGCw/_old 2026-07-08 17:41:53.876678722 +0200 +++ /var/tmp/diff_new_pack.E7BGCw/_new 2026-07-08 17:41:53.880678862 +0200 @@ -17,7 +17,7 @@ Name: python-smbprotocol -Version: 1.16.1 +Version: 1.17.0 Release: 0 Summary: SMBv2/v3 client for Python 2 and 3 License: MIT ++++++ python-smbprotocol-1.16.1.tar.gz -> python-smbprotocol-1.17.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/.github/workflows/ci.yml new/smbprotocol-1.17.0/.github/workflows/ci.yml --- old/smbprotocol-1.16.1/.github/workflows/ci.yml 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/.github/workflows/ci.yml 2026-07-07 05:14:30.000000000 +0200 @@ -27,7 +27,7 @@ steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Build smbprotocol run: | @@ -35,7 +35,7 @@ python -m build - name: Upload built smbprotocol - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: artifact path: ./dist/* @@ -54,7 +54,6 @@ - macOS-latest - windows-latest python-version: - - 3.9 - '3.10' - '3.11' - '3.12' @@ -62,13 +61,13 @@ - '3.14' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v8 with: name: artifact path: ./dist @@ -94,21 +93,21 @@ - name: Upload Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Unit Test Results (${{ matrix.os }} ${{ matrix.python-version }} ${{ matrix.python-arch }}) path: ./junit/test-results.xml - name: Upload Coverage Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Coverage Results (${{ matrix.os }} ${{ matrix.python-version }} ${{ matrix.python-arch }}) path: ./coverage.xml - name: Upload Coverage to codecov if: always() - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: ./coverage.xml flags: ${{ steps.os.outputs.name }},py${{ matrix.python-version }},${{ matrix.python-arch }} @@ -124,7 +123,7 @@ id-token: write steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v8 with: name: artifact path: ./dist diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/.pre-commit-config.yaml new/smbprotocol-1.17.0/.pre-commit-config.yaml --- old/smbprotocol-1.16.1/.pre-commit-config.yaml 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/.pre-commit-config.yaml 2026-07-07 05:14:30.000000000 +0200 @@ -1,10 +1,10 @@ repos: - repo: https://github.com/psf/black - rev: 25.9.0 + rev: 26.3.1 hooks: - id: black - repo: https://github.com/PyCQA/isort - rev: 6.1.0 + rev: 8.0.1 hooks: - id: isort diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/CHANGELOG.md new/smbprotocol-1.17.0/CHANGELOG.md --- old/smbprotocol-1.16.1/CHANGELOG.md 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/CHANGELOG.md 2026-07-07 05:14:30.000000000 +0200 @@ -1,5 +1,26 @@ # Changelog +## 1.17.0 - 2026-07-07 + +* Drop support for Python 3.9, minimum verison if now 3.10 +* Added the `hostname_override` kwarg to `smbprotocol.Session` that can override the hostname used in the SPN for authentication +* Correctly perform a DFS referral lookup in the `query_directory` on a directory +* Fix the `FlagField` str representation when the `flag_type` contains a dunder attribute with an `int` value +* Added some extra flags from MS-SMB2 that have been added recently +* Fix `Connection.client_security_mode` to be `SMB2_NEGOTIATE_SIGNING_REQUIRED` when server reports signing is required +* Solve numerous issues with `smbclient.rmtree` and `ignore_errors` not working with various exceptions +* Make `smbclient.scandir` return a context manager that can close SMB resources. This method should now be called like + * The return value is still an iterable and will continue to work as it did before but code should still be updated to use the context manager to ensure resources are released + +```python +with smbclient.scandir(...) as scan_gen: + for entry in scan_gen: + ... +``` + +* Handle `STATUS_NO_SUCH_FILE` during `smbclient.scandir` when using a pattern that matches no file in the specified path +* Improve various threading race conditions and deadlocks in the underlying client handler + ## 1.16.1 - 2026-04-02 * Fix memory leaks in `Structure` and subclasses by converting lambda default values to static methods and using `weakref.proxy` to prevent circular references diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/README.md new/smbprotocol-1.17.0/README.md --- old/smbprotocol-1.16.1/README.md 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/README.md 2026-07-07 05:14:30.000000000 +0200 @@ -32,7 +32,7 @@ ## Requirements -* Python 3.9+ +* Python 3.10+ * For Kerberos auth on Linux * [python-gssapi](https://github.com/pythongssapi/python-gssapi) * [pykrb5](https://github.com/jborean93/pykrb5) @@ -224,8 +224,9 @@ ```bash # Install in current environment. # Recommend to have virtual environment installed at .venv path. -pip install -r requirements-dev.txt -pip install -e . +# Dependency groups need pip 25.1+. +python -m pip install "pip>=25.1" +pip install -e . --group dev ``` From there to run the basic tests run; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/build_helpers/lib.sh new/smbprotocol-1.17.0/build_helpers/lib.sh --- old/smbprotocol-1.16.1/build_helpers/lib.sh 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/build_helpers/lib.sh 2026-07-07 05:14:30.000000000 +0200 @@ -64,7 +64,9 @@ --verbose echo "Installing dev dependencies" - python -m pip install -r requirements-dev.txt + # --group needs pip 25.1+ (PEP 735) + python -m pip install "pip>=25.1" + python -m pip install --group dev if [ x"${GITHUB_ACTIONS}" = "xtrue" ]; then echo "::endgroup::" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/codecov.yml new/smbprotocol-1.17.0/codecov.yml --- old/smbprotocol-1.16.1/codecov.yml 1970-01-01 01:00:00.000000000 +0100 +++ new/smbprotocol-1.17.0/codecov.yml 2026-07-07 05:14:30.000000000 +0200 @@ -0,0 +1,5 @@ +coverage: + status: + project: + default: + informational: true diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/pyproject.toml new/smbprotocol-1.17.0/pyproject.toml --- old/smbprotocol-1.16.1/pyproject.toml 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/pyproject.toml 2026-07-07 05:14:30.000000000 +0200 @@ -6,10 +6,10 @@ [project] name = "smbprotocol" -version = "1.16.1" +version = "1.17.0" description = "Interact with a server using the SMB 2/3 Protocol" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = "MIT" authors = [ { name = "Jordan Borean", email = "[email protected]" } @@ -18,7 +18,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -36,6 +35,22 @@ [project.urls] homepage = "https://github.com/jborean93/smbprotocol" +[dependency-groups] +test = [ + "pytest", + "pytest-cov", + "pytest-mock", +] +lint = [ + "black == 26.3.1", + "isort == 8.0.1", + "pre-commit", +] +dev = [ + {include-group = "test"}, + {include-group = "lint"}, +] + [tool.setuptools.packages.find] where = ["src"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/requirements-dev.txt new/smbprotocol-1.17.0/requirements-dev.txt --- old/smbprotocol-1.16.1/requirements-dev.txt 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/requirements-dev.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1,9 +0,0 @@ -black == 25.9.0 -build -cryptography >= 2.0 -isort == 6.1.0 -pre-commit -pyspnego -pytest -pytest-cov -pytest-mock diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbclient/__init__.py new/smbprotocol-1.17.0/src/smbclient/__init__.py --- old/smbprotocol-1.16.1/src/smbclient/__init__.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbclient/__init__.py 2026-07-07 05:14:30.000000000 +0200 @@ -8,6 +8,7 @@ XATTR_REPLACE, SMBDirEntry, SMBDirEntryInformation, + SMBScandirIterator, SMBStatResult, SMBStatVolumeResult, copyfile, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbclient/_io.py new/smbprotocol-1.17.0/src/smbclient/_io.py --- old/smbprotocol-1.16.1/src/smbclient/_io.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbclient/_io.py 2026-07-07 05:14:30.000000000 +0200 @@ -3,11 +3,13 @@ import io import logging +import warnings from smbclient._pool import ClientConfig, dfs_request, get_smb_tree from smbprotocol import MAX_PAYLOAD_SIZE from smbprotocol.exceptions import ( NoMoreFiles, + NoSuchFile, ObjectNameNotFound, ObjectPathNotFound, PathNotCovered, @@ -159,6 +161,40 @@ yield Open(tree, fd_path) +def _next_dfs_referral(raw_io, attempted_paths): + """ + Walk DFS referrals for ``raw_io`` and return the next candidate ``Open`` whose + (share_name, file_name) hasn't been attempted yet. Returns ``None`` if all + referrals are exhausted, letting the caller re-raise the original DFS error. + + Used by both :class:`SMBFileTransaction` (open/CreateRequest path) and + :meth:`SMBDirectoryIO.query_directory` (already-open enumeration path) so + they share a single source of truth for DFS retry selection. Each caller + is still responsible for swapping the underlying fd after a candidate is + returned because that step differs (transaction mutates ``_actions`` while + the directory IO closes/reopens the fd). + + :param raw_io: The SMBRawIO whose path returned STATUS_PATH_NOT_COVERED or + a generic name/path-not-found that's actually DFS. + :param attempted_paths: Mutable set used to dedupe referral targets across + multiple retries within a single operation. See + https://github.com/jborean93/smbprotocol/issues/228. + :return: A new ``Open`` instance for the caller to use, or ``None``. + """ + for smb_open in _resolve_dfs(raw_io): + if smb_open.tree_connect.share_name == raw_io.fd.tree_connect.share_name: + continue + + tested_path = f"{smb_open.tree_connect.share_name}{smb_open.file_name}".lower() + if tested_path in attempted_paths: + continue + + attempted_paths.add(tested_path) + return smb_open + + return None + + def ioctl_request(transaction, ctl_code, output_size=0, flags=IOCTLFlags.SMB2_0_IOCTL_IS_IOCTL, input_buffer=b""): """ Sends an IOCTL request to the server. @@ -303,32 +339,18 @@ if not (idx == 0 and isinstance(send_msgs[0], SMB2CreateRequest)): raise - for smb_open in _resolve_dfs(self.raw): - if smb_open.tree_connect.share_name == self.raw.fd.tree_connect.share_name: - continue - - # Ensure we don't continuously try the same DFS referral targets if it's already been attempted. - # https://github.com/jborean93/smbprotocol/issues/228 - tested_path = f"{smb_open.tree_connect.share_name}{smb_open.file_name}".lower() - if tested_path in self._attempted_dfs_paths: - continue - - self._attempted_dfs_paths.add(tested_path) - - self.raw.fd = smb_open - - # In case this is a transaction with an explicit open we want to reopen it with the new params - # before trying it again. - self.raw.open(transaction=self) - self._actions[0] = self._actions.pop(-1) - - try_again = True - break - - else: - # Either there wasn't any DFS referrals or none of them worked, just reraise the error. + smb_open = _next_dfs_referral(self.raw, self._attempted_dfs_paths) + if smb_open is None: + # No DFS referrals or all of them already attempted; propagate the error. raise + self.raw.fd = smb_open + # In case this is a transaction with an explicit open we want to reopen it with the new params + # before trying it again. + self.raw.open(transaction=self) + self._actions[0] = self._actions.pop(-1) + try_again = True + except SMBResponseException as exc: failures.append(SMBOSError(exc.status, self.raw.name)) @@ -359,7 +381,11 @@ def __init__( self, path, mode="r", share_access=None, desired_access=None, file_attributes=None, create_options=0, **kwargs ): - tree, fd_path = get_smb_tree(path, **kwargs) + # get_smb_tree runs outside the SMBFileTransaction layer that normally translates these. + try: + tree, fd_path = get_smb_tree(path, **kwargs) + except SMBResponseException as exc: + raise SMBOSError(exc.status, path) from exc self.share_access = share_access self.fd = Open(tree, fd_path) @@ -404,6 +430,17 @@ super().__init__() + def __del__(self): + # io.IOBase.__del__ would call self.close(), which self-deadlocks if GC + # runs it on the SMB worker thread. + try: + closed = self.closed + except AttributeError: + return + if closed: + return + warnings.warn(f"unclosed SMB handle {self._name!r}", ResourceWarning, source=self) + def __enter__(self): self.open() return self @@ -615,11 +652,31 @@ def query_directory(self, pattern, info_class): query_flags = QueryDirectoryFlags.SMB2_RESTART_SCANS + attempted_dfs_paths = set() while True: try: entries = self.fd.query_directory(pattern, info_class, flags=query_flags) - except NoMoreFiles: + except (NoMoreFiles, NoSuchFile): + # MS-SMB2 3.3.5.18 lists both as QUERY_DIRECTORY end codes. A server + # returns STATUS_NO_SUCH_FILE when the search pattern matches no files. + # Treat either as end of enumeration. break + except (PathNotCovered, ObjectNameNotFound, ObjectPathNotFound): + # The MS-DFSC docs state that STATUS_PATH_NOT_COVERED is used when encountering a DFS link to a + # different server during directory enumeration. Samba may return the generic name or path not found. + smb_open = _next_dfs_referral(self, attempted_dfs_paths) + if smb_open is None: + # No DFS referral resolved; propagate the error. + raise + + try: + self.fd.close() + except SMBResponseException: + pass + self.fd = smb_open + self.open() + query_flags = QueryDirectoryFlags.SMB2_RESTART_SCANS + continue query_flags = 0 # Only the first request should have set SMB2_RESTART_SCANS yield from entries diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbclient/_os.py new/smbprotocol-1.17.0/src/smbclient/_os.py --- old/smbprotocol-1.16.1/src/smbclient/_os.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbclient/_os.py 2026-07-07 05:14:30.000000000 +0200 @@ -666,11 +666,46 @@ _delete(SMBDirectoryIO, path, **kwargs) -def scandir(path, search_pattern="*", **kwargs): +class SMBScandirIterator: + """Iterator over SMB directory entries with ``with``-driven close. + + Iterable directly and usable as a context manager whose exit releases + the SMB directory handle. + """ + + __slots__ = ("_gen",) + + def __init__(self, gen: t.Generator[SMBDirEntry, None, None]) -> None: + self._gen = gen + + def __iter__(self) -> SMBScandirIterator: + return self + + def __next__(self) -> SMBDirEntry: + return next(self._gen) + + def __enter__(self) -> SMBScandirIterator: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def close(self) -> None: + self._gen.close() + + +def scandir(path: str, search_pattern: str = "*", **kwargs: t.Any) -> SMBScandirIterator: """ Return an iterator of DirEntry objects corresponding to the entries in the directory given by path. The entries are yielded in arbitrary order, and the special entries '.' and '..' are not included. + Mirrors stdlib ``os.scandir()``: the returned iterator also supports the context-manager protocol so callers can + release the SMB directory handle deterministically: + + with smbclient.scandir(path) as it: + for entry in it: + ... + Using scandir() instead of listdir() can significantly increase the performance of code that also needs file type or file attribute information, because DirEntry objects expose this information if the SMB server provides it when scanning a directory. All DirEntry methods may perform a SMB request, but is_dir(), is_file(), is_symlink() usually @@ -678,11 +713,15 @@ Python documentation for how DirEntry is set up and the methods and attributes that are available. :param path: The path to a directory to scan. - :param search_pattern: THe search string to match against the names of directories or files. This pattern can use + :param search_pattern: The search string to match against the names of directories or files. This pattern can use '*' as a wildcard for multiple chars and '?' as a wildcard for a single char. Does not support regex patterns. :param kwargs: Common SMB Session arguments for smbclient. - :return: An iterator of DirEntry objects in the directory. + :return: A context-manager iterator of DirEntry objects in the directory. """ + return SMBScandirIterator(_scandir(path, search_pattern, **kwargs)) + + +def _scandir(path: str, search_pattern: str = "*", **kwargs: t.Any) -> t.Generator[SMBDirEntry, None, None]: connection_cache = kwargs.get("connection_cache", None) with SMBDirectoryIO(path, share_access="rwd", **kwargs) as fd: for raw_dir_info in fd.query_directory(search_pattern, FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION): @@ -1023,26 +1062,27 @@ dirs = [] files = [] bottom_up_dirs = [] - while True: - try: + with scandir_gen: + while True: try: - entry = next(scandir_gen) - except StopIteration: - break - except OSError as err: - if onerror is not None: - onerror(err) - return - - if not entry.is_dir(): - files.append(entry.name) - continue - - dirs.append(entry.name) - if not topdown and (follow_symlinks or not entry.is_symlink()): - # Add the directory to the bottom up list which is recursively walked below, we exclude symlink dirs if - # follow_symlinks is False. - bottom_up_dirs.append(entry.path) + try: + entry = next(scandir_gen) + except StopIteration: + break + except OSError as err: + if onerror is not None: + onerror(err) + return + + if not entry.is_dir(): + files.append(entry.name) + continue + + dirs.append(entry.name) + if not topdown and (follow_symlinks or not entry.is_symlink()): + # Add the directory to the bottom up list which is recursively walked below, we exclude symlink dirs + # if follow_symlinks is False. + bottom_up_dirs.append(entry.path) walk_kwargs = {"topdown": topdown, "onerror": onerror, "follow_symlinks": follow_symlinks} walk_kwargs.update(kwargs) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbclient/shutil.py new/smbprotocol-1.17.0/src/smbclient/shutil.py --- old/smbprotocol-1.16.1/src/smbclient/shutil.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbclient/shutil.py 2026-07-07 05:14:30.000000000 +0200 @@ -310,7 +310,8 @@ :return: The dst path. """ if is_remote_path(src): - dir_entries = list(scandir(src, **kwargs)) + with scandir(src, **kwargs) as scandir_gen: + dir_entries = list(scandir_gen) else: dir_entries = list(os.scandir(src)) @@ -411,41 +412,42 @@ def onerror(*args): raise - if islink(path, **kwargs): - try: + # islink issues SMB requests, so route failures through onerror. + try: + if islink(path, **kwargs): raise OSError("Cannot call rmtree on a symbolic link") - except OSError: - onerror(islink, path, sys.exc_info()) - return - - scandir_gen = scandir(path, **kwargs) - while True: - try: - dir_entry = next(scandir_gen) - except StopIteration: - break - except OSError: - onerror(scandir, path, sys.exc_info()) - continue + except OSError: + onerror(islink, path, sys.exc_info()) + return - # In case the entry is a directory symbolic link we need to remove the dir itself and not recurse down into - # it with rmtree. Doing that would result in a symbolic link target having it's contents removed even if it's - # outside the rmtree scope. - if ( - dir_entry.is_symlink() - and dir_entry.stat(follow_symlinks=False).st_file_attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY - ): + with scandir(path, **kwargs) as scandir_gen: + while True: try: - rmdir(dir_entry.path, **kwargs) + dir_entry = next(scandir_gen) + except StopIteration: + break except OSError: - onerror(rmdir, dir_entry.path, sys.exc_info()) - elif dir_entry.is_dir(): - rmtree(dir_entry.path, ignore_errors, onerror, **kwargs) - else: - try: - remove(dir_entry.path, **kwargs) - except OSError: - onerror(remove, dir_entry.path, sys.exc_info()) + onerror(scandir, path, sys.exc_info()) + continue + + # In case the entry is a directory symbolic link we need to remove the dir itself and not recurse down into + # it with rmtree. Doing that would result in a symbolic link target having it's contents removed even if + # it's outside the rmtree scope. + if ( + dir_entry.is_symlink() + and dir_entry.stat(follow_symlinks=False).st_file_attributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY + ): + try: + rmdir(dir_entry.path, **kwargs) + except OSError: + onerror(rmdir, dir_entry.path, sys.exc_info()) + elif dir_entry.is_dir(): + rmtree(dir_entry.path, ignore_errors, onerror, **kwargs) + else: + try: + remove(dir_entry.path, **kwargs) + except OSError: + onerror(remove, dir_entry.path, sys.exc_info()) try: rmdir(path, **kwargs) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbprotocol/connection.py new/smbprotocol-1.17.0/src/smbprotocol/connection.py --- old/smbprotocol-1.16.1/src/smbprotocol/connection.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbprotocol/connection.py 2026-07-07 05:14:30.000000000 +0200 @@ -921,6 +921,7 @@ SecurityMode.SMB2_NEGOTIATE_SIGNING_REQUIRED ): self.require_signing = True + self.client_security_mode = SecurityMode.SMB2_NEGOTIATE_SIGNING_REQUIRED log.info("Connection require signing: %s", self.require_signing) capabilities = smb_response["capabilities"] @@ -1032,6 +1033,12 @@ :param resolve_symlinks: Set to automatically resolve symlinks in the path when opening a file or directory. :return: SMB2HeaderResponse of the received message """ + # Only the worker thread delivers responses, so calling receive() from it would wait + # on an event only it can set. + worker = self._t_worker + if worker is not None and threading.get_ident() == worker.ident: + raise SMBException("Connection.receive() called from the SMB worker thread, would self-deadlock") + # Make sure the receiver is still active, if not this raises an exception. self._check_worker_running() @@ -1431,7 +1438,9 @@ if request.message["reserved"].get_value() == 1: self.outstanding_requests.pop(message_id, None) except Exception as exc: - # The exception is raised in _check_worker_running by the main thread when send/receive is called next. + log.exception("SMB receive worker died (outstanding=%d)", len(self.outstanding_requests)) + + # The exception is raised in _check_worker_running by the caller thread when send/receive is called next. self._t_exc = exc # While a caller of send/receive could theoretically catch this exception, we consider any failures diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbprotocol/open.py new/smbprotocol-1.17.0/src/smbprotocol/open.py --- old/smbprotocol-1.16.1/src/smbprotocol/open.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbprotocol/open.py 2026-07-07 05:14:30.000000000 +0200 @@ -230,6 +230,7 @@ """ SMB2_READFLAG_READ_UNBUFFERED = 0x01 + SMB2_READFLAG_REQUEST_COMPRESSED = 0x02 class ReadWriteChannel: @@ -1102,6 +1103,10 @@ class Open: + # MS-SMB2 3.2.4.1.4 ChainedFileId: 0xFF * 16 signals "use the Open + # from the most recent CREATE in this related-compound chain". + _RELATED_COMPOUND_FILE_ID = b"\xff" * 16 + def __init__(self, tree, name): """ [MS-SMB2] v53.0 2017-09-15 @@ -1125,10 +1130,7 @@ self.file_attributes = None # properties used privately - # set to { 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF } to allow message - # compounding with the open as the first message, once opened this - # will be overwritten by the open response - self.file_id = b"\xff" * 16 + self.file_id = self._RELATED_COMPOUND_FILE_ID self.tree_connect = tree self.connection = tree.session.connection self.oplock_level = None @@ -1595,28 +1597,29 @@ try: response = self.connection.receive(request) except FileClosed: + # already closed server-side + return + else: + c_resp = SMB2CloseResponse() + c_resp.unpack(response["data"].get_value()) + log.debug(c_resp) + + # update the attributes if requested + close_request = SMB2CloseRequest() + close_request.unpack(request.message["data"].get_value()) + if close_request["flags"].has_flag(CloseFlags.SMB2_CLOSE_FLAG_POSTQUERY_ATTRIB): + self.creation_time = c_resp["creation_time"].get_value() + self.last_access_time = c_resp["last_access_time"].get_value() + self.last_write_time = c_resp["last_write_time"].get_value() + self.change_time = c_resp["change_time"].get_value() + self.allocation_size = c_resp["allocation_size"].get_value() + self.end_of_file = c_resp["end_of_file"].get_value() + self.file_attributes = c_resp["file_attributes"].get_value() + return c_resp + finally: + # file_id may be unregistered (unsent compound) or already removed by a prior close self._connected = False self.tree_connect.session.open_table.pop(self.file_id, None) - return - - c_resp = SMB2CloseResponse() - c_resp.unpack(response["data"].get_value()) - log.debug(c_resp) - self._connected = False - del self.tree_connect.session.open_table[self.file_id] - - # update the attributes if requested - close_request = SMB2CloseRequest() - close_request.unpack(request.message["data"].get_value()) - if close_request["flags"].has_flag(CloseFlags.SMB2_CLOSE_FLAG_POSTQUERY_ATTRIB): - self.creation_time = c_resp["creation_time"].get_value() - self.last_access_time = c_resp["last_access_time"].get_value() - self.last_write_time = c_resp["last_write_time"].get_value() - self.change_time = c_resp["change_time"].get_value() - self.allocation_size = c_resp["allocation_size"].get_value() - self.end_of_file = c_resp["end_of_file"].get_value() - self.file_attributes = c_resp["file_attributes"].get_value() - return c_resp def lock(self, locks, lsn=0, lsi=0, wait=True, send=True): """ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbprotocol/session.py new/smbprotocol-1.17.0/src/smbprotocol/session.py --- old/smbprotocol-1.16.1/src/smbprotocol/session.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbprotocol/session.py 2026-07-07 05:14:30.000000000 +0200 @@ -3,7 +3,6 @@ import hashlib import logging -import random from collections import OrderedDict from typing import Literal, Optional @@ -177,6 +176,7 @@ username: Optional[str] = None, password: Optional[str] = None, require_encryption=True, + hostname_override=None, auth_protocol: Literal["negotiate", "ntlm", "kerberos"] = "negotiate", ): """ @@ -219,6 +219,8 @@ :param require_encryption: Whether any messages sent over the session require encryption regardless of the server settings (Dialects 3+), needs to be set to False for older dialects. + :param hostname_override: Specifies hostname context for Kerberos + authentication when given IP address to the connection context. :param auth_protocol: The protocol to use for authentication. Possible values are 'negotiate', 'ntlm' or 'kerberos'. Defaults to 'negotiate'. @@ -240,6 +242,7 @@ self.connection = connection self.username = username self.password = password + self._hostname_override = hostname_override # No need to validate this as the spnego library will raise a ValueError self.auth_protocol = auth_protocol @@ -274,7 +277,7 @@ self.username, self.password, service="cifs", - hostname=self.connection.server_name, + hostname=self._hostname_override or self.connection.server_name, options=spnego.NegotiateOptions.session_key, protocol=self.auth_protocol, ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbprotocol/structure.py new/smbprotocol-1.17.0/src/smbprotocol/structure.py --- old/smbprotocol-1.16.1/src/smbprotocol/structure.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbprotocol/structure.py 2026-07-07 05:14:30.000000000 +0200 @@ -797,7 +797,8 @@ return "0" flags = [] for flag, value in vars(self.flag_type).items(): - if isinstance(value, int) and self.has_flag(value): + # ignore and dunder __foo__ attributes. + if not flag.startswith("__") and isinstance(value, int) and self.has_flag(value): flags.append(flag) flags.sort() return f"({field_value}) " + (", ".join(flags)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbprotocol/transport.py new/smbprotocol-1.17.0/src/smbprotocol/transport.py --- old/smbprotocol-1.16.1/src/smbprotocol/transport.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbprotocol/transport.py 2026-07-07 05:14:30.000000000 +0200 @@ -135,10 +135,10 @@ return None, timeout # pragma: no cover read = select.select([self._sock], [], [], max(timeout, 1))[0] - timeout = timeout - (timeit.default_timer() - start_time) + elapsed = timeit.default_timer() - start_time + timeout = timeout - elapsed if not read: - log.debug("Socket recv(%s) timed out") - raise TimeoutError() + raise TimeoutError(f"recv idle for {elapsed:.1f}s ({offset}/{length} bytes)") try: b_data = self._sock.recv(read_len) @@ -148,6 +148,10 @@ if e.errno not in [errno.ESHUTDOWN, errno.ECONNRESET, errno.ECONNABORTED]: # Avoid collecting coverage here to avoid CI failing due to race condition differences raise # pragma: no cover + log.warning( + "socket aborted by peer (%s), treating as EOF", + errno.errorcode.get(e.errno, e.errno), + ) b_data = b"" read_len = len(b_data) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/src/smbprotocol/tree.py new/smbprotocol-1.17.0/src/smbprotocol/tree.py --- old/smbprotocol-1.16.1/src/smbprotocol/tree.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/src/smbprotocol/tree.py 2026-07-07 05:14:30.000000000 +0200 @@ -74,6 +74,8 @@ SMB2_SHAREFLAG_ENABLE_HASH_V2 = 0x00004000 SMB2_SHAREFLAG_ENCRYPT_DATA = 0x00008000 SMB2_SHAREFLAG_IDENTITY_REMOTING = 0x00040000 + SMB2_SHAREFLAG_COMPRESS_DATA = 0x00100000 + SMB2_SHAREFLAG_ISOLATED_TRANSPORT = 0x00200000 class ShareCapabilities: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/conftest.py new/smbprotocol-1.17.0/tests/conftest.py --- old/smbprotocol-1.16.1/tests/conftest.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/conftest.py 2026-07-07 05:14:30.000000000 +0200 @@ -154,6 +154,32 @@ ) +class StubScandirGen: + """Server-free stand-in for the _scandir() generator: injects a + mid-iteration failure and records close() calls, so the caller + close-on-exception tests need no SMB server.""" + + def __init__(self, closes, next_exc=None): + self._closes = closes + self._next_exc = next_exc + + def __iter__(self): + return self + + def __next__(self): + if self._next_exc is not None: + raise self._next_exc + raise StopIteration + + def close(self): + self._closes.append(True) + + [email protected] +def stub_scandir_gen(): + return StubScandirGen + + @pytest.fixture(scope="module") def smb_real(): # for these tests to work the server at SMB_SERVER must support dialect diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_connection.py new/smbprotocol-1.17.0/tests/test_connection.py --- old/smbprotocol-1.16.1/tests/test_connection.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/test_connection.py 2026-07-07 05:14:30.000000000 +0200 @@ -987,7 +987,11 @@ assert len(connection.salt) == 32 assert connection.sequence_window["low"] == 1 assert connection.sequence_window["high"] == 2 - assert connection.client_security_mode == SecurityMode.SMB2_NEGOTIATE_SIGNING_ENABLED + assert ( + connection.client_security_mode == SecurityMode.SMB2_NEGOTIATE_SIGNING_REQUIRED + if connection.server_security_mode & SecurityMode.SMB2_NEGOTIATE_SIGNING_REQUIRED + else SecurityMode.SMB2_NEGOTIATE_SIGNING_ENABLED + ) assert connection.supports_encryption if connection.server_security_mode & SecurityMode.SMB2_NEGOTIATE_SIGNING_REQUIRED: @@ -1111,6 +1115,16 @@ with pytest.raises(SMBConnectionClosed): connection.receive(None) + def test_receive_from_worker_thread_raises(self, mocker): + # receive() on the worker thread would wait for an event only the worker can set. + connection = Connection(uuid.uuid4(), "server", 445, True) + fake_worker = mocker.MagicMock() + fake_worker.ident = threading.get_ident() + connection._t_worker = fake_worker + + with pytest.raises(SMBException, match="self-deadlock"): + connection.receive(None) + def test_verify_fail_no_session(self, smb_real): connection = Connection(uuid.uuid4(), smb_real[2], smb_real[3], True) connection.connect() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_open.py new/smbprotocol-1.17.0/tests/test_open.py --- old/smbprotocol-1.16.1/tests/test_open.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/test_open.py 2026-07-07 05:14:30.000000000 +0200 @@ -23,7 +23,14 @@ SMB2CreateResponseLeaseV2, SMB2CreateTimewarpToken, ) -from smbprotocol.exceptions import EndOfFile, SMBException, SMBUnsupportedFeature +from smbprotocol.exceptions import ( + EndOfFile, + FileClosed, + ObjectNameNotFound, + SMBException, + SMBResponseException, + SMBUnsupportedFeature, +) from smbprotocol.file_info import ( FileAttributes, FileEndOfFileInformation, @@ -71,6 +78,16 @@ from smbprotocol.tree import TreeConnect [email protected] +def closing_open(mocker): + """Open-shaped mock with the state `_close_response` mutates.""" + open_obj = mocker.MagicMock() + open_obj._connected = True + open_obj.file_id = mocker.sentinel.file_id + open_obj.tree_connect.session.open_table = {open_obj.file_id: open_obj} + return open_obj + + class TestSMB2CreateRequest: def test_create_message(self): timewarp_token = SMB2CreateTimewarpToken() @@ -2534,3 +2551,47 @@ finally: connection.disconnect(True) + + def test_close_response_marks_disconnected_on_receive_failure(self, closing_open, mocker): + closing_open.connection.receive.side_effect = ObjectNameNotFound() + + with pytest.raises(SMBResponseException): + Open._close_response(closing_open, mocker.MagicMock()) + + assert not closing_open._connected + assert not closing_open.tree_connect.session.open_table + + def test_close_response_marks_disconnected_on_file_closed(self, closing_open, mocker): + closing_open.connection.receive.side_effect = FileClosed() + + assert Open._close_response(closing_open, mocker.MagicMock()) is None + assert not closing_open._connected + assert not closing_open.tree_connect.session.open_table + + def test_close_response_marks_disconnected_on_success(self, closing_open, mocker): + closing_open.connection.receive.return_value = mocker.MagicMock() + mocker.patch("smbprotocol.open.SMB2CloseResponse") + close_req_cls = mocker.patch("smbprotocol.open.SMB2CloseRequest") + close_req_cls.return_value.__getitem__.return_value.has_flag.return_value = False + + Open._close_response(closing_open, mocker.MagicMock()) + + assert not closing_open._connected + assert not closing_open.tree_connect.session.open_table + + def test_close_response_tolerates_already_popped_entry_on_receive_failure(self, closing_open, mocker): + closing_open.tree_connect.session.open_table.clear() + closing_open.connection.receive.side_effect = ObjectNameNotFound() + + with pytest.raises(SMBResponseException): + Open._close_response(closing_open, mocker.MagicMock()) + + assert not closing_open._connected + + def test_close_response_tolerates_already_popped_entry_on_file_closed(self, closing_open, mocker): + closing_open.tree_connect.session.open_table.clear() + closing_open.connection.receive.side_effect = FileClosed() + + Open._close_response(closing_open, mocker.MagicMock()) + + assert not closing_open._connected diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_smbclient_io.py new/smbprotocol-1.17.0/tests/test_smbclient_io.py --- old/smbprotocol-1.16.1/tests/test_smbclient_io.py 1970-01-01 01:00:00.000000000 +0100 +++ new/smbprotocol-1.17.0/tests/test_smbclient_io.py 2026-07-07 05:14:30.000000000 +0200 @@ -0,0 +1,41 @@ +# Copyright: (c) 2026, Acconeer AB <[email protected]> +# MIT License (see LICENSE or https://opensource.org/licenses/MIT) + +import warnings + +import pytest + +import smbclient._io as io +from smbclient._io import SMBRawIO + + [email protected] +def raw(mocker): + """Construct an SMBRawIO without opening any socket. + + SMBRawIO.__init__ normally calls get_smb_tree which establishes a connection. + Patching that and Open lets the finalizer be exercised deterministically. + """ + mock_tree = mocker.MagicMock() + mocker.patch.object(io, "get_smb_tree", return_value=(mock_tree, "file")) + mocker.patch.object(io, "Open") + + return SMBRawIO(r"\\server\share\file.txt", mode="r", share_access="r") + + +def test_del_does_not_close_a_connected_handle(raw): + # The override must skip self.close() to avoid deadlocking the worker. + raw.fd.connected = True + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ResourceWarning) + raw.__del__() + + raw.fd.close.assert_not_called() + + +def test_del_emits_resource_warning_for_leaked_handle(raw): + raw.fd.connected = True + + with pytest.warns(ResourceWarning, match=r"unclosed SMB handle"): + raw.__del__() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_smbclient_os.py new/smbprotocol-1.17.0/tests/test_smbclient_os.py --- old/smbprotocol-1.16.1/tests/test_smbclient_os.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/test_smbclient_os.py 2026-07-07 05:14:30.000000000 +0200 @@ -1292,6 +1292,52 @@ assert names == ["file-test1.txt"] +def test_scandir_empty_directory(smb_share): + # Some servers return STATUS_NO_SUCH_FILE here instead of STATUS_NO_MORE_FILES. + empty_dir = rf"{smb_share}\empty" + smbclient.mkdir(empty_dir) + + assert list(smbclient.scandir(empty_dir)) == [] + + +def test_scandir_with_non_matching_pattern(smb_share): + # Samba returns STATUS_NO_SUCH_FILE here instead of STATUS_NO_MORE_FILES. + with smbclient.open_file(rf"{smb_share}\file.txt", mode="w") as fd: + fd.write("x") + + assert list(smbclient.scandir(smb_share, search_pattern="nomatch_*")) == [] + + +def test_scandir_as_context_manager(smb_share): + for filename in ["file1.txt", "file2.txt"]: + with smbclient.open_file(rf"{smb_share}\{filename}", mode="w") as fd: + fd.write("content") + + # Full iteration inside the context manager yields every entry. + with smbclient.scandir(smb_share) as scandir_gen: + assert isinstance(scandir_gen, smbclient.SMBScandirIterator) + assert sorted(entry.name for entry in scandir_gen) == ["file1.txt", "file2.txt"] + + # Abandoning iteration early still releases the handle on block exit: the + # iterator is finalised, so resuming it stops instead of yielding the rest. + it = smbclient.scandir(smb_share) + with it: + assert next(it).name in ("file1.txt", "file2.txt") + with pytest.raises(StopIteration): + next(it) + + +def test_scandir_iterator_contract(): + it = smbclient.SMBScandirIterator(x for x in ["a", "b"]) + with it as entered: + assert entered is it + assert next(it) == "a" + + # __exit__ closed the underlying generator, so iteration is exhausted. + with pytest.raises(StopIteration): + next(it) + + @pytest.mark.skipif( os.name != "nt" and not os.environ.get("SMB_FORCE", False), reason="cannot create symlinks on Samba" ) @@ -1981,6 +2027,19 @@ assert scanned_roots[src_dirname]["files"] == ["file.txt"] +def test_walk_closes_scandir_iterator_on_unhandled_exception(monkeypatch, stub_scandir_gen): + closes = [] + monkeypatch.setattr( + "smbclient._os._scandir", + lambda *a, **kw: stub_scandir_gen(closes, RuntimeError("simulated mid-iter failure")), + ) + + with pytest.raises(RuntimeError, match="simulated mid-iter failure"): + list(smbclient.walk(r"\\server\share\dir")) + + assert closes == [True] + + def test_xattr_file(smb_share): filename = "%s\\file.txt" % smb_share @@ -2170,3 +2229,14 @@ with pytest.raises(SMBOSError): smbclient.lstat(fake_file) + + +def test_open_file_missing_share_raises_os_error(smb_real): + # A missing share fails tree connect with STATUS_BAD_NETWORK_NAME, a non-OSError + # that must surface as SMBOSError so except OSError / ignore_errors callers catch it. + bad_path = rf"\\{smb_real[2]}\does_not_exist\file.txt" + + with pytest.raises(SMBOSError) as exc_info: + smbclient.open_file(bad_path, username=smb_real[0], password=smb_real[1], port=smb_real[3]) + + assert exc_info.value.filename == bad_path diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_smbclient_pool.py new/smbprotocol-1.17.0/tests/test_smbclient_pool.py --- old/smbprotocol-1.16.1/tests/test_smbclient_pool.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/test_smbclient_pool.py 2026-07-07 05:14:30.000000000 +0200 @@ -8,7 +8,13 @@ import smbclient._io as io import smbclient._pool as pool from smbprotocol.dfs import DFSReferralResponse -from smbprotocol.exceptions import BadNetworkName, InvalidParameter, ObjectPathNotFound +from smbprotocol.exceptions import ( + BadNetworkName, + InvalidParameter, + ObjectPathNotFound, + SMBOSError, +) +from smbprotocol.header import NtStatus from .conftest import DC_REFERRAL, DOMAIN_NAME, DOMAIN_REFERRAL @@ -150,3 +156,16 @@ with pytest.raises(ObjectPathNotFound): list(io._resolve_dfs(raw_io)) + + +def test_raw_io_translates_smb_response_to_os_error(mocker): + # Anything escaping get_smb_tree here must surface as OSError so the + # rmtree, remove, rmdir, and scandir error paths route it correctly. + mocker.patch.object(io, "get_smb_tree", side_effect=BadNetworkName()) + + path = r"\\server\share\missing" + with pytest.raises(SMBOSError) as exc_info: + io.SMBRawIO(path) + + assert exc_info.value.ntstatus == NtStatus.STATUS_BAD_NETWORK_NAME + assert exc_info.value.filename == path diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_smbclient_shutil.py new/smbprotocol-1.17.0/tests/test_smbclient_shutil.py --- old/smbprotocol-1.16.1/tests/test_smbclient_shutil.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/test_smbclient_shutil.py 2026-07-07 05:14:30.000000000 +0200 @@ -1583,3 +1583,54 @@ assert callback_args[0][0].__name__ == "islink" assert callback_args[0][1] == dst_dirname assert isinstance(callback_args[0][2][1], OSError) + + +def test_rmtree_islink_failure_invokes_onerror(mocker): + # islink issues SMB requests and can fail. Verify rmtree routes that failure + # through onerror so ignore_errors=True still suppresses it. + fake_path = r"\\server\share\dst" + + def _failing_islink(*args, **kwargs): + raise SMBOSError(NtStatus.STATUS_DELETE_PENDING, fake_path) + + _failing_islink.__name__ = "islink" + mocker.patch("smbclient.shutil.islink", new=_failing_islink) + + # ignore_errors=True path: must not raise, must return cleanly. + rmtree(fake_path, ignore_errors=True) + + # onerror path: callback receives (islink, path, exc_info). + callback_args = [] + rmtree(fake_path, onerror=lambda *args: callback_args.append(args)) + + assert len(callback_args) == 1 + assert callback_args[0][0].__name__ == "islink" + assert callback_args[0][1] == fake_path + assert isinstance(callback_args[0][2][1], SMBOSError) + + +def test_rmtree_closes_scandir_iterator_on_unhandled_exception(monkeypatch, stub_scandir_gen): + closes = [] + monkeypatch.setattr( + "smbclient._os._scandir", + lambda *a, **kw: stub_scandir_gen(closes, RuntimeError("simulated mid-iter failure")), + ) + monkeypatch.setattr("smbclient.shutil.islink", lambda *a, **kw: False) + + with pytest.raises(RuntimeError, match="simulated mid-iter failure"): + rmtree(r"\\server\share\dst") + + assert closes == [True] + + +def test_copytree_closes_scandir_iterator_on_unhandled_exception(monkeypatch, stub_scandir_gen): + closes = [] + monkeypatch.setattr( + "smbclient._os._scandir", + lambda *a, **kw: stub_scandir_gen(closes, RuntimeError("simulated mid-iter failure")), + ) + + with pytest.raises(RuntimeError, match="simulated mid-iter failure"): + copytree(r"\\server\share\src", r"\\server\share\dst") + + assert closes == [True] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/smbprotocol-1.16.1/tests/test_structure.py new/smbprotocol-1.17.0/tests/test_structure.py --- old/smbprotocol-1.16.1/tests/test_structure.py 2026-04-02 04:49:28.000000000 +0200 +++ new/smbprotocol-1.17.0/tests/test_structure.py 2026-07-07 05:14:30.000000000 +0200 @@ -1647,6 +1647,21 @@ field.set_value(0x00000082) assert str(exc.value) == "Invalid flag for field field value set 128" + def test_flag_str_ignores_dunder_attributes(self): + class MyClass: + __test__ = 3 + + FLAG1 = 1 + FLAG2 = 2 + + expected = "(3) FLAG1, FLAG2" + + field = FlagField(size=4, flag_type=MyClass) + field.set_value(3) + actual = str(field) + + assert actual == expected + class TestBoolField: class StructureTest(Structure):
