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/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/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/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/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 " 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/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..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 @@ -154,6 +159,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/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 f366db6e3cda..327d89599f69 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. @@ -354,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) 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) 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)