From aca5b3f694cdabaee8d9accbb0c6fcd01839e9f3 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:42:53 -0300 Subject: [PATCH 1/4] Refs #37178 -- Deferred django.middleware import in django.utils.deprecation. django.utils.deprecation imported django.middleware.MiddlewareMixin at module level, solely to serve its deprecated __getattr__ import path. That gave the module a hard dependency on asgiref (via django.middleware) merely to be imported, which breaks in contexts where Django's own runtime dependencies aren't installed yet, such as a PEP 517 build backend resolving django.__version__ before installing install_requires. --- django/utils/deprecation.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/django/utils/deprecation.py b/django/utils/deprecation.py index 17bad6fe6b9b..a61fc943c15c 100644 --- a/django/utils/deprecation.py +++ b/django/utils/deprecation.py @@ -5,7 +5,6 @@ from collections import Counter from inspect import iscoroutinefunction -from django.middleware import MiddlewareMixin as _MiddlewareMixin from django.utils.inspect import signature from django.utils.warnings import django_file_prefixes @@ -23,6 +22,7 @@ class RemovedInDjango2029Warning(PendingDeprecationWarning): def __getattr__(name): + # RemovedInDjango2029Warning: remove the whole if-block. if name == "MiddlewareMixin": warnings.warn( "Importing MiddlewareMixin from django.utils.deprecation is deprecated. " @@ -30,7 +30,13 @@ def __getattr__(name): RemovedInDjango2029Warning, stacklevel=2, ) - return _MiddlewareMixin + # Imported here, not at module level, so that merely importing this + # module doesn't require django.middleware's own dependencies. That + # matters this early, e.g. while a build backend is reading + # django.__version__ before Django's own dependencies are installed. + from django.middleware import MiddlewareMixin + + return MiddlewareMixin if name == "RemovedInDjango70Warning": warnings.warn( "RemovedInDjango2028Warning should be used instead of " From cc933385e75172cabacb49cfb9fe3571845cb216 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:47:00 -0300 Subject: [PATCH 2/4] Fixed #37271 -- Added calendar version support to django.utils.version. From Django 2028, feature releases are calendar versioned as YYYY[.N], where N is the patch number, as per DEP 20. A version is now read as the numbers of the printed version followed by the status and its iteration, so calendar versions have four components, (year, patch, status, iteration), while the earlier scheme keeps its five. django.VERSION is now a VersionTuple, providing `.feature`, `.patch`, `.status`, and `.iteration` attributes which give the same answers under both schemes. Indexing it from its third component on, reading it in a way which depends on its length, and comparing it against three or more components are deprecated, as each breaks or silently changes meaning when the tuple loses a component. Thanks Carlton Gibson for reviews. --- django/__init__.py | 4 +- django/utils/version.py | 170 +++++++++++++++++++-- docs/internals/deprecation.txt | 10 ++ docs/releases/6.2.txt | 15 ++ tests/version/tests.py | 268 +++++++++++++++++++++++++++++++++ 5 files changed, 452 insertions(+), 15 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index b96aa2e1610d..662920f5e91f 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ -from django.utils.version import get_version +from django.utils.version import VersionTuple, get_version -VERSION = (6, 2, 0, "alpha", 0) +VERSION = VersionTuple(6, 2, 0, "alpha", 0) __version__ = get_version(VERSION) diff --git a/django/utils/version.py b/django/utils/version.py index be727df7b6e3..127dc2c35f0e 100644 --- a/django/utils/version.py +++ b/django/utils/version.py @@ -21,35 +21,179 @@ PY315 = sys.version_info >= (3, 15) +def _validate_version(version): + # A version is the numbers of the printed version, followed by the status + # and its iteration, so its last three components are always the patch + # number, the status, and the iteration. Keeping the numbers as printed is + # what makes django.VERSION >= (2028, 3) mean what it looks like. + assert len(version) in (4, 5) + assert version[-2] in ("alpha", "beta", "rc", "final") + + +class VersionTuple(tuple): + """django.VERSION, with named access to its components. + + A version is (major, minor, micro, status, iteration) for X.Y[.Z] + releases, and (year, patch, status, iteration) for the calendar versions + used from Django 2028, which have no minor component. See DEP 20. The + `.feature`, `.patch`, `.status`, and `.iteration` attributes answer the + same under both schemes: + + VERSION.feature (6, 2) for 6.2.1, and (2028,) for 2028.1 + VERSION.patch 1 for both 6.2.1 and 2028.1 + """ + + __slots__ = () + + def __new__(cls, *version): + _validate_version(version) + return super().__new__(cls, version) + + def __getnewargs__(self): + # The components are passed to __new__() individually, so copying and + # pickling must unpack them. + return self._components + + @property + def _components(self): + # The components as a plain tuple, without deprecation warnings. + return tuple.__getitem__(self, slice(None)) + + # Indexed from the end, where the two schemes agree. + @property + def feature(self): + return self._components[:-3] + + @property + def patch(self): + return tuple.__getitem__(self, -3) + + @property + def status(self): + return tuple.__getitem__(self, -2) + + @property + def iteration(self): + return tuple.__getitem__(self, -1) + + # RemovedInDjango2028Warning: everything from here to the end of the class + # only warns about the coming shape change. Remove it all when the + # deprecation ends. The attributes above stay. + def _warn(self, message): + if len(self._components) == 4: + # Calendar versions already have their final shape. + return + # Imported here to avoid a circular import: django.utils.deprecation + # imports django.utils.inspect, which imports this module. + from django.utils.deprecation import ( + RemovedInDjango2028Warning, + warn_about_external_use, + ) + + warn_about_external_use( + f"{message} django.VERSION has four components from Django 2028, " + "(year, patch, status, iteration), as calendar versions have no " + "minor component. Use `.feature`, `.patch`, `.status`, and " + "`.iteration` attributes instead.", + RemovedInDjango2028Warning, + # Report the caller of the tuple operation, not the operation, and + # stay quiet when Django reads its own version. + skip_name_prefixes="django.utils.version.VersionTuple", + ) + + def _warn_comparison(self, other): + # A comparison against three or more components can reach the ones + # which move down an index, so its result can change from Django 2028. + if isinstance(other, tuple) and tuple.__len__(other) > 2: + self._warn( + "Comparing django.VERSION with three or more components is " + "deprecated." + ) + + def __getitem__(self, index): + # Losing the minor component moves every later component down one + # index: VERSION[2] is the micro version now, but the status from + # Django 2028. Indexing any of them is therefore deprecated. + length = tuple.__len__(self) + if isinstance(index, slice): + deprecated = any(i >= 2 for i in range(*index.indices(length))) + else: + position = index + length if index < 0 else index + deprecated = 2 <= position < length + if deprecated: + self._warn( + "Indexing django.VERSION from its third component on is deprecated." + ) + return tuple.__getitem__(self, index) + + def __iter__(self): + self._warn("Iterating or unpacking django.VERSION is deprecated.") + return tuple.__iter__(self) + + def __len__(self): + self._warn("Relying on the length of django.VERSION is deprecated.") + return tuple.__len__(self) + + def __eq__(self, other): + self._warn_comparison(other) + return tuple.__eq__(self, other) + + def __ne__(self, other): + self._warn_comparison(other) + return tuple.__ne__(self, other) + + def __lt__(self, other): + self._warn_comparison(other) + return tuple.__lt__(self, other) + + def __le__(self, other): + self._warn_comparison(other) + return tuple.__le__(self, other) + + def __gt__(self, other): + self._warn_comparison(other) + return tuple.__gt__(self, other) + + def __ge__(self, other): + self._warn_comparison(other) + return tuple.__ge__(self, other) + + __hash__ = tuple.__hash__ + + def get_version(version=None): """Return a PEP 440-compliant version number from VERSION.""" version = get_complete_version(version) # Now build the two parts of the version number: - # main = X.Y[.Z] + # main = X.Y[.Z] or YYYY[.N] # sub = .devN - for pre-alpha releases # | {a|b|rc}N - for alpha, beta, and rc releases main = get_main_version(version) + *_, status, iteration = version sub = "" - if version[3] == "alpha" and version[4] == 0: + if status == "alpha" and iteration == 0: git_changeset = get_git_changeset() if git_changeset: sub = ".dev%s" % git_changeset - elif version[3] != "final": + elif status != "final": mapping = {"alpha": "a", "beta": "b", "rc": "rc"} - sub = mapping[version[3]] + str(version[4]) + sub = mapping[status] + str(iteration) return main + sub def get_main_version(version=None): - """Return main version (X.Y[.Z]) from VERSION.""" + """Return main version (X.Y[.Z] or YYYY[.N]) from VERSION.""" version = get_complete_version(version) - parts = 2 if version[2] == 0 else 3 - return ".".join(str(x) for x in version[:parts]) + # The numbers of the printed version, without a zero patch number. + numbers = version[:-2] + if numbers[-1] == 0: + numbers = numbers[:-1] + return ".".join(str(number) for number in numbers) def get_complete_version(version=None): @@ -59,19 +203,19 @@ def get_complete_version(version=None): """ if version is None: from django import VERSION as version - else: - assert len(version) == 5 - assert version[3] in ("alpha", "beta", "rc", "final") + elif not isinstance(version, VersionTuple): + # VersionTuple validates itself when constructed. + _validate_version(version) return version def get_docs_version(version=None): version = get_complete_version(version) - if version[3] != "final": + if version[-2] != "final": return "dev" - else: - return "%d.%d" % version[:2] + # Documentation is published per feature release. + return ".".join(str(number) for number in version[:-3]) @functools.lru_cache diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 12e45ba9bd98..bb9ac6e997e9 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -154,6 +154,16 @@ details on these changes. * Support for ``Model.from_db()`` methods that do not accept the ``fetch_mode`` keyword argument will be removed. +See the :ref:`Django 6.2 release notes ` for more +details on these changes. + +* ``django.VERSION`` will have four components, ``(year, patch, status, + iteration)``, as calendar versions have no minor component. Indexing it + from its third component on, reading it in a way which depends on its + length, and comparing it against three or more components are all + deprecated. Use the ``.feature``, ``.patch``, ``.status``, and + ``.iteration`` attributes instead. + .. _deprecation-removed-in-6.1: 6.1 diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index f366db6e3cda..4631c9dd2ce8 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -343,6 +343,21 @@ Features deprecated in 6.2 Miscellaneous ------------- +* Indexing ``django.VERSION`` from its third component on, reading it in a way + which depends on its length, such as unpacking it or calling ``len()`` on it, + and comparing it against three or more components are all deprecated. Under + the calendar versioning adopted in `DEP 20`_, versions are ``YYYY[.N]`` and + have no minor component, so from Django 2028 ``django.VERSION`` has four + components, ``(year, patch, status, iteration)``. Use the ``.feature``, + ``.patch``, ``.status``, and ``.iteration`` attributes, which give the same + answers under both versioning schemes:: + + django.VERSION.feature # (6, 2) for 6.2.1, and (2028,) for 2028.1 + django.VERSION.patch # 1 for both 6.2.1 and 2028.1 + + Comparisons against one or two components, such as + ``django.VERSION >= (6, 2)``, are unaffected. + * The :class:`~django.middleware.MiddlewareMixin` class moved from ``django.utils.deprecation`` to ``django.middleware``. The old import path is deprecated. diff --git a/tests/version/tests.py b/tests/version/tests.py index a173276e5574..2ffe0f0097f0 100644 --- a/tests/version/tests.py +++ b/tests/version/tests.py @@ -1,11 +1,19 @@ +import copy +import operator +import pickle from unittest import skipUnless +import django import django.utils.version from django import get_version from django.test import SimpleTestCase +from django.utils.deprecation import RemovedInDjango2028Warning from django.utils.version import ( + VersionTuple, get_complete_version, + get_docs_version, get_git_changeset, + get_main_version, get_version_tuple, ) @@ -45,10 +53,63 @@ def test_releases(self): for ver_tuple, ver_string in tuples_to_strings: self.assertEqual(get_version(ver_tuple), ver_string) + def test_calendar_development(self): + get_git_changeset.cache_clear() + ver_tuple = (2029, 0, "alpha", 0) + # This will return a different result when it's run within or outside + # of a git clone: 2029.devYYYYMMDDHHMMSS or 2029. + ver_string = get_version(ver_tuple) + self.assertRegex(ver_string, r"2029(\.dev[0-9]+)?") + + def test_calendar_releases(self): + tuples_to_strings = ( + ((2028, 0, "alpha", 1), "2028a1"), + ((2028, 0, "beta", 1), "2028b1"), + ((2028, 0, "rc", 1), "2028rc1"), + ((2028, 0, "final", 0), "2028"), + ((2028, 1, "final", 0), "2028.1"), + ((2028, 15, "final", 0), "2028.15"), + ((2030, 2, "final", 0), "2030.2"), + ) + for ver_tuple, ver_string in tuples_to_strings: + with self.subTest(version=ver_tuple): + self.assertEqual(get_version(ver_tuple), ver_string) + + def test_get_main_version(self): + cases = [ + ((1, 4, 0, "alpha", 1), "1.4"), + ((1, 4, 0, "final", 0), "1.4"), + ((1, 4, 1, "final", 0), "1.4.1"), + ((2028, 0, "alpha", 1), "2028"), + ((2028, 0, "final", 0), "2028"), + ((2028, 1, "final", 0), "2028.1"), + ((2028, 15, "final", 0), "2028.15"), + ] + for ver_tuple, expected in cases: + with self.subTest(version=ver_tuple): + self.assertEqual(get_main_version(ver_tuple), expected) + + def test_get_docs_version(self): + cases = [ + ((1, 4, 0, "alpha", 1), "dev"), + ((1, 4, 0, "final", 0), "1.4"), + ((1, 4, 1, "final", 0), "1.4"), + ((2028, 0, "alpha", 1), "dev"), + ((2028, 0, "final", 0), "2028"), + ((2028, 1, "final", 0), "2028"), + ((2028, 15, "final", 0), "2028"), + ] + for ver_tuple, expected in cases: + with self.subTest(version=ver_tuple): + self.assertEqual(get_docs_version(ver_tuple), expected) + def test_get_version_tuple(self): self.assertEqual(get_version_tuple("1.2.3"), (1, 2, 3)) self.assertEqual(get_version_tuple("1.2.3b2"), (1, 2, 3)) self.assertEqual(get_version_tuple("1.2.3b2.dev0"), (1, 2, 3)) + self.assertEqual(get_version_tuple("2028"), (2028,)) + self.assertEqual(get_version_tuple("2028.1"), (2028, 1)) + self.assertEqual(get_version_tuple("2028b2"), (2028,)) def test_get_version_invalid_version(self): tests = [ @@ -60,3 +121,210 @@ def test_get_version_invalid_version(self): for version in tests: with self.subTest(version=version), self.assertRaises(AssertionError): get_complete_version(version) + + +class VersionTupleTests(SimpleTestCase): + + # The deprecation only applies to versions in the X.Y[.Z] scheme, whose + # tuple loses a component when calendar versions arrive. + version = VersionTuple(6, 2, 1, "final", 0) + calendar_version = VersionTuple(2028, 5, "final", 0) + # RemovedInDjango2028Warning. + hint = "django.VERSION has four components from Django 2028" + + def test_attributes(self): + cases = [ + ((6, 2, 0, "alpha", 1), (6, 2), 0, "alpha", 1), + ((6, 2, 0, "final", 0), (6, 2), 0, "final", 0), + ((6, 2, 1, "final", 0), (6, 2), 1, "final", 0), + ((1, 11, 29, "final", 0), (1, 11), 29, "final", 0), + ((2028, 0, "alpha", 1), (2028,), 0, "alpha", 1), + ((2028, 0, "final", 0), (2028,), 0, "final", 0), + ((2028, 5, "final", 0), (2028,), 5, "final", 0), + ] + for version, feature, patch, status, iteration in cases: + version = VersionTuple(*version) + with self.subTest(version=version): + self.assertEqual(version.feature, feature) + self.assertEqual(version.patch, patch) + self.assertEqual(version.status, status) + self.assertEqual(version.iteration, iteration) + + def test_feature_comparisons_span_both_schemes(self): + self.assertIs(self.version.feature >= (5, 2), True) + self.assertIs(self.version.feature == (6, 2), True) + self.assertIs(self.calendar_version.feature >= (5, 2), True) + self.assertIs(self.calendar_version.feature == (2028,), True) + self.assertIs(self.calendar_version.feature < (2029,), True) + + def test_django_version_is_a_version_tuple(self): + self.assertIsInstance(django.VERSION, VersionTuple) + # RemovedInDjango2028Warning: remove the rest of this test. + msg = "Indexing django.VERSION from its third component" + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + django.VERSION[2] + + def test_invalid_version(self): + cases = [ + # Too few or too many components. + (6, 2, "final"), + (6, 2, 0, "final", 0, 0), + (2028, 5, "final"), + (2028, 5, 0, "final", 0, 0), + # The status is not where it belongs, second to last. + (6, 2, 0, "final"), + # Invalid development status. + (6, 2, 0, "gamma", 0), + (2028, 5, "gamma", 0), + ] + for version in cases: + with self.subTest(version=version), self.assertRaises(AssertionError): + VersionTuple(*version) + + # RemovedInDjango2028Warning. + def test_indexing_from_third_component_deprecated(self): + msg = "Indexing django.VERSION from its third component on" + indexes = [ + 2, + 3, + 4, + -1, + -2, + -3, + slice(3), + slice(None), + slice(1, None), + slice(None, None, 2), + ] + for index in indexes: + with self.subTest(index=index): + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg) as ctx: + self.version[index] + self.assertIn(self.hint, str(ctx.warning)) + + # RemovedInDjango2028Warning. + def test_indexing_first_two_components_not_deprecated(self): + cases = [ + (0, 6), + (1, 2), + (-4, 2), + (-5, 6), + (slice(2), (6, 2)), + (slice(1), (6,)), + (slice(0, 2), (6, 2)), + ] + for index, expected in cases: + with self.subTest(index=index): + self.assertEqual(self.version[index], expected) + + # RemovedInDjango2028Warning. + def test_iterating_deprecated(self): + msg = "Iterating or unpacking django.VERSION is deprecated." + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + major, minor, micro, status, iteration = self.version + self.assertEqual( + (major, minor, micro, status, iteration), (6, 2, 1, "final", 0) + ) + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + self.assertEqual(tuple(self.version), (6, 2, 1, "final", 0)) + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + self.assertEqual(list(self.version), [6, 2, 1, "final", 0]) + + # RemovedInDjango2028Warning. + def test_length_deprecated(self): + msg = "Relying on the length of django.VERSION is deprecated." + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + self.assertEqual(len(self.version), 5) + + # RemovedInDjango2028Warning. + def test_comparison_with_three_or_more_components_deprecated(self): + msg = "Comparing django.VERSION with three or more components is deprecated." + operators = [ + operator.eq, + operator.ne, + operator.lt, + operator.le, + operator.gt, + operator.ge, + ] + for other in [(6, 2, 1), (6, 2, 1, "final", 0)]: + for op in operators: + with self.subTest(other=other, operator=op.__name__): + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + op(self.version, other) + + # RemovedInDjango2028Warning. + def test_reflected_comparison_deprecated(self): + msg = "Comparing django.VERSION with three or more components is deprecated." + with self.assertWarnsMessage(RemovedInDjango2028Warning, msg): + self.assertIs((6, 2, 1, "final", 0) == self.version, True) + + # RemovedInDjango2028Warning. + def test_comparison_with_fewer_components_not_deprecated(self): + cases = [ + (operator.ge, (6,), True), + (operator.ge, (6, 2), True), + (operator.ge, (6, 3), False), + (operator.lt, (2028,), True), + (operator.eq, (6, 2), False), + (operator.ne, (6, 2), True), + ] + for op, other, expected in cases: + with self.subTest(operator=op.__name__, other=other): + self.assertIs(op(self.version, other), expected) + + # RemovedInDjango2028Warning. + def test_comparison_with_non_tuple_not_deprecated(self): + self.assertIs(self.version == "6.2.1", False) + + # RemovedInDjango2028Warning. + def test_calendar_versions_not_deprecated(self): + # Calendar versions already have their final shape, so nothing about + # reading them is deprecated. + self.assertEqual(self.calendar_version[1], 5) + self.assertEqual(self.calendar_version[2], "final") + self.assertEqual(self.calendar_version[:], (2028, 5, "final", 0)) + self.assertEqual(len(self.calendar_version), 4) + self.assertEqual(tuple(self.calendar_version), (2028, 5, "final", 0)) + self.assertIs(self.calendar_version == (2028, 5, "final", 0), True) + + def test_hashing(self): + self.assertEqual(hash(self.version), hash((6, 2, 1, "final", 0))) + + def test_repr(self): + self.assertEqual(repr(self.version), "(6, 2, 1, 'final', 0)") + self.assertEqual(repr(self.calendar_version), "(2028, 5, 'final', 0)") + + def test_copying_and_pickling(self): + # These go through __getnewargs__(), which must unpack the components + # for the __new__() signature. + for label, restore in [ + ("pickle", lambda v: pickle.loads(pickle.dumps(v))), + ("copy", copy.copy), + ("deepcopy", copy.deepcopy), + ]: + for version in [self.version, self.calendar_version]: + with self.subTest(label, version=version): + restored = restore(version) + self.assertIsInstance(restored, VersionTuple) + self.assertEqual(restored.feature, version.feature) + self.assertEqual(restored.patch, version.patch) + self.assertEqual(restored.status, version.status) + self.assertEqual(restored.iteration, version.iteration) + + def test_version_helpers(self): + cases = [ + ((6, 2, 1, "final", 0), "6.2.1", "6.2.1", "6.2"), + ((2028, 0, "alpha", 1), "2028a1", "2028", "dev"), + ((2028, 0, "rc", 2), "2028rc2", "2028", "dev"), + ((2028, 0, "final", 0), "2028", "2028", "2028"), + ((2028, 5, "final", 0), "2028.5", "2028.5", "2028"), + ((2030, 12, "final", 0), "2030.12", "2030.12", "2030"), + ] + for version, expected, main, docs in cases: + version = VersionTuple(*version) + with self.subTest(version=version): + self.assertIs(get_complete_version(version), version) + self.assertEqual(get_version(version), expected) + self.assertEqual(get_main_version(version), main) + self.assertEqual(get_docs_version(version), docs) From 0398417c503e9f32674b36865abc54e575e8bcd7 Mon Sep 17 00:00:00 2001 From: VimalN2005 Date: Thu, 3 Sep 2026 19:04:36 +0530 Subject: [PATCH 3/4] Fixed #36523 -- Added django.utils.module_loading.qualname() helper. Thanks Jake Howard for the reivew. --- django/core/mail/backends/base.py | 3 +- django/db/migrations/serializer.py | 20 ++++---- django/tasks/backends/immediate.py | 5 +- django/tasks/base.py | 4 +- django/urls/resolvers.py | 6 ++- django/utils/module_loading.py | 20 ++++++++ tests/utils_tests/test_module_loading.py | 65 ++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 18 deletions(-) diff --git a/django/core/mail/backends/base.py b/django/core/mail/backends/base.py index 1d42c44a69fc..f62a6c57b854 100644 --- a/django/core/mail/backends/base.py +++ b/django/core/mail/backends/base.py @@ -2,6 +2,7 @@ from django.core.mail import InvalidMailer from django.utils.deprecation import RemovedInDjango2028Warning, warn_about_external_use +from django.utils.module_loading import qualname # RemovedInDjango2028Warning. _NOT_PROVIDED = object() @@ -57,7 +58,7 @@ def __init__( # backend or a super.__init__() call from a custom subclass). # Use something more precise than self.__class__.__name__, # which is often just "EmailBackend". - class_name = f"{type(self).__module__}.{type(self).__qualname__}" + class_name = qualname(type(self)) warn_about_external_use( f"{class_name}.__init__() does not support {kwarg_names}. " "In Django 2028, BaseEmailBackend will raise a TypeError " diff --git a/django/db/migrations/serializer.py b/django/db/migrations/serializer.py index 17dc95fe6e37..8d361e00a2be 100644 --- a/django/db/migrations/serializer.py +++ b/django/db/migrations/serializer.py @@ -18,6 +18,7 @@ from django.db.migrations.utils import COMPILED_REGEX_TYPE, RegexObject from django.db.models.deletion import DatabaseOnDelete from django.utils.functional import LazyObject, Promise +from django.utils.module_loading import qualname from django.utils.version import get_docs_version FUNCTION_TYPES = (types.FunctionType, types.BuiltinFunctionType, types.MethodType) @@ -202,14 +203,15 @@ def serialize(self): module_name = self.value.__module__ - if "<" not in self.value.__qualname__: # Qualname can include - return "%s.%s" % (module_name, self.value.__qualname__), { - "import %s" % self.value.__module__ - } + try: + name = qualname(self.value) + except ValueError as e: + raise ValueError( + "Could not find function %s in %s.\n" + % (self.value.__name__, module_name) + ) from e - raise ValueError( - "Could not find function %s in %s.\n" % (self.value.__name__, module_name) - ) + return name, {"import %s" % module_name} class FunctoolsPartialSerializer(BaseSerializer): @@ -342,9 +344,7 @@ def serialize(self): if module == builtins.__name__: return self.value.__name__, set() else: - return "%s.%s" % (module, self.value.__qualname__), { - "import %s" % module - } + return qualname(self.value), {"import %s" % module} class UUIDSerializer(BaseSerializer): diff --git a/django/tasks/backends/immediate.py b/django/tasks/backends/immediate.py index 2e154850aa2c..2d7f029365e3 100644 --- a/django/tasks/backends/immediate.py +++ b/django/tasks/backends/immediate.py @@ -6,6 +6,7 @@ from django.utils import timezone from django.utils.crypto import get_random_string from django.utils.json import normalize_json +from django.utils.module_loading import qualname from .base import BaseTaskBackend @@ -59,9 +60,7 @@ def _execute_task(self, task_result): exception_type = type(e) task_result.errors.append( TaskError( - exception_class_path=( - f"{exception_type.__module__}.{exception_type.__qualname__}" - ), + exception_class_path=qualname(exception_type), traceback="".join(format_exception(e)), ) ) diff --git a/django/tasks/base.py b/django/tasks/base.py index b5c960a7cf05..a344126868d0 100644 --- a/django/tasks/base.py +++ b/django/tasks/base.py @@ -8,7 +8,7 @@ from django.db.models.enums import TextChoices from django.utils.json import normalize_json -from django.utils.module_loading import import_string +from django.utils.module_loading import import_string, qualname from django.utils.translation import pgettext_lazy from .exceptions import TaskResultMismatch @@ -145,7 +145,7 @@ def get_backend(self): @property def module_path(self): - return f"{self.func.__module__}.{self.func.__qualname__}" + return qualname(self.func) def task( diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py index 6c681f9d8d32..8a4fcb78d286 100644 --- a/django/urls/resolvers.py +++ b/django/urls/resolvers.py @@ -23,6 +23,7 @@ from django.utils.datastructures import MultiValueDict from django.utils.functional import cached_property from django.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes +from django.utils.module_loading import qualname from django.utils.regex_helper import _lazy_re_compile, normalize from django.utils.translation import get_language @@ -495,9 +496,10 @@ def lookup_str(self): callback = callback.func if hasattr(callback, "view_class"): callback = callback.view_class - elif not hasattr(callback, "__name__"): + try: + return qualname(callback) + except ValueError: return callback.__module__ + "." + callback.__class__.__name__ - return callback.__module__ + "." + callback.__qualname__ class URLResolver: diff --git a/django/utils/module_loading.py b/django/utils/module_loading.py index 70f26d746ee1..cf00509de75d 100644 --- a/django/utils/module_loading.py +++ b/django/utils/module_loading.py @@ -1,6 +1,7 @@ import copy import os import sys +import types from importlib import import_module from importlib.util import find_spec as importlib_find @@ -118,3 +119,22 @@ def module_dir(module): if filename is not None: return os.path.dirname(filename) raise ValueError("Cannot determine directory containing %s" % module) + + +def qualname(value): + """ + Return the fully qualified name (dotted module path) for a class, function, + type, or module (e.g. 'django.db.models.Model'). + """ + if isinstance(value, types.ModuleType): + return value.__name__ + if not hasattr(value, "__module__") or value.__module__ is None: + msg = f"Cannot determine module path for {value!r}: no __module__ attribute." + raise ValueError(msg) + if not hasattr(value, "__qualname__"): + msg = f"Cannot determine module path for {value!r}: no __qualname__ attribute." + raise ValueError(msg) + if "<" in value.__qualname__: + msg = f"Cannot determine module path for {value!r}: local or anonymous object." + raise ValueError(msg) + return f"{value.__module__}.{value.__qualname__}" diff --git a/tests/utils_tests/test_module_loading.py b/tests/utils_tests/test_module_loading.py index 7752da19b972..0dcb0b5e8772 100644 --- a/tests/utils_tests/test_module_loading.py +++ b/tests/utils_tests/test_module_loading.py @@ -4,12 +4,14 @@ from importlib import import_module from zipimport import zipimporter +import django.utils.module_loading from django.test import SimpleTestCase, modify_settings from django.test.utils import extend_sys_path from django.utils.module_loading import ( autodiscover_modules, import_string, module_has_submodule, + qualname, ) @@ -255,3 +257,66 @@ def setUp(self): def tearDown(self): super().tearDown() sys.path_hooks.pop(0) + + +class TopLevelClass: + class NestedClass: + @classmethod + def clsmethod(cls): + pass + + def method(self): + pass + + +class CustomException(Exception): + pass + + +def top_level_func(): + pass + + +class QualnameTests(SimpleTestCase): + def test_classes_and_functions(self): + tests = [ + (int, "builtins.int"), + (str, "builtins.str"), + (len, "builtins.len"), + (ValueError, "builtins.ValueError"), + (CustomException, "utils_tests.test_module_loading.CustomException"), + (sys, "sys"), + ( + django.utils.module_loading, + "django.utils.module_loading", + ), + (TopLevelClass, "utils_tests.test_module_loading.TopLevelClass"), + ( + TopLevelClass.NestedClass, + "utils_tests.test_module_loading.TopLevelClass.NestedClass", + ), + ( + TopLevelClass.NestedClass.clsmethod, + "utils_tests.test_module_loading.TopLevelClass.NestedClass.clsmethod", + ), + (top_level_func, "utils_tests.test_module_loading.top_level_func"), + ] + for val, expected in tests: + with self.subTest(val=val): + self.assertEqual(qualname(val), expected) + + def test_invalid_values(self): + def local_func(): + pass + + tests = [ + (lambda: None, "local or anonymous object."), + (local_func, "local or anonymous object."), + (TopLevelClass(), "no __qualname__ attribute."), + (None, "no __module__ attribute."), + (123, "no __module__ attribute."), + ("str", "no __module__ attribute."), + ] + for val, msg in tests: + with self.subTest(val=val), self.assertRaisesMessage(ValueError, msg): + qualname(val) From b3f4d83aad7f589f165a6d8b020b7acba4936f35 Mon Sep 17 00:00:00 2001 From: Huwaiza Date: Sat, 5 Sep 2026 02:24:26 +0500 Subject: [PATCH 4/4] Fixed #37013 -- Deprecated Trunc/Extract in migrations with USE_TZ=True and no tzinfo. In Django 2029, the current timezone will be stored in the migration. --- django/db/models/functions/datetime.py | 22 +++++++++++ docs/internals/deprecation.txt | 5 +++ docs/ref/models/database-functions.txt | 20 ++++++++++ docs/releases/6.2.txt | 11 ++++++ .../datetime/test_extract_trunc.py | 37 +++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 842987bf264e..643e13c96ee6 100644 --- a/django/db/models/functions/datetime.py +++ b/django/db/models/functions/datetime.py @@ -1,3 +1,5 @@ +import warnings + from django.conf import settings from django.db.models.expressions import Func from django.db.models.fields import ( @@ -17,6 +19,7 @@ YearLte, ) from django.utils import timezone +from django.utils.deprecation import RemovedInDjango2029Warning, django_file_prefixes class TimezoneMixin: @@ -35,6 +38,25 @@ def get_tzname(self): tzname = timezone._get_timezone_name(self.tzinfo) return tzname + def deconstruct(self): + path, args, kwargs = super().deconstruct() + if self.tzinfo is None and settings.USE_TZ: + warnings.warn( + f"The {self.__class__.__name__}() database function's tzinfo " + "argument is not provided, which can lead to inconsistent " + "behavior if TIME_ZONE changes. In Django 2029, the current " + "time zone will be captured in migrations when tzinfo is " + "omitted. Pass an explicit tzinfo to suppress this warning.", + category=RemovedInDjango2029Warning, + skip_file_prefixes=django_file_prefixes(), + ) + # RemovedInDjango2029Warning: when the deprecation ends, replace + # the warning with the following, so that the current timezone is + # captured in migrations instead of being silently resolved at run + # time (get_tzname() is left unchanged for query-time usage): + # kwargs["tzinfo"] = timezone.get_current_timezone() + return path, args, kwargs + class Extract(TimezoneMixin, Transform): lookup_name = None diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index bb9ac6e997e9..df925c6ee825 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -23,6 +23,11 @@ details on these changes. * Calling ``QuerySet.aiterator()`` after ``prefetch_related()`` without providing a ``chunk_size`` will raise :exc:`ValueError`. +* The :class:`~django.db.models.functions.Extract` and + :class:`~django.db.models.functions.Trunc` database functions will capture + the current timezone in migrations when the ``tzinfo`` argument is omitted + and :setting:`USE_TZ` is ``True``. + .. _deprecation-removed-in-2028: 2028 diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 8706422521cd..aa863aec6034 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -233,6 +233,16 @@ Django usually uses the databases' extract function, so you may use any provided by :mod:`zoneinfo`, can be passed to extract a value in a specific timezone. +.. deprecated:: 6.2 + + Omitting the ``tzinfo`` argument when these functions are used in + migrations (for example as a + :attr:`db_default `) and + :setting:`USE_TZ` is ``True`` is deprecated. The timezone information + is not captured during migration serialization, which can lead to + inconsistent behavior if :setting:`TIME_ZONE` changes. Pass the + ``tzinfo`` argument explicitly to suppress this warning. + Given the datetime ``2015-06-15 23:30:01.000321+00:00``, the built-in ``lookup_name``\s return: @@ -594,6 +604,16 @@ value. If ``output_field`` is omitted, it will default to the ``output_field`` of ``expression``. A ``tzinfo`` subclass, usually provided by :mod:`zoneinfo`, can be passed to truncate a value in a specific timezone. +.. deprecated:: 6.2 + + Omitting the ``tzinfo`` argument when these functions are used in + migrations (for example as a + :attr:`db_default `) and + :setting:`USE_TZ` is ``True`` is deprecated. The timezone information + is not captured during migration serialization, which can lead to + inconsistent behavior if :setting:`TIME_ZONE` changes. Pass the + ``tzinfo`` argument explicitly to suppress this warning. + Given the datetime ``2015-06-15 14:30:50.000321+00:00``, the built-in ``kind``\s return: diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index 4631c9dd2ce8..327d89599f69 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -369,3 +369,14 @@ Miscellaneous providing a ``chunk_size`` is deprecated. It currently falls back to a ``chunk_size`` of 2000, but a ``ValueError`` will be raised in Django 2029. + +* Omitting the ``tzinfo`` argument of + :class:`~django.db.models.functions.Extract` and + :class:`~django.db.models.functions.Trunc` database functions when used in + migrations (for example as a + :attr:`db_default `) and :setting:`USE_TZ` + is ``True`` is deprecated. The timezone information is not captured during + migration serialization, which can lead to inconsistent behavior if + :setting:`TIME_ZONE` changes. Pass the ``tzinfo`` argument explicitly to + suppress this warning. In Django 2029, the current timezone will be captured + automatically when ``tzinfo`` is omitted. diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index 7c4b87282777..10a56f715bd6 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/tests/db_functions/datetime/test_extract_trunc.py @@ -45,6 +45,7 @@ skipUnlessDBFeature, ) from django.utils import timezone +from django.utils.deprecation import RemovedInDjango2029Warning from ..models import Author, DTModel, Fan @@ -1974,3 +1975,39 @@ def test_trunc_filter_non_utc_active(self): .count(), 1, ) + + def test_extract_deconstruct_tzinfo_none(self): + msg = "Extract() database function's tzinfo argument is not provided" + # RemovedInDjango2029Warning: When the deprecation ends, replace with: + # _, _, kwargs = Extract("start_datetime", "hour").deconstruct() + # self.assertEqual(kwargs["tzinfo"].key, settings.TIME_ZONE) + with self.assertWarnsMessage(RemovedInDjango2029Warning, msg): + Extract("start_datetime", "hour").deconstruct() + + def test_trunc_deconstruct_tzinfo_none(self): + msg = "Trunc() database function's tzinfo argument is not provided" + # RemovedInDjango2029Warning: When the deprecation ends, replace with: + # _, _, kwargs = Trunc("start_datetime", "day").deconstruct() + # self.assertEqual(kwargs["tzinfo"].key, settings.TIME_ZONE) + with self.assertWarnsMessage(RemovedInDjango2029Warning, msg): + Trunc("start_datetime", "day").deconstruct() + + @override_settings(USE_TZ=False) + def test_extract_deconstruct_use_tz_false(self): + _, _, kwargs = Extract("start_datetime", "hour").deconstruct() + self.assertNotIn("tzinfo", kwargs) + + @override_settings(USE_TZ=False) + def test_trunc_deconstruct_use_tz_false(self): + _, _, kwargs = Trunc("start_datetime", "day").deconstruct() + self.assertNotIn("tzinfo", kwargs) + + def test_extract_deconstruct_with_tzinfo(self): + melb = zoneinfo.ZoneInfo("Australia/Melbourne") + _, _, kwargs = Extract("start_datetime", "hour", tzinfo=melb).deconstruct() + self.assertEqual(kwargs["tzinfo"], melb) + + def test_trunc_deconstruct_with_tzinfo(self): + melb = zoneinfo.ZoneInfo("Australia/Melbourne") + _, _, kwargs = Trunc("start_datetime", "day", tzinfo=melb).deconstruct() + self.assertEqual(kwargs["tzinfo"], melb)