https://github.com/python/cpython/commit/c75ff2ef8eb71d91b1f92db9c2bc7ff18c582ab1
commit: c75ff2ef8eb71d91b1f92db9c2bc7ff18c582ab1
branch: main
author: Jacob Walls <jacobtylerwa...@gmail.com>
committer: methane <songofaca...@gmail.com>
date: 2024-10-23T13:41:33+09:00
summary:

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

files:
A Lib/test/test_unittest/namespace_test_pkg/bar/__init__.py
A Lib/test/test_unittest/namespace_test_pkg/bar/test_bar.py
A Lib/test/test_unittest/namespace_test_pkg/noop/no2/__init__.py
A Lib/test/test_unittest/namespace_test_pkg/noop/no2/test_no2.py
A Lib/test/test_unittest/namespace_test_pkg/noop/test_noop.py
A Lib/test/test_unittest/namespace_test_pkg/test_foo.py
A Misc/NEWS.d/next/Library/2024-09-07-13-57-49.gh-issue-80958.fVYnqV.rst
M Doc/library/unittest.rst
M Doc/whatsnew/3.14.rst
M Lib/test/test_unittest/test_discovery.py
M Lib/unittest/loader.py
M Makefile.pre.in

diff --git a/Doc/library/unittest.rst b/Doc/library/unittest.rst
index c49aba69b12126..38bad9405597dd 100644
--- a/Doc/library/unittest.rst
+++ b/Doc/library/unittest.rst
@@ -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``.
 
 
 .. _organizing-tests:
@@ -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.
@@ -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:
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index d52faa614db94e..1dd6c19018934b 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -421,6 +421,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
diff --git a/Lib/test/test_unittest/namespace_test_pkg/bar/__init__.py 
b/Lib/test/test_unittest/namespace_test_pkg/bar/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/Lib/test/test_unittest/namespace_test_pkg/bar/test_bar.py 
b/Lib/test/test_unittest/namespace_test_pkg/bar/test_bar.py
new file mode 100644
index 00000000000000..05b184d9eba685
--- /dev/null
+++ b/Lib/test/test_unittest/namespace_test_pkg/bar/test_bar.py
@@ -0,0 +1,5 @@
+import unittest
+
+class PassingTest(unittest.TestCase):
+    def test_true(self):
+        self.assertTrue(True)
diff --git a/Lib/test/test_unittest/namespace_test_pkg/noop/no2/__init__.py 
b/Lib/test/test_unittest/namespace_test_pkg/noop/no2/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/Lib/test/test_unittest/namespace_test_pkg/noop/no2/test_no2.py 
b/Lib/test/test_unittest/namespace_test_pkg/noop/no2/test_no2.py
new file mode 100644
index 00000000000000..05b184d9eba685
--- /dev/null
+++ b/Lib/test/test_unittest/namespace_test_pkg/noop/no2/test_no2.py
@@ -0,0 +1,5 @@
+import unittest
+
+class PassingTest(unittest.TestCase):
+    def test_true(self):
+        self.assertTrue(True)
diff --git a/Lib/test/test_unittest/namespace_test_pkg/noop/test_noop.py 
b/Lib/test/test_unittest/namespace_test_pkg/noop/test_noop.py
new file mode 100644
index 00000000000000..05b184d9eba685
--- /dev/null
+++ b/Lib/test/test_unittest/namespace_test_pkg/noop/test_noop.py
@@ -0,0 +1,5 @@
+import unittest
+
+class PassingTest(unittest.TestCase):
+    def test_true(self):
+        self.assertTrue(True)
diff --git a/Lib/test/test_unittest/namespace_test_pkg/test_foo.py 
b/Lib/test/test_unittest/namespace_test_pkg/test_foo.py
new file mode 100644
index 00000000000000..05b184d9eba685
--- /dev/null
+++ b/Lib/test/test_unittest/namespace_test_pkg/test_foo.py
@@ -0,0 +1,5 @@
+import unittest
+
+class PassingTest(unittest.TestCase):
+    def test_true(self):
+        self.assertTrue(True)
diff --git a/Lib/test/test_unittest/test_discovery.py 
b/Lib/test/test_unittest/test_discovery.py
index a44b18406c08be..38c9779daaf87d 100644
--- a/Lib/test/test_unittest/test_discovery.py
+++ b/Lib/test/test_unittest/test_discovery.py
@@ -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):
@@ -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
@@ -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
@@ -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
 
diff --git a/Lib/unittest/loader.py b/Lib/unittest/loader.py
index 22797b83a68bc8..a52950dad224ee 100644
--- a/Lib/unittest/loader.py
+++ b/Lib/unittest/loader.py
@@ -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:
@@ -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
@@ -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)
 
@@ -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)
@@ -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:
@@ -363,7 +392,8 @@ 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:
@@ -371,11 +401,11 @@ def _find_tests(self, start_dir, pattern):
                 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
@@ -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
diff --git a/Makefile.pre.in b/Makefile.pre.in
index fb6f22d57397db..d6f75a931a3db2 100644
--- a/Makefile.pre.in
+++ b/Makefile.pre.in
@@ -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 \
diff --git 
a/Misc/NEWS.d/next/Library/2024-09-07-13-57-49.gh-issue-80958.fVYnqV.rst 
b/Misc/NEWS.d/next/Library/2024-09-07-13-57-49.gh-issue-80958.fVYnqV.rst
new file mode 100644
index 00000000000000..f0edd7b1ac6e8b
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-09-07-13-57-49.gh-issue-80958.fVYnqV.rst
@@ -0,0 +1 @@
+unittest discovery supports PEP 420 namespace packages as start directory 
again.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to