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,10 @@ +import __builtin__ +import os import sys import types +from contextlib import contextmanager +from test import test_support import unittest @@ -264,6 +268,210 @@ # within a test case class, or a callable object which returns a # TestCase or TestSuite instance." # + # What happens when the module is found, but the attribute can't -- + # when a custom __import__ is being used? + # + # Replacing __import__ alters the stack trace of ImportErrors when a + # module is not found. This method helps ensure that code is not + # relying on the form of the stack trace in certain ways. + def test_loadTestsFromName__unknown_attr_name_and_new__import__(self): + @contextmanager + def replaced_import(): + old__import__ = __builtin__.__import__ + + # This is a minimal __import__ wrapper to alter the stack trace. + def my__import__(*args, **kwargs): + """Import a module.""" + return old__import__(*args, **kwargs) + + __builtin__.__import__ = my__import__ + try: + yield + finally: + __builtin__.__import__ = old__import__ + + loader = unittest.TestLoader() + + try: + with replaced_import(): + loader.loadTestsFromName('unittest.sdasfasfasdf') + except AttributeError, e: + self.assertEqual(str(e), "'module' object has no attribute " + "'sdasfasfasdf'") + else: + self.fail("TestLoader.loadTestsFromName failed to raise " + "AttributeError") + + ## Test methods related to ImportErrors (see issue 7559) + #### + + # 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. + + 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 _do_test_in_context(self, do_test, package_name): + """Call test code in the appropriate context managers. + + The context managers create a temp directory, add it to sys.path, + and create a package in the temp directory with the given name. + + """ + with test_support.temp_cwd(test_support.TESTFN): + cwd = os.getcwd() + with test_support.DirsOnSysPath(cwd): + self._create_package(package_name) + do_test() + + # 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): + package_name = 'package_foo' + def do_test(): + file_name = 'mod_with_bad_import' + os.extsep + 'py' + path = os.path.join(package_name, 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._do_test_in_context(do_test, package_name) + + # 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): + package_name = 'package_foo' + def do_test(): + sub_name = 'subpackage' + sub_dir = os.path.join(package_name, 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._do_test_in_context(do_test, package_name) + + # 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): + package_name = 'package_foo' + def do_test(): + sub_name = 'subpackage' + sub_dir = os.path.join(package_name, 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._do_test_in_context(do_test, package_name) + + # 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): + package_name = 'package_foo' + def do_test(): + sub_name = 'subpackage' + sub_dir = os.path.join(package_name, 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._do_test_in_context(do_test, package_name) + + #### + ## /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 + # TestCase or TestSuite instance." + # # What happens when we provide the module, but the attribute can't be # found? def test_loadTestsFromName__relative_unknown_name(self): Index: Lib/unittest/loader.py =================================================================== --- Lib/unittest/loader.py (revision 79760) +++ Lib/unittest/loader.py (working copy) @@ -74,6 +74,33 @@ 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 the module for any frame 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: + tb = sys.exc_info()[2] + while tb: + frame_name = tb.tb_frame.f_globals["__name__"] + if frame_name == module_name: + raised_from_module.append(1) + raise + tb = tb.tb_next + raise + def loadTestsFromName(self, name, module=None): """Return a suite of all tests cases given a string specifier. @@ -88,15 +115,25 @@ 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 that import succeeds, then this ImportError was + # because the final part was not found. parts = parts[1:] obj = module for part in parts: + # If the following line raises an AttributeError, then an + # ImportError occurred above. We let the AttributeError bubble + # up in all cases instead of the ImportError so as not to break + # code currently catching AttributeError. parent, obj = obj, getattr(obj, part) if isinstance(obj, types.ModuleType):