Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions django/contrib/auth/password_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 2 additions & 15 deletions django/db/backends/base/creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 27 additions & 14 deletions django/utils/module_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,47 @@
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))
and (spec := getattr(module, "__spec__", None))
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):
Expand Down
12 changes: 9 additions & 3 deletions docs/ref/utils.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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``
===========================

Expand Down
8 changes: 8 additions & 0 deletions docs/releases/6.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/auth_tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions tests/auth_tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/utils_tests/test_module/collision/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
collider = "I'm an object"
1 change: 1 addition & 0 deletions tests/utils_tests/test_module/collision/collider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
content = "In the collider submodule"
31 changes: 30 additions & 1 deletion tests/utils_tests/test_module_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
Loading