gh-80958: unittest: discovery support for namespace packages as start directory (#123820)

This commit is contained in:
Jacob Walls 2024-10-23 00:41:33 -04:00 committed by GitHub
parent 34653bba64
commit c75ff2ef8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 145 additions and 37 deletions

View File

@ -340,28 +340,21 @@ Test modules and packages can customize test loading and discovery by through
the `load_tests protocol`_. the `load_tests protocol`_.
.. versionchanged:: 3.4 .. versionchanged:: 3.4
Test discovery supports :term:`namespace packages <namespace package>` Test discovery supports :term:`namespace packages <namespace package>`.
for the start directory. Note that you need to specify the top level
directory too (e.g.
``python -m unittest discover -s root/namespace -t root``).
.. versionchanged:: 3.11 .. versionchanged:: 3.11
:mod:`unittest` dropped the :term:`namespace packages <namespace package>` Test discovery dropped the :term:`namespace packages <namespace package>`
support in Python 3.11. It has been broken since Python 3.7. Start directory and support. It has been broken since Python 3.7.
subdirectories containing tests must be regular package that have Start directory and its subdirectories containing tests must be regular
``__init__.py`` file. package that have ``__init__.py`` file.
Directories containing start directory still can be a namespace package. If the start directory is the dotted name of the package, the ancestor packages
In this case, you need to specify start directory as dotted package name, can be namespace packages.
and target directory explicitly. For example::
# proj/ <-- current directory .. versionchanged:: 3.14
# namespace/ Test discovery supports :term:`namespace package` as start directory again.
# mypkg/ To avoid scanning directories unrelated to Python,
# __init__.py tests are not searched in subdirectories that do not contain ``__init__.py``.
# test_mypkg.py
python -m unittest discover -s namespace.mypkg -t .
.. _organizing-tests: .. _organizing-tests:
@ -1915,10 +1908,8 @@ Loading and running tests
Modules that raise :exc:`SkipTest` on import are recorded as skips, Modules that raise :exc:`SkipTest` on import are recorded as skips,
not errors. not errors.
.. versionchanged:: 3.4
*start_dir* can be a :term:`namespace packages <namespace package>`. *start_dir* can be a :term:`namespace packages <namespace package>`.
.. versionchanged:: 3.4
Paths are sorted before being imported so that execution order is the Paths are sorted before being imported so that execution order is the
same even if the underlying file system's ordering is not dependent same even if the underlying file system's ordering is not dependent
on file name. on file name.
@ -1930,11 +1921,13 @@ Loading and running tests
.. versionchanged:: 3.11 .. versionchanged:: 3.11
*start_dir* can not be a :term:`namespace packages <namespace package>`. *start_dir* can not be a :term:`namespace packages <namespace package>`.
It has been broken since Python 3.7 and Python 3.11 officially remove it. It has been broken since Python 3.7, and Python 3.11 officially removes it.
.. versionchanged:: 3.13 .. versionchanged:: 3.13
*top_level_dir* is only stored for the duration of *discover* call. *top_level_dir* is only stored for the duration of *discover* call.
.. versionchanged:: 3.14
*start_dir* can once again be a :term:`namespace package`.
The following attributes of a :class:`TestLoader` can be configured either by The following attributes of a :class:`TestLoader` can be configured either by
subclassing or assignment on an instance: subclassing or assignment on an instance:

View File

@ -421,6 +421,15 @@ unicodedata
* The Unicode database has been updated to Unicode 16.0.0. * The Unicode database has been updated to Unicode 16.0.0.
unittest
--------
* unittest discovery supports :term:`namespace package` as start
directory again. It was removed in Python 3.11.
(Contributed by Jacob Walls in :gh:`80958`.)
.. Add improved modules above alphabetically, not here at the end. .. Add improved modules above alphabetically, not here at the end.
Optimizations Optimizations

View File

@ -0,0 +1,5 @@
import unittest
class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)

View File

@ -0,0 +1,5 @@
import unittest
class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)

View File

@ -0,0 +1,5 @@
import unittest
class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)

View File

@ -0,0 +1,5 @@
import unittest
class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)

View File

