Skip to content

Commit b0fb242

Browse files
Add Django 5.1 support (#98)
Also improve handling of SQLite exclusive transactions
1 parent 3363045 commit b0fb242

File tree

6 files changed

+90
-15
lines changed

6 files changed

+90
-15
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ jobs:
1616
matrix:
1717
os: [windows-latest, macos-latest, ubuntu-latest]
1818
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
19-
django-version: ["4.2", "5.0"]
19+
django-version: ["4.2", "5.0", "5.1"]
2020
exclude:
2121
- django-version: "5.0"
2222
python-version: "3.8"
2323
- django-version: "5.0"
2424
python-version: "3.9"
25+
- django-version: "5.1"
26+
python-version: "3.8"
27+
- django-version: "5.1"
28+
python-version: "3.9"
2529
- os: windows-latest # JSON1 is only built-in on 3.9+
2630
python-version: 3.8
2731

@@ -60,7 +64,7 @@ jobs:
6064
strategy:
6165
fail-fast: false
6266
matrix:
63-
django-version: ["4.2", "5.0"]
67+
django-version: ["4.2", "5.0", "5.1"]
6468
steps:
6569
- uses: actions/checkout@v4
6670
- name: Set up Python 3.12
@@ -95,7 +99,7 @@ jobs:
9599
strategy:
96100
fail-fast: false
97101
matrix:
98-
django-version: ["4.2", "5.0"]
102+
django-version: ["4.2", "5.0", "5.1"]
99103
steps:
100104
- uses: actions/checkout@v4
101105
- name: Set up Python 3.12

django_tasks/backends/database/backend.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass
22
from typing import TYPE_CHECKING, Any, Iterable, TypeVar
33

4+
import django
45
from django.apps import apps
56
from django.core.checks import messages
67
from django.core.exceptions import ValidationError
@@ -76,6 +77,7 @@ async def aget_result(self, result_id: str) -> TaskResult:
7677

7778
def check(self, **kwargs: Any) -> Iterable[messages.CheckMessage]:
7879
from .models import DBTaskResult
80+
from .utils import connection_requires_manual_exclusive_transaction
7981

8082
yield from super().check(**kwargs)
8183

@@ -89,10 +91,12 @@ def check(self, **kwargs: Any) -> Iterable[messages.CheckMessage]:
8991
)
9092

