From 65a9f14196c338d70889bd54753370606b3fb4eb Mon Sep 17 00:00:00 2001 From: Leland Date: Mon, 18 May 2026 15:32:12 -0700 Subject: [PATCH] Fixed #36864 -- Supported modules and submodules in import_string(). Before, only submodules worked (and only if already imported). Now, all modules work regardless of prior import state. Updated expected exception in auth tests where import_string() returns a non-callable. --- django/contrib/auth/password_validation.py | 7 ++++ django/db/backends/base/creation.py | 17 +------- django/utils/module_loading.py | 41 ++++++++++++------- docs/ref/utils.txt | 12 ++++-- docs/releases/6.2.txt | 8 ++++ tests/auth_tests/test_models.py | 2 +- tests/auth_tests/test_validators.py | 13 +++++- .../test_module/collision/__init__.py | 1 + .../test_module/collision/collider.py | 1 + tests/utils_tests/test_module_loading.py | 31 +++++++++++++- 10 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 tests/utils_tests/test_module/collision/__init__.py create mode 100644 tests/utils_tests/test_module/collision/collider.py diff --git a/django/contrib/auth/password_validation.py b/django/contrib/auth/password_validation.py index 690be5870050..d6b637e01d44 100644 --- a/django/contrib/auth/password_validation.py +++ b/django/contrib/auth/password_validation.py @@ -33,6 +33,13 @@ def get_password_validators(validator_config): "AUTH_PASSWORD_VALIDATORS setting." ) raise ImproperlyConfigured(msg % validator["NAME"]) + else: + if not callable(klass): + msg = ( + "The configured validator in NAME is not callable: %s. " + "Check your AUTH_PASSWORD_VALIDATORS setting." + ) + raise ImproperlyConfigured(msg % validator["NAME"]) validators.append(klass(**validator.get("OPTIONS", {}))) return validators diff --git a/django/db/backends/base/creation.py b/django/db/backends/base/creation.py index 5087f80b7ebd..859fb07ff537 100644 --- a/django/db/backends/base/creation.py +++ b/django/db/backends/base/creation.py @@ -372,21 +372,8 @@ def _mark_test(self, test_name, skip_reason=None): test_app = test_name.split(".")[0] # Importing a test app that isn't installed raises RuntimeError. if test_app in settings.INSTALLED_APPS: - try: - test_frame = import_string(module_or_class_name) - except ImportError: - # import_string() can raise ImportError if a submodule's parent - # module hasn't already been imported during test discovery. - # This can happen in at least two cases: - # 1. When running a subset of tests in a module, the test - # runner won't import tests in that module's other - # submodules. - # 2. When the parallel test runner spawns workers with an empty - # import cache. - test_to_mark = import_string(test_name) - test_frame = sys.modules.get(test_to_mark.__module__) - else: - test_to_mark = getattr(test_frame, name_to_mark) + test_frame = import_string(module_or_class_name) + test_to_mark = getattr(test_frame, name_to_mark) if skip_reason: setattr(test_frame, name_to_mark, skip(skip_reason)(test_to_mark)) else: diff --git a/django/utils/module_loading.py b/django/utils/module_loading.py index 28067e8d89d4..70f26d746ee1 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,39 @@ 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: + if "." not in dotted_path: + return cached_import(dotted_path) + else: + # Force a consistent state by eagerly importing the entire dotted path. + try: + cached_import(dotted_path) + except ImportError: + pass + 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 + module = cached_import(module_path) - 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 + # Attempt getattr first, which will return any named objects + # in a loaded module or a previously loaded submodule. + try: + import_target = getattr(module, class_name) + except AttributeError: + try: + import_target = cached_import(dotted_path) + except ImportError as err: + raise ImportError( + 'Module "%s" does not define a "%s" attribute/class' + % (module_path, class_name) + ) from err + return import_target def autodiscover_modules(*args, **kwargs): diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 1c42784d1394..bbd69c8ebada 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -824,9 +824,10 @@ 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 - example:: + Imports a dotted module path and returns the module or attribute/class + designated by the path. When ``dotted_path`` could resolve to either a + class or a module, the module will be preferentially imported. Raises + :exc:`ImportError` if the import failed. For example:: from django.utils.module_loading import import_string @@ -836,6 +837,11 @@ Functions for working with Python modules. from django.core.exceptions import ValidationError + .. versionchanged:: 6.2 + + Support for importing modules was added, along with deterministic + handling for ambiguous cases. + ``django.utils.safestring`` =========================== diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index a25c012a37fc..2e59060b1563 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -238,6 +238,10 @@ URLs Utilities ~~~~~~~~~ +* :func:`.utils.module_loading.import_string` now supports modules. Previously, + top-level modules did not work, and submodules only worked if already + imported. + * ... Validators @@ -272,6 +276,10 @@ Miscellaneous ``datetime.datetime(2000, 1, 1, 0, 0, 0, 1)`` now serializes to ``"2000-01-01T00:00:00"`` rather than ``"2000-01-01T00:00:00.000"``. +* :func:`.utils.module_loading.import_string` now deterministically favors + submodules in ambiguous cases where depending on prior import state, a + same-named attribute of the parent module might have been returned instead. + .. _deprecated-features-6.2: Features deprecated in 6.2 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..7b0979b421c2 100644 --- a/tests/auth_tests/test_validators.py +++ b/tests/auth_tests/test_validators.py @@ -51,10 +51,19 @@ def test_get_password_validators_custom(self): self.assertEqual(get_password_validators([]), []) - def test_get_password_validators_custom_invalid(self): + def test_get_password_validators_custom_nonexistent(self): + validator_config = [{"NAME": "does.not.exist"}] + msg = ( + "The module in NAME could not be imported: does.not.exist. " + "Check your AUTH_PASSWORD_VALIDATORS setting." + ) + with self.assertRaisesMessage(ImproperlyConfigured, msg): + get_password_validators(validator_config) + + def test_get_password_validators_custom_uncallable(self): validator_config = [{"NAME": "json.tool"}] msg = ( - "The module in NAME could not be imported: json.tool. " + "The configured validator in NAME is not callable: json.tool. " "Check your AUTH_PASSWORD_VALIDATORS setting." ) with self.assertRaisesMessage(ImproperlyConfigured, msg): diff --git a/tests/utils_tests/test_module/collision/__init__.py b/tests/utils_tests/test_module/collision/__init__.py new file mode 100644 index 000000000000..af53ce1faeaa --- /dev/null +++ b/tests/utils_tests/test_module/collision/__init__.py @@ -0,0 +1 @@ +collider = "I'm an object" diff --git a/tests/utils_tests/test_module/collision/collider.py b/tests/utils_tests/test_module/collision/collider.py new file mode 100644 index 000000000000..15a2e82911f8 --- /dev/null +++ b/tests/utils_tests/test_module/collision/collider.py @@ -0,0 +1 @@ +content = "In the collider submodule" diff --git a/tests/utils_tests/test_module_loading.py b/tests/utils_tests/test_module_loading.py index 80ada3abd788..7752da19b972 100644 --- a/tests/utils_tests/test_module_loading.py +++ b/tests/utils_tests/test_module_loading.py @@ -135,10 +135,39 @@ def test_import_string(self): # Test exceptions raised with self.assertRaises(ImportError): - import_string("no_dots_in_path") + import_string("does_not_exist") msg = 'Module "utils_tests" does not define a "unexistent" attribute' with self.assertRaisesMessage(ImportError, msg): import_string("utils_tests.unexistent") + msg = ( + 'Module "utils_tests.test_module" does not define a "unexistent" attribute' + ) + with self.assertRaisesMessage(ImportError, msg): + import_string("utils_tests.test_module.unexistent") + + def test_import_string_objects_collision_handling(self): + collision_dotpath = "utils_tests.test_module.collision.collider" + self.addCleanup(sys.modules.pop, collision_dotpath, None) + + # When there is a name collision between __init__-defined variables and + # submodules, we get back the submodule. + cls = import_string(collision_dotpath) + mod = import_module(collision_dotpath) + self.assertEqual(mod, cls) + + def test_import_string_unloaded_module(self): + module_dotpath = "utils_tests" + self.addCleanup(sys.modules.pop, module_dotpath, None) + import_string(module_dotpath) + + def test_import_string_unloaded_submodule(self): + submodule_dotpath = "utils_tests.test_module.good_module" + self.addCleanup(sys.modules.pop, submodule_dotpath, None) + import_string(submodule_dotpath) + + def test_import_string_empty(self): + with self.assertRaisesMessage(ValueError, "Empty module name"): + import_string("") @modify_settings(INSTALLED_APPS={"append": "utils_tests.test_module"})