From 58f7f24a1d37838db161835f6d504fa5c7a566df Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Mon, 11 Aug 2025 16:54:51 +0200 Subject: [PATCH 01/21] Add async support: run transaction start/stop in an async context when using TestCase (i.e., not transactional) --- pyproject.toml | 4 ++ pytest_django/fixtures.py | 143 +++++++++++++++++++++++++++++++++++++- pytest_django/plugin.py | 45 ++++++++++-- tests/test_async_db.py | 60 ++++++++++++++++ tox.ini | 5 +- 5 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 tests/test_async_db.py diff --git a/pyproject.toml b/pyproject.toml index 36cdfe32..01428127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,10 @@ coverage = [ "coverage[toml]", "coverage-enable-subprocess", ] +async = [ + "asgiref>=3.9.1", + "pytest-asyncio", +] postgres = [ "psycopg[binary]", ] diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index 115dc4cc..c12abb85 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from collections.abc import Generator, Iterable, Sequence +from collections.abc import AsyncGenerator, Generator, Iterable, Sequence from contextlib import AbstractContextManager, contextmanager from functools import partial from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Protocol, Union @@ -202,7 +202,7 @@ def django_db_setup( @pytest.fixture -def _django_db_helper( +def _sync_django_db_helper( request: pytest.FixtureRequest, django_db_setup: None, django_db_blocker: DjangoDbBlocker, @@ -298,6 +298,145 @@ def tearDownClass(cls) -> None: PytestDjangoTestCase.doClassCleanups() +try: + import pytest_asyncio +except ImportError: + + async def _async_django_db_helper( + request: pytest.FixtureRequest, + django_db_blocker: DjangoDbBlocker, + ) -> AsyncGenerator[None, None]: + raise RuntimeError( + "The `pytest_asyncio` plugin is required to use the `async_django_db` fixture." + ) + yield # pragma: no cover +else: + + @pytest_asyncio.fixture + async def _async_django_db_helper( + request: pytest.FixtureRequest, + django_db_blocker: DjangoDbBlocker, + ) -> AsyncGenerator[None, None]: + # same as _sync_django_db_helper, except for running the transaction start and rollback wrapped in a + # `sync_to_async` call + if is_django_unittest(request): + yield + return + transactional, reset_sequences, databases, serialized_rollback, available_apps = ( + _get_django_db_settings(request) + ) + + with django_db_blocker.unblock(async_only=True): + import django.db + import django.test + + if transactional: + test_case_class = django.test.TransactionTestCase + else: + test_case_class = django.test.TestCase + + _reset_sequences = reset_sequences + _serialized_rollback = serialized_rollback + _databases = databases + _available_apps = available_apps + + class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] + reset_sequences = _reset_sequences + serialized_rollback = _serialized_rollback + if _databases is not None: + databases = _databases + if _available_apps is not None: + available_apps = _available_apps + + # For non-transactional tests, skip executing `django.test.TestCase`'s + # `setUpClass`/`tearDownClass`, only execute the super class ones. + # + # `TestCase`'s class setup manages the `setUpTestData`/class-level + # transaction functionality. We don't use it; instead we (will) offer + # our own alternatives. So it only adds overhead, and does some things + # which conflict with our (planned) functionality, particularly, it + # closes all database connections in `tearDownClass` which inhibits + # wrapping tests in higher-scoped transactions. + # + # It's possible a new version of Django will add some unrelated + # functionality to these methods, in which case skipping them completely + # would not be desirable. Let's cross that bridge when we get there... + if not transactional: + + @classmethod + def setUpClass(cls) -> None: + super(django.test.TestCase, cls).setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + super(django.test.TestCase, cls).tearDownClass() + + from asgiref.sync import sync_to_async + + await sync_to_async(PytestDjangoTestCase.setUpClass)() + + test_case = PytestDjangoTestCase(methodName="__init__") + await sync_to_async(test_case._pre_setup, thread_sensitive=True)() + + yield + + await sync_to_async(test_case._post_teardown, thread_sensitive=True)() + + await sync_to_async(PytestDjangoTestCase.tearDownClass)() + + await sync_to_async(PytestDjangoTestCase.doClassCleanups)() + + +def _get_django_db_settings(request: pytest.FixtureRequest) -> _DjangoDb: + django_marker = request.node.get_closest_marker("django_db") + if django_marker: + ( + transactional, + reset_sequences, + databases, + serialized_rollback, + available_apps, + ) = validate_django_db(django_marker) + else: + ( + transactional, + reset_sequences, + databases, + serialized_rollback, + available_apps, + ) = False, False, None, False, None + + transactional = ( + transactional + or reset_sequences + or ("transactional_db" in request.fixturenames or "live_server" in request.fixturenames) + ) + + reset_sequences = reset_sequences or ("django_db_reset_sequences" in request.fixturenames) + serialized_rollback = serialized_rollback or ( + "django_db_serialized_rollback" in request.fixturenames + ) + return transactional, reset_sequences, databases, serialized_rollback, available_apps + + +@pytest.fixture +def _django_db_helper( + request: pytest.FixtureRequest, + django_db_setup: None, + django_db_blocker: DjangoDbBlocker, +): + asyncio_marker = request.node.get_closest_marker("asyncio") + transactional, *_ = _get_django_db_settings(request) + if transactional or not asyncio_marker: + # add the original sync fixture + request.getfixturevalue("_sync_django_db_helper") + else: + # add the async fixture. Will run it inside the event loop, which will cause the sync to async calls to + # start a transaction on the thread safe executor for that loop. This allows us to roll back orm calls made + # in that async test context. + request.getfixturevalue("_async_django_db_helper") + + def _django_db_signature( transaction: bool = False, reset_sequences: bool = False, diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 9bab8971..cebfa52d 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -11,18 +11,21 @@ import os import pathlib import sys +import threading import types from collections.abc import Generator from contextlib import AbstractContextManager from functools import reduce -from typing import TYPE_CHECKING, NoReturn +from typing import TYPE_CHECKING, Any, Callable, NoReturn import pytest from .django_compat import is_django_unittest from .fixtures import ( + _async_django_db_helper, # noqa: F401 _django_db_helper, # noqa: F401 _live_server_helper, # noqa: F401 + _sync_django_db_helper, # noqa: F401 admin_client, # noqa: F401 admin_user, # noqa: F401 async_client, # noqa: F401 @@ -815,7 +818,7 @@ def __init__(self, *, _ispytest: bool = False) -> None: ) self._history = [] # type: ignore[var-annotated] - self._real_ensure_connection = None + self._real_ensure_connection: None | Callable[[Any], Any] = None @property def _dj_db_wrapper(self) -> django.db.backends.base.base.BaseDatabaseWrapper: @@ -831,7 +834,7 @@ def _dj_db_wrapper(self) -> django.db.backends.base.base.BaseDatabaseWrapper: def _save_active_wrapper(self) -> None: self._history.append(self._dj_db_wrapper.ensure_connection) - def _blocking_wrapper(*args, **kwargs) -> NoReturn: + def _blocking_wrapper(self, *args, **kwargs) -> NoReturn: __tracebackhide__ = True raise RuntimeError( "Database access not allowed, " @@ -839,10 +842,42 @@ def _blocking_wrapper(*args, **kwargs) -> NoReturn: '"db" or "transactional_db" fixtures to enable it.' ) - def unblock(self) -> AbstractContextManager[None]: + def _unblocked_async_only(self, *args, **kwargs): + __tracebackhide__ = True + from asgiref.sync import SyncToAsync + + is_in_sync_to_async_thread = ( + next(iter(SyncToAsync.single_thread_executor._threads)) == threading.current_thread() + ) + if not is_in_sync_to_async_thread: + raise RuntimeError( + "Database access is only allowed in an async context, " + "modify your test fixtures to be async or use the transactional_db fixture." + ) + elif self._real_ensure_connection is not None: + self._real_ensure_connection(*args, **kwargs) + + def _unblocked_sync_only(self, *args, **kwargs): + __tracebackhide__ = True + if threading.current_thread() != threading.main_thread(): + raise RuntimeError( + "Database access is only allowed in the main thread, " + "modify your test fixtures to be sync or use the transactional_db fixture." + ) + elif self._real_ensure_connection is not None: + self._real_ensure_connection(*args, **kwargs) + + def unblock(self, sync_only=False, async_only=False) -> AbstractContextManager[None]: """Enable access to the Django database.""" + if sync_only and async_only: + raise ValueError("Cannot use both sync_only and async_only. Choose at most one.") self._save_active_wrapper() - self._dj_db_wrapper.ensure_connection = self._real_ensure_connection + if sync_only: + self._dj_db_wrapper.ensure_connection = self._unblocked_sync_only + elif async_only: + self._dj_db_wrapper.ensure_connection = self._unblocked_async_only + else: + self._dj_db_wrapper.ensure_connection = self._real_ensure_connection return _DatabaseBlockerContextManager(self) def block(self) -> AbstractContextManager[None]: diff --git a/tests/test_async_db.py b/tests/test_async_db.py new file mode 100644 index 00000000..5ed005f3 --- /dev/null +++ b/tests/test_async_db.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable +from typing import Any, Callable, ParamSpec, TypeVar, Union + +import pytest + +from pytest_django_test.app.models import Item + + +_R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) +_P = ParamSpec("_P") +FixtureFunction = Callable[_P, _R] + +try: + import pytest_asyncio +except ImportError: + pytestmark: Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] = pytest.mark.skip( + "pytest-asyncio is not installed" + ) + fixturemark: Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] = pytest.mark.skip( + "pytest-asyncio is not installed" + ) + +else: + pytestmark = pytest.mark.asyncio + fixturemark = pytest_asyncio.fixture + + +@pytest.mark.parametrize("run_number", [1, 2]) +@pytestmark +@pytest.mark.django_db +async def test_async_db(db, run_number) -> None: + # test async database usage remains isolated between tests + + assert await Item.objects.acount() == 0 + # make a new item instance, to be rolled back by the transaction wrapper before the next parametrized run + await Item.objects.acreate(name="blah") + assert await Item.objects.acount() == 1 + + +@fixturemark +async def db_item(db) -> Any: + return await Item.objects.acreate(name="async") + + +@pytest.fixture +def sync_db_item(db) -> Any: + return Item.objects.create(name="sync") + + +@pytestmark +@pytest.mark.xfail(strict=True, reason="Sync fixture used in async test") +async def test_db_item(db_item: Item, sync_db_item) -> None: + pass + + +@pytest.mark.xfail(strict=True, reason="Async fixture used in sync test") +def test_sync_db_item(async_db_item: Item, sync_db_item) -> None: + pass diff --git a/tox.ini b/tox.ini index 59d4cb57..c55535d0 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ envlist = [testenv] dependency_groups = testing + !dj42: async coverage: coverage mysql: mysql postgres: postgres @@ -43,7 +44,9 @@ commands = coverage: coverage xml [testenv:linting] -dependency_groups = linting +dependency_groups = + linting + async commands = ruff check --diff {posargs:pytest_django pytest_django_test tests} ruff format --quiet --diff {posargs:pytest_django pytest_django_test tests} From 3ee56c4e35a9126f06e2eb52d0f7df62e42155ab Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Mon, 11 Aug 2025 16:59:02 +0200 Subject: [PATCH 02/21] Revertme: test pipeline --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b6fb5cb8..42c8bbad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - 'fix/*' tags: - "*" pull_request: From 35154602ee48a4b9cc8c11b9238eb221c5ed1a0d Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Mon, 11 Aug 2025 17:11:05 +0200 Subject: [PATCH 03/21] Move paramspec into type check block for python 3.9 --- tests/test_async_db.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_async_db.py b/tests/test_async_db.py index 5ed005f3..a7a3432b 100644 --- a/tests/test_async_db.py +++ b/tests/test_async_db.py @@ -1,24 +1,27 @@ from __future__ import annotations from collections.abc import AsyncIterator, Awaitable -from typing import Any, Callable, ParamSpec, TypeVar, Union +from typing import TYPE_CHECKING import pytest from pytest_django_test.app.models import Item -_R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) -_P = ParamSpec("_P") -FixtureFunction = Callable[_P, _R] +if TYPE_CHECKING: + from typing import Any, Callable, ParamSpec, TypeVar, Union + + _R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) + _P = ParamSpec("_P") + FixtureFunction = Callable[_P, _R] try: import pytest_asyncio except ImportError: - pytestmark: Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] = pytest.mark.skip( + pytestmark: "Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]]" = pytest.mark.skip( # noqa: UP037 "pytest-asyncio is not installed" ) - fixturemark: Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] = pytest.mark.skip( + fixturemark: "Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]]" = pytest.mark.skip( # noqa: UP037 "pytest-asyncio is not installed" ) From 3f1fe986c15146c109889d18ccb7c0ee63279337 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Mon, 11 Aug 2025 17:12:35 +0200 Subject: [PATCH 04/21] Revertme: remove cov step --- .github/workflows/main.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 42c8bbad..59174c38 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,14 +66,6 @@ jobs: sarif_file: zizmor.sarif category: zizmor - - name: Report coverage - if: contains(matrix.name, 'coverage') - uses: codecov/codecov-action@v5 - with: - fail_ci_if_error: true - files: ./coverage.xml - token: ${{ secrets.CODECOV_TOKEN }} - strategy: fail-fast: false matrix: From 1a098502c196c30f1da6607b813576616f308dfb Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Mon, 11 Aug 2025 19:04:30 +0200 Subject: [PATCH 05/21] Fix type hints --- tests/test_async_db.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/tests/test_async_db.py b/tests/test_async_db.py index a7a3432b..be36c1e4 100644 --- a/tests/test_async_db.py +++ b/tests/test_async_db.py @@ -1,33 +1,22 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Awaitable -from typing import TYPE_CHECKING +from typing import Any, cast import pytest +from _pytest.mark import MarkDecorator from pytest_django_test.app.models import Item -if TYPE_CHECKING: - from typing import Any, Callable, ParamSpec, TypeVar, Union - - _R = TypeVar("_R", bound=Union[Awaitable[Any], AsyncIterator[Any]]) - _P = ParamSpec("_P") - FixtureFunction = Callable[_P, _R] - try: import pytest_asyncio except ImportError: - pytestmark: "Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]]" = pytest.mark.skip( # noqa: UP037 - "pytest-asyncio is not installed" - ) - fixturemark: "Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]]" = pytest.mark.skip( # noqa: UP037 - "pytest-asyncio is not installed" - ) + pytestmark: MarkDecorator = pytest.mark.skip("pytest-asyncio is not installed") + fixturemark: MarkDecorator = pytest.mark.skip("pytest-asyncio is not installed") else: pytestmark = pytest.mark.asyncio - fixturemark = pytest_asyncio.fixture + fixturemark = cast(MarkDecorator, pytest_asyncio.fixture) @pytest.mark.parametrize("run_number", [1, 2]) From 629cafd435bf211a4152552b509b17255bef7733 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 10:09:20 +0200 Subject: [PATCH 06/21] Add docs for async, thread checks, refer to docs in plugin errors --- docs/database.rst | 82 +++++++++++++++++++++++++++++++++++++++ pytest_django/fixtures.py | 2 +- pytest_django/plugin.py | 4 +- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/database.rst b/docs/database.rst index fcdd219a..a7070e3f 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -60,6 +60,87 @@ select using an argument to the ``django_db`` mark:: def test_spam(): pass # test relying on transactions + +Async tests and database transactions +------------------------------------- + +``pytest-django`` supports async tests that use Django's async ORM APIs. +This requires the `pytest-asyncio `_ +plugin and marking your tests appropriately. + +Requirements +"""""""""""" + +- Install ``pytest-asyncio``. +- Mark async tests with both ``@pytest.mark.asyncio`` and + ``@pytest.mark.django_db`` (or request the ``db``/``transactional_db`` fixtures). + +Example (async ORM with transactional rollback per test):: + + import pytest + + @pytest.mark.asyncio + @pytest.mark.django_db + async def test_async_db_is_isolated(): + assert await Item.objects.acount() == 0 + await Item.objects.acreate(name="example") + assert await Item.objects.acount() == 1 + # changes are rolled back after the test + +.. _`async-db-behavior`: + +Behavior of ``db`` in async tests +""""""""""""""""""""""""""""""""" + +Tests using ``db`` wrap each test in a transaction and roll that transaction back at the end +(like ``django.test.TestCase``). In Django, transactions are bound to the database +connection, which is unique per thread. This means that all your database changes +must be made within the same thread to ensure they are rolled back before the next test. + +Django Async ORM calls, as of writing, use the ``asgiref.sync.sync_to_async`` +decorator to run the ORM calls on a dedicated thread executor. + +For async tests, pytest-django ensures the transaction +setup/teardown happens via ``asgiref.sync.sync_to_async``, which means the transaction is started & run on the +same thread on which async orm calls inside your test, like ``aget()`` are made. This ensures your test code +can safely modify the database using the async calls, as all its queries will be rolled back after the test. + +Tests using ``transactional_db`` flush the database between tests. This means that no matter in which thread +your test modifies the database, the changes will be removed after the test. This means you can avoid thinking +about sync/async database access if your test uses ``transactional_db``, at the cost of slower tests: +A flush is generally slower than rolling back a transaction. + +.. _`db-thread-safeguards`: +Safeguards against database access from different threads +""""""""""""""""""""""""""""""""""""""""""""""""""""""""" +When using the database in a test with transaction rollback, you must ensure that +database access is only done from the same thread that the test is running on. + +To avoid your fixtures/tests making changes outside the test thread, and as a result, the transaction, pytest-django +actively restricts where database connections may be opened: + +- In async tests using ``db``: database access is only allowed from the single + thread used by ``SyncToAsync``. Using sync fixtures that touch the database in + an async test will raise:: + + RuntimeError: Database access is only allowed in an async context, modify your + test fixtures to be async or use the transactional_db fixture. + + Fix by converting those fixtures to async (use ``pytest_asyncio.fixture``) and + using Django's async ORM methods (e.g. ``.acreate()``, ``.aget()``, ``.acount()``), + or by requesting ``transactional_db`` if you must keep sync fixtures. + See :ref:`async-db-behavior` for more details. + +- In sync tests: database access is only allowed from the main thread. Attempting to use the database connection + from a different thread will raise:: + + RuntimeError: Database access is only allowed in the main thread, modify your + test fixtures to be sync or use the transactional_db fixture. + + Fix this by ensuring all database transactions run in the main thread (e.g., avoiding the use of async fixtures), + or use ``transactional_db`` to allow mixing. + + .. _`multi-db`: Tests requiring multiple databases @@ -524,3 +605,4 @@ Put this in ``conftest.py``:: django_db_blocker.unblock() yield django_db_blocker.restore() + diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index c12abb85..3ec25501 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -239,7 +239,7 @@ def _sync_django_db_helper( "django_db_serialized_rollback" in request.fixturenames ) - with django_db_blocker.unblock(): + with django_db_blocker.unblock(sync_only=not transactional): import django.db import django.test diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index cebfa52d..28525b8a 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -839,7 +839,7 @@ def _blocking_wrapper(self, *args, **kwargs) -> NoReturn: raise RuntimeError( "Database access not allowed, " 'use the "django_db" mark, or the ' - '"db" or "transactional_db" fixtures to enable it.' + '"db" or "transactional_db" fixtures to enable it. ' ) def _unblocked_async_only(self, *args, **kwargs): @@ -853,6 +853,7 @@ def _unblocked_async_only(self, *args, **kwargs): raise RuntimeError( "Database access is only allowed in an async context, " "modify your test fixtures to be async or use the transactional_db fixture." + "See https://pytest-django.readthedocs.io/en/latest/database.html#db-thread-safeguards for more information." ) elif self._real_ensure_connection is not None: self._real_ensure_connection(*args, **kwargs) @@ -863,6 +864,7 @@ def _unblocked_sync_only(self, *args, **kwargs): raise RuntimeError( "Database access is only allowed in the main thread, " "modify your test fixtures to be sync or use the transactional_db fixture." + "See https://pytest-django.readthedocs.io/en/latest/database.html#db-thread-safeguards for more information." ) elif self._real_ensure_connection is not None: self._real_ensure_connection(*args, **kwargs) From 4a7966473b5cd6902b2045bfa5859010081c9271 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 10:30:12 +0200 Subject: [PATCH 07/21] Revert "Revertme: test pipeline" This reverts commit 3ee56c4e35a9126f06e2eb52d0f7df62e42155ab. --- .github/workflows/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 59174c38..72e4af0b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - 'fix/*' tags: - "*" pull_request: From f46bac0897c8ae4411da0de8e18d0226d0ebdfbc Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 10:30:17 +0200 Subject: [PATCH 08/21] Revert "Revertme: remove cov step" This reverts commit 3f1fe986c15146c109889d18ccb7c0ee63279337. --- .github/workflows/main.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 72e4af0b..b6fb5cb8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -65,6 +65,14 @@ jobs: sarif_file: zizmor.sarif category: zizmor + - name: Report coverage + if: contains(matrix.name, 'coverage') + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + strategy: fail-fast: false matrix: From 767381451cad83a2420a78dde605e993e2792fd0 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 10:47:57 +0200 Subject: [PATCH 09/21] Drop sync extra checks --- docs/database.rst | 9 --------- pytest_django/plugin.py | 19 ++----------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/docs/database.rst b/docs/database.rst index a7070e3f..b49683d3 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -131,15 +131,6 @@ actively restricts where database connections may be opened: or by requesting ``transactional_db`` if you must keep sync fixtures. See :ref:`async-db-behavior` for more details. -- In sync tests: database access is only allowed from the main thread. Attempting to use the database connection - from a different thread will raise:: - - RuntimeError: Database access is only allowed in the main thread, modify your - test fixtures to be sync or use the transactional_db fixture. - - Fix this by ensuring all database transactions run in the main thread (e.g., avoiding the use of async fixtures), - or use ``transactional_db`` to allow mixing. - .. _`multi-db`: diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 28525b8a..9cd7a20d 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -858,25 +858,10 @@ def _unblocked_async_only(self, *args, **kwargs): elif self._real_ensure_connection is not None: self._real_ensure_connection(*args, **kwargs) - def _unblocked_sync_only(self, *args, **kwargs): - __tracebackhide__ = True - if threading.current_thread() != threading.main_thread(): - raise RuntimeError( - "Database access is only allowed in the main thread, " - "modify your test fixtures to be sync or use the transactional_db fixture." - "See https://pytest-django.readthedocs.io/en/latest/database.html#db-thread-safeguards for more information." - ) - elif self._real_ensure_connection is not None: - self._real_ensure_connection(*args, **kwargs) - - def unblock(self, sync_only=False, async_only=False) -> AbstractContextManager[None]: + def unblock(self, async_only=False) -> AbstractContextManager[None]: """Enable access to the Django database.""" - if sync_only and async_only: - raise ValueError("Cannot use both sync_only and async_only. Choose at most one.") self._save_active_wrapper() - if sync_only: - self._dj_db_wrapper.ensure_connection = self._unblocked_sync_only - elif async_only: + if async_only: self._dj_db_wrapper.ensure_connection = self._unblocked_async_only else: self._dj_db_wrapper.ensure_connection = self._real_ensure_connection From 061ca4b82275687dcd1bebd8a2905383c35194d2 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 10:55:40 +0200 Subject: [PATCH 10/21] Fix docs layout --- docs/database.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/database.rst b/docs/database.rst index b49683d3..f27ef0f1 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -69,7 +69,7 @@ This requires the `pytest-asyncio plugin and marking your tests appropriately. Requirements -"""""""""""" +------------ - Install ``pytest-asyncio``. - Mark async tests with both ``@pytest.mark.asyncio`` and @@ -90,7 +90,7 @@ Example (async ORM with transactional rollback per test):: .. _`async-db-behavior`: Behavior of ``db`` in async tests -""""""""""""""""""""""""""""""""" +--------------------------------- Tests using ``db`` wrap each test in a transaction and roll that transaction back at the end (like ``django.test.TestCase``). In Django, transactions are bound to the database @@ -111,8 +111,9 @@ about sync/async database access if your test uses ``transactional_db``, at the A flush is generally slower than rolling back a transaction. .. _`db-thread-safeguards`: + Safeguards against database access from different threads -""""""""""""""""""""""""""""""""""""""""""""""""""""""""" +--------------------------------------------------------- When using the database in a test with transaction rollback, you must ensure that database access is only done from the same thread that the test is running on. From cca2dff3222fe778df14ba74c061541d9d01ae6b Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 10:57:31 +0200 Subject: [PATCH 11/21] Remove unblock kwarg from sync --- pytest_django/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index 3ec25501..c12abb85 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -239,7 +239,7 @@ def _sync_django_db_helper( "django_db_serialized_rollback" in request.fixturenames ) - with django_db_blocker.unblock(sync_only=not transactional): + with django_db_blocker.unblock(): import django.db import django.test From 1bf9be64ecde09b15571f29edf950fc1c3e8a475 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 11:18:22 +0200 Subject: [PATCH 12/21] Reduce code duplication: reuse test case class creation logic for sync & async fixtures --- pytest_django/fixtures.py | 137 ++++++++++++++++---------------------- 1 file changed, 59 insertions(+), 78 deletions(-) diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index c12abb85..77cc48ff 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -201,6 +201,47 @@ def django_db_setup( ) +def _build_pytest_django_test_case( + test_case_class, + *, + reset_sequences: bool, + serialized_rollback: bool, + databases, + available_apps, + skip_django_testcase_class_setup: bool, +): + # Build a custom TestCase subclass with configured attributes and optional + # overrides to skip Django's TestCase class-level setup/teardown. + import django.test # local import to avoid hard dependency at import time + + _reset_sequences = reset_sequences + _serialized_rollback = serialized_rollback + _databases = databases + _available_apps = available_apps + + class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] + reset_sequences = _reset_sequences + serialized_rollback = _serialized_rollback + if _databases is not None: + databases = _databases + if _available_apps is not None: + available_apps = _available_apps + + if skip_django_testcase_class_setup: + + @classmethod + def setUpClass(cls) -> None: # type: ignore[override] + # Skip django.test.TestCase.setUpClass, call its super instead + super(django.test.TestCase, cls).setUpClass() + + @classmethod + def tearDownClass(cls) -> None: # type: ignore[override] + # Skip django.test.TestCase.tearDownClass, call its super instead + super(django.test.TestCase, cls).tearDownClass() + + return PytestDjangoTestCase + + @pytest.fixture def _sync_django_db_helper( request: pytest.FixtureRequest, @@ -248,41 +289,14 @@ def _sync_django_db_helper( else: test_case_class = django.test.TestCase - _reset_sequences = reset_sequences - _serialized_rollback = serialized_rollback - _databases = databases - _available_apps = available_apps - - class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] - reset_sequences = _reset_sequences - serialized_rollback = _serialized_rollback - if _databases is not None: - databases = _databases - if _available_apps is not None: - available_apps = _available_apps - - # For non-transactional tests, skip executing `django.test.TestCase`'s - # `setUpClass`/`tearDownClass`, only execute the super class ones. - # - # `TestCase`'s class setup manages the `setUpTestData`/class-level - # transaction functionality. We don't use it; instead we (will) offer - # our own alternatives. So it only adds overhead, and does some things - # which conflict with our (planned) functionality, particularly, it - # closes all database connections in `tearDownClass` which inhibits - # wrapping tests in higher-scoped transactions. - # - # It's possible a new version of Django will add some unrelated - # functionality to these methods, in which case skipping them completely - # would not be desirable. Let's cross that bridge when we get there... - if not transactional: - - @classmethod - def setUpClass(cls) -> None: - super(django.test.TestCase, cls).setUpClass() - - @classmethod - def tearDownClass(cls) -> None: - super(django.test.TestCase, cls).tearDownClass() + PytestDjangoTestCase = _build_pytest_django_test_case( + test_case_class, + reset_sequences=reset_sequences, + serialized_rollback=serialized_rollback, + databases=databases, + available_apps=available_apps, + skip_django_testcase_class_setup=(not transactional), + ) PytestDjangoTestCase.setUpClass() @@ -319,9 +333,6 @@ async def _async_django_db_helper( ) -> AsyncGenerator[None, None]: # same as _sync_django_db_helper, except for running the transaction start and rollback wrapped in a # `sync_to_async` call - if is_django_unittest(request): - yield - return transactional, reset_sequences, databases, serialized_rollback, available_apps = ( _get_django_db_settings(request) ) @@ -330,46 +341,16 @@ async def _async_django_db_helper( import django.db import django.test - if transactional: - test_case_class = django.test.TransactionTestCase - else: - test_case_class = django.test.TestCase - - _reset_sequences = reset_sequences - _serialized_rollback = serialized_rollback - _databases = databases - _available_apps = available_apps - - class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] - reset_sequences = _reset_sequences - serialized_rollback = _serialized_rollback - if _databases is not None: - databases = _databases - if _available_apps is not None: - available_apps = _available_apps - - # For non-transactional tests, skip executing `django.test.TestCase`'s - # `setUpClass`/`tearDownClass`, only execute the super class ones. - # - # `TestCase`'s class setup manages the `setUpTestData`/class-level - # transaction functionality. We don't use it; instead we (will) offer - # our own alternatives. So it only adds overhead, and does some things - # which conflict with our (planned) functionality, particularly, it - # closes all database connections in `tearDownClass` which inhibits - # wrapping tests in higher-scoped transactions. - # - # It's possible a new version of Django will add some unrelated - # functionality to these methods, in which case skipping them completely - # would not be desirable. Let's cross that bridge when we get there... - if not transactional: - - @classmethod - def setUpClass(cls) -> None: - super(django.test.TestCase, cls).setUpClass() - - @classmethod - def tearDownClass(cls) -> None: - super(django.test.TestCase, cls).tearDownClass() + test_case_class = django.test.TestCase + + PytestDjangoTestCase = _build_pytest_django_test_case( + test_case_class, + reset_sequences=reset_sequences, + serialized_rollback=serialized_rollback, + databases=databases, + available_apps=available_apps, + skip_django_testcase_class_setup=True, + ) from asgiref.sync import sync_to_async From 0525d62efcdf3047c283e512acba623d0ce83f03 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 11:40:59 +0200 Subject: [PATCH 13/21] Revert "Drop sync extra checks" This reverts commit 767381451cad83a2420a78dde605e993e2792fd0. --- docs/database.rst | 9 +++++++++ pytest_django/plugin.py | 19 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/database.rst b/docs/database.rst index f27ef0f1..8f1511c8 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -132,6 +132,15 @@ actively restricts where database connections may be opened: or by requesting ``transactional_db`` if you must keep sync fixtures. See :ref:`async-db-behavior` for more details. +- In sync tests: database access is only allowed from the main thread. Attempting to use the database connection + from a different thread will raise:: + + RuntimeError: Database access is only allowed in the main thread, modify your + test fixtures to be sync or use the transactional_db fixture. + + Fix this by ensuring all database transactions run in the main thread (e.g., avoiding the use of async fixtures), + or use ``transactional_db`` to allow mixing. + .. _`multi-db`: diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 9cd7a20d..28525b8a 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -858,10 +858,25 @@ def _unblocked_async_only(self, *args, **kwargs): elif self._real_ensure_connection is not None: self._real_ensure_connection(*args, **kwargs) - def unblock(self, async_only=False) -> AbstractContextManager[None]: + def _unblocked_sync_only(self, *args, **kwargs): + __tracebackhide__ = True + if threading.current_thread() != threading.main_thread(): + raise RuntimeError( + "Database access is only allowed in the main thread, " + "modify your test fixtures to be sync or use the transactional_db fixture." + "See https://pytest-django.readthedocs.io/en/latest/database.html#db-thread-safeguards for more information." + ) + elif self._real_ensure_connection is not None: + self._real_ensure_connection(*args, **kwargs) + + def unblock(self, sync_only=False, async_only=False) -> AbstractContextManager[None]: """Enable access to the Django database.""" + if sync_only and async_only: + raise ValueError("Cannot use both sync_only and async_only. Choose at most one.") self._save_active_wrapper() - if async_only: + if sync_only: + self._dj_db_wrapper.ensure_connection = self._unblocked_sync_only + elif async_only: self._dj_db_wrapper.ensure_connection = self._unblocked_async_only else: self._dj_db_wrapper.ensure_connection = self._real_ensure_connection From 2c1d21b877bd06d3d83627f5571f9af1f4093b1b Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 11:41:26 +0200 Subject: [PATCH 14/21] Revert "Remove unblock kwarg from sync" This reverts commit cca2dff3222fe778df14ba74c061541d9d01ae6b. --- pytest_django/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index 77cc48ff..8d727a2f 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -280,7 +280,7 @@ def _sync_django_db_helper( "django_db_serialized_rollback" in request.fixturenames ) - with django_db_blocker.unblock(): + with django_db_blocker.unblock(sync_only=not transactional): import django.db import django.test From 95b03a0768effef869b8fb43a222b6c6ffb0f164 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 12:02:14 +0200 Subject: [PATCH 15/21] Wrap patched method with one containing explicit wrapped self to fix functionality for django<5.1 --- pytest_django/plugin.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 28525b8a..070668f1 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -842,7 +842,7 @@ def _blocking_wrapper(self, *args, **kwargs) -> NoReturn: '"db" or "transactional_db" fixtures to enable it. ' ) - def _unblocked_async_only(self, *args, **kwargs): + def _unblocked_async_only(self, wrapper_self, *args, **kwargs): __tracebackhide__ = True from asgiref.sync import SyncToAsync @@ -856,9 +856,9 @@ def _unblocked_async_only(self, *args, **kwargs): "See https://pytest-django.readthedocs.io/en/latest/database.html#db-thread-safeguards for more information." ) elif self._real_ensure_connection is not None: - self._real_ensure_connection(*args, **kwargs) + self._real_ensure_connection(wrapper_self, *args, **kwargs) - def _unblocked_sync_only(self, *args, **kwargs): + def _unblocked_sync_only(self, wrapper_self, *args, **kwargs): __tracebackhide__ = True if threading.current_thread() != threading.main_thread(): raise RuntimeError( @@ -867,7 +867,7 @@ def _unblocked_sync_only(self, *args, **kwargs): "See https://pytest-django.readthedocs.io/en/latest/database.html#db-thread-safeguards for more information." ) elif self._real_ensure_connection is not None: - self._real_ensure_connection(*args, **kwargs) + self._real_ensure_connection(wrapper_self, *args, **kwargs) def unblock(self, sync_only=False, async_only=False) -> AbstractContextManager[None]: """Enable access to the Django database.""" @@ -875,9 +875,15 @@ def unblock(self, sync_only=False, async_only=False) -> AbstractContextManager[N raise ValueError("Cannot use both sync_only and async_only. Choose at most one.") self._save_active_wrapper() if sync_only: - self._dj_db_wrapper.ensure_connection = self._unblocked_sync_only + def _method(wrapper_self, *args, **kwargs): + return self._unblocked_sync_only(wrapper_self, *args, **kwargs) + + self._dj_db_wrapper.ensure_connection = _method elif async_only: - self._dj_db_wrapper.ensure_connection = self._unblocked_async_only + def _method(wrapper_self, *args, **kwargs): + return self._unblocked_async_only(wrapper_self, *args, **kwargs) + + self._dj_db_wrapper.ensure_connection = _method else: self._dj_db_wrapper.ensure_connection = self._real_ensure_connection return _DatabaseBlockerContextManager(self) From b219fcb3525c3869cf39898da4cdeffeeb35430e Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 12:03:34 +0200 Subject: [PATCH 16/21] Chore: reformat --- pytest_django/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 070668f1..071c6dbe 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -875,11 +875,13 @@ def unblock(self, sync_only=False, async_only=False) -> AbstractContextManager[N raise ValueError("Cannot use both sync_only and async_only. Choose at most one.") self._save_active_wrapper() if sync_only: + def _method(wrapper_self, *args, **kwargs): return self._unblocked_sync_only(wrapper_self, *args, **kwargs) self._dj_db_wrapper.ensure_connection = _method elif async_only: + def _method(wrapper_self, *args, **kwargs): return self._unblocked_async_only(wrapper_self, *args, **kwargs) From 593d02fc66463d6fee211deec6a53ea3841edf8c Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 12:11:24 +0200 Subject: [PATCH 17/21] Chore: update type hints --- pytest_django/fixtures.py | 6 +++--- pytest_django/plugin.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index 8d727a2f..b70d7f55 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -219,7 +219,7 @@ def _build_pytest_django_test_case( _databases = databases _available_apps = available_apps - class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] + class PytestDjangoTestCase(test_case_class): reset_sequences = _reset_sequences serialized_rollback = _serialized_rollback if _databases is not None: @@ -230,12 +230,12 @@ class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] if skip_django_testcase_class_setup: @classmethod - def setUpClass(cls) -> None: # type: ignore[override] + def setUpClass(cls) -> None: # Skip django.test.TestCase.setUpClass, call its super instead super(django.test.TestCase, cls).setUpClass() @classmethod - def tearDownClass(cls) -> None: # type: ignore[override] + def tearDownClass(cls) -> None: # Skip django.test.TestCase.tearDownClass, call its super instead super(django.test.TestCase, cls).tearDownClass() diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index 071c6dbe..89395ccd 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -842,7 +842,7 @@ def _blocking_wrapper(self, *args, **kwargs) -> NoReturn: '"db" or "transactional_db" fixtures to enable it. ' ) - def _unblocked_async_only(self, wrapper_self, *args, **kwargs): + def _unblocked_async_only(self, wrapper_self: Any, *args, **kwargs): __tracebackhide__ = True from asgiref.sync import SyncToAsync @@ -858,7 +858,7 @@ def _unblocked_async_only(self, wrapper_self, *args, **kwargs): elif self._real_ensure_connection is not None: self._real_ensure_connection(wrapper_self, *args, **kwargs) - def _unblocked_sync_only(self, wrapper_self, *args, **kwargs): + def _unblocked_sync_only(self, wrapper_self: Any, *args, **kwargs): __tracebackhide__ = True if threading.current_thread() != threading.main_thread(): raise RuntimeError( From 3c986bf7667de0db7cc29979388723386ca61d2a Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 12:20:21 +0200 Subject: [PATCH 18/21] Add test case for db access outside main thread in sync context --- tests/test_db_thread_safeguards.py | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_db_thread_safeguards.py diff --git a/tests/test_db_thread_safeguards.py b/tests/test_db_thread_safeguards.py new file mode 100644 index 00000000..225e50dd --- /dev/null +++ b/tests/test_db_thread_safeguards.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import threading + +import pytest + +from pytest_django_test.app.models import Item + + +@pytest.mark.django_db +def test_sync_db_access_in_non_main_thread_is_blocked() -> None: + """ + Ensure that when using the sync django_db helper (non-transactional), + database access from a different thread raises the expected RuntimeError + stating that DB access is only allowed in the main thread. + + Mirrors the intent of the async equivalent test that checks thread + safeguards for async contexts. + """ + captured: list[BaseException | None] = [None] + + def worker() -> None: + try: + # Any ORM operation that touches the DB will attempt to ensure a connection. + # This should raise from the "sync_only" db blocker in non-main threads. + Item.objects.count() + except BaseException as exc: # noqa: BLE001 - we want to capture exactly what is raised + captured[0] = exc + + t = threading.Thread(target=worker) + t.start() + t.join() + + assert captured[0] is not None, "Expected DB access in worker thread to raise an exception" + assert isinstance(captured[0], RuntimeError) + assert "only allowed in the main thread" in str(captured[0]) From 0fb5f3885d83bfc1b49c00eaa973a549b7483d54 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 12:22:26 +0200 Subject: [PATCH 19/21] Add unblock test case when both flags are set --- tests/test_fixtures.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 600ac626..8591aa34 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -750,6 +750,11 @@ def test_unblock_with_block(self, django_db_blocker: DjangoDbBlocker) -> None: with django_db_blocker.unblock(): Item.objects.exists() + def test_unblock_with_both_flags_raises_valueerror(self, django_db_blocker: DjangoDbBlocker) -> None: + # When both sync_only and async_only are True, unblock should reject with ValueError + with pytest.raises(ValueError, match="Cannot use both sync_only and async_only"): + django_db_blocker.unblock(sync_only=True, async_only=True) + def test_mail(mailoutbox) -> None: assert mailoutbox is mail.outbox # check that mail.outbox and fixture value is same object From 4b1274cf2e1ce73ecb1306c73991ec9cb1f035ca Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 12:22:42 +0200 Subject: [PATCH 20/21] chore: format --- tests/test_fixtures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 8591aa34..7bd97042 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -750,7 +750,9 @@ def test_unblock_with_block(self, django_db_blocker: DjangoDbBlocker) -> None: with django_db_blocker.unblock(): Item.objects.exists() - def test_unblock_with_both_flags_raises_valueerror(self, django_db_blocker: DjangoDbBlocker) -> None: + def test_unblock_with_both_flags_raises_valueerror( + self, django_db_blocker: DjangoDbBlocker + ) -> None: # When both sync_only and async_only are True, unblock should reject with ValueError with pytest.raises(ValueError, match="Cannot use both sync_only and async_only"): django_db_blocker.unblock(sync_only=True, async_only=True) From 9aff5fd70437393ec45d24312851ef763c1d0c56 Mon Sep 17 00:00:00 2001 From: Lode Rosseel Date: Tue, 12 Aug 2025 21:35:08 +0200 Subject: [PATCH 21/21] Chore: fix tox indenting --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 4aa2db12..e173a57e 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ envlist = [testenv] dependency_groups = testing - !dj42: async + !dj42: async coverage: coverage mysql: mysql postgres: postgres @@ -45,8 +45,8 @@ commands = [testenv:linting] dependency_groups = - linting - async + linting + async commands = ruff check --diff {posargs:pytest_django pytest_django_test tests} ruff format --quiet --diff {posargs:pytest_django pytest_django_test tests}