Skip to content

adjust cache signal receiver to work with both sync and async backends #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions django_valkey/async_cache/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ class AsyncValkeyCache(
BaseValkeyCache[AsyncDefaultClient, AValkey], AsyncBackendCommands
):
DEFAULT_CLIENT_CLASS = "django_valkey.async_cache.client.default.AsyncDefaultClient"
is_async = True
18 changes: 18 additions & 0 deletions django_valkey/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ brotli = [

[dependency-groups]
dev = [
"anyio>=4.9.0",
"black>=25.1.0",
"coverage>=7.8.0",
"django-cmd>=2.6",
Expand All @@ -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",
Expand Down Expand Up @@ -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"]
Expand Down
6 changes: 4 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from typing import cast

import pytest
import pytest_asyncio
from pytest_django.fixtures import SettingsWrapper

from asgiref.compatibility import iscoroutinefunction
Expand All @@ -12,10 +11,13 @@
from django_valkey.base import BaseValkeyCache
from django_valkey.cache import ValkeyCache


pytestmark = pytest.mark.anyio

# for some reason `isawaitable` doesn't work here
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()
Expand Down
11 changes: 11 additions & 0 deletions tests/tests_async/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import pytest


@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"


@pytest.fixture(scope="session", autouse=True)
async def keepalive(anyio_backend):
pass
7 changes: 4 additions & 3 deletions tests/tests_async/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from unittest.mock import patch, AsyncMock

import pytest
import pytest_asyncio
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture

Expand All @@ -22,7 +21,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):
Expand All @@ -31,7 +33,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):
Expand Down
20 changes: 11 additions & 9 deletions tests/tests_async/test_cache_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from typing import cast

import pytest
import pytest_asyncio
from pytest import LogCaptureFixture
from pytest_django.fixtures import SettingsWrapper

Expand All @@ -15,6 +14,9 @@
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 = {
Expand Down Expand Up @@ -75,15 +77,17 @@
}


@pytest.mark.asyncio(loop_scope="session")
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
):
Expand All @@ -92,7 +96,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:
Expand Down Expand Up @@ -210,7 +214,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]:
Expand All @@ -220,14 +224,13 @@ 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")
class TestDjangoValkeyCacheEscapePrefix:
async def test_delete_pattern(
self, key_prefix_cache: AsyncValkeyCache, with_prefix_cache: AsyncValkeyCache
Expand Down Expand Up @@ -258,7 +261,6 @@ async def test_keys(
assert "b" not in keys


@pytest.mark.asyncio(loop_scope="session")
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"
Expand Down
6 changes: 3 additions & 3 deletions tests/tests_async/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -11,16 +10,17 @@
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)
yield client
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
Expand Down
7 changes: 3 additions & 4 deletions tests/tests_async/test_connection_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion tests/tests_async/test_connection_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from django_valkey import pool

pytestmark = pytest.mark.anyio


@pytest.mark.parametrize(
"connection_string",
Expand All @@ -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={
Expand Down
86 changes: 86 additions & 0 deletions tests/tests_async/test_requests.py
Original file line number Diff line number Diff line change
@@ -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
Loading