summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMariusz Felisiak <felisiak.mariusz@gmail.com>2023-01-18 09:46:01 +0100
committerGitHub <noreply@github.com>2023-01-18 09:46:01 +0100
commit3bbe22dafcc69c5ffa79707f5a74eb1faf466e12 (patch)
tree4bec6a7016732be33f2e1cddd80c18478cb2dee9
parentd54717118360e8679aa2bd0c5a1625f3e84712ba (diff)
Fixed #34233 -- Dropped support for Python 3.8 and 3.9.
-rw-r--r--.github/workflows/schedule_tests.yml2
-rw-r--r--INSTALL2
-rw-r--r--django/contrib/auth/hashers.py5
-rw-r--r--django/contrib/staticfiles/storage.py2
-rw-r--r--django/core/cache/backends/filebased.py2
-rw-r--r--django/core/cache/utils.py2
-rw-r--r--django/core/handlers/asgi.py2
-rw-r--r--django/core/validators.py10
-rw-r--r--django/db/backends/base/base.py9
-rw-r--r--django/db/backends/sqlite3/_functions.py9
-rw-r--r--django/db/backends/utils.py2
-rw-r--r--django/templatetags/tz.py6
-rw-r--r--django/test/runner.py4
-rw-r--r--django/test/testcases.py27
-rw-r--r--django/utils/asyncio.py25
-rw-r--r--django/utils/cache.py2
-rw-r--r--django/utils/crypto.py17
-rw-r--r--django/utils/http.py80
-rw-r--r--django/utils/timezone.py7
-rw-r--r--docs/howto/windows.txt4
-rw-r--r--docs/internals/contributing/writing-code/unit-tests.txt10
-rw-r--r--docs/intro/reusable-apps.txt6
-rw-r--r--docs/intro/tutorial01.txt2
-rw-r--r--docs/releases/5.0.txt2
-rw-r--r--docs/topics/i18n/timezones.txt3
-rw-r--r--setup.cfg5
-rw-r--r--tests/admin_scripts/tests.py9
-rw-r--r--tests/admin_views/tests.py6
-rw-r--r--tests/admin_widgets/tests.py6
-rw-r--r--tests/db_functions/datetime/test_extract_trunc.py6
-rw-r--r--tests/migrations/test_writer.py6
-rw-r--r--tests/requirements/py3.txt1
-rw-r--r--tests/test_utils/tests.py43
-rw-r--r--tests/timezones/tests.py6
-rw-r--r--tests/utils_tests/test_autoreload.py6
-rw-r--r--tests/utils_tests/test_module_loading.py34
-rw-r--r--tests/utils_tests/test_timezone.py6
-rw-r--r--tox.ini2
38 files changed, 51 insertions, 327 deletions
diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml
index d758642ef7..84183ca39e 100644
--- a/.github/workflows/schedule_tests.yml
+++ b/.github/workflows/schedule_tests.yml
@@ -16,8 +16,6 @@ jobs:
strategy:
matrix:
python-version:
- - '3.8'
- - '3.9'
- '3.10'
- '3.11'
- '3.12-dev'
diff --git a/INSTALL b/INSTALL
index cd9dd33274..247b0bcdae 100644
--- a/INSTALL
+++ b/INSTALL
@@ -1,6 +1,6 @@
Thanks for downloading Django.
-To install it, make sure you have Python 3.8 or greater installed. Then run
+To install it, make sure you have Python 3.10 or greater installed. Then run
this command from the command prompt:
python -m pip install .
diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py
index e72a4ebe79..31f8a309d4 100644
--- a/django/contrib/auth/hashers.py
+++ b/django/contrib/auth/hashers.py
@@ -14,7 +14,6 @@ from django.utils.crypto import (
RANDOM_STRING_CHARS,
constant_time_compare,
get_random_string,
- md5,
pbkdf2,
)
from django.utils.deprecation import RemovedInDjango51Warning
@@ -684,7 +683,7 @@ class MD5PasswordHasher(BasePasswordHasher):
def encode(self, password, salt):
self._check_encode_args(password, salt)
- hash = md5((salt + password).encode()).hexdigest()
+ hash = hashlib.md5((salt + password).encode()).hexdigest()
return "%s$%s$%s" % (self.algorithm, salt, hash)
def decode(self, encoded):
@@ -799,7 +798,7 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher):
def encode(self, password, salt):
if salt != "":
raise ValueError("salt must be empty.")
- return md5(password.encode()).hexdigest()
+ return hashlib.md5(password.encode()).hexdigest()
def decode(self, encoded):
return {
diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py
index 445cf6b954..b3ba21f2b2 100644
--- a/django/contrib/staticfiles/storage.py
+++ b/django/contrib/staticfiles/storage.py
@@ -2,6 +2,7 @@ import json
import os
import posixpath
import re
+from hashlib import md5
from urllib.parse import unquote, urldefrag, urlsplit, urlunsplit
from django.conf import STATICFILES_STORAGE_ALIAS, settings
@@ -9,7 +10,6 @@ from django.contrib.staticfiles.utils import check_settings, matches_patterns
from django.core.exceptions import ImproperlyConfigured
from django.core.files.base import ContentFile
from django.core.files.storage import FileSystemStorage, storages
-from django.utils.crypto import md5
from django.utils.functional import LazyObject
diff --git a/django/core/cache/backends/filebased.py b/django/core/cache/backends/filebased.py
index 215fefbcc0..29d49c0ede 100644
--- a/django/core/cache/backends/filebased.py
+++ b/django/core/cache/backends/filebased.py
@@ -6,11 +6,11 @@ import random
import tempfile
import time
import zlib
+from hashlib import md5
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache
from django.core.files import locks
from django.core.files.move import file_move_safe
-from django.utils.crypto import md5
class FileBasedCache(BaseCache):
diff --git a/django/core/cache/utils.py b/django/core/cache/utils.py
index ff2a23aa6f..87f0f9cb09 100644
--- a/django/core/cache/utils.py
+++ b/django/core/cache/utils.py
@@ -1,4 +1,4 @@
-from django.utils.crypto import md5
+from hashlib import md5
TEMPLATE_FRAGMENT_KEY_TEMPLATE = "template.cache.%s.%s"
diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
index f0125e7321..998d135691 100644
--- a/django/core/handlers/asgi.py
+++ b/django/core/handlers/asgi.py
@@ -2,6 +2,7 @@ import logging
import sys
import tempfile
import traceback
+from contextlib import aclosing
from asgiref.sync import ThreadSensitiveContext, sync_to_async
@@ -19,7 +20,6 @@ from django.http import (
parse_cookie,
)
from django.urls import set_script_prefix
-from django.utils.asyncio import aclosing
from django.utils.functional import cached_property
logger = logging.getLogger("django.request")
diff --git a/django/core/validators.py b/django/core/validators.py
index c73490588d..6c622f5788 100644
--- a/django/core/validators.py
+++ b/django/core/validators.py
@@ -275,16 +275,6 @@ def validate_ipv4_address(value):
raise ValidationError(
_("Enter a valid IPv4 address."), code="invalid", params={"value": value}
)
- else:
- # Leading zeros are forbidden to avoid ambiguity with the octal
- # notation. This restriction is included in Python 3.9.5+.
- # TODO: Remove when dropping support for PY39.
- if any(octet != "0" and octet[0] == "0" for octet in value.split(".")):
- raise ValidationError(
- _("Enter a valid IPv4 address."),
- code="invalid",
- params={"value": value},
- )
def validate_ipv6_address(value):
diff --git a/django/db/backends/base/base.py b/django/db/backends/base/base.py
index 5f2e7bcd4d..84b9974b40 100644
--- a/django/db/backends/base/base.py
+++ b/django/db/backends/base/base.py
@@ -5,22 +5,17 @@ import logging
import threading
import time
import warnings
+import zoneinfo
from collections import deque
from contextlib import contextmanager
-from django.db.backends.utils import debug_transaction
-
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import DEFAULT_DB_ALIAS, DatabaseError, NotSupportedError
from django.db.backends import utils
from django.db.backends.base.validation import BaseDatabaseValidation
from django.db.backends.signals import connection_created
+from django.db.backends.utils import debug_transaction
from django.db.transaction import TransactionManagementError
from django.db.utils import DatabaseErrorWrapper
from django.utils.asyncio import async_unsafe
diff --git a/django/db/backends/sqlite3/_functions.py b/django/db/backends/sqlite3/_functions.py
index c60549f8af..9c1ef4a30a 100644
--- a/django/db/backends/sqlite3/_functions.py
+++ b/django/db/backends/sqlite3/_functions.py
@@ -4,8 +4,9 @@ Implementations of SQL functions for SQLite.
import functools
import random
import statistics
+import zoneinfo
from datetime import timedelta
-from hashlib import sha1, sha224, sha256, sha384, sha512
+from hashlib import md5, sha1, sha224, sha256, sha384, sha512
from math import (
acos,
asin,
@@ -32,14 +33,8 @@ from django.db.backends.utils import (
typecast_timestamp,
)
from django.utils import timezone
-from django.utils.crypto import md5
from django.utils.duration import duration_microseconds
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
def register(connection):
create_deterministic_function = functools.partial(
diff --git a/django/db/backends/utils.py b/django/db/backends/utils.py
index df6532e81f..71c1f2c7e8 100644
--- a/django/db/backends/utils.py
+++ b/django/db/backends/utils.py
@@ -4,9 +4,9 @@ import functools
import logging
import time
from contextlib import contextmanager
+from hashlib import md5
from django.db import NotSupportedError
-from django.utils.crypto import md5
from django.utils.dateparse import parse_time
logger = logging.getLogger("django.db.backends")
diff --git a/django/templatetags/tz.py b/django/templatetags/tz.py
index 92240b2a39..f2cee2d3fe 100644
--- a/django/templatetags/tz.py
+++ b/django/templatetags/tz.py
@@ -1,12 +1,8 @@
+import zoneinfo
from datetime import datetime
from datetime import timezone as datetime_timezone
from datetime import tzinfo
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django.template import Library, Node, TemplateSyntaxError
from django.utils import timezone
diff --git a/django/test/runner.py b/django/test/runner.py
index 4232e82e9b..014d4ea464 100644
--- a/django/test/runner.py
+++ b/django/test/runner.py
@@ -1,6 +1,7 @@
import argparse
import ctypes
import faulthandler
+import hashlib
import io
import itertools
import logging
@@ -27,7 +28,6 @@ from django.test.utils import setup_databases as _setup_databases
from django.test.utils import setup_test_environment
from django.test.utils import teardown_databases as _teardown_databases
from django.test.utils import teardown_test_environment
-from django.utils.crypto import new_hash
from django.utils.datastructures import OrderedSet
try:
@@ -580,7 +580,7 @@ class Shuffler:
@classmethod
def _hash_text(cls, text):
- h = new_hash(cls.hash_algorithm, usedforsecurity=False)
+ h = hashlib.new(cls.hash_algorithm, usedforsecurity=False)
h.update(text.encode("utf-8"))
return h.hexdigest()
diff --git a/django/test/testcases.py b/django/test/testcases.py
index 23148537f5..017c6eefd0 100644
--- a/django/test/testcases.py
+++ b/django/test/testcases.py
@@ -53,7 +53,6 @@ from django.test.utils import (
)
from django.utils.deprecation import RemovedInDjango51Warning
from django.utils.functional import classproperty
-from django.utils.version import PY310
from django.views.static import serve
logger = logging.getLogger("django.test")
@@ -795,32 +794,6 @@ class SimpleTestCase(unittest.TestCase):
**kwargs,
)
- # A similar method is available in Python 3.10+.
- if not PY310:
-
- @contextmanager
- def assertNoLogs(self, logger, level=None):
- """
- Assert no messages are logged on the logger, with at least the
- given level.
- """
- if isinstance(level, int):
- level = logging.getLevelName(level)
- elif level is None:
- level = "INFO"
- try:
- with self.assertLogs(logger, level) as cm:
- yield
- except AssertionError as e:
- msg = e.args[0]
- expected_msg = (
- f"no logs of level {level} or higher triggered on {logger}"
- )
- if msg != expected_msg:
- raise e
- else:
- self.fail(f"Unexpected logs found: {cm.output!r}")
-
def assertFieldOutput(
self,
fieldclass,
diff --git a/django/utils/asyncio.py b/django/utils/asyncio.py
index eea2df48e2..1e79f90c2c 100644
--- a/django/utils/asyncio.py
+++ b/django/utils/asyncio.py
@@ -37,28 +37,3 @@ def async_unsafe(message):
return decorator(func)
else:
return decorator
-
-
-try:
- from contextlib import aclosing
-except ImportError:
- # TODO: Remove when dropping support for PY39.
- from contextlib import AbstractAsyncContextManager
-
- # Backport of contextlib.aclosing() from Python 3.10. Copyright (C) Python
- # Software Foundation (see LICENSE.python).
- class aclosing(AbstractAsyncContextManager):
- """
- Async context manager for safely finalizing an asynchronously
- cleaned-up resource such as an async generator, calling its
- ``aclose()`` method.
- """
-
- def __init__(self, thing):
- self.thing = thing
-
- async def __aenter__(self):
- return self.thing
-
- async def __aexit__(self, *exc_info):
- await self.thing.aclose()
diff --git a/django/utils/cache.py b/django/utils/cache.py
index 2dd2c7796c..d4574217f4 100644
--- a/django/utils/cache.py
+++ b/django/utils/cache.py
@@ -16,11 +16,11 @@ An example: i18n middleware would need to distinguish caches by the
"""
import time
from collections import defaultdict
+from hashlib import md5
from django.conf import settings
from django.core.cache import caches
from django.http import HttpResponse, HttpResponseNotModified
-from django.utils.crypto import md5
from django.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag
from django.utils.log import log_response
from django.utils.regex_helper import _lazy_re_compile
diff --git a/django/utils/crypto.py b/django/utils/crypto.py
index 341cb742c1..1c0e7001c6 100644
--- a/django/utils/crypto.py
+++ b/django/utils/crypto.py
@@ -7,7 +7,6 @@ import secrets
from django.conf import settings
from django.utils.encoding import force_bytes
-from django.utils.inspect import func_supports_parameter
class InvalidAlgorithm(ValueError):
@@ -75,19 +74,3 @@ def pbkdf2(password, salt, iterations, dklen=0, digest=None):
password = force_bytes(password)
salt = force_bytes(salt)
return hashlib.pbkdf2_hmac(digest().name, password, salt, iterations, dklen)
-
-
-# TODO: Remove when dropping support for PY38. inspect.signature() is used to
-# detect whether the usedforsecurity argument is available as this fix may also
-# have been applied by downstream package maintainers to other versions in
-# their repositories.
-if func_supports_parameter(hashlib.md5, "usedforsecurity"):
- md5 = hashlib.md5
- new_hash = hashlib.new
-else:
-
- def md5(data=b"", *, usedforsecurity=True):
- return hashlib.md5(data)
-
- def new_hash(hash_algorithm, *, usedforsecurity=True):
- return hashlib.new(hash_algorithm)
diff --git a/django/utils/http.py b/django/utils/http.py
index 3e7acb5835..8fd40c27af 100644
--- a/django/utils/http.py
+++ b/django/utils/http.py
@@ -4,18 +4,9 @@ import re
import unicodedata
from binascii import Error as BinasciiError
from email.utils import formatdate
-from urllib.parse import (
- ParseResult,
- SplitResult,
- _coerce_args,
- _splitnetloc,
- _splitparams,
- quote,
- scheme_chars,
- unquote,
-)
+from urllib.parse import quote, unquote
from urllib.parse import urlencode as original_urlencode
-from urllib.parse import uses_params
+from urllib.parse import urlparse
from django.utils.datastructures import MultiValueDict
from django.utils.regex_helper import _lazy_re_compile
@@ -47,10 +38,6 @@ ASCTIME_DATE = _lazy_re_compile(r"^\w{3} %s %s %s %s$" % (__M, __D2, __T, __Y))
RFC3986_GENDELIMS = ":/?#[]@"
RFC3986_SUBDELIMS = "!$&'()*+,;="
-# TODO: Remove when dropping support for PY38.
-# Unsafe bytes to be removed per WHATWG spec.
-_UNSAFE_URL_BYTES_TO_REMOVE = ["\t", "\r", "\n"]
-
def urlencode(query, doseq=False):
"""
@@ -283,74 +270,13 @@ def url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False):
)
-# TODO: Remove when dropping support for PY38.
-# Copied from urllib.parse.urlparse() but uses fixed urlsplit() function.
-def _urlparse(url, scheme="", allow_fragments=True):
- """Parse a URL into 6 components:
- <scheme>://<netloc>/<path>;<params>?<query>#<fragment>
- Return a 6-tuple: (scheme, netloc, path, params, query, fragment).
- Note that we don't break the components up in smaller bits
- (e.g. netloc is a single string) and we don't expand % escapes."""
- url, scheme, _coerce_result = _coerce_args(url, scheme)
- splitresult = _urlsplit(url, scheme, allow_fragments)
- scheme, netloc, url, query, fragment = splitresult
- if scheme in uses_params and ";" in url:
- url, params = _splitparams(url)
- else:
- params = ""
- result = ParseResult(scheme, netloc, url, params, query, fragment)
- return _coerce_result(result)
-
-
-# TODO: Remove when dropping support for PY38.
-def _remove_unsafe_bytes_from_url(url):
- for b in _UNSAFE_URL_BYTES_TO_REMOVE:
- url = url.replace(b, "")
- return url
-
-
-# TODO: Remove when dropping support for PY38.
-# Backport of urllib.parse.urlsplit() from Python 3.9.
-def _urlsplit(url, scheme="", allow_fragments=True):
- """Parse a URL into 5 components:
- <scheme>://<netloc>/<path>?<query>#<fragment>
- Return a 5-tuple: (scheme, netloc, path, query, fragment).
- Note that we don't break the components up in smaller bits
- (e.g. netloc is a single string) and we don't expand % escapes."""
- url, scheme, _coerce_result = _coerce_args(url, scheme)
- url = _remove_unsafe_bytes_from_url(url)
- scheme = _remove_unsafe_bytes_from_url(scheme)
-
- netloc = query = fragment = ""
- i = url.find(":")
- if i > 0:
- for c in url[:i]:
- if c not in scheme_chars:
- break
- else:
- scheme, url = url[:i].lower(), url[i + 1 :]
-
- if url[:2] == "//":
- netloc, url = _splitnetloc(url, 2)
- if ("[" in netloc and "]" not in netloc) or (
- "]" in netloc and "[" not in netloc
- ):
- raise ValueError("Invalid IPv6 URL")
- if allow_fragments and "#" in url:
- url, fragment = url.split("#", 1)
- if "?" in url:
- url, query = url.split("?", 1)
- v = SplitResult(scheme, netloc, url, query, fragment)
- return _coerce_result(v)
-
-
def _url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False):
# Chrome considers any URL with more than two slashes to be absolute, but
# urlparse is not so flexible. Treat any url with three slashes as unsafe.
if url.startswith("///"):
return False
try:
- url_info = _urlparse(url)
+ url_info = urlparse(url)
except ValueError: # e.g. invalid IPv6 addresses
return False
# Forbid URLs like http:///example.com - with a scheme, but without a hostname.
diff --git a/django/utils/timezone.py b/django/utils/timezone.py
index ca9c817345..102562b254 100644
--- a/django/utils/timezone.py
+++ b/django/utils/timezone.py
@@ -3,12 +3,7 @@ Timezone-related classes and functions.
"""
import functools
-
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
+import zoneinfo
from contextlib import ContextDecorator
from datetime import datetime, timedelta, timezone, tzinfo
diff --git a/docs/howto/windows.txt b/docs/howto/windows.txt
index 5bd5b3594a..fecb2fd790 100644
--- a/docs/howto/windows.txt
+++ b/docs/howto/windows.txt
@@ -4,7 +4,7 @@ How to install Django on Windows
.. highlight:: doscon
-This document will guide you through installing Python 3.8 and Django on
+This document will guide you through installing Python 3.11 and Django on
Windows. It also provides instructions for setting up a virtual environment,
which makes it easier to work on Python projects. This is meant as a beginner's
guide for users working on Django projects and does not reflect how Django
@@ -20,7 +20,7 @@ Install Python
==============
Django is a Python web framework, thus requiring Python to be installed on your
-machine. At the time of writing, Python 3.8 is the latest version.
+machine. At the time of writing, Python 3.11 is the latest version.
To install Python on your machine go to https://www.python.org/downloads/. The
website should offer you a download button for the latest Python version.
diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt
index ba7209991c..eb0506e5ee 100644
--- a/docs/internals/contributing/writing-code/unit-tests.txt
+++ b/docs/internals/contributing/writing-code/unit-tests.txt
@@ -91,14 +91,14 @@ In addition to the default environments, ``tox`` supports running unit tests
for other versions of Python and other database backends. Since Django's test
suite doesn't bundle a settings file for database backends other than SQLite,
however, you must :ref:`create and provide your own test settings
-<running-unit-tests-settings>`. For example, to run the tests on Python 3.9
+<running-unit-tests-settings>`. For example, to run the tests on Python 3.10
using PostgreSQL:
.. console::
- $ tox -e py39-postgres -- --settings=my_postgres_settings
+ $ tox -e py310-postgres -- --settings=my_postgres_settings
-This command sets up a Python 3.9 virtual environment, installs Django's
+This command sets up a Python 3.10 virtual environment, installs Django's
test suite dependencies (including those for PostgreSQL), and calls
``runtests.py`` with the supplied arguments (in this case,
``--settings=my_postgres_settings``).
@@ -113,14 +113,14 @@ above:
.. code-block:: console
- $ DJANGO_SETTINGS_MODULE=my_postgres_settings tox -e py39-postgres
+ $ DJANGO_SETTINGS_MODULE=my_postgres_settings tox -e py310-postgres
Windows users should use:
.. code-block:: doscon
...\> set DJANGO_SETTINGS_MODULE=my_postgres_settings
- ...\> tox -e py39-postgres
+ ...\> tox -e py310-postgres
Running the JavaScript tests
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/intro/reusable-apps.txt b/docs/intro/reusable-apps.txt
index f0fa4b1306..ed05f88a75 100644
--- a/docs/intro/reusable-apps.txt
+++ b/docs/intro/reusable-apps.txt
@@ -220,15 +220,15 @@ this. For a small app like polls, this process isn't too difficult.
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
- Programming Language :: Python :: 3.8
- Programming Language :: Python :: 3.9
+ Programming Language :: Python :: 3.10
+ Programming Language :: Python :: 3.11
Topic :: Internet :: WWW/HTTP
Topic :: Internet :: WWW/HTTP :: Dynamic Content
[options]
include_package_data = true
packages = find:
- python_requires = >=3.8
+ python_requires = >=3.10
install_requires =
Django >= X.Y # Replace "X.Y" as appropriate
diff --git a/docs/intro/tutorial01.txt b/docs/intro/tutorial01.txt
index a308339fa9..4f9dc67da5 100644
--- a/docs/intro/tutorial01.txt
+++ b/docs/intro/tutorial01.txt
@@ -23,7 +23,7 @@ in a shell prompt (indicated by the $ prefix):
If Django is installed, you should see the version of your installation. If it
isn't, you'll get an error telling "No module named django".
-This tutorial is written for Django |version|, which supports Python 3.8 and
+This tutorial is written for Django |version|, which supports Python 3.10 and
later. If the Django version doesn't match, you can refer to the tutorial for
your version of Django by using the version switcher at the bottom right corner
of this page, or update Django to the newest version. If you're using an older
diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt
index cd54b03cf7..78148c79e8 100644
--- a/docs/releases/5.0.txt
+++ b/docs/releases/5.0.txt
@@ -21,6 +21,8 @@ Python compatibility
Django 5.0 supports Python 3.10, 3.11, and 3.12. We **highly recommend** and
only officially support the latest release of each series.
+The Django 4.2.x series is the last to support Python 3.8 and 3.9.
+
Third-party library support for older version of Django
=======================================================
diff --git a/docs/topics/i18n/timezones.txt b/docs/topics/i18n/timezones.txt
index 10170fcdea..19415a13b6 100644
--- a/docs/topics/i18n/timezones.txt
+++ b/docs/topics/i18n/timezones.txt
@@ -32,8 +32,7 @@ False <USE_TZ>` in your settings file.
In older version, time zone support was disabled by default.
Time zone support uses :mod:`zoneinfo`, which is part of the Python standard
-library from Python 3.9. The ``backports.zoneinfo`` package is automatically
-installed alongside Django if you are using Python 3.8.
+library from Python 3.9.
If you're wrestling with a particular problem, start with the :ref:`time zone
FAQ <time-zones-faq>`.
diff --git a/setup.cfg b/setup.cfg
index afef79c2ab..529bc6044f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -17,8 +17,6 @@ classifiers =
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
- Programming Language :: Python :: 3.8
- Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Topic :: Internet :: WWW/HTTP
@@ -34,13 +32,12 @@ project_urls =
Tracker = https://code.djangoproject.com/
[options]
-python_requires = >=3.8
+python_requires = >=3.10
packages = find:
include_package_data = true
zip_safe = false
install_requires =
asgiref >= 3.6.0
- backports.zoneinfo; python_version<"3.9"
sqlparse >= 0.2.2
tzdata; sys_platform == 'win32'
diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py
index 4149a31e21..51d0498ff7 100644
--- a/tests/admin_scripts/tests.py
+++ b/tests/admin_scripts/tests.py
@@ -32,7 +32,6 @@ from django.db.migrations.recorder import MigrationRecorder
from django.test import LiveServerTestCase, SimpleTestCase, TestCase, override_settings
from django.test.utils import captured_stderr, captured_stdout
from django.urls import path
-from django.utils.version import PY39
from django.views.static import serve
from . import urls
@@ -107,7 +106,7 @@ class AdminScriptTestCase(SimpleTestCase):
paths.append(os.path.dirname(backend_dir))
return paths
- def run_test(self, args, settings_file=None, apps=None, umask=None):
+ def run_test(self, args, settings_file=None, apps=None, umask=-1):
base_dir = os.path.dirname(self.test_dir)
# The base dir for Django's tests is one level up.
tests_dir = os.path.dirname(os.path.dirname(__file__))
@@ -136,12 +135,11 @@ class AdminScriptTestCase(SimpleTestCase):
cwd=self.test_dir,
env=test_environ,
text=True,
- # subprocess.run()'s umask was added in Python 3.9.
- **({"umask": umask} if umask and PY39 else {}),
+ umask=umask,
)
return p.stdout, p.stderr
- def run_django_admin(self, args, settings_file=None, umask=None):
+ def run_django_admin(self, args, settings_file=None, umask=-1):
return self.run_test(["-m", "django", *args], settings_file, umask=umask)
def run_manage(self, args, settings_file=None, manage_py=None):
@@ -2812,7 +2810,6 @@ class StartProject(LiveServerTestCase, AdminScriptTestCase):
sys.platform == "win32",
"Windows only partially supports umasks and chmod.",
)
- @unittest.skipUnless(PY39, "subprocess.run()'s umask was added in Python 3.9.")
def test_honor_umask(self):
_, err = self.run_django_admin(["startproject", "testproject"], umask=0o077)
self.assertNoOutput(err)
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index f7764a7f36..86fb9740a5 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -2,14 +2,10 @@ import datetime
import os
import re
import unittest
+import zoneinfo
from unittest import mock
from urllib.parse import parse_qsl, urljoin, urlparse
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django.contrib import admin
from django.contrib.admin import AdminSite, ModelAdmin
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py
index 70b1233ef4..4948a60ee0 100644
--- a/tests/admin_widgets/tests.py
+++ b/tests/admin_widgets/tests.py
@@ -1,15 +1,11 @@
import gettext
import os
import re
+import zoneinfo
from datetime import datetime, timedelta
from importlib import import_module
from unittest import skipUnless
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django import forms
from django.conf import settings
from django.contrib import admin
diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py
index 80043fe3f4..29212b6e24 100644
--- a/tests/db_functions/datetime/test_extract_trunc.py
+++ b/tests/db_functions/datetime/test_extract_trunc.py
@@ -1,11 +1,7 @@
+import zoneinfo
from datetime import datetime, timedelta
from datetime import timezone as datetime_timezone
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django.conf import settings
from django.db import DataError, OperationalError
from django.db.models import (
diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py
index 9c026ffb82..0762c43dde 100644
--- a/tests/migrations/test_writer.py
+++ b/tests/migrations/test_writer.py
@@ -8,13 +8,9 @@ import pathlib
import re
import sys
import uuid
+import zoneinfo
from unittest import mock
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
import custom_migration_operations.more_operations
import custom_migration_operations.operations
diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt
index 397aaa06b1..3cc35b8c61 100644
--- a/tests/requirements/py3.txt
+++ b/tests/requirements/py3.txt
@@ -1,7 +1,6 @@
aiosmtpd
asgiref >= 3.6.0
argon2-cffi >= 19.2.0
-backports.zoneinfo; python_version < '3.9'
bcrypt
black
docutils
diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py
index aa58b47a94..be95f5ec03 100644
--- a/tests/test_utils/tests.py
+++ b/tests/test_utils/tests.py
@@ -1,4 +1,3 @@
-import logging
import os
import unittest
import warnings
@@ -47,7 +46,6 @@ from django.test.utils import (
)
from django.urls import NoReverseMatch, path, reverse, reverse_lazy
from django.utils.deprecation import RemovedInDjango51Warning
-from django.utils.log import DEFAULT_LOGGING
from django.utils.version import PY311
from .models import Car, Person, PossessedCar
@@ -1198,47 +1196,6 @@ class AssertWarnsMessageTests(SimpleTestCase):
func1()
-# TODO: Remove when dropping support for PY39.
-class AssertNoLogsTest(SimpleTestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- logging.config.dictConfig(DEFAULT_LOGGING)
- cls.addClassCleanup(logging.config.dictConfig, settings.LOGGING)
-
- def setUp(self):
- self.logger = logging.getLogger("django")
-
- @override_settings(DEBUG=True)
- def test_fails_when_log_emitted(self):
- msg = "Unexpected logs found: ['INFO:django:FAIL!']"
- with self.assertRaisesMessage(AssertionError, msg):
- with self.assertNoLogs("django", "INFO"):
- self.logger.info("FAIL!")
-
- @override_settings(DEBUG=True)
- def test_text_level(self):
- with self.assertNoLogs("django", "INFO"):
- self.logger.debug("DEBUG logs are ignored.")
-
- @override_settings(DEBUG=True)
- def test_int_level(self):
- with self.assertNoLogs("django", logging.INFO):
- self.logger.debug("DEBUG logs are ignored.")
-
- @override_settings(DEBUG=True)
- def test_default_level(self):
- with self.assertNoLogs("django"):
- self.logger.debug("DEBUG logs are ignored.")
-
- @override_settings(DEBUG=True)
- def test_does_not_hide_other_failures(self):
- msg = "1 != 2"
- with self.assertRaisesMessage(AssertionError, msg):
- with self.assertNoLogs("django"):
- self.assertEqual(1, 2)
-
-
class AssertFieldOutputTests(SimpleTestCase):
def test_assert_field_output(self):
error_invalid = ["Enter a valid email address."]
diff --git a/tests/timezones/tests.py b/tests/timezones/tests.py
index 56784db207..e3e2ea431e 100644
--- a/tests/timezones/tests.py
+++ b/tests/timezones/tests.py
@@ -1,15 +1,11 @@
import datetime
import re
import sys
+import zoneinfo
from contextlib import contextmanager
from unittest import SkipTest, skipIf
from xml.dom.minidom import parseString
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django.contrib.auth.models import User
from django.core import serializers
from django.db import connection
diff --git a/tests/utils_tests/test_autoreload.py b/tests/utils_tests/test_autoreload.py
index b6286925b7..e33276ba61 100644
--- a/tests/utils_tests/test_autoreload.py
+++ b/tests/utils_tests/test_autoreload.py
@@ -9,16 +9,12 @@ import time
import types
import weakref
import zipfile
+import zoneinfo
from importlib import import_module
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock, skip, skipIf
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
import django.__main__
from django.apps.registry import Apps
from django.test import SimpleTestCase
diff --git a/tests/utils_tests/test_module_loading.py b/tests/utils_tests/test_module_loading.py
index 736d456d2d..80ada3abd7 100644
--- a/tests/utils_tests/test_module_loading.py
+++ b/tests/utils_tests/test_module_loading.py
@@ -11,7 +11,6 @@ from django.utils.module_loading import (
import_string,
module_has_submodule,
)
-from django.utils.version import PY310
class DefaultLoader(unittest.TestCase):
@@ -205,35 +204,12 @@ class AutodiscoverModulesTestCase(SimpleTestCase):
self.assertEqual(site._registry, {"lorem": "ipsum"})
-if PY310:
+class TestFinder:
+ def __init__(self, *args, **kwargs):
+ self.importer = zipimporter(*args, **kwargs)
- class TestFinder:
- def __init__(self, *args, **kwargs):
- self.importer = zipimporter(*args, **kwargs)
-
- def find_spec(self, path, target=None):
- return self.importer.find_spec(path, target)
-
-else:
-
- class TestFinder:
- def __init__(self, *args, **kwargs):
- self.importer = zipimporter(*args, **kwargs)
-
- def find_module(self, path):
- importer = self.importer.find_module(path)
- if importer is None:
- return
- return TestLoader(importer)
-
- class TestLoader:
- def __init__(self, importer):
- self.importer = importer
-
- def load_module(self, name):
- mod = self.importer.load_module(name)
- mod.__loader__ = self
- return mod
+ def find_spec(self, path, target=None):
+ return self.importer.find_spec(path, target)
class CustomLoader(EggLoader):
diff --git a/tests/utils_tests/test_timezone.py b/tests/utils_tests/test_timezone.py
index 931347ad46..43bb2bc7a3 100644
--- a/tests/utils_tests/test_timezone.py
+++ b/tests/utils_tests/test_timezone.py
@@ -1,11 +1,7 @@
import datetime
+import zoneinfo
from unittest import mock
-try:
- import zoneinfo
-except ImportError:
- from backports import zoneinfo
-
from django.test import SimpleTestCase, override_settings
from django.utils import timezone
diff --git a/tox.ini b/tox.ini
index e3db75bf4a..98cab3abf4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -24,7 +24,7 @@ passenv = DJANGO_SETTINGS_MODULE PYTHONPATH HOME DISPLAY OBJC_DISABLE_INITIALIZE
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
- py{3,38,39,310,311}: -rtests/requirements/py3.txt
+ py{3,310,311}: -rtests/requirements/py3.txt
postgres: -rtests/requirements/postgres.txt
mysql: -rtests/requirements/mysql.txt
oracle: -rtests/requirements/oracle.txt