@ -4,12 +4,14 @@ import re
import sys import sys
import types import types
import pickle import pickle
from importlib._bootstrap_external import NamespaceLoader
from test import support from test import support
from test.support import import_helper from test.support import import_helper
import unittest import unittest
import unittest.mock import unittest.mock
import test.test_unittest import test.test_unittest
from test.test_importlib import util as test_util
class TestableTestProgram(unittest.TestProgram): class TestableTestProgram(unittest.TestProgram):
@ -395,7 +397,7 @@ class TestDiscovery(unittest.TestCase):
self.addCleanup(restore_isdir) self.addCleanup(restore_isdir)
_find_tests_args = [] _find_tests_args = []
def _find_tests(start_dir, pattern): def _find_tests(start_dir, pattern, namespace=None):
_find_tests_args.append((start_dir, pattern)) _find_tests_args.append((start_dir, pattern))
return ['tests'] return ['tests']
loader._find_tests = _find_tests loader._find_tests = _find_tests
@ -815,7 +817,7 @@ class TestDiscovery(unittest.TestCase):
expectedPath = os.path.abspath(os.path.dirname(test.test_unittest.__file__)) expectedPath = os.path.abspath(os.path.dirname(test.test_unittest.__file__))
self.wasRun = False self.wasRun = False
def _find_tests(start_dir, pattern): def _find_tests(start_dir, pattern, namespace=None):
self.wasRun = True self.wasRun = True
self.assertEqual(start_dir, expectedPath) self.assertEqual(start_dir, expectedPath)
return tests return tests
@ -848,6 +850,54 @@ class TestDiscovery(unittest.TestCase):
'Can not use builtin modules ' 'Can not use builtin modules '
'as dotted module names') 'as dotted module names')
def test_discovery_from_dotted_namespace_packages(self):
loader = unittest.TestLoader()
package = types.ModuleType('package')
package.__name__ = "tests"
package.__path__ = ['/a', '/b']
package.__file__ = None
package.__spec__ = types.SimpleNamespace(
name=package.__name__,
loader=NamespaceLoader(package.__name__, package.__path__, None),
submodule_search_locations=['/a', '/b']
)
def _import(packagename, *args, **kwargs):
sys.modules[packagename] = package
return package
_find_tests_args = []
def _find_tests(start_dir, pattern, namespace=None):
_find_tests_args.append((start_dir, pattern))
return ['%s/tests' % start_dir]
loader._find_tests = _find_tests
loader.suiteClass = list
with unittest.mock.patch('builtins.__import__', _import):
# Since loader.discover() can modify sys.path, restore it when done.
with import_helper.DirsOnSysPath():
# Make sure to remove 'package' from sys.modules when done.
with test_util.uncache('package'):
suite = loader.discover('package')
self.assertEqual(suite, ['/a/tests', '/b/tests'])
def test_discovery_start_dir_is_namespace(self):
"""Subdirectory discovery not affected if start_dir is a namespace pkg."""
loader = unittest.TestLoader()
with (
import_helper.DirsOnSysPath(os.path.join(os.path.dirname(__file__))),
test_util.uncache('namespace_test_pkg')
):
suite = loader.discover('namespace_test_pkg')
self.assertEqual(
{list(suite)[0]._tests[0].__module__ for suite in suite._tests if list(suite)},
# files under namespace_test_pkg.noop not discovered.
{'namespace_test_pkg.test_foo', 'namespace_test_pkg.bar.test_bar'},
)
def test_discovery_failed_discovery(self): def test_discovery_failed_discovery(self):
from test.test_importlib import util from test.test_importlib import util

View File