9193
db_connection = connections[router.db_for_read(DBTaskResult)]
94+
# Manually called to set `transaction_mode`
95+
db_connection.get_connection_params()
9296
if (
93-
db_connection.vendor == "sqlite"
94-
and hasattr(db_connection, "transaction_mode")
95-
and db_connection.transaction_mode != "EXCLUSIVE"
97+
# Versions below 5.1 can't be configured, so always assume exclusive transactions
98+
django.VERSION >= (5, 1)
99+
and connection_requires_manual_exclusive_transaction(db_connection)
96100
):
97101
yield messages.CheckMessage(
98102
messages.ERROR,

django_tasks/backends/database/utils.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,26 @@
22
from typing import Any, Generator, Optional, Union
33
from uuid import UUID
44

5+
import django
56
from django.db import transaction
7+
from django.db.backends.base.base import BaseDatabaseWrapper
8+
9+
10+
def connection_requires_manual_exclusive_transaction(
11+
connection: BaseDatabaseWrapper,
12+
) -> bool:
13+
"""
14+
Determine whether the backend requires manual transaction handling.
15+
16+
Extracted from `exclusive_transaction` for unit testing purposes.
17+
"""
18+
if connection.vendor != "sqlite":
19+
return False
20+
21+
if django.VERSION < (5, 1):
22+
return True
23+
24+
return connection.transaction_mode != "EXCLUSIVE" # type:ignore[attr-defined,no-any-return]
625

726

827
@contextmanager
@@ -12,12 +31,12 @@ def exclusive_transaction(using: Optional[str] = None) -> Generator[Any, Any, An
1231
1332
This functionality is built-in to Django 5.1+.
1433
"""
15-
connection = transaction.get_connection(using)
34+
connection: BaseDatabaseWrapper = transaction.get_connection(using)
35+
36+
if connection_requires_manual_exclusive_transaction(connection):
37+
if django.VERSION >= (5, 1):
38+
raise RuntimeError("Transactions must be EXCLUSIVE")
1639

17-
if (
18-
connection.vendor == "sqlite"
19-
and getattr(connection, "transaction_mode", None) != "EXCLUSIVE"
20-
):
2140
with connection.cursor() as c:
2241
c.execute("BEGIN EXCLUSIVE")
2342
try:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ classifiers = [
2828
"Framework :: Django",
2929
"Framework :: Django :: 4.2",
3030
"Framework :: Django :: 5.0",
31+
"Framework :: Django :: 5.1",
3132
"Intended Audience :: Developers",
3233
"Operating System :: OS Independent",
3334
"Natural Language :: English",

tests/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import sys
33

44
import dj_database_url
5+
import django
56

67
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
78

@@ -60,6 +61,9 @@
6061
)
6162
}
6263

64+
# Set exclusive transactions in 5.1+
65+
if django.VERSION >= (5, 1) and "sqlite" in DATABASES["default"]["ENGINE"]:
66+
DATABASES["default"].setdefault("OPTIONS", {})["transaction_mode"] = "EXCLUSIVE"
6367

6468
USE_TZ = True
6569

tests/tests/test_database_backend.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
from typing import Sequence, Union, cast
88
from unittest import mock, skipIf
99

10+
import django
1011
from django.core.exceptions import SuspiciousOperation
1112
from django.core.management import call_command, execute_from_command_line
1213
from django.db import connection, connections, transaction
1314
from django.db.models import QuerySet
1415
from django.db.utils import IntegrityError, OperationalError
15-
from django.test import TransactionTestCase, override_settings
16+
from django.test import TestCase, TransactionTestCase, override_settings
1617
from django.urls import reverse
1718
from django.utils import timezone
1819

@@ -22,7 +23,11 @@
2223
logger as db_worker_logger,
2324
)
2425
from django_tasks.backends.database.models import DBTaskResult
25-
from django_tasks.backends.database.utils import exclusive_transaction, normalize_uuid
26+
from django_tasks.backends.database.utils import (
27+
connection_requires_manual_exclusive_transaction,
28+
exclusive_transaction,
29+
normalize_uuid,
30+
)
2631
from django_tasks.exceptions import ResultDoesNotExist
2732
from tests import tasks as test_tasks
2833

@@ -211,7 +216,7 @@ def test_missing_task_path(self) -> None:
211216
def test_check(self) -> None:
212217
errors = list(default_task_backend.check())
213218

214-
self.assertEqual(len(errors), 0)
219+
self.assertEqual(len(errors), 0, errors)
215220

216221
@override_settings(INSTALLED_APPS=[])
217222
def test_database_backend_app_missing(self) -> None:
@@ -873,8 +878,46 @@ def test_get_locked_with_locked_rows(self) -> None:
873878
normalize_uuid(result_2.id),
874879
)
875880
self.assertEqual(
876-
normalize_uuid(DBTaskResult.objects.get_locked().id), # type:ignore[union-attr]
881+
normalize_uuid(DBTaskResult.objects.get_locked().id), # type:ignore
877882
normalize_uuid(result_2.id),
878883
)
879884
finally:
880885
new_connection.close()
886+
887+
888+
class ConnectionExclusiveTranscationTestCase(TestCase):
889+
def setUp(self) -> None:
890+
self.connection = connections.create_connection("default")
891+
892+
def tearDown(self) -> None:
893+
self.connection.close()
894+
895+
@skipIf(connection.vendor == "sqlite", "SQLite handled separately")
896+
def test_non_sqlite(self) -> None:
897+
self.assertFalse(
898+
connection_requires_manual_exclusive_transaction(self.connection)
899+
)
900+
901+
@skipIf(
902+
django.VERSION >= (5, 1),
903+
"Newer Django versions support custom transaction modes",
904+
)
905+
@skipIf(connection.vendor != "sqlite", "SQLite only")
906+
def test_old_django_requires_manual_transaction(self) -> None:
907+
self.assertTrue(
908+
connection_requires_manual_exclusive_transaction(self.connection)
909+
)
910+
911+
@skipIf(django.VERSION < (5, 1), "Old Django versions require manual transactions")
912+
@skipIf(connection.vendor != "sqlite", "SQLite only")
913+
def test_explicit_transaction(self) -> None:
914+
# HACK: Set the attribute manually
915+
self.connection.transaction_mode = None # type:ignore[attr-defined]
916+
self.assertTrue(
917+
connection_requires_manual_exclusive_transaction(self.connection)
918+
)
919+
920+
self.connection.transaction_mode = "EXCLUSIVE" # type:ignore[attr-defined]
921+
self.assertFalse(
922+
connection_requires_manual_exclusive_transaction(self.connection)
923+
)

0 commit comments

Comments
 (0)