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 e7692c5156812e4..ec6777b8d7be192 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]: + 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 + 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] = (), 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/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..9f7e4ca52bb92ff 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -16,7 +16,10 @@ import tempfile import textwrap import unittest +import unittest.mock import warnings +import zipfile +import zipimport from test import support from test.support import isolation @@ -381,6 +384,53 @@ 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)) + # 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) + 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/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..8582878b153e4a5 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,50 @@ 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): + """The sorted (name, is_package) pairs of the modules in *path*. + + *path* is a directory inside an archive on sys.path; its path entry + finder lists it (see pkgutil.iter_modules()). + """ + 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 +286,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 +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 _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) + siblings = _list_archive_dir(parent) + is_not_importable = (name, True) not in siblings else: # support for discovery from dotted module names try: @@ -380,6 +433,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. + 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 @@ -388,7 +444,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, + _ARCHIVE_PACKAGE if in_archive else _FILESYSTEM_ENTRY) if tests is not None: yield tests if not should_recurse: @@ -396,11 +453,17 @@ 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 in_archive: + paths = [(name if ispkg else f'{name}.py', + _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) + full_path, pattern, False, kind) if tests is not None: yield tests if should_recurse: @@ -412,16 +475,27 @@ 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=_FILESYSTEM_ENTRY): """Used by discovery. Loads tests from a single file, or a directories' __init__.py when - passed the directory. + 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 os.path.isfile(full_path): + if kind == _FILESYSTEM_ENTRY: + if os.path.isfile(full_path): + 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 = _FILESYSTEM_PACKAGE + if kind in (_FILESYSTEM_MODULE, _ARCHIVE_MODULE): if not VALID_MODULE_NAME.match(basename): # valid Python identifiers only return None, False @@ -455,11 +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 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 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/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.