diff --git a/django_valkey/async_cache/cache.py b/django_valkey/async_cache/cache.py index 0fbe416..e12a97c 100644 --- a/django_valkey/async_cache/cache.py +++ b/django_valkey/async_cache/cache.py @@ -8,3 +8,4 @@ class AsyncValkeyCache( BaseValkeyCache[AsyncDefaultClient, AValkey], AsyncBackendCommands ): DEFAULT_CLIENT_CLASS = "django_valkey.async_cache.client.default.AsyncDefaultClient" + is_async = True diff --git a/django_valkey/base.py b/django_valkey/base.py index 8e17f37..f717b08 100644 --- a/django_valkey/base.py +++ b/django_valkey/base.py @@ -558,3 +558,21 @@ async def hkeys(self, *args, **kwargs) -> list[Any]: async def hexists(self, *args, **kwargs) -> bool: return await self.client.hexists(*args, **kwargs) + + +# temp fix for django's #36047 +# TODO: remove this when it's fixed in django +from django.core import signals # noqa: E402 +from django.core.cache import caches, close_caches # noqa: E402 + + +async def close_async_caches(**kwargs): + for conn in caches.all(initialized_only=True): + if getattr(conn, "is_async", False): + await conn.aclose() + else: + conn.close() + + +signals.request_finished.connect(close_async_caches) +signals.request_finished.disconnect(close_caches) diff --git a/pyproject.toml b/pyproject.toml index bfb8445..b5934b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ brotli = [ [dependency-groups] dev = [ + "anyio>=4.9.0", "black>=25.1.0", "coverage>=7.8.0", "django-cmd>=2.6", @@ -66,7 +67,6 @@ dev = [ "mypy>=1.15.0", "pre-commit>=4.2.0", "pytest>=8.3.5", - "pytest-asyncio>=0.26.0", "pytest-django>=4.11.1", "pytest-mock>=3.14.0", "pytest-subtests>=0.14.1", @@ -104,8 +104,6 @@ ignore_missing_settings = true [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "tests.settings.sqlite" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" [tool.coverage.run] plugins = ["django_coverage_plugin"] diff --git a/tests/conftest.py b/tests/conftest.py index e4eedeb..4457ea0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ from typing import cast import pytest -import pytest_asyncio from pytest_django.fixtures import SettingsWrapper from asgiref.compatibility import iscoroutinefunction @@ -12,10 +11,12 @@ from django_valkey.base import BaseValkeyCache from django_valkey.cache import ValkeyCache -# for some reason `isawaitable` doesn't work here + +pytestmark = pytest.mark.anyio + if iscoroutinefunction(default_cache.clear): - @pytest_asyncio.fixture(loop_scope="session") + @pytest.fixture(scope="function") async def cache(): yield default_cache await default_cache.aclear() diff --git a/tests/tests_async/conftest.py b/tests/tests_async/conftest.py new file mode 100644 index 0000000..c19998b --- /dev/null +++ b/tests/tests_async/conftest.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +# this keeps the event loop open for the entire test suite +@pytest.fixture(scope="session", autouse=True) +async def keepalive(anyio_backend): + pass diff --git a/tests/tests_async/test_backend.py b/tests/tests_async/test_backend.py index 4c8a9cc..d17ce9e 100644 --- a/tests/tests_async/test_backend.py +++ b/tests/tests_async/test_backend.py @@ -1,4 +1,3 @@ -import asyncio import contextlib import datetime import threading @@ -8,10 +7,11 @@ from unittest.mock import patch, AsyncMock import pytest -import pytest_asyncio from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture +import anyio + from django.core.cache import caches from django.core.cache.backends.base import DEFAULT_TIMEOUT from django.test import override_settings @@ -22,7 +22,10 @@ from django_valkey.serializers.msgpack import MSGPackSerializer -@pytest_asyncio.fixture(loop_scope="session") +pytestmark = pytest.mark.anyio + + +@pytest.fixture async def patch_itersize_setting() -> Iterable[None]: del caches["default"] with override_settings(DJANGO_VALKEY_SCAN_ITERSIZE=30): @@ -31,7 +34,6 @@ async def patch_itersize_setting() -> Iterable[None]: del caches["default"] -@pytest.mark.asyncio(loop_scope="session") class TestAsyncDjangoValkeyCache: async def test_set_int(self, cache: AsyncValkeyCache): if isinstance(cache.client, AsyncHerdClient): @@ -72,7 +74,7 @@ async def test_setnx_timeout(self, cache: AsyncValkeyCache): # test that timeout still works for nx=True res = await cache.aset("test_key_nx", 1, timeout=2, nx=True) assert res is True - await asyncio.sleep(3) + await anyio.sleep(3) res = await cache.aget("test_key_nx") assert res is None @@ -80,7 +82,7 @@ async def test_setnx_timeout(self, cache: AsyncValkeyCache): await cache.aset("test_key_nx", 1) res = await cache.aset("test_key_nx", 2, timeout=2, nx=True) assert res is None - await asyncio.sleep(3) + await anyio.sleep(3) res = await cache.aget("test_key_nx") assert res == 1 @@ -151,7 +153,7 @@ async def test_save_float(self, cache: AsyncValkeyCache): async def test_timeout(self, cache: AsyncValkeyCache): await cache.aset("test_key", 222, timeout=3) - await asyncio.sleep(4) + await anyio.sleep(4) res = await cache.aget("test_key") assert res is None @@ -170,7 +172,7 @@ async def test_timeout_parameter_as_positional_argument( await cache.aset("test_key", 222, 1) res1 = await cache.aget("test_key") - await asyncio.sleep(2) + await anyio.sleep(2) res2 = await cache.aget("test_key") assert res1 == 222 assert res2 is None @@ -731,7 +733,7 @@ async def test_lock_released_by_thread(self, cache: AsyncValkeyCache): async def release_lock(lock_): await lock_.release() - t = threading.Thread(target=asyncio.run, args=[release_lock(lock)]) + t = threading.Thread(target=anyio.run, args=[release_lock, lock]) t.start() t.join() @@ -801,7 +803,7 @@ async def test_touch_positive_timeout(self, cache: AsyncValkeyCache): assert await cache.atouch("test_key", 2) is True assert await cache.aget("test_key") == 222 - await asyncio.sleep(3) + await anyio.sleep(3) assert await cache.aget("test_key") is None async def test_touch_negative_timeout(self, cache: AsyncValkeyCache): @@ -819,7 +821,7 @@ async def test_touch_forever(self, cache: AsyncValkeyCache): result = await cache.atouch("test_key", None) assert result is True assert await cache.attl("test_key") is None - await asyncio.sleep(2) + await anyio.sleep(2) assert await cache.aget("test_key") == "foo" async def test_touch_forever_nonexistent(self, cache: AsyncValkeyCache): @@ -830,7 +832,7 @@ async def test_touch_default_timeout(self, cache: AsyncValkeyCache): await cache.aset("test_key", "foo", timeout=1) result = await cache.atouch("test_key") assert result is True - await asyncio.sleep(2) + await anyio.sleep(2) assert await cache.aget("test_key") == "foo" async def test_clear(self, cache: AsyncValkeyCache): diff --git a/tests/tests_async/test_cache_options.py b/tests/tests_async/test_cache_options.py index d9d4940..444f72b 100644 --- a/tests/tests_async/test_cache_options.py +++ b/tests/tests_async/test_cache_options.py @@ -4,7 +4,6 @@ from typing import cast import pytest -import pytest_asyncio from pytest import LogCaptureFixture from pytest_django.fixtures import SettingsWrapper @@ -15,6 +14,8 @@ from django_valkey.async_cache.cache import AsyncValkeyCache from django_valkey.async_cache.client import AsyncHerdClient, AsyncDefaultClient +pytestmark = pytest.mark.anyio + methods_with_no_parameters = {"clear", "close"} methods_with_one_required_parameters = { @@ -75,15 +76,19 @@ } -@pytest.mark.asyncio(loop_scope="session") +# TODO: when django adjusts the signal, remove this decorator (and the ones below) +@pytest.mark.filterwarnings("ignore:coroutine 'AsyncBackendCommands.close'") class TestDjangoValkeyOmitException: - @pytest_asyncio.fixture + @pytest.fixture async def conf_cache(self, settings: SettingsWrapper): caches_settings = copy.deepcopy(settings.CACHES) + # NOTE: this files raises RuntimeWarning because `conn.close` was not awaited, + # this is expected because django calls the signal manually during this test + # to debug, put a `raise` in django.utils.connection.BaseConnectionHandler.close_all settings.CACHES = caches_settings return caches_settings - @pytest_asyncio.fixture + @pytest.fixture async def conf_cache_to_ignore_exception( self, settings: SettingsWrapper, conf_cache ): @@ -92,7 +97,7 @@ async def conf_cache_to_ignore_exception( settings.DJANGO_VALKEY_IGNORE_EXCEPTIONS = True settings.DJANGO_VALKEY_LOG_IGNORE_EXCEPTIONS = True - @pytest_asyncio.fixture + @pytest.fixture async def ignore_exceptions_cache( self, conf_cache_to_ignore_exception ) -> AsyncValkeyCache: @@ -210,7 +215,7 @@ async def test_error_raised_when_ignore_is_not_set(self, conf_cache): await cache.get("key") -@pytest_asyncio.fixture +@pytest.fixture async def key_prefix_cache( cache: AsyncValkeyCache, settings: SettingsWrapper ) -> Iterable[AsyncValkeyCache]: @@ -220,14 +225,14 @@ async def key_prefix_cache( yield cache -@pytest_asyncio.fixture +@pytest.fixture async def with_prefix_cache() -> Iterable[AsyncValkeyCache]: with_prefix = cast(AsyncValkeyCache, caches["with_prefix"]) yield with_prefix await with_prefix.clear() -@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.filterwarnings("ignore:coroutine 'AsyncBackendCommands.close'") class TestDjangoValkeyCacheEscapePrefix: async def test_delete_pattern( self, key_prefix_cache: AsyncValkeyCache, with_prefix_cache: AsyncValkeyCache @@ -258,7 +263,7 @@ async def test_keys( assert "b" not in keys -@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.filterwarnings("ignore:coroutine 'AsyncBackendCommands.close'") async def test_custom_key_function(cache: AsyncValkeyCache, settings: SettingsWrapper): caches_setting = copy.deepcopy(settings.CACHES) caches_setting["default"]["KEY_FUNCTION"] = "tests.test_cache_options.make_key" diff --git a/tests/tests_async/test_client.py b/tests/tests_async/test_client.py index 60f09d5..341d1ce 100644 --- a/tests/tests_async/test_client.py +++ b/tests/tests_async/test_client.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock import pytest -import pytest_asyncio from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture @@ -11,8 +10,10 @@ from django_valkey.async_cache.cache import AsyncValkeyCache from django_valkey.async_cache.client import AsyncDefaultClient +pytestmark = pytest.mark.anyio -@pytest_asyncio.fixture + +@pytest.fixture async def cache_client(cache: AsyncValkeyCache) -> Iterable[AsyncDefaultClient]: client = cache.client await client.aset("TestClientClose", 0) @@ -20,7 +21,6 @@ async def cache_client(cache: AsyncValkeyCache) -> Iterable[AsyncDefaultClient]: await client.adelete("TestClientClose") -@pytest.mark.asyncio(loop_scope="session") class TestClientClose: async def test_close_client_disconnect_default( self, cache_client: AsyncDefaultClient, mocker: MockerFixture diff --git a/tests/tests_async/test_connection_factory.py b/tests/tests_async/test_connection_factory.py index 5501bb5..3682af6 100644 --- a/tests/tests_async/test_connection_factory.py +++ b/tests/tests_async/test_connection_factory.py @@ -6,7 +6,9 @@ from django_valkey.async_cache import pool -@pytest.mark.asyncio +pytestmark = pytest.mark.anyio + + async def test_connection_factory_redefine_from_opts(): cf = sync_pool.get_connection_factory( options={ @@ -30,7 +32,6 @@ async def test_connection_factory_redefine_from_opts(): ), ], ) -@pytest.mark.asyncio async def test_connection_factory_opts(conn_factory: str, expected): cf = sync_pool.get_connection_factory( path=None, @@ -55,7 +56,6 @@ async def test_connection_factory_opts(conn_factory: str, expected): ), ], ) -@pytest.mark.asyncio async def test_connection_factory_path(conn_factory: str, expected): cf = sync_pool.get_connection_factory( path=conn_factory, @@ -66,7 +66,6 @@ async def test_connection_factory_path(conn_factory: str, expected): assert isinstance(cf, expected) -@pytest.mark.asyncio async def test_connection_factory_no_sentinels(): with pytest.raises(ImproperlyConfigured): sync_pool.get_connection_factory( diff --git a/tests/tests_async/test_connection_string.py b/tests/tests_async/test_connection_string.py index a18c574..740bf60 100644 --- a/tests/tests_async/test_connection_string.py +++ b/tests/tests_async/test_connection_string.py @@ -2,6 +2,8 @@ from django_valkey import pool +pytestmark = pytest.mark.anyio + @pytest.mark.parametrize( "connection_string", @@ -11,7 +13,6 @@ "valkeys://localhost:3333?db=2", ], ) -@pytest.mark.asyncio async def test_connection_strings(connection_string: str): cf = pool.get_connection_factory( options={ diff --git a/tests/tests_async/test_requests.py b/tests/tests_async/test_requests.py new file mode 100644 index 0000000..b377c57 --- /dev/null +++ b/tests/tests_async/test_requests.py @@ -0,0 +1,86 @@ +import pytest + +from django.core import signals +from django.core.cache import close_caches + +from django_valkey.base import close_async_caches +from django_valkey.async_cache.cache import AsyncValkeyCache + + +pytestmark = pytest.mark.anyio + + +class TestWithOldSignal: + @pytest.fixture(autouse=True) + def setup(self): + signals.request_finished.disconnect(close_async_caches) + signals.request_finished.connect(close_caches) + yield + signals.request_finished.disconnect(close_caches) + signals.request_finished.connect(close_async_caches) + + def test_old_receiver_is_registered_and_new_receiver_unregistered(self, setup): + sync_receivers, async_receivers = signals.request_finished._live_receivers(None) + assert close_caches in sync_receivers + assert close_async_caches not in async_receivers + + async def test_warning_output_when_request_finished(self, async_client): + with pytest.warns( + RuntimeWarning, + match="coroutine 'AsyncBackendCommands.close' was never awaited", + ) as record: + await async_client.get("/async/") + + assert ( + str(record[0].message) + == "coroutine 'AsyncBackendCommands.close' was never awaited" + ) + + async def test_manually_await_signal(self, recwarn): + await signals.request_finished.asend(self.__class__) + assert len(recwarn) == 1 + + assert ( + str(recwarn[0].message) + == "coroutine 'AsyncBackendCommands.close' was never awaited" + ) + + # for some reason if i make this function sync, it can't get the log + async def test_manually_call_signal(self): + with pytest.warns( + RuntimeWarning, + match="coroutine 'AsyncBackendCommands.close' was never awaited", + ) as record: + signals.request_finished.send(self.__class__) + assert len(record) == 1 + + assert ( + str(record[0].message) + == "coroutine 'AsyncBackendCommands.close' was never awaited" + ) + + +class TestWithNewSignal: + async def test_warning_output_when_request_finished(self, async_client, recwarn): + await async_client.get("/async/") + + assert len(recwarn) == 0 + + async def test_manually_await_signal(self, recwarn): + await signals.request_finished.asend(self.__class__) + assert len(recwarn) == 0 + + def test_manually_call_signal(self, recwarn): + signals.request_finished.send(self.__class__) + assert len(recwarn) == 0 + + def test_receiver_is_registered_and_old_receiver_unregistered(self): + sync_receivers, async_receivers = signals.request_finished._live_receivers(None) + assert close_async_caches in async_receivers + assert close_caches not in sync_receivers + + async def test_close_is_called_by_signal(self, mocker): + close_spy = mocker.spy(AsyncValkeyCache, "close") + await signals.request_finished.asend(self.__class__) + assert close_spy.await_count == 1 + assert close_spy.call_count == 1