Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
25 changes: 2 additions & 23 deletions Doc/library/unittest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ Test Discovery

Unittest supports simple test discovery. In order to be compatible with test
discovery, all of the test files must be :ref:`modules <tut-modules>` or
:ref:`packages <tut-packages>` importable from the top-level directory of
:ref:`packages <tut-packages>` (including :term:`namespace packages
<namespace package>`) importable from the top-level directory of
the project (this means that their filenames must be valid :ref:`identifiers
<identifiers>`).

Expand Down Expand Up @@ -345,24 +346,6 @@ the `load_tests protocol`_.
directory too (e.g.
``python -m unittest discover -s root/namespace -t root``).

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

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::

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

python -m unittest discover -s namespace.mypkg -t .


.. _organizing-tests:

Expand Down Expand Up @@ -1928,10 +1911,6 @@ Loading and running tests
whether their path matches *pattern*, because it is impossible for
a package name to match the default pattern.

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

.. versionchanged:: 3.13
*top_level_dir* is only stored for the duration of *discover* call.

Expand Down
4 changes: 0 additions & 4 deletions Doc/whatsnew/3.11.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2028,10 +2028,6 @@ Removed C APIs are :ref:`listed separately <whatsnew311-c-api-removed>`.
* Removed the deprecated :meth:`!split` method of :class:`!_tkinter.TkappType`.
(Contributed by Erlend E. Aasland in :issue:`38371`.)

* Removed namespace package support from :mod:`unittest` discovery.
It was introduced in Python 3.4 but has been broken since Python 3.7.
(Contributed by Inada Naoki in :issue:`23882`.)

* Removed the undocumented private :meth:`!float.__set_format__` method,
previously known as :meth:`!float.__setformat__` in Python 3.7.
Its docstring said: "You probably don't want to use this function.
Expand Down
40 changes: 38 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,40 @@ 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_failed_discovery(self):
from test.test_importlib import util

Expand Down
54 changes: 40 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,22 @@ 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:
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 +366,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 +375,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 +387,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, namespace)
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, namespace)
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 +444,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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Restore support for unittest discovery of PEP 420 namespace packages.
Loading