Skip to content

Commit

Permalink
Patch function and readme.
Browse files Browse the repository at this point in the history
  • Loading branch information
iurisilvio committed Jan 12, 2023
1 parent f7ad180 commit 474fc79
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 24 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ poetry.lock

*.pyc
.coverage
.python-version
.tox
redis*.db
redis*.db
98 changes: 96 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,101 @@
# django-cache-mock

Use in-process mocks to avoid setting up external caches for Django during
development.

Django has a limited built-in `django.core.cache.backends.locmem.LocMemCache`,
to help development, but Django do some magic to always give you a working
connection.

I have some reasons to abuse Django cache this way:

* Thread safety: Django spin one connection per thread to avoid issues with
thread unsafe drivers.
* Good defaults: Django run connections with good defaults.
* Connection reuse: Django already have a pool running and in most cases it is
better to use it.

## Install

```shell
$ pip install django-cache-mock
```

Also, it is possible to install with the backends you want.

For `mockcache`, it installs a fork of the original package because it doesn´t
work for new versions of Python.

```shell
$ pip install django-cache-mock[mockcache]
$ pip install django-cache-mock[fakeredis]
$ pip install django-cache-mock[redislite]
```

## How to use

In your Django settings you already have `CACHES` defined.

For `memcached`, it's something like that:

```python
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": os.getenv("MEMCACHED_HOSTS"),
"OPTIONS": {
"no_delay": True,
"ignore_exc": True,
"max_pool_size": 4,
"use_pooling": True,
},
},
}
```

Just make a call to `django_cache_mock.patch` to replace with a mock backend.

**The lib will patch only when cache LOCATION is not defined.**

```python
import django_cache_mock

if DEBUG: # Apply it only in debug mode to be extra careful.
django_cache_mock.patch(CACHES, "default", "mockcache")
```

This patch replace cache with a mocked one. For mockcache,

## Custom cache options

The `patch` function accepts custom params. It can be used to override mock
behaviours, like the db file `redislite` will use, defined by `LOCATION`:

```python
django_cache_mock.patch(CACHES, "default", "redislite", {"LOCATION": "data/redis.db"})
```

## How to access connections

To get Django memcached and redis clients from cache:

```python
from django.core.cache import caches

def give_me_memcached():
return caches["memcached"]._cache

# for django.core.cache.backends.redis
def give_me_primary_redis():
return caches["redis"]._cache.get_client()

def give_me_secondary_redis():
return caches["redis"]._cache.get_client(write=False)

# for django-redis
def give_me_primary_redis():
return caches["redis"].client.get_client()

def give_me_secondary_redis():
return caches["redis"].client.get_client(write=False)
```
pip install django-cache-mock
```
3 changes: 3 additions & 0 deletions django_cache_mock/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django_cache_mock.mock import patch

__all__ = ("patch",)
1 change: 0 additions & 1 deletion django_cache_mock/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

logger = logging.getLogger(__name__)


try:
from django.core.cache.backends.redis import RedisCache, RedisCacheClient

Expand Down
26 changes: 26 additions & 0 deletions django_cache_mock/mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import logging

SUPPORTED_BACKENDS = {
"mockcache": "django_cache_mock.backends.memcached.MockcacheCache",
"fakeredis": "django_cache_mock.backends.redis.FakeRedisCache",
"redislite": "django_cache_mock.backends.redis.RedisLiteCache",
}

logger = logging.getLogger(__name__)


def patch(caches, cache_alias, backend, params=None, *, force=False):
current_config = caches[cache_alias]
location = current_config.get("LOCATION")
if location and not force:
logger.debug(f"Skipped cache {cache_alias} patch because LOCATION is defined.")
return False

if params is None:
params = {}

params["BACKEND"] = SUPPORTED_BACKENDS[backend]
logger.info(f"Cache {cache_alias} mocked with {backend}.")
logger.debug(f"{params=}.")
caches[cache_alias] = params
return True
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ show_missing = true

[tool.pytest.ini_options]
addopts = "--cov --black --flake8 --isort"
filterwarnings = ["ignore::DeprecationWarning", "ignore::pytest.PytestWarning"]
7 changes: 2 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,12 @@ def setup_env():
os.environ["DJANGO_SETTINGS_MODULE"] = "tests.testapp.settings"


@pytest.fixture(
scope="session",
params=["default", "mockcache", "fakeredis", "redislite"],
)
@pytest.fixture(params=["default", "mockcache", "fakeredis", "redislite"])
def cache_alias(request):
return os.getenv("CACHE_ALIAS", request.param)


@pytest.fixture(autouse=True)
@pytest.fixture
def cache_cleanup(cache_alias):
yield
caches[cache_alias].clear()
22 changes: 7 additions & 15 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import threading

import pytest
from django.core.cache import caches

from tests.thread_with_exceptions import Thread

class _Thread(threading.Thread):
def run(self):
self.exc = None
try:
super().run()
except BaseException as exc:
self.exc = exc

def join(self):
threading.Thread.join(self)
if self.exc:
raise self.exc
@pytest.fixture(autouse=True)
def always_cleanup_cache_here(cache_cleanup):
pass


def test_get_with_default_value(cache_alias):
Expand All @@ -28,11 +20,11 @@ def _threaded_assert_value(cache_alias, key, expected_value):
assert value == expected_value


def test_differente_threads_use_same_data(cache_alias):
def test_different_threads_use_same_data(cache_alias):
cache = caches[cache_alias]
key = "FOO"
value = "BAR"
cache.set(key, value)
thread = _Thread(target=_threaded_assert_value, args=(cache_alias, key, value))
thread = Thread(target=_threaded_assert_value, args=(cache_alias, key, value))
thread.start()
thread.join()
35 changes: 35 additions & 0 deletions tests/test_mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from unittest.mock import sentinel

import pytest

from django_cache_mock import patch


def test_do_not_patch_when_location_is_defined():
caches = {"default": {"LOCATION": sentinel.location}}
patch(caches, "default", "mockcache")
assert caches["default"]["LOCATION"] == sentinel.location


def test_patch_forced_when_location_is_defined():
caches = {"default": {"LOCATION": sentinel.location}}
patch(caches, "default", "mockcache", force=True)
expected_backend = "django_cache_mock.backends.memcached.MockcacheCache"
assert caches["default"]["BACKEND"] == expected_backend


def test_fail_for_cache_unknown():
with pytest.raises(KeyError):
patch({}, "default", "mockcache")


def test_fail_for_backend_unknown():
with pytest.raises(KeyError):
patch({"default": {}}, "default", "fakefake")


def test_custom_params():
caches = {"default": {"FOO": sentinel.foo}}
patch(caches, "default", "mockcache", {"BAR": sentinel.bar})
assert "FOO" not in caches["default"]
assert caches["default"]["BAR"] == sentinel.bar
12 changes: 12 additions & 0 deletions tests/test_thread_with_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pytest

from tests.thread_with_exceptions import Thread


def test_thread_error():
"""Just a sane check that _Thread really raises exceptions."""
thread = Thread(target=lambda: 1 / 0)
thread.start()

with pytest.raises(ZeroDivisionError):
thread.join()
15 changes: 15 additions & 0 deletions tests/thread_with_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import threading


class Thread(threading.Thread):
def run(self):
self.exc = None
try:
super().run()
except BaseException as exc:
self.exc = exc

def join(self):
threading.Thread.join(self)
if self.exc:
raise self.exc

0 comments on commit 474fc79

Please sign in to comment.