Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 14 additions & 21 deletions Doc/library/unittest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -340,28 +340,21 @@ Test modules and packages can customize test loading and discovery by through
the `load_tests protocol`_.

.. versionchanged:: 3.4
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``).
Test discovery supports :term:`namespace packages <namespace package>`.

.. versionchanged:: 3.11
:mod:`unittest` dropped the :term:`namespace packages <namespace package>`
support in Python 3.11. It has been broken since Python 3.7. Start directory and
subdirectories containing tests must be regular package that have
``__init__.py`` file.
Test discovery dropped the :term:`namespace packages <namespace package>`
support. It has been broken since Python 3.7.
Start directory and its subdirectories containing tests must be regular
package that have ``__init__.py`` file.

Directories containing start directory still can be a namespace package.
In this case, you need to specify start directory as dotted package name,
and target directory explicitly. For example::
If the start directory is the dotted name of the package, the ancestor packages
can be namespace packages.

# proj/ <-- current directory
# namespace/
# mypkg/
# __init__.py
# test_mypkg.py

python -m unittest discover -s namespace.mypkg -t .
.. versionchanged:: 3.14
Test discovery supports :term:`namespace package` as start directory again.
To avoid scanning directories unrelated to Python,
tests are not searched in subdirectories that do not contain ``__init__.py``.
Comment on lines +356 to +357
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is great 👍



.. _organizing-tests:
Expand Down Expand Up @@ -1915,10 +1908,8 @@ Loading and running tests
Modules that raise :exc:`SkipTest` on import are recorded as skips,
not errors.

.. versionchanged:: 3.4
*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
same even if the underlying file system's ordering is not dependent
on file name.
Expand All @@ -1930,11 +1921,13 @@ Loading and running tests

.. versionchanged:: 3.11
*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
*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
subclassing or assignment on an instance:
Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,15 @@ unicodedata

* 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.

Optimizations
Expand Down
Empty file.
5 changes: 5 additions & 0 deletions Lib/test/test_unittest/namespace_test_pkg/bar/test_bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import unittest

class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import unittest

class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)
5 changes: 5 additions & 0 deletions Lib/test/test_unittest/namespace_test_pkg/noop/test_noop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import unittest

class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)
5 changes: 5 additions & 0 deletions Lib/test/test_unittest/namespace_test_pkg/test_foo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import unittest

class PassingTest(unittest.TestCase):
def test_true(self):
self.assertTrue(True)
54 changes: 52 additions & 2 deletions Lib/test/test_unittest/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import sys
import types
import pickle
from importlib._bootstrap_external import NamespaceLoader
from test import support
from test.support import import_helper

import unittest
import unittest.mock
import test.test_unittest
from test.test_importlib import util as test_util


class TestableTestProgram(unittest.TestProgram):
Expand Down Expand Up @@ -395,7 +397,7 @@ def restore_isdir():
self.addCleanup(restore_isdir)

_find_tests_args = []
def _find_tests(start_dir, pattern):
def _find_tests(start_dir, pattern, namespace=None):
_find_tests_args.append((start_dir, pattern))
return ['tests']
loader._find_tests = _find_tests
Expand Down Expand Up @@ -815,7 +817,7 @@ def test_discovery_from_dotted_path(self):
expectedPath = os.path.abspath(os.path.dirname(test.test_unittest.__file__))

self.wasRun = False
def _find_tests(start_dir, pattern):
def _find_tests(start_dir, pattern, namespace=None):
self.wasRun = True
self.assertEqual(start_dir, expectedPath)
return tests
Expand Down Expand Up @@ -848,6 +850,54 @@ def restore():
'Can not use builtin modules '
'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):
from test.test_importlib import util

Expand Down
59 changes: 45 additions & 14 deletions Lib/unittest/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
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:
Expand All @@ -286,12 +288,25 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
is_not_importable = True
else:
the_module = sys.modules[start_dir]
top_part = start_dir.split('.')[0]
try:
start_dir = os.path.abspath(
os.path.dirname((the_module.__file__)))
except AttributeError:
if the_module.__name__ in sys.builtin_module_names:
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
Expand All @@ -300,14 +315,27 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
f"don't know how to discover from {the_module!r}"
) from None

else:
top_part = start_dir.split('.')[0]
start_dir = os.path.abspath(os.path.dirname((the_module.__file__)))

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)

if is_not_importable:
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
return self.suiteClass(tests)

Expand Down Expand Up @@ -343,7 +371,7 @@ def _match_path(self, path, full_path, pattern):
# override this method to use alternative matching strategy
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."""
# Handle the __init__ in this package
name = self._get_name_from_path(start_dir)
Expand All @@ -352,7 +380,8 @@ def _find_tests(self, start_dir, pattern):
if name != '.' and name not in self._loading_packages:
# name is in self._loading_packages while we have called into
# 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:
yield tests
if not should_recurse:
Expand All @@ -363,19 +392,20 @@ def _find_tests(self, start_dir, pattern):
paths = sorted(os.listdir(start_dir))
for path in paths:
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:
yield tests
if should_recurse:
# we found a package that didn't use load_tests.
name = self._get_name_from_path(full_path)
self._loading_packages.add(name)
try:
yield from self._find_tests(full_path, pattern)
yield from self._find_tests(full_path, pattern, False)
finally:
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.

Loads tests from a single file, or a directories' __init__.py when
Expand Down Expand Up @@ -419,7 +449,8 @@ def _find_test_path(self, full_path, pattern):
msg % (mod_name, module_dir, expected_dir))
return self.loadTestsFromModule(module, pattern=pattern), False
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

load_tests = None
Expand Down
4 changes: 4 additions & 0 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,10 @@ TESTSUBDIRS= idlelib/idle_test \
test/test_tools \
test/test_ttk \
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_warnings \
test/test_warnings/data \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
unittest discovery supports PEP 420 namespace packages as start directory again.
Loading