diff --git a/django/utils/module_loading.py b/django/utils/module_loading.py index 28067e8d89d4..0890d1c04380 100644 --- a/django/utils/module_loading.py +++ b/django/utils/module_loading.py @@ -5,7 +5,7 @@ from importlib.util import find_spec as importlib_find -def cached_import(module_path, class_name): +def cached_import(module_path): # Check whether module is loaded and fully initialized. if not ( (module := sys.modules.get(module_path)) @@ -13,26 +13,37 @@ def cached_import(module_path, class_name): and getattr(spec, "_initializing", False) is False ): module = import_module(module_path) - return getattr(module, class_name) + return module def import_string(dotted_path): """ - Import a dotted module path and return the attribute/class designated by - the last name in the path. Raise ImportError if the import failed. + Import a dotted module path and return the module or attribute/class + designated by the path. Raise ImportError if the import failed. """ try: - module_path, class_name = dotted_path.rsplit(".", 1) - except ValueError as err: - raise ImportError("%s doesn't look like a module path" % dotted_path) from err - - try: - return cached_import(module_path, class_name) - except AttributeError as err: - raise ImportError( - 'Module "%s" does not define a "%s" attribute/class' - % (module_path, class_name) - ) from err + module = cached_import(dotted_path) + except ImportError as err: + # Attempt to load an attribute from a module + try: + module_path, class_name = dotted_path.rsplit(".", 1) + except ValueError: + # The supplied path contained no dots, so must be a module, + # and we've already failed to load. + raise err + + module = cached_import(module_path) + + try: + class_or_attr = getattr(module, class_name) + except AttributeError as err: + raise ImportError( + 'Module "%s" does not define a "%s" attribute/class' + % (module_path, class_name) + ) from err + + return class_or_attr + return module def autodiscover_modules(*args, **kwargs): diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 1c42784d1394..4d4854b2702d 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -824,8 +824,8 @@ Functions for working with Python modules. .. function:: import_string(dotted_path) - Imports a dotted module path and returns the attribute/class designated by - the last name in the path. Raises ``ImportError`` if the import failed. For + Imports a dotted module path and returns the module or attribute/class + designated by the path. Raises ``ImportError`` if the import failed. For example:: from django.utils.module_loading import import_string diff --git a/tests/auth_tests/test_models.py b/tests/auth_tests/test_models.py index 6155cc92f569..17b2fd2e5721 100644 --- a/tests/auth_tests/test_models.py +++ b/tests/auth_tests/test_models.py @@ -464,7 +464,7 @@ def test_nonexistent_backend(self): ) def test_invalid_backend_submodule(self): - with self.assertRaises(ImportError): + with self.assertRaises(TypeError): User.objects.with_perm( "auth.test", backend="json.tool", diff --git a/tests/auth_tests/test_validators.py b/tests/auth_tests/test_validators.py index 3ce261163370..1ad4c45f3bae 100644 --- a/tests/auth_tests/test_validators.py +++ b/tests/auth_tests/test_validators.py @@ -15,7 +15,7 @@ password_validators_help_texts, validate_password, ) -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import ValidationError from django.db import models from django.test import SimpleTestCase, TestCase, override_settings from django.test.utils import isolate_apps @@ -53,11 +53,8 @@ def test_get_password_validators_custom(self): def test_get_password_validators_custom_invalid(self): validator_config = [{"NAME": "json.tool"}] - msg = ( - "The module in NAME could not be imported: json.tool. " - "Check your AUTH_PASSWORD_VALIDATORS setting." - ) - with self.assertRaisesMessage(ImproperlyConfigured, msg): + msg = "'module' object is not callable" + with self.assertRaisesMessage(TypeError, msg): get_password_validators(validator_config) def test_validate_password(self): diff --git a/tests/utils_tests/test_module_loading.py b/tests/utils_tests/test_module_loading.py index 80ada3abd788..7d2417985d72 100644 --- a/tests/utils_tests/test_module_loading.py +++ b/tests/utils_tests/test_module_loading.py @@ -134,12 +134,24 @@ def test_import_string(self): self.assertEqual(cls, import_string) # Test exceptions raised - with self.assertRaises(ImportError): - import_string("no_dots_in_path") msg = 'Module "utils_tests" does not define a "unexistent" attribute' with self.assertRaisesMessage(ImportError, msg): import_string("utils_tests.unexistent") + def test_import_string_unloaded_modules(self): + """Regression test for issue #36864""" + module_dotpath = "utils_tests" + submodule_dotpath = "utils_tests.test_module.good_module" + + import_string(module_dotpath) + import_string(submodule_dotpath) + + with self.assertRaisesMessage(ValueError, "Empty module name"): + import_string("") + + self.addCleanup(sys.modules.pop, submodule_dotpath, None) + self.addCleanup(sys.modules.pop, module_dotpath, None) + @modify_settings(INSTALLED_APPS={"append": "utils_tests.test_module"}) class AutodiscoverModulesTestCase(SimpleTestCase):