From c1fe23875b8649c062a7a9f0303eaa0be5e43691 Mon Sep 17 00:00:00 2001 From: lazerg Date: Tue, 29 Sep 2026 11:47:41 +0500 Subject: [PATCH] gh-158377: Restore unittest loader top-level dir when discovery fails --- Lib/test/test_unittest/test_discovery.py | 4 +- Lib/unittest/loader.py | 128 +++++++++--------- ...-09-29-06-45-40.gh-issue-158377.ZpHWrb.rst | 3 + 3 files changed, 70 insertions(+), 65 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-29-06-45-40.gh-issue-158377.ZpHWrb.rst diff --git a/Lib/test/test_unittest/test_discovery.py b/Lib/test/test_unittest/test_discovery.py index da184bd12be8d17..79111b35722a64a 100644 --- a/Lib/test/test_unittest/test_discovery.py +++ b/Lib/test/test_unittest/test_discovery.py @@ -386,7 +386,7 @@ def restore_path(): with self.assertRaises(ImportError): loader.discover('/foo/bar', top_level_dir='/foo') - self.assertEqual(loader._top_level_dir, full_path) + self.assertEqual(loader._top_level_dir, None) self.assertIn(full_path, sys.path) os.path.isfile = lambda path: True @@ -408,7 +408,7 @@ def _find_tests(start_dir, pattern, namespace=None): top_level_dir = os.path.abspath('/foo/bar') start_dir = os.path.abspath('/foo/bar/baz') self.assertEqual(suite, "['tests']") - self.assertEqual(loader._top_level_dir, os.path.abspath('/foo')) + self.assertEqual(loader._top_level_dir, None) self.assertEqual(_find_tests_args, [(start_dir, 'pattern')]) self.assertIn(top_level_dir, sys.path) diff --git a/Lib/unittest/loader.py b/Lib/unittest/loader.py index 697520246f0e3c6..98e005205729a52 100644 --- a/Lib/unittest/loader.py +++ b/Lib/unittest/loader.py @@ -278,73 +278,75 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None): # in sys.path to minimise likelihood of conflicts between installed # modules and development versions? sys.path.insert(0, top_level_dir) - self._top_level_dir = top_level_dir - - is_not_importable = False - is_namespace = False - tests = [] - if os.path.isdir(os.path.abspath(start_dir)): - start_dir = os.path.abspath(start_dir) - if start_dir != top_level_dir: - is_not_importable = not os.path.isfile(os.path.join(start_dir, '__init__.py')) - else: - # support for discovery from dotted module names - try: - __import__(start_dir) - except ImportError: - is_not_importable = True + try: + self._top_level_dir = top_level_dir + + is_not_importable = False + is_namespace = False + tests = [] + if os.path.isdir(os.path.abspath(start_dir)): + start_dir = os.path.abspath(start_dir) + if start_dir != top_level_dir: + is_not_importable = not os.path.isfile(os.path.join(start_dir, '__init__.py')) else: - the_module = sys.modules[start_dir] - if not hasattr(the_module, "__file__") or the_module.__file__ is None: - # look for namespace packages - try: - spec = the_module.__spec__ - except AttributeError: - spec = None - - if spec and spec.submodule_search_locations is not None: - is_namespace = True - - for path in the_module.__path__: - if (not set_implicit_top and - not path.startswith(top_level_dir)): - continue - self._top_level_dir = \ - (path.split(the_module.__name__ - .replace(".", os.path.sep))[0]) - tests.extend(self._find_tests(path, pattern, namespace=True)) - elif the_module.__name__ in sys.builtin_module_names: - # builtin module - raise TypeError('Can not use builtin modules ' - 'as dotted module names') from None - else: - raise TypeError( - f"don't know how to discover from {the_module!r}" - ) from None - + # support for discovery from dotted module names + try: + __import__(start_dir) + except ImportError: + is_not_importable = True else: - top_part = start_dir.split('.')[0] - start_dir = os.path.abspath(os.path.dirname((the_module.__file__))) - - if set_implicit_top: - if not is_namespace: - if sys.modules[top_part].__file__ is None: - self._top_level_dir = os.path.dirname(the_module.__file__) - if self._top_level_dir not in sys.path: - sys.path.insert(0, self._top_level_dir) + the_module = sys.modules[start_dir] + if not hasattr(the_module, "__file__") or the_module.__file__ is None: + # look for namespace packages + try: + spec = the_module.__spec__ + except AttributeError: + spec = None + + if spec and spec.submodule_search_locations is not None: + is_namespace = True + + for path in the_module.__path__: + if (not set_implicit_top and + not path.startswith(top_level_dir)): + continue + self._top_level_dir = \ + (path.split(the_module.__name__ + .replace(".", os.path.sep))[0]) + tests.extend(self._find_tests(path, pattern, namespace=True)) + elif the_module.__name__ in sys.builtin_module_names: + # builtin module + raise TypeError('Can not use builtin modules ' + 'as dotted module names') from None else: - self._top_level_dir = \ - self._get_directory_containing_module(top_part) - sys.path.remove(top_level_dir) - - if is_not_importable: - raise ImportError('Start directory is not importable: %r' % start_dir) + raise TypeError( + f"don't know how to discover from {the_module!r}" + ) from None - if not is_namespace: - tests = list(self._find_tests(start_dir, pattern)) - - self._top_level_dir = original_top_level_dir - return self.suiteClass(tests) + else: + top_part = start_dir.split('.')[0] + start_dir = os.path.abspath(os.path.dirname((the_module.__file__))) + + if set_implicit_top: + if not is_namespace: + if sys.modules[top_part].__file__ is None: + self._top_level_dir = os.path.dirname(the_module.__file__) + if self._top_level_dir not in sys.path: + sys.path.insert(0, self._top_level_dir) + else: + self._top_level_dir = \ + self._get_directory_containing_module(top_part) + sys.path.remove(top_level_dir) + + if is_not_importable: + raise ImportError('Start directory is not importable: %r' % start_dir) + + if not is_namespace: + tests = list(self._find_tests(start_dir, pattern)) + + return self.suiteClass(tests) + finally: + self._top_level_dir = original_top_level_dir def _get_directory_containing_module(self, module_name): module = sys.modules[module_name] diff --git a/Misc/NEWS.d/next/Library/2026-09-29-06-45-40.gh-issue-158377.ZpHWrb.rst b/Misc/NEWS.d/next/Library/2026-09-29-06-45-40.gh-issue-158377.ZpHWrb.rst new file mode 100644 index 000000000000000..efafde1750f87d4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-29-06-45-40.gh-issue-158377.ZpHWrb.rst @@ -0,0 +1,3 @@ +:meth:`unittest.TestLoader.discover` now restores the loader's top-level +directory when discovery fails, so a later call on the same loader is not +affected.