Browse Source

Fixed #34233 -- Dropped support for Python 3.8 and 3.9.

Mariusz Felisiak 2 years ago
parent
commit
3bbe22dafc

+ 0 - 2
.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'

+ 1 - 1
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 .

+ 2 - 3
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 {

+ 1 - 1
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
 
 

+ 1 - 1
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):

+ 1 - 1
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"
 

+ 1 - 1
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")

+ 0 - 10
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):

+ 2 - 7
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

+ 2 - 7
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(

+ 1 - 1
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")

+ 1 - 5
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
 

+ 2 - 2
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()
 

+ 0 - 27
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,

+ 0 - 25
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()

+ 1 - 1
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

+ 0 - 17
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)

+ 3 - 77
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.

+ 1 - 6
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
 

+ 2 - 2
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.

+ 5 - 5
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
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+ 3 - 3
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
 

+ 1 - 1
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

+ 2 - 0
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
 =======================================================
 

+ 1 - 2
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>`.

+ 1 - 4
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'
 

+ 3 - 6
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)

+ 1 - 5
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

+ 1 - 5
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

+ 1 - 5
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 (

+ 1 - 5
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
 

+ 0 - 1
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

+ 0 - 43
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."]

+ 1 - 5
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

+ 1 - 5
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

+ 5 - 29
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):

+ 1 - 5
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
 

+ 1 - 1
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