diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..dabead29 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66440eb4..30b52ba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,49 +15,46 @@ jobs: fail-fast: false matrix: python-version: - - '3.6' - - '3.7' - - '3.8' - - '3.9' - '3.10' + - '3.11' django-version: - - '2.2' - - '3.1' - - '3.2' - - '4.0' + - '4.2' + - '5.0' redis-version: - 'latest' - # only test pre-release dependencies for the latest Python + # Only test pre-release dependencies for the latest Python. include: + # Django 4.2 and python 3.8 with latest redis + - django-version: '4.2' + redis-version: 'latest' + python-version: '3.8' + + # Django 4.2 and python 3.9 with latest redis + - django-version: '4.2' + redis-version: 'latest' + python-version: '3.9' + # latest Django with pre-release redis - - django-version: '4.0' + - django-version: '5.0' redis-version: 'master' - python-version: '3.10' + python-version: '3.11' # latest redis with pre-release Django - django-version: 'main' redis-version: 'latest' - python-version: '3.10' + python-version: '3.11' # pre-release Django and redis - django-version: 'main' redis-version: 'master' - python-version: '3.10' - - # exclude python 3.6 and 3.7 for django 4.x as they are not supported see https://docs.djangoproject.com/en/dev/releases/4.0/#python-compatibility - exclude: - - django-version: '4.0' - python-version: '3.6' - - - django-version: '4.0' - python-version: '3.7' + python-version: '3.11' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -67,7 +64,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: pip-test-python-${{ matrix.python-version }}-django-${{ matrix.django-version }}-redis-${{ matrix.redis-version }}-${{ hashFiles('**/setup.*') }} @@ -96,7 +93,7 @@ jobs: REDIS: ${{ matrix.redis-version }} - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: env_vars: DJANGO,REDIS,PYTHON flags: tests @@ -114,15 +111,14 @@ jobs: matrix: tool: - 'black' - - 'flake8' - - 'isort' + - 'ruff' - 'mypy' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -132,7 +128,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: pip-lint-${{ hashFiles('**/setup.*') }} @@ -142,14 +138,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox + python -m pip install --upgrade "tox<4" - name: Run ${{ matrix.tool }} run: tox -e ${{ matrix.tool }} - name: Upload coverage if: ${{ matrix.tool == 'mypy' }} - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: flags: mypy @@ -158,12 +154,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -173,7 +169,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: pip-check-changelog-${{ hashFiles('**/setup.*') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05640c22..b592707c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.8 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4215c65d..ab501954 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.6.0 hooks: - id: check-ast - id: check-case-conflict @@ -9,22 +9,15 @@ repos: - id: check-symlinks - id: debug-statements -- repo: https://github.com/asottile/pyupgrade - rev: v2.32.0 +# from readme - ruff with autofix must run before +# other formatters, such as black +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.8 hooks: - - id: pyupgrade + - id: ruff + args: [ --fix, --exit-non-zero-on-fix , --show-fixes] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 24.4.2 hooks: - id: black - -- repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 - -- repo: https://github.com/PyCQA/isort - rev: 5.10.1 - hooks: - - id: isort diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..80747f8e --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,137 @@ +# https://beta.ruff.rs/docs/rules/ +lint.select = [ + # rules from pyflakes + "F", + + # rules from pycodestyle + "E", "W", + + # rules from mccabe + "C90", + + # rules from isort + "I", + + # rules from pyupgrade + "UP", + + # rules from flake8-2020 + "YTT", + + # rules from flake8-annotations +# "ANN", + + # rules from flake8-bandit + "S", + + # rules from flake8-blind-except + "BLE", + + # rules from flake8-boolean-trap + # TODO: "FBT", + + # rules from flake8-bugbear + "B", + + # rules from flake8-builtins + "A", + + # rules from flake8-commas + "COM", + + # rules from flake8-comprehensions + "C4", + + # rules from flake8-datetimez + # TODO: "DTZ", + + # rules from flake8-debugger + "T10", + + # rules from flake8-django + "DJ", + + # rules from flake8-errmsg + "EM", + + # rules from flake8-executable + "EXE", + + # rules from flake8-implicit-str-concat + "ISC", + + # rules from flake8-import-conventions + "ICN", + + # rules from flake8-logging-format + "G", + + # rules from flake8-no-pep420 + "INP", + + # rules from flake8-pie + # TODO: "PIE", + + # rules from flake8-print + "T20", + + # rules from flake8-pyi + "PYI", + + # rules from flake8-pytest-style + # TODO: "PT", + + # rules from flake8-raise + "RSE", + + # rules from flake8-return + "RET", + + # rules from flake8-self + # TODO: "SLF", + + # rules from flake8-simplify + "SIM", + + # rules from flake8-tidy-imports + "TID", + + # rules from flake8-type-checking + "TCH", + + # rules from flake8-gettext + "INT", + + # rules from flake8-unused-arguments + # TODO: "ARG", + + # rules from flake8-use-pathlib + "PTH", + + # removes unused noqa comments + "RUF100", +] + +lint.ignore = [ + "COM812", # missing trailing comma, covered by black + "ANN101", # ignore missing type annotation in self parameter + "S311", # ignore Standard pseudo-random generators because they are not used for cryptographic purposes +] + +fix = true + +target-version = "py38" + +[lint.flake8-tidy-imports] +## Disallow all relative imports. +ban-relative-imports = "all" + +[lint.per-file-ignores] +# ignore assert statements in tests +"tests/*.py" = ["S101"] + +# ignore SECRET_KEY in settings files in tests +"tests/settings/*.py" = ["S105"] + +# pickle is used on purpose and its use is discouraged +"django_redis/serializers/pickle.py" = ["S301"] diff --git a/AUTHORS.rst b/AUTHORS.rst index 12f25c6d..463d543b 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,10 +1,10 @@ Andrei Antoukh / niwibe -Sean Bleier -Matt Dennewitz -Jannis Leidel -S. Angel / Twidi -Noah Kantrowitz / coderanger -Martin Mahner / bartTC +Sean Bleier +Matt Dennewitz +Jannis Leidel +S. Angel / Twidi +Noah Kantrowitz / coderanger +Martin Mahner / bartTC Timothée Peignier / cyberdelia Lior Sion / liorsion Ales Zoulek / aleszoulek @@ -14,3 +14,4 @@ David Zderic / dzderic Kirill Zaitsev / teferi Jon Dufresne Anès Foufa +Segyo Myung \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9385e3e4..8e865f4a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,49 @@ Changelog .. towncrier release notes start +django-redis 5.4.0 (2023-10-01) +=============================== + +Features +-------- + +- Connection factory goes to cache options (`#680 `_) + + +Documentation +------------- + +- Added note in docs for correctly configuring hiredis parser when using redis-py version 5. (`#677 `_) + + +django-redis 5.3.0 (2023-06-16) +=============================== + +Features +-------- + +- Add support for django 4 (`#627 `_) + + +Bug Fixes +--------- + +- Access `django_redis.cache.DJANGO_REDIS_SCAN_ITERSIZE` and `django_redis.client.herd.CACHE_HERD_TIMEOUT` in runtime to not read Django settings in import time. (`#638 `_) +- Skipping pickle serializer test for django >= 4.2 (`#646 `_) + + +Miscellaneous +------------- + +- Speed up deleting multiple keys by a pattern with pipelines and larger itersize (`#609 `_) +- Print full exception traceback when logging ignored exceptions (`#611 `_) +- Fix mypy linting (`#626 `_) +- Added support for python 3.11 (`#633 `_) +- Fix CI, running tox<4 to still support Python 3.6. (`#645 `_) +- Dropped support for django 2.2 and 3.1 (`#649 `_) +- Run actions & tox against Django 4..2 (`#668 `_) + + django-redis 5.2.0 (2021-12-22) =============================== diff --git a/README.rst b/README.rst index 266780c4..03d6cf30 100644 --- a/README.rst +++ b/README.rst @@ -291,6 +291,21 @@ Let see an example, of how make it work with *lzma* compression format: } } +*Gzip* compression support: + +.. code-block:: python + + import gzip + + CACHES = { + "default": { + # ... + "OPTIONS": { + "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", + } + } + } + Memcached exceptions behavior ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -483,6 +498,15 @@ pattern syntax as the ``keys`` function and returns the number of deleted keys. >>> from django.core.cache import cache >>> cache.delete_pattern("foo_*") +To achieve the best performance while deleting many keys, you should set ``DJANGO_REDIS_SCAN_ITERSIZE`` to a relatively +high number (e.g., 100_000) by default in Django settings or pass it directly to the ``delete_pattern``. + + +.. code-block:: pycon + + >>> from django.core.cache import cache + >>> cache.delete_pattern("foo_*", itersize=100_000) + Redis native commands ~~~~~~~~~~~~~~~~~~~~~ @@ -727,6 +751,35 @@ In order to enable this functionality you should add the following: }, } +It is also possible to set some caches as sentinels and some as not: + +.. code-block:: python + + SENTINELS = [ + ('sentinel-1', 26379), + ('sentinel-2', 26379), + ('sentinel-3', 26379), + ] + CACHES = { + "sentinel": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://service_name/db", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.SentinelClient", + "SENTINELS": SENTINELS, + "CONNECTION_POOL_CLASS": "redis.sentinel.SentinelConnectionPool", + "CONNECTION_FACTORY": "django_redis.pool.SentinelConnectionFactory", + }, + }, + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + }, + } + .. _Redis Sentinels: https://redis.io/topics/sentinel Pluggable parsers @@ -745,6 +798,8 @@ used with django-redis. "PARSER_CLASS": "redis.connection.HiredisParser", } +Note: if using version 5 of redis-py, use ``"redis.connection._HiredisParser"`` for the ``PARSER_CLASS`` due to an internal rename of classes within that package. + Pluggable clients ~~~~~~~~~~~~~~~~~ @@ -802,7 +857,7 @@ Herd client ^^^^^^^^^^^ This pluggable client helps dealing with the thundering herd problem. You can read more about it -on link: `Wikipedia `_ +on link: `Wikipedia `_ Like previous pluggable clients, it inherits all functionality from the default client, adding some additional methods for getting/setting keys. @@ -862,7 +917,7 @@ msgpack library): } } -.. _MsgPack: http://msgpack.org/ +.. _MsgPack: https://msgpack.org/ Pluggable Redis client ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/changelog.d/598.feature b/changelog.d/598.feature new file mode 100644 index 00000000..dfae9a2a --- /dev/null +++ b/changelog.d/598.feature @@ -0,0 +1 @@ +Support HashMaps \ No newline at end of file diff --git a/changelog.d/688.feature b/changelog.d/688.feature new file mode 100644 index 00000000..ca7fe07d --- /dev/null +++ b/changelog.d/688.feature @@ -0,0 +1 @@ +Support gzip compression \ No newline at end of file diff --git a/changelog.d/690.misc b/changelog.d/690.misc new file mode 100644 index 00000000..2431c0cd --- /dev/null +++ b/changelog.d/690.misc @@ -0,0 +1 @@ +Pin pytest to <7.0 until compatibility issues are resolved \ No newline at end of file diff --git a/changelog.d/692.misc b/changelog.d/692.misc new file mode 100644 index 00000000..2083a1a7 --- /dev/null +++ b/changelog.d/692.misc @@ -0,0 +1 @@ +Replace isort and flake8 with ruff \ No newline at end of file diff --git a/changelog.d/693.misc b/changelog.d/693.misc new file mode 100644 index 00000000..64c724d3 --- /dev/null +++ b/changelog.d/693.misc @@ -0,0 +1 @@ +Drop django 4.0 \ No newline at end of file diff --git a/changelog.d/695.misc b/changelog.d/695.misc new file mode 100644 index 00000000..3b1b4601 --- /dev/null +++ b/changelog.d/695.misc @@ -0,0 +1 @@ +Upgrade black to 23.10.1 \ No newline at end of file diff --git a/changelog.d/696.misc b/changelog.d/696.misc new file mode 100644 index 00000000..066426d6 --- /dev/null +++ b/changelog.d/696.misc @@ -0,0 +1 @@ +Typed DefaultClient \ No newline at end of file diff --git a/changelog.d/697.misc b/changelog.d/697.misc new file mode 100644 index 00000000..dd57591c --- /dev/null +++ b/changelog.d/697.misc @@ -0,0 +1 @@ +Support pytest>=7 \ No newline at end of file diff --git a/changelog.d/699.misc b/changelog.d/699.misc new file mode 100644 index 00000000..0aaebb99 --- /dev/null +++ b/changelog.d/699.misc @@ -0,0 +1 @@ +Drop support for django 3.2, python 3.6 and python 3.7 \ No newline at end of file diff --git a/changelog.d/701.misc b/changelog.d/701.misc new file mode 100644 index 00000000..89180a32 --- /dev/null +++ b/changelog.d/701.misc @@ -0,0 +1 @@ +Support tox 4 \ No newline at end of file diff --git a/changelog.d/702.misc b/changelog.d/702.misc new file mode 100644 index 00000000..631a01c3 --- /dev/null +++ b/changelog.d/702.misc @@ -0,0 +1 @@ +Configured dependabot for github actions \ No newline at end of file diff --git a/changelog.d/703.misc b/changelog.d/703.misc new file mode 100644 index 00000000..fc52b72f --- /dev/null +++ b/changelog.d/703.misc @@ -0,0 +1 @@ +Use ubuntu-latest for CI \ No newline at end of file diff --git a/changelog.d/724.bugfix b/changelog.d/724.bugfix new file mode 100644 index 00000000..36ff0f14 --- /dev/null +++ b/changelog.d/724.bugfix @@ -0,0 +1 @@ +Hotfix for timeout=DEFAULT_TIMEOUT in expire and pexpire \ No newline at end of file diff --git a/changelog.d/729.misc b/changelog.d/729.misc new file mode 100644 index 00000000..09df626f --- /dev/null +++ b/changelog.d/729.misc @@ -0,0 +1 @@ +Dropped support for django 4.1 and added support for django 5.0 \ No newline at end of file diff --git a/changelog.d/730.feature b/changelog.d/730.feature new file mode 100644 index 00000000..d41ae639 --- /dev/null +++ b/changelog.d/730.feature @@ -0,0 +1 @@ +Support for sets and support basic operations, sadd, scard, sdiff, sdiffstore, sinter, sinterstore, smismember, sismember, smembers, smove, spop, srandmember, srem, sscan, sscan_iter, sunion, sunionstore \ No newline at end of file diff --git a/django_redis/__init__.py b/django_redis/__init__.py index 108780d6..579f1d44 100644 --- a/django_redis/__init__.py +++ b/django_redis/__init__.py @@ -1,4 +1,4 @@ -VERSION = (5, 2, 0) +VERSION = (5, 4, 0) __version__ = ".".join(map(str, VERSION)) @@ -11,10 +11,11 @@ def get_redis_connection(alias="default", write=True): cache = caches[alias] + error_message = "This backend does not support this feature" if not hasattr(cache, "client"): - raise NotImplementedError("This backend does not support this feature") + raise NotImplementedError(error_message) if not hasattr(cache.client, "get_client"): - raise NotImplementedError("This backend does not support this feature") + raise NotImplementedError(error_message) return cache.client.get_client(write) diff --git a/django_redis/cache.py b/django_redis/cache.py index d3e8708d..f7b943a3 100644 --- a/django_redis/cache.py +++ b/django_redis/cache.py @@ -7,9 +7,7 @@ from django.core.cache.backends.base import BaseCache from django.utils.module_loading import import_string -from .exceptions import ConnectionInterrupted - -DJANGO_REDIS_SCAN_ITERSIZE = getattr(settings, "DJANGO_REDIS_SCAN_ITERSIZE", 10) +from django_redis.exceptions import ConnectionInterrupted CONNECTION_INTERRUPTED = object() @@ -32,10 +30,10 @@ def _decorator(self, *args, **kwargs): except ConnectionInterrupted as e: if self._ignore_exceptions: if self._log_ignored_exceptions: - self.logger.error(str(e)) + self.logger.exception("Exception ignored") return return_value - raise e.__cause__ + raise e.__cause__ # noqa: B904 return _decorator @@ -45,6 +43,9 @@ def __init__(self, server: str, params: Dict[str, Any]) -> None: super().__init__(params) self._server = server self._params = params + self._default_scan_itersize = getattr( + settings, "DJANGO_REDIS_SCAN_ITERSIZE", 10 + ) options = params.get("OPTIONS", {}) self._client_cls = options.get( @@ -105,7 +106,7 @@ def delete(self, *args, **kwargs): @omit_exception def delete_pattern(self, *args, **kwargs): - kwargs["itersize"] = kwargs.get("itersize", DJANGO_REDIS_SCAN_ITERSIZE) + kwargs.setdefault("itersize", self._default_scan_itersize) return self.client.delete_pattern(*args, **kwargs) @omit_exception @@ -183,3 +184,91 @@ def close(self, **kwargs): @omit_exception def touch(self, *args, **kwargs): return self.client.touch(*args, **kwargs) + + @omit_exception + def sadd(self, *args, **kwargs): + return self.client.sadd(*args, **kwargs) + + @omit_exception + def scard(self, *args, **kwargs): + return self.client.scard(*args, **kwargs) + + @omit_exception + def sdiff(self, *args, **kwargs): + return self.client.sdiff(*args, **kwargs) + + @omit_exception + def sdiffstore(self, *args, **kwargs): + return self.client.sdiffstore(*args, **kwargs) + + @omit_exception + def sinter(self, *args, **kwargs): + return self.client.sinter(*args, **kwargs) + + @omit_exception + def sinterstore(self, *args, **kwargs): + return self.client.sinterstore(*args, **kwargs) + + @omit_exception + def sismember(self, *args, **kwargs): + return self.client.sismember(*args, **kwargs) + + @omit_exception + def smembers(self, *args, **kwargs): + return self.client.smembers(*args, **kwargs) + + @omit_exception + def smove(self, *args, **kwargs): + return self.client.smove(*args, **kwargs) + + @omit_exception + def spop(self, *args, **kwargs): + return self.client.spop(*args, **kwargs) + + @omit_exception + def srandmember(self, *args, **kwargs): + return self.client.srandmember(*args, **kwargs) + + @omit_exception + def srem(self, *args, **kwargs): + return self.client.srem(*args, **kwargs) + + @omit_exception + def sscan(self, *args, **kwargs): + return self.client.sscan(*args, **kwargs) + + @omit_exception + def sscan_iter(self, *args, **kwargs): + return self.client.sscan_iter(*args, **kwargs) + + @omit_exception + def smismember(self, *args, **kwargs): + return self.client.smismember(*args, **kwargs) + + @omit_exception + def sunion(self, *args, **kwargs): + return self.client.sunion(*args, **kwargs) + + @omit_exception + def sunionstore(self, *args, **kwargs): + return self.client.sunionstore(*args, **kwargs) + + @omit_exception + def hset(self, *args, **kwargs): + return self.client.hset(*args, **kwargs) + + @omit_exception + def hdel(self, *args, **kwargs): + return self.client.hdel(*args, **kwargs) + + @omit_exception + def hlen(self, *args, **kwargs): + return self.client.hlen(*args, **kwargs) + + @omit_exception + def hkeys(self, *args, **kwargs): + return self.client.hkeys(*args, **kwargs) + + @omit_exception + def hexists(self, *args, **kwargs): + return self.client.hexists(*args, **kwargs) diff --git a/django_redis/client/__init__.py b/django_redis/client/__init__.py index ba40c4a8..d8e7fe54 100644 --- a/django_redis/client/__init__.py +++ b/django_redis/client/__init__.py @@ -1,6 +1,6 @@ -from .default import DefaultClient -from .herd import HerdClient -from .sentinel import SentinelClient -from .sharded import ShardClient +from django_redis.client.default import DefaultClient +from django_redis.client.herd import HerdClient +from django_redis.client.sentinel import SentinelClient +from django_redis.client.sharded import ShardClient __all__ = ["DefaultClient", "HerdClient", "SentinelClient", "ShardClient"] diff --git a/django_redis/client/default.py b/django_redis/client/default.py index 1df90a27..1c34e5ac 100644 --- a/django_redis/client/default.py +++ b/django_redis/client/default.py @@ -2,8 +2,19 @@ import re import socket from collections import OrderedDict -from datetime import datetime -from typing import Any, Dict, Iterator, List, Optional, Union +from contextlib import suppress +from typing import ( + Any, + Dict, + Iterable, + Iterator, + List, + Optional, + Set, + Tuple, + Union, + cast, +) from django.conf import settings from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache, get_key_func @@ -11,10 +22,11 @@ from django.utils.module_loading import import_string from redis import Redis from redis.exceptions import ConnectionError, ResponseError, TimeoutError +from redis.typing import AbsExpiryT, EncodableT, ExpiryT, KeyT, PatternT -from .. import pool -from ..exceptions import CompressorError, ConnectionInterrupted -from ..util import CacheKey +from django_redis import pool +from django_redis.exceptions import CompressorError, ConnectionInterrupted +from django_redis.util import CacheKey _main_exceptions = (TimeoutError, ResponseError, ConnectionError, socket.timeout) @@ -37,7 +49,8 @@ def __init__(self, server, params: Dict[str, Any], backend: BaseCache) -> None: ) if not self._server: - raise ImproperlyConfigured("Missing connections string") + error_message = "Missing connections string" + raise ImproperlyConfigured(error_message) if not isinstance(self._server, (list, tuple, set)): self._server = self._server.split(",") @@ -61,9 +74,17 @@ def __init__(self, server, params: Dict[str, Any], backend: BaseCache) -> None: self.connection_factory = pool.get_connection_factory(options=self._options) - def __contains__(self, key: Any) -> bool: + def __contains__(self, key: KeyT) -> bool: return self.has_key(key) + def _has_compression_enabled(self) -> bool: + return ( + self._options.get( + "COMPRESSOR", "django_redis.compressors.identity.IdentityCompressor" + ) + != "django_redis.compressors.identity.IdentityCompressor" + ) + def get_next_client_index( self, write: bool = True, tried: Optional[List[int]] = None ) -> int: @@ -75,7 +96,7 @@ def get_next_client_index( behavior. """ if tried is None: - tried = list() + tried = [] if tried and len(tried) < len(self._server): not_tried = [i for i in range(0, len(self._server)) if i not in tried] @@ -90,8 +111,7 @@ def get_client( self, write: bool = True, tried: Optional[List[int]] = None, - show_index: bool = False, - ): + ) -> Redis: """ Method used for obtain a raw redis client. @@ -104,10 +124,26 @@ def get_client( if self._clients[index] is None: self._clients[index] = self.connect(index) - if show_index: - return self._clients[index], index - else: - return self._clients[index] + return self._clients[index] # type:ignore + + def get_client_with_index( + self, + write: bool = True, + tried: Optional[List[int]] = None, + ) -> Tuple[Redis, int]: + """ + Method used for obtain a raw redis client. + + This function is used by almost all cache backend + operations for obtain a native redis client/connection + instance. + """ + index = self.get_next_client_index(write=write, tried=tried) + + if self._clients[index] is None: + self._clients[index] = self.connect(index) + + return self._clients[index], index # type:ignore def connect(self, index: int = 0) -> Redis: """ @@ -117,16 +153,20 @@ def connect(self, index: int = 0) -> Redis: """ return self.connection_factory.connect(self._server[index]) - def disconnect(self, index=0, client=None): - """delegates the connection factory to disconnect the client""" - if not client: + def disconnect(self, index: int = 0, client: Optional[Redis] = None) -> None: + """ + delegates the connection factory to disconnect the client + """ + if client is None: client = self._clients[index] - return self.connection_factory.disconnect(client) if client else None + + if client is not None: + self.connection_factory.disconnect(client) def set( self, - key: Any, - value: Any, + key: KeyT, + value: EncodableT, timeout: Optional[float] = DEFAULT_TIMEOUT, version: Optional[int] = None, client: Optional[Redis] = None, @@ -150,9 +190,7 @@ def set( while True: try: if client is None: - client, index = self.get_client( - write=True, tried=tried, show_index=True - ) + client, index = self.get_client_with_index(write=True, tried=tried) if timeout is not None: # Convert to milliseconds @@ -164,13 +202,11 @@ def set( # not expire (in our case delete) the value if it exists. # Obviously expire not existent value is noop. return not self.has_key(key, version=version, client=client) - else: - # redis doesn't support negative timeouts in ex flags - # so it seems that it's better to just delete the key - # than to set it and than expire in a pipeline - return bool( - self.delete(key, client=client, version=version) - ) + + # redis doesn't support negative timeouts in ex flags + # so it seems that it's better to just delete the key + # than to set it and than expire in a pipeline + return bool(self.delete(key, client=client, version=version)) return bool(client.set(nkey, nvalue, nx=nx, px=timeout, xx=xx)) except _main_exceptions as e: @@ -186,7 +222,7 @@ def set( def incr_version( self, - key: Any, + key: KeyT, delta: int = 1, version: Optional[int] = None, client: Optional[Redis] = None, @@ -211,7 +247,8 @@ def incr_version( raise ConnectionInterrupted(connection=client) from e if value is None: - raise ValueError("Key '%s' not found" % key) + error_message = f"Key '{key!r}' not found" + raise ValueError(error_message) if isinstance(key, CacheKey): new_key = self.make_key(key.original_key(), version=version + delta) @@ -224,10 +261,10 @@ def incr_version( def add( self, - key: Any, - value: Any, - timeout: Any = DEFAULT_TIMEOUT, - version: Optional[Any] = None, + key: KeyT, + value: EncodableT, + timeout: Optional[float] = DEFAULT_TIMEOUT, + version: Optional[int] = None, client: Optional[Redis] = None, ) -> bool: """ @@ -239,8 +276,8 @@ def add( def get( self, - key: Any, - default=None, + key: KeyT, + default: Optional[Any] = None, version: Optional[int] = None, client: Optional[Redis] = None, ) -> Any: @@ -265,7 +302,7 @@ def get( return self.decode(value) def persist( - self, key: Any, version: Optional[int] = None, client: Optional[Redis] = None + self, key: KeyT, version: Optional[int] = None, client: Optional[Redis] = None ) -> bool: if client is None: client = self.get_client(write=True) @@ -276,19 +313,33 @@ def persist( def expire( self, - key: Any, - timeout, + key: KeyT, + timeout: ExpiryT, version: Optional[int] = None, client: Optional[Redis] = None, ) -> bool: + if timeout is DEFAULT_TIMEOUT: + timeout = self._backend.default_timeout # type: ignore + if client is None: client = self.get_client(write=True) key = self.make_key(key, version=version) - return client.expire(key, timeout) + # for some strange reason mypy complains, + # saying that timeout type is float | timedelta + return client.expire(key, timeout) # type: ignore + + def pexpire( + self, + key: KeyT, + timeout: ExpiryT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> bool: + if timeout is DEFAULT_TIMEOUT: + timeout = self._backend.default_timeout # type: ignore - def pexpire(self, key, timeout, version=None, client=None) -> bool: if client is None: client = self.get_client(write=True) @@ -296,12 +347,14 @@ def pexpire(self, key, timeout, version=None, client=None) -> bool: # Temporary casting until https://github.com/redis/redis-py/issues/1664 # is fixed. - return bool(client.pexpire(key, timeout)) + # for some strange reason mypy complains, + # saying that timeout type is float | timedelta + return bool(client.pexpire(key, timeout)) # type: ignore def pexpire_at( self, - key: Any, - when: Union[datetime, int], + key: KeyT, + when: AbsExpiryT, version: Optional[int] = None, client: Optional[Redis] = None, ) -> bool: @@ -318,8 +371,8 @@ def pexpire_at( def expire_at( self, - key: Any, - when: Union[datetime, int], + key: KeyT, + when: AbsExpiryT, version: Optional[int] = None, client: Optional[Redis] = None, ) -> bool: @@ -336,13 +389,14 @@ def expire_at( def lock( self, - key, + key: KeyT, version: Optional[int] = None, - timeout=None, - sleep=0.1, - blocking_timeout=None, + timeout: Optional[float] = None, + sleep: float = 0.1, + blocking: bool = True, + blocking_timeout: Optional[float] = None, client: Optional[Redis] = None, - thread_local=True, + thread_local: bool = True, ): if client is None: client = self.get_client(write=True) @@ -352,13 +406,14 @@ def lock( key, timeout=timeout, sleep=sleep, + blocking=blocking, blocking_timeout=blocking_timeout, thread_local=thread_local, ) def delete( self, - key: Any, + key: KeyT, version: Optional[int] = None, prefix: Optional[str] = None, client: Optional[Redis] = None, @@ -393,16 +448,23 @@ def delete_pattern( try: count = 0 + pipeline = client.pipeline() + for key in client.scan_iter(match=pattern, count=itersize): - client.delete(key) + pipeline.delete(key) count += 1 + pipeline.execute() + return count except _main_exceptions as e: raise ConnectionInterrupted(connection=client) from e def delete_many( - self, keys, version: Optional[int] = None, client: Optional[Redis] = None - ): + self, + keys: Iterable[KeyT], + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: """ Remove multiple keys at once. """ @@ -413,7 +475,7 @@ def delete_many( keys = [self.make_key(k, version=version) for k in keys] if not keys: - return + return 0 try: return client.delete(*keys) @@ -433,35 +495,46 @@ def clear(self, client: Optional[Redis] = None) -> None: except _main_exceptions as e: raise ConnectionInterrupted(connection=client) from e - def decode(self, value: Union[bytes, int]) -> Any: + def decode(self, value: EncodableT) -> Any: """ Decode the given value. """ try: value = int(value) except (ValueError, TypeError): - try: + # Handle little values, chosen to be not compressed + with suppress(CompressorError): value = self._compressor.decompress(value) - except CompressorError: - # Handle little values, chosen to be not compressed - pass value = self._serializer.loads(value) return value - def encode(self, value: Any) -> Union[bytes, Any]: + def encode(self, value: EncodableT) -> Union[bytes, int]: """ Encode the given value. """ if isinstance(value, bool) or not isinstance(value, int): value = self._serializer.dumps(value) - value = self._compressor.compress(value) - return value + return self._compressor.compress(value) return value + def _decode_iterable_result( + self, result: Any, covert_to_set: bool = True + ) -> Union[List[Any], None, Any]: + if result is None: + return None + if isinstance(result, list): + if covert_to_set: + return {self.decode(value) for value in result} + return [self.decode(value) for value in result] + return self.decode(result) + def get_many( - self, keys, version: Optional[int] = None, client: Optional[Redis] = None + self, + keys: Iterable[KeyT], + version: Optional[int] = None, + client: Optional[Redis] = None, ) -> OrderedDict: """ Retrieve many keys. @@ -490,7 +563,7 @@ def get_many( def set_many( self, - data: Dict[Any, Any], + data: Dict[KeyT, EncodableT], timeout: Optional[float] = DEFAULT_TIMEOUT, version: Optional[int] = None, client: Optional[Redis] = None, @@ -515,7 +588,7 @@ def set_many( def _incr( self, - key: Any, + key: KeyT, delta: int = 1, version: Optional[int] = None, client: Optional[Redis] = None, @@ -544,8 +617,9 @@ def _incr( """ value = client.eval(lua, 1, key, delta) if value is None: - raise ValueError("Key '%s' not found" % key) - except ResponseError: + error_message = f"Key '{key!r}' not found" + raise ValueError(error_message) + except ResponseError as e: # if cached value or total value is greater than 64 bit signed # integer. # elif int is encoded. so redis sees the data as string. @@ -557,7 +631,8 @@ def _incr( # returns -2 if the key does not exist # means, that key have expired if timeout == -2: - raise ValueError("Key '%s' not found" % key) + error_message = f"Key '{key!r}' not found" + raise ValueError(error_message) from e value = self.get(key, version=version, client=client) + delta self.set(key, value, version=version, timeout=timeout, client=client) except _main_exceptions as e: @@ -567,7 +642,7 @@ def _incr( def incr( self, - key: Any, + key: KeyT, delta: int = 1, version: Optional[int] = None, client: Optional[Redis] = None, @@ -588,7 +663,7 @@ def incr( def decr( self, - key: Any, + key: KeyT, delta: int = 1, version: Optional[int] = None, client: Optional[Redis] = None, @@ -600,7 +675,7 @@ def decr( return self._incr(key=key, delta=-delta, version=version, client=client) def ttl( - self, key: Any, version: Optional[int] = None, client: Optional[Redis] = None + self, key: KeyT, version: Optional[int] = None, client: Optional[Redis] = None ) -> Optional[int]: """ Executes TTL redis command and return the "time-to-live" of specified key. @@ -617,15 +692,17 @@ def ttl( if t >= 0: return t - elif t == -1: + if t == -1: return None - elif t == -2: + if t == -2: return 0 - else: - # Should never reach here - return None - def pttl(self, key, version=None, client=None): + # Should never reach here + return None + + def pttl( + self, key: KeyT, version: Optional[int] = None, client: Optional[Redis] = None + ) -> Optional[int]: """ Executes PTTL redis command and return the "time-to-live" of specified key. If key is a non volatile key, it returns None. @@ -641,16 +718,16 @@ def pttl(self, key, version=None, client=None): if t >= 0: return t - elif t == -1: + if t == -1: return None - elif t == -2: + if t == -2: return 0 - else: - # Should never reach here - return None + + # Should never reach here + return None def has_key( - self, key: Any, version: Optional[int] = None, client: Optional[Redis] = None + self, key: KeyT, version: Optional[int] = None, client: Optional[Redis] = None ) -> bool: """ Test if key exists. @@ -704,8 +781,8 @@ def keys( raise ConnectionInterrupted(connection=client) from e def make_key( - self, key: Any, version: Optional[Any] = None, prefix: Optional[str] = None - ) -> CacheKey: + self, key: KeyT, version: Optional[int] = None, prefix: Optional[str] = None + ) -> KeyT: if isinstance(key, CacheKey): return key @@ -719,7 +796,7 @@ def make_key( def make_pattern( self, pattern: str, version: Optional[int] = None, prefix: Optional[str] = None - ) -> CacheKey: + ) -> str: if isinstance(pattern, CacheKey): return pattern @@ -733,7 +810,258 @@ def make_pattern( return CacheKey(self._backend.key_func(pattern, prefix, version_str)) - def close(self, **kwargs): + def sadd( + self, + key: KeyT, + *values: Any, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + client = self.get_client(write=True) + + key = self.make_key(key, version=version) + encoded_values = [self.encode(value) for value in values] + return int(client.sadd(key, *encoded_values)) + + def scard( + self, + key: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + return int(client.scard(key)) + + def sdiff( + self, + *keys: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if client is None: + client = self.get_client(write=False) + + nkeys = [self.make_key(key, version=version) for key in keys] + return {self.decode(value) for value in client.sdiff(*nkeys)} + + def sdiffstore( + self, + dest: KeyT, + *keys: KeyT, + version_dest: Optional[int] = None, + version_keys: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + client = self.get_client(write=True) + + dest = self.make_key(dest, version=version_dest) + nkeys = [self.make_key(key, version=version_keys) for key in keys] + return int(client.sdiffstore(dest, *nkeys)) + + def sinter( + self, + *keys: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if client is None: + client = self.get_client(write=False) + + nkeys = [self.make_key(key, version=version) for key in keys] + return {self.decode(value) for value in client.sinter(*nkeys)} + + def sinterstore( + self, + dest: KeyT, + *keys: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + client = self.get_client(write=True) + + dest = self.make_key(dest, version=version) + nkeys = [self.make_key(key, version=version) for key in keys] + return int(client.sinterstore(dest, *nkeys)) + + def smismember( + self, + key: KeyT, + *members, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> List[bool]: + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + encoded_members = [self.encode(member) for member in members] + + return [bool(value) for value in client.smismember(key, *encoded_members)] + + def sismember( + self, + key: KeyT, + member: Any, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> bool: + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + member = self.encode(member) + return bool(client.sismember(key, member)) + + def smembers( + self, + key: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + return {self.decode(value) for value in client.smembers(key)} + + def smove( + self, + source: KeyT, + destination: KeyT, + member: Any, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> bool: + if client is None: + client = self.get_client(write=True) + + source = self.make_key(source, version=version) + destination = self.make_key(destination) + member = self.encode(member) + return bool(client.smove(source, destination, member)) + + def spop( + self, + key: KeyT, + count: Optional[int] = None, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Union[Set, Any]: + if client is None: + client = self.get_client(write=True) + + nkey = self.make_key(key, version=version) + result = client.spop(nkey, count) + return self._decode_iterable_result(result) + + def srandmember( + self, + key: KeyT, + count: Optional[int] = None, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Union[List, Any]: + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + result = client.srandmember(key, count) + return self._decode_iterable_result(result, covert_to_set=False) + + def srem( + self, + key: KeyT, + *members: EncodableT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + client = self.get_client(write=True) + + key = self.make_key(key, version=version) + nmembers = [self.encode(member) for member in members] + return int(client.srem(key, *nmembers)) + + def sscan( + self, + key: KeyT, + match: Optional[str] = None, + count: Optional[int] = 10, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if self._has_compression_enabled() and match: + err_msg = "Using match with compression is not supported." + raise ValueError(err_msg) + + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + + cursor, result = client.sscan( + key, + match=cast(PatternT, self.encode(match)) if match else None, + count=count, + ) + return {self.decode(value) for value in result} + + def sscan_iter( + self, + key: KeyT, + match: Optional[str] = None, + count: Optional[int] = 10, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Iterator[Any]: + if self._has_compression_enabled() and match: + err_msg = "Using match with compression is not supported." + raise ValueError(err_msg) + + if client is None: + client = self.get_client(write=False) + + key = self.make_key(key, version=version) + for value in client.sscan_iter( + key, + match=cast(PatternT, self.encode(match)) if match else None, + count=count, + ): + yield self.decode(value) + + def sunion( + self, + *keys: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if client is None: + client = self.get_client(write=False) + + nkeys = [self.make_key(key, version=version) for key in keys] + return {self.decode(value) for value in client.sunion(*nkeys)} + + def sunionstore( + self, + destination: Any, + *keys: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + client = self.get_client(write=True) + + destination = self.make_key(destination, version=version) + encoded_keys = [self.make_key(key, version=version) for key in keys] + return int(client.sunionstore(destination, *encoded_keys)) + + def close(self) -> None: close_flag = self._options.get( "CLOSE_CONNECTION", getattr(settings, "DJANGO_REDIS_CLOSE_CONNECTION", False), @@ -741,8 +1069,10 @@ def close(self, **kwargs): if close_flag: self.do_close_clients() - def do_close_clients(self): - """default implementation: Override in custom client""" + def do_close_clients(self) -> None: + """ + default implementation: Override in custom client + """ num_clients = len(self._clients) for idx in range(num_clients): self.disconnect(index=idx) @@ -750,7 +1080,7 @@ def do_close_clients(self): def touch( self, - key: Any, + key: KeyT, timeout: Optional[float] = DEFAULT_TIMEOUT, version: Optional[int] = None, client: Optional[Redis] = None, @@ -768,7 +1098,83 @@ def touch( key = self.make_key(key, version=version) if timeout is None: return bool(client.persist(key)) - else: - # Convert to milliseconds - timeout = int(timeout * 1000) - return bool(client.pexpire(key, timeout)) + + # Convert to milliseconds + timeout = int(timeout * 1000) + return bool(client.pexpire(key, timeout)) + + def hset( + self, + name: str, + key: KeyT, + value: EncodableT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + """ + Set the value of hash name at key to value. + Returns the number of fields added to the hash. + """ + if client is None: + client = self.get_client(write=True) + nkey = self.make_key(key, version=version) + nvalue = self.encode(value) + return int(client.hset(name, nkey, nvalue)) + + def hdel( + self, + name: str, + key: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + """ + Remove keys from hash name. + Returns the number of fields deleted from the hash. + """ + if client is None: + client = self.get_client(write=True) + nkey = self.make_key(key, version=version) + return int(client.hdel(name, nkey)) + + def hlen( + self, + name: str, + client: Optional[Redis] = None, + ) -> int: + """ + Return the number of items in hash name. + """ + if client is None: + client = self.get_client(write=False) + return int(client.hlen(name)) + + def hkeys( + self, + name: str, + client: Optional[Redis] = None, + ) -> List[Any]: + """ + Return a list of keys in hash name. + """ + if client is None: + client = self.get_client(write=False) + try: + return [self.reverse_key(k.decode()) for k in client.hkeys(name)] + except _main_exceptions as e: + raise ConnectionInterrupted(connection=client) from e + + def hexists( + self, + name: str, + key: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> bool: + """ + Return True if key exists in hash name, else False. + """ + if client is None: + client = self.get_client(write=False) + nkey = self.make_key(key, version=version) + return bool(client.hexists(name, nkey)) diff --git a/django_redis/client/herd.py b/django_redis/client/herd.py index e818a475..94d539cd 100644 --- a/django_redis/client/herd.py +++ b/django_redis/client/herd.py @@ -6,8 +6,8 @@ from django.conf import settings from redis.exceptions import ConnectionError, ResponseError, TimeoutError -from ..exceptions import ConnectionInterrupted -from .default import DEFAULT_TIMEOUT, DefaultClient +from django_redis.client.default import DEFAULT_TIMEOUT, DefaultClient +from django_redis.exceptions import ConnectionInterrupted _main_exceptions = (ConnectionError, ResponseError, TimeoutError, socket.timeout) @@ -21,15 +21,12 @@ class Marker: pass -CACHE_HERD_TIMEOUT = getattr(settings, "CACHE_HERD_TIMEOUT", 60) - - -def _is_expired(x): - if x >= CACHE_HERD_TIMEOUT: +def _is_expired(x, herd_timeout: int) -> bool: + if x >= herd_timeout: return True - val = x + random.randint(1, CACHE_HERD_TIMEOUT) + val = x + random.randint(1, herd_timeout) - if val >= CACHE_HERD_TIMEOUT: + if val >= herd_timeout: return True return False @@ -37,6 +34,7 @@ def _is_expired(x): class HerdClient(DefaultClient): def __init__(self, *args, **kwargs): self._marker = Marker() + self._herd_timeout = getattr(settings, "CACHE_HERD_TIMEOUT", 60) super().__init__(*args, **kwargs) def _pack(self, value, timeout): @@ -55,7 +53,7 @@ def _unpack(self, value): now = int(time.time()) if herd_timeout < now: x = now - herd_timeout - return unpacked, _is_expired(x) + return unpacked, _is_expired(x, self._herd_timeout) return unpacked, False @@ -69,7 +67,6 @@ def set( nx=False, xx=False, ): - if timeout is DEFAULT_TIMEOUT: timeout = self._backend.default_timeout @@ -85,7 +82,7 @@ def set( ) packed = self._pack(value, timeout) - real_timeout = timeout + CACHE_HERD_TIMEOUT + real_timeout = timeout + self._herd_timeout return super().set( key, packed, timeout=real_timeout, version=version, client=client, nx=nx @@ -150,10 +147,10 @@ def set_many( raise ConnectionInterrupted(connection=client) from e def incr(self, *args, **kwargs): - raise NotImplementedError() + raise NotImplementedError def decr(self, *args, **kwargs): - raise NotImplementedError() + raise NotImplementedError def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, client=None): if client is None: diff --git a/django_redis/client/sentinel.py b/django_redis/client/sentinel.py index 709f4e53..d4f418dc 100644 --- a/django_redis/client/sentinel.py +++ b/django_redis/client/sentinel.py @@ -3,7 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from redis.sentinel import SentinelConnectionPool -from .default import DefaultClient +from django_redis.client.default import DefaultClient def replace_query(url, query): @@ -33,9 +33,10 @@ def __init__(self, server, params, backend): def connect(self, *args, **kwargs): connection = super().connect(*args, **kwargs) if not isinstance(connection.connection_pool, SentinelConnectionPool): - raise ImproperlyConfigured( + error_message = ( "Settings DJANGO_REDIS_CONNECTION_FACTORY or " "CACHE[].OPTIONS.CONNECTION_POOL_CLASS is not configured correctly." ) + raise ImproperlyConfigured(error_message) return connection diff --git a/django_redis/client/sharded.py b/django_redis/client/sharded.py index a1b0edd0..5e2eec90 100644 --- a/django_redis/client/sharded.py +++ b/django_redis/client/sharded.py @@ -1,14 +1,16 @@ import re from collections import OrderedDict from datetime import datetime -from typing import Union +from typing import Any, Iterator, List, Optional, Set, Union +from redis import Redis from redis.exceptions import ConnectionError +from redis.typing import KeyT -from ..exceptions import ConnectionInterrupted -from ..hash_ring import HashRing -from ..util import CacheKey -from .default import DEFAULT_TIMEOUT, DefaultClient +from django_redis.client.default import DEFAULT_TIMEOUT, DefaultClient +from django_redis.exceptions import ConnectionInterrupted +from django_redis.hash_ring import HashRing +from django_redis.util import CacheKey class ShardClient(DefaultClient): @@ -37,8 +39,7 @@ def get_server_name(self, _key): g = self._findhash.match(key) if g is not None and len(g.groups()) > 0: key = g.groups()[0] - name = self._ring.get_node(key) - return name + return self._ring.get_node(key) def get_server(self, key): name = self.get_server_name(key) @@ -80,7 +81,14 @@ def get_many(self, keys, version=None): return recovered_data def set( - self, key, value, timeout=DEFAULT_TIMEOUT, version=None, client=None, nx=False + self, + key, + value, + timeout=DEFAULT_TIMEOUT, + version=None, + client=None, + nx=False, + xx=False, ): """ Persist a value to the cache, and set an optional expiration time. @@ -90,10 +98,16 @@ def set( client = self.get_server(key) return super().set( - key=key, value=value, timeout=timeout, version=version, client=client, nx=nx + key=key, + value=value, + timeout=timeout, + version=version, + client=client, + nx=nx, + xx=xx, ) - def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None): + def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None, client=None): """ Set a bunch of values in the cache at once from a dict of key/value pairs. This is much more efficient than calling set() multiple times. @@ -102,7 +116,7 @@ def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None): the default cache timeout will be used. """ for key, value in data.items(): - self.set(key, value, timeout, version=version) + self.set(key, value, timeout, version=version, client=client) def has_key(self, key, version=None, client=None): """ @@ -205,7 +219,6 @@ def lock( client=None, thread_local=True, ): - if client is None: key = self.make_key(key, version=version) client = self.get_server(key) @@ -247,7 +260,8 @@ def incr_version(self, key, delta=1, version=None, client=None): raise ConnectionInterrupted(connection=client) from e if value is None: - raise ValueError("Key '%s' not found" % key) + msg = f"Key '{key}' not found" + raise ValueError(msg) if isinstance(key, CacheKey): new_key = self.make_key(key.original_key(), version=version + delta) @@ -273,13 +287,14 @@ def decr(self, key, delta=1, version=None, client=None): return super().decr(key=key, delta=delta, version=version, client=client) def iter_keys(self, key, version=None): - raise NotImplementedError("iter_keys not supported on sharded client") + error_message = "iter_keys not supported on sharded client" + raise NotImplementedError(error_message) def keys(self, search, version=None): pattern = self.make_pattern(search, version=version) keys = [] try: - for server, connection in self._serverdict.items(): + for connection in self._serverdict.values(): keys.extend(connection.keys(pattern)) except ConnectionError as e: # FIXME: technically all clients should be passed as `connection`. @@ -300,12 +315,12 @@ def delete_pattern( kwargs["count"] = itersize keys = [] - for server, connection in self._serverdict.items(): + for connection in self._serverdict.values(): keys.extend(key for key in connection.scan_iter(**kwargs)) res = 0 if keys: - for server, connection in self._serverdict.items(): + for connection in self._serverdict.values(): res += connection.delete(*keys) return res @@ -323,3 +338,148 @@ def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None, client=None): def clear(self, client=None): for connection in self._serverdict.values(): connection.flushdb() + + def sadd( + self, + key: KeyT, + *values: Any, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().sadd(key, *values, version=version, client=client) + + def scard( + self, + key: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().scard(key=key, version=version, client=client) + + def smembers( + self, + key: KeyT, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().smembers(key=key, version=version, client=client) + + def smove( + self, + source: KeyT, + destination: KeyT, + member: Any, + version: Optional[int] = None, + client: Optional[Redis] = None, + ): + if client is None: + source = self.make_key(source, version=version) + client = self.get_server(source) + destination = self.make_key(destination, version=version) + + return super().smove( + source=source, + destination=destination, + member=member, + version=version, + client=client, + ) + + def srem( + self, + key: KeyT, + *members, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> int: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().srem(key, *members, version=version, client=client) + + def sscan( + self, + key: KeyT, + match: Optional[str] = None, + count: Optional[int] = 10, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Set[Any]: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().sscan( + key=key, match=match, count=count, version=version, client=client + ) + + def sscan_iter( + self, + key: KeyT, + match: Optional[str] = None, + count: Optional[int] = 10, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Iterator[Any]: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().sscan_iter( + key=key, match=match, count=count, version=version, client=client + ) + + def srandmember( + self, + key: KeyT, + count: Optional[int] = None, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Union[Set, Any]: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().srandmember(key=key, count=count, version=version, client=client) + + def sismember( + self, + key: KeyT, + member: Any, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> bool: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().sismember(key, member, version=version, client=client) + + def spop( + self, + key: KeyT, + count: Optional[int] = None, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> Union[Set, Any]: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().spop(key=key, count=count, version=version, client=client) + + def smismember( + self, + key: KeyT, + *members, + version: Optional[int] = None, + client: Optional[Redis] = None, + ) -> List[bool]: + if client is None: + key = self.make_key(key, version=version) + client = self.get_server(key) + return super().smismember(key, *members, version=version, client=client) diff --git a/django_redis/compressors/gzip.py b/django_redis/compressors/gzip.py new file mode 100644 index 00000000..533499f4 --- /dev/null +++ b/django_redis/compressors/gzip.py @@ -0,0 +1,19 @@ +import gzip + +from django_redis.compressors.base import BaseCompressor +from django_redis.exceptions import CompressorError + + +class GzipCompressor(BaseCompressor): + min_length = 15 + + def compress(self, value: bytes) -> bytes: + if len(value) > self.min_length: + return gzip.compress(value) + return value + + def decompress(self, value: bytes) -> bytes: + try: + return gzip.decompress(value) + except gzip.BadGzipFile as e: + raise CompressorError from e diff --git a/django_redis/compressors/identity.py b/django_redis/compressors/identity.py index 4946886f..c7114203 100644 --- a/django_redis/compressors/identity.py +++ b/django_redis/compressors/identity.py @@ -1,4 +1,4 @@ -from .base import BaseCompressor +from django_redis.compressors.base import BaseCompressor class IdentityCompressor(BaseCompressor): diff --git a/django_redis/compressors/lz4.py b/django_redis/compressors/lz4.py index ffcd4794..940c96d5 100644 --- a/django_redis/compressors/lz4.py +++ b/django_redis/compressors/lz4.py @@ -1,8 +1,8 @@ from lz4.frame import compress as _compress from lz4.frame import decompress as _decompress -from ..exceptions import CompressorError -from .base import BaseCompressor +from django_redis.compressors.base import BaseCompressor +from django_redis.exceptions import CompressorError class Lz4Compressor(BaseCompressor): @@ -17,4 +17,4 @@ def decompress(self, value: bytes) -> bytes: try: return _decompress(value) except Exception as e: - raise CompressorError(e) + raise CompressorError from e diff --git a/django_redis/compressors/lzma.py b/django_redis/compressors/lzma.py index b8c17bcf..1ab1a024 100644 --- a/django_redis/compressors/lzma.py +++ b/django_redis/compressors/lzma.py @@ -1,7 +1,7 @@ import lzma -from ..exceptions import CompressorError -from .base import BaseCompressor +from django_redis.compressors.base import BaseCompressor +from django_redis.exceptions import CompressorError class LzmaCompressor(BaseCompressor): @@ -17,4 +17,4 @@ def decompress(self, value: bytes) -> bytes: try: return lzma.decompress(value) except lzma.LZMAError as e: - raise CompressorError(e) + raise CompressorError from e diff --git a/django_redis/compressors/zlib.py b/django_redis/compressors/zlib.py index 014fc77e..98767325 100644 --- a/django_redis/compressors/zlib.py +++ b/django_redis/compressors/zlib.py @@ -1,7 +1,7 @@ import zlib -from ..exceptions import CompressorError -from .base import BaseCompressor +from django_redis.compressors.base import BaseCompressor +from django_redis.exceptions import CompressorError class ZlibCompressor(BaseCompressor): @@ -17,4 +17,4 @@ def decompress(self, value: bytes) -> bytes: try: return zlib.decompress(value) except zlib.error as e: - raise CompressorError(e) + raise CompressorError from e diff --git a/django_redis/compressors/zstd.py b/django_redis/compressors/zstd.py index ddf49d0d..82a09187 100644 --- a/django_redis/compressors/zstd.py +++ b/django_redis/compressors/zstd.py @@ -1,7 +1,7 @@ import pyzstd -from ..exceptions import CompressorError -from .base import BaseCompressor +from django_redis.compressors.base import BaseCompressor +from django_redis.exceptions import CompressorError class ZStdCompressor(BaseCompressor): @@ -16,4 +16,4 @@ def decompress(self, value: bytes) -> bytes: try: return pyzstd.decompress(value) except pyzstd.ZstdError as e: - raise CompressorError(e) + raise CompressorError from e diff --git a/django_redis/pool.py b/django_redis/pool.py index dda3b945..b0e5f2a3 100644 --- a/django_redis/pool.py +++ b/django_redis/pool.py @@ -5,19 +5,18 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from redis import Redis -from redis.connection import DefaultParser, to_bool +from redis.connection import ConnectionPool, DefaultParser, to_bool from redis.sentinel import Sentinel class ConnectionFactory: - # Store connection pool by cache backend options. # # _pools is a process-global, as otherwise _pools is cleared every time # ConnectionFactory is instantiated, as Django creates new cache client # (DefaultClient) instance for every request. - _pools: Dict[str, Redis] = {} + _pools: Dict[str, ConnectionPool] = {} def __init__(self, options): pool_cls_path = options.get( @@ -49,16 +48,16 @@ def make_connection_params(self, url): socket_timeout = self.options.get("SOCKET_TIMEOUT", None) if socket_timeout: - assert isinstance( - socket_timeout, (int, float) - ), "Socket timeout should be float or integer" + if not isinstance(socket_timeout, (int, float)): + error_message = "Socket timeout should be float or integer" + raise ImproperlyConfigured(error_message) kwargs["socket_timeout"] = socket_timeout socket_connect_timeout = self.options.get("SOCKET_CONNECT_TIMEOUT", None) if socket_connect_timeout: - assert isinstance( - socket_connect_timeout, (int, float) - ), "Socket connect timeout should be float or integer" + if not isinstance(socket_connect_timeout, (int, float)): + error_message = "Socket connect timeout should be float or integer" + raise ImproperlyConfigured(error_message) kwargs["socket_connect_timeout"] = socket_connect_timeout return kwargs @@ -69,10 +68,9 @@ def connect(self, url: str) -> Redis: return a new connection. """ params = self.make_connection_params(url) - connection = self.get_connection(params) - return connection + return self.get_connection(params) - def disconnect(self, connection): + def disconnect(self, connection: Redis) -> None: """ Given a not null client connection it disconnect from the Redis server. @@ -141,9 +139,8 @@ def __init__(self, options): sentinels = options.get("SENTINELS") if not sentinels: - raise ImproperlyConfigured( - "SENTINELS must be provided as a list of (host, port)." - ) + error_message = "SENTINELS must be provided as a list of (host, port)." + raise ImproperlyConfigured(error_message) # provide the connection pool kwargs to the sentinel in case it # needs to use the socket options for the sentinels themselves @@ -185,6 +182,9 @@ def get_connection_factory(path=None, options=None): "DJANGO_REDIS_CONNECTION_FACTORY", "django_redis.pool.ConnectionFactory", ) + opt_conn_factory = options.get("CONNECTION_FACTORY") + if opt_conn_factory: + path = opt_conn_factory cls = import_string(path) return cls(options or {}) diff --git a/django_redis/serializers/json.py b/django_redis/serializers/json.py index 06e62542..8be9bb29 100644 --- a/django_redis/serializers/json.py +++ b/django_redis/serializers/json.py @@ -3,7 +3,7 @@ from django.core.serializers.json import DjangoJSONEncoder -from .base import BaseSerializer +from django_redis.serializers.base import BaseSerializer class JSONSerializer(BaseSerializer): diff --git a/django_redis/serializers/msgpack.py b/django_redis/serializers/msgpack.py index d41591f6..6af58a67 100644 --- a/django_redis/serializers/msgpack.py +++ b/django_redis/serializers/msgpack.py @@ -2,7 +2,7 @@ import msgpack -from .base import BaseSerializer +from django_redis.serializers.base import BaseSerializer class MSGPackSerializer(BaseSerializer): diff --git a/django_redis/serializers/pickle.py b/django_redis/serializers/pickle.py index c304360d..e63d3c9a 100644 --- a/django_redis/serializers/pickle.py +++ b/django_redis/serializers/pickle.py @@ -3,7 +3,7 @@ from django.core.exceptions import ImproperlyConfigured -from .base import BaseSerializer +from django_redis.serializers.base import BaseSerializer class PickleSerializer(BaseSerializer): @@ -18,12 +18,14 @@ def setup_pickle_version(self, options) -> None: try: self._pickle_version = int(options["PICKLE_VERSION"]) if self._pickle_version > pickle.HIGHEST_PROTOCOL: - raise ImproperlyConfigured( + error_message = ( f"PICKLE_VERSION can't be higher than pickle.HIGHEST_PROTOCOL:" f" {pickle.HIGHEST_PROTOCOL}" ) - except (ValueError, TypeError): - raise ImproperlyConfigured("PICKLE_VERSION value must be an integer") + raise ImproperlyConfigured(error_message) + except (ValueError, TypeError) as e: + error_message = "PICKLE_VERSION value must be an integer" + raise ImproperlyConfigured(error_message) from e def dumps(self, value: Any) -> bytes: return pickle.dumps(value, self._pickle_version) diff --git a/setup.cfg b/setup.cfg index 28c1fc1a..fbbc9113 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,110 +12,92 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 2.2 - Framework :: Django :: 3.1 - Framework :: Django :: 3.2 - Framework :: Django :: 4.0 + Framework :: Django :: 4.2 + Framework :: Django :: 5.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Software Development :: Libraries Topic :: Utilities [options] -python_requires = >=3.6 +python_requires = >=3.8 packages = django_redis django_redis.client django_redis.serializers django_redis.compressors install_requires = - Django>=2.2 - redis>=3,!=4.0.0,!=4.0.1 + Django>=4.2 + redis>=4.0.2 [options.extras_require] -hiredis = redis[hiredis]>=3,!=4.0.0,!=4.0.1 +hiredis = redis[hiredis]>=4.0.2 [coverage:run] omit = - # tests tests/*, [coverage:report] precision = 1 skip_covered = true -[flake8] -ignore = - W503 - W601 - E203 -max-line-length = 88 - -[isort] -profile = black -multi_line_output = 3 - [tox:tox] minversion = 3.15.0 envlist = black - flake8 - isort + ruff mypy # tests against released versions - py{36,37,38,39,310}-dj{22,31,32,40}-redislatest + py{38,39,310,311}-dj{42,50}-redislatest # tests against unreleased versions - py310-dj40-redismaster - py310-djmain-redis{latest,master} + py311-dj50-redismaster + py311-djmain-redis{latest,master} [gh-actions] python = - 3.6: py36 - 3.7: py37 - 3.8: py38, black, flake8, isort, mypy + 3.8: py38, black, ruff, mypy 3.9: py39 3.10: py310 + 3.11: py311 [gh-actions:env] DJANGO = - 2.2: dj22 - 3.1: dj31 - 3.2: dj32 - 4.0: dj40 + 4.2: dj42 + 5.0: dj50 main: djmain REDIS = latest: redislatest master: redismaster [testenv] -passenv = CI GITHUB* +passenv = CI, GITHUB* commands = {envpython} -m pytest --cov-report= --ds=settings.sqlite {posargs} + {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_herd {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_json {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_lz4 {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_msgpack {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_sentinel {posargs} + {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_sentinel_opts {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_sharding {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_usock {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_zlib {posargs} {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_zstd {posargs} + {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_gzip {posargs} {envpython} -m coverage report {envpython} -m coverage xml deps = - dj22: Django>=2.2,<2.3 - dj31: Django>=3.1,<3.2 - dj32: Django>=3.2,<3.3 - dj40: Django>=4.0,<4.1 + dj42: Django>=4.2,<5.0 + dj50: Django>=5.0,<5.1 djmain: https://github.com/django/django/archive/main.tar.gz msgpack>=0.6.0 pytest @@ -127,19 +109,17 @@ deps = lz4>=0.15 pyzstd>=0.15 -[testenv:{black,flake8,isort,mypy}] +[testenv:{black,ruff,mypy}] basepython = python3 envdir={toxworkdir}/lint commands = black: black --target-version py36 {posargs:--check --diff} setup.py django_redis/ tests/ - flake8: flake8 {posargs} setup.py django_redis/ tests/ - isort: isort {posargs:--check-only --diff} django_redis/ tests/ + ruff: ruff {posargs:check --show-fixes} django_redis/ tests/ mypy: mypy {posargs:--cobertura-xml-report .} django_redis tests deps = black django-stubs - flake8 - isort >= 5.0.2 + ruff lxml mypy # typing dependencies @@ -162,7 +142,7 @@ filterwarnings = error::FutureWarning error::PendingDeprecationWarning ignore:.*distutils package is deprecated.*:DeprecationWarning -python_paths = tests +pythonpath = tests testpaths = tests xfail_strict = true diff --git a/tests/conftest.py b/tests/conftest.py index 1b99f0c7..59ea7d8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,10 @@ import pytest from django.core.cache import cache as default_cache -from django_redis.cache import RedisCache +from django_redis.cache import BaseCache @pytest.fixture -def cache() -> Iterable[RedisCache]: +def cache() -> Iterable[BaseCache]: yield default_cache default_cache.clear() diff --git a/tests/settings/__init__.py b/tests/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/settings/sqlite_gzip.py b/tests/settings/sqlite_gzip.py new file mode 100644 index 00000000..7ebb1580 --- /dev/null +++ b/tests/settings/sqlite_gzip.py @@ -0,0 +1,41 @@ +SECRET_KEY = "django_tests_secret_key" + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", + }, + }, + "doesnotexist": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:56379?db=1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", + }, + }, + "sample": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", + }, + }, + "with_prefix": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379?db=1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", + }, + "KEY_PREFIX": "test-prefix", + }, +} + +INSTALLED_APPS = ["django.contrib.sessions"] + +USE_TZ = False diff --git a/tests/settings/sqlite_herd.py b/tests/settings/sqlite_herd.py index 7cfb7af4..9bac3a4c 100644 --- a/tests/settings/sqlite_herd.py +++ b/tests/settings/sqlite_herd.py @@ -27,3 +27,5 @@ INSTALLED_APPS = ["django.contrib.sessions"] USE_TZ = False + +CACHE_HERD_TIMEOUT = 2 diff --git a/tests/settings/sqlite_sentinel_opts.py b/tests/settings/sqlite_sentinel_opts.py new file mode 100644 index 00000000..29f079ed --- /dev/null +++ b/tests/settings/sqlite_sentinel_opts.py @@ -0,0 +1,49 @@ +SECRET_KEY = "django_tests_secret_key" + +SENTINELS = [("127.0.0.1", 26379)] + +conn_factory = "django_redis.pool.SentinelConnectionFactory" + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": ["redis://default_service?db=5"], + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "SENTINELS": SENTINELS, + "CONNECTION_FACTORY": conn_factory, + }, + }, + "doesnotexist": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://missing_service?db=1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "SENTINELS": SENTINELS, + "CONNECTION_FACTORY": conn_factory, + }, + }, + "sample": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://default_service?db=1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.SentinelClient", + "SENTINELS": SENTINELS, + "CONNECTION_FACTORY": conn_factory, + }, + }, + "with_prefix": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://default_service?db=1", + "KEY_PREFIX": "test-prefix", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "SENTINELS": SENTINELS, + "CONNECTION_FACTORY": conn_factory, + }, + }, +} + +INSTALLED_APPS = ["django.contrib.sessions"] + +USE_TZ = False diff --git a/tests/test_backend.py b/tests/test_backend.py index 0e8e1fdf..e5c54e18 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -2,21 +2,30 @@ import threading import time from datetime import timedelta -from typing import List, Union, cast +from typing import Iterable, List, Union, cast from unittest.mock import patch import pytest from django.core.cache import caches +from django.core.cache.backends.base import DEFAULT_TIMEOUT +from django.test import override_settings from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture -import django_redis.cache from django_redis.cache import RedisCache from django_redis.client import ShardClient, herd from django_redis.serializers.json import JSONSerializer from django_redis.serializers.msgpack import MSGPackSerializer -herd.CACHE_HERD_TIMEOUT = 2 + +@pytest.fixture +def patch_itersize_setting() -> Iterable[None]: + # destroy cache to force recreation with overriden settings + del caches["default"] + with override_settings(DJANGO_REDIS_SCAN_ITERSIZE=30): + yield + # destroy cache to force recreation with original settings + del caches["default"] class TestDjangoRedisCache: @@ -199,7 +208,12 @@ def test_set_many(self, cache: RedisCache): res = cache.get_many(["a", "b", "c"]) assert res == {"a": 1, "b": 2, "c": 3} - def test_set_call_empty_pipeline(self, cache: RedisCache, mocker: MockerFixture): + def test_set_call_empty_pipeline( + self, + cache: RedisCache, + mocker: MockerFixture, + settings: SettingsWrapper, + ): if isinstance(cache.client, ShardClient): pytest.skip("ShardClient doesn't support get_client") @@ -212,7 +226,7 @@ def test_set_call_empty_pipeline(self, cache: RedisCache, mocker: MockerFixture) if isinstance(cache.client, herd.HerdClient): default_timeout = cache.client._backend.default_timeout - herd_timeout = (default_timeout + herd.CACHE_HERD_TIMEOUT) * 1000 + herd_timeout = (default_timeout + settings.CACHE_HERD_TIMEOUT) * 1000 herd_pack_value = cache.client._pack(value, default_timeout) mocked_set.assert_called_once_with( cache.client.make_key(key, version=None), @@ -495,11 +509,15 @@ def test_delete_pattern_with_custom_count(self, client_mock, cache: RedisCache): @patch("django_redis.cache.RedisCache.client") def test_delete_pattern_with_settings_default_scan_count( - self, client_mock, cache: RedisCache + self, + client_mock, + patch_itersize_setting, + cache: RedisCache, + settings: SettingsWrapper, ): for key in ["foo-aa", "foo-ab", "foo-bb", "foo-bc"]: cache.set(key, "foo") - expected_count = django_redis.cache.DJANGO_REDIS_SCAN_ITERSIZE + expected_count = settings.DJANGO_REDIS_SCAN_ITERSIZE cache.delete_pattern("*foo-a*") @@ -541,7 +559,6 @@ def test_ttl(self, cache: RedisCache): assert ttl == 0 def test_pttl(self, cache: RedisCache): - # Test pttl cache.set("foo", "bar", 10) ttl = cache.pttl("foo") @@ -590,6 +607,11 @@ def test_expire(self, cache: RedisCache): assert pytest.approx(ttl) == 20 assert cache.expire("not-existent-key", 20) is False + def test_expire_with_default_timeout(self, cache: RedisCache): + cache.set("foo", "bar", timeout=None) + assert cache.expire("foo", DEFAULT_TIMEOUT) is True + assert cache.expire("not-existent-key", DEFAULT_TIMEOUT) is False + def test_pexpire(self, cache: RedisCache): cache.set("foo", "bar", timeout=None) assert cache.pexpire("foo", 20500) is True @@ -598,8 +620,12 @@ def test_pexpire(self, cache: RedisCache): assert pytest.approx(ttl, 10) == 20500 assert cache.pexpire("not-existent-key", 20500) is False - def test_pexpire_at(self, cache: RedisCache): + def test_pexpire_with_default_timeout(self, cache: RedisCache): + cache.set("foo", "bar", timeout=None) + assert cache.pexpire("foo", DEFAULT_TIMEOUT) is True + assert cache.pexpire("not-existent-key", DEFAULT_TIMEOUT) is False + def test_pexpire_at(self, cache: RedisCache): # Test settings expiration time 1 hour ahead by datetime. cache.set("foo", "bar", timeout=None) expiration_time = datetime.datetime.now() + timedelta(hours=1) @@ -625,7 +651,6 @@ def test_pexpire_at(self, cache: RedisCache): assert cache.pexpire_at("not-existent-key", expiration_time) is False def test_expire_at(self, cache: RedisCache): - # Test settings expiration time 1 hour ahead by datetime. cache.set("foo", "bar", timeout=None) expiration_time = datetime.datetime.now() + timedelta(hours=1) @@ -652,7 +677,19 @@ def test_expire_at(self, cache: RedisCache): def test_lock(self, cache: RedisCache): lock = cache.lock("foobar") - lock.acquire(blocking=True) + assert lock.acquire(blocking=True) + + assert cache.has_key("foobar") + lock.release() + assert not cache.has_key("foobar") + + def test_lock_not_blocking(self, cache: RedisCache): + lock = cache.lock("foobar") + assert lock.acquire(blocking=False) + + lock2 = cache.lock("foobar") + + assert not lock2.acquire(blocking=False) assert cache.has_key("foobar") lock.release() @@ -660,7 +697,7 @@ def test_lock(self, cache: RedisCache): def test_lock_released_by_thread(self, cache: RedisCache): lock = cache.lock("foobar", thread_local=False) - lock.acquire(blocking=True) + assert lock.acquire(blocking=True) def release_lock(lock_): lock_.release() @@ -720,6 +757,18 @@ def test_primary_replica_switching(self, cache: RedisCache): assert client.get_client(write=True) == "Foo" assert client.get_client(write=False) == "Bar" + def test_primary_replica_switching_with_index(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache = cast(RedisCache, caches["sample"]) + client = cache.client + client._server = ["foo", "bar"] + client._clients = ["Foo", "Bar"] + + assert client.get_client_with_index(write=True) == ("Foo", 0) + assert client.get_client_with_index(write=False) == ("Bar", 1) + def test_touch_zero_timeout(self, cache: RedisCache): cache.set("test_key", 222, timeout=10) @@ -771,3 +820,208 @@ def test_clear(self, cache: RedisCache): cache.clear() value_from_cache_after_clear = cache.get("foo") assert value_from_cache_after_clear is None + + def test_hset(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + cache.hset("foo_hash1", "foo1", "bar1") + cache.hset("foo_hash1", "foo2", "bar2") + assert cache.hlen("foo_hash1") == 2 + assert cache.hexists("foo_hash1", "foo1") + assert cache.hexists("foo_hash1", "foo2") + + def test_hdel(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + cache.hset("foo_hash2", "foo1", "bar1") + cache.hset("foo_hash2", "foo2", "bar2") + assert cache.hlen("foo_hash2") == 2 + deleted_count = cache.hdel("foo_hash2", "foo1") + assert deleted_count == 1 + assert cache.hlen("foo_hash2") == 1 + assert not cache.hexists("foo_hash2", "foo1") + assert cache.hexists("foo_hash2", "foo2") + + def test_hlen(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + assert cache.hlen("foo_hash3") == 0 + cache.hset("foo_hash3", "foo1", "bar1") + assert cache.hlen("foo_hash3") == 1 + cache.hset("foo_hash3", "foo2", "bar2") + assert cache.hlen("foo_hash3") == 2 + + def test_hkeys(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + cache.hset("foo_hash4", "foo1", "bar1") + cache.hset("foo_hash4", "foo2", "bar2") + cache.hset("foo_hash4", "foo3", "bar3") + keys = cache.hkeys("foo_hash4") + assert len(keys) == 3 + for i in range(len(keys)): + assert keys[i] == f"foo{i + 1}" + + def test_hexists(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + cache.hset("foo_hash5", "foo1", "bar1") + assert cache.hexists("foo_hash5", "foo1") + assert not cache.hexists("foo_hash5", "foo") + + def test_sadd(self, cache: RedisCache): + assert cache.sadd("foo", "bar") == 1 + assert cache.smembers("foo") == {"bar"} + + def test_scard(self, cache: RedisCache): + cache.sadd("foo", "bar", "bar2") + assert cache.scard("foo") == 2 + + def test_sdiff(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.sdiff("foo1", "foo2") == {"bar1"} + + def test_sdiffstore(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.sdiffstore("foo3", "foo1", "foo2") == 1 + assert cache.smembers("foo3") == {"bar1"} + + def test_sdiffstore_with_keys_version(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2", version=2) + cache.sadd("foo2", "bar2", "bar3", version=2) + assert cache.sdiffstore("foo3", "foo1", "foo2", version_keys=2) == 1 + assert cache.smembers("foo3") == {"bar1"} + + def test_sdiffstore_with_different_keys_versions_without_initial_set_in_version( + self, cache: RedisCache + ): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2", version=1) + cache.sadd("foo2", "bar2", "bar3", version=2) + assert cache.sdiffstore("foo3", "foo1", "foo2", version_keys=2) == 0 + + def test_sdiffstore_with_different_keys_versions_with_initial_set_in_version( + self, cache: RedisCache + ): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2", version=2) + cache.sadd("foo2", "bar2", "bar3", version=1) + assert cache.sdiffstore("foo3", "foo1", "foo2", version_keys=2) == 2 + + def test_sinter(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.sinter("foo1", "foo2") == {"bar2"} + + def test_interstore(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.sinterstore("foo3", "foo1", "foo2") == 1 + assert cache.smembers("foo3") == {"bar2"} + + def test_sismember(self, cache: RedisCache): + cache.sadd("foo", "bar") + assert cache.sismember("foo", "bar") is True + assert cache.sismember("foo", "bar2") is False + + def test_smove(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.smove("foo1", "foo2", "bar1") is True + assert cache.smove("foo1", "foo2", "bar4") is False + assert cache.smembers("foo1") == {"bar2"} + assert cache.smembers("foo2") == {"bar1", "bar2", "bar3"} + + def test_spop_default_count(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + assert cache.spop("foo") in {"bar1", "bar2"} + assert cache.smembers("foo") in [{"bar1"}, {"bar2"}] + + def test_spop(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + assert cache.spop("foo", 1) in [{"bar1"}, {"bar2"}] + assert cache.smembers("foo") in [{"bar1"}, {"bar2"}] + + def test_srandmember_default_count(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + assert cache.srandmember("foo") in {"bar1", "bar2"} + + def test_srandmember(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + assert cache.srandmember("foo", 1) in [["bar1"], ["bar2"]] + + def test_srem(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + assert cache.srem("foo", "bar1") == 1 + assert cache.srem("foo", "bar3") == 0 + + def test_sscan(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + items = cache.sscan("foo") + assert items == {"bar1", "bar2"} + + def test_sscan_with_match(self, cache: RedisCache): + if cache.client._has_compression_enabled(): + pytest.skip("Compression is enabled, sscan with match is not supported") + cache.sadd("foo", "bar1", "bar2", "zoo") + items = cache.sscan("foo", match="zoo") + assert items == {"zoo"} + + def test_sscan_iter(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2") + items = cache.sscan_iter("foo") + assert set(items) == {"bar1", "bar2"} + + def test_sscan_iter_with_match(self, cache: RedisCache): + if cache.client._has_compression_enabled(): + pytest.skip( + "Compression is enabled, sscan_iter with match is not supported" + ) + cache.sadd("foo", "bar1", "bar2", "zoo") + items = cache.sscan_iter("foo", match="bar*") + assert set(items) == {"bar1", "bar2"} + + def test_smismember(self, cache: RedisCache): + cache.sadd("foo", "bar1", "bar2", "bar3") + assert cache.smismember("foo", "bar1", "bar2", "xyz") == [True, True, False] + + def test_sunion(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.sunion("foo1", "foo2") == {"bar1", "bar2", "bar3"} + + def test_sunionstore(self, cache: RedisCache): + if isinstance(cache.client, ShardClient): + pytest.skip("ShardClient doesn't support get_client") + + cache.sadd("foo1", "bar1", "bar2") + cache.sadd("foo2", "bar2", "bar3") + assert cache.sunionstore("foo3", "foo1", "foo2") == 3 + assert cache.smembers("foo3") == {"bar1", "bar2", "bar3"} diff --git a/tests/test_cache_options.py b/tests/test_cache_options.py index 93259111..652a03d5 100644 --- a/tests/test_cache_options.py +++ b/tests/test_cache_options.py @@ -3,6 +3,7 @@ import pytest from django.core.cache import caches +from pytest import LogCaptureFixture from pytest_django.fixtures import SettingsWrapper from redis.exceptions import ConnectionError @@ -22,8 +23,10 @@ def reverse_key(key: str) -> str: def ignore_exceptions_cache(settings: SettingsWrapper) -> RedisCache: caches_setting = copy.deepcopy(settings.CACHES) caches_setting["doesnotexist"]["OPTIONS"]["IGNORE_EXCEPTIONS"] = True + caches_setting["doesnotexist"]["OPTIONS"]["LOG_IGNORED_EXCEPTIONS"] = True settings.CACHES = caches_setting settings.DJANGO_REDIS_IGNORE_EXCEPTIONS = True + settings.DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True return cast(RedisCache, caches["doesnotexist"]) @@ -34,12 +37,22 @@ def test_get_django_omit_exceptions_many_returns_default_arg( assert ignore_exceptions_cache.get_many(["key1", "key2", "key3"]) == {} -def test_get_django_omit_exceptions(ignore_exceptions_cache: RedisCache): +def test_get_django_omit_exceptions( + caplog: LogCaptureFixture, ignore_exceptions_cache: RedisCache +): assert ignore_exceptions_cache._ignore_exceptions is True + assert ignore_exceptions_cache._log_ignored_exceptions is True + assert ignore_exceptions_cache.get("key") is None assert ignore_exceptions_cache.get("key", "default") == "default" assert ignore_exceptions_cache.get("key", default="default") == "default" + assert len(caplog.records) == 3 + assert all( + record.levelname == "ERROR" and record.msg == "Exception ignored" + for record in caplog.records + ) + def test_get_django_omit_exceptions_priority_1(settings: SettingsWrapper): caches_setting = copy.deepcopy(settings.CACHES) diff --git a/tests/test_client.py b/tests/test_client.py index 807fbf3a..74308657 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,5 @@ from typing import Iterable -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch import pytest from django.core.cache import DEFAULT_CACHE_ALIAS @@ -104,6 +104,28 @@ def test_delete_pattern_calls_scan_iter_with_count_if_itersize_given( count=90210, match=make_pattern_mock.return_value ) + @patch("test_client.DefaultClient.make_pattern") + @patch("test_client.DefaultClient.get_client", return_value=Mock()) + @patch("test_client.DefaultClient.__init__", return_value=None) + def test_delete_pattern_calls_pipeline_delete_and_execute( + self, init_mock, get_client_mock, make_pattern_mock + ): + client = DefaultClient() + client._backend = Mock() + client._backend.key_prefix = "" + get_client_mock.return_value.scan_iter.return_value = [":1:foo", ":1:foo-a"] + get_client_mock.return_value.pipeline.return_value = Mock() + get_client_mock.return_value.pipeline.return_value.delete = Mock() + get_client_mock.return_value.pipeline.return_value.execute = Mock() + + client.delete_pattern(pattern="foo*") + + assert get_client_mock.return_value.pipeline.return_value.delete.call_count == 2 + get_client_mock.return_value.pipeline.return_value.delete.assert_has_calls( + [call(":1:foo"), call(":1:foo-a")] + ) + get_client_mock.return_value.pipeline.return_value.execute.assert_called_once() + class TestShardClient: @patch("test_client.DefaultClient.make_pattern") diff --git a/tests/test_connection_factory.py b/tests/test_connection_factory.py new file mode 100644 index 00000000..58427ff6 --- /dev/null +++ b/tests/test_connection_factory.py @@ -0,0 +1,60 @@ +import pytest +from django.core.exceptions import ImproperlyConfigured + +from django_redis import pool + + +def test_connection_factory_redefine_from_opts(): + cf = pool.get_connection_factory( + path="django_redis.pool.ConnectionFactory", + options={ + "CONNECTION_FACTORY": "django_redis.pool.SentinelConnectionFactory", + "SENTINELS": [("127.0.0.1", "26739")], + }, + ) + assert cf.__class__.__name__ == "SentinelConnectionFactory" + + +@pytest.mark.parametrize( + "conn_factory,expected", + [ + ("django_redis.pool.SentinelConnectionFactory", pool.SentinelConnectionFactory), + ("django_redis.pool.ConnectionFactory", pool.ConnectionFactory), + ], +) +def test_connection_factory_opts(conn_factory: str, expected): + cf = pool.get_connection_factory( + path=None, + options={ + "CONNECTION_FACTORY": conn_factory, + "SENTINELS": [("127.0.0.1", "26739")], + }, + ) + assert isinstance(cf, expected) + + +@pytest.mark.parametrize( + "conn_factory,expected", + [ + ("django_redis.pool.SentinelConnectionFactory", pool.SentinelConnectionFactory), + ("django_redis.pool.ConnectionFactory", pool.ConnectionFactory), + ], +) +def test_connection_factory_path(conn_factory: str, expected): + cf = pool.get_connection_factory( + path=conn_factory, + options={ + "SENTINELS": [("127.0.0.1", "26739")], + }, + ) + assert isinstance(cf, expected) + + +def test_connection_factory_no_sentinels(): + with pytest.raises(ImproperlyConfigured): + pool.get_connection_factory( + path=None, + options={ + "CONNECTION_FACTORY": "django_redis.pool.SentinelConnectionFactory", + }, + ) diff --git a/tests/test_hashring.py b/tests/test_hashring.py index b890adb7..42f5b9ff 100644 --- a/tests/test_hashring.py +++ b/tests/test_hashring.py @@ -4,14 +4,14 @@ class Node: - def __init__(self, id): - self.id = id + def __init__(self, identifier): + self.identifier = identifier def __str__(self): - return f"node:{self.id}" + return f"node:{self.identifier}" def __repr__(self): - return f"" + return f"" @pytest.fixture @@ -24,7 +24,7 @@ def test_hashring(hash_ring): for key in [f"test{x}" for x in range(10)]: node = hash_ring.get_node(key) - ids.append(node.id) + ids.append(node.identifier) assert ids == [0, 2, 1, 2, 2, 2, 2, 0, 1, 1] diff --git a/tests/test_session.py b/tests/test_session.py index 1c179eb7..bcea9c39 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Optional, Type +import django +import pytest from django.conf import settings from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.cache import SessionStore as CacheSession @@ -365,6 +367,10 @@ def test_session_save_does_not_resurrect_session_logged_out_in_other_context(sel class SessionTests(SessionTestsMixin, unittest.TestCase): backend = CacheSession + @pytest.mark.skipif( + django.VERSION >= (4, 2), + reason="PickleSerializer is removed as of https://code.djangoproject.com/ticket/29708", + ) def test_actual_expiry(self): if isinstance( caches[DEFAULT_CACHE_ALIAS].client._serializer, MSGPackSerializer