Skip to content

Commit e28bb15

Browse files
committed
Issue 17457: extend test discovery to support namespace packages
1 parent 8933521 commit e28bb15

File tree

4 files changed

+150
-11
lines changed

4 files changed

+150
-11
lines changed

Lib/unittest/loader.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ class TestLoader(object):
6161
def loadTestsFromTestCase(self, testCaseClass):
6262
"""Return a suite of all tests cases contained in testCaseClass"""
6363
if issubclass(testCaseClass, suite.TestSuite):
64-
raise TypeError("Test cases should not be derived from TestSuite." \
65-
" Maybe you meant to derive from TestCase?")
64+
raise TypeError("Test cases should not be derived from "
65+
"TestSuite. Maybe you meant to derive from "
66+
"TestCase?")
6667
testCaseNames = self.getTestCaseNames(testCaseClass)
6768
if not testCaseNames and hasattr(testCaseClass, 'runTest'):
6869
testCaseNames = ['runTest']
@@ -200,6 +201,8 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
200201
self._top_level_dir = top_level_dir
201202

202203
is_not_importable = False
204+
is_namespace = False
205+
tests = []
203206
if os.path.isdir(os.path.abspath(start_dir)):
204207
start_dir = os.path.abspath(start_dir)
205208
if start_dir != top_level_dir:
@@ -213,15 +216,52 @@ def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
213216
else:
214217
the_module = sys.modules[start_dir]
215218
top_part = start_dir.split('.')[0]
216-
start_dir = os.path.abspath(os.path.dirname((the_module.__file__)))
219+
try:
220+
start_dir = os.path.abspath(
221+
os.path.dirname((the_module.__file__)))
222+
except AttributeError:
223+
# look for namespace packages
224+
try:
225+
spec = the_module.__spec__
226+
except AttributeError:
227+
spec = None
228+
229+
if spec and spec.loader is None:
230+
if spec.submodule_search_locations is not None:
231+
is_namespace = True
232+
233+
for path in the_module.__path__:
234+
if (not set_implicit_top and
235+
not path.startswith(top_level_dir)):
236+
continue
237+
self._top_level_dir = \
238+
(path.split(the_module.__name__
239+
.replace(".", os.path.sep))[0])
240+
tests.extend(self._find_tests(path,
241+
pattern,
242+
namespace=True))
243+
elif the_module.__name__ in sys.builtin_module_names:
244+
# builtin module
245+
raise TypeError('Can not use builtin modules '
246+
'as dotted module names') from None
247+
else:
248+
raise TypeError(
249+
'don\'t know how to discover from {!r}'
250+
.format(the_module)) from None
251+
217252
if set_implicit_top:
218-
self._top_level_dir = self._get_directory_containing_module(top_part)
219-
sys.path.remove(top_level_dir)
253+
if not is_namespace:
254+
self._top_level_dir = \
255+
self._get_directory_containing_module(top_part)
256+
sys.path.remove(top_level_dir)
257+
else:
258+
sys.path.remove(top_level_dir)
220259

221260
if is_not_importable:
222261
raise ImportError('Start directory is not importable: %r' % start_dir)
223262

224-
tests = list(self._find_tests(start_dir, pattern))
263+
if not is_namespace:
264+
tests = list(self._find_tests(start_dir, pattern))
225265
return self.suiteClass(tests)
226266

227267
def _get_directory_containing_module(self, module_name):
@@ -254,7 +294,7 @@ def _match_path(self, path, full_path, pattern):
254294
# override this method to use alternative matching strategy
255295
return fnmatch(path, pattern)
256296

257-
def _find_tests(self, start_dir, pattern):
297+
def _find_tests(self, start_dir, pattern, namespace=False):
258298
"""Used by discovery. Yields test suites it loads."""
259299
paths = sorted(os.listdir(start_dir))
260300

@@ -287,7 +327,8 @@ def _find_tests(self, start_dir, pattern):
287327
raise ImportError(msg % (mod_name, module_dir, expected_dir))
288328
yield self.loadTestsFromModule(module)
289329
elif os.path.isdir(full_path):
290-
if not os.path.isfile(os.path.join(full_path, '__init__.py')):
330+
if (not namespace and
331+
not os.path.isfile(os.path.join(full_path, '__init__.py'))):
291332
continue
292333

293334
load_tests = None
@@ -304,7 +345,8 @@ def _find_tests(self, start_dir, pattern):
304345
# tests loaded from package file
305346
yield tests
306347
# recurse into the package
307-
yield from self._find_tests(full_path, pattern)
348+
yield from self._find_tests(full_path, pattern,
349+
namespace=namespace)
308350
else:
309351
try:
310352
yield load_tests(self, tests, pattern)

Lib/unittest/test/test_discovery.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22
import re
33
import sys
4+
import types
5+
import builtins
46
from test import support
57

68
import unittest
@@ -173,7 +175,7 @@ def restore_isdir():
173175
self.addCleanup(restore_isdir)
174176

175177
_find_tests_args = []
176-
def _find_tests(start_dir, pattern):
178+
def _find_tests(start_dir, pattern, namespace=None):
177179
_find_tests_args.append((start_dir, pattern))
178180
return ['tests']
179181
loader._find_tests = _find_tests
@@ -436,7 +438,7 @@ def test_discovery_from_dotted_path(self):
436438
expectedPath = os.path.abspath(os.path.dirname(unittest.test.__file__))
437439

438440
self.wasRun = False
439-
def _find_tests(start_dir, pattern):
441+
def _find_tests(start_dir, pattern, namespace=None):
440442
self.wasRun = True
441443
self.assertEqual(start_dir, expectedPath)
442444
return tests
@@ -446,5 +448,79 @@ def _find_tests(start_dir, pattern):
446448
self.assertEqual(suite._tests, tests)
447449

448450

451+
def test_discovery_from_dotted_path_builtin_modules(self):
452+
453+
loader = unittest.TestLoader()
454+
455+
listdir = os.listdir
456+
os.listdir = lambda _: ['test_this_does_not_exist.py']
457+
isfile = os.path.isfile
458+
isdir = os.path.isdir
459+
os.path.isdir = lambda _: False
460+
orig_sys_path = sys.path[:]
461+
def restore():
462+
os.path.isfile = isfile
463+
os.path.isdir = isdir
464+
os.listdir = listdir
465+
sys.path[:] = orig_sys_path
466+
self.addCleanup(restore)
467+
468+
with self.assertRaises(TypeError) as cm:
469+
loader.discover('sys')
470+
self.assertEqual(str(cm.exception),
471+
'Can not use builtin modules '
472+
'as dotted module names')
473+
474+
def test_discovery_from_dotted_namespace_packages(self):
475+
loader = unittest.TestLoader()
476+
477+
orig_import = __import__
478+
package = types.ModuleType('package')
479+
package.__path__ = ['/a', '/b']
480+
package.__spec__ = types.SimpleNamespace(
481+
loader=None,
482+
submodule_search_locations=['/a', '/b']
483+
)
484+
485+
def _import(packagename, *args, **kwargs):
486+
sys.modules[packagename] = package
487+
return package
488+
489+
def cleanup():
490+
builtins.__import__ = orig_import
491+
self.addCleanup(cleanup)
492+
builtins.__import__ = _import
493+
494+
_find_tests_args = []
495+
def _find_tests(start_dir, pattern, namespace=None):
496+
_find_tests_args.append((start_dir, pattern))
497+
return ['%s/tests' % start_dir]
498+
499+
loader._find_tests = _find_tests
500+
loader.suiteClass = list
501+
suite = loader.discover('package')
502+
self.assertEqual(suite, ['/a/tests', '/b/tests'])
503+
504+
def test_discovery_failed_discovery(self):
505+
loader = unittest.TestLoader()
506+
package = types.ModuleType('package')
507+
orig_import = __import__
508+
509+
def _import(packagename, *args, **kwargs):
510+
sys.modules[packagename] = package
511+
return package
512+
513+
def cleanup():
514+
builtins.__import__ = orig_import
515+
self.addCleanup(cleanup)
516+
builtins.__import__ = _import
517+
518+
with self.assertRaises(TypeError) as cm:
519+
loader.discover('package')
520+
self.assertEqual(str(cm.exception),
521+
'don\'t know how to discover from {!r}'
522+
.format(package))
523+
524+
449525
if __name__ == '__main__':
450526
unittest.main()

Misc/NEWS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,9 @@ Core and Builtins
479479
Library
480480
-------
481481

482+
- Issue #17457: unittest test discovery now works with namespace packages.
483+
Patch by Claudiu Popa.
484+
482485
- Issue #18235: Fix the sysconfig variables LDSHARED and BLDSHARED under AIX.
483486
Patch by David Edelsohn.
484487

Misc/python-wing5.wpr

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!wing
2+
#!version=5.0
3+
##################################################################
4+
# Wing IDE project file #
5+
##################################################################
6+
[project attributes]
7+
proj.directory-list = [{'dirloc': loc('..'),
8+
'excludes': [u'.hg',
9+
u'Lib/unittest/__pycache__',
10+
u'Lib/unittest/test/__pycache__',
11+
u'Lib/__pycache__',
12+
u'build',
13+
u'Doc/build'],
14+
'filter': '*',
15+
'include_hidden': False,
16+
'recursive': True,
17+
'watch_for_changes': True}]
18+
proj.file-type = 'shared'

0 commit comments

Comments
 (0)