@ -274,6 +274,8 @@ class TestLoader(object):
self._top_level_dir = top_level_dir self._top_level_dir = top_level_dir
is_not_importable = False is_not_importable = False
is_namespace = False
tests = []
if os.path.isdir(os.path.abspath(start_dir)): if os.path.isdir(os.path.abspath(start_dir)):
start_dir = os.path.abspath(start_dir) start_dir = os.path.abspath(start_dir)
if start_dir != top_level_dir: if start_dir != top_level_dir:
@ -286,12 +288,25 @@ class TestLoader(object):
is_not_importable = True is_not_importable = True
else: else:
the_module = sys.modules[start_dir] the_module = sys.modules[start_dir]
top_part = start_dir.split('.')[0] if not hasattr(the_module, "__file__") or the_module.__file__ is None:
try: # look for namespace packages
start_dir = os.path.abspath( try:
os.path.dirname((the_module.__file__))) spec = the_module.__spec__
except AttributeError: except AttributeError:
if the_module.__name__ in sys.builtin_module_names: 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 # builtin module
raise TypeError('Can not use builtin modules ' raise TypeError('Can not use builtin modules '
'as dotted module names') from None 'as dotted module names') from None
@ -300,14 +315,27 @@ class TestLoader(object):
f"don't know how to discover from {the_module!r}" f"don't know how to discover from {the_module!r}"
) from None ) from None
else:
top_part = start_dir.split('.')[0]
start_dir = os.path.abspath(os.path.dirname((the_module.__file__)))
if set_implicit_top: if set_implicit_top:
self._top_level_dir = self._get_directory_containing_module(top_part) 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) sys.path.remove(top_level_dir)
if is_not_importable: if is_not_importable:
raise ImportError('Start directory is not importable: %r' % start_dir) raise ImportError('Start directory is not importable: %r' % start_dir)
tests = list(self._find_tests(start_dir, pattern)) if not is_namespace:
tests = list(self._find_tests(start_dir, pattern))
self._top_level_dir = original_top_level_dir self._top_level_dir = original_top_level_dir
return self.suiteClass(tests) return self.suiteClass(tests)
@ -343,7 +371,7 @@ class TestLoader(object):
# override this method to use alternative matching strategy # override this method to use alternative matching strategy
return fnmatch(path, pattern) return fnmatch(path, pattern)
def _find_tests(self, start_dir, pattern): def _find_tests(self, start_dir, pattern, namespace=False):
"""Used by discovery. Yields test suites it loads.""" """Used by discovery. Yields test suites it loads."""
# Handle the __init__ in this package # Handle the __init__ in this package
name = self._get_name_from_path(start_dir) name = self._get_name_from_path(start_dir)
@ -352,7 +380,8 @@ class TestLoader(object):
if name != '.' and name not in self._loading_packages: if name != '.' and name not in self._loading_packages:
# name is in self._loading_packages while we have called into # name is in self._loading_packages while we have called into
# loadTestsFromModule with name. # loadTestsFromModule with name.
tests, should_recurse = self._find_test_path(start_dir, pattern) tests, should_recurse = self._find_test_path(
start_dir, pattern, namespace)
if tests is not None: if tests is not None:
yield tests yield tests
if not should_recurse: if not should_recurse:
@ -363,7 +392,8 @@ class TestLoader(object):
paths = sorted(os.listdir(start_dir)) paths = sorted(os.listdir(start_dir))
for path in paths: for path in paths:
full_path = os.path.join(start_dir, path) full_path = os.path.join(start_dir, path)
tests, should_recurse = self._find_test_path(full_path, pattern) tests, should_recurse = self._find_test_path(
full_path, pattern, False)
if tests is not None: if tests is not None:
yield tests yield tests
if should_recurse: if should_recurse:
@ -371,11 +401,11 @@ class TestLoader(object):
name = self._get_name_from_path(full_path) name = self._get_name_from_path(full_path)
self._loading_packages.add(name) self._loading_packages.add(name)
try: try:
yield from self._find_tests(full_path, pattern) yield from self._find_tests(full_path, pattern, False)
finally: finally:
self._loading_packages.discard(name) self._loading_packages.discard(name)
def _find_test_path(self, full_path, pattern): def _find_test_path(self, full_path, pattern, namespace=False):
"""Used by discovery. """Used by discovery.
Loads tests from a single file, or a directories' __init__.py when Loads tests from a single file, or a directories' __init__.py when
@ -419,7 +449,8 @@ class TestLoader(object):
msg % (mod_name, module_dir, expected_dir)) msg % (mod_name, module_dir, expected_dir))
return self.loadTestsFromModule(module, pattern=pattern), False return self.loadTestsFromModule(module, pattern=pattern), False
elif os.path.isdir(full_path): elif os.path.isdir(full_path):
if not os.path.isfile(os.path.join(full_path, '__init__.py')): if (not namespace and
not os.path.isfile(os.path.join(full_path, '__init__.py'))):
return None, False return None, False
load_tests = None load_tests = None

View File

@ -2534,6 +2534,10 @@ TESTSUBDIRS= idlelib/idle_test \
test/test_tools \ test/test_tools \
test/test_ttk \ test/test_ttk \
test/test_unittest \ test/test_unittest \
test/test_unittest/namespace_test_pkg \
test/test_unittest/namespace_test_pkg/bar \
test/test_unittest/namespace_test_pkg/noop \
test/test_unittest/namespace_test_pkg/noop/no2 \
test/test_unittest/testmock \ test/test_unittest/testmock \
test/test_warnings \ test/test_warnings \
test/test_warnings/data \ test/test_warnings/data \

View File

@ -0,0 +1 @@
unittest discovery supports PEP 420 namespace packages as start directory again.