Index: Misc/NEWS =================================================================== --- Misc/NEWS (revision 79760) +++ Misc/NEWS (working copy) @@ -176,6 +176,10 @@ - Issue #1220212: Added os.kill support for Windows, including support for sending CTRL+C and CTRL+BREAK events to console subprocesses. +- Issue #7559: unittest: TestLoader.loadTestsFromName() now lets ImportErrors + bubble up if a bad import statement is encountered while loading a nested + module. Before the method raised an AttributeError. + Extension Modules ----------------- Index: Lib/unittest/test/test_loader.py =================================================================== --- Lib/unittest/test/test_loader.py (revision 79760) +++ Lib/unittest/test/test_loader.py (working copy) @@ -1,6 +1,9 @@ +import os import sys import types +from contextlib import contextmanager +from test import test_support import unittest @@ -259,6 +262,174 @@ else: self.fail("TestLoader.loadTestsFromName failed to raise AttributeError") + ## Test methods related to ImportErrors + #### + + # For this group of test methods, the specifier name is a ``dotted name'' + # that may resolve to either a nested module (or nested module not found). + # + # By nested module, we mean a module package_foo.bar, for example, + # where package_foo is found. + # + # What happens if a bad import statement is encountered while loading + # a nested module? + # + # In these cases we want the ImportError to bubble up instead of an + # AttributeError being raised. An AttributeError should be raised only + # if the nested module were not found and there were no intervening + # ImportErrors (i.e. somewhere in the __init__.py files leading up to + # the nested module). + # + # To test various import scenarios, we require some helper methods to set + # up temporary package directories, etc. + + @contextmanager + def _dir_in_path(self, dir): + os.mkdir(dir) + sys.path.insert(0, dir) + try: + yield + finally: + sys.path.pop(0) + test_support.rmtree(dir) + + def _create_file(self, path, text=''): + """Create a file with given contents.""" + f = open(path, 'w') + f.write(text) + f.close() + + def _create_package(self, package_dir, init_text=''): + """Create a package with given __init__ contents.""" + os.mkdir(package_dir) + init_file_name = '__init__' + os.extsep + 'py' + init_file_path = os.path.join(package_dir, init_file_name) + self._create_file(init_file_path, init_text) + + def _test_in_context(self, test_function): + test_dir = test_support.TESTFN + with self._dir_in_path(test_dir), \ + test_support.EnvironmentVarGuard() as env: + #env['PYTHONPATH'] = test_support.TESTFN + package_name = 'package_foo' + package_dir = os.path.join(test_dir, package_name) + self._create_package(package_dir) + test_function(package_dir) + + # Parameter: package_foo.mod_with_bad_import. + # + # package_foo/mod_with_bad_import.py: + # import no_exist + # + # This is the standard case. This should raise an ImportError since + # the module exists but contains a bad import statement. + def test_loadTestsFromName__with_bad_import(self): + def do_test(package_dir): + file_name = 'mod_with_bad_import' + os.extsep + 'py' + path = os.path.join(package_dir, file_name) + self._create_file(path, 'import no_exist') + + loader = unittest.TestLoader() + + try: + loader.loadTestsFromName('package_foo.mod_with_bad_import') + except ImportError, e: + self.assertEqual(str(e), "No module named no_exist") + else: + self.fail("TestLoader.loadTestsFromName failed to raise an " + "ImportError") + + self._test_in_context(do_test) + + # Parameter: package_foo.subpackage.no_exist_xxx + # + # package/subpackage/__init__.py: + # import no_exist + # + # This should raise an ImportError despite the module not existing since + # an intervening __init__.py has a bad import statement. + def test_loadTestsFromName__with_bad_import_in_init(self): + def do_test(package_dir): + sub_name = 'subpackage' + sub_dir = os.path.join(package_dir, sub_name) + self._create_package(sub_dir, 'import no_exist') + + loader = unittest.TestLoader() + + try: + loader.loadTestsFromName('package_foo.subpackage.no_exist_xxx') + except ImportError, e: + self.assertEqual(str(e), "No module named no_exist") + else: + self.fail("TestLoader.loadTestsFromName failed to raise an " + "ImportError") + + self._test_in_context(do_test) + + # Parameter: package_foo.subpackage.good + # + # package_foo/subpackage/__init__.py: + # import package_foo.subpackage.good + # import no_exist + # + # This should raise an ImportError. This case is unusual because the + # nested module has already been processed by the time the bad import + # import statement is reached. This is because of the module's + # explicit appearance in an intervening __init__.py. + def test_loadTestsFromName__with_bad_import_after_same_init_import(self): + def do_test(package_dir): + sub_name = 'subpackage' + sub_dir = os.path.join(package_dir, sub_name) + self._create_package(sub_dir, 'import package_foo.subpackage.good\n' + 'import no_exist') + + file_name = 'good' + os.extsep + 'py' + path = os.path.join(sub_dir, file_name) + self._create_file(path, 'pass') # empty module + + loader = unittest.TestLoader() + + try: + loader.loadTestsFromName('package_foo.subpackage.good') + except ImportError, e: + self.assertEqual(str(e), "No module named no_exist") + else: + self.fail("TestLoader.loadTestsFromName failed to raise an " + "ImportError") + + self._test_in_context(do_test) + + # Parameter: package_foo.subpackage.no_exist + # + # package/subpackage/__init__.py: + # import package_foo.subpackage.no_exist + # + # This is another unusual case. This should raise an ImportError despite + # the module not existing since the intervening __init__.py has a bad + # import statement (which happens to be for the same module being + # explicitly loaded). + def test_loadTestsFromName__with_same_no_exist_init_import(self): + def do_test(package_dir): + sub_name = 'subpackage' + sub_dir = os.path.join(package_dir, sub_name) + self._create_package(sub_dir, + 'import package_foo.subpackage.no_exist') + + loader = unittest.TestLoader() + + try: + loader.loadTestsFromName('package_foo.subpackage.no_exist') + except ImportError, e: + self.assertEqual(str(e), "No module named no_exist") + else: + self.fail("TestLoader.loadTestsFromName failed to raise an " + "ImportError") + + self._test_in_context(do_test) + + #### + ## /Test methods related to ImportErrors + # "The specifier name is a ``dotted name'' that may resolve either to # a module, a test case class, a TestSuite instance, a test method # within a test case class, or a callable object which returns a Index: Lib/unittest/loader.py =================================================================== --- Lib/unittest/loader.py (revision 79760) +++ Lib/unittest/loader.py (working copy) @@ -74,6 +74,32 @@ self.suiteClass) return tests + # This method was adapated from-- + # + # http://twistedmatrix.com/trac/browser/trunk/twisted/python/reflect.py#L382 + def _import_with_error_check(self, module_name, raised_from_module): + """Import a module while checking from where ImportErrors were raised. + + An empty list should be passed in for the raised_from_module + parameter. + + If this method raises an ImportError and the module with name + module_name is in any part of the stack trace, then this method + adds an element to raised_from_module so the list evaluates to True. + + """ + try: + return __import__(module_name) + except ImportError: + traceback = sys.exc_info()[2] + while traceback: + frame_name = traceback.tb_frame.f_globals["__name__"] + if frame_name == module_name: + raised_from_module.append(1) + raise + traceback = traceback.tb_next + raise + def loadTestsFromName(self, name, module=None): """Return a suite of all tests cases given a string specifier. @@ -88,15 +114,26 @@ parts_copy = parts[:] while parts_copy: try: - module = __import__('.'.join(parts_copy)) + module_name = '.'.join(parts_copy) + raised_from_module = [] + module = self._import_with_error_check(module_name, + raised_from_module) break except ImportError: del parts_copy[-1] - if not parts_copy: + if not parts_copy or raised_from_module: raise + # Otherwise, import again without the final part. + # If the next import succeeds, then this ImportError + # was because the final part was not found. parts = parts[1:] obj = module for part in parts: + # If an ImportError occurred above because of a module part not + # found, then the following line will raise an AttributeError. + # We keep this behavior rather than allowing the original + # ImportError to bubble up so as not to break code that is + # currently catching AttributeError. parent, obj = obj, getattr(obj, part) if isinstance(obj, types.ModuleType):