From 7571f1181a5a8a679f161b94368a3859f896dff2 Mon Sep 17 00:00:00 2001 From: Aniket Singh Yadav Date: Fri, 19 Jun 2026 13:08:32 +0000 Subject: [PATCH 1/3] Make os.walk() and os.fwalk() raise NotADirectoryError for non-directory top --- Lib/os.py | 15 +++++++++++++-- Lib/test/test_os/test_os.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Lib/os.py b/Lib/os.py index a5e1d8055569988..739292b60a783b0 100644 --- a/Lib/os.py +++ b/Lib/os.py @@ -374,6 +374,14 @@ def walk(top, topdown=True, onerror=None, followlinks=False): """ sys.audit("os.walk", top, topdown, onerror, followlinks) + try: + top_stat = stat(fspath(top)) + except OSError: + pass # non-existing: fall through, scandir will handle it via onerror + else: + if not path.isdir(fspath(top)) and not path.islink(fspath(top)): + raise NotADirectoryError(20, "Not a directory", top) + stack = [fspath(top)] islink, join = path.islink, path.join while stack: @@ -540,11 +548,14 @@ def _fwalk(stack, isbytes, topdown, onerror, follow_symlinks): stack.append((_fwalk_close, topfd)) if not follow_symlinks: if isroot and not st.S_ISDIR(orig_st.st_mode): - return + raise NotADirectoryError(20, "Not a directory", toppath) if not path.samestat(orig_st, stat(topfd)): return - scandir_it = scandir(topfd) + try: + scandir_it = scandir(topfd) + except NotADirectoryError: + raise NotADirectoryError(20, "Not a directory", toppath) dirs = [] nondirs = [] entries = None if topdown or follow_symlinks else [] diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index 68cb32cd40be306..d8cdeef0c77b6f5 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -1981,6 +1981,13 @@ def test_walk_above_recursion_limit(self): self.assertEqual(sorted(dirs), ["SUB1", "SUB2", "d"]) self.assertEqual(all, expected) + def test_walk_on_file_raises_not_a_directory(self): + # gh-101420: os.walk() was silently returning [] when top is a + # regular file instead of raising NotADirectoryError. + with tempfile.NamedTemporaryFile() as f: + with self.assertRaises(NotADirectoryError): + list(os.walk(f.name)) + @unittest.skipUnless(hasattr(os, 'fwalk'), "Test needs os.fwalk()") class FwalkTests(WalkTests): @@ -2038,6 +2045,19 @@ def test_yields_correct_dir_fd(self): # check that listdir() returns consistent information self.assertEqual(set(os.listdir(rootfd)), set(dirs) | set(files)) + def test_fwalk_on_file_raises_not_a_directory(self): + with tempfile.NamedTemporaryFile() as f: + with self.assertRaises(NotADirectoryError): + list(os.fwalk(f.name, follow_symlinks=False)) + + # follow_symlinks=True: raised but with fd int as filename, must now have path + with self.assertRaises(NotADirectoryError) as ctx: + list(os.fwalk(f.name, follow_symlinks=True)) + self.assertEqual( + ctx.exception.filename, f.name, + "filename should be the path string, not a raw fd integer" + ) + @unittest.skipIf( support.is_android, "dup return value is unpredictable on Android" ) From c8ac2d83d3d337ec925b9d52dc2edc6075f8dea8 Mon Sep 17 00:00:00 2001 From: Aniket Singh Yadav Date: Sat, 20 Jun 2026 12:32:45 +0000 Subject: [PATCH 2/3] update tests to raise NotADirectoryError --- Lib/test/test_os/test_os.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index d8cdeef0c77b6f5..a6df04cb131e625 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -1863,14 +1863,10 @@ def test_walk_bad_dir2(self): self.assertRaises(StopIteration, next, walk_it) walk_it = self.walk(self.tmp1_path) - self.assertRaises(StopIteration, next, walk_it) + self.assertRaises(NotADirectoryError, next, walk_it) walk_it = self.walk(self.tmp1_path, follow_symlinks=True) - if self.is_fwalk: - with self.assertRaises(OSError) as cm: - next(walk_it) - self.assertIn(cm.exception.errno, (errno.ENOTDIR, errno.EINVAL)) - self.assertRaises(StopIteration, next, walk_it) + self.assertRaises(NotADirectoryError, next, walk_it) @unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()') @unittest.skipIf(sys.platform == "vxworks", @@ -1881,12 +1877,10 @@ def test_walk_named_pipe(self): self.addCleanup(os.unlink, path) walk_it = self.walk(path) - self.assertRaises(StopIteration, next, walk_it) + self.assertRaises(NotADirectoryError, next, walk_it) walk_it = self.walk(path, follow_symlinks=True) - if self.is_fwalk: - self.assertRaises(NotADirectoryError, next, walk_it) - self.assertRaises(StopIteration, next, walk_it) + self.assertRaises(NotADirectoryError, next, walk_it) @unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()') @unittest.skipIf(sys.platform == "vxworks", From e0736accaff39b120ddceadb99a60427f227c0be Mon Sep 17 00:00:00 2001 From: Aniket Singh Yadav Date: Thu, 25 Jun 2026 20:20:23 +0000 Subject: [PATCH 3/3] Handle NotADirectoryError in shutil._rmtree_unsafe and pathlib.Path.walk when os.walk raises early --- Lib/pathlib/__init__.py | 16 +++++++++++----- Lib/shutil.py | 39 +++++++++++++++++++++------------------ 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index ffec9c545ee11f8..86d4581891ef9a8 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -1086,11 +1086,17 @@ def walk(self, top_down=True, on_error=None, follow_symlinks=False): root_dir = str(self) if not follow_symlinks: follow_symlinks = os._walk_symlinks_as_files - results = os.walk(root_dir, top_down, on_error, follow_symlinks) - for path_str, dirnames, filenames in results: - if root_dir == '.': - path_str = path_str[2:] - yield self._from_parsed_string(path_str), dirnames, filenames + try: + results = os.walk(root_dir, top_down, on_error, follow_symlinks) + for path_str, dirnames, filenames in results: + if root_dir == '.': + path_str = path_str[2:] + yield self._from_parsed_string(path_str), dirnames, filenames + except OSError as err: + if on_error is not None: + on_error(err) + else: + raise def absolute(self): """Return an absolute version of this path diff --git a/Lib/shutil.py b/Lib/shutil.py index c8d02bbaeb80b4e..598b6e351a28f3d 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -685,24 +685,27 @@ def _rmtree_unsafe(path, dir_fd, onexc): def onerror(err): if not isinstance(err, FileNotFoundError): onexc(os.scandir, err.filename, err) - results = os.walk(path, topdown=False, onerror=onerror, followlinks=os._walk_symlinks_as_files) - for dirpath, dirnames, filenames in results: - for name in dirnames: - fullname = os.path.join(dirpath, name) - try: - os.rmdir(fullname) - except FileNotFoundError: - continue - except OSError as err: - onexc(os.rmdir, fullname, err) - for name in filenames: - fullname = os.path.join(dirpath, name) - try: - os.unlink(fullname) - except FileNotFoundError: - continue - except OSError as err: - onexc(os.unlink, fullname, err) + try: + results = os.walk(path, topdown=False, onerror=onerror, followlinks=os._walk_symlinks_as_files) + for dirpath, dirnames, filenames in results: + for name in dirnames: + fullname = os.path.join(dirpath, name) + try: + os.rmdir(fullname) + except FileNotFoundError: + continue + except OSError as err: + onexc(os.rmdir, fullname, err) + for name in filenames: + fullname = os.path.join(dirpath, name) + try: + os.unlink(fullname) + except FileNotFoundError: + continue + except OSError as err: + onexc(os.unlink, fullname, err) + except OSError as err: + onexc(os.walk, path, err) try: os.rmdir(path) except FileNotFoundError: