From e463e5bf83a9974fe315dd3648ee6c1d7ea64cde Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Mon, 7 Sep 2026 23:31:26 +0000 Subject: [PATCH 1/3] gh-157144: Let regrtest find tests that are not on the file system When the standard library is a zip archive on sys.path, the test package is not a directory: findtests() failed with NotADirectoryError from os.listdir() and load_package_tests() failed with "Start directory is not importable" from unittest discovery. Fall back to listing the test modules with pkgutil.iter_modules() through the import system. --- Lib/test/libregrtest/findtests.py | 13 +++- Lib/test/support/__init__.py | 67 ++++++++++++++++++- Lib/test/test_regrtest.py | 26 ++++++- Lib/test/test_support.py | 46 +++++++++++++ ...-09-07-23-40-00.gh-issue-157144.rgzip1.rst | 3 + 5 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst diff --git a/Lib/test/libregrtest/findtests.py b/Lib/test/libregrtest/findtests.py index e7692c5156812e4..2d5830dc405811b 100644 --- a/Lib/test/libregrtest/findtests.py +++ b/Lib/test/libregrtest/findtests.py @@ -37,13 +37,24 @@ def findtestdir(path: StrPath | None = None) -> StrPath: return path or os.path.dirname(os.path.dirname(__file__)) or os.curdir +def _listdir(testdir: StrPath) -> list[str]: + try: + return os.listdir(testdir) + except NotADirectoryError: + # The test package is not on the file system, e.g. it is inside a + # zip archive: ask the import system instead. + import pkgutil + return [name if ispkg else f"{name}.py" + for _, name, ispkg in pkgutil.iter_modules([testdir])] + + def findtests(*, testdir: StrPath | None = None, exclude: Container[str] = (), split_test_dirs: set[TestName] = SPLITTESTDIRS, base_mod: str = "") -> TestList: """Return a list of all applicable test modules.""" testdir = findtestdir(testdir) tests = [] - for name in os.listdir(testdir): + for name in _listdir(testdir): mod, ext = os.path.splitext(name) if (not mod.startswith("test_")) or (mod in exclude): continue diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 28a0ba6c666629b..38ad96a2e1618f9 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -173,13 +173,74 @@ def load_tests(*args): if pattern is None: pattern = "test*" top_dir = STDLIB_DIR - package_tests = loader.discover(start_dir=pkg_dir, - top_level_dir=top_dir, - pattern=pattern) + if not os.path.isdir(pkg_dir): + # The test package is not on the file system, e.g. it is inside a + # zip archive: unittest cannot discover it, but the import system + # can still list its modules. + package_tests = _load_package_tests_from_import_system( + pkg_dir, loader, pattern) + else: + package_tests = loader.discover(start_dir=pkg_dir, + top_level_dir=top_dir, + pattern=pattern) standard_tests.addTests(package_tests) return standard_tests +def _get_package_name(pkg_dir): + """Return the dotted name of the package located in *pkg_dir*.""" + # The package calling load_package_tests() has already been imported. + for name, module in list(sys.modules.items()): + try: + paths = list(module.__path__) + except Exception: + continue + if pkg_dir in paths: + return name + # Fall back to the location of the package in the standard library. + return os.path.relpath(pkg_dir, STDLIB_DIR).replace(os.sep, '.') + + +def _load_package_tests_from_import_system(pkg_dir, loader, pattern, + package=None): + """Load the tests of a package which is not on the file system. + + Mimic unittest discovery for a package that unittest cannot walk, + e.g. a package inside a zip archive, by asking the import system for + its modules. + """ + import fnmatch + import importlib + import pkgutil + from unittest.loader import _make_failed_import_test, _make_skipped_test + + if package is None: + package = _get_package_name(pkg_dir) + tests = [] + for _, name, ispkg in pkgutil.iter_modules([pkg_dir]): + if not ispkg and not fnmatch.fnmatch(f"{name}.py", pattern): + continue + fullname = f"{package}.{name}" + try: + module = importlib.import_module(fullname) + except unittest.SkipTest as exc: + tests.append(_make_skipped_test(fullname, exc, loader.suiteClass)) + continue + except Exception: + error_case, error_message = _make_failed_import_test( + fullname, loader.suiteClass) + loader.errors.append(error_message) + tests.append(error_case) + continue + tests.append(loader.loadTestsFromModule(module, pattern=pattern)) + if ispkg and getattr(module, 'load_tests', None) is None: + # Like unittest discovery, recurse into a sub-package which + # does not use the load_tests protocol. + tests.extend(_load_package_tests_from_import_system( + os.path.join(pkg_dir, name), loader, pattern, fullname)) + return tests + + def get_attribute(obj, name): """Get an attribute, raising SkipTest if AttributeError is raised.""" try: diff --git a/Lib/test/test_regrtest.py b/Lib/test/test_regrtest.py index c966f8659e2abb0..ec05023f7d8de4c 100644 --- a/Lib/test/test_regrtest.py +++ b/Lib/test/test_regrtest.py @@ -24,9 +24,11 @@ import textwrap import unittest import unittest.mock +import zipfile +import zipimport from xml.etree import ElementTree -from test.libregrtest.findtests import collect_cases +from test.libregrtest.findtests import collect_cases, findtests from test.libregrtest.filter import set_match_tests from test.libregrtest.run_workers import MultiprocessIterator from test import support @@ -2638,6 +2640,28 @@ def test_collect_cases_skiptest(self): self.assertEqual(cases_by_module, {}) self.assertIn(testname, skipped) + def test_findtests_not_on_file_system(self): + # gh-157144: the test directory may not be on the file system, + # e.g. the standard library is a zip archive on sys.path. + zip_path = os.path.join(self.tmptestdir, 'tests.zip') + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.writestr('tests/test_a.py', '') + zf.writestr('tests/test_b.py', '') + zf.writestr('tests/not_a_test.py', '') + zf.writestr('tests/test_data.txt', '') + zf.writestr('tests/test_pkg/__init__.py', '') + zf.writestr('tests/test_pkg/test_c.py', '') + testdir = os.path.join(zip_path, 'tests') + self.addCleanup(zipimport._zip_directory_cache.pop, zip_path, None) + for path in (testdir, os.path.join(testdir, 'test_pkg')): + self.addCleanup(sys.path_importer_cache.pop, path, None) + + tests = findtests(testdir=testdir) + self.assertEqual(tests, ['test_a', 'test_b', 'test_pkg']) + + tests = findtests(testdir=testdir, split_test_dirs={'test_pkg'}) + self.assertEqual(tests, ['test.test_pkg.test_c', 'test_a', 'test_b']) + class MultiprocessIteratorTestCase(unittest.TestCase): def test_yields_all_groups_once(self): diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 243da190e48f5d2..7d1e800706ba3e4 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -17,6 +17,8 @@ import textwrap import unittest import warnings +import zipfile +import zipimport from test import support from test.support import isolation @@ -381,6 +383,50 @@ def test_DirsOnSysPath(self): self.assertNotIn("foo", sys.path) self.assertNotIn("bar", sys.path) + def test_load_package_tests_not_on_file_system(self): + # gh-157144: load_package_tests() loads the tests of a package + # which is not on the file system, e.g. inside a zip archive. + package = textwrap.dedent(''' + import os + from test.support import load_package_tests + + def load_tests(*args): + return load_package_tests(os.path.dirname(__file__), *args) + ''') + test_module = textwrap.dedent(''' + import unittest + + class Tests(unittest.TestCase): + def test_zip(self): + pass + ''') + tmpdir = self.enterContext(os_helper.temp_dir()) + zip_path = os.path.join(tmpdir, 'zpkg.zip') + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.writestr('zpkg/__init__.py', package) + zf.writestr('zpkg/test_x.py', test_module) + # Does not match the default "test*" pattern. + zf.writestr('zpkg/other.py', test_module) + self.enterContext(import_helper.DirsOnSysPath(zip_path)) + self.addCleanup(zipimport._zip_directory_cache.pop, zip_path, None) + for path in (zip_path, os.path.join(zip_path, 'zpkg')): + self.addCleanup(sys.path_importer_cache.pop, path, None) + for name in ('zpkg', 'zpkg.test_x', 'zpkg.other'): + self.addCleanup(import_helper.unload, name) + + zpkg = importlib.import_module('zpkg') + loader = unittest.TestLoader() + suite = loader.loadTestsFromModule(zpkg) + self.assertEqual(loader.errors, []) + self.assertEqual(suite.countTestCases(), 1) + self.assertIn('zpkg.test_x', sys.modules) + self.assertNotIn('zpkg.other', sys.modules) + + result = unittest.TestResult() + suite.run(result) + self.assertEqual(result.testsRun, 1) + self.assertTrue(result.wasSuccessful(), result.errors) + def test_captured_stdout(self): with support.captured_stdout() as stdout: print("hello") diff --git a/Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst b/Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst new file mode 100644 index 000000000000000..d728fa8799d8422 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst @@ -0,0 +1,3 @@ +The test suite can now enumerate test modules and test packages that are not +on the file system, such as a standard library inside a zip archive on +:data:`sys.path`. From 20afacb8277f2831f21253cfcc197427ef62e9c8 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Tue, 8 Sep 2026 01:44:57 +0000 Subject: [PATCH 2/3] gh-157144: Support archives in unittest discovery instead of in regrtest Make unittest.TestLoader.discover() work when the start directory is inside an archive on sys.path, such as a zip file: the archive's path entry finder lists the entries through pkgutil.iter_modules() and tells modules and packages apart, and the rest of discovery (module naming, importing, the load_tests protocol) already works on such paths. With that, test.support.load_package_tests() needs no special case and goes back to plain discovery. libregrtest's findtests() keeps a small fallback for listing the top level test directory, now keyed on os.path.isdir() rather than on the exception os.listdir() raises, which is NotADirectoryError on POSIX but FileNotFoundError on Windows. --- Doc/library/unittest.rst | 4 + Lib/test/libregrtest/findtests.py | 14 +-- Lib/test/support/__init__.py | 67 +---------- Lib/test/test_support.py | 4 + Lib/test/test_unittest/test_discovery.py | 107 ++++++++++++++++++ Lib/unittest/loader.py | 77 ++++++++++--- ...-09-08-00-30-00.gh-issue-157144.udzip1.rst | 3 + 7 files changed, 192 insertions(+), 84 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-08-00-30-00.gh-issue-157144.udzip1.rst diff --git a/Doc/library/unittest.rst b/Doc/library/unittest.rst index e1bc32fb79c2eb1..9bd13a1df31f329 100644 --- a/Doc/library/unittest.rst +++ b/Doc/library/unittest.rst @@ -2027,6 +2027,10 @@ Loading and running tests .. versionchanged:: 3.14 *start_dir* can once again be a :term:`namespace package`. + .. versionchanged:: next + *start_dir* can be inside an archive on :data:`sys.path`, such as + a zip file, whose path entry finder lists its contents. + The following attributes of a :class:`TestLoader` can be configured either by subclassing or assignment on an instance: diff --git a/Lib/test/libregrtest/findtests.py b/Lib/test/libregrtest/findtests.py index 2d5830dc405811b..ec6777b8d7be192 100644 --- a/Lib/test/libregrtest/findtests.py +++ b/Lib/test/libregrtest/findtests.py @@ -38,14 +38,14 @@ def findtestdir(path: StrPath | None = None) -> StrPath: def _listdir(testdir: StrPath) -> list[str]: - try: - return os.listdir(testdir) - except NotADirectoryError: - # The test package is not on the file system, e.g. it is inside a - # zip archive: ask the import system instead. + if not os.path.isdir(testdir): + # The test package may be inside an archive on sys.path, such as a + # zip file, which the import system can still list. import pkgutil - return [name if ispkg else f"{name}.py" - for _, name, ispkg in pkgutil.iter_modules([testdir])] + if pkgutil.get_importer(testdir) is not None: + return [name if ispkg else f"{name}.py" + for _, name, ispkg in pkgutil.iter_modules([testdir])] + return os.listdir(testdir) def findtests(*, testdir: StrPath | None = None, exclude: Container[str] = (), diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 38ad96a2e1618f9..28a0ba6c666629b 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -173,74 +173,13 @@ def load_tests(*args): if pattern is None: pattern = "test*" top_dir = STDLIB_DIR - if not os.path.isdir(pkg_dir): - # The test package is not on the file system, e.g. it is inside a - # zip archive: unittest cannot discover it, but the import system - # can still list its modules. - package_tests = _load_package_tests_from_import_system( - pkg_dir, loader, pattern) - else: - package_tests = loader.discover(start_dir=pkg_dir, - top_level_dir=top_dir, - pattern=pattern) + package_tests = loader.discover(start_dir=pkg_dir, + top_level_dir=top_dir, + pattern=pattern) standard_tests.addTests(package_tests) return standard_tests -def _get_package_name(pkg_dir): - """Return the dotted name of the package located in *pkg_dir*.""" - # The package calling load_package_tests() has already been imported. - for name, module in list(sys.modules.items()): - try: - paths = list(module.__path__) - except Exception: - continue - if pkg_dir in paths: - return name - # Fall back to the location of the package in the standard library. - return os.path.relpath(pkg_dir, STDLIB_DIR).replace(os.sep, '.') - - -def _load_package_tests_from_import_system(pkg_dir, loader, pattern, - package=None): - """Load the tests of a package which is not on the file system. - - Mimic unittest discovery for a package that unittest cannot walk, - e.g. a package inside a zip archive, by asking the import system for - its modules. - """ - import fnmatch - import importlib - import pkgutil - from unittest.loader import _make_failed_import_test, _make_skipped_test - - if package is None: - package = _get_package_name(pkg_dir) - tests = [] - for _, name, ispkg in pkgutil.iter_modules([pkg_dir]): - if not ispkg and not fnmatch.fnmatch(f"{name}.py", pattern): - continue - fullname = f"{package}.{name}" - try: - module = importlib.import_module(fullname) - except unittest.SkipTest as exc: - tests.append(_make_skipped_test(fullname, exc, loader.suiteClass)) - continue - except Exception: - error_case, error_message = _make_failed_import_test( - fullname, loader.suiteClass) - loader.errors.append(error_message) - tests.append(error_case) - continue - tests.append(loader.loadTestsFromModule(module, pattern=pattern)) - if ispkg and getattr(module, 'load_tests', None) is None: - # Like unittest discovery, recurse into a sub-package which - # does not use the load_tests protocol. - tests.extend(_load_package_tests_from_import_system( - os.path.join(pkg_dir, name), loader, pattern, fullname)) - return tests - - def get_attribute(obj, name): """Get an attribute, raising SkipTest if AttributeError is raised.""" try: diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 7d1e800706ba3e4..9f7e4ca52bb92ff 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -16,6 +16,7 @@ import tempfile import textwrap import unittest +import unittest.mock import warnings import zipfile import zipimport @@ -408,6 +409,9 @@ def test_zip(self): # Does not match the default "test*" pattern. zf.writestr('zpkg/other.py', test_module) self.enterContext(import_helper.DirsOnSysPath(zip_path)) + # As when the standard library itself is a zip archive. + self.enterContext(unittest.mock.patch.object( + support, 'STDLIB_DIR', zip_path)) self.addCleanup(zipimport._zip_directory_cache.pop, zip_path, None) for path in (zip_path, os.path.join(zip_path, 'zpkg')): self.addCleanup(sys.path_importer_cache.pop, path, None) diff --git a/Lib/test/test_unittest/test_discovery.py b/Lib/test/test_unittest/test_discovery.py index da184bd12be8d17..13fe23754fe3017 100644 --- a/Lib/test/test_unittest/test_discovery.py +++ b/Lib/test/test_unittest/test_discovery.py @@ -7,9 +7,12 @@ from importlib._bootstrap_external import NamespaceLoader from test import support from test.support import import_helper +from test.support import os_helper import unittest import unittest.mock +import zipfile +import zipimport import test.test_unittest from test.test_importlib import util as test_util @@ -920,5 +923,109 @@ def _import(packagename, *args, **kwargs): .format(package)) +class TestDiscoveryInArchive(unittest.TestCase): + """Discovery of tests inside a zip archive on sys.path (gh-157144).""" + + TEST_MODULE = """\ +import unittest + +class Test%s(unittest.TestCase): + def test_%s(self): + pass +""" + + def setUp(self): + self.tmpdir = self.enterContext(os_helper.temp_dir()) + self.zip_path = os.path.join(self.tmpdir, 'tests.zip') + with zipfile.ZipFile(self.zip_path, 'w') as zf: + zf.writestr('zpkg/__init__.py', '') + zf.writestr('zpkg/test_a.py', self.TEST_MODULE % ('A', 'a')) + zf.writestr('zpkg/test_b.py', self.TEST_MODULE % ('B', 'b')) + # Does not match the pattern. + zf.writestr('zpkg/other.py', self.TEST_MODULE % ('X', 'x')) + # A sub-package is recursed into. + zf.writestr('zpkg/sub/__init__.py', '') + zf.writestr('zpkg/sub/test_c.py', self.TEST_MODULE % ('C', 'c')) + # A sub-package with load_tests() is not recursed into. + zf.writestr('zpkg/loaded/__init__.py', + 'def load_tests(loader, tests, pattern):\n' + ' return tests\n') + zf.writestr('zpkg/loaded/test_d.py', self.TEST_MODULE % ('D', 'd')) + # A directory which is not a package is ignored. + zf.writestr('zpkg/data/test_e.py', self.TEST_MODULE % ('E', 'e')) + self.enterContext(import_helper.DirsOnSysPath()) + self.addCleanup(self.forget_archive) + + def forget_archive(self): + for name in list(sys.modules): + if name == 'zpkg' or name.startswith('zpkg.'): + del sys.modules[name] + for path in list(sys.path_importer_cache): + if path.startswith(self.zip_path): + del sys.path_importer_cache[path] + zipimport._zip_directory_cache.pop(self.zip_path, None) + + def discover(self, start_dir, **kwargs): + loader = unittest.TestLoader() + suite = loader.discover(start_dir, **kwargs) + self.assertEqual(loader.errors, []) + return suite + + def suite_ids(self, suite): + ids = [] + for test in suite: + if isinstance(test, unittest.TestSuite): + ids.extend(self.suite_ids(test)) + else: + ids.append(test.id()) + return sorted(ids) + + def test_discover_package_in_archive(self): + suite = self.discover(os.path.join(self.zip_path, 'zpkg'), + top_level_dir=self.zip_path) + self.assertEqual(self.suite_ids(suite), [ + 'zpkg.sub.test_c.TestC.test_c', + 'zpkg.test_a.TestA.test_a', + 'zpkg.test_b.TestB.test_b', + ]) + self.assertNotIn('zpkg.other', sys.modules) + self.assertNotIn('zpkg.loaded.test_d', sys.modules) + result = unittest.TestResult() + suite.run(result) + self.assertEqual(result.testsRun, 3) + self.assertTrue(result.wasSuccessful(), result.errors) + + def test_discover_archive_root(self): + suite = self.discover(self.zip_path) + self.assertEqual(self.suite_ids(suite), [ + 'zpkg.sub.test_c.TestC.test_c', + 'zpkg.test_a.TestA.test_a', + 'zpkg.test_b.TestB.test_b', + ]) + + def test_discover_pattern(self): + suite = self.discover(os.path.join(self.zip_path, 'zpkg'), + pattern='test_[ac]*', + top_level_dir=self.zip_path) + self.assertEqual(self.suite_ids(suite), [ + 'zpkg.sub.test_c.TestC.test_c', + 'zpkg.test_a.TestA.test_a', + ]) + + def test_discover_from_dotted_name_in_archive(self): + sys.path.insert(0, self.zip_path) + suite = self.discover('zpkg.sub') + self.assertEqual(self.suite_ids(suite), + ['zpkg.sub.test_c.TestC.test_c']) + + def test_discover_not_a_package(self): + loader = unittest.TestLoader() + for start_dir in ('zpkg/data', 'zpkg/nonexistent'): + with self.subTest(start_dir=start_dir): + with self.assertRaisesRegex(ImportError, 'not importable'): + loader.discover(os.path.join(self.zip_path, start_dir), + top_level_dir=self.zip_path) + + if __name__ == '__main__': unittest.main() diff --git a/Lib/unittest/loader.py b/Lib/unittest/loader.py index 697520246f0e3c6..995f3be18e32059 100644 --- a/Lib/unittest/loader.py +++ b/Lib/unittest/loader.py @@ -2,6 +2,7 @@ import inspect import os +import pkgutil import re import sys import traceback @@ -61,6 +62,33 @@ def _splitext(path): return os.path.splitext(path)[0] +def _list_archive_dir(path): + """List the modules in *path* when it is not a directory on disk. + + A test package can live inside an archive on sys.path, such as a zip + file, where os.listdir() does not work but the path entry finder for + the archive still knows the contents (see pkgutil.iter_modules()). + Return a sorted list of (name, is_package) pairs, or None if *path* is + a directory on the file system or nothing can import from it. + """ + if os.path.isdir(path): + return None + # Probe the path hooks directly rather than with pkgutil.get_importer(), + # which would cache a negative result in sys.path_importer_cache and so + # hide a directory created at that path later on. + if sys.path_importer_cache.get(path) is None: + for hook in sys.path_hooks: + try: + hook(path) + except ImportError: + continue + break + else: + return None + return sorted((name, ispkg) + for _, name, ispkg in pkgutil.iter_modules([path])) + + class TestLoader(object): """ This class is responsible for loading tests according to various criteria @@ -241,7 +269,8 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None): All test modules must be importable from the top level of the project. If the start directory is not the top level directory then the top - level directory must be specified separately. + level directory must be specified separately. The start directory + may also be inside an archive on sys.path, such as a zip file. If a test package name (directory with '__init__.py') matches the pattern then the package will be checked for a 'load_tests' function. If @@ -287,6 +316,13 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None): 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')) + elif _list_archive_dir(os.path.abspath(start_dir)) is not None: + # a directory inside an archive on sys.path, such as a zip file + start_dir = os.path.abspath(start_dir) + if start_dir != top_level_dir: + parent, name = os.path.split(start_dir) + is_not_importable = ( + (name, True) not in (_list_archive_dir(parent) or ())) else: # support for discovery from dotted module names try: @@ -380,6 +416,9 @@ def _match_path(self, path, full_path, pattern): def _find_tests(self, start_dir, pattern, namespace=False): """Used by discovery. Yields test suites it loads.""" + # Inside an archive the import system lists the entries and tells + # modules and packages apart; on disk the file system does. + entries = _list_archive_dir(start_dir) # Handle the __init__ in this package name = self._get_name_from_path(start_dir) # name is '.' when start_dir == top_level_dir (and top_level_dir is by @@ -388,7 +427,8 @@ def _find_tests(self, start_dir, pattern, namespace=False): # name is in self._loading_packages while we have called into # loadTestsFromModule with name. tests, should_recurse = self._find_test_path( - start_dir, pattern, namespace) + start_dir, pattern, namespace, + kind=None if entries is None else 'package') if tests is not None: yield tests if not should_recurse: @@ -396,11 +436,16 @@ def _find_tests(self, start_dir, pattern, namespace=False): # package. return # Handle the contents. - paths = sorted(os.listdir(start_dir)) - for path in paths: + if entries is None: + paths = [(path, None) for path in sorted(os.listdir(start_dir))] + else: + paths = [(name if ispkg else f'{name}.py', + 'package' if ispkg else 'module') + for name, ispkg in entries] + for path, kind in paths: full_path = os.path.join(start_dir, path) tests, should_recurse = self._find_test_path( - full_path, pattern, False) + full_path, pattern, False, kind=kind) if tests is not None: yield tests if should_recurse: @@ -412,16 +457,26 @@ def _find_tests(self, start_dir, pattern, namespace=False): finally: self._loading_packages.discard(name) - def _find_test_path(self, full_path, pattern, namespace=False): + def _find_test_path(self, full_path, pattern, namespace=False, + kind=None): """Used by discovery. Loads tests from a single file, or a directories' __init__.py when - passed the directory. + passed the directory. *kind* is 'module' or 'package' for an entry + inside an archive, or None to consult the file system. Returns a tuple (None_or_tests_from_file, should_recurse). """ basename = os.path.basename(full_path) - if os.path.isfile(full_path): + if kind is None: + if os.path.isfile(full_path): + kind = 'module' + elif os.path.isdir(full_path): + init = os.path.join(full_path, '__init__.py') + if not namespace and not os.path.isfile(init): + return None, False + kind = 'package' + if kind == 'module': if not VALID_MODULE_NAME.match(basename): # valid Python identifiers only return None, False @@ -455,11 +510,7 @@ def _find_test_path(self, full_path, pattern, namespace=False): raise ImportError( msg % (mod_name, module_dir, expected_dir)) return self.loadTestsFromModule(module, pattern=pattern), False - elif os.path.isdir(full_path): - if (not namespace and - not os.path.isfile(os.path.join(full_path, '__init__.py'))): - return None, False - + elif kind == 'package': load_tests = None tests = None name = self._get_name_from_path(full_path) diff --git a/Misc/NEWS.d/next/Library/2026-09-08-00-30-00.gh-issue-157144.udzip1.rst b/Misc/NEWS.d/next/Library/2026-09-08-00-30-00.gh-issue-157144.udzip1.rst new file mode 100644 index 000000000000000..7016a425833779e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-08-00-30-00.gh-issue-157144.udzip1.rst @@ -0,0 +1,3 @@ +:meth:`unittest.TestLoader.discover` can now discover tests inside an +archive on :data:`sys.path`, such as a zip file, by listing it through the +archive's path entry finder. From d3f5935ca004a9d18ebd24748755731e760342e2 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Tue, 8 Sep 2026 02:15:37 +0000 Subject: [PATCH 3/3] gh-157144: Name the entry kinds in unittest discovery Address review: describe what _find_tests() hands _find_test_path() with module-level constants (_FILESYSTEM_ENTRY, _FILESYSTEM_MODULE, _FILESYSTEM_PACKAGE, _ARCHIVE_MODULE, _ARCHIVE_PACKAGE) instead of inline strings and None, and split the archive helper into a predicate and a listing function so that neither returns None to mean anything. Drop the regrtest NEWS entry; the unittest one covers the change. --- Lib/unittest/loader.py | 97 +++++++++++-------- ...-09-07-23-40-00.gh-issue-157144.rgzip1.rst | 3 - 2 files changed, 58 insertions(+), 42 deletions(-) delete mode 100644 Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst diff --git a/Lib/unittest/loader.py b/Lib/unittest/loader.py index 995f3be18e32059..8582878b153e4a5 100644 --- a/Lib/unittest/loader.py +++ b/Lib/unittest/loader.py @@ -62,29 +62,46 @@ def _splitext(path): return os.path.splitext(path)[0] +# What _find_tests() asks _find_test_path() to load: an entry on the file +# system, which _find_test_path() classifies itself, or a module or package +# inside an archive on sys.path (a zip file, say), which the archive's path +# entry finder has already classified. +_FILESYSTEM_ENTRY = 'filesystem entry' +_FILESYSTEM_MODULE = 'filesystem module' +_FILESYSTEM_PACKAGE = 'filesystem package' +_ARCHIVE_MODULE = 'archive module' +_ARCHIVE_PACKAGE = 'archive package' + + +def _is_archive_dir(path): + """Is *path* a directory inside an archive on sys.path? + + A test package can live inside an archive such as a zip file, where + os.listdir() does not work but the path entry finder for the archive + still knows the contents. The path hooks are probed directly rather + than with pkgutil.get_importer(), which would cache a negative result + in sys.path_importer_cache and so hide a directory created at that + path later on. + """ + if os.path.isdir(path): + return False + if sys.path_importer_cache.get(path) is not None: + return True + for hook in sys.path_hooks: + try: + hook(path) + except ImportError: + continue + return True + return False + + def _list_archive_dir(path): - """List the modules in *path* when it is not a directory on disk. + """The sorted (name, is_package) pairs of the modules in *path*. - A test package can live inside an archive on sys.path, such as a zip - file, where os.listdir() does not work but the path entry finder for - the archive still knows the contents (see pkgutil.iter_modules()). - Return a sorted list of (name, is_package) pairs, or None if *path* is - a directory on the file system or nothing can import from it. + *path* is a directory inside an archive on sys.path; its path entry + finder lists it (see pkgutil.iter_modules()). """ - if os.path.isdir(path): - return None - # Probe the path hooks directly rather than with pkgutil.get_importer(), - # which would cache a negative result in sys.path_importer_cache and so - # hide a directory created at that path later on. - if sys.path_importer_cache.get(path) is None: - for hook in sys.path_hooks: - try: - hook(path) - except ImportError: - continue - break - else: - return None return sorted((name, ispkg) for _, name, ispkg in pkgutil.iter_modules([path])) @@ -316,13 +333,13 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None): 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')) - elif _list_archive_dir(os.path.abspath(start_dir)) is not None: + elif _is_archive_dir(os.path.abspath(start_dir)): # a directory inside an archive on sys.path, such as a zip file start_dir = os.path.abspath(start_dir) if start_dir != top_level_dir: parent, name = os.path.split(start_dir) - is_not_importable = ( - (name, True) not in (_list_archive_dir(parent) or ())) + siblings = _list_archive_dir(parent) + is_not_importable = (name, True) not in siblings else: # support for discovery from dotted module names try: @@ -418,7 +435,7 @@ def _find_tests(self, start_dir, pattern, namespace=False): """Used by discovery. Yields test suites it loads.""" # Inside an archive the import system lists the entries and tells # modules and packages apart; on disk the file system does. - entries = _list_archive_dir(start_dir) + in_archive = _is_archive_dir(start_dir) # Handle the __init__ in this package name = self._get_name_from_path(start_dir) # name is '.' when start_dir == top_level_dir (and top_level_dir is by @@ -428,7 +445,7 @@ def _find_tests(self, start_dir, pattern, namespace=False): # loadTestsFromModule with name. tests, should_recurse = self._find_test_path( start_dir, pattern, namespace, - kind=None if entries is None else 'package') + _ARCHIVE_PACKAGE if in_archive else _FILESYSTEM_ENTRY) if tests is not None: yield tests if not should_recurse: @@ -436,16 +453,17 @@ def _find_tests(self, start_dir, pattern, namespace=False): # package. return # Handle the contents. - if entries is None: - paths = [(path, None) for path in sorted(os.listdir(start_dir))] - else: + if in_archive: paths = [(name if ispkg else f'{name}.py', - 'package' if ispkg else 'module') - for name, ispkg in entries] + _ARCHIVE_PACKAGE if ispkg else _ARCHIVE_MODULE) + for name, ispkg in _list_archive_dir(start_dir)] + else: + paths = [(path, _FILESYSTEM_ENTRY) + for path in sorted(os.listdir(start_dir))] for path, kind in paths: full_path = os.path.join(start_dir, path) tests, should_recurse = self._find_test_path( - full_path, pattern, False, kind=kind) + full_path, pattern, False, kind) if tests is not None: yield tests if should_recurse: @@ -458,25 +476,26 @@ def _find_tests(self, start_dir, pattern, namespace=False): self._loading_packages.discard(name) def _find_test_path(self, full_path, pattern, namespace=False, - kind=None): + kind=_FILESYSTEM_ENTRY): """Used by discovery. Loads tests from a single file, or a directories' __init__.py when - passed the directory. *kind* is 'module' or 'package' for an entry - inside an archive, or None to consult the file system. + passed the directory. *kind* says whether full_path is a module or + a package inside an archive (_ARCHIVE_MODULE, _ARCHIVE_PACKAGE) or + an entry on the file system (_FILESYSTEM_ENTRY) to be classified. Returns a tuple (None_or_tests_from_file, should_recurse). """ basename = os.path.basename(full_path) - if kind is None: + if kind == _FILESYSTEM_ENTRY: if os.path.isfile(full_path): - kind = 'module' + kind = _FILESYSTEM_MODULE elif os.path.isdir(full_path): init = os.path.join(full_path, '__init__.py') if not namespace and not os.path.isfile(init): return None, False - kind = 'package' - if kind == 'module': + kind = _FILESYSTEM_PACKAGE + if kind in (_FILESYSTEM_MODULE, _ARCHIVE_MODULE): if not VALID_MODULE_NAME.match(basename): # valid Python identifiers only return None, False @@ -510,7 +529,7 @@ def _find_test_path(self, full_path, pattern, namespace=False, raise ImportError( msg % (mod_name, module_dir, expected_dir)) return self.loadTestsFromModule(module, pattern=pattern), False - elif kind == 'package': + elif kind in (_FILESYSTEM_PACKAGE, _ARCHIVE_PACKAGE): load_tests = None tests = None name = self._get_name_from_path(full_path) diff --git a/Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst b/Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst deleted file mode 100644 index d728fa8799d8422..000000000000000 --- a/Misc/NEWS.d/next/Tests/2026-09-07-23-40-00.gh-issue-157144.rgzip1.rst +++ /dev/null @@ -1,3 +0,0 @@ -The test suite can now enumerate test modules and test packages that are not -on the file system, such as a standard library inside a zip archive on -:data:`sys.path`.