Skip to content

Commit e36888b

Browse files
authored
Merge pull request #28 from amirreza8002/async_fix
adjust cache signal receiver to work with both sync and async backends 1. add a signal receiver and hook it in 2. uninstall django's receiver 3. migrate to anyio instead of pytest-async for testing 4. adjust some async test
2 parents 0dd7520 + 0e6b541 commit e36888b

File tree

11 files changed

+159
-35
lines changed

11 files changed

+159
-35
lines changed

django_valkey/async_cache/cache.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ class AsyncValkeyCache(
88
BaseValkeyCache[AsyncDefaultClient, AValkey], AsyncBackendCommands
99
):
1010
DEFAULT_CLIENT_CLASS = "django_valkey.async_cache.client.default.AsyncDefaultClient"
11+
is_async = True

django_valkey/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,21 @@ async def hkeys(self, *args, **kwargs) -> list[Any]:
558558

559559
async def hexists(self, *args, **kwargs) -> bool:
560560
return await self.client.hexists(*args, **kwargs)
561+
562+
563+
# temp fix for django's #36047
564+
# TODO: remove this when it's fixed in django
565+
from django.core import signals # noqa: E402
566+
from django.core.cache import caches, close_caches # noqa: E402
567+
568+
569+
async def close_async_caches(**kwargs):
570+
for conn in caches.all(initialized_only=True):
571+
if getattr(conn, "is_async", False):
572+
await conn.aclose()
573+
else:
574+
conn.close()
575+
576+
577+
signals.request_finished.connect(close_async_caches)
578+
signals.request_finished.disconnect(close_caches)

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ brotli = [
5757

5858
[dependency-groups]
5959
dev = [
60+
"anyio>=4.9.0",
6061
"black>=25.1.0",
6162
"coverage>=7.8.0",
6263
"django-cmd>=2.6",
@@ -66,7 +67,6 @@ dev = [
6667
"mypy>=1.15.0",
6768
"pre-commit>=4.2.0",
6869
"pytest>=8.3.5",
69-
"pytest-asyncio>=0.26.0",
7070
"pytest-django>=4.11.1",
7171
"pytest-mock>=3.14.0",
7272
"pytest-subtests>=0.14.1",
@@ -104,8 +104,6 @@ ignore_missing_settings = true
104104

105105
[tool.pytest.ini_options]
106106
DJANGO_SETTINGS_MODULE = "tests.settings.sqlite"
107-
asyncio_mode = "auto"
108-
asyncio_default_fixture_loop_scope = "session"
109107

110108
[tool.coverage.run]
111109
plugins = ["django_coverage_plugin"]

tests/conftest.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from typing import cast
44

55
import pytest
6-
import pytest_asyncio
76
from pytest_django.fixtures import SettingsWrapper
87

98
from asgiref.compatibility import iscoroutinefunction
@@ -12,10 +11,12 @@
1211
from django_valkey.base import BaseValkeyCache
1312
from django_valkey.cache import ValkeyCache
1413

15-
# for some reason `isawaitable` doesn't work here
14+
15+
pytestmark = pytest.mark.anyio
16+
1617
if iscoroutinefunction(default_cache.clear):
1718

18-
@pytest_asyncio.fixture(loop_scope="session")
19+
@pytest.fixture(scope="function")
1920
async def cache():
2021
yield default_cache
2122
await default_cache.aclear()

tests/tests_async/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
4+
@pytest.fixture(scope="session")
5+
def anyio_backend():
6+
return "asyncio"
7+
8+
9+
# this keeps the event loop open for the entire test suite
10+
@pytest.fixture(scope="session", autouse=True)
11+
async def keepalive(anyio_backend):
12+
pass

tests/tests_async/test_backend.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import contextlib
32
import datetime
43
import threading
@@ -8,10 +7,11 @@
87
from unittest.mock import patch, AsyncMock
98

109
import pytest
11-
import pytest_asyncio
1210
from pytest_django.fixtures import SettingsWrapper
1311
from pytest_mock import MockerFixture
1412

13+
import anyio
14+
1515
from django.core.cache import caches
1616
from django.core.cache.backends.base import DEFAULT_TIMEOUT
1717
from django.test import override_settings
@@ -22,7 +22,10 @@
2222
from django_valkey.serializers.msgpack import MSGPackSerializer
2323

2424

25-
@pytest_asyncio.fixture(loop_scope="session")
25+
pytestmark = pytest.mark.anyio
26+
27+
28+
@pytest.fixture
2629
async def patch_itersize_setting() -> Iterable[None]:
2730
del caches["default"]
2831
with override_settings(DJANGO_VALKEY_SCAN_ITERSIZE=30):
@@ -31,7 +34,6 @@ async def patch_itersize_setting() -> Iterable[None]:
3134
del caches["default"]
3235

3336

34-
@pytest.mark.asyncio(loop_scope="session")
3537
class TestAsyncDjangoValkeyCache:
3638
async def test_set_int(self, cache: AsyncValkeyCache):
3739
if isinstance(cache.client, AsyncHerdClient):
@@ -72,15 +74,15 @@ async def test_setnx_timeout(self, cache: AsyncValkeyCache):
7274
# test that timeout still works for nx=True
7375
res = await cache.aset("test_key_nx", 1, timeout=2, nx=True)
7476
assert res is True
75-
await asyncio.sleep(3)
77+
await anyio.sleep(3)
7678
res = await cache.aget("test_key_nx")
7779
assert res is None
7880

7981
# test that timeout will not affect key, if it was there
8082
await cache.aset("test_key_nx", 1)
8183
res = await cache.aset("test_key_nx", 2, timeout=2, nx=True)
8284
assert res is None
83-
await asyncio.sleep(3)
85+
await anyio.sleep(3)
8486
res = await cache.aget("test_key_nx")
8587
assert res == 1
8688

@@ -151,7 +153,7 @@ async def test_save_float(self, cache: AsyncValkeyCache):
151153

152154
async def test_timeout(self, cache: AsyncValkeyCache):
153155
await cache.aset("test_key", 222, timeout=3)
154-
await asyncio.sleep(4)
156+
await anyio.sleep(4)
155157

156158
res = await cache.aget("test_key")
157159
assert res is None
@@ -170,7 +172,7 @@ async def test_timeout_parameter_as_positional_argument(
170172

171173
await cache.aset("test_key", 222, 1)
172174
res1 = await cache.aget("test_key")
173-
await asyncio.sleep(2)
175+
await anyio.sleep(2)
174176
res2 = await cache.aget("test_key")
175177
assert res1 == 222
176178
assert res2 is None
@@ -731,7 +733,7 @@ async def test_lock_released_by_thread(self, cache: AsyncValkeyCache):
731733
async def release_lock(lock_):
732734
await lock_.release()
733735

734-
t = threading.Thread(target=asyncio.run, args=[release_lock(lock)])
736+
t = threading.Thread(target=anyio.run, args=[release_lock, lock])
735737
t.start()
736738
t.join()
737739

@@ -801,7 +803,7 @@ async def test_touch_positive_timeout(self, cache: AsyncValkeyCache):
801803

802804
assert await cache.atouch("test_key", 2) is True
803805
assert await cache.aget("test_key") == 222
804-
await asyncio.sleep(3)
806+
await anyio.sleep(3)
805807
assert await cache.aget("test_key") is None
806808

807809
async def test_touch_negative_timeout(self, cache: AsyncValkeyCache):
@@ -819,7 +821,7 @@ async def test_touch_forever(self, cache: AsyncValkeyCache):
819821
result = await cache.atouch("test_key", None)
820822
assert result is True
821823
assert await cache.attl("test_key") is None
822-
await asyncio.sleep(2)
824+
await anyio.sleep(2)
823825
assert await cache.aget("test_key") == "foo"
824826

825827
async def test_touch_forever_nonexistent(self, cache: AsyncValkeyCache):
@@ -830,7 +832,7 @@ async def test_touch_default_timeout(self, cache: AsyncValkeyCache):
830832
await cache.aset("test_key", "foo", timeout=1)
831833
result = await cache.atouch("test_key")
832834
assert result is True
833-
await asyncio.sleep(2)
835+
await anyio.sleep(2)
834836
assert await cache.aget("test_key") == "foo"
835837

836838
async def test_clear(self, cache: AsyncValkeyCache):

tests/tests_async/test_cache_options.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from typing import cast
55

66
import pytest
7-
import pytest_asyncio
87
from pytest import LogCaptureFixture
98
from pytest_django.fixtures import SettingsWrapper
109

@@ -15,6 +14,8 @@
1514
from django_valkey.async_cache.cache import AsyncValkeyCache
1615
from django_valkey.async_cache.client import AsyncHerdClient, AsyncDefaultClient
1716

17+
pytestmark = pytest.mark.anyio
18+
1819
methods_with_no_parameters = {"clear", "close"}
1920

2021
methods_with_one_required_parameters = {
@@ -75,15 +76,19 @@
7576
}
7677

7778

78-
@pytest.mark.asyncio(loop_scope="session")
79+
# TODO: when django adjusts the signal, remove this decorator (and the ones below)
80+
@pytest.mark.filterwarnings("ignore:coroutine 'AsyncBackendCommands.close'")
7981
class TestDjangoValkeyOmitException:
80-
@pytest_asyncio.fixture
82+
@pytest.fixture
8183
async def conf_cache(self, settings: SettingsWrapper):
8284
caches_settings = copy.deepcopy(settings.CACHES)
85+
# NOTE: this files raises RuntimeWarning because `conn.close` was not awaited,
86+
# this is expected because django calls the signal manually during this test
87+
# to debug, put a `raise` in django.utils.connection.BaseConnectionHandler.close_all
8388
settings.CACHES = caches_settings
8489
return caches_settings
8590

86-
@pytest_asyncio.fixture
91+
@pytest.fixture
8792
async def conf_cache_to_ignore_exception(
8893
self, settings: SettingsWrapper, conf_cache
8994
):
@@ -92,7 +97,7 @@ async def conf_cache_to_ignore_exception(
9297
settings.DJANGO_VALKEY_IGNORE_EXCEPTIONS = True
9398
settings.DJANGO_VALKEY_LOG_IGNORE_EXCEPTIONS = True
9499

95-
@pytest_asyncio.fixture
100+
@pytest.fixture
96101
async def ignore_exceptions_cache(
97102
self, conf_cache_to_ignore_exception
98103
) -> AsyncValkeyCache:
@@ -210,7 +215,7 @@ async def test_error_raised_when_ignore_is_not_set(self, conf_cache):
210215
await cache.get("key")
211216

212217

213-
@pytest_asyncio.fixture
218+
@pytest.fixture
214219
async def key_prefix_cache(
215220
cache: AsyncValkeyCache, settings: SettingsWrapper
216221
) -> Iterable[AsyncValkeyCache]:
@@ -220,14 +225,14 @@ async def key_prefix_cache(
220225
yield cache
221226

222227

223-
@pytest_asyncio.fixture
228+
@pytest.fixture
224229
async def with_prefix_cache() -> Iterable[AsyncValkeyCache]:
225230
with_prefix = cast(AsyncValkeyCache, caches["with_prefix"])
226231
yield with_prefix
227232
await with_prefix.clear()
228233

229234

230-
@pytest.mark.asyncio(loop_scope="session")
235+
@pytest.mark.filterwarnings("ignore:coroutine 'AsyncBackendCommands.close'")
231236
class TestDjangoValkeyCacheEscapePrefix:
232237
async def test_delete_pattern(
233238
self, key_prefix_cache: AsyncValkeyCache, with_prefix_cache: AsyncValkeyCache
@@ -258,7 +263,7 @@ async def test_keys(
258263
assert "b" not in keys
259264

260265

261-
@pytest.mark.asyncio(loop_scope="session")
266+
@pytest.mark.filterwarnings("ignore:coroutine 'AsyncBackendCommands.close'")
262267
async def test_custom_key_function(cache: AsyncValkeyCache, settings: SettingsWrapper):
263268
caches_setting = copy.deepcopy(settings.CACHES)
264269
caches_setting["default"]["KEY_FUNCTION"] = "tests.test_cache_options.make_key"

tests/tests_async/test_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from unittest.mock import AsyncMock
33

44
import pytest
5-
import pytest_asyncio
65
from pytest_django.fixtures import SettingsWrapper
76
from pytest_mock import MockerFixture
87

@@ -11,16 +10,17 @@
1110
from django_valkey.async_cache.cache import AsyncValkeyCache
1211
from django_valkey.async_cache.client import AsyncDefaultClient
1312

13+
pytestmark = pytest.mark.anyio
1414

15-
@pytest_asyncio.fixture
15+
16+
@pytest.fixture
1617
async def cache_client(cache: AsyncValkeyCache) -> Iterable[AsyncDefaultClient]:
1718
client = cache.client
1819
await client.aset("TestClientClose", 0)
1920
yield client
2021
await client.adelete("TestClientClose")
2122

2223

23-
@pytest.mark.asyncio(loop_scope="session")
2424
class TestClientClose:
2525
async def test_close_client_disconnect_default(
2626
self, cache_client: AsyncDefaultClient, mocker: MockerFixture

tests/tests_async/test_connection_factory.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from django_valkey.async_cache import pool
77

88

9-
@pytest.mark.asyncio
9+
pytestmark = pytest.mark.anyio
10+
11+
1012
async def test_connection_factory_redefine_from_opts():
1113
cf = sync_pool.get_connection_factory(
1214
options={
@@ -30,7 +32,6 @@ async def test_connection_factory_redefine_from_opts():
3032
),
3133
],
3234
)
33-
@pytest.mark.asyncio
3435
async def test_connection_factory_opts(conn_factory: str, expected):
3536
cf = sync_pool.get_connection_factory(
3637
path=None,
@@ -55,7 +56,6 @@ async def test_connection_factory_opts(conn_factory: str, expected):
5556
),
5657
],
5758
)
58-
@pytest.mark.asyncio
5959
async def test_connection_factory_path(conn_factory: str, expected):
6060
cf = sync_pool.get_connection_factory(
6161
path=conn_factory,
@@ -66,7 +66,6 @@ async def test_connection_factory_path(conn_factory: str, expected):
6666
assert isinstance(cf, expected)
6767

6868

69-
@pytest.mark.asyncio
7069
async def test_connection_factory_no_sentinels():
7170
with pytest.raises(ImproperlyConfigured):
7271
sync_pool.get_connection_factory(

tests/tests_async/test_connection_string.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from django_valkey import pool
44

5+
pytestmark = pytest.mark.anyio
6+
57

68
@pytest.mark.parametrize(
79
"connection_string",
@@ -11,7 +13,6 @@
1113
"valkeys://localhost:3333?db=2",
1214
],
1315
)
14-
@pytest.mark.asyncio
1516
async def test_connection_strings(connection_string: str):
1617
cf = pool.get_connection_factory(
1718
options={

0 commit comments

Comments
 (0)