├── tests ├── __init__.py ├── settings │ ├── __init__.py │ ├── urls.py │ ├── sqlite_herd.py │ ├── sqlite_usock.py │ ├── sqlite_sharding.py │ ├── sqlite_usock_async.py │ ├── sqlite_sentinel.py │ ├── sqlite.py │ ├── sqlite_lz4.py │ ├── sqlite_async_herd.py │ ├── sqlite_bz2.py │ ├── sqlite_gzip.py │ ├── sqlite_json.py │ ├── sqlite_zlib.py │ ├── sqlite_zstd.py │ ├── sqlite_brotli.py │ ├── sqlite_msgpack.py │ ├── sqlite_msgspec_json.py │ ├── sqlite_msgspec_msgpack.py │ ├── sqlite_sentinel_opts.py │ ├── sqlite_async_sentinel.py │ ├── sqlite_cluster.py │ ├── sqlite_async.py │ └── sqlite_async_sentinel_opts.py ├── tests_async │ ├── __init__.py │ ├── conftest.py │ ├── test_connection_string.py │ ├── test_connection_factory.py │ ├── test_client.py │ └── test_requests.py ├── tests_cluster │ ├── __init__.py │ ├── test_cache_options.py │ ├── test_client.py │ └── test_backend.py ├── test_connection_string.py ├── wait_for_valkey.sh ├── README.rst ├── test_hashring.py ├── test_serializers.py ├── conftest.py ├── start_valkey.sh └── test_connection_factory.py ├── django_valkey ├── compressors │ ├── __init__.py │ ├── identity.py │ ├── bz2.py │ ├── gzip.py │ ├── base.py │ ├── zlib.py │ ├── brotli.py │ ├── lz4.py │ ├── zstd.py │ └── lzma.py ├── serializers │ ├── __init__.py │ ├── base.py │ ├── msgpack.py │ ├── json.py │ ├── msgspec.py │ └── pickle.py ├── cluster_cache │ ├── __init__.py │ ├── client │ │ ├── __init__.py │ │ └── default.py │ ├── pool.py │ └── cache.py ├── client │ ├── default.py │ ├── __init__.py │ ├── sentinel.py │ └── herd.py ├── async_cache │ ├── client │ │ ├── __init__.py │ │ ├── default.py │ │ ├── sentinel.py │ │ └── herd.py │ ├── cache.py │ ├── __init__.py │ └── pool.py ├── exceptions.py ├── cache.py ├── __init__.py ├── hash_ring.py ├── util.py ├── pool.py └── base_pool.py ├── docs ├── changes.md ├── cluster │ ├── .nav.yml │ └── basic_configurations.md ├── commands │ ├── .nav.yml │ ├── connection_pool_commands.md │ ├── raw_access.md │ └── valkey_native_commands.md ├── configure │ ├── .nav.yml │ ├── basic_configurations.md │ ├── sentinel_configurations.md │ └── compressors.md ├── async │ ├── .nav.yml │ ├── access_to_pool.md │ ├── raw_access.md │ ├── async_commands.md │ ├── configurations.md │ └── advanced_configurations.md ├── .nav.yml ├── installation.md ├── migration_from_django_redis.md ├── index.md └── customize.md ├── .github ├── dependabot.yml └── workflows │ ├── ruff.yaml │ ├── release.yml │ └── ci.yml ├── dockers └── sentinel.conf ├── .readthedocs.yaml ├── tasks.py ├── .gitignore ├── .pre-commit-config.yaml ├── .editorconfig ├── AUTHORS.rst ├── mkdocs.yml ├── LICENSE ├── pyproject.toml ├── compose.yaml ├── CHANGES.md ├── README.rst └── util └── wait-for-it.sh /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/tests_async/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/tests_cluster/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_valkey/compressors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_valkey/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_valkey/cluster_cache/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changes.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | --8<-- "./CHANGES.md" 3 | -------------------------------------------------------------------------------- /docs/cluster/.nav.yml: -------------------------------------------------------------------------------- 1 | nav: 2 | - basic_configurations.md 3 | -------------------------------------------------------------------------------- /docs/commands/.nav.yml: -------------------------------------------------------------------------------- 1 | nav: 2 | - valkey_native_commands.md 3 | - raw_access.md 4 | - connection_pool_commands.md -------------------------------------------------------------------------------- /django_valkey/cluster_cache/client/__init__.py: -------------------------------------------------------------------------------- 1 | from django_valkey.cluster_cache.client.default import DefaultClusterClient 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" -------------------------------------------------------------------------------- /docs/configure/.nav.yml: -------------------------------------------------------------------------------- 1 | nav: 2 | - basic_configurations.md 3 | - advanced_configurations.md 4 | - sentinel_configurations.md 5 | - compressors.md -------------------------------------------------------------------------------- /docs/async/.nav.yml: -------------------------------------------------------------------------------- 1 | nav: 2 | - configurations.md 3 | - advanced_configurations.md 4 | - async_commands.md 5 | - raw_access.md 6 | - access_to_pool.md -------------------------------------------------------------------------------- /dockers/sentinel.conf: -------------------------------------------------------------------------------- 1 | sentinel resolve-hostnames yes 2 | sentinel monitor mymaster valkey 6379 1 3 | sentinel down-after-milliseconds mymaster 5000 4 | sentinel failover-timeout mymaster 60000 5 | sentinel parallel-syncs mymaster 1 -------------------------------------------------------------------------------- /docs/.nav.yml: -------------------------------------------------------------------------------- 1 | sort: 2 | sections: first 3 | 4 | nav: 5 | - installation.md 6 | 7 | - configure 8 | - async 9 | - cluster 10 | - commands 11 | 12 | - migration_from_django_redis.md 13 | - customize.md 14 | - changes.md 15 | -------------------------------------------------------------------------------- /django_valkey/client/default.py: -------------------------------------------------------------------------------- 1 | from valkey import Valkey 2 | 3 | from django_valkey.base_client import BaseClient, ClientCommands 4 | 5 | 6 | class DefaultClient(BaseClient[Valkey], ClientCommands[Valkey]): 7 | CONNECTION_FACTORY_PATH = "django_valkey.pool.ConnectionFactory" 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-lts-latest" 5 | tools: 6 | python: "3.13" 7 | 8 | jobs: 9 | pre_build: 10 | - pip install mkdocs-material mkdocs-awesome-nav 11 | mkdocs: 12 | configuration: mkdocs.yml 13 | 14 | formats: 15 | - pdf 16 | - epub 17 | -------------------------------------------------------------------------------- /tests/tests_async/conftest.py: -------------------------------------------------------------------------------- 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 13 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yaml: -------------------------------------------------------------------------------- 1 | name: ruff 2 | on: [push, pull_request] 3 | jobs: 4 | ruff: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: astral-sh/ruff-action@v3 9 | with: 10 | version: "latest" 11 | - run: ruff check 12 | - run: ruff format 13 | -------------------------------------------------------------------------------- /django_valkey/serializers/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class BaseSerializer: 5 | def __init__(self, options): 6 | pass 7 | 8 | def dumps(self, value: Any) -> bytes: 9 | raise NotImplementedError 10 | 11 | def loads(self, value: bytes) -> Any: 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /django_valkey/client/__init__.py: -------------------------------------------------------------------------------- 1 | from django_valkey.client.default import DefaultClient 2 | from django_valkey.client.herd import HerdClient 3 | from django_valkey.client.sentinel import SentinelClient 4 | from django_valkey.client.sharded import ShardClient 5 | 6 | __all__ = ["DefaultClient", "HerdClient", "SentinelClient", "ShardClient"] 7 | -------------------------------------------------------------------------------- /django_valkey/async_cache/client/__init__.py: -------------------------------------------------------------------------------- 1 | from django_valkey.async_cache.client.default import AsyncDefaultClient 2 | from django_valkey.async_cache.client.herd import AsyncHerdClient 3 | from django_valkey.async_cache.client.sentinel import AsyncSentinelClient 4 | 5 | __all__ = ["AsyncDefaultClient", "AsyncHerdClient", "AsyncSentinelClient"] 6 | -------------------------------------------------------------------------------- /django_valkey/async_cache/client/default.py: -------------------------------------------------------------------------------- 1 | from valkey.asyncio import Valkey as AValkey 2 | 3 | from django_valkey.base_client import BaseClient, AsyncClientCommands 4 | 5 | 6 | class AsyncDefaultClient(BaseClient[AValkey], AsyncClientCommands[AValkey]): 7 | CONNECTION_FACTORY_PATH = "django_valkey.async_cache.pool.AsyncConnectionFactory" 8 | -------------------------------------------------------------------------------- /django_valkey/serializers/msgpack.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import msgpack 4 | 5 | from django_valkey.serializers.base import BaseSerializer 6 | 7 | 8 | class MSGPackSerializer(BaseSerializer): 9 | def dumps(self, value: Any) -> bytes: 10 | return msgpack.dumps(value) 11 | 12 | def loads(self, value: bytes) -> Any: 13 | return msgpack.loads(value, raw=False) 14 | -------------------------------------------------------------------------------- /django_valkey/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConnectionInterrupted(Exception): 2 | def __init__(self, connection, parent=None): 3 | self.connection = connection 4 | 5 | def __str__(self) -> str: 6 | error_type = type(self.__cause__).__name__ 7 | error_msg = str(self.__cause__) 8 | return f"Valkey {error_type}: {error_msg}" 9 | 10 | 11 | class CompressorError(Exception): 12 | pass 13 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from invoke import task 5 | 6 | 7 | @task 8 | def devenv(c): 9 | clean(c) 10 | cmd = "docker compose --profile all up -d" 11 | c.run(cmd) 12 | 13 | 14 | @task 15 | def clean(c): 16 | if os.path.isdir("build"): 17 | shutil.rmtree("build") 18 | if os.path.isdir("dist"): 19 | shutil.rmtree("dist") 20 | 21 | c.run("docker compose --profile all rm -s -f") 22 | -------------------------------------------------------------------------------- /tests/settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django.http import HttpResponse 3 | from django.urls import path 4 | 5 | 6 | async def async_view(request): 7 | res = await cache.get("a") 8 | return HttpResponse(res) 9 | 10 | 11 | async def sync_view(request): 12 | res = cache.get("a") 13 | return HttpResponse(res) 14 | 15 | 16 | urlpatterns = [ 17 | path("async/", async_view), 18 | path("sync/", sync_view), 19 | ] 20 | -------------------------------------------------------------------------------- /docs/commands/connection_pool_commands.md: -------------------------------------------------------------------------------- 1 | # Access the connection pool 2 | 3 | you can get the connection pool using this code: 4 | 5 | ```python 6 | from django_valkey import get_valkey_connection 7 | 8 | r = get_valkey_connection("default") # use the name defined in ``CACHES`` settings 9 | connection_pool = r.connection_pool 10 | print(f"created connections so far: {connection_pool._created_connections}") 11 | ``` 12 | 13 | this will verify how many connections the pool has opened. 14 | -------------------------------------------------------------------------------- /django_valkey/compressors/identity.py: -------------------------------------------------------------------------------- 1 | from django_valkey.compressors.base import BaseCompressor 2 | 3 | 4 | class IdentityCompressor(BaseCompressor): 5 | """ 6 | the default class used by django_valkey 7 | it simple returns the value, with no change 8 | exists to simplify switching to compressors 9 | """ 10 | 11 | def _compress(self, value: bytes) -> bytes: 12 | return value 13 | 14 | def _decompress(self, value: bytes) -> bytes: 15 | return value 16 | -------------------------------------------------------------------------------- /django_valkey/serializers/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from django.core.serializers.json import DjangoJSONEncoder 5 | 6 | from django_valkey.serializers.base import BaseSerializer 7 | 8 | 9 | class JSONSerializer(BaseSerializer): 10 | encoder_class = DjangoJSONEncoder 11 | 12 | def dumps(self, value: Any) -> bytes: 13 | return json.dumps(value, cls=self.encoder_class).encode() 14 | 15 | def loads(self, value: bytes) -> Any: 16 | return json.loads(value.decode()) 17 | -------------------------------------------------------------------------------- /docs/async/access_to_pool.md: -------------------------------------------------------------------------------- 1 | # Access the connection pool 2 | 3 | you can get the connection pool using this code: 4 | 5 | ```python 6 | from django_valkey.async_cache import get_valkey_connection 7 | 8 | async def get_connection(): 9 | r = await get_valkey_connection("default") # use the name defined in ``CACHES`` settings 10 | connection_pool = r.connection_pool 11 | print(f"created connections so far: {connection_pool._created_connections}") 12 | ``` 13 | 14 | this will verify how many connections the pool has opened. 15 | -------------------------------------------------------------------------------- /django_valkey/cache.py: -------------------------------------------------------------------------------- 1 | from valkey import Valkey 2 | 3 | from django_valkey.base import ( 4 | BaseValkeyCache, 5 | BackendCommands, 6 | decorate_all_methods, 7 | omit_exception, 8 | ) 9 | from django_valkey.client import DefaultClient 10 | 11 | 12 | @decorate_all_methods(omit_exception) 13 | class DecoratedBackendCommands(BackendCommands): 14 | pass 15 | 16 | 17 | class ValkeyCache(BaseValkeyCache[DefaultClient, Valkey], DecoratedBackendCommands): 18 | DEFAULT_CLIENT_CLASS = "django_valkey.client.DefaultClient" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[c|o] 2 | .DS_Store 3 | *.sql 4 | *.bz2 5 | *~ 6 | *.log 7 | *.json 8 | *.wsgi 9 | local_settings.py 10 | development_settings.py 11 | *.egg-info 12 | .project 13 | .pydevproject 14 | .settings 15 | versiontools* 16 | _build* 17 | doc/index.html 18 | /build/ 19 | /dist/ 20 | *.swp 21 | \#* 22 | .\#* 23 | .tox 24 | dump.rdb 25 | .idea 26 | .venv 27 | .coverage 28 | coverage.xml 29 | cobertura.xml 30 | 31 | uv.lock 32 | dist 33 | htmlcov 34 | test.sh 35 | test_*.sh 36 | 37 | docs/build 38 | 39 | .cluster 40 | 41 | .python-version 42 | -------------------------------------------------------------------------------- /tests/test_connection_string.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_valkey import pool 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "connection_string", 8 | [ 9 | "unix://tmp/foo.bar?db=1", 10 | "valkey://localhost/2", 11 | "valkeys://localhost:3333?db=2", 12 | ], 13 | ) 14 | def test_connection_strings(connection_string: str): 15 | cf = pool.get_connection_factory( 16 | options={"CONNECTION_FACTORY": "django_valkey.pool.ConnectionFactory"} 17 | ) 18 | res = cf.make_connection_params(connection_string) 19 | assert res["url"] == connection_string 20 | -------------------------------------------------------------------------------- /tests/wait_for_valkey.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CONTAINER=$1 4 | PORT=$2 5 | 6 | for i in {1..60}; do 7 | if sudo docker inspect "$CONTAINER" \ 8 | --format '{{.State.Health.Status}}' \ 9 | | grep -q starting; then 10 | sleep 1 11 | else 12 | if ! nc -z 127.0.0.1 $PORT &>/dev/null; then 13 | echo >&2 "Port $PORT does not seem to be open, valkey will not work with docker rootless!" 14 | fi 15 | # exit successfully in case nc was not found or -z is not supported 16 | exit 0 17 | fi 18 | done 19 | 20 | echo >&2 "Valkey did not seem to start in ~60s, aborting" 21 | exit 1 22 | -------------------------------------------------------------------------------- /tests/README.rst: -------------------------------------------------------------------------------- 1 | Running the test suite 2 | ---------------------- 3 | 4 | .. code-block:: bash 5 | 6 | # start valkey and a sentinel (uses docker with image valkey:latest) 7 | PRIMARY=$(tests/start_valkey.sh) 8 | SENTINEL=$(tests/start_valkey.sh --sentinel) 9 | 10 | # or just wait 5 - 10 seconds and most likely this would be the case 11 | tests/wait_for_valkey.sh $PRIMARY 6379 12 | tests/wait_for_valkey.sh $SENTINEL 26379 13 | 14 | # run the tests 15 | tox 16 | 17 | # shut down valkey 18 | for container in $PRIMARY $SENTINEL; do 19 | docker stop $container && docker rm $container 20 | done 21 | -------------------------------------------------------------------------------- /django_valkey/serializers/msgspec.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import msgspec 4 | 5 | from django_valkey.serializers.base import BaseSerializer 6 | 7 | 8 | class MsgSpecJsonSerializer(BaseSerializer): 9 | def dumps(self, value: Any) -> bytes: 10 | return msgspec.json.encode(value) 11 | 12 | def loads(self, value: bytes) -> Any: 13 | return msgspec.json.decode(value) 14 | 15 | 16 | class MsgSpecMsgPackSerializer(BaseSerializer): 17 | def dumps(self, value: Any) -> bytes: 18 | return msgspec.msgpack.encode(value) 19 | 20 | def loads(self, value: bytes) -> Any: 21 | return msgspec.msgpack.decode(value) 22 | -------------------------------------------------------------------------------- /django_valkey/async_cache/cache.py: -------------------------------------------------------------------------------- 1 | from valkey.asyncio.client import Valkey as AValkey 2 | 3 | from django_valkey.base import ( 4 | BaseValkeyCache, 5 | AsyncBackendCommands, 6 | decorate_all_methods, 7 | omit_exception_async, 8 | ) 9 | from django_valkey.async_cache.client.default import AsyncDefaultClient 10 | 11 | 12 | @decorate_all_methods(omit_exception_async) 13 | class DecoratedAsyncBackendCommands(AsyncBackendCommands): 14 | pass 15 | 16 | 17 | class AsyncValkeyCache( 18 | BaseValkeyCache[AsyncDefaultClient, AValkey], DecoratedAsyncBackendCommands 19 | ): 20 | DEFAULT_CLIENT_CLASS = "django_valkey.async_cache.client.default.AsyncDefaultClient" 21 | is_async = True 22 | -------------------------------------------------------------------------------- /tests/tests_async/test_connection_string.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_valkey import pool 4 | 5 | pytestmark = pytest.mark.anyio 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "connection_string", 10 | [ 11 | "unix://tmp/foo.bar?db=1", 12 | "valkey://localhost/2", 13 | "valkeys://localhost:3333?db=2", 14 | ], 15 | ) 16 | async def test_connection_strings(connection_string: str): 17 | cf = pool.get_connection_factory( 18 | options={ 19 | "CONNECTION_FACTORY": "django_valkey.async_cache.pool.AsyncConnectionFactory" 20 | } 21 | ) 22 | res = cf.make_connection_params(connection_string) 23 | assert res["url"] == connection_string 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/adamchainz/django-upgrade 3 | rev: 1.29.1 4 | hooks: 5 | - id: django-upgrade 6 | args: [--target-version, "4.2"] 7 | 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.14.4 10 | hooks: 11 | - id: ruff-check 12 | - id: ruff-format 13 | 14 | - repo: https://github.com/codespell-project/codespell 15 | rev: v2.4.1 16 | hooks: 17 | - id: codespell # See pyproject.toml for args 18 | additional_dependencies: 19 | - tomli 20 | 21 | - repo: https://github.com/abravalheri/validate-pyproject 22 | rev: v0.24.1 23 | hooks: 24 | - id: validate-pyproject 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 4 7 | indent_style = space 8 | charset = utf-8 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.py] 14 | max_line_length = 88 15 | 16 | [*.{html,css,scss,json,yml,xml,yaml}] 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [*.json] 23 | indent_size = 2 24 | insert_final_newline = ignore 25 | 26 | [*.min.js] 27 | indent_style = ignore 28 | indent_size = ignore 29 | 30 | [*.bat] 31 | indent_style = tab 32 | 33 | [docs/**.txt] 34 | max_line_length = 79 35 | 36 | [Makefile] 37 | indent_style = tab 38 | 39 | [default.conf] 40 | indent_size = 2 41 | 42 | -------------------------------------------------------------------------------- /django_valkey/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 4, 0) 2 | __version__ = ".".join(map(str, VERSION)) 3 | 4 | 5 | def get_valkey_connection(alias="default", write=True, key=None): 6 | """ 7 | Helper used for obtaining a raw valkey client. 8 | """ 9 | 10 | from django.core.cache import caches 11 | 12 | cache = caches[alias] 13 | 14 | error_message = "This backend does not support this feature" 15 | if not hasattr(cache, "client"): 16 | raise NotImplementedError(error_message) 17 | 18 | if not hasattr(cache.client, "get_client"): 19 | raise NotImplementedError(error_message) 20 | 21 | if hasattr(cache.client, "get_server_name"): 22 | return cache.client.get_client(key=key) 23 | 24 | return cache.client.get_client(write) 25 | -------------------------------------------------------------------------------- /django_valkey/async_cache/__init__.py: -------------------------------------------------------------------------------- 1 | from inspect import iscoroutinefunction 2 | 3 | 4 | async def get_valkey_connection(alias="default", write=True): 5 | """ 6 | Helper used for obtaining a raw valkey client. 7 | """ 8 | 9 | from django.core.cache import caches 10 | 11 | cache = caches[alias] 12 | 13 | error_message = "This backend does not support this feature" 14 | if not hasattr(cache, "client"): 15 | raise NotImplementedError(error_message) 16 | 17 | if not hasattr(cache.client, "get_client"): 18 | raise NotImplementedError(error_message) 19 | 20 | if not iscoroutinefunction(cache.client.get_client): 21 | raise "use django_valkey.get_valkey_connection for sync backends" 22 | 23 | return await cache.client.get_client(write) 24 | -------------------------------------------------------------------------------- /tests/test_hashring.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_valkey.hash_ring import HashRing 4 | 5 | 6 | class Node: 7 | def __init__(self, identifier): 8 | self.identifier = identifier 9 | 10 | def __str__(self): 11 | return f"node:{self.identifier}" 12 | 13 | def __repr__(self): 14 | return f"" 15 | 16 | 17 | @pytest.fixture 18 | def hash_ring(): 19 | return HashRing([Node(i) for i in range(3)]) 20 | 21 | 22 | def test_hashring(hash_ring): 23 | ids = [] 24 | 25 | for key in [f"test{x}" for x in range(10)]: 26 | node = hash_ring.get_node(key) 27 | ids.append(node.identifier) 28 | 29 | assert ids == [0, 2, 1, 2, 2, 2, 2, 0, 1, 1] 30 | 31 | 32 | def test_hashring_brute_force(hash_ring): 33 | for key in (f"test{x}" for x in range(10000)): 34 | assert hash_ring.get_node(key) 35 | -------------------------------------------------------------------------------- /django_valkey/compressors/bz2.py: -------------------------------------------------------------------------------- 1 | import bz2 2 | 3 | from django_valkey.compressors.base import BaseCompressor 4 | 5 | 6 | class Bz2Compressor(BaseCompressor): 7 | """ 8 | Bz2Compressor 9 | set with: 10 | CACHES = { 11 | "default": { 12 | # ... 13 | "OPTIONS": { 14 | "COMPRESSOR": "django_valkey.compressors.bz2.Bz2Compressor", 15 | } 16 | } 17 | } 18 | 19 | compression parameters: 20 | to set `compresslevel` use `CACHE_COMPRESS_LEVEL` in your settings, defaults to 4. 21 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 22 | """ 23 | 24 | def _compress(self, value: bytes) -> bytes: 25 | return bz2.compress(value, compresslevel=self.level or 9) 26 | 27 | def _decompress(self, value: bytes) -> bytes: 28 | return bz2.decompress(value) 29 | -------------------------------------------------------------------------------- /django_valkey/compressors/gzip.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | 3 | from django_valkey.compressors.base import BaseCompressor 4 | 5 | 6 | class GzipCompressor(BaseCompressor): 7 | """ 8 | GzipCompressor 9 | set with: 10 | CACHES = { 11 | "default": { 12 | # ... 13 | "OPTIONS": { 14 | "COMPRESSOR": "django_valkey.compressors.gzip.GzipCompressor", 15 | } 16 | } 17 | } 18 | 19 | compression parameters: 20 | to set `compresslevel` use `CACHE_COMPRESS_LEVEL` in your settings, defaults to 4. 21 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 22 | """ 23 | 24 | def _compress(self, value: bytes) -> bytes: 25 | return gzip.compress(value, compresslevel=self.level or 9) 26 | 27 | def _decompress(self, value: bytes) -> bytes: 28 | return gzip.decompress(value) 29 | -------------------------------------------------------------------------------- /django_valkey/compressors/base.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from django_valkey.exceptions import CompressorError 4 | 5 | 6 | class BaseCompressor: 7 | min_length = getattr(settings, "CACHE_COMPRESS_MIN_LENGTH", 15) 8 | level: int | None = getattr(settings, "CACHE_COMPRESS_LEVEL", None) 9 | 10 | def __init__(self, options): 11 | self._options: dict = options 12 | 13 | def compress(self, value): 14 | if len(value) > self.min_length: 15 | return self._compress(value) 16 | return value 17 | 18 | def _compress(self, value: bytes) -> bytes: 19 | raise NotImplementedError 20 | 21 | def decompress(self, value: bytes) -> bytes: 22 | try: 23 | return self._decompress(value) 24 | except Exception as e: 25 | raise CompressorError from e 26 | 27 | def _decompress(self, value: bytes) -> bytes: 28 | raise NotImplementedError 29 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | import pytest 4 | 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | from django_valkey.serializers.pickle import PickleSerializer 8 | 9 | 10 | class TestPickleSerializer: 11 | def test_invalid_pickle_version_provided(self): 12 | with pytest.raises( 13 | ImproperlyConfigured, match="PICKLE_VERSION value must be an integer" 14 | ): 15 | PickleSerializer({"PICKLE_VERSION": "not-an-integer"}) 16 | 17 | def test_setup_pickle_version_not_explicitly_specified(self): 18 | serializer = PickleSerializer({}) 19 | assert serializer._pickle_version == pickle.DEFAULT_PROTOCOL 20 | 21 | def test_setup_pickle_version_too_high(self): 22 | with pytest.raises( 23 | ImproperlyConfigured, 24 | match=f"PICKLE_VERSION can't be higher than pickle.HIGHEST_PROTOCOL:" 25 | f" {pickle.HIGHEST_PROTOCOL}", 26 | ): 27 | PickleSerializer({"PICKLE_VERSION": pickle.HIGHEST_PROTOCOL + 1}) 28 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation guide 2 | 3 | 4 | ## Basic Installation: 5 | 6 | ```shell 7 | pip install django-valkey 8 | ``` 9 | 10 | ## Install with C-bindings for maximum performance: 11 | 12 | ```shell 13 | pip install django-valkey[libvalkey] 14 | ``` 15 | 16 | ## Install with 3rd-party serializers 17 | 18 | 19 | ### Install with msgpack serializer: 20 | 21 | ```shell 22 | pip install django-valkey[msgpack] 23 | ``` 24 | 25 | ### Install with msgspec serializer: 26 | 27 | ```shell 28 | pip install django-valkey[msgspec] 29 | ``` 30 | 31 | ## Install with 3rd party compression libraries: 32 | 33 | ### lz4 library: 34 | 35 | ```shell 36 | pip install django-valkey[lz4] 37 | ``` 38 | 39 | ### zstd library: 40 | 41 | ```shell 42 | pip install django-valkey[zstd] # only needed before python 3.14 43 | ``` 44 | 45 | ### brotli library: 46 | 47 | ```shell 48 | pip install django-valkey[brotli] 49 | ``` 50 | 51 | ## Coming from django-redis? 52 | 53 | check out our migration guide [Migration from django-redis](migration_from_django_redis.md) 54 | -------------------------------------------------------------------------------- /docs/async/raw_access.md: -------------------------------------------------------------------------------- 1 | # Raw operations 2 | 3 | ## Access the underlying valkey client 4 | if for whatever reason you need to do things that the clients don't support, you can access the underlying valkey connections and send commands by hand 5 | to get a connection, you can do: 6 | 7 | ```python 8 | from django_valkey.async_cache import get_valkey_connection 9 | 10 | raw_client = await get_valkey_connection("default") 11 | ``` 12 | 13 | in this example `"default"` is the alias name of the backend, that you configured in django's `CACHES` setting 14 | the signature of the function is as follows: 15 | ```python 16 | async def get_valkey_connection(alias: str="default", write: bool=True): ... 17 | ``` 18 | 19 | `alias` is the name you gave each server in django's `CACHES` setting. 20 | `write` is used to determine if the operation will write to the cache database, so it should be `True` for `set` and `False` for `get`. 21 | 22 | ### raw operation utilities 23 | visit the [raw operation utilities](../commands/raw_access.md#raw-operation-utilities) of the sync documentations, the same concepts applies here as well. 24 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | django-redis package: 2 | Andrei Antoukh / niwibe 3 | Sean Bleier 4 | Matt Dennewitz 5 | Jannis Leidel 6 | S. Angel / Twidi 7 | Noah Kantrowitz / coderanger 8 | Martin Mahner / bartTC 9 | Timothée Peignier / cyberdelia 10 | Lior Sion / liorsion 11 | Ales Zoulek / aleszoulek 12 | James Aylett / jaylett 13 | Todd Boland / boland 14 | David Zderic / dzderic 15 | Kirill Zaitsev / teferi 16 | Jon Dufresne 17 | Anès Foufa 18 | Segyo Myung 19 | 20 | translate to valkey API: 21 | Amirreza Sohrabi far 22 | q0w 23 | Christian Clauss 24 | Ülgen Sarıkavak https://github.com/ulgens 25 | -------------------------------------------------------------------------------- /docs/async/async_commands.md: -------------------------------------------------------------------------------- 1 | # Valkey native commands 2 | 3 | you can directly work with valkey using django's cache object. 4 | 5 | most subject discussed in [valkey commands](../commands/valkey_native_commands.md) also applies here. 6 | 7 | ```pycon 8 | >>> from django.core.cache import cache 9 | 10 | >>> await cache.aget("foo") 11 | ``` 12 | 13 | the method names are the same as the sync ones discussed in [valkey commands](../commands/valkey_native_commands.md), and the API is almost the same. 14 | 15 | the only difference is that the async backend returns a coroutine or async generator depending on the method, and you should `await` it or iterate over it. 16 | 17 | ```python 18 | import contextlib 19 | from django.core.cache import cache 20 | 21 | async with contextlib.aclosing(cache.aiter_keys("foo*")) as keys: 22 | async for k in keys: 23 | print(k) 24 | ``` 25 | 26 | another thing to notice is that the method names are the same as the sync ones: `await get()` or `get()`. 27 | but if you want to be explicit the same methods are also available with a `a` prepended to them: `await aget()`. 28 | this goes for all public methods of the async client. -------------------------------------------------------------------------------- /tests/settings/sqlite_herd.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=5"], 7 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.HerdClient"}, 8 | }, 9 | "doesnotexist": { 10 | "BACKEND": "django_valkey.cache.ValkeyCache", 11 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 12 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.HerdClient"}, 13 | }, 14 | "sample": { 15 | "BACKEND": "django_valkey.cache.ValkeyCache", 16 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 17 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.HerdClient"}, 18 | }, 19 | "with_prefix": { 20 | "BACKEND": "django_valkey.cache.ValkeyCache", 21 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 22 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.HerdClient"}, 23 | "KEY_PREFIX": "test-prefix", 24 | }, 25 | } 26 | 27 | INSTALLED_APPS = ["django.contrib.sessions"] 28 | 29 | USE_TZ = False 30 | 31 | CACHE_HERD_TIMEOUT = 2 32 | -------------------------------------------------------------------------------- /tests/settings/sqlite_usock.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["unix:///tmp/valkey.sock?db=1", "unix:///tmp/valkey.sock?db=1"], 7 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.DefaultClient"}, 8 | }, 9 | "doesnotexist": { 10 | "BACKEND": "django_valkey.cache.ValkeyCache", 11 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 12 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.DefaultClient"}, 13 | }, 14 | "sample": { 15 | "BACKEND": "django_valkey.cache.ValkeyCache", 16 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 17 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.DefaultClient"}, 18 | }, 19 | "with_prefix": { 20 | "BACKEND": "django_valkey.cache.ValkeyCache", 21 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 22 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.DefaultClient"}, 23 | "KEY_PREFIX": "test-prefix", 24 | }, 25 | } 26 | 27 | INSTALLED_APPS = ["django.contrib.sessions"] 28 | 29 | USE_TZ = False 30 | -------------------------------------------------------------------------------- /tests/settings/sqlite_sharding.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=2"], 7 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.ShardClient"}, 8 | }, 9 | "doesnotexist": { 10 | "BACKEND": "django_valkey.cache.ValkeyCache", 11 | "LOCATION": ["valkey://127.0.0.1:56379?db=1", "valkey://127.0.0.1:56379?db=2"], 12 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.ShardClient"}, 13 | }, 14 | "sample": { 15 | "BACKEND": "django_valkey.cache.ValkeyCache", 16 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 17 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.ShardClient"}, 18 | }, 19 | "with_prefix": { 20 | "BACKEND": "django_valkey.cache.ValkeyCache", 21 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 22 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.client.ShardClient"}, 23 | "KEY_PREFIX": "test-prefix", 24 | }, 25 | } 26 | 27 | INSTALLED_APPS = ["django.contrib.sessions"] 28 | 29 | USE_TZ = False 30 | -------------------------------------------------------------------------------- /django_valkey/cluster_cache/pool.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.utils.module_loading import import_string 4 | 5 | from valkey.cluster import ValkeyCluster 6 | from valkey.connection import ConnectionPool, DefaultParser 7 | 8 | from django_valkey.base_pool import BaseConnectionFactory 9 | 10 | 11 | class ClusterConnectionFactory(BaseConnectionFactory[ValkeyCluster, ConnectionPool]): 12 | path_pool_cls = "valkey.connection.ConnectionPool" 13 | path_base_cls = "valkey.cluster.ValkeyCluster" 14 | 15 | def disconnect(self, connection: ValkeyCluster) -> None: 16 | connection.disconnect_connection_pools() 17 | 18 | def get_parser_cls(self): 19 | cls = self.options.get("PARSER_CLS", None) 20 | if cls is None: 21 | return DefaultParser 22 | return import_string(cls) 23 | 24 | def connect(self, url: str) -> ValkeyCluster: 25 | params = self.make_connection_params(url) 26 | return self.get_connection(params) 27 | 28 | def get_connection(self, params: dict) -> ValkeyCluster | Any: 29 | return self.base_client_cls( 30 | url=params["url"], 31 | parser_class=params["parser_class"], 32 | **self.base_client_cls_kwargs, 33 | ) 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from collections.abc import Iterable 3 | from typing import cast 4 | 5 | import pytest 6 | from pytest_django.fixtures import SettingsWrapper 7 | 8 | from asgiref.compatibility import iscoroutinefunction 9 | from django.core.cache import cache as default_cache, caches 10 | 11 | from django_valkey.base import BaseValkeyCache 12 | from django_valkey.cache import ValkeyCache 13 | 14 | 15 | pytestmark = pytest.mark.anyio 16 | 17 | if iscoroutinefunction(default_cache.clear): 18 | 19 | @pytest.fixture(scope="function") 20 | async def cache(): 21 | yield default_cache 22 | await default_cache.aclear() 23 | 24 | else: 25 | 26 | @pytest.fixture 27 | def cache() -> Iterable[BaseValkeyCache]: 28 | yield default_cache 29 | default_cache.clear() 30 | 31 | 32 | @pytest.fixture 33 | def key_prefix_cache( 34 | cache: ValkeyCache, settings: SettingsWrapper 35 | ) -> Iterable[ValkeyCache]: 36 | caches_setting = copy.deepcopy(settings.CACHES) 37 | caches_setting["default"]["KEY_PREFIX"] = "*" 38 | settings.CACHES = caches_setting 39 | yield cache 40 | 41 | 42 | @pytest.fixture 43 | def with_prefix_cache() -> Iterable[ValkeyCache]: 44 | with_prefix = cast(ValkeyCache, caches["with_prefix"]) 45 | yield with_prefix 46 | with_prefix.clear() 47 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: django valkey 2 | site_url: https://django-valkey.readthedocs.io/en/latest/ 3 | site_description: django-valkey documentation 4 | repo_url: https://github.com/django-utils/django-valkey 5 | 6 | markdown_extensions: 7 | - pymdownx.highlight: 8 | anchor_linenums: true 9 | line_spans: __span 10 | pygments_lang_class: true 11 | - pymdownx.snippets: 12 | check_paths: true 13 | base_path: ["."] 14 | - pymdownx.inlinehilite 15 | - pymdownx.superfences 16 | 17 | plugins: 18 | - awesome-nav 19 | 20 | theme: 21 | name: material 22 | highlightjs: true 23 | hljs_languages: 24 | - python 25 | - pycon 26 | 27 | palette: 28 | - media: "(prefers-color-scheme)" 29 | toggle: 30 | icon: material/brightness-auto 31 | name: Switch to dark mode 32 | 33 | - media: "(prefers-color-scheme: light)" 34 | schema: default 35 | primary: indigo 36 | accent: indigo 37 | toggle: 38 | icon: material/weather-sunny 39 | name: Switch to dark mode 40 | 41 | - media: "(prefers-color-scheme: dark)" 42 | scheme: slate 43 | primary: black 44 | accent: indigo 45 | toggle: 46 | icon: material/weather-night 47 | name: Switch to system preference 48 | 49 | features: 50 | - content.code.copy 51 | 52 | -------------------------------------------------------------------------------- /django_valkey/compressors/zlib.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import zlib 3 | 4 | from django.conf import settings 5 | 6 | from django_valkey.compressors.base import BaseCompressor 7 | 8 | 9 | class ZlibCompressor(BaseCompressor): 10 | """ 11 | zlib compression 12 | set with: 13 | CACHES = { 14 | "default": { 15 | # ... 16 | "OPTIONS": { 17 | "COMPRESSOR": "django_valkey.compressors.zlib.ZlibCompressor", 18 | } 19 | } 20 | } 21 | 22 | compression parameters: 23 | to set `level` use `CACHE_COMPRESS_LEVEL` in your settings, defaults to 4. 24 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 25 | 26 | to set `wbits` use `COMPRESS_ZLIB_WBITS` in your settings, defaults to 15. 27 | this works for both compression and decompression 28 | 29 | """ 30 | 31 | wbits = getattr(settings, "COMPRESS_ZLIB_WBITS", 15) 32 | 33 | def _compress(self, value: bytes) -> bytes: 34 | if int(platform.python_version_tuple()[1]) >= 11: 35 | return zlib.compress(value, level=self.level or 6, wbits=self.wbits) 36 | else: 37 | return zlib.compress(value, level=self.level or 6) 38 | 39 | def _decompress(self, value: bytes) -> bytes: 40 | return zlib.decompress(value, wbits=self.wbits) 41 | -------------------------------------------------------------------------------- /django_valkey/serializers/pickle.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from typing import Any 3 | 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from django_valkey.serializers.base import BaseSerializer 7 | 8 | 9 | class PickleSerializer(BaseSerializer): 10 | def __init__(self, options) -> None: 11 | self._pickle_version = pickle.DEFAULT_PROTOCOL 12 | self.setup_pickle_version(options) 13 | 14 | super().__init__(options=options) 15 | 16 | def setup_pickle_version(self, options) -> None: 17 | if "PICKLE_VERSION" in options: 18 | try: 19 | self._pickle_version = int(options["PICKLE_VERSION"]) 20 | if self._pickle_version > pickle.HIGHEST_PROTOCOL: 21 | error_message = ( 22 | f"PICKLE_VERSION can't be higher than pickle.HIGHEST_PROTOCOL:" 23 | f" {pickle.HIGHEST_PROTOCOL}" 24 | ) 25 | raise ImproperlyConfigured(error_message) 26 | except (ValueError, TypeError) as e: 27 | error_message = "PICKLE_VERSION value must be an integer" 28 | raise ImproperlyConfigured(error_message) from e 29 | 30 | def dumps(self, value: Any) -> bytes: 31 | return pickle.dumps(value, self._pickle_version) 32 | 33 | def loads(self, value: bytes) -> Any: 34 | return pickle.loads(value) 35 | -------------------------------------------------------------------------------- /tests/start_valkey.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This command will start valkey for both the CI and for local testing 4 | 5 | if ! command -v docker &> /dev/null; then 6 | echo >&2 "Docker is required but was not found." 7 | exit 1 8 | fi 9 | 10 | ARGS=() 11 | PORT=6379 12 | SENTINEL=0 13 | while (($# > 0)); do 14 | case "$1" in 15 | --sentinel) 16 | # setup a valkey sentinel 17 | CONF=$(mktemp -d) 18 | ARGS=("${ARGS[@]}" "$CONF/valkey.conf" --sentinel) 19 | PORT=26379 20 | SENTINEL=1 21 | 22 | cat > "$CONF/valkey.conf" < 3 | Copyright (c) 2011 Sean Bleier 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions 9 | are met: 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 2. Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 3. The name of the author may not be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS`` AND ANY EXPRESS OR 19 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 20 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 21 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 22 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 23 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 27 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /django_valkey/cluster_cache/cache.py: -------------------------------------------------------------------------------- 1 | from valkey.cluster import ValkeyCluster 2 | 3 | from django_valkey.base import BaseValkeyCache, BackendCommands 4 | from django_valkey.cluster_cache.client import DefaultClusterClient 5 | 6 | 7 | class ClusterCommands: 8 | def msetnx(self: "ClusterValkeyCache", *args, **kwargs): 9 | return self.client.msetnx(*args, **kwargs) 10 | 11 | def mget_nonatomic(self: "ClusterValkeyCache", *args, **kwargs): 12 | return self.client.mget_nonatomic(*args, **kwargs) 13 | 14 | def mset_nonatomic(self: "ClusterValkeyCache", *args, **kwargs): 15 | return self.client.mset_nonatomic(*args, **kwargs) 16 | 17 | def readonly(self: "ClusterValkeyCache", *args, **kwargs): 18 | return self.client.readonly(*args, **kwargs) 19 | 20 | def readwrite(self: "ClusterValkeyCache", *args, **kwargs): 21 | return self.client.readwrite(*args, **kwargs) 22 | 23 | def keyslot(self: "ClusterValkeyCache", *args, **kwargs): 24 | return self.client.keyslot(*args, **kwargs) 25 | 26 | def flushall(self: "ClusterValkeyCache", *args, **kwargs): 27 | return self.client.flushall(*args, **kwargs) 28 | 29 | def invalidate_key_from_cache(self: "ClusterValkeyCache", *args, **kwargs): 30 | return self.client.invalidate_key_from_cache(*args, **kwargs) 31 | 32 | 33 | class ClusterValkeyCache( 34 | BaseValkeyCache[DefaultClusterClient, ValkeyCluster], 35 | ClusterCommands, 36 | BackendCommands, 37 | ): 38 | DEFAULT_CLIENT_CLASS = "django_valkey.cluster_cache.client.DefaultClusterClient" 39 | -------------------------------------------------------------------------------- /tests/settings/sqlite_lz4.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "COMPRESSOR": "django_valkey.compressors.lz4.Lz4Compressor", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "COMPRESSOR": "django_valkey.compressors.lz4.Lz4Compressor", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "127.0.0.1:6379?db=1,127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "COMPRESSOR": "django_valkey.compressors.lz4.Lz4Compressor", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "COMPRESSOR": "django_valkey.compressors.lz4.Lz4Compressor", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_async_herd.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=5"], 7 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.async_cache.client.AsyncHerdClient"}, 8 | }, 9 | "doesnotexist": { 10 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 11 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 12 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.async_cache.client.AsyncHerdClient"}, 13 | }, 14 | "sample": { 15 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 16 | "LOCATION": "valkey://127.0.0.1:6379:1,valkey://127.0.0.1:6379:1", 17 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.async_cache.client.AsyncHerdClient"}, 18 | }, 19 | "with_prefix": { 20 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 21 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 22 | "OPTIONS": {"CLIENT_CLASS": "django_valkey.async_cache.client.AsyncHerdClient"}, 23 | "KEY_PREFIX": "test-prefix", 24 | }, 25 | } 26 | 27 | # Include `django.contrib.auth` and `django.contrib.contenttypes` for mypy / 28 | # django-stubs. 29 | 30 | # See: 31 | # - https://github.com/typeddjango/django-stubs/issues/318 32 | # - https://github.com/typeddjango/django-stubs/issues/534 33 | INSTALLED_APPS = [ 34 | "django.contrib.sessions", 35 | ] 36 | 37 | CACHE_HERD_TIMEOUT = 2 38 | 39 | USE_TZ = False 40 | 41 | ROOT_URLCONF = "tests.settings.urls" 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_bz2.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "COMPRESSOR": "django_valkey.compressors.bz2.Bz2Compressor", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "COMPRESSOR": "django_valkey.compressors.bz2.Bz2Compressor", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "COMPRESSOR": "django_valkey.compressors.bz2.Bz2Compressor", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "COMPRESSOR": "django_valkey.compressors.bz2.Bz2Compressor", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_gzip.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "COMPRESSOR": "django_valkey.compressors.gzip.GzipCompressor", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "COMPRESSOR": "django_valkey.compressors.gzip.GzipCompressor", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "COMPRESSOR": "django_valkey.compressors.gzip.GzipCompressor", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "COMPRESSOR": "django_valkey.compressors.gzip.GzipCompressor", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_json.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "SERIALIZER": "django_valkey.serializers.json.JSONSerializer", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "SERIALIZER": "django_valkey.serializers.json.JSONSerializer", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "SERIALIZER": "django_valkey.serializers.json.JSONSerializer", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "SERIALIZER": "django_valkey.serializers.json.JSONSerializer", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_zlib.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "COMPRESSOR": "django_valkey.compressors.zlib.ZlibCompressor", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "COMPRESSOR": "django_valkey.compressors.zlib.ZlibCompressor", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "COMPRESSOR": "django_valkey.compressors.zlib.ZlibCompressor", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "COMPRESSOR": "django_valkey.compressors.zlib.ZlibCompressor", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_zstd.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "COMPRESSOR": "django_valkey.compressors.zstd.ZStdCompressor", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "COMPRESSOR": "django_valkey.compressors.zstd.ZStdCompressor", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "COMPRESSOR": "django_valkey.compressors.zstd.ZStdCompressor", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "COMPRESSOR": "django_valkey.compressors.zstd.ZStdCompressor", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_brotli.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "COMPRESSOR": "django_valkey.compressors.brotli.BrotliCompressor", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "COMPRESSOR": "django_valkey.compressors.brotli.BrotliCompressor", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "COMPRESSOR": "django_valkey.compressors.brotli.BrotliCompressor", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "COMPRESSOR": "django_valkey.compressors.brotli.BrotliCompressor", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_msgpack.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "SERIALIZER": "django_valkey.serializers.msgpack.MSGPackSerializer", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "SERIALIZER": "django_valkey.serializers.msgpack.MSGPackSerializer", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "SERIALIZER": "django_valkey.serializers.msgpack.MSGPackSerializer", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "SERIALIZER": "django_valkey.serializers.msgpack.MSGPackSerializer", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_msgspec_json.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecJsonSerializer", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecJsonSerializer", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecJsonSerializer", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecJsonSerializer", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/settings/sqlite_msgspec_msgpack.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cache.ValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 9 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecMsgPackSerializer", 10 | }, 11 | }, 12 | "doesnotexist": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 15 | "OPTIONS": { 16 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 17 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecMsgPackSerializer", 18 | }, 19 | }, 20 | "sample": { 21 | "BACKEND": "django_valkey.cache.ValkeyCache", 22 | "LOCATION": "valkey://127.0.0.1:6379?db=1,valkey://127.0.0.1:6379?db=1", 23 | "OPTIONS": { 24 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 25 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecMsgPackSerializer", 26 | }, 27 | }, 28 | "with_prefix": { 29 | "BACKEND": "django_valkey.cache.ValkeyCache", 30 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 33 | "SERIALIZER": "django_valkey.serializers.msgspec.MsgSpecMsgPackSerializer", 34 | }, 35 | "KEY_PREFIX": "test-prefix", 36 | }, 37 | } 38 | 39 | INSTALLED_APPS = ["django.contrib.sessions"] 40 | 41 | USE_TZ = False 42 | -------------------------------------------------------------------------------- /tests/tests_cluster/test_cache_options.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from pytest_django.fixtures import SettingsWrapper 4 | 5 | from valkey import ValkeyCluster 6 | 7 | from django_valkey.cluster_cache.cache import ClusterValkeyCache 8 | 9 | 10 | class TestDjangoValkeyCacheEscapePrefix: 11 | def test_keys( 12 | self, 13 | key_prefix_cache: ClusterValkeyCache, # noqa: F811 14 | with_prefix_cache: ClusterValkeyCache, # noqa: F811 15 | ): 16 | key_prefix_cache.set("a", "1") 17 | with_prefix_cache.set("b", "2") 18 | keys = key_prefix_cache.keys("*", target_nodes=ValkeyCluster.ALL_NODES) 19 | assert "a" in keys 20 | assert "b" not in keys 21 | 22 | 23 | def test_custom_key_function(cache: ClusterValkeyCache, settings: SettingsWrapper): 24 | caches_setting = copy.deepcopy(settings.CACHES) 25 | caches_setting["default"]["KEY_FUNCTION"] = "tests.test_cache_options.make_key" 26 | caches_setting["default"]["REVERSE_KEY_FUNCTION"] = ( 27 | "tests.test_cache_options.reverse_key" 28 | ) 29 | settings.CACHES = caches_setting 30 | 31 | for key in ["{foo}-aa", "{foo}-ab", "{foo}-bb", "{foo}-bc"]: 32 | cache.set(key, "foo") 33 | 34 | res = cache.delete_pattern("*{foo}-a*") 35 | assert bool(res) is True 36 | 37 | keys = cache.keys("{foo}*", target_nodes=ValkeyCluster.ALL_NODES) 38 | assert set(keys) == {"{foo}-bb", "{foo}-bc"} 39 | # ensure our custom function was actually called 40 | assert { 41 | k.decode() 42 | for k in cache.client.get_client(write=False).keys( 43 | "*", target_nodes=ValkeyCluster.ALL_NODES 44 | ) 45 | } == ({"#1#{foo}-bc", "#1#{foo}-bb"}) 46 | -------------------------------------------------------------------------------- /docs/migration_from_django_redis.md: -------------------------------------------------------------------------------- 1 | # Migration from django-redis 2 | 3 | If you have django-redis setup and want to migrate to using django-valkey these are the steps you might need to take: 4 | 5 | 6 | ## Install django-valkey 7 | 8 | As explained in [Installation guide](installation.md) you can easily install django-valkey. 9 | this project can easily live alongside django-redis so you don't need to delete that if you don't want to. 10 | 11 | 12 | ## Different configuration 13 | 14 | The `REDIS_CLIENT_CLASS` has been renamed to `BASE_CLIENT_CLASS`. 15 | The `REDIS_CLIENT_KWARGS` has been renamed to `BASE_CLIENT_KWARGS`. 16 | so if you have any one these two configured in your project you should change that. 17 | 18 | 19 | other than the above change the rest of the API is consistent, 20 | but any of the configurations that have any form of `redis` in it has been changed to valkey. 21 | you can easily fix this by running this commands: 22 | 23 | ```shell 24 | sed -i 's/REDIS/VALKEY/' settings.py 25 | sed -i 's/redis/valkey/' settings.py 26 | sed -i 's/Redis/Valkey/' settings.py 27 | ``` 28 | 29 | where settings.py is the file you have your configs in, change the file name if you are using a different name. 30 | 31 | 32 | ## Different commands 33 | 34 | in django-redis, `get_many()` is an atomic operation, but `set_many()` is non-atomic. 35 | 36 | in django-valkey `mget()` and `mset()` are atomic, and `get_many()` and `set_many()` are non-atomic. 37 | 38 | 39 | ## More options 40 | 41 | Although the above steps are completely enough to get you going, if you want you can now easily customize your compression behaviour. 42 | Check out [Compressor support](configure/compressors.md) for complete explanation. 43 | -------------------------------------------------------------------------------- /tests/settings/sqlite_sentinel_opts.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | SENTINELS = [("127.0.0.1", 26379)] 4 | 5 | conn_factory = "django_valkey.pool.SentinelConnectionFactory" 6 | 7 | CACHES = { 8 | "default": { 9 | "BACKEND": "django_valkey.cache.ValkeyCache", 10 | "LOCATION": ["valkey://mymaster?db=5"], 11 | "OPTIONS": { 12 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 13 | "SENTINELS": SENTINELS, 14 | "CONNECTION_FACTORY": conn_factory, 15 | }, 16 | }, 17 | "doesnotexist": { 18 | "BACKEND": "django_valkey.cache.ValkeyCache", 19 | "LOCATION": "valkey://missing_service?db=1", 20 | "OPTIONS": { 21 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 22 | "SENTINELS": SENTINELS, 23 | "CONNECTION_FACTORY": conn_factory, 24 | }, 25 | }, 26 | "sample": { 27 | "BACKEND": "django_valkey.cache.ValkeyCache", 28 | "LOCATION": "valkey://mymaster?db=1", 29 | "OPTIONS": { 30 | "CLIENT_CLASS": "django_valkey.client.SentinelClient", 31 | "SENTINELS": SENTINELS, 32 | "CONNECTION_FACTORY": conn_factory, 33 | }, 34 | }, 35 | "with_prefix": { 36 | "BACKEND": "django_valkey.cache.ValkeyCache", 37 | "LOCATION": "valkey://mymaster?db=1", 38 | "KEY_PREFIX": "test-prefix", 39 | "OPTIONS": { 40 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 41 | "SENTINELS": SENTINELS, 42 | "CONNECTION_FACTORY": conn_factory, 43 | }, 44 | }, 45 | } 46 | 47 | INSTALLED_APPS = ["django.contrib.sessions"] 48 | 49 | USE_TZ = False 50 | -------------------------------------------------------------------------------- /tests/settings/sqlite_async_sentinel.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | DJANGO_VALKEY_CONNECTION_FACTORY = ( 4 | "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory" 5 | ) 6 | 7 | SENTINELS = [("127.0.0.1", 26379)] 8 | 9 | CACHES = { 10 | "default": { 11 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 12 | "LOCATION": ["valkey://mymaster?db=1"], 13 | "OPTIONS": { 14 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncSentinelClient", 15 | "SENTINELS": SENTINELS, 16 | }, 17 | }, 18 | "doesnotexist": { 19 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 20 | "LOCATION": "valkey://missing_service?db=1", 21 | "OPTIONS": { 22 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient", 23 | "SENTINELS": SENTINELS, 24 | }, 25 | }, 26 | "sample": { 27 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 28 | "LOCATION": "valkey://mymaster?db=1", 29 | "OPTIONS": { 30 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncSentinelClient", 31 | "SENTINELS": SENTINELS, 32 | }, 33 | }, 34 | "with_prefix": { 35 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 36 | "LOCATION": "valkey://mymaster?db=1", 37 | "KEY_PREFIX": "test-prefix", 38 | "OPTIONS": { 39 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient", 40 | "SENTINELS": SENTINELS, 41 | }, 42 | }, 43 | } 44 | 45 | INSTALLED_APPS = ["django.contrib.sessions"] 46 | 47 | USE_TZ = False 48 | 49 | ROOT_URLCONF = "tests.settings.urls" 50 | -------------------------------------------------------------------------------- /tests/settings/sqlite_cluster.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.cluster_cache.cache.ClusterValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:7005", "valkey://127.0.0.1:7005"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.cluster_cache.client.DefaultClusterClient" 9 | }, 10 | }, 11 | "doesnotexist": { 12 | "BACKEND": "django_valkey.cluster_cache.cache.ClusterValkeyCache", 13 | "LOCATION": "valkey://127.0.0.1:56379?db=0", 14 | "OPTIONS": { 15 | "CLIENT_CLASS": "django_valkey.cluster_cache.client.DefaultClusterClient" 16 | }, 17 | }, 18 | "sample": { 19 | "BACKEND": "django_valkey.cluster_cache.cache.ClusterValkeyCache", 20 | "LOCATION": "valkey://127.0.0.1:7005:0,valkey://127.0.0.1:7002:0", 21 | "OPTIONS": { 22 | "CLIENT_CLASS": "django_valkey.cluster_cache.client.DefaultClusterClient" 23 | }, 24 | }, 25 | "with_prefix": { 26 | "BACKEND": "django_valkey.cluster_cache.cache.ClusterValkeyCache", 27 | "LOCATION": "valkey://127.0.0.1:7005?db=0", 28 | "OPTIONS": { 29 | "CLIENT_CLASS": "django_valkey.cluster_cache.client.DefaultClusterClient" 30 | }, 31 | "KEY_PREFIX": "test-prefix", 32 | }, 33 | } 34 | 35 | # Include `django.contrib.auth` and `django.contrib.contenttypes` for mypy / 36 | # django-stubs. 37 | 38 | # See: 39 | # - https://github.com/typeddjango/django-stubs/issues/318 40 | # - https://github.com/typeddjango/django-stubs/issues/534 41 | INSTALLED_APPS = [ 42 | "django.contrib.auth", 43 | "django.contrib.contenttypes", 44 | "django.contrib.sessions", 45 | ] 46 | 47 | USE_TZ = False 48 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # django-valkey 2 | 3 | [valkey](https://valkey.io/) is an open source BSD Licensed high-performance key/value database that supports a variety of workloads such as *caching*, message queues, and can act as a primary database. 4 | 5 | django-valkey is a customizable valkey backend to be used in django. 6 | this project was initially a fork of the wonderful `django-redis` project. 7 | 8 | 9 | ## django-valkey Features 10 | 11 | 1. Uses native valkey-py url notation connection strings 12 | 2. Pluggable clients: 13 | 1. Default Client 14 | 2. Herd Client 15 | 3. Sentinel Client 16 | 4. Sharded Client 17 | 5. Async client 18 | 6. or just plug in your own client 19 | 3. Pluggable serializers: 20 | 1. Pickle Serializer 21 | 2. Json Serializer 22 | 3. msgpack serializer 23 | 4. or plug in your own serializer 24 | 4. Pluggable compression: 25 | 1. brotli compression 26 | 2. bz2 compression (bzip2) 27 | 3. gzip compression 28 | 4. lz4 compression 29 | 5. lzma compression 30 | 6. zlib compression 31 | 7. zstd compression 32 | 8. plug in your own 33 | 5. Pluggable parsers 34 | 1. Valkey's default parser 35 | 2. plug in your own 36 | 6. Pluggable connection pool 37 | 1. Valkey's default connection pool 38 | 2. plug in your own 39 | 7. Comprehensive test suite 40 | 8. Supports infinite timeouts 41 | 9. Facilities for raw access to Valkey client/connection pool 42 | 10. Highly configurable (really, just look around) 43 | 11. Unix sockets supported by default 44 | 45 | ## Requirements 46 | 47 | [Python](https://www.python.org/downloads/) 3.10+ 48 | 49 | [Django](https://www.djangoproject.com/download/) 4.2.20+ 50 | 51 | [valkey-py](https://pypi.org/project/valkey/) 6.0.1+ 52 | 53 | [Valkey](https://valkey.io/download/) 7.2.6+ -------------------------------------------------------------------------------- /tests/settings/sqlite_async.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | CACHES = { 4 | "default": { 5 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 6 | "LOCATION": ["valkey://127.0.0.1:6379?db=1", "valkey://127.0.0.1:6379?db=1"], 7 | "OPTIONS": { 8 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient" 9 | }, 10 | }, 11 | "doesnotexist": { 12 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 13 | "LOCATION": "valkey://127.0.0.1:56379?db=1", 14 | "OPTIONS": { 15 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient" 16 | }, 17 | }, 18 | "sample": { 19 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 20 | "LOCATION": "valkey://127.0.0.1:6379:1,valkey://127.0.0.1:6379:1", 21 | "OPTIONS": { 22 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient" 23 | }, 24 | }, 25 | "with_prefix": { 26 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 27 | "LOCATION": "valkey://127.0.0.1:6379?db=1", 28 | "OPTIONS": { 29 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient" 30 | }, 31 | "KEY_PREFIX": "test-prefix", 32 | }, 33 | } 34 | 35 | # Include `django.contrib.auth` and `django.contrib.contenttypes` for mypy / 36 | # django-stubs. 37 | 38 | # See: 39 | # - https://github.com/typeddjango/django-stubs/issues/318 40 | # - https://github.com/typeddjango/django-stubs/issues/534 41 | INSTALLED_APPS = [ 42 | "django.contrib.auth", 43 | "django.contrib.contenttypes", 44 | "django.contrib.sessions", 45 | ] 46 | 47 | USE_TZ = False 48 | 49 | ROOT_URLCONF = "tests.settings.urls" 50 | -------------------------------------------------------------------------------- /docs/async/configurations.md: -------------------------------------------------------------------------------- 1 | # Configure The Async Client 2 | 3 | **Warning**: as of django 5.2, async support for cache backends is flaky, if you decide to use the async backends do so with caution. 4 | 5 | **Important**: the async client is not compatible with django's cache middlewares. 6 | if you need the middlewares, consider using the sync client or implement a new middleware. 7 | 8 | there are two async clients available, a normal client and a herd client. 9 | 10 | ## Default client 11 | 12 | to setup the async client you can configure your settings file to look like this: 13 | 14 | ```python 15 | CACHES = { 16 | "default": { 17 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 18 | "LOCATION": "valkey://127.0.0.1:6379", 19 | "OPTIONS": {...}, 20 | }, 21 | } 22 | ``` 23 | 24 | take a look at [Configure the database URL](../configure/advanced_configurations.md#configure-the-database-url) to see other ways to write the URL. 25 | And that's it, the backend defaults to use AsyncDefaultClient as client interface, AsyncConnectionFactory as connection factory and valkey-py's async client. 26 | 27 | you can, of course configure it to use any other class, or pass in extras args and kwargs, the same way that was discussed at [Advanced Configurations](../configure/advanced_configurations.md). 28 | 29 | ## Herd client 30 | 31 | to set up herd client configure your settings like this: 32 | 33 | ```python 34 | CACHES = { 35 | "default": { 36 | "BACKEND": "django_valkey.async_cache.caches.AsyncValkeyCache", 37 | "LOCATION": "valkey://127.0.0.1:6379", 38 | "OPTIONS": { 39 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncHerdClient", 40 | }, 41 | }, 42 | } 43 | ``` 44 | 45 | for a more specified guide look at [Advanced Async Configuration](advanced_configurations.md). -------------------------------------------------------------------------------- /django_valkey/client/sentinel.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from valkey.sentinel import SentinelConnectionPool 6 | 7 | from django_valkey.client.default import DefaultClient 8 | 9 | 10 | def replace_query(url, query): 11 | return urlunparse((*url[:4], urlencode(query, doseq=True), url[5])) 12 | 13 | 14 | class SentinelClient(DefaultClient): 15 | CONNECTION_FACTORY_PATH = "django_valkey.pool.SentinelConnectionFactory" 16 | 17 | """ 18 | Sentinel client which uses the single valkey URL specified by the CACHE's 19 | LOCATION to create a LOCATION configuration for two connection pools; One 20 | pool for the primaries and another pool for the replicas, and upon 21 | connecting ensures the connection pool factory is configured correctly. 22 | """ 23 | 24 | def __init__(self, server, params, backend): 25 | if isinstance(server, str): 26 | url = urlparse(server) 27 | primary_query = parse_qs(url.query, keep_blank_values=True) 28 | replica_query = primary_query 29 | primary_query["is_master"] = [1] # type: ignore 30 | replica_query["is_master"] = [0] # type: ignore 31 | 32 | server = [replace_query(url, i) for i in (primary_query, replica_query)] 33 | 34 | super().__init__(server, params, backend) 35 | 36 | def connect(self, *args, **kwargs): 37 | connection = super().connect(*args, **kwargs) 38 | if not isinstance(connection.connection_pool, SentinelConnectionPool): 39 | error_message = ( 40 | "Settings DJANGO_VALKEY_CONNECTION_FACTORY or " 41 | "CACHE[].OPTIONS.CONNECTION_POOL_CLASS is not configured correctly." 42 | ) 43 | raise ImproperlyConfigured(error_message) 44 | 45 | return connection 46 | -------------------------------------------------------------------------------- /django_valkey/compressors/brotli.py: -------------------------------------------------------------------------------- 1 | import brotli 2 | 3 | from django.conf import settings 4 | 5 | from django_valkey.compressors.base import BaseCompressor 6 | 7 | 8 | class BrotliCompressor(BaseCompressor): 9 | """ 10 | Brotli compressor 11 | set with: 12 | CACHES = { 13 | "default": { 14 | # ... 15 | "OPTIONS": { 16 | "COMPRESSOR": "django_valkey.compressors.brotli.BrotliCompressor", 17 | } 18 | } 19 | } 20 | 21 | compression parameters: 22 | to set `quality` use `CACHE_COMPRESS_LEVEL` in your settings, defaults to 4. 23 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 24 | to set `lgwin` use `COMPRESS_BROTLI_LGWIN` in your settings, defaults to 22. 25 | to set `lgblock` use `COMPRESS_BROTLI_LGBLOCK` in your settings, defaults to 0. 26 | 27 | to set `mode` use `COMPRESS_BROTLI_MODE` in your settings, 28 | the accepted values are: "GENERIC", "TEXT", "FONT" 29 | the default is GENERIC, if other value is written, GENERIC will be used instead 30 | """ 31 | 32 | mode = getattr(settings, "COMPRESS_BROTLI_MODE", "GENERIC") 33 | lgwin = getattr(settings, "COMPRESS_BROTLI_LGWIN", 22) 34 | lgblock = getattr(settings, "COMPRESS_BROTLI_LGBLOCK", 0) 35 | 36 | match mode.upper(): 37 | case "TEXT": 38 | mode = brotli.MODE_TEXT 39 | case "FONT": 40 | mode = brotli.MODE_FONT 41 | case _: 42 | mode = brotli.MODE_GENERIC 43 | 44 | def _compress(self, value): 45 | return brotli.compress( 46 | value, 47 | quality=self.level or 11, 48 | lgwin=self.lgwin, 49 | lgblock=self.lgblock, 50 | mode=self.mode, 51 | ) 52 | 53 | def _decompress(self, value): 54 | return brotli.decompress(value) 55 | -------------------------------------------------------------------------------- /django_valkey/async_cache/client/sentinel.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs, urlparse 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from valkey.asyncio.sentinel import SentinelConnectionPool 6 | 7 | from django_valkey.async_cache.client.default import AsyncDefaultClient 8 | from django_valkey.client.sentinel import replace_query 9 | 10 | 11 | class AsyncSentinelClient(AsyncDefaultClient): 12 | CONNECTION_FACTORY_PATH = ( 13 | "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory" 14 | ) 15 | """ 16 | Sentinel client which uses the single valkey URL specified by the CACHE's 17 | LOCATION to create a LOCATION configuration for two connection pools; One 18 | pool for the primaries and another pool for the replicas, and upon 19 | connecting ensures the connection pool factory is configured correctly. 20 | """ 21 | 22 | def __init__(self, server, params, backend): 23 | if isinstance(server, str): 24 | url = urlparse(server) 25 | primary_query = parse_qs(url.query, keep_blank_values=True) 26 | replica_query = primary_query 27 | primary_query["is_master"] = [1] # type: ignore 28 | replica_query["is_master"] = [0] # type: ignore 29 | 30 | server = [replace_query(url, i) for i in (primary_query, replica_query)] 31 | 32 | super().__init__(server, params, backend) 33 | 34 | async def connect(self, *args, **kwargs): 35 | connection = await super().connect(*args, **kwargs) 36 | if not isinstance(connection.connection_pool, SentinelConnectionPool): 37 | error_message = ( 38 | "Settings DJANGO_VALKEY_CONNECTION_FACTORY or " 39 | "CACHE[].OPTIONS.CONNECTION_POOL_CLASS is not configured correctly." 40 | ) 41 | raise ImproperlyConfigured(error_message) 42 | 43 | return connection 44 | -------------------------------------------------------------------------------- /docs/cluster/basic_configurations.md: -------------------------------------------------------------------------------- 1 | # Basic cluster configuration 2 | 3 | for installation, look at our [Installation guide](../installation.md) 4 | 5 | 6 | ## Configure as cache backend 7 | 8 | to start using django-valkey's cluster backend, change your django cache setting to something like this: 9 | 10 | ```python 11 | CACHES = { 12 | "default": { 13 | "BACKEND": "django_valkey.cluster_cache.cache.ClusterValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:6379", 15 | "OPTIONS": {...} 16 | } 17 | } 18 | ``` 19 | 20 | you need to point to at least one of the cluster nodes in `LOCATION`, or pass a list of multiple nodes 21 | 22 | at the moment, only one client is available for cluster backend 23 | 24 | most of the configurations you see in [basic configuration](../configure/basic_configurations.md) and [advanced configuration](../configure/advanced_configurations.md) 25 | apply here as well, except the following: 26 | 27 | 28 | 29 | ### Memcached exception behavior 30 | in [Memcached exception behavior](../configure/basic_configurations.md#memcached-exception-behavior) we discussed how to ignore and log exceptitions, 31 | sadly, until we find a way around it, this is not accessible with cluster backend 32 | 33 | 34 | ## Multi-key Commands 35 | 36 | please refer to [valkey-py docs](https://valkey-py.readthedocs.io/en/latest/clustering.html#multi-key-commands) on how to use multi-key commands, such as `mset`, `megt`, etc... 37 | 38 | there are some other info in their documentations that might be of interest to you, we suggest you take a look 39 | 40 | 41 | ## Additional methods 42 | in addition to what other `django-valkey` clients provide, cluster client supports the following methods: 43 | 44 | * mset_nonatomic (same as `set_many`) 45 | * msetnx 46 | * mget_nonatomic (same as `get_many`) 47 | * readonly 48 | * readwrite 49 | * keyslot 50 | * flushall 51 | * invalidate_key_from_cache 52 | -------------------------------------------------------------------------------- /tests/settings/sqlite_async_sentinel_opts.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django_tests_secret_key" 2 | 3 | SENTINELS = [("127.0.0.1", 26379)] 4 | 5 | conn_factory = "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory" 6 | 7 | CACHES = { 8 | "default": { 9 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 10 | "LOCATION": ["valkey://mymaster?db=5"], 11 | "OPTIONS": { 12 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient", 13 | "SENTINELS": SENTINELS, 14 | "CONNECTION_FACTORY": conn_factory, 15 | }, 16 | }, 17 | "doesnotexist": { 18 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 19 | "LOCATION": "valkey://missing_service?db=1", 20 | "OPTIONS": { 21 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient", 22 | "SENTINELS": SENTINELS, 23 | "CONNECTION_FACTORY": conn_factory, 24 | }, 25 | }, 26 | "sample": { 27 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 28 | "LOCATION": "valkey://mymaster?db=1", 29 | "OPTIONS": { 30 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncSentinelClient", 31 | "SENTINELS": SENTINELS, 32 | "CONNECTION_FACTORY": conn_factory, 33 | }, 34 | }, 35 | "with_prefix": { 36 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 37 | "LOCATION": "valkey://mymaster?db=1", 38 | "KEY_PREFIX": "test-prefix", 39 | "OPTIONS": { 40 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient", 41 | "SENTINELS": SENTINELS, 42 | "CONNECTION_FACTORY": conn_factory, 43 | }, 44 | }, 45 | } 46 | 47 | INSTALLED_APPS = ["django.contrib.sessions"] 48 | 49 | USE_TZ = False 50 | 51 | ROOT_URLCONF = "tests.settings.urls" 52 | -------------------------------------------------------------------------------- /django_valkey/hash_ring.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import hashlib 3 | from collections.abc import Iterable, Iterator 4 | from typing import Dict, List, Tuple 5 | 6 | 7 | class HashRing: 8 | nodes: List[str] = [] 9 | 10 | def __init__(self, nodes: Iterable[str] = (), replicas: int = 128) -> None: 11 | self.replicas: int = replicas 12 | self.ring: Dict[str, str] = {} 13 | self.sorted_keys: List[str] = [] 14 | 15 | for node in nodes: 16 | self.add_node(node) 17 | 18 | def add_node(self, node: str) -> None: 19 | self.nodes.append(node) 20 | 21 | for x in range(self.replicas): 22 | _key = f"{node}:{x}" 23 | _hash = hashlib.sha256(_key.encode()).hexdigest() 24 | 25 | self.ring[_hash] = node 26 | self.sorted_keys.append(_hash) 27 | 28 | self.sorted_keys.sort() 29 | 30 | def remove_node(self, node: str) -> None: 31 | self.nodes.remove(node) 32 | for x in range(self.replicas): 33 | _hash = hashlib.sha256(f"{node}:{x}".encode()).hexdigest() 34 | del self.ring[_hash] 35 | self.sorted_keys.remove(_hash) 36 | 37 | def get_node(self, key: str) -> str | None: 38 | n, i = self.get_node_pos(key) 39 | return n 40 | 41 | def get_node_pos(self, key: str) -> Tuple[str, int] | Tuple[None, None]: 42 | if len(self.ring) == 0: 43 | return None, None 44 | 45 | _hash = hashlib.sha256(key.encode()).hexdigest() 46 | idx = bisect.bisect(self.sorted_keys, _hash) 47 | idx = min(idx - 1, (self.replicas * len(self.nodes)) - 1) 48 | return self.ring[self.sorted_keys[idx]], idx 49 | 50 | def iter_nodes(self, key: str) -> Iterator[Tuple[str, str] | Tuple[None, None]]: 51 | if len(self.ring) == 0: 52 | yield None, None 53 | 54 | node, pos = self.get_node_pos(key) 55 | for k in self.sorted_keys[pos:]: 56 | yield k, self.ring[k] 57 | 58 | def __call__(self, key: str) -> str | None: 59 | return self.get_node(key) 60 | -------------------------------------------------------------------------------- /django_valkey/compressors/lz4.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | try: 4 | from lz4.frame import compress 5 | from lz4.frame import decompress 6 | except ImportError: # python-lz4/python-lz4#302 7 | if sys.version_info >= (3, 14): 8 | compress = decompress = None 9 | else: 10 | raise 11 | 12 | from django.conf import settings 13 | 14 | from django_valkey.compressors.base import BaseCompressor 15 | 16 | 17 | class Lz4Compressor(BaseCompressor): 18 | """ 19 | Lz4 compressor 20 | set with: 21 | CACHES = { 22 | "default": { 23 | # ... 24 | "OPTIONS": { 25 | "COMPRESSOR": "django_valkey.compressors.lz4.Lz4Compressor", 26 | } 27 | } 28 | } 29 | 30 | compression parameters: 31 | to set `compression_level` use `CACHE_COMPRESS_LEVEL` in your settings, defaults to 4. 32 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 33 | to set `block_size` use `COMPRESS_LZ4_BLOCK_SIZE` in your settings, it defaults to 0. 34 | to set `content_checksum` use `COMPRESS_LZ4_CONTENT_CHECKSUM` in your settings, it defaults to 0. 35 | to set `blocked_linked` use `COMPRESS_LZ4_BLOCK_LINKED` in your settings, it defaults to True. 36 | to set `store_size` use `COMPRESS_LZ4_STORE_SIZE` in your settings, it defaults to True. 37 | """ 38 | 39 | block_size = getattr(settings, "COMPRESS_LZ4_BLOCK_SIZE", 0) 40 | content_checksum = getattr(settings, "COMPRESS_LZ4_CONTENT_CHECKSUM", 0) 41 | block_linked = getattr(settings, "COMPRESS_LZ4_BLOCK_LINKED", True) 42 | store_size = getattr(settings, "COMPRESS_LZ4_STORE_SIZE", True) 43 | 44 | def _compress(self, value: bytes) -> bytes: 45 | return compress( 46 | value, 47 | compression_level=self.level or 0, 48 | block_size=self.block_size, 49 | content_checksum=self.content_checksum, 50 | block_linked=self.block_linked, 51 | store_size=self.store_size, 52 | ) 53 | 54 | def _decompress(self, value: bytes) -> bytes: 55 | return decompress(value) 56 | -------------------------------------------------------------------------------- /tests/test_connection_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from django_valkey import pool 6 | 7 | 8 | def test_connection_factory_redefine_from_opts(): 9 | cf = pool.get_connection_factory( 10 | path="django_valkey.pool.ConnectionFactory", 11 | options={ 12 | "CONNECTION_FACTORY": "django_valkey.pool.SentinelConnectionFactory", 13 | "SENTINELS": [("127.0.0.1", "26739")], 14 | }, 15 | ) 16 | assert cf.__class__.__name__ == "SentinelConnectionFactory" 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "conn_factory,expected", 21 | [ 22 | ( 23 | "django_valkey.pool.SentinelConnectionFactory", 24 | pool.SentinelConnectionFactory, 25 | ), 26 | ("django_valkey.pool.ConnectionFactory", pool.ConnectionFactory), 27 | ], 28 | ) 29 | def test_connection_factory_opts(conn_factory: str, expected): 30 | cf = pool.get_connection_factory( 31 | path=None, 32 | options={ 33 | "CONNECTION_FACTORY": conn_factory, 34 | "SENTINELS": [("127.0.0.1", "26739")], 35 | }, 36 | ) 37 | assert isinstance(cf, expected) 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "conn_factory,expected", 42 | [ 43 | ( 44 | "django_valkey.pool.SentinelConnectionFactory", 45 | pool.SentinelConnectionFactory, 46 | ), 47 | ("django_valkey.pool.ConnectionFactory", pool.ConnectionFactory), 48 | ], 49 | ) 50 | def test_connection_factory_path(conn_factory: str, expected): 51 | cf = pool.get_connection_factory( 52 | path=conn_factory, 53 | options={ 54 | "SENTINELS": [("127.0.0.1", "26739")], 55 | }, 56 | ) 57 | assert isinstance(cf, expected) 58 | 59 | 60 | def test_connection_factory_no_sentinels(): 61 | with pytest.raises(ImproperlyConfigured): 62 | pool.get_connection_factory( 63 | path=None, 64 | options={ 65 | "CONNECTION_FACTORY": "django_valkey.pool.SentinelConnectionFactory", 66 | }, 67 | ) 68 | -------------------------------------------------------------------------------- /django_valkey/compressors/zstd.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.conf import settings 3 | 4 | from django_valkey.compressors.base import BaseCompressor 5 | 6 | if sys.version_info >= (3, 14): 7 | from compression import zstd 8 | else: 9 | from backports import zstd 10 | 11 | 12 | class ZStdCompressor(BaseCompressor): 13 | """ 14 | ZStdCompressor 15 | set with: 16 | CACHES = { 17 | "default": { 18 | # ... 19 | "OPTIONS": { 20 | "COMPRESSOR": "django_valkey.compressors.zstd.ZStdCompressor", 21 | } 22 | } 23 | } 24 | 25 | compression parameters: 26 | to set `level` use `CACHE_COMPRESS_LEVEL` 27 | to set `options` `COMPRESS_ZSTD_OPTIONS` in your settings. 28 | if `COMPRESSION_ZSTD_OPTIONS` is set, level won't be used 29 | 30 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 31 | 32 | to set `zstd_dict` use `COMPRESS_ZSTD_DICT` in your settings, it defaults to None. 33 | 34 | decompression parameters: 35 | to set `options` use `DECOMPRESS_ZSTD_OPTIONS` in your settings, 36 | if no value provided, it uses `COMPRESS_ZSTD_OPTIONS`. 37 | 38 | to set `zstd_dict` use `DECOMPRESS_ZSTD_DICT` in your settings, 39 | if no value is provided `COMPRESS_ZSTD_DICT` is used. 40 | 41 | """ 42 | 43 | options: dict | None = getattr(settings, "COMPRESS_ZSTD_OPTIONS", None) 44 | decomp_options = getattr(settings, "DECOMPRESS_ZSTD_OPTIONS", None) 45 | zstd_dict = getattr(settings, "COMPRESS_ZSTD_DICT", None) 46 | decomp_zstd_dict = getattr(settings, "DECOMPRESS_ZSTD_DICT", None) 47 | 48 | def _compress(self, value: bytes) -> bytes: 49 | return zstd.compress( 50 | value, 51 | options=self.options, 52 | level=self.level or 1, 53 | zstd_dict=self.zstd_dict, 54 | ) 55 | 56 | def _decompress(self, value: bytes) -> bytes: 57 | return zstd.decompress( 58 | value, 59 | zstd_dict=self.decomp_zstd_dict or self.zstd_dict, 60 | options=self.decomp_options or self.options, 61 | ) 62 | -------------------------------------------------------------------------------- /django_valkey/compressors/lzma.py: -------------------------------------------------------------------------------- 1 | import lzma 2 | 3 | from django.conf import settings 4 | 5 | from django_valkey.compressors.base import BaseCompressor 6 | 7 | 8 | class LzmaCompressor(BaseCompressor): 9 | """ 10 | LzmaCompressor 11 | set with: 12 | CACHES = { 13 | "default": { 14 | # ... 15 | "OPTIONS": { 16 | "COMPRESSOR": "django_valkey.compressors.lzma.LzmaCompressor", 17 | } 18 | } 19 | } 20 | 21 | compression parameters: 22 | to set `preset` use `CACHE_COMPRESS_LEVEL` in your settings, defaults to 4. 23 | to set `minimum size` set `CACHE_COMPRESS_MIN_LENGTH` in your settings, defaults to 15. 24 | to set `format` use `COMPRESS_LZMA_FORMAT` in your settings, it defaults to 1. 25 | to set `check` use `COMPRESS_LZMA_CHECK` in your settings, it defaults to -1. 26 | to set `filters` use `COMPRESS_LZMA_FILTERS` in your settings, it defaults to None. 27 | 28 | decompression parameters: 29 | to set `memlimit` use `DECOMPRESS_LZMA_MEMLIMIT` in your settings, it defaults to None. 30 | to set `format` use `DECOMPRESS_LZMA_FORMAT` in your settings, it defaults to 0. 31 | to set `filters` use `DECOMPRESS_LZMA_FILTERS` in your settings, it defaults to None. 32 | """ 33 | 34 | format = getattr(settings, "COMPRESS_LZMA_FORMAT", 1) 35 | check = getattr(settings, "COMPRESS_LZMA_CHECK", -1) 36 | filters: dict | None = getattr(settings, "COMPRESS_LZMA_FILTERS", None) 37 | 38 | memlimit: int = getattr(settings, "DECOMPRESS_LZMA_MEMLIMIT", None) 39 | decomp_format = getattr(settings, "DECOMPRESS_LZMA_FORMAT", 0) 40 | decomp_filters = getattr(settings, "DECOMPRESS_LZMA_FILTERS", None) 41 | 42 | def _compress(self, value: bytes) -> bytes: 43 | return lzma.compress( 44 | value, 45 | preset=self.level or 4, 46 | format=self.format or 4, 47 | check=self.check, 48 | filters=self.filters, 49 | ) 50 | 51 | def _decompress(self, value: bytes) -> bytes: 52 | return lzma.decompress( 53 | value, 54 | memlimit=self.memlimit, 55 | format=self.decomp_format, 56 | filters=self.decomp_filters, 57 | ) 58 | -------------------------------------------------------------------------------- /django_valkey/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | from contextlib import suppress 3 | from typing import Any 4 | 5 | from valkey.typing import KeyT, EncodableT 6 | 7 | from django_valkey.compressors.base import BaseCompressor 8 | from django_valkey.exceptions import CompressorError 9 | from django_valkey.serializers.base import BaseSerializer 10 | 11 | 12 | class CacheKey(str): 13 | """ 14 | A stub string class that we can use to check if a key was created already. 15 | """ 16 | 17 | def original_key(self) -> str: 18 | return self.rsplit(":", 1)[1] 19 | 20 | 21 | def default_reverse_key(key: str) -> str: 22 | return key.split(":", 2)[2] 23 | 24 | 25 | special_re = re.compile("([*?[])") 26 | 27 | 28 | def glob_escape(s: str) -> str: 29 | return special_re.sub(r"[\1]", s) 30 | 31 | 32 | def make_key( 33 | key: KeyT | None, key_func, version: int | None = None, prefix: str | None = None 34 | ) -> CacheKey | None: 35 | if not key: 36 | return key 37 | 38 | if isinstance(key, CacheKey): 39 | return key 40 | 41 | return CacheKey(key_func(key, prefix, version)) 42 | 43 | 44 | def make_pattern( 45 | pattern: str | None, key_func, version: int | None = None, prefix: str | None = None 46 | ) -> CacheKey | None: 47 | if not pattern: 48 | return pattern 49 | 50 | if isinstance(pattern, CacheKey): 51 | return pattern 52 | 53 | prefix = glob_escape(prefix) 54 | version_str = glob_escape(str(version)) 55 | 56 | return CacheKey(key_func(pattern, prefix, version_str)) 57 | 58 | 59 | def encode( 60 | value: EncodableT, serializer: BaseSerializer, compressor: BaseCompressor 61 | ) -> bytes | int | float: 62 | if type(value) not in {int, float}: 63 | value = serializer.dumps(value) 64 | return compressor.compress(value) 65 | 66 | return value 67 | 68 | 69 | def decode(value: bytes, serializer: BaseSerializer, compressor: BaseCompressor) -> Any: 70 | try: 71 | if value.isdigit(): 72 | value = int(value) 73 | else: 74 | value = float(value) 75 | 76 | except (ValueError, TypeError): 77 | with suppress(CompressorError): 78 | value = compressor.decompress(value) 79 | value = serializer.loads(value) 80 | 81 | return value 82 | -------------------------------------------------------------------------------- /tests/tests_async/test_connection_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from django_valkey import pool as sync_pool 6 | from django_valkey.async_cache import pool 7 | 8 | 9 | pytestmark = pytest.mark.anyio 10 | 11 | 12 | async def test_connection_factory_redefine_from_opts(): 13 | cf = sync_pool.get_connection_factory( 14 | options={ 15 | "CONNECTION_FACTORY": "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory", 16 | "SENTINELS": [("127.0.0.1", "26379")], 17 | }, 18 | ) 19 | assert cf.__class__.__name__ == "AsyncSentinelConnectionFactory" 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "conn_factory,expected", 24 | [ 25 | ( 26 | "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory", 27 | pool.AsyncSentinelConnectionFactory, 28 | ), 29 | ( 30 | "django_valkey.async_cache.pool.AsyncConnectionFactory", 31 | pool.AsyncConnectionFactory, 32 | ), 33 | ], 34 | ) 35 | async def test_connection_factory_opts(conn_factory: str, expected): 36 | cf = sync_pool.get_connection_factory( 37 | path=None, 38 | options={ 39 | "CONNECTION_FACTORY": conn_factory, 40 | "SENTINELS": [("127.0.0.1", "26739")], 41 | }, 42 | ) 43 | assert isinstance(cf, expected) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "conn_factory,expected", 48 | [ 49 | ( 50 | "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory", 51 | pool.AsyncSentinelConnectionFactory, 52 | ), 53 | ( 54 | "django_valkey.async_cache.pool.AsyncConnectionFactory", 55 | pool.AsyncConnectionFactory, 56 | ), 57 | ], 58 | ) 59 | async def test_connection_factory_path(conn_factory: str, expected): 60 | cf = sync_pool.get_connection_factory( 61 | path=conn_factory, 62 | options={ 63 | "SENTINELS": [("127.0.0.1", "26739")], 64 | }, 65 | ) 66 | assert isinstance(cf, expected) 67 | 68 | 69 | async def test_connection_factory_no_sentinels(): 70 | with pytest.raises(ImproperlyConfigured): 71 | sync_pool.get_connection_factory( 72 | path=None, 73 | options={ 74 | "CONNECTION_FACTORY": "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory", 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /tests/tests_async/test_client.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | import pytest 4 | from pytest_django.fixtures import SettingsWrapper 5 | from pytest_mock import MockerFixture 6 | 7 | from django.core.cache import DEFAULT_CACHE_ALIAS 8 | 9 | from django_valkey.async_cache.cache import AsyncValkeyCache 10 | from django_valkey.async_cache.client import AsyncDefaultClient 11 | 12 | pytestmark = pytest.mark.anyio 13 | 14 | 15 | @pytest.fixture 16 | async def cache_client(cache: AsyncValkeyCache) -> Iterable[AsyncDefaultClient]: 17 | client = cache.client 18 | await client.aset("TestClientClose", 0) 19 | yield client 20 | await client.adelete("TestClientClose") 21 | 22 | 23 | class TestClientClose: 24 | async def test_close_client_disconnect_default( 25 | self, cache_client: AsyncDefaultClient, mocker: MockerFixture 26 | ): 27 | mock = mocker.patch.object( 28 | cache_client.connection_factory, "disconnect", new_callable=mocker.AsyncMock 29 | ) 30 | 31 | await cache_client.aclose() 32 | assert not mock.called 33 | 34 | async def test_close_disconnect_settings( 35 | self, 36 | cache_client: AsyncDefaultClient, 37 | settings: SettingsWrapper, 38 | mocker: MockerFixture, 39 | ): 40 | mock = mocker.patch.object( 41 | cache_client.connection_factory, "disconnect", new_callable=mocker.AsyncMock 42 | ) 43 | 44 | settings.DJANGO_VALKEY_CLOSE_CONNECTION = True 45 | 46 | await cache_client.aclose() 47 | assert mock.called 48 | 49 | async def test_close_disconnect_settings_cache( 50 | self, 51 | cache_client: AsyncDefaultClient, 52 | mocker: MockerFixture, 53 | settings: SettingsWrapper, 54 | ): 55 | mock = mocker.patch.object( 56 | cache_client.connection_factory, "disconnect", new_callable=mocker.AsyncMock 57 | ) 58 | 59 | settings.CACHES[DEFAULT_CACHE_ALIAS]["OPTIONS"]["CLOSE_CONNECTION"] = True 60 | await cache_client.aset("TestClientClose", 0) 61 | 62 | await cache_client.aclose() 63 | assert mock.called 64 | 65 | async def test_close_disconnect_client_options( 66 | self, cache_client: AsyncDefaultClient, mocker: MockerFixture 67 | ): 68 | mock = mocker.patch.object( 69 | cache_client.connection_factory, "disconnect", new_callable=mocker.AsyncMock 70 | ) 71 | 72 | cache_client._options["CLOSE_CONNECTION"] = True 73 | 74 | await cache_client.aclose() 75 | assert mock.called 76 | -------------------------------------------------------------------------------- /django_valkey/async_cache/pool.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from urllib.parse import urlparse, parse_qs 3 | 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.module_loading import import_string 6 | 7 | from valkey._parsers.url_parser import to_bool 8 | from valkey.asyncio import Valkey as AValkey 9 | from valkey.asyncio.connection import ConnectionPool, DefaultParser 10 | from valkey.asyncio.sentinel import Sentinel 11 | 12 | from django_valkey.base_pool import BaseConnectionFactory 13 | 14 | 15 | class AsyncConnectionFactory(BaseConnectionFactory[AValkey, ConnectionPool]): 16 | path_pool_cls = "valkey.asyncio.connection.ConnectionPool" 17 | path_base_cls = "valkey.asyncio.client.Valkey" 18 | 19 | async def disconnect(self, connection: type[AValkey]) -> None: 20 | await connection.connection_pool.disconnect() 21 | 22 | def get_parser_cls(self) -> type[DefaultParser] | type: 23 | cls = self.options.get("PARSER_CLS", None) 24 | if cls is None: 25 | return DefaultParser 26 | return import_string(cls) 27 | 28 | async def connect(self, url: str) -> AValkey | Any: 29 | params = self.make_connection_params(url) 30 | return await self.get_connection(params) 31 | 32 | async def get_connection(self, params: dict) -> AValkey | Any: 33 | pool = self.get_or_create_connection_pool(params) 34 | return await self.base_client_cls( 35 | connection_pool=pool, **self.base_client_cls_kwargs 36 | ) 37 | 38 | 39 | class AsyncSentinelConnectionFactory(AsyncConnectionFactory): 40 | def __init__(self, options: dict): 41 | try: 42 | self.sentinels = options["SENTINELS"] 43 | except KeyError: 44 | e = "SENTINELS must be provided as a list of (host, port)." 45 | raise ImproperlyConfigured(e) 46 | 47 | options.setdefault( 48 | "CONNECTION_POOL_CLASS", "valkey.asyncio.sentinel.SentinelConnectionPool" 49 | ) 50 | super().__init__(options) 51 | connection_kwargs = self.make_connection_params(None) 52 | connection_kwargs.pop("url") 53 | connection_kwargs.update(self.pool_cls_kwargs) 54 | self._sentinel = Sentinel( 55 | self.sentinels, 56 | sentinel_kwargs=options.get("SENTINEL_KWARGS"), 57 | **connection_kwargs, 58 | ) 59 | 60 | def get_connection_pool(self, params: dict) -> ConnectionPool | Any: 61 | url = urlparse(params["url"]) 62 | cp_params = params 63 | cp_params.update(service_name=url.hostname, sentinel_manager=self._sentinel) 64 | pool = super().get_connection_pool(cp_params) 65 | 66 | is_master = parse_qs(url.query).get("is_master") 67 | if is_master: 68 | pool.is_master = to_bool(is_master[0]) 69 | 70 | return pool 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | # There should be no dev tags created, but to be safe, 8 | # let's not publish them. 9 | - '!*.dev*' 10 | 11 | env: 12 | # Change these for your project's URLs 13 | PYPI_URL: https://pypi.org/project/django-valkey 14 | 15 | jobs: 16 | build: 17 | name: Build distribution 📦 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.x" 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v5 28 | - name: Build a binary wheel and a source tarball 29 | run: uv build 30 | - name: Store the distribution packages 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: python-package-distributions 34 | path: dist/ 35 | 36 | publish-to-pypi: 37 | name: >- 38 | Publish Python 🐍 distribution 📦 to PyPI 39 | needs: 40 | - build 41 | runs-on: ubuntu-latest 42 | environment: 43 | name: pypi 44 | url: ${{ env.PYPI_URL }} 45 | permissions: 46 | id-token: write # IMPORTANT: mandatory for trusted publishing 47 | steps: 48 | - name: Download all the dists 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | - name: Publish distribution 📦 to PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1.12 55 | 56 | github-release: 57 | name: >- 58 | Sign the Python 🐍 distribution 📦 with Sigstore 59 | and upload them to GitHub Release 60 | needs: 61 | - publish-to-pypi 62 | runs-on: ubuntu-latest 63 | 64 | permissions: 65 | contents: write # IMPORTANT: mandatory for making GitHub Releases 66 | id-token: write # IMPORTANT: mandatory for sigstore 67 | 68 | steps: 69 | - name: Download all the dists 70 | uses: actions/download-artifact@v4 71 | with: 72 | name: python-package-distributions 73 | path: dist/ 74 | - name: Sign the dists with Sigstore 75 | uses: sigstore/gh-action-sigstore-python@v3.0.0 76 | with: 77 | inputs: >- 78 | ./dist/*.tar.gz 79 | ./dist/*.whl 80 | - name: Create GitHub Release 81 | env: 82 | GITHUB_TOKEN: ${{ github.token }} 83 | run: >- 84 | gh release create 85 | '${{ github.ref_name }}' 86 | --repo '${{ github.repository }}' 87 | --notes "" 88 | - name: Upload artifact signatures to GitHub Release 89 | env: 90 | GITHUB_TOKEN: ${{ github.token }} 91 | # Upload to GitHub Release using the `gh` CLI. 92 | # `dist/` contains the built packages, and the 93 | # sigstore-produced signatures and certificates. 94 | run: >- 95 | gh release upload 96 | '${{ github.ref_name }}' dist/** 97 | --repo '${{ github.repository }}' -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-valkey" 7 | version = "0.4.0" 8 | description = "a valkey backend for django" 9 | readme = "README.rst" 10 | requires-python = ">=3.10" 11 | license = "BSD-3-Clause" 12 | maintainers = [ 13 | {name = "amirreza",email = "amir.rsf1380@gmail.com"}, 14 | ] 15 | classifiers = [ 16 | "Programming Language :: Python", 17 | "Environment :: Web Environment", 18 | "Development Status :: 4 - Beta" , 19 | "Topic :: Utilities", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: BSD License", 22 | "Operating System :: OS Independent", 23 | "Topic :: Software Development :: Libraries", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "Framework :: Django :: 4.2", 32 | "Framework :: Django :: 5.2", 33 | "Framework :: Django :: 6.0" 34 | ] 35 | 36 | dependencies = [ 37 | "django>=4.2,!=5.0.*,!=5.1.*", 38 | "valkey>=6.0.2", 39 | ] 40 | 41 | 42 | [project.optional-dependencies] 43 | libvalkey = [ 44 | "libvalkey>=4.0.1", 45 | ] 46 | lz4 = [ 47 | "lz4>=4.3.3", 48 | ] 49 | pyzstd = [ 50 | "backports.zstd>=1.0.0", 51 | ] 52 | zstd = [ 53 | "backports.zstd>=1.0.0", 54 | ] 55 | msgpack = [ 56 | "msgpack>=1.1.0", 57 | ] 58 | brotli = [ 59 | "brotli>=1.1.0", 60 | ] 61 | 62 | msgspec = [ 63 | "msgspec>=0.19.0", 64 | ] 65 | 66 | [dependency-groups] 67 | dev = [ 68 | "anyio>=4.9.0", 69 | "coverage>=7.8.0", 70 | "django-cmd>=2.6", 71 | "django-coverage-plugin>=3.1.0", 72 | "django-stubs>=5.1.3", 73 | "invoke>=2.2.0", 74 | "mypy>=1.15.0", 75 | "pre-commit>=4.2.0", 76 | "pytest>=8.3.5", 77 | "pytest-django>=4.11.1", 78 | "pytest-mock>=3.14.0", 79 | "pytest-subtests>=0.14.1", 80 | "ruff>=0.12.5", 81 | ] 82 | docs = [ 83 | "mkdocs>=1.6.1", 84 | "mkdocs-awesome-nav>=3.1.1", 85 | "mkdocs-material>=9.6.12", 86 | ] 87 | ipython = [ 88 | "ipython>=8.35.0", 89 | ] 90 | 91 | [project.urls] 92 | Homepage = "https://github.com/django-commons/django-valkey" 93 | Source = "https://github.com/django-commons/django-valkey" 94 | Issues = "https://github.com/django-commons/django-valkey/issues" 95 | Documentation = "https://django-valkey.readthedocs.io/en/latest/" 96 | 97 | 98 | 99 | [tool.mypy] 100 | plugins = ["mypy_django_plugin.main"] 101 | pretty = true 102 | show_error_codes = true 103 | show_error_context = true 104 | warn_redundant_casts = true 105 | warn_unused_ignores = true 106 | warn_unreachable = true 107 | 108 | [tool.django-stubs] 109 | django_settings_module = "tests.settings.sqlite" 110 | ignore_missing_settings = true 111 | 112 | [tool.pytest.ini_options] 113 | DJANGO_SETTINGS_MODULE = "tests.settings.sqlite" 114 | 115 | [tool.coverage.run] 116 | plugins = ["django_coverage_plugin"] 117 | parallel = true 118 | 119 | [tool.codespell] 120 | ignore-words-list = "aadd,asend,smove" 121 | 122 | [tool.ruff.lint.per-file-ignores] 123 | "__init__.py" = ["E402", "F401", "F403"] 124 | -------------------------------------------------------------------------------- /tests/tests_async/test_requests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core import signals 4 | from django.core.cache import close_caches 5 | 6 | from django_valkey.base import close_async_caches 7 | from django_valkey.async_cache.cache import AsyncValkeyCache 8 | 9 | 10 | pytestmark = pytest.mark.anyio 11 | 12 | 13 | class TestWithOldSignal: 14 | @pytest.fixture(autouse=True) 15 | def setup(self): 16 | signals.request_finished.disconnect(close_async_caches) 17 | signals.request_finished.connect(close_caches) 18 | yield 19 | signals.request_finished.disconnect(close_caches) 20 | signals.request_finished.connect(close_async_caches) 21 | 22 | def test_old_receiver_is_registered_and_new_receiver_unregistered(self, setup): 23 | sync_receivers, async_receivers = signals.request_finished._live_receivers(None) 24 | assert close_caches in sync_receivers 25 | assert close_async_caches not in async_receivers 26 | 27 | async def test_warning_output_when_request_finished(self, async_client): 28 | with pytest.warns( 29 | RuntimeWarning, 30 | match="coroutine 'AsyncBackendCommands.close' was never awaited", 31 | ) as record: 32 | await async_client.get("/async/") 33 | 34 | assert ( 35 | str(record[0].message) 36 | == "coroutine 'AsyncBackendCommands.close' was never awaited" 37 | ) 38 | 39 | async def test_manually_await_signal(self, recwarn): 40 | await signals.request_finished.asend(self.__class__) 41 | assert len(recwarn) == 1 42 | 43 | assert ( 44 | str(recwarn[0].message) 45 | == "coroutine 'AsyncBackendCommands.close' was never awaited" 46 | ) 47 | 48 | # TODO: find why garbage collector doesn't collect the coroutine when the method is 49 | # sync (even when gc is called manually, it doesn't collect) 50 | async def test_manually_call_signal(self): 51 | with pytest.warns( 52 | RuntimeWarning, 53 | match="coroutine 'AsyncBackendCommands.close' was never awaited", 54 | ) as record: 55 | signals.request_finished.send(self.__class__) 56 | assert len(record) == 1 57 | 58 | assert ( 59 | str(record[0].message) 60 | == "coroutine 'AsyncBackendCommands.close' was never awaited" 61 | ) 62 | 63 | 64 | class TestWithNewSignal: 65 | async def test_warning_output_when_request_finished(self, async_client, recwarn): 66 | await async_client.get("/async/") 67 | 68 | assert len(recwarn) == 0 69 | 70 | async def test_manually_await_signal(self, recwarn): 71 | await signals.request_finished.asend(self.__class__) 72 | assert len(recwarn) == 0 73 | 74 | def test_manually_call_signal(self, recwarn): 75 | signals.request_finished.send(self.__class__) 76 | assert len(recwarn) == 0 77 | 78 | def test_receiver_is_registered_and_old_receiver_unregistered(self): 79 | sync_receivers, async_receivers = signals.request_finished._live_receivers(None) 80 | assert close_async_caches in async_receivers 81 | assert close_caches not in sync_receivers 82 | 83 | async def test_close_is_called_by_signal(self, mocker): 84 | close_spy = mocker.spy(AsyncValkeyCache, "close") 85 | await signals.request_finished.asend(self.__class__) 86 | assert close_spy.await_count == 1 87 | assert close_spy.call_count == 1 88 | -------------------------------------------------------------------------------- /docs/configure/basic_configurations.md: -------------------------------------------------------------------------------- 1 | # Basic configuration 2 | 3 | if you haven't installed django-valkey yet, head out to [Installation guide](../installation.md). 4 | 5 | 6 | ## Configure as cache backend 7 | 8 | to start using django-valkey, change your django cache setting to something like this: 9 | 10 | ```python 11 | CACHES = { 12 | "default": { 13 | "BACKEND": "django_valkey.cache.ValkeyCache", 14 | "LOCATION": "valkey://127.0.0.1:6379", 15 | "OPTIONS": {...} 16 | } 17 | } 18 | ``` 19 | 20 | django-valkey uses the valkey-py native URL notation for connection strings, it allows better interoperability and has a connection string in more "standard" way. will explore this more in [Advanced Configurations](advanced_configurations.md). 21 | 22 | when using [Valkey's ACLs](https://valkey.io/topics/acl) you will need to add the username and password to the URL. 23 | the login for the user `django` would look like this: 24 | 25 | ```python 26 | CACHES = { 27 | "default": { 28 | # ... 29 | "LOCATION": "valkey://django:mysecret@localhost:6379/0", 30 | # ... 31 | } 32 | } 33 | ``` 34 | 35 | you can also provide the password in the `OPTIONS` dictionary 36 | this is specially useful if you have a password that is not URL safe 37 | but *notice* that if a password is provided by the URL, it won't be overridden by the password in `OPTIONS`. 38 | 39 | ```python 40 | CACHES = { 41 | "default": { 42 | "BACKEND": "django-valkey.cache.ValkeyCache", 43 | "LOCATION": "valkey://django@localhost:6379/0", 44 | "OPTIONS": { 45 | "PASSWORD": "mysecret" 46 | } 47 | } 48 | } 49 | ``` 50 | 51 | **Note:** you probably should read the password from environment variables 52 | 53 | 54 | ## Memcached exception behavior 55 | 56 | In some situations, when Valkey is only used for cache, you do not want 57 | exceptions when Valkey is down. This is default behavior in the memcached 58 | backend and it can be emulated in django-valkey. 59 | 60 | For setup memcached like behaviour (ignore connection exceptions), you should 61 | set `IGNORE_EXCEPTIONS` settings on your cache configuration: 62 | 63 | ```python 64 | CACHES = { 65 | "default": { 66 | # ... 67 | "OPTIONS": { 68 | "IGNORE_EXCEPTIONS": True, 69 | } 70 | } 71 | } 72 | ``` 73 | 74 | Also, if you want to apply the same settings to all configured caches, you can set the global flag in 75 | your settings: 76 | 77 | ```python 78 | DJANGO_VALKEY_IGNORE_EXCEPTIONS = True 79 | ``` 80 | 81 | ### Log exceptions when ignored 82 | 83 | when ignoring exceptions with `IGNORE_EXCEPTIONS` or `DJANGO_VALKEY_IGNORE_EXCEPTION`, you may optionally log exceptions by setting the global variable `DJANGO_VALKEY_LOG_EXCEPTION` in your settings: 84 | 85 | ```python 86 | DJANGO_VALKEY_LOG_IGNORED_EXCEPTION = True 87 | ``` 88 | 89 | If you wish to specify a logger in which the exceptions are outputted, set the global variable `DJANGO_VALKEY_LOGGER` to the string name or path of the desired logger. 90 | the default value is `__name__` if no logger was specified 91 | 92 | ```python 93 | DJANGO_VALKEY_LOGGER = "some.logger" 94 | ``` 95 | 96 | ## Socket timeout 97 | 98 | Socket timeout can be set using `SOCKET_TIMEOUT` and 99 | `SOCKET_CONNECT_TIMEOUT` options: 100 | 101 | ```python 102 | CACHES = { 103 | "default": { 104 | # ... 105 | "OPTIONS": { 106 | "SOCKET_CONNECT_TIMEOUT": 5, # seconds 107 | "SOCKET_TIMEOUT": 5, # seconds 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | `SOCKET_CONNECT_TIMEOUT` is the timeout for the connection to be established 114 | and `SOCKET_TIMEOUT` is the timeout for read and write operations after the 115 | connection is established. 116 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | 5 | services: 6 | 7 | valkey: 8 | image: valkey/valkey:latest 9 | container_name: valkey-standalone 10 | ports: 11 | - 6379:6379 12 | profiles: 13 | - standalone 14 | - sentinel 15 | - replica 16 | - all 17 | command: valkey-server --save "" 18 | healthcheck: 19 | test: valkey-cli ping 20 | interval: 5s 21 | timeout: 5s 22 | retries: 5 23 | 24 | valkey-node-0: 25 | image: bitnamilegacy/valkey-cluster:8.1 26 | environment: 27 | - "VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" 28 | - "ALLOW_EMPTY_PASSWORD=yes" 29 | - "VALKEY_RDB_POLICY_DISABLED=yes" 30 | - "VALKEY_AOF_ENABLED=no" 31 | ports: 32 | - 7000:6379 33 | profiles: 34 | - cluster 35 | - all 36 | 37 | valkey-node-1: 38 | image: bitnamilegacy/valkey-cluster:8.1 39 | environment: 40 | - "VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" 41 | - "ALLOW_EMPTY_PASSWORD=yes" 42 | - "VALKEY_RDB_POLICY_DISABLED=yes" 43 | - "VALKEY_AOF_ENABLED=no" 44 | - "VALKEY_PRIMARY_HOST=localhost" 45 | ports: 46 | - 7001:6379 47 | profiles: 48 | - cluster 49 | - all 50 | 51 | valkey-node-2: 52 | image: bitnamilegacy/valkey-cluster:8.1 53 | environment: 54 | - "VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" 55 | - "ALLOW_EMPTY_PASSWORD=yes" 56 | - "VALKEY_RDB_POLICY_DISABLED=yes" 57 | - "VALKEY_AOF_ENABLED=no" 58 | ports: 59 | - 7002:6379 60 | profiles: 61 | - cluster 62 | - all 63 | 64 | valkey-node-3: 65 | image: bitnamilegacy/valkey-cluster:8.1 66 | environment: 67 | - "VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" 68 | - "ALLOW_EMPTY_PASSWORD=yes" 69 | - "VALKEY_RDB_POLICY_DISABLED=yes" 70 | - "VALKEY_AOF_ENABLED=no" 71 | - "VALKEY_PRIMARY_PORT_NUMBER=7002" 72 | - "VALKEY_PRIMARY_HOST=localhost" 73 | ports: 74 | - 7003:6379 75 | profiles: 76 | - cluster 77 | - all 78 | 79 | valkey-node-4: 80 | image: bitnamilegacy/valkey-cluster:8.1 81 | environment: 82 | - "VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" 83 | - "ALLOW_EMPTY_PASSWORD=yes" 84 | - "VALKEY_RDB_POLICY_DISABLED=yes" 85 | - "VALKEY_AOF_ENABLED=no" 86 | ports: 87 | - 7004:6379 88 | profiles: 89 | - cluster 90 | - all 91 | 92 | valkey-node-5: 93 | image: bitnamilegacy/valkey-cluster:8.1 94 | environment: 95 | - "VALKEY_CLUSTER_REPLICAS=1" 96 | - "VALKEY_NODES=valkey-node-0 valkey-node-1 valkey-node-2 valkey-node-3 valkey-node-4 valkey-node-5" 97 | - "VALKEY_CLUSTER_CREATOR=yes" 98 | - "ALLOW_EMPTY_PASSWORD=yes" 99 | - "VALKEY_RDB_POLICY_DISABLED=yes" 100 | - "VALKEY_AOF_ENABLED=no" 101 | - "VALKEY_PRIMARY_PORT_NUMBER=7004" 102 | - "VALKEY_PRIMARY_HOST=localhost" 103 | ports: 104 | - 7005:6379 105 | profiles: 106 | - cluster 107 | - all 108 | 109 | sentinel: 110 | image: valkey/valkey:latest 111 | container_name: valkey-sentinel 112 | depends_on: 113 | valkey: 114 | condition: service_healthy 115 | 116 | entrypoint: "/usr/local/bin/valkey-sentinel /valkey.conf --port 26379" 117 | ports: 118 | - 26379:26379 119 | volumes: 120 | - "./dockers/sentinel.conf:/valkey.conf" 121 | profiles: 122 | - sentinel 123 | - all 124 | 125 | healthcheck: 126 | test: valkey-cli -p 26379 ping 127 | interval: 5s 128 | timeout: 5s 129 | retries: 5 130 | -------------------------------------------------------------------------------- /django_valkey/pool.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from urllib.parse import parse_qs, urlparse 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.utils.module_loading import import_string 7 | 8 | from valkey import Valkey 9 | from valkey._parsers.url_parser import to_bool 10 | from valkey.connection import ConnectionPool, DefaultParser 11 | from valkey.sentinel import Sentinel 12 | 13 | from django_valkey.base_pool import BaseConnectionFactory, Base 14 | 15 | 16 | class ConnectionFactory(BaseConnectionFactory[Valkey, ConnectionPool]): 17 | path_pool_cls = "valkey.connection.ConnectionPool" 18 | path_base_cls = "valkey.client.Valkey" 19 | 20 | def disconnect(self, connection: type[Valkey] | type) -> None: 21 | """ 22 | Given a not null client connection it disconnects from the Valkey server. 23 | 24 | The default implementation uses a pool to hold connections. 25 | """ 26 | connection.connection_pool.disconnect() 27 | 28 | def get_parser_cls(self) -> type[DefaultParser] | type: 29 | cls = self.options.get("PARSER_CLASS", None) 30 | if cls is None: 31 | return DefaultParser 32 | return import_string(cls) 33 | 34 | def connect(self, url: str) -> Valkey | Any: 35 | params = self.make_connection_params(url) 36 | return self.get_connection(params) 37 | 38 | def get_connection(self, params: dict) -> Base | Any: 39 | pool = self.get_or_create_connection_pool(params) 40 | return self.base_client_cls(connection_pool=pool, **self.base_client_cls_kwargs) 41 | 42 | 43 | class SentinelConnectionFactory(ConnectionFactory): 44 | def __init__(self, options: dict): 45 | # allow overriding the default SentinelConnectionPool class 46 | options.setdefault( 47 | "CONNECTION_POOL_CLASS", "valkey.sentinel.SentinelConnectionPool" 48 | ) 49 | super().__init__(options) 50 | 51 | sentinels = options.get("SENTINELS") 52 | if not sentinels: 53 | error_message = "SENTINELS must be provided as a list of (host, port)." 54 | raise ImproperlyConfigured(error_message) 55 | 56 | # provide the connection pool kwargs to the sentinel in case it 57 | # needs to use the socket options for the sentinels themselves 58 | connection_kwargs = self.make_connection_params(None) 59 | connection_kwargs.pop("url") 60 | connection_kwargs.update(self.pool_cls_kwargs) 61 | self._sentinel = Sentinel( 62 | sentinels, 63 | sentinel_kwargs=options.get("SENTINEL_KWARGS"), 64 | **connection_kwargs, 65 | ) 66 | 67 | def get_connection_pool(self, params: dict) -> ConnectionPool | Any: 68 | """ 69 | Given a connection parameters, return a new sentinel connection pool 70 | for them. 71 | """ 72 | url = urlparse(params["url"]) 73 | 74 | # explicitly set service_name and sentinel_manager for the 75 | # SentinelConnectionPool constructor since will be called by from_url 76 | cp_params = params 77 | cp_params.update(service_name=url.hostname, sentinel_manager=self._sentinel) 78 | pool = super().get_connection_pool(cp_params) 79 | 80 | # convert "is_master" to a boolean if set on the URL, otherwise if not 81 | # provided it defaults to True. 82 | is_master: list[str] = parse_qs(url.query).get("is_master") 83 | if is_master: 84 | pool.is_master = to_bool(is_master[0]) 85 | 86 | return pool 87 | 88 | 89 | def get_connection_factory( 90 | path: str | None = None, options: dict | None = None 91 | ) -> ConnectionFactory | SentinelConnectionFactory | Any: 92 | path = getattr(settings, "DJANGO_VALKEY_CONNECTION_FACTORY", path) 93 | opt_conn_factory = options.get("CONNECTION_FACTORY") 94 | if opt_conn_factory: 95 | path = opt_conn_factory 96 | 97 | cls = import_string(path) 98 | return cls(options) 99 | -------------------------------------------------------------------------------- /tests/tests_cluster/test_client.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | import pytest 4 | 5 | from django_valkey.cluster_cache.cache import ClusterValkeyCache 6 | from django_valkey.cluster_cache.client import DefaultClusterClient 7 | 8 | 9 | @pytest.fixture 10 | def cache_client(cache: ClusterValkeyCache) -> Iterable[DefaultClusterClient]: 11 | client = cache.client 12 | client.set("TestClientClose", 0) 13 | yield client 14 | client.delete("TestClientClose") 15 | 16 | 17 | class TestDefaultClusterClient: 18 | def test_delete_pattern_calls_get_client_given_no_client(self, mocker): 19 | mocker.patch("django_valkey.base_client.BaseClient.__init__", return_value=None) 20 | get_client_mock = mocker.patch( 21 | "django_valkey.base_client.ClientCommands.get_client" 22 | ) 23 | client = DefaultClusterClient() 24 | client._backend = mocker.Mock() 25 | client._backend.key_prefix = "" 26 | 27 | client.delete_pattern(pattern="{foo}*") 28 | get_client_mock.assert_called_once_with(write=True, tried=None) 29 | 30 | def test_delete_pattern_calls_make_pattern(self, mocker): 31 | mocker.patch("django_valkey.base_client.BaseClient.__init__", return_value=None) 32 | get_client_mock = mocker.patch( 33 | "django_valkey.base_client.ClientCommands.get_client", 34 | return_value=mocker.Mock(), 35 | ) 36 | make_pattern_mock = mocker.patch( 37 | "django_valkey.base_client.BaseClient.make_pattern" 38 | ) 39 | client = DefaultClusterClient() 40 | client._backend = mocker.Mock() 41 | client._backend.key_prefix = "" 42 | get_client_mock.return_value.scan_iter.return_value = [] 43 | 44 | client.delete_pattern(pattern="{foo}*") 45 | 46 | kwargs = {"version": None, "prefix": None} 47 | make_pattern_mock.assert_called_once_with("{foo}*", **kwargs) 48 | 49 | def test_delete_pattern_calls_scan_iter_with_count_if_itersize_given(self, mocker): 50 | mocker.patch("django_valkey.base_client.BaseClient.__init__", return_value=None) 51 | get_client_mock = mocker.patch( 52 | "django_valkey.base_client.ClientCommands.get_client", 53 | return_value=mocker.Mock(), 54 | ) 55 | make_pattern_mock = mocker.patch( 56 | "django_valkey.base_client.BaseClient.make_pattern" 57 | ) 58 | client = DefaultClusterClient() 59 | client._backend = mocker.Mock() 60 | client._backend.key_prefix = "" 61 | get_client_mock.return_value.scan_iter.return_value = [] 62 | 63 | client.delete_pattern(pattern="{foo}*", itersize=90210) 64 | 65 | get_client_mock.return_value.scan_iter.assert_called_once_with( 66 | count=90210, match=make_pattern_mock.return_value 67 | ) 68 | 69 | def test_delete_pattern_calls_pipeline_delete_and_execute(self, mocker): 70 | mocker.patch("django_valkey.base_client.BaseClient.__init__", return_value=None) 71 | get_client_mock = mocker.patch( 72 | "django_valkey.base_client.ClientCommands.get_client", 73 | return_value=mocker.Mock(), 74 | ) 75 | mocker.patch("django_valkey.base_client.BaseClient.make_pattern") 76 | 77 | client = DefaultClusterClient() 78 | client._backend = mocker.Mock() 79 | client._backend.key_prefix = "" 80 | get_client_mock.return_value.scan_iter.return_value = [":1:{foo}", ":1:{foo}-a"] 81 | get_client_mock.return_value.pipeline.return_value = mocker.Mock() 82 | get_client_mock.return_value.pipeline.return_value.delete = mocker.Mock() 83 | get_client_mock.return_value.pipeline.return_value.execute = mocker.Mock() 84 | 85 | client.delete_pattern(pattern="{foo}*") 86 | 87 | assert get_client_mock.return_value.pipeline.return_value.delete.call_count == 2 88 | get_client_mock.return_value.pipeline.return_value.delete.assert_has_calls( 89 | [mocker.call(":1:{foo}"), mocker.call(":1:{foo}-a")] 90 | ) 91 | get_client_mock.return_value.pipeline.return_value.execute.assert_called_once() 92 | -------------------------------------------------------------------------------- /django_valkey/cluster_cache/client/default.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from valkey.cluster import ValkeyCluster 4 | from valkey.typing import KeyT, EncodableT 5 | 6 | from django_valkey.base_client import BaseClient, ClientCommands, _main_exceptions 7 | from django_valkey.exceptions import ConnectionInterrupted 8 | 9 | 10 | class DefaultClusterClient(ClientCommands, BaseClient[ValkeyCluster]): 11 | CONNECTION_FACTORY_PATH = ( 12 | "django_valkey.cluster_cache.pool.ClusterConnectionFactory" 13 | ) 14 | 15 | def readonly(self, target_nodes=None, client=None): 16 | client = self._get_client(write=True, client=client) 17 | return client.readonly(target_nodes) 18 | 19 | def readwrite(self, target_nodes=None, client=None): 20 | client = self._get_client(write=True, client=client) 21 | return client.readwrite(target_nodes) 22 | 23 | def keys( 24 | self, 25 | pattern="*", 26 | target_nodes=ValkeyCluster.DEFAULT_NODE, 27 | version=None, 28 | client=None, 29 | **kwargs, 30 | ): 31 | client = self._get_client(client=client) 32 | pattern = self.make_pattern(pattern, version=version) 33 | 34 | try: 35 | keys = client.keys(pattern=pattern, target_nodes=target_nodes, **kwargs) 36 | except _main_exceptions as e: 37 | raise ConnectionInterrupted(connection=client) from e 38 | return {self.reverse_key(key.decode()) for key in keys} 39 | 40 | def mset( 41 | self, 42 | data: Dict[KeyT, EncodableT], 43 | version=None, 44 | client=None, 45 | nx=False, 46 | atomic=True, 47 | ) -> bool | list[bool]: 48 | """ 49 | Access valkey's mset method. 50 | it is important to take care of cluster limitations mentioned here: https://valkey-py.readthedocs.io/en/latest/clustering.html#multi-key-commands 51 | """ 52 | data = { 53 | self.make_key(k, version=version): self.encode(v) for k, v in data.items() 54 | } 55 | client = self._get_client(write=True, client=client) 56 | if not atomic: 57 | return client.mset_nonatomic(data) 58 | if nx: 59 | return client.msetnx(data) 60 | try: 61 | return client.mset(data) 62 | except _main_exceptions as e: 63 | raise ConnectionInterrupted(connection=client) from e 64 | 65 | def msetnx(self, data: Dict[KeyT, EncodableT], version=None, client=None) -> bool: 66 | try: 67 | return self.mset(data, version=version, client=client, nx=True) 68 | except _main_exceptions as e: 69 | raise ConnectionInterrupted(connection=client) from e 70 | 71 | def mset_nonatomic( 72 | self, data: Dict[KeyT, EncodableT], version=None, client=None 73 | ) -> list[bool]: 74 | try: 75 | return self.mset(data, version=version, client=client, atomic=False) 76 | except _main_exceptions as e: 77 | raise ConnectionInterrupted(connection=client) from e 78 | 79 | set_many = mset_nonatomic 80 | 81 | def mget_nonatomic(self, keys, version=None, client=None): 82 | client = self._get_client(client=client) 83 | map_keys = {self.make_key(k, version=version): k for k in keys} 84 | try: 85 | values = client.mget_nonatomic(map_keys) 86 | except _main_exceptions as e: 87 | raise ConnectionInterrupted(connection=client) from e 88 | 89 | recovered_data = {} 90 | for key, value in zip(keys, values): 91 | if value is None: 92 | continue 93 | recovered_data[key] = self.decode(value) 94 | return recovered_data 95 | 96 | get_many = mget_nonatomic 97 | 98 | def keyslot(self, key, version=None, client=None): 99 | client = self._get_client(client=client) 100 | key = self.make_key(key, version=version) 101 | return client.keyslot(key) 102 | 103 | def flushall(self, asynchronous=False, client=None): 104 | client = self._get_client(client=client) 105 | return client.flushall(asynchronous=asynchronous) 106 | 107 | def invalidate_key_from_cache(self, client=None): 108 | client = self._get_client(client=client) 109 | return client.invalidate_key_from_cache() 110 | -------------------------------------------------------------------------------- /django_valkey/base_pool.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic, Any 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.utils.module_loading import import_string 5 | 6 | Pool = TypeVar("Pool") 7 | Base = TypeVar("Base") 8 | 9 | 10 | class BaseConnectionFactory(Generic[Base, Pool]): 11 | # Store connection pool by cache backend options. 12 | # 13 | # _pools is a process-global, as otherwise _pools is cleared every time 14 | # ConnectionFactory is instantiated, as Django creates new cache client 15 | # (DefaultClient) instance for every request. 16 | 17 | _pools: dict[str, Pool | Any] = {} 18 | 19 | def __init__(self, options: dict): 20 | pool_cls_path = options.get("CONNECTION_POOL_CLASS", self.path_pool_cls) 21 | self.pool_cls: type[Pool] | type = import_string(pool_cls_path) 22 | self.pool_cls_kwargs = options.get("CONNECTION_POOL_KWARGS", {}) 23 | 24 | base_client_cls_path = options.get("BASE_CLIENT_CLASS", self.path_base_cls) 25 | self.base_client_cls: type[Base] | type = import_string(base_client_cls_path) 26 | self.base_client_cls_kwargs = options.get("BASE_CLIENT_KWARGS", {}) 27 | 28 | self.options = options 29 | 30 | def make_connection_params(self, url: str | None) -> dict: 31 | """ 32 | Given a main connection parameters, build a complete 33 | dict of connection parameters. 34 | """ 35 | 36 | kwargs = { 37 | "url": url, 38 | "parser_class": self.get_parser_cls(), 39 | } 40 | 41 | socket_timeout = self.options.get("SOCKET_TIMEOUT", None) 42 | # TODO: do we need to check for existence? 43 | if socket_timeout: 44 | if not isinstance(socket_timeout, (int, float)): 45 | error_message = "Socket timeout should be float or integer" 46 | raise ImproperlyConfigured(error_message) 47 | kwargs["socket_timeout"] = socket_timeout 48 | 49 | socket_connect_timeout = self.options.get("SOCKET_CONNECT_TIMEOUT", None) 50 | if socket_connect_timeout: 51 | if not isinstance(socket_connect_timeout, (int, float)): 52 | error_message = "Socket connect timeout should be float or integer" 53 | raise ImproperlyConfigured(error_message) 54 | kwargs["socket_connect_timeout"] = socket_connect_timeout 55 | 56 | password = self.options.get("PASSWORD", None) 57 | if password: 58 | kwargs["password"] = password 59 | 60 | return kwargs 61 | 62 | def get_connection_pool(self, params: dict) -> Pool | Any: 63 | """ 64 | Given a connection parameters, return a new 65 | connection pool for them. 66 | 67 | Overwrite this method if you want a custom 68 | behavior on creating connection pool. 69 | """ 70 | cp_params = params 71 | cp_params.update(self.pool_cls_kwargs) 72 | pool = self.pool_cls.from_url(**cp_params) 73 | 74 | if pool.connection_kwargs.get("password", None) is None: 75 | pool.connection_kwargs["password"] = params.get("password", None) 76 | pool.reset() 77 | 78 | return pool 79 | 80 | def get_or_create_connection_pool(self, params: dict) -> Pool | Any: 81 | """ 82 | Given a connection parameters and return a new 83 | or cached connection pool for them. 84 | 85 | Reimplement this method if you want distinct 86 | connection pool instance caching behavior. 87 | """ 88 | key: str = params["url"] 89 | if key not in self._pools: 90 | self._pools[key] = self.get_connection_pool(params) 91 | return self._pools[key] 92 | 93 | def get_connection(self, params: dict) -> Base | Any: 94 | """ 95 | Given a now preformatted params, return a 96 | new connection. 97 | 98 | The default implementation uses a cached pools 99 | for create new connection. 100 | """ 101 | raise NotImplementedError 102 | 103 | def connect(self, url: str) -> Base | Any: 104 | """ 105 | Given a basic connection parameters, 106 | return a new connection. 107 | """ 108 | raise NotImplementedError 109 | 110 | def disconnect(self, connection: type[Base]): 111 | raise NotImplementedError 112 | 113 | def get_parser_cls(self): 114 | raise NotImplementedError 115 | -------------------------------------------------------------------------------- /docs/async/advanced_configurations.md: -------------------------------------------------------------------------------- 1 | # Advanced Async Configuration 2 | 3 | most of the subject discussed in [Advanced configuration](../configure/advanced_configurations.md) apply to async mode as well, just don't use a sync client :) 4 | 5 | also all the compressor details we talked about in [Compressor support](../configure/compressors.md) work as is in async mode 6 | 7 | **Important**: the async clients are not compatible with django's cache middleware. 8 | if you need those middlewares, consider using a sync client or implement a new middleware 9 | 10 | ## Clients 11 | 12 | We have three async client, `AsyncDefaultClient`, available in `django_valkey.async_cache.client.default`, `AsyncHerdClient` available in `django_valkey.async_cache.client.herd` and `AsyncSentinelClient` at `django_valkey.async_cache.client.sentinel`. 13 | the default client can also be used with sentinels, as we'll discuss later. 14 | 15 | note that all clients are imported and available at `django_valkey.async_cache.client` 16 | 17 | ### Default client 18 | 19 | the `AsyncDefaultClient` is configured by default by `AsyncValkeyCache`, so if you have configured that as your backend you are all set, but if you want to be explicit or use the client with a different backend you can write it like this: 20 | 21 | ```python 22 | CACHES = { 23 | "async": { 24 | "BACKEND": "path.to.backend", 25 | "LOCATION": [ 26 | "valkey://user:pass@127.0.0.1:6379", 27 | ] 28 | "OPTIONS": { 29 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncDefaultClient", 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | or you can replace the client with your own like that. 36 | 37 | ### Sentinel Client 38 | 39 | to support sentinels, django_valkey comes with a client and a connection factory, technically you don't need the connection factory, but it provides you with some nice features. 40 | a dedicated page on sentinel client has been written in [Sentinel configuration](../configure/sentinel_configurations.md), tho that is for the sync version, the principle is the same. 41 | 42 | the connection factory is at `django_valkey.async_cache.pool.AsyncSentinelConnectionFactory`. 43 | 44 | to configure the async sentinel client you can write your settings like this: 45 | 46 | ```python 47 | SENTINELS = [ 48 | ("127.0.0.1", 26379), # a list of (host name, port) tuples. 49 | ] 50 | 51 | CACHES = { 52 | "default": { 53 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 54 | "LOCATION": "valkey://service_name/db", 55 | "OPTIONS": { 56 | "CLIENT_CLASS": "django_valkey.client.SentinelClient", 57 | "SENTINELS": SENTINELS, 58 | 59 | # optional 60 | "SENTINEL_KWARGS": {} 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | *note*: the sentinel client uses the sentinel connection factory by default, you can change it by setting `DJANGO_VALKEY_CONNECTION_FACTORY` in your django settings or `CONNECTION_FACTORY` in your `CACHES` OPTIONS. 67 | 68 | ### Herd client 69 | 70 | the herd client needs to be configured, but it's as simple as this: 71 | 72 | ```python 73 | CACHES = { 74 | "default": { 75 | "BACKEND": "django_valkey.async_cache.cache.AsyncValkeyCache", 76 | "LOCATION": ["valkey://127.0.0.1:6379"], 77 | "OPTIONS": { 78 | "CLIENT_CLASS": "django_valkey.async_cache.client.AsyncHerdClient", 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | ## Connection Factory 85 | 86 | django_valkey's async library comes with two connection factories, `AsyncConnectionFactory` for general uses and `AsyncSentinelConnectionFactory` for sentinel uses. 87 | 88 | the default connection factory is `AsyncConnectionFactory`, so if you are using a sentinel server you should configure your caches like this: 89 | 90 | ```python 91 | CACHES = { 92 | "async": { 93 | # ... 94 | "OPTIONS": { 95 | "CONNECTION_FACTORY": "django_valkey.async_cache.pool.AsyncSentinelConnectionFactory" 96 | } 97 | } 98 | } 99 | 100 | CACHE_HERD_TIMEOUT = 20 # if not set, it's default to 60 101 | ``` 102 | 103 | or set it as the global connection factory like this: 104 | 105 | ```python 106 | DJANGO_VALKEY_CONNECTION_FACTORY = "django_valkey.async_cache.client.default.AsyncDefaultClient" 107 | ``` 108 | 109 | note that `"CONNECTION_FACTORY"` overrides `DJANGO_VALKEY_CONNECTION_FACTORY` for the specified server. 110 | 111 | if you want to use another factory you can use the same code with the path to your class. 112 | 113 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | populate-cache: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 60 9 | name: Update docker cache 10 | steps: 11 | - uses: actions/checkout@v5 12 | - name: Cache docker images 13 | id: custom-cache 14 | uses: actions/cache@v4 15 | with: 16 | path: ./custom-cache/ 17 | key: custom-cache 18 | - if: ${{ steps.custom-cache.outputs.cache-hit != 'true' || github.event_name == 'schedule' }} 19 | name: Update cache 20 | run: | 21 | mkdir -p ./custom-cache/ 22 | docker compose --profile all build 23 | docker pull valkey/valkey:latest 24 | docker pull bitnamilegacy/valkey-cluster:8.1 25 | docker save bitnamilegacy/valkey-cluster valkey/valkey:latest -o ./custom-cache/all.tar 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | needs: [populate-cache] 30 | timeout-minutes: 60 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | python-version: 35 | - '3.10' 36 | - '3.11' 37 | - '3.12' 38 | - '3.13' 39 | - '3.14' 40 | django-version: 41 | - 'django==4.2' 42 | - 'django==5.2' 43 | - 'django==6.0' 44 | 45 | include: 46 | - python-version: '3.13' 47 | django-version: "git+https://github.com/django/django.git@main#egg=Django" 48 | experimental: true 49 | - python-version: '3.14' 50 | django-version: "git+https://github.com/django/django.git@main#egg=Django" 51 | experimental: true 52 | 53 | exclude: 54 | - python-version: '3.10' 55 | django-version: 'django==6.0' 56 | - python-version: '3.11' 57 | django-version: 'django==6.0' 58 | 59 | env: 60 | ACTIONS_ALLOW_UNSECURE_COMMANDS: true 61 | steps: 62 | - uses: actions/checkout@v5 63 | 64 | - name: Set up Python ${{ matrix.python-version }} 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: ${{ matrix.python-version }} 68 | allow-prereleases: true 69 | 70 | - name: Cache docker images 71 | id: custom-cache 72 | uses: actions/cache@v4 73 | with: 74 | path: ./custom-cache/ 75 | fail-on-cache-miss: true 76 | key: custom-cache 77 | 78 | - name: Use Cache 79 | run: docker image load -i ./custom-cache/all.tar 80 | 81 | - name: Install uv 82 | uses: astral-sh/setup-uv@v7 83 | with: 84 | enable-cache: true 85 | 86 | - name: install dependencies 87 | run: | 88 | uv sync --all-extras --dev 89 | 90 | - name: Install project 91 | run: | 92 | uv pip install ${{ matrix.django-version }} 93 | 94 | - name: tests 95 | run: | 96 | uv run invoke devenv 97 | chmod +x ./util/wait-for-it.sh 98 | 99 | ./util/wait-for-it.sh localhost:6379 100 | ./util/wait-for-it.sh localhost:7000 101 | 102 | uv run pytest tests/*.py --ds=tests.settings.sqlite -x 103 | uv run pytest tests/*.py --ds=tests.settings.sqlite_herd -x 104 | uv run pytest tests/*.py --ds=tests.settings.sqlite_json -x 105 | uv run pytest tests/*.py --ds=tests.settings.sqlite_msgspec_json -x 106 | uv run pytest tests/*.py --ds=tests.settings.sqlite_msgspec_msgpack -x 107 | uv run pytest tests/*.py --ds=tests.settings.sqlite_msgpack -x 108 | uv run pytest tests/*.py --ds=tests.settings.sqlite_sentinel -x 109 | uv run pytest tests/*.py --ds=tests.settings.sqlite_sentinel_opts -x 110 | uv run pytest tests/*.py --ds=tests.settings.sqlite_sharding -x 111 | uv run pytest tests/*.py --ds=tests.settings.sqlite_zlib -x 112 | uv run pytest tests/*.py --ds=tests.settings.sqlite_zstd -x 113 | uv run pytest tests/*.py --ds=tests.settings.sqlite_gzip -x 114 | uv run pytest tests/*.py --ds=tests.settings.sqlite_bz2 -x 115 | uv run pytest tests/tests_async/*.py --ds=tests.settings.sqlite_async -x 116 | uv run pytest tests/tests_async/*.py --ds=tests.settings.sqlite_async_herd -x 117 | uv run pytest tests/*.py tests/tests_cluster --ds=tests.settings.sqlite_cluster -x 118 | # uv run pytest tests/*.py --ds=tests.settings.sqlite_usock -x 119 | 120 | env: 121 | DJANGO: ${{ matrix.django-version }} 122 | -------------------------------------------------------------------------------- /docs/customize.md: -------------------------------------------------------------------------------- 1 | # Customizing django-valkey 2 | 3 | The basics of how to introduce your own classes to be used by django-valkey has been discussed in length in [Advanced Configuration](configure/advanced_configurations.md). 4 | 5 | in this section we're going to look at the base classes that django-valkey provides, and you can use them to write your classes faster. 6 | 7 | django-valkey comes with three base classes: `django_valkey.base.BaseValkeyCache`, `django_valkey.base_client.BaseClient` and `django_valkey.base_pool.BaseConnectionFactory`. 8 | 9 | ## BaseValkeyCache 10 | 11 | `BaseValkeyCache` is not a standalone class, to make use of it you need to add the actual methods, in `django-valkey` this is done by `django_valkey.base.BackendCommands` or `django_valkey.base.AsyncBackendCommands` depending if you use sync or async clients 12 | `BaseValkeyCache` contains connection methods and configures the behaviour of the cache 13 | `BaseValkeyCache` inherits from `typing.Generic` to type hint two things: 14 | 1. the client, such as `django_valkey.client.default.DefaultClient`. 15 | 2. the underlying backend, such as `valkey.Valkey`. 16 | 17 | to inherit from this base class you can take the example of our own cache backend: 18 | 19 | ```python 20 | from valkey import Valkey 21 | 22 | from django_valkey.base import BaseValkeyCache, BackendCommands 23 | from django_valkey.client import DefaultClient 24 | 25 | class ValkeyCache(BaseValkeyCache[DefaultClient, Valkey], BackendCommands): 26 | DEFAULT_CLIENT_CLASS = "django_valkey.client.DefaultClient" 27 | ... 28 | ``` 29 | 30 | the `DEFAULT_CLIENT_CLASS` class attribute defined in the example is **mandatory**, it is so we can have imports in other modules. 31 | 32 | `BaseValkeyCache` can work with both *sync* and *async* subclasses, but it doesn't implement any of the methods, you need to inherit the command classes for this to work. 33 | 34 | 35 | ## BaseClient 36 | like `BaseValkeyCache`, `BaseClient` is not a standalone class. 37 | this class has all the logic necessary to connect to a cache server, and utility methods that helps with different operations, 38 | but it does not handle any of the operations by itself, you need one of `django_valkey.base_client.ClientCommands` or `django_valkey.base_client.AsyncClientCommands` for sync or async clients, respectively. 39 | the command classes implement the actual operations such as `get` and `set`. 40 | 41 | `BaseClient` inherits from `typing.Generic` to make cleaner type hints. 42 | the `typing.Generic` needs a backend to be passed in, e.g: `valkey.Valkey` 43 | 44 | the base class also needs the subclasses to have a `CONNECTION_FACTORY_PATH` class variable pointing to the connection factory class. 45 | 46 | an example code would look like this: 47 | 48 | ```python 49 | from valkey import Valkey 50 | 51 | from django_valkey.base_client import BaseClient, ClientCommands 52 | 53 | class DefaultClient(BaseClient[Valkey], ClientCommands[Valkey]): 54 | CONNECTION_FACTORY_PATH = "django_valkey.pool.ConnectionFactory" 55 | ``` 56 | 57 | *note* that CONNECTION_FACTORY_PATH is only used if `DJANGO_VALKEY_CONNECTION_FACTORY` is not set. 58 | 59 | `BaseClient` can work with both sync and async subclasses, you would use one of `django_valkey.base_client.ClientCommands` for sync, and `django_valkey.base_client.AsyncClientCommands` for async clients. 60 | 61 | 62 | ## BaseConnectionFactory 63 | 64 | the `BaseConnectionFactory` inherits from `typing.Generic` to give more robust type hinting, and allow our four connection pools to have cleaner codebase. 65 | 66 | to inherit from this class you need to pass in the underlying backend that you are using and the connection pool, for example this is one of the connection pools in this project: 67 | 68 | ```python 69 | from valkey import Valkey 70 | from valkey.connection import ConnectionPool 71 | 72 | from django_valkey.base_pool import BaseConnectionFactory 73 | 74 | 75 | class ConnectionFactory(BaseConnectionFactory[Valkey, ConnectionPool]): 76 | path_pool_cls = "valkey.connection.ConnectionPool" 77 | path_base_cls = "valkey.client.Valkey" 78 | ``` 79 | 80 | the two class attributes defined there are also **mandatory** since they are passed to other modules. 81 | 82 | this base class has eight methods implemented, but four of them raise `NotImplementedError`, so let's have a look at those: 83 | 84 | 1. `connect()` this method can be both sync and async, depending on your work. 85 | 2. `disconnect()` this method, as well, can be both sync and async. 86 | 3. `get_connection()` in our implementation, connect() calls this method to get the connection, it also can be both sync and async, you can omit this one tho 87 | 4. `get_parser_cls()` this method can only be sync, it returns a parser class (and not object) 88 | -------------------------------------------------------------------------------- /docs/configure/sentinel_configurations.md: -------------------------------------------------------------------------------- 1 | # Sentinel configuration 2 | 3 | a sentinel configuration has these parts: 4 | 5 | 1. `DJANGO_VALKEY_CONNECTION_FACTORY`: you can use the ConnectionFactory or SentinelConnectionFactory. the sentinel client uses SentinelConnectionFactory by default. 6 | SentinelConnectionFactory inherits from ConnectionFactory but adds checks to see if configuration is correct, also adds features to make configuration more robust. 7 | 8 | 2. `CACHES["default"]["OPTIONS"]["CONNECTION_FACTORY"]`: does what the above option does, but only in the scope of the cache server it was defined in. 9 | 10 | 3. `CACHES["default"]["OPTIONS"]["CLIENT_CLASS"]`: setting the client class to SentinelClient will add some checks to ensure proper configs and makes working with primary and replica pools easier 11 | you can get by just using the DefaultClient but using SentinelClient is recommended. 12 | 4. `CACHES["default"]["OPTIONS"]["CONNECTION_POOL_CLASS"]`: if you have configured the above settings to use Sentinel friendly options you don't have to set this, otherwise you might want to set this to `valkey.sentinel.SentinelConnectionPool`. 13 | 14 | 5. `CACHES["default"]["OPTIONS"]["SENTINELS"]`: a list of (host, port) providing the sentinel's connection information. 15 | 16 | 6. `CACHES["default"]["OPTIONS"]["SENTINEL_KWARGS"]`: a dictionary of arguments sent down to the underlying Sentinel client 17 | 18 | the below code is a bit long but comprehensive example of different ways to configure a sentinel backend. 19 | *Note* that depending on how you configured your backend, you might need to adjust the `LOCATION` to fit other configs 20 | 21 | ```python 22 | DJANGO_VALKEY_CONNECTION_FACTORY = "django_valkey.pool.SentinelConnectionFactory" 23 | 24 | # SENTINELS is a list of (host name, port) tuples 25 | # These sentinels are shared between all the examples, and are passed 26 | # directly to valkey Sentinel. These can also be defined inline. 27 | SENTINELS = [ 28 | ('sentinel-1', 26379), 29 | ('sentinel-2', 26379), 30 | ('sentinel-3', 26379), 31 | ] 32 | 33 | CACHES = { 34 | "default": { 35 | # ... 36 | "LOCATION": "valkey://service_name/db", # note you should pass in valkey service name, not address 37 | "OPTIONS": { 38 | # While the default client will work, this will check you 39 | # have configured things correctly, and also create a 40 | # primary and replica pool for the service specified by 41 | # LOCATION rather than requiring two URLs. 42 | "CLIENT_CLASS": "django_valkey.client.SentinelClient", 43 | 44 | # these are passed directly to valkey sentinel 45 | "SENTINELS": SENTINELS, 46 | 47 | # optional 48 | "SENTINEL_KWARGS": {}, 49 | 50 | # you can override the connection pool (optional) 51 | # (it is originally defined in connection factory) 52 | "CONNECTION_POOL_CLASS": "valkey.sentinel.SentinelConnectionPool", 53 | }, 54 | }, 55 | 56 | # a minimal example using the SentinelClient 57 | "minimal": { 58 | "BACKEND": "django_valkey.cache.ValkeyCache", 59 | "LOCATION": "valkey://minimal_service_name/db", 60 | 61 | "OPTIONS": { 62 | "CLIENT_CLASS": "django_valkey.client.SentinelClient", 63 | "SENTINELS": SENTINELS, 64 | }, 65 | }, 66 | 67 | # a minimal example using the DefaultClient 68 | "other": { 69 | "BACKEND": "django_valkey.cache.ValkeyCache", 70 | "LOCATION": [ 71 | # The DefaultClient is [primary, replicas], but with the 72 | # SentinelConnectionPool it only requires "is_master=1" for primary and "is_master=0" for replicas. 73 | "valkey://other_service_name/db?is_master=1", 74 | "valkey://other_service_name/db?is_master=0", 75 | ] 76 | "OPTIONS": {"SENTINELS": SENTINELS}, 77 | }, 78 | 79 | # a minimal example only using replicas in read only mode 80 | # (and the DefaultClient). 81 | "readonly": { 82 | "BACKEND": "django_valkey.cache.ValkeyCache", 83 | "LOCATION": "valkey://readonly_service_name/db?is_master=0", 84 | "OPTIONS": {"SENTINELS": SENTINELS}, 85 | }, 86 | } 87 | ``` 88 | 89 | 90 | ### Use sentinel and normal servers together 91 | it is also possible to set some caches as sentinels and some as not: 92 | 93 | ```python 94 | SENTINELS = [ 95 | ('sentinel-1', 26379), 96 | ('sentinel-2', 26379), 97 | ('sentinel-3', 26379), 98 | ] 99 | CACHES = { 100 | "sentinel": { 101 | "BACKEND": "django_valkey.cache.ValkeyCache", 102 | "LOCATION": "valkey://service_name/db", 103 | "OPTIONS": { 104 | "CLIENT_CLASS": "django_valkey.client.SentinelClient", 105 | "SENTINELS": SENTINELS, 106 | "CONNECTION_POOL_CLASS": "valkey.sentinel.SentinelConnectionPool", 107 | "CONNECTION_FACTORY": "django_valkey.pool.SentinelConnectionFactory", 108 | }, 109 | }, 110 | "default": { 111 | "BACKEND": "django_valkey.cache.ValkeyCache", 112 | "LOCATION": "valkey://127.0.0.1:6379/1", 113 | "OPTIONS": { 114 | "CLIENT_CLASS": "django_valkey.client.DefaultClient", 115 | }, 116 | }, 117 | } 118 | ``` 119 | -------------------------------------------------------------------------------- /django_valkey/client/herd.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import Any 3 | 4 | from valkey import Valkey 5 | from valkey.typing import KeyT, EncodableT 6 | 7 | from django_valkey.base_client import ( 8 | DEFAULT_TIMEOUT, 9 | Backend, 10 | HerdCommonMethods, 11 | _main_exceptions, 12 | ) 13 | from django_valkey.client.default import DefaultClient 14 | from django_valkey.exceptions import ConnectionInterrupted 15 | 16 | 17 | class HerdClient(HerdCommonMethods, DefaultClient): 18 | def set( 19 | self, 20 | key: KeyT, 21 | value: EncodableT, 22 | timeout: int | None = DEFAULT_TIMEOUT, 23 | version: int | None = None, 24 | client: Valkey | None = None, 25 | nx: bool = False, 26 | xx: bool = False, 27 | ): 28 | if timeout is DEFAULT_TIMEOUT: 29 | timeout = self._backend.default_timeout 30 | 31 | if timeout is None or timeout <= 0: 32 | return super().set( 33 | key, 34 | value, 35 | timeout=timeout, 36 | version=version, 37 | client=client, 38 | nx=nx, 39 | xx=xx, 40 | ) 41 | 42 | packed = self._pack(value, timeout) 43 | real_timeout = timeout + self._herd_timeout 44 | 45 | return super().set( 46 | key, 47 | packed, 48 | timeout=real_timeout, 49 | version=version, 50 | client=client, 51 | nx=nx, 52 | xx=xx, 53 | ) 54 | 55 | def get(self, key, default=None, version=None, client=None): 56 | packed = super().get(key, default=default, version=version, client=client) 57 | val, refresh = self._unpack(packed) 58 | 59 | if refresh: 60 | return default 61 | 62 | return val 63 | 64 | def get_many(self, keys, version=None, client=None): 65 | client = self._get_client(write=False, client=client) 66 | 67 | if not keys: 68 | return {} 69 | 70 | recovered_data = {} 71 | 72 | new_keys = [self.make_key(key, version=version) for key in keys] 73 | map_keys = dict(zip(new_keys, keys)) 74 | 75 | try: 76 | pipeline = client.pipeline() 77 | for key in new_keys: 78 | pipeline.get(key) 79 | results = pipeline.execute() 80 | except _main_exceptions as e: 81 | raise ConnectionInterrupted(connection=client) from e 82 | 83 | for key, value in zip(new_keys, results): 84 | if value is None: 85 | continue 86 | 87 | val, refresh = self._unpack(self.decode(value)) 88 | recovered_data[map_keys[key]] = None if refresh else val 89 | 90 | return recovered_data 91 | 92 | def mget( 93 | self, 94 | keys: Iterable[KeyT], 95 | version: int | None = None, 96 | client: Backend | Any | None = None, 97 | ) -> dict: 98 | client = self._get_client(write=False, client=client) 99 | if not keys: 100 | return {} 101 | 102 | recovered_data = {} 103 | 104 | new_keys = [self.make_key(key, version=version) for key in keys] 105 | 106 | try: 107 | results = client.mget(new_keys) 108 | except _main_exceptions as e: 109 | raise ConnectionInterrupted(connection=client) from e 110 | 111 | for key, value in zip(keys, results): 112 | if value is None: 113 | continue 114 | val, refresh = self._unpack(self.decode(value)) 115 | recovered_data[key] = None if refresh else val 116 | return recovered_data 117 | 118 | def set_many( 119 | self, data, timeout=DEFAULT_TIMEOUT, version=None, client=None, herd=True 120 | ): 121 | """ 122 | Set a bunch of values in the cache at once from a dict of key/value 123 | pairs. This is much more efficient than calling set() multiple times. 124 | 125 | If timeout is given, that timeout will be used for the key; otherwise 126 | the default cache timeout will be used. 127 | """ 128 | client = self._get_client(write=True, client=client) 129 | 130 | set_function = self.set if herd else super().set 131 | 132 | try: 133 | pipeline = client.pipeline() 134 | for key, value in data.items(): 135 | set_function(key, value, timeout, version=version, client=pipeline) 136 | pipeline.execute() 137 | except _main_exceptions as e: 138 | raise ConnectionInterrupted(connection=client) from e 139 | 140 | def incr(self, *args, **kwargs): 141 | raise NotImplementedError 142 | 143 | def decr(self, *args, **kwargs): 144 | raise NotImplementedError 145 | 146 | def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, client=None): 147 | client = self._get_client(write=True, client=client) 148 | 149 | value = self.get(key, version=version, client=client) 150 | if value is None: 151 | return False 152 | 153 | self.set(key, value, timeout=timeout, version=version, client=client) 154 | return True 155 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | Version 0.4.0 2 | ------------- 3 | 4 | ### new 5 | - added support for msgspec serialization (both json and msgpack) 6 | - support django 6 7 | - support python 3.14 8 | 9 | ### Breaking changes 10 | - dropped support for django 5.0 and 5.1 11 | - `BackendCommands` and `AsyncBackendCommands` are no longer decorated with `omit_exception`. 12 | - added `omit_exception_async` to decorate async operations, instead of using `omit_exception` for both sync and async. 13 | - `omit_exception` no longer supports async functions and generators. 14 | - added `DecoratedBackendCommands` and `DecoratedAsyncBackendCommands` as commands decorated with `omit_exception` and `omit_exception_async`. 15 | - `AsyncValkeyCache` and `ValkeyCache` no longer inherit from `BackendCommands` and `AsyncBackendCommands`, they inherit from `DecoratedBackendCommands` and `DecoratedAsyncBackendCommands` instead. 16 | - stdlib `zstd` is used instead of `pyzstd` (`zstd` is the same as `pyzstd`, but since the package name are different this is counted is breaking change) 17 | 18 | ### improvement 19 | - removed the undecorator loop from cluster client 20 | 21 | Version 0.3.2 22 | ------------- 23 | 24 | - add `decr_version` method to be compatible with django's `BaseCache` 25 | 26 | Version 0.3.1 27 | ------------- 28 | 29 | - allow access to `make_key` and `make_pattern` from backend 30 | - fix typos 31 | - update pre-commit hooks 32 | - add codespell 33 | - attend a deprecated function in testcase 34 | - add django 6 as supported version 35 | 36 | Version 0.3.0 37 | ------------- 38 | 39 | ### New 40 | - official support for cluster servers!!! 41 | - now all operations support omitting exceptions 42 | - `make_key`, `make_pattern`, `encode` and `decode` are now functions, so they can be used outside the backend (e.g: when using the raw client) 43 | - `django_valkey.get_valkey_connection` now works with shard client as well 44 | - replace django's `close_caches` receiver with `close_async_caches` 45 | 46 | ### bug fix 47 | - fixed bug of `omit_exception` not handling generator and async generators 48 | - fixed bug of async `get_valkey_connection` checking if the client is async. 49 | 50 | ### internal change 51 | - `make_key` and `make_pattern` return None if key/pattern is None 52 | - moved all operations from `django_valkey.cache` and `django_valkey.async_cache.cache` to `django_valkey.base`. 53 | - cluster client now uses the same methods as normal client, unless it has to have a specific method. 54 | - prefixing async methods with `a` is done dynamically now; (so no more `aset = set`), it's all handled in `__getattr__`. 55 | - moved async client methods to `django_valkey.base_client.AsyncClientCommands`. 56 | - moved sync client methods to `django_valkey.base_client.ClientCommands`. 57 | - `make_key`, `make_pattern`, `encode`, `decode`, `_decode_iterable_result` are now all sync methods and moved to `django_valkey.base_client.BaseClient`. 58 | - `AsyncHerdClient._pack` and `AsyncHerdClient._unpack` are now sync methods. 59 | - common parts of herd clients now live in `django_valkey.base_client` 60 | - shard client now has `get_client` instead of `get_server` 61 | - all tests now use pytest tools 62 | - use anyio for async tests instead of `pytest-asyncio` 63 | - add editorconfig file 64 | 65 | Version 0.2.0 66 | ------------- 67 | 68 | - drop support for EOL django versions 69 | 70 | - floats are no longer serialized 71 | this is to support atomic float operations 72 | 73 | - minor bug fix 74 | 75 | Version 0.1.8 76 | ------------- 77 | 78 | - Added ``AsyncSentinelClient`` 79 | 80 | - internal bug fix 81 | 82 | Version 0.1.7 83 | ------------- 84 | 85 | - added ``AsyncHerdClient`` 86 | 87 | Version 0.1.6 88 | ------------- 89 | 90 | - added ``blocking`` and ``lock_class`` to get_lock parameters. 91 | 92 | Version 0.1.5 93 | ------------- 94 | 95 | - Added mset and mget (atomic) 96 | 97 | - get_many() is now non-atomic 98 | 99 | - extended base.BaseValkeyCache to have more logic 100 | 101 | Version 0.1.4 102 | ------------- 103 | 104 | - Added BaseClient class 105 | 106 | - **BREAKING:** changed BaseConnectionPool to BaeConnectionFactory 107 | 108 | Version 0.1.3 109 | ------------- 110 | 111 | - Moved some method implementation out of BaseConnectionFactory class 112 | 113 | - added a guide to use the base classes 114 | 115 | 116 | Version 0.1.2 117 | ------------- 118 | 119 | - Added an async backend 120 | 121 | - Added an async client 122 | 123 | - Added two async connection factories 124 | 125 | - Added documentation for async client 126 | 127 | Version 0.0.17 128 | -------------- 129 | 130 | - Added migration guide to documents 131 | 132 | - changed documentation setup 133 | 134 | - added RESP3 documentation 135 | 136 | - changes in README.rst 137 | 138 | - cleaning up the codebase 139 | 140 | - fixed some test settings 141 | 142 | - major internal refactoring 143 | 144 | - renamed ``lock()`` to ``get_lock()``, but the old form is still available. 145 | 146 | Version 0.0.15 147 | -------------- 148 | 149 | Released 2024/09/07 150 | 151 | - Added this documentation! 152 | 153 | - changed ``VALKEY_CLIENT_CLASS`` to ``BASE_CLIENT_CLASS`` for clarity 154 | 155 | - changed ``CLIENT_KWARGS`` to ``BASE_CLIENT_KWARGS`` for clarity 156 | 157 | - added some docstring to compressor classes 158 | 159 | - fixed some messages generated by autocomplete 160 | -------------------------------------------------------------------------------- /django_valkey/async_cache/client/herd.py: -------------------------------------------------------------------------------- 1 | from valkey import Valkey 2 | from valkey.typing import KeyT, EncodableT 3 | 4 | from django_valkey.async_cache.client import AsyncDefaultClient 5 | from django_valkey.base_client import ( 6 | DEFAULT_TIMEOUT, 7 | HerdCommonMethods, 8 | _main_exceptions, 9 | ) 10 | from django_valkey.exceptions import ConnectionInterrupted 11 | 12 | 13 | class AsyncHerdClient(HerdCommonMethods, AsyncDefaultClient): 14 | async def set( 15 | self, 16 | key: KeyT, 17 | value: EncodableT, 18 | timeout: int | None = DEFAULT_TIMEOUT, 19 | version: int | None = None, 20 | client: Valkey | None = None, 21 | nx: bool = False, 22 | xx: bool = False, 23 | ): 24 | if timeout is DEFAULT_TIMEOUT: 25 | timeout = self._backend.default_timeout 26 | 27 | if timeout is None or timeout <= 0: 28 | return await super().set( 29 | key, 30 | value, 31 | timeout=timeout, 32 | version=version, 33 | client=client, 34 | nx=nx, 35 | xx=xx, 36 | ) 37 | 38 | packed = self._pack(value, timeout) 39 | real_timeout = timeout + self._herd_timeout 40 | 41 | return await super().set( 42 | key, 43 | packed, 44 | timeout=real_timeout, 45 | version=version, 46 | client=client, 47 | nx=nx, 48 | xx=xx, 49 | ) 50 | 51 | async def get(self, key, default=None, version=None, client=None): 52 | packed = await super().get(key, default=default, version=version, client=client) 53 | val, refresh = self._unpack(packed) 54 | 55 | if refresh: 56 | return default 57 | 58 | return val 59 | 60 | async def get_many(self, keys, version=None, client=None): 61 | client = await self._get_client(write=False, client=client) 62 | 63 | if not keys: 64 | return {} 65 | 66 | recovered_data = {} 67 | 68 | new_keys = [self.make_key(key, version=version) for key in keys] 69 | map_keys = dict(zip(new_keys, keys)) 70 | 71 | try: 72 | pipeline = await client.pipeline() 73 | for key in new_keys: 74 | await pipeline.get(key) 75 | results = await pipeline.execute() 76 | except _main_exceptions as e: 77 | raise ConnectionInterrupted(connection=client) from e 78 | 79 | for key, value in zip(new_keys, results): 80 | if value is None: 81 | continue 82 | 83 | val, refresh = self._unpack(self.decode(value)) 84 | recovered_data[map_keys[key]] = None if refresh else val 85 | 86 | return recovered_data 87 | 88 | async def mget(self, keys, version=None, client=None): 89 | if not keys: 90 | return {} 91 | 92 | client = await self._get_client(write=False, client=client) 93 | 94 | recovered_data = {} 95 | 96 | new_keys = [self.make_key(key, version=version) for key in keys] 97 | map_keys = dict(zip(new_keys, keys)) 98 | 99 | try: 100 | results = await client.mget(new_keys) 101 | except _main_exceptions as e: 102 | raise ConnectionInterrupted(connection=client) from e 103 | 104 | for key, value in zip(new_keys, results): 105 | if value is None: 106 | continue 107 | 108 | val, refresh = self._unpack(self.decode(value)) 109 | recovered_data[map_keys[key]] = None if refresh else val 110 | 111 | return recovered_data 112 | 113 | async def set_many( 114 | self, data, timeout=DEFAULT_TIMEOUT, version=None, client=None, herd=True 115 | ): 116 | """ 117 | Set a bunch of values in the cache at once from a dict of key/value 118 | pairs. This is much more efficient than calling set() multiple times. 119 | 120 | If timeout is given, that timeout will be used for the key; otherwise 121 | the default cache timeout will be used. 122 | """ 123 | client = await self._get_client(write=True, client=client) 124 | 125 | set_function = self.set if herd else super().set 126 | 127 | try: 128 | pipeline = await client.pipeline() 129 | for key, value in data.items(): 130 | await set_function( 131 | key, value, timeout, version=version, client=pipeline 132 | ) 133 | await pipeline.execute() 134 | except _main_exceptions as e: 135 | raise ConnectionInterrupted(connection=client) from e 136 | 137 | def incr(self, *args, **kwargs): 138 | raise NotImplementedError 139 | 140 | def decr(self, *args, **kwargs): 141 | raise NotImplementedError 142 | 143 | async def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, client=None): 144 | client = await self._get_client(write=True, client=client) 145 | 146 | value = await self.get(key, version=version, client=client) 147 | if value is None: 148 | return False 149 | 150 | await self.set(key, value, timeout=timeout, version=version, client=client) 151 | return True 152 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Valkey cache backend for Django 3 | =============================== 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-valkey?label=PyPi 6 | :target: https://pypi.org/project/django-valkey/ 7 | :alt: Pypi 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/django-valkey.svg 10 | :target: https://img/pypi.org/project/django-valkey/ 11 | :alt: Python versions 12 | 13 | .. image:: https://readthedocs.org/projects/django-valkey/badge/?version=latest&style=flat 14 | :target: https://django-valkey.readthedocs.io/en/latest/ 15 | :alt: docs 16 | 17 | .. image:: https://static.pepy.tech/badge/django-valkey/month 18 | :target: https://pepy.tech/project/django-valkey 19 | :alt: downloads/month 20 | 21 | .. image:: https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26 22 | :target: https://djangopackages.org/packages/p/django-valkey/ 23 | :alt: Published on Django Packages 24 | 25 | Introduction 26 | ------------ 27 | 28 | django-valkey is a BSD licensed, full featured Valkey cache and session backend 29 | for Django. 30 | 31 | this project is a fork of the wonderful `django-redis `_ project. 32 | they wrote all the good codes. 33 | 34 | Why use django-valkey? 35 | ~~~~~~~~~~~~~~~~~~~~~~ 36 | 37 | - Valkey is a free licenced and well maintained key/value database 38 | - Uses native valkey-py url notation connection strings 39 | - async support 40 | - Pluggable clients 41 | - Pluggable parsers 42 | - Pluggable serializers 43 | - Primary/secondary support in the default client 44 | - Comprehensive test suite 45 | - Used in production in several projects as cache and session storage 46 | - Supports infinite timeouts 47 | - Facilities for raw access to Valkey client/connection pool 48 | - Highly configurable (can emulate memcached exception behavior, for example) 49 | - Unix sockets supported by default 50 | 51 | Requirements 52 | ~~~~~~~~~~~~ 53 | 54 | - `Python`_ 3.10+ 55 | - `Django`_ 4.2.20+ 56 | - `Django~=5.0` and `Django~=5.1` are not supported, because they are end of life: https://endoflife.date/django 57 | - `valkey-py`_ 6.0.2+ 58 | - `Valkey server`_ 7.2.6+ 59 | 60 | .. _Python: https://www.python.org/downloads/ 61 | .. _Django: https://www.djangoproject.com/download/ 62 | .. _valkey-py: https://pypi.org/project/valkey/ 63 | .. _Valkey server: https://valkey.io/download 64 | 65 | User guide 66 | ---------- 67 | 68 | Documentation 69 | ~~~~~~~~~~~~~ 70 | check out our `Docs `_ for a complete explanation 71 | 72 | Installation 73 | ~~~~~~~~~~~~ 74 | 75 | Install with pip: 76 | 77 | .. code-block:: console 78 | 79 | python -m pip install django-valkey 80 | 81 | Install with c bindings: 82 | 83 | .. code-block:: console 84 | 85 | python -m pip install django-valkey[libvalkey] 86 | 87 | Install 3rd party compression 88 | 89 | .. code-block:: console 90 | 91 | python -m pip install django-valkey[lz4] 92 | 93 | .. code-block:: console 94 | 95 | python -m pip install django-valkey[zstd] # not needed since python 3.14 96 | 97 | .. code-block:: console 98 | 99 | python -m pip install django-valkey[brotli] 100 | 101 | Install with 3rd party serializers 102 | 103 | .. code-block:: console 104 | 105 | python -m pip install django-valkey[msgpack] 106 | 107 | .. code-block:: console 108 | 109 | python -m pip install django-valkey[msgspec] 110 | 111 | 112 | 113 | 114 | Contribution 115 | ~~~~~~~~~~~~ 116 | contribution rules are like other projects,being respectful and keeping the ethics. 117 | also make an issue before going through troubles of coding, someone might already be doing what you want to do. 118 | 119 | 120 | Todo 121 | ~~~~ 122 | 123 | 1. Fix the CI in cluster branch. 124 | 2. Add cluster to documentations. 125 | 126 | License 127 | ------- 128 | 129 | .. code-block:: text 130 | 131 | Copyright (v) 2024 Amirreza Sohrabi far 132 | Copyright (c) 2011-2016 Andrey Antukh 133 | Copyright (c) 2011 Sean Bleier 134 | 135 | All rights reserved. 136 | 137 | Redistribution and use in source and binary forms, with or without 138 | modification, are permitted provided that the following conditions 139 | are met: 140 | 1. Redistributions of source code must retain the above copyright 141 | notice, this list of conditions and the following disclaimer. 142 | 2. Redistributions in binary form must reproduce the above copyright 143 | notice, this list of conditions and the following disclaimer in the 144 | documentation and/or other materials provided with the distribution. 145 | 3. The name of the author may not be used to endorse or promote products 146 | derived from this software without specific prior written permission. 147 | 148 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS`` AND ANY EXPRESS OR 149 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 150 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 151 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 152 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 153 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 154 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 155 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 156 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 157 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 158 | -------------------------------------------------------------------------------- /docs/commands/raw_access.md: -------------------------------------------------------------------------------- 1 | # Raw operations 2 | 3 | ## Access the underlying valkey client 4 | if for whatever reason you need to do things that the clients don't support, you can access the underlying valkey connections and send commands by hand 5 | to get a connection, you can do: 6 | 7 | ```python 8 | from django_valkey import get_valkey_connection 9 | 10 | raw_client = get_valkey_connection("default") 11 | ``` 12 | 13 | in this example `"default"` is the alias name of the backend, that you configured in django's `CACHES` setting 14 | the signature of the function is as follows: 15 | ```python 16 | def get_valkey_connection(alias: str="default", write: bool=True, key=None): ... 17 | ``` 18 | 19 | `alias` is the name you gave each server in django's `CACHES` setting. 20 | `write` is used to determine if the operation will write to the cache database, so it should be `True` for `set` and `False` for `get`. 21 | `key` is only used with the shard client, it'll be explained below. 22 | 23 | ### get raw access while using shard client 24 | **note**: this only works as of v0.3.0 25 | 26 | to get access to the client while using the shard client, you need to pass in the key you are going to work with, 27 | this is because the shard client determines which server to use by the key. 28 | 29 | **note**: if you are trying to use a key that was set by django_valkey's interface, you need to make the key before passing it to `get_valkey_connection` 30 | we explain how to make a key below. 31 | 32 | 33 | ### raw operation utilities 34 | 35 | as of `django_valkey` v0.3.0, we provide some additional utilities that might be of interest while doing raw operations: 36 | 37 | ```python 38 | from django_valkey.util import make_key, make_pattern, encode, decode 39 | ``` 40 | 41 | #### make_key 42 | `make_key` is used to create keys when we are setting a value in the cache server, and so the same operation is used to read that key. 43 | `make_key` was an internal method, but as of v0.3.0 it's a function you can use easily 44 | 45 | ```python 46 | from django.core.cache.backends.base import default_key_func # this is the default key func, if you are using a custom one, use that instead 47 | from django_valkey.util import make_key 48 | 49 | make_key( 50 | key="my_key", 51 | key_func=default_key_func, # the default key func, customize based on your configs 52 | version=1, # 1 is default, customize it based on your configs 53 | prefix="", # default prefix, customize based on your config 54 | ) 55 | ``` 56 | 57 | the above call will generate a key that looks like `":1:my_key"` 58 | might be worthy to note that the return value of `make_key` is not a `str`, but a subclass of str, you can find it as `django_valkey.util.CacheKey` 59 | 60 | to communicate with cache objects created by `django_valkey` in a raw operation, you should to use this function to make things easy, 61 | but it's recommended to always use it to make things consistent and up to conventions. 62 | 63 | if you don't want to handwrite the arguments, you can find the values used by your client from the `cache` object: 64 | ```python 65 | from django.core.cache import cache 66 | 67 | key_func = cache.key_func 68 | version = cache.version 69 | key_prefix = cache.key_prefix 70 | ``` 71 | 72 | #### make_pattern 73 | `make_pattern` is used to make a pattern, which is used for searching and finding keys that are similar, 74 | for example `foo*` will match `foo1`, `foo2`, `foo_something` and so one 75 | 76 | `make_pattern` is used in operations such as `iter_keys`, `delete_pattern` and so on. 77 | 78 | to use `make_pattern` notice the following example 79 | 80 | ```python 81 | from django.core.cache.backends.base import default_key_func # this is the default key func, if you are using a custom one, use that instead 82 | from django_valkey.util import make_pattern 83 | 84 | make_pattern( 85 | pattern="my_*", 86 | key_func=default_key_func, # the default key func, customize based on your configs 87 | version=1, # 1 is default, customize it based on your configs 88 | prefix="", # default prefix, customize based on your config 89 | ) 90 | ``` 91 | 92 | if you don't want to handwrite the arguments, you can find the values used by your client from the `cache` object: 93 | ```python 94 | from django.core.cache import cache 95 | 96 | key_func = cache.key_func 97 | version = cache.version 98 | key_prefix = cache.key_prefix 99 | ``` 100 | 101 | #### encode and decode 102 | `encode` and `decode` are called on values that will be saved/read (not on keys, only values) 103 | 104 | encode does two things: 105 | first it serializes the data 106 | then it compresses that data 107 | 108 | decode is the opposite: 109 | first decompresses the data 110 | then deserializes it 111 | 112 | to use encode and decode, you need an instance of the serializer and compressor you are using 113 | 114 | you can access the ones your config uses from `cache.client._compressor` and `cache.client._serializer`, 115 | or you can pass in any object you want (note that you need to pass an instance, not the class) 116 | 117 | ```python 118 | from django.core.cache import cache 119 | 120 | from django_valkey.util import encode, decode 121 | 122 | encode(value="my_value", serializer=cache.client._serializer, compressor=cache.client._compressor) 123 | decode(value="my_value", serializer=cache.client._serializer, compressor=cache.client._compressor) 124 | ``` 125 | 126 | if you want to pass in the serializer and compressor by hand, you can instantiate one of the classes we provide and pass in the object, or instantiate your custom class, 127 | just note that the classes need a dictionary of configurations to be passed to them to be instantiated, 128 | you can see what configs they get from the [serializer](../configure/advanced_configurations.md#configure-the-serializer) and [compressor](../configure/compressors.md) docs. -------------------------------------------------------------------------------- /docs/commands/valkey_native_commands.md: -------------------------------------------------------------------------------- 1 | # valkey commands 2 | 3 | if you need to use valkey operations, you can access the client as follows: 4 | 5 | ```pycon 6 | >>> from django.core.cache import cache 7 | >>> cache.set("key", "value1", nx=True) 8 | True 9 | >>> cache.set("key", "value2", nx=True) 10 | False 11 | >>> cache.get("key") 12 | "value1" 13 | ``` 14 | 15 | the list of supported commands is very long, if needed you can check the methods at `django_valkey.base.BackendCommands`. 16 | 17 | ### Infinite timeout 18 | 19 | django-valkey comes with infinite timeouts supported out of the box. And it 20 | behaves in the same way as django backend contract specifies: 21 | 22 | - `timeout=0` expires the value immediately. 23 | - `timeout=None` infinite timeout. 24 | 25 | ```python 26 | cache.set("key", "value", timeout=None) 27 | ``` 28 | 29 | ### Get and Set in bulk 30 | 31 | django-valkey has two different kind of method for bulk get/set: atomic and non-atomic. 32 | 33 | atomic operations are done with `mget()` and `mset()` 34 | 35 | ```pycon 36 | >>> from django.core.cache import cache 37 | >>> cache.mset({"a": 1, "b": 2}) 38 | >>> cache.mget(["a", "b"]) 39 | {"a": 1, "b": 2} 40 | ``` 41 | 42 | the non-atomic operations are done with `get_many()` and `set_many()`: 43 | 44 | ```pycon 45 | >>> from django.core.cache import cache 46 | >>> cache.set_many({"a": 1, "b": 2}) 47 | >>> cache.get_many(["a", "b"]) 48 | {"a": 1, "b": 2} 49 | ``` 50 | 51 | **Note**: django-redis users should note that in django redis `get_many()` is an atomic operation, but `set_many()` is non-atomic, but in `django-valkey` they are both non-atomic. 52 | 53 | ### Scan and Delete in bulk 54 | 55 | when you need to search for keys that have similar patterns, or delete them, you can use the helper methods that come with django-valkey: 56 | 57 | ```pycon 58 | >>> from django.core.cache import cache 59 | >>> cache.keys("foo_*") 60 | ["foo_1", "foo_2"] 61 | ``` 62 | 63 | if you are looking for a very large amount of data, this is **not** suitable; instead use `iter_keys`. 64 | this will return a generator that you can iterate over more efficiently. 65 | 66 | ```pycon 67 | >>> from django.core.cache import cache 68 | >>> cache.iter_keys("foo_*") 69 | >> next(cache.iter_keys("foo_*)) 71 | 'foo_1' 72 | >>> foos = cache.iter_keys("foo_*") 73 | >>> for i in foos: 74 | ... print(i) 75 | 'foo_1' 76 | 'foo_2' 77 | ``` 78 | 79 | to delete keys, you should use `delete_pattern` which has the same glob pattern syntax as `keys` and returns the number of deleted keys. 80 | 81 | ```pycon 82 | >>> from django.core.cache import cache 83 | >>> cache.delete_pattern("foo_*") 84 | 2 85 | ``` 86 | 87 | To achieve the best performance while deleting many keys, you should set `DJANGO_VALKEY_SCAN_ITERSIZE` to a relatively 88 | high number (e.g., 100_000) by default in Django settings or pass it directly to the `delete_pattern`. 89 | 90 | ```pycon 91 | >>> from django.core.cache import cache 92 | >>> cache.delete_pattern("foo_*", itersize=100_000) 93 | ``` 94 | 95 | ### Get ttl (time-to-live) from key 96 | 97 | with valkey you can access to ttl of any sorted key, to do so, django-valky exposes the `ttl` method. 98 | 99 | the ttl method returns: 100 | 101 | - `0` if key does not exist (or already expired). 102 | - `None` for keys that exist but does not have expiration. 103 | - the ttl value for any volatile key (any key that has expiration). 104 | 105 | ```pycon 106 | >>> from django.core.cache import cache 107 | >>> cache.set("foo", "value", timeout=25) 108 | >>> cache.ttl("foo") 109 | 25 110 | >>> cache.ttl("not-exists") 111 | 0 112 | ``` 113 | 114 | you can also access the ttl of any sorted key in milliseconds, use the `pttl` method to do so: 115 | 116 | ```pycon 117 | >>> from django.core.cache import cache 118 | >>> cache.set("foo", "value", timeout=25) 119 | >>> cache.pttl("foo") 120 | 25000 121 | >>> cache.pttl("non-existent") 122 | 0 123 | ``` 124 | 125 | ### Expire & Persist 126 | 127 | in addition to the `ttl` and `pttl` methods, you can use the `persist` method so the key would have infinite timeout: 128 | 129 | ```pycon 130 | >>> cache.set("foo", "bar", timeout=22) 131 | >>> cache.ttl("foo") 132 | 22 133 | >>> cache.persist("foo") 134 | True 135 | >>> cache.ttl("foo") 136 | None 137 | ``` 138 | 139 | you can also use `expire` to set a new timeout on the key: 140 | 141 | ```pycon 142 | >>> cache.set("foo", "bar", timeout=22) 143 | >>> cache.expire("foo", timeout=5) 144 | True 145 | >>> cache.ttl("foo") 146 | 5 147 | ``` 148 | 149 | The `pexpire` method can be used to set new timeout in millisecond precision: 150 | 151 | 152 | ```pycon 153 | >>> cache.set("foo", "bar", timeout=22) 154 | >>> cache.pexpire("foo", timeout=5505) 155 | True 156 | >>> cache.pttl("foo") 157 | 5505 158 | ``` 159 | 160 | The `expire_at` method can be used to make the key expire at a specific moment in time: 161 | 162 | ```pycon 163 | >>> cache.set("foo", "bar", timeout=22) 164 | >>> cache.expire_at("foo", datetime.now() + timedelta(hours=1)) 165 | True 166 | >>> cache.ttl("foo") 167 | 3600 168 | ``` 169 | 170 | The `pexpire_at` method can be used to make the key expire at a specific moment in time, with milliseconds precision: 171 | 172 | ```pycon 173 | >>> cache.set("foo", "bar", timeout=22) 174 | >>> cache.pexpire_at("foo", datetime.now() + timedelta(milliseconds=900, hours=1)) 175 | True 176 | >>> cache.ttl("foo") 177 | 3601 178 | >>> cache.pttl("foo") 179 | 3600900 180 | ``` 181 | 182 | ### Locks 183 | 184 | django-valkey also supports locks. 185 | valkey has distributed named locks which are identical to `threading.Lock` so you can useit as replacement. 186 | 187 | ```python 188 | with cache.get_lock("somekey"): 189 | do_something()) 190 | ``` 191 | 192 | this command is also available as `cache.lock()` but will be removed in the future. 193 | 194 | ### Access Raw client 195 | 196 | if the commands provided by django-valkey backend is not enough, or you want to use them in a different way, you can access the underlying client as follows: 197 | 198 | ```pycon 199 | >>> from django-valkey import get_valkey_connection 200 | >>> con = get_valkey_connection("default") 201 | >>> con 202 | 203 | ``` 204 | 205 | **Warning**: not all clients support this feature: 206 | ShardClient will raise an exception if tried to be used like this. 207 | -------------------------------------------------------------------------------- /tests/tests_cluster/test_backend.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | import pytest 4 | from pytest_django.fixtures import SettingsWrapper 5 | 6 | from django.core.cache import caches 7 | from django.test import override_settings 8 | 9 | from valkey.cluster import ValkeyCluster 10 | 11 | from django_valkey.cluster_cache.cache import ClusterValkeyCache 12 | 13 | 14 | @pytest.fixture 15 | def patch_itersize_setting() -> Iterable[None]: 16 | # destroy cache to force recreation with overridden settings 17 | del caches["default"] 18 | with override_settings(DJANGO_VALKEY_SCAN_ITERSIZE=30): 19 | yield 20 | # destroy cache to force recreation with original settings 21 | del caches["default"] 22 | 23 | 24 | class TestDjangoValkeyCache: 25 | def test_mget(self, cache: ClusterValkeyCache): 26 | cache.set("{foo}a", 1) 27 | cache.set("{foo}b", 2) 28 | cache.set("{foo}c", 3) 29 | 30 | res = cache.mget(["{foo}a", "{foo}b", "{foo}c"]) 31 | assert res == {"{foo}a": 1, "{foo}b": 2, "{foo}c": 3} 32 | 33 | def test_mget_unicode(self, cache: ClusterValkeyCache): 34 | cache.set("{foo}a", "1") 35 | cache.set("{foo}ب", "2") 36 | cache.set("{foo}c", "الف") 37 | 38 | res = cache.mget(["{foo}a", "{foo}ب", "{foo}c"]) 39 | assert res == {"{foo}a": "1", "{foo}ب": "2", "{foo}c": "الف"} 40 | 41 | def test_mset(self, cache: ClusterValkeyCache): 42 | cache.mset({"a{foo}": 1, "b{foo}": 2, "c{foo}": 3}) 43 | res = cache.mget(["a{foo}", "b{foo}", "c{foo}"]) 44 | assert res == {"a{foo}": 1, "b{foo}": 2, "c{foo}": 3} 45 | 46 | def test_msetnx(self, cache: ClusterValkeyCache): 47 | cache.mset({"a{foo}": 1, "b{foo}": 2, "c{foo}": 3}) 48 | res = cache.mget(["a{foo}", "b{foo}", "c{foo}"]) 49 | assert res == {"a{foo}": 1, "b{foo}": 2, "c{foo}": 3} 50 | cache.msetnx({"a{foo}": 3, "new{foo}": 1, "other{foo}": 1}) 51 | res = cache.mget(["a{foo}", "new{foo}", "other{foo}"]) 52 | assert res == {"a{foo}": 1} 53 | 54 | def test_delete_pattern(self, cache: ClusterValkeyCache): 55 | for key in ["foo-aa", "foo-ab", "foo-bb", "foo-bc"]: 56 | cache.set(key, "foo") 57 | 58 | res = cache.delete_pattern("*foo-a*") 59 | assert bool(res) is True 60 | 61 | keys = cache.keys("foo*", target_nodes=ValkeyCluster.ALL_NODES) 62 | assert set(keys) == {"foo-bb", "foo-bc"} 63 | 64 | res = cache.delete_pattern("*foo-a*") 65 | assert bool(res) is False 66 | 67 | def test_delete_pattern_with_custom_count(self, cache: ClusterValkeyCache, mocker): 68 | client_mock = mocker.patch( 69 | "django_valkey.cluster_cache.cache.ClusterValkeyCache.client" 70 | ) 71 | for key in ["foo-aa", "foo-ab", "foo-bb", "foo-bc"]: 72 | cache.set(key, "foo") 73 | 74 | cache.delete_pattern("*foo-a*", itersize=2) 75 | 76 | client_mock.delete_pattern.assert_called_once_with("*foo-a*", itersize=2) 77 | 78 | def test_delete_pattern_with_settings_default_scan_count( 79 | self, 80 | patch_itersize_setting, 81 | cache: ClusterValkeyCache, 82 | settings: SettingsWrapper, 83 | mocker, 84 | ): 85 | client_mock = mocker.patch( 86 | "django_valkey.cluster_cache.cache.ClusterValkeyCache.client" 87 | ) 88 | for key in ["foo-aa", "foo-ab", "foo-bb", "foo-bc"]: 89 | cache.set(key, "foo") 90 | expected_count = settings.DJANGO_VALKEY_SCAN_ITERSIZE 91 | 92 | cache.delete_pattern("*foo-a*") 93 | 94 | client_mock.delete_pattern.assert_called_once_with( 95 | "*foo-a*", itersize=expected_count 96 | ) 97 | 98 | def test_sdiff(self, cache: ClusterValkeyCache): 99 | cache.sadd("{foo}1", "bar1", "bar2") 100 | cache.sadd("{foo}2", "bar2", "bar3") 101 | assert cache.sdiff("{foo}1", "{foo}2") == {"bar1"} 102 | 103 | def test_sdiffstore(self, cache: ClusterValkeyCache): 104 | cache.sadd("{foo}1", "bar1", "bar2") 105 | cache.sadd("{foo}2", "bar2", "bar3") 106 | assert cache.sdiffstore("{foo}3", "{foo}1", "{foo}2") == 1 107 | assert cache.smembers("{foo}3") == {"bar1"} 108 | 109 | def test_sdiffstore_with_keys_version(self, cache: ClusterValkeyCache): 110 | cache.sadd("{foo}1", "bar1", "bar2", version=2) 111 | cache.sadd("{foo}2", "bar2", "bar3", version=2) 112 | assert cache.sdiffstore("{foo}3", "{foo}1", "{foo}2", version_keys=2) == 1 113 | assert cache.smembers("{foo}3") == {"bar1"} 114 | 115 | def test_sdiffstore_with_different_keys_versions_without_initial_set_in_version( 116 | self, cache: ClusterValkeyCache 117 | ): 118 | cache.sadd("{foo}1", "bar1", "bar2", version=1) 119 | cache.sadd("{foo}2", "bar2", "bar3", version=2) 120 | assert cache.sdiffstore("{foo}3", "{foo}1", "{foo}2", version_keys=2) == 0 121 | 122 | def test_sdiffstore_with_different_keys_versions_with_initial_set_in_version( 123 | self, cache: ClusterValkeyCache 124 | ): 125 | cache.sadd("{foo}1", "bar1", "bar2", version=2) 126 | cache.sadd("{foo}2", "bar2", "bar3", version=1) 127 | assert cache.sdiffstore("{foo}3", "{foo}1", "{foo}2", version_keys=2) == 2 128 | 129 | def test_sinter(self, cache: ClusterValkeyCache): 130 | cache.sadd("{foo}1", "bar1", "bar2") 131 | cache.sadd("{foo}2", "bar2", "bar3") 132 | assert cache.sinter("{foo}1", "{foo}2") == {"bar2"} 133 | 134 | def test_sinterstore(self, cache: ClusterValkeyCache): 135 | cache.sadd("{foo}1", "bar1", "bar2") 136 | cache.sadd("{foo}2", "bar2", "bar3") 137 | assert cache.sinterstore("{foo}3", "{foo}1", "{foo}2") == 1 138 | assert cache.smembers("{foo}3") == {"bar2"} 139 | 140 | def test_smove(self, cache: ClusterValkeyCache): 141 | cache.sadd("{foo}1", "bar1", "bar2") 142 | cache.sadd("{foo}2", "bar2", "bar3") 143 | assert cache.smove("{foo}1", "{foo}2", "bar1") is True 144 | assert cache.smove("{foo}1", "{foo}2", "bar4") is False 145 | assert cache.smembers("{foo}1") == {"bar2"} 146 | assert cache.smembers("{foo}2") == {"bar1", "bar2", "bar3"} 147 | 148 | def test_sunion(self, cache: ClusterValkeyCache): 149 | cache.sadd("{foo}1", "bar1", "bar2") 150 | cache.sadd("{foo}2", "bar2", "bar3") 151 | assert cache.sunion("{foo}1", "{foo}2") == {"bar1", "bar2", "bar3"} 152 | 153 | def test_sunionstore(self, cache: ClusterValkeyCache): 154 | cache.sadd("{foo}1", "bar1", "bar2") 155 | cache.sadd("{foo}2", "bar2", "bar3") 156 | assert cache.sunionstore("{foo}3", "{foo}1", "{foo}2") == 3 157 | assert cache.smembers("{foo}3") == {"bar1", "bar2", "bar3"} 158 | 159 | def test_flushall(self, cache: ClusterValkeyCache): 160 | cache.set("{foo}a", 1) 161 | cache.sadd("{foo}1", "bar1", "bar2") 162 | cache.hset("foo_hash1", "foo1", "bar1") 163 | cache.flushall() 164 | assert not cache.get("{foo}a") 165 | assert cache.smembers("{foo}a") == set() 166 | assert not cache.hexists("foo_hash1", "foo1") 167 | -------------------------------------------------------------------------------- /util/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | # 4 | # Copyright (c) 2016 Giles Hall 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | # this software and associated documentation files (the "Software"), to deal in 9 | # the Software without restriction, including without limitation the rights to 10 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 11 | # of the Software, and to permit persons to whom the Software is furnished to do 12 | # so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | WAITFORIT_cmdname=${0##*/} 26 | 27 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 28 | 29 | usage() 30 | { 31 | cat << USAGE >&2 32 | Usage: 33 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 34 | -h HOST | --host=HOST Host or IP under test 35 | -p PORT | --port=PORT TCP port under test 36 | Alternatively, you specify the host and port as host:port 37 | -s | --strict Only execute subcommand if the test succeeds 38 | -q | --quiet Don't output any status messages 39 | -t TIMEOUT | --timeout=TIMEOUT 40 | Timeout in seconds, zero for no timeout 41 | -- COMMAND ARGS Execute command with args after the test finishes 42 | USAGE 43 | exit 1 44 | } 45 | 46 | wait_for() 47 | { 48 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 49 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 50 | else 51 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 52 | fi 53 | WAITFORIT_start_ts=$(date +%s) 54 | while : 55 | do 56 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 57 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 58 | WAITFORIT_result=$? 59 | else 60 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 61 | WAITFORIT_result=$? 62 | fi 63 | if [[ $WAITFORIT_result -eq 0 ]]; then 64 | WAITFORIT_end_ts=$(date +%s) 65 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 66 | break 67 | fi 68 | sleep 1 69 | done 70 | return $WAITFORIT_result 71 | } 72 | 73 | wait_for_wrapper() 74 | { 75 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 76 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 77 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 78 | else 79 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 80 | fi 81 | WAITFORIT_PID=$! 82 | trap "kill -INT -$WAITFORIT_PID" INT 83 | wait $WAITFORIT_PID 84 | WAITFORIT_RESULT=$? 85 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 86 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 87 | fi 88 | return $WAITFORIT_RESULT 89 | } 90 | 91 | # process arguments 92 | while [[ $# -gt 0 ]] 93 | do 94 | case "$1" in 95 | *:* ) 96 | WAITFORIT_hostport=(${1//:/ }) 97 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 98 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 99 | shift 1 100 | ;; 101 | --child) 102 | WAITFORIT_CHILD=1 103 | shift 1 104 | ;; 105 | -q | --quiet) 106 | WAITFORIT_QUIET=1 107 | shift 1 108 | ;; 109 | -s | --strict) 110 | WAITFORIT_STRICT=1 111 | shift 1 112 | ;; 113 | -h) 114 | WAITFORIT_HOST="$2" 115 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 116 | shift 2 117 | ;; 118 | --host=*) 119 | WAITFORIT_HOST="${1#*=}" 120 | shift 1 121 | ;; 122 | -p) 123 | WAITFORIT_PORT="$2" 124 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 125 | shift 2 126 | ;; 127 | --port=*) 128 | WAITFORIT_PORT="${1#*=}" 129 | shift 1 130 | ;; 131 | -t) 132 | WAITFORIT_TIMEOUT="$2" 133 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 134 | shift 2 135 | ;; 136 | --timeout=*) 137 | WAITFORIT_TIMEOUT="${1#*=}" 138 | shift 1 139 | ;; 140 | --) 141 | shift 142 | WAITFORIT_CLI=("$@") 143 | break 144 | ;; 145 | --help) 146 | usage 147 | ;; 148 | *) 149 | echoerr "Unknown argument: $1" 150 | usage 151 | ;; 152 | esac 153 | done 154 | 155 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 156 | echoerr "Error: you need to provide a host and port to test." 157 | usage 158 | fi 159 | 160 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 161 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 162 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 163 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 164 | 165 | # Check to see if timeout is from busybox? 166 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 167 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 168 | 169 | WAITFORIT_BUSYTIMEFLAG="" 170 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 171 | WAITFORIT_ISBUSY=1 172 | # Check if busybox timeout uses -t flag 173 | # (recent Alpine versions don't support -t anymore) 174 | if timeout &>/dev/stdout | grep -q -e '-t '; then 175 | WAITFORIT_BUSYTIMEFLAG="-t" 176 | fi 177 | else 178 | WAITFORIT_ISBUSY=0 179 | fi 180 | 181 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 182 | wait_for 183 | WAITFORIT_RESULT=$? 184 | exit $WAITFORIT_RESULT 185 | else 186 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 187 | wait_for_wrapper 188 | WAITFORIT_RESULT=$? 189 | else 190 | wait_for 191 | WAITFORIT_RESULT=$? 192 | fi 193 | fi 194 | 195 | if [[ $WAITFORIT_CLI != "" ]]; then 196 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 197 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 198 | exit $WAITFORIT_RESULT 199 | fi 200 | exec "${WAITFORIT_CLI[@]}" 201 | else 202 | exit $WAITFORIT_RESULT 203 | fi 204 | -------------------------------------------------------------------------------- /docs/configure/compressors.md: -------------------------------------------------------------------------------- 1 | # Compressor support 2 | 3 | the default compressor class is `django_valkey.compressors.identity.IdentityCompressor` 4 | 5 | this class doesn't compress the data, it only returns the data as is, but it works as a swappable placeholder if we want to compress data. 6 | 7 | compressors have two common global settings and some of them have a number of their own. 8 | the common settings are: 9 | `CACHE_COMPRESS_LEVEL` which tells the compression tool how much compression to perform. 10 | `CACHE_COMPRESS_MIN_LENGTH` which tells django-valkey how big a data should be to be compressed. 11 | 12 | the library specific settings are listed in their respective sections, you should look at their documentations for more info. 13 | 14 | ### Brotli compression 15 | 16 | to use the brotli compressor you need to install the `brotli` package first 17 | you can do that with: 18 | 19 | ```shell 20 | pip install django-valkey[brotli] 21 | ``` 22 | 23 | or simply 24 | 25 | ```shell 26 | pip install brotli 27 | ``` 28 | 29 | to configure the compressor you should edit you settings files to look like this: 30 | 31 | ```python 32 | CACHES = { 33 | "default": { 34 | # ... 35 | "OPTIONS": { 36 | "COMPRESSOR": "django_valkey.compressors.brotli.BrotliCompressor", 37 | } 38 | } 39 | } 40 | # optionally you can set compression parameters: 41 | CACHE_COMPRESS_LEVEL = 11 # defaults to 11 42 | CACHE_COMPRESS_MIN_LENGTH = 15 # defaults to 15 43 | COMPRESS_BROTLI_LGWIN = 22 # defaults to 22 44 | COMPRESS_BROTLI_LGBLOCK = 0 # defaults to 0 45 | COMPRESS_BROTLI_MODE = "GENERIC" # defaults to "GENERIC" other options are: ("GENERIC", "TEXT", "FONT") 46 | ``` 47 | 48 | *NOTE* the values shown here are only examples and *not* best practice or anything. 49 | 50 | you can read more about this compressor at their [Documentations](https://pypi.org/project/Brotli/) (and source code, for this matter) 51 | 52 | ### Bz2 compression 53 | 54 | the bz2 compression library comes in python's standard library, so if you have a normal python installation just configure it and it's done: 55 | 56 | ```python 57 | CACHES = { 58 | "default": { 59 | # ... 60 | "OPTIONS": { 61 | "COMPRESSOR": "django_valkey.compressors.bz2.Bz2Compressor", 62 | } 63 | } 64 | } 65 | # optionally you can set compression parameters: 66 | CACHE_COMPRESS_LEVEL = 9 # defaults to 9 67 | CACHE_COMPRESS_MIN_LEVEL = 15 # defaults to 15 68 | ``` 69 | 70 | *NOTE* the values shown here are only examples and *not* best practice or anything. 71 | 72 | ### Gzip compression 73 | 74 | the gzip compression library also comes with python's standard library, so configure it like this and you are done: 75 | 76 | ```python 77 | CACHES = { 78 | "default": { 79 | # ... 80 | "OPTIONS": { 81 | "COMPRESSOR": "django_valkey.compressors.gzip.GzipCompressor", 82 | } 83 | } 84 | } 85 | # optionally you can set compression parameters: 86 | CACHE_COMPRESS_LEVEL = 9 # defaults to 9 87 | CACHE_COMPRESS_MIN_LEVEL = 15 # defaults to 15 88 | ``` 89 | 90 | *NOTE* the values shown here are only examples and *not* best practice or anything. 91 | 92 | 93 | ### Lz4 compression 94 | 95 | to use the lz4 compression you need to install the lz4 package first: 96 | 97 | ```shell 98 | pip install django-valkey[lz4] 99 | ``` 100 | 101 | or simply 102 | 103 | ```shell 104 | pip install lz4 105 | ``` 106 | 107 | then you can configure it like this: 108 | 109 | ```python 110 | CACHES = { 111 | "default": { 112 | # ... 113 | "OPTIONS": { 114 | "COMPRESSOR": "django_valkey.compressors.lz4.Lz4Compressor", 115 | } 116 | } 117 | } 118 | # optionally you can set compression parameters: 119 | CACHE_COMPRESS_LEVEL = 0 # defaults to 0 120 | CACHE_COMPRESS_MIN_LEVEL = 15 # defaults to 15 121 | 122 | COMPRESS_LZ4_BLOCK_SIZE = 0 # defaults to 0 123 | COMPRESS_LZ4_CONTENT_CHECKSUM = 0 # defaults to 0 124 | COMPRESS_LZ4_BLOCK_LINKED = True # defaults to True 125 | COMPRESS_LZ4_STORE_SIZE = True # defaults to True 126 | ``` 127 | 128 | *NOTE* the values shown here are only examples and *not* best practice or anything. 129 | 130 | 131 | ### Lzma compression 132 | 133 | lzma compression library also comes with python's standard library 134 | 135 | ```python 136 | CACHES = { 137 | "default": { 138 | # ... 139 | "OPTIONS": { 140 | "COMPRESSOR": "django_valkey.compressors.lzma.LzmaCompressor", 141 | } 142 | } 143 | } 144 | # optionally you can set compression parameters: 145 | CACHE_COMPRESS_LEVEL = 9 # defaults to 4 146 | CACHE_COMPRESS_MIN_LEVEL = 15 # defaults to 15 147 | 148 | COMPRESS_LZMA_FORMAT = 1 # defaults to 1 149 | COMPRESS_LZMA_CHECK = -1 # defaults to -1 150 | COMPRESS_LZMA_FILTERS = None # defaults to None 151 | 152 | # optional decompression parameters 153 | DECOMPRESS_LZMA_MEMLIMIT = None # defaults to None (if you want to change this, make sure you read lzma docs about it's dangers) 154 | DECOMPRESS_LZMA_FORMAT = 0 # defaults to 4 155 | DECOMPERSS_LZMA_FILTERS = None # defaults to None 156 | ``` 157 | 158 | *NOTE* the values shown here are only examples and *not* best practice or anything. 159 | 160 | ### Zlib compression 161 | 162 | zlib compression library also comes with python's standard library 163 | 164 | ```python 165 | CACHES = { 166 | "default": { 167 | # ... 168 | "OPTIONS": { 169 | "COMPRESSOR": "django_valkey.compressors.zlib.ZlibCompressor", 170 | } 171 | } 172 | } 173 | # optionally you can set compression parameters: 174 | CACHE_COMPRESS_LEVEL = 9 # defaults to 6 175 | CACHE_COMPRESS_MIN_LEVEL = 15 # defaults to 15 176 | 177 | compress_zlib_wbits = 15 # defaults to 15 (NOTE: only available in python 3.11 and newer 178 | ``` 179 | 180 | *NOTE* the values shown here are only examples and *not* best practice or anything. 181 | 182 | 183 | ### Zstd compression 184 | 185 | as of `django-valkey` 0.4.0, the zstd library from python stdlib is used instead of `pyzstd`. 186 | 187 | if you are using python 3.14 or above, you don't need to install anything, 188 | otherwise, you need to install the `backports.zstd` library: 189 | 190 | ```shell 191 | pip install django-valkey[zstd] 192 | # or 193 | pip install django-valkey[pyzstd] # this is for backwards compatibility 194 | ``` 195 | 196 | or 197 | 198 | ```shell 199 | pip install backports.zstd 200 | ``` 201 | 202 | then you can configure it as such: 203 | 204 | ```python 205 | CACHES = { 206 | "default": { 207 | # ... 208 | "OPTIONS": { 209 | "COMPRESSOR": "django_valkey.compressors.zstd.ZStdCompressor", 210 | } 211 | } 212 | } 213 | # optionally you can set compression parameters: 214 | CACHE_COMPRESS_LEVEL = 1 # defaults to 1 215 | CACHE_COMPRESS_MIN_LEVEL = 15 # defaults to 15 216 | 217 | # the below settings are all defaulted to None 218 | COMPRESS_ZSTD_OPTIONS = {...} # if you set this, `CACHE_COMPRESS_LEVEL` will be ignored. 219 | DECOMPRESS_ZSTD_OPTIONS = {...} # note: if you don't set this, the above one will be used. 220 | COMPRESS_ZSTD_DICT = {...} 221 | DECOMPRESS_ZSTD_DICT = {...} # note: if you don't set this, the above one will be used. 222 | ``` 223 | 224 | *NOTE* the values shown here are only examples and *not* best practice or anything. 225 | --------------------------------------------------------------------------------