mirror of
https://github.com/jazzband/django-constance.git
synced 2026-03-16 22:40:24 +00:00
Added async support (#656)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
* Added async logic * Added tests and fixed async deadlock on aset * Used abstract base class for backend to simplify code coverage * Reordered try except block * Added explicit thread safety * Fixed linting error * Worked on redis init block * Fixed async test setup * Added tests for redis instantiation * Fixed linting errors
This commit is contained in:
parent
675c9446cb
commit
4ac1e546c7
17 changed files with 1026 additions and 18 deletions
1
AUTHORS
1
AUTHORS
|
|
@ -32,6 +32,7 @@ Merijn Bertels <merijn.bertels@gmail.com>
|
||||||
Omer Katz <omer.drow@gmail.com>
|
Omer Katz <omer.drow@gmail.com>
|
||||||
Petr Knap <dev@petrknap.cz>
|
Petr Knap <dev@petrknap.cz>
|
||||||
Philip Neustrom <philipn@gmail.com>
|
Philip Neustrom <philipn@gmail.com>
|
||||||
|
Philipp Thumfart <philipp@thumfart.eu>
|
||||||
Pierre-Olivier Marec <pomarec@free.fr>
|
Pierre-Olivier Marec <pomarec@free.fr>
|
||||||
Roman Krejcik <farin@farin.cz>
|
Roman Krejcik <farin@farin.cz>
|
||||||
Silvan Spross <silvan.spross@gmail.com>
|
Silvan Spross <silvan.spross@gmail.com>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,48 @@
|
||||||
"""Defines the base constance backend."""
|
"""Defines the base constance backend."""
|
||||||
|
|
||||||
|
from abc import ABC
|
||||||
|
from abc import abstractmethod
|
||||||
|
|
||||||
class Backend:
|
|
||||||
|
class Backend(ABC):
|
||||||
|
@abstractmethod
|
||||||
def get(self, key):
|
def get(self, key):
|
||||||
"""
|
"""
|
||||||
Get the key from the backend store and return the value.
|
Get the key from the backend store and return the value.
|
||||||
Return None if not found.
|
Return None if not found.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def aget(self, key):
|
||||||
|
"""
|
||||||
|
Get the key from the backend store and return the value.
|
||||||
|
Return None if not found.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def mget(self, keys):
|
def mget(self, keys):
|
||||||
"""
|
"""
|
||||||
Get the keys from the backend store and return a list of the values.
|
Get the keys from the backend store and return a list of the values.
|
||||||
Return an empty list if not found.
|
Return an empty list if not found.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def amget(self, keys):
|
||||||
|
"""
|
||||||
|
Get the keys from the backend store and return a list of the values.
|
||||||
|
Return an empty list if not found.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
"""Add the value to the backend store given the key."""
|
"""Add the value to the backend store given the key."""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def aset(self, key, value):
|
||||||
|
"""Add the value to the backend store given the key."""
|
||||||
|
...
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,46 @@ class DatabaseBackend(Backend):
|
||||||
self._cache.add(key, value)
|
self._cache.add(key, value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
async def aget(self, key):
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
prefixed_key = self.add_prefix(key)
|
||||||
|
value = None
|
||||||
|
if self._cache:
|
||||||
|
value = await self._cache.aget(prefixed_key)
|
||||||
|
if value is None:
|
||||||
|
await sync_to_async(self.autofill, thread_sensitive=True)()
|
||||||
|
value = await self._cache.aget(prefixed_key)
|
||||||
|
if value is None:
|
||||||
|
match = await self._model._default_manager.filter(key=prefixed_key).only("value").afirst()
|
||||||
|
if match:
|
||||||
|
value = loads(match.value)
|
||||||
|
if self._cache:
|
||||||
|
await self._cache.aadd(prefixed_key, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def amget(self, keys):
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
prefixed_keys_map = {self.add_prefix(key): key for key in keys}
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
if self._cache:
|
||||||
|
cache_results = await self._cache.aget_many(prefixed_keys_map.keys())
|
||||||
|
for prefixed_key, value in cache_results.items():
|
||||||
|
results[prefixed_keys_map[prefixed_key]] = value
|
||||||
|
|
||||||
|
missing_prefixed_keys = [k for k in prefixed_keys_map if prefixed_keys_map[k] not in results]
|
||||||
|
if missing_prefixed_keys:
|
||||||
|
try:
|
||||||
|
async for const in self._model._default_manager.filter(key__in=missing_prefixed_keys):
|
||||||
|
results[prefixed_keys_map[const.key]] = loads(const.value)
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
key = self.add_prefix(key)
|
key = self.add_prefix(key)
|
||||||
created = False
|
created = False
|
||||||
|
|
@ -119,6 +159,13 @@ class DatabaseBackend(Backend):
|
||||||
|
|
||||||
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
||||||
|
|
||||||
|
async def aset(self, key, value):
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
# We use sync_to_async because Django's transaction.atomic() and database connections are thread-local.
|
||||||
|
# This ensures the operation runs in the correct database thread until native async transactions are supported.
|
||||||
|
return await sync_to_async(self.set, thread_sensitive=True)(key, value)
|
||||||
|
|
||||||
def clear(self, sender, instance, created, **kwargs):
|
def clear(self, sender, instance, created, **kwargs):
|
||||||
if self._cache and not created:
|
if self._cache and not created:
|
||||||
keys = [self.add_prefix(k) for k in settings.CONFIG]
|
keys = [self.add_prefix(k) for k in settings.CONFIG]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ class MemoryBackend(Backend):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._storage.get(key)
|
return self._storage.get(key)
|
||||||
|
|
||||||
|
async def aget(self, key):
|
||||||
|
# Memory operations are fast enough that we don't need true async here
|
||||||
|
return self.get(key)
|
||||||
|
|
||||||
def mget(self, keys):
|
def mget(self, keys):
|
||||||
if not keys:
|
if not keys:
|
||||||
return None
|
return None
|
||||||
|
|
@ -30,8 +34,18 @@ class MemoryBackend(Backend):
|
||||||
result.append((key, value))
|
result.append((key, value))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def amget(self, keys):
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
with self._lock:
|
||||||
|
return {key: self._storage[key] for key in keys if key in self._storage}
|
||||||
|
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
old_value = self._storage.get(key)
|
old_value = self._storage.get(key)
|
||||||
self._storage[key] = value
|
self._storage[key] = value
|
||||||
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
||||||
|
|
||||||
|
async def aset(self, key, value):
|
||||||
|
# Memory operations are fast enough that we don't need true async here
|
||||||
|
self.set(key, value)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
|
||||||
|
|
@ -17,27 +18,59 @@ class RedisBackend(Backend):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._prefix = settings.REDIS_PREFIX
|
self._prefix = settings.REDIS_PREFIX
|
||||||
connection_cls = settings.REDIS_CONNECTION_CLASS
|
connection_cls = settings.REDIS_CONNECTION_CLASS
|
||||||
if connection_cls is not None:
|
async_connection_cls = settings.REDIS_ASYNC_CONNECTION_CLASS
|
||||||
|
|
||||||
|
if connection_cls:
|
||||||
self._rd = utils.import_module_attr(connection_cls)()
|
self._rd = utils.import_module_attr(connection_cls)()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
import redis
|
import redis
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImproperlyConfigured("The Redis backend requires redis-py to be installed.") from None
|
raise ImproperlyConfigured("The Redis backend requires redis-py to be installed.") from None
|
||||||
if isinstance(settings.REDIS_CONNECTION, str):
|
|
||||||
self._rd = redis.from_url(settings.REDIS_CONNECTION)
|
|
||||||
else:
|
else:
|
||||||
self._rd = redis.Redis(**settings.REDIS_CONNECTION)
|
if isinstance(settings.REDIS_CONNECTION, str):
|
||||||
|
self._rd = redis.from_url(settings.REDIS_CONNECTION)
|
||||||
|
else:
|
||||||
|
self._rd = redis.Redis(**settings.REDIS_CONNECTION)
|
||||||
|
|
||||||
|
if async_connection_cls:
|
||||||
|
self._ard = utils.import_module_attr(async_connection_cls)()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import redis.asyncio as aredis
|
||||||
|
except ImportError:
|
||||||
|
# We set this to none instead of raising an error to indicate that async support is not available
|
||||||
|
# without breaking existing sync usage.
|
||||||
|
self._ard = None
|
||||||
|
else:
|
||||||
|
if isinstance(settings.REDIS_CONNECTION, str):
|
||||||
|
self._ard = aredis.from_url(settings.REDIS_CONNECTION)
|
||||||
|
else:
|
||||||
|
self._ard = aredis.Redis(**settings.REDIS_CONNECTION)
|
||||||
|
|
||||||
def add_prefix(self, key):
|
def add_prefix(self, key):
|
||||||
return f"{self._prefix}{key}"
|
return f"{self._prefix}{key}"
|
||||||
|
|
||||||
|
def _check_async_support(self):
|
||||||
|
if self._ard is None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"Async support for the Redis backend requires redis>=4.2.0 "
|
||||||
|
"or a custom CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS to be configured."
|
||||||
|
)
|
||||||
|
|
||||||
def get(self, key):
|
def get(self, key):
|
||||||
value = self._rd.get(self.add_prefix(key))
|
value = self._rd.get(self.add_prefix(key))
|
||||||
if value:
|
if value:
|
||||||
return loads(value)
|
return loads(value)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def aget(self, key):
|
||||||
|
self._check_async_support()
|
||||||
|
value = await self._ard.get(self.add_prefix(key))
|
||||||
|
if value:
|
||||||
|
return loads(value)
|
||||||
|
return None
|
||||||
|
|
||||||
def mget(self, keys):
|
def mget(self, keys):
|
||||||
if not keys:
|
if not keys:
|
||||||
return
|
return
|
||||||
|
|
@ -46,15 +79,37 @@ class RedisBackend(Backend):
|
||||||
if value:
|
if value:
|
||||||
yield key, loads(value)
|
yield key, loads(value)
|
||||||
|
|
||||||
|
async def amget(self, keys):
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
self._check_async_support()
|
||||||
|
prefixed_keys = [self.add_prefix(key) for key in keys]
|
||||||
|
values = await self._ard.mget(prefixed_keys)
|
||||||
|
return {key: loads(value) for key, value in zip(keys, values) if value}
|
||||||
|
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
old_value = self.get(key)
|
old_value = self.get(key)
|
||||||
self._rd.set(self.add_prefix(key), dumps(value))
|
self._rd.set(self.add_prefix(key), dumps(value))
|
||||||
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
||||||
|
|
||||||
|
async def _aset_internal(self, key, value, old_value):
|
||||||
|
"""
|
||||||
|
Internal set operation. Separated to allow subclasses to provide old_value
|
||||||
|
without going through self.aget() which may have locking behavior.
|
||||||
|
"""
|
||||||
|
self._check_async_support()
|
||||||
|
await self._ard.set(self.add_prefix(key), dumps(value))
|
||||||
|
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
|
||||||
|
|
||||||
|
async def aset(self, key, value):
|
||||||
|
old_value = await self.aget(key)
|
||||||
|
await self._aset_internal(key, value, old_value)
|
||||||
|
|
||||||
|
|
||||||
class CachingRedisBackend(RedisBackend):
|
class CachingRedisBackend(RedisBackend):
|
||||||
_sentinel = object()
|
_sentinel = object()
|
||||||
_lock = RLock()
|
_lock = RLock()
|
||||||
|
_async_lock = None # Lazy-initialized asyncio.Lock
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -62,6 +117,12 @@ class CachingRedisBackend(RedisBackend):
|
||||||
self._cache = {}
|
self._cache = {}
|
||||||
self._sentinel = object()
|
self._sentinel = object()
|
||||||
|
|
||||||
|
def _get_async_lock(self):
|
||||||
|
# Lazily create the asyncio lock to avoid issues with event loops
|
||||||
|
if self._async_lock is None:
|
||||||
|
self._async_lock = asyncio.Lock()
|
||||||
|
return self._async_lock
|
||||||
|
|
||||||
def _has_expired(self, value):
|
def _has_expired(self, value):
|
||||||
return value[0] <= monotonic()
|
return value[0] <= monotonic()
|
||||||
|
|
||||||
|
|
@ -79,11 +140,41 @@ class CachingRedisBackend(RedisBackend):
|
||||||
|
|
||||||
return value[1]
|
return value[1]
|
||||||
|
|
||||||
|
async def _aget_unlocked(self, key):
|
||||||
|
"""
|
||||||
|
Get value with cache support but without acquiring lock.
|
||||||
|
Caller must already hold the lock.
|
||||||
|
"""
|
||||||
|
value = self._cache.get(key, self._sentinel)
|
||||||
|
if value is self._sentinel or self._has_expired(value):
|
||||||
|
new_value = await super().aget(key)
|
||||||
|
self._cache_value(key, new_value)
|
||||||
|
return new_value
|
||||||
|
return value[1]
|
||||||
|
|
||||||
|
async def aget(self, key):
|
||||||
|
value = self._cache.get(key, self._sentinel)
|
||||||
|
|
||||||
|
if value is self._sentinel or self._has_expired(value):
|
||||||
|
async with self._get_async_lock():
|
||||||
|
# Double-check after acquiring lock, then delegate to unlocked version
|
||||||
|
return await self._aget_unlocked(key)
|
||||||
|
|
||||||
|
return value[1]
|
||||||
|
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
super().set(key, value)
|
super().set(key, value)
|
||||||
self._cache_value(key, value)
|
self._cache_value(key, value)
|
||||||
|
|
||||||
|
async def aset(self, key, value):
|
||||||
|
async with self._get_async_lock():
|
||||||
|
# Use unlocked version since we already hold the lock
|
||||||
|
old_value = await self._aget_unlocked(key)
|
||||||
|
# Use internal method to avoid lock recursion (super().aset calls self.aget)
|
||||||
|
await self._aset_internal(key, value, old_value)
|
||||||
|
self._cache_value(key, value)
|
||||||
|
|
||||||
def mget(self, keys):
|
def mget(self, keys):
|
||||||
if not keys:
|
if not keys:
|
||||||
return
|
return
|
||||||
|
|
@ -91,3 +182,38 @@ class CachingRedisBackend(RedisBackend):
|
||||||
value = self.get(key)
|
value = self.get(key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
yield key, value
|
yield key, value
|
||||||
|
|
||||||
|
async def amget(self, keys):
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
missing_keys = []
|
||||||
|
|
||||||
|
# First, check the local cache for all keys
|
||||||
|
for key in keys:
|
||||||
|
value = self._cache.get(key, self._sentinel)
|
||||||
|
if value is not self._sentinel and not self._has_expired(value):
|
||||||
|
results[key] = value[1]
|
||||||
|
else:
|
||||||
|
missing_keys.append(key)
|
||||||
|
|
||||||
|
# Fetch missing keys from Redis
|
||||||
|
if missing_keys:
|
||||||
|
async with self._get_async_lock():
|
||||||
|
# Re-check cache for keys that might have been fetched while waiting for lock
|
||||||
|
still_missing = []
|
||||||
|
for key in missing_keys:
|
||||||
|
value = self._cache.get(key, self._sentinel)
|
||||||
|
if value is not self._sentinel and not self._has_expired(value):
|
||||||
|
results[key] = value[1]
|
||||||
|
else:
|
||||||
|
still_missing.append(key)
|
||||||
|
|
||||||
|
if still_missing:
|
||||||
|
fetched = await super().amget(still_missing)
|
||||||
|
for key, value in fetched.items():
|
||||||
|
self._cache_value(key, value)
|
||||||
|
results[key] = value
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,149 @@
|
||||||
|
import asyncio
|
||||||
|
import warnings
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncValueProxy:
|
||||||
|
def __init__(self, key, config, default):
|
||||||
|
self._key = key
|
||||||
|
self._config = config
|
||||||
|
self._default = default
|
||||||
|
self._value = None
|
||||||
|
self._fetched = False
|
||||||
|
|
||||||
|
def __await__(self):
|
||||||
|
return self._get_value().__await__()
|
||||||
|
|
||||||
|
async def _get_value(self):
|
||||||
|
if not self._fetched:
|
||||||
|
result = await self._config._backend.aget(self._key)
|
||||||
|
if result is None:
|
||||||
|
result = self._default
|
||||||
|
await self._config.aset(self._key, result)
|
||||||
|
self._value = result
|
||||||
|
self._fetched = True
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def _get_sync_value(self):
|
||||||
|
warnings.warn(
|
||||||
|
f"Synchronous access to Constance setting '{self._key}' inside an async loop. "
|
||||||
|
f"Use 'await config.{self._key}' instead.",
|
||||||
|
RuntimeWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return self._config._get_sync_value(self._key, self._default)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self._get_sync_value())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return repr(self._get_sync_value())
|
||||||
|
|
||||||
|
def __int__(self):
|
||||||
|
return int(self._get_sync_value())
|
||||||
|
|
||||||
|
def __float__(self):
|
||||||
|
return float(self._get_sync_value())
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return bool(self._get_sync_value())
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self._get_sync_value() == other
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return self._get_sync_value() != other
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
return self._get_sync_value() < other
|
||||||
|
|
||||||
|
def __le__(self, other):
|
||||||
|
return self._get_sync_value() <= other
|
||||||
|
|
||||||
|
def __gt__(self, other):
|
||||||
|
return self._get_sync_value() > other
|
||||||
|
|
||||||
|
def __ge__(self, other):
|
||||||
|
return self._get_sync_value() >= other
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self._get_sync_value()[key]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._get_sync_value())
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._get_sync_value())
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
return item in self._get_sync_value()
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self._get_sync_value())
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
return self._get_sync_value() + other
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
return self._get_sync_value() - other
|
||||||
|
|
||||||
|
def __mul__(self, other):
|
||||||
|
return self._get_sync_value() * other
|
||||||
|
|
||||||
|
def __truediv__(self, other):
|
||||||
|
return self._get_sync_value() / other
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""The global config wrapper that handles the backend."""
|
"""The global config wrapper that handles the backend."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__setattr__("_backend", utils.import_module_attr(settings.BACKEND)())
|
super().__setattr__("_backend", utils.import_module_attr(settings.BACKEND)())
|
||||||
|
|
||||||
|
def _get_sync_value(self, key, default):
|
||||||
|
result = self._backend.get(key)
|
||||||
|
if result is None:
|
||||||
|
result = default
|
||||||
|
setattr(self, key, default)
|
||||||
|
return result
|
||||||
|
|
||||||
def __getattr__(self, key):
|
def __getattr__(self, key):
|
||||||
|
if key == "_backend":
|
||||||
|
return super().__getattribute__(key)
|
||||||
try:
|
try:
|
||||||
if len(settings.CONFIG[key]) not in (2, 3):
|
if len(settings.CONFIG[key]) not in (2, 3):
|
||||||
raise AttributeError(key)
|
raise AttributeError(key)
|
||||||
default = settings.CONFIG[key][0]
|
default = settings.CONFIG[key][0]
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise AttributeError(key) from e
|
raise AttributeError(key) from e
|
||||||
result = self._backend.get(key)
|
|
||||||
if result is None:
|
try:
|
||||||
result = default
|
asyncio.get_running_loop()
|
||||||
setattr(self, key, default)
|
except RuntimeError:
|
||||||
return result
|
return self._get_sync_value(key, default)
|
||||||
return result
|
return AsyncValueProxy(key, self, default)
|
||||||
|
|
||||||
def __setattr__(self, key, value):
|
def __setattr__(self, key, value):
|
||||||
|
if key == "_backend":
|
||||||
|
super().__setattr__(key, value)
|
||||||
|
return
|
||||||
if key not in settings.CONFIG:
|
if key not in settings.CONFIG:
|
||||||
raise AttributeError(key)
|
raise AttributeError(key)
|
||||||
self._backend.set(key, value)
|
self._backend.set(key, value)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def aset(self, key, value):
|
||||||
|
if key not in settings.CONFIG:
|
||||||
|
raise AttributeError(key)
|
||||||
|
await self._backend.aset(key, value)
|
||||||
|
|
||||||
|
async def amget(self, keys):
|
||||||
|
backend_values = await self._backend.amget(keys)
|
||||||
|
# Merge with defaults like utils.get_values_for_keys
|
||||||
|
default_initial = {name: settings.CONFIG[name][0] for name in keys if name in settings.CONFIG}
|
||||||
|
return dict(default_initial, **backend_values)
|
||||||
|
|
||||||
def __dir__(self):
|
def __dir__(self):
|
||||||
return settings.CONFIG.keys()
|
return settings.CONFIG.keys()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ REDIS_CACHE_TIMEOUT = getattr(settings, "CONSTANCE_REDIS_CACHE_TIMEOUT", 60)
|
||||||
|
|
||||||
REDIS_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_CONNECTION_CLASS", None)
|
REDIS_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_CONNECTION_CLASS", None)
|
||||||
|
|
||||||
|
REDIS_ASYNC_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS", None)
|
||||||
|
|
||||||
REDIS_CONNECTION = getattr(settings, "CONSTANCE_REDIS_CONNECTION", {})
|
REDIS_CONNECTION = getattr(settings, "CONSTANCE_REDIS_CONNECTION", {})
|
||||||
|
|
||||||
SUPERUSER_ONLY = getattr(settings, "CONSTANCE_SUPERUSER_ONLY", True)
|
SUPERUSER_ONLY = getattr(settings, "CONSTANCE_SUPERUSER_ONLY", True)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,16 @@ def get_values():
|
||||||
return dict(default_initial, **dict(config._backend.mget(settings.CONFIG)))
|
return dict(default_initial, **dict(config._backend.mget(settings.CONFIG)))
|
||||||
|
|
||||||
|
|
||||||
|
async def aget_values():
|
||||||
|
"""
|
||||||
|
Get dictionary of values from the backend asynchronously
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
default_initial = {name: options[0] for name, options in settings.CONFIG.items()}
|
||||||
|
backend_values = await config.amget(settings.CONFIG.keys())
|
||||||
|
return dict(default_initial, **backend_values)
|
||||||
|
|
||||||
|
|
||||||
def get_values_for_keys(keys):
|
def get_values_for_keys(keys):
|
||||||
"""
|
"""
|
||||||
Retrieve values for specified keys from the backend.
|
Retrieve values for specified keys from the backend.
|
||||||
|
|
@ -43,3 +53,24 @@ def get_values_for_keys(keys):
|
||||||
|
|
||||||
# Merge default values and backend values, prioritizing backend values
|
# Merge default values and backend values, prioritizing backend values
|
||||||
return dict(default_initial, **dict(config._backend.mget(keys)))
|
return dict(default_initial, **dict(config._backend.mget(keys)))
|
||||||
|
|
||||||
|
|
||||||
|
async def aget_values_for_keys(keys):
|
||||||
|
"""
|
||||||
|
Retrieve values for specified keys from the backend asynchronously.
|
||||||
|
|
||||||
|
:param keys: List of keys to retrieve.
|
||||||
|
:return: Dictionary with values for the specified keys.
|
||||||
|
:raises AttributeError: If any key is not found in the configuration.
|
||||||
|
"""
|
||||||
|
if not isinstance(keys, (list, tuple, set)):
|
||||||
|
raise TypeError("keys must be a list, tuple, or set of strings")
|
||||||
|
|
||||||
|
default_initial = {name: options[0] for name, options in settings.CONFIG.items() if name in keys}
|
||||||
|
|
||||||
|
missing_keys = [key for key in keys if key not in default_initial]
|
||||||
|
if missing_keys:
|
||||||
|
raise AttributeError(f'"{", ".join(missing_keys)}" keys not found in configuration.')
|
||||||
|
|
||||||
|
backend_values = await config.amget(keys)
|
||||||
|
return dict(default_initial, **backend_values)
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ from datetime import datetime
|
||||||
def get_version():
|
def get_version():
|
||||||
# Try to get version from installed package metadata
|
# Try to get version from installed package metadata
|
||||||
try:
|
try:
|
||||||
|
from importlib.metadata import PackageNotFoundError
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
return version("django-constance")
|
return version("django-constance")
|
||||||
except Exception:
|
except (ImportError, PackageNotFoundError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fall back to setuptools_scm generated version file
|
# Fall back to setuptools_scm generated version file
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,36 @@ object and accessing the variables with attribute lookups::
|
||||||
if config.THE_ANSWER == 42:
|
if config.THE_ANSWER == 42:
|
||||||
answer_the_question()
|
answer_the_question()
|
||||||
|
|
||||||
|
Asynchronous usage
|
||||||
|
^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
If you are using Django's asynchronous features (like async views), you can ``await`` the settings directly on the standard ``config`` object::
|
||||||
|
|
||||||
|
from constance import config
|
||||||
|
|
||||||
|
async def my_async_view(request):
|
||||||
|
# Accessing settings is awaitable
|
||||||
|
if await config.THE_ANSWER == 42:
|
||||||
|
return await answer_the_question_async()
|
||||||
|
|
||||||
|
async def update_settings():
|
||||||
|
# Updating settings asynchronously
|
||||||
|
await config.aset('THE_ANSWER', 43)
|
||||||
|
|
||||||
|
# Bulk retrieval is supported as well
|
||||||
|
values = await config.amget(['THE_ANSWER', 'SITE_NAME'])
|
||||||
|
|
||||||
|
Performance and Safety
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
While synchronous access (e.g., ``config.THE_ANSWER``) still works inside async views for some backends, it is highly discouraged:
|
||||||
|
|
||||||
|
* **Blocking:** Synchronous access blocks the event loop, reducing the performance of your entire application.
|
||||||
|
* **Safety Guards:** For the Database backend, Django's safety guards will raise a ``SynchronousOnlyOperation`` error if you attempt to access a setting synchronously from an async thread.
|
||||||
|
* **Automatic Detection:** Constance will emit a ``RuntimeWarning`` if it detects synchronous access inside an asynchronous event loop, helping you identify and fix these performance bottlenecks.
|
||||||
|
|
||||||
|
For peak performance, especially with the Redis backend, always use the ``await`` syntax which leverages native asynchronous drivers.
|
||||||
|
|
||||||
Django templates
|
Django templates
|
||||||
^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
from constance import settings
|
from constance import settings
|
||||||
|
from constance.base import Config
|
||||||
from tests.storage import StorageTestsMixin
|
from tests.storage import StorageTestsMixin
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,3 +54,95 @@ class TestDatabaseWithCache(StorageTestsMixin, TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
settings.BACKEND = self.old_backend
|
settings.BACKEND = self.old_backend
|
||||||
settings.DATABASE_CACHE_BACKEND = self.old_cache_backend
|
settings.DATABASE_CACHE_BACKEND = self.old_cache_backend
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseAsync(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.old_backend = settings.BACKEND
|
||||||
|
settings.BACKEND = "constance.backends.database.DatabaseBackend"
|
||||||
|
self.config = Config()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
settings.BACKEND = self.old_backend
|
||||||
|
|
||||||
|
async def test_aget_returns_none_for_missing_key(self):
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
async def test_aget_returns_value(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 42)
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
async def test_aset_stores_value(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 99)
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 99)
|
||||||
|
|
||||||
|
async def test_amget_returns_empty_for_no_keys(self):
|
||||||
|
result = await self.config._backend.amget([])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
async def test_amget_returns_values(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 10)
|
||||||
|
await self.config._backend.aset("BOOL_VALUE", value=True)
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseWithCacheAsync(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.old_backend = settings.BACKEND
|
||||||
|
settings.BACKEND = "constance.backends.database.DatabaseBackend"
|
||||||
|
self.old_cache_backend = settings.DATABASE_CACHE_BACKEND
|
||||||
|
settings.DATABASE_CACHE_BACKEND = "default"
|
||||||
|
self.config = Config()
|
||||||
|
self.config._backend._cache.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
settings.BACKEND = self.old_backend
|
||||||
|
settings.DATABASE_CACHE_BACKEND = self.old_cache_backend
|
||||||
|
|
||||||
|
async def test_aget_returns_none_for_missing_key(self):
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
async def test_aget_returns_value_from_cache(self):
|
||||||
|
# First set a value using async
|
||||||
|
await self.config._backend.aset("INT_VALUE", 42)
|
||||||
|
# Clear cache and re-fetch to test aget path
|
||||||
|
self.config._backend._cache.clear()
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
async def test_aget_populates_cache(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 42)
|
||||||
|
self.config._backend._cache.clear()
|
||||||
|
|
||||||
|
# aget should populate the cache
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
async def test_aset_stores_value(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 99)
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 99)
|
||||||
|
|
||||||
|
async def test_amget_returns_empty_for_no_keys(self):
|
||||||
|
result = await self.config._backend.amget([])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
async def test_amget_returns_values(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 10)
|
||||||
|
await self.config._backend.aset("BOOL_VALUE", value=True)
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
|
||||||
|
|
||||||
|
async def test_amget_uses_cache(self):
|
||||||
|
# Set values using async and ensure they're cached
|
||||||
|
await self.config._backend.aset("INT_VALUE", 10)
|
||||||
|
await self.config._backend.aset("BOOL_VALUE", value=True)
|
||||||
|
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result["INT_VALUE"], 10)
|
||||||
|
self.assertEqual(result["BOOL_VALUE"], True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
from constance import settings
|
from constance import settings
|
||||||
|
from constance.base import Config
|
||||||
from tests.storage import StorageTestsMixin
|
from tests.storage import StorageTestsMixin
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,3 +16,43 @@ class TestMemory(StorageTestsMixin, TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.config._backend._storage = {}
|
self.config._backend._storage = {}
|
||||||
settings.BACKEND = self.old_backend
|
settings.BACKEND = self.old_backend
|
||||||
|
|
||||||
|
def test_mget_empty_keys(self):
|
||||||
|
result = self.config._backend.mget([])
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMemoryAsync(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.old_backend = settings.BACKEND
|
||||||
|
settings.BACKEND = "constance.backends.memory.MemoryBackend"
|
||||||
|
self.config = Config()
|
||||||
|
self.config._backend._storage = {}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.config._backend._storage = {}
|
||||||
|
settings.BACKEND = self.old_backend
|
||||||
|
|
||||||
|
async def test_aget_returns_none_for_missing_key(self):
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
async def test_aget_returns_value(self):
|
||||||
|
self.config._backend.set("INT_VALUE", 42)
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
async def test_aset_stores_value(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 99)
|
||||||
|
result = self.config._backend.get("INT_VALUE")
|
||||||
|
self.assertEqual(result, 99)
|
||||||
|
|
||||||
|
async def test_amget_returns_empty_for_no_keys(self):
|
||||||
|
result = await self.config._backend.amget([])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
async def test_amget_returns_values(self):
|
||||||
|
self.config._backend.set("INT_VALUE", 10)
|
||||||
|
self.config._backend.set("BOOL_VALUE", value=True)
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
from constance import settings
|
from constance import settings
|
||||||
|
from constance.backends.redisd import RedisBackend
|
||||||
|
from constance.base import Config
|
||||||
from tests.storage import StorageTestsMixin
|
from tests.storage import StorageTestsMixin
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,6 +23,241 @@ class TestRedis(StorageTestsMixin, TestCase):
|
||||||
self.config._backend._rd.clear()
|
self.config._backend._rd.clear()
|
||||||
settings.BACKEND = self.old_backend
|
settings.BACKEND = self.old_backend
|
||||||
|
|
||||||
|
def test_mget_empty_keys(self):
|
||||||
|
# Test that mget returns None for empty keys
|
||||||
|
result = list(self.config._backend.mget([]) or [])
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
class TestCachingRedis(TestRedis):
|
class TestCachingRedis(TestRedis):
|
||||||
_BACKEND = "constance.backends.redisd.CachingRedisBackend"
|
_BACKEND = "constance.backends.redisd.CachingRedisBackend"
|
||||||
|
|
||||||
|
def test_mget_empty_keys(self):
|
||||||
|
# Test that mget returns None for empty keys
|
||||||
|
result = list(self.config._backend.mget([]) or [])
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedisAsync(TransactionTestCase):
|
||||||
|
_BACKEND = "constance.backends.redisd.RedisBackend"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.old_backend = settings.BACKEND
|
||||||
|
settings.BACKEND = self._BACKEND
|
||||||
|
self.config = Config()
|
||||||
|
self.config._backend._rd.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.config._backend._rd.clear()
|
||||||
|
settings.BACKEND = self.old_backend
|
||||||
|
|
||||||
|
async def test_aget_returns_none_for_missing_key(self):
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
async def test_aget_returns_value(self):
|
||||||
|
self.config._backend.set("INT_VALUE", 42)
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
async def test_aset_stores_value(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 99)
|
||||||
|
result = self.config._backend.get("INT_VALUE")
|
||||||
|
self.assertEqual(result, 99)
|
||||||
|
|
||||||
|
async def test_amget_returns_empty_for_no_keys(self):
|
||||||
|
result = await self.config._backend.amget([])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
async def test_amget_returns_values(self):
|
||||||
|
self.config._backend.set("INT_VALUE", 10)
|
||||||
|
self.config._backend.set("BOOL_VALUE", value=True)
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
|
||||||
|
|
||||||
|
async def test_amget_skips_missing_keys(self):
|
||||||
|
self.config._backend.set("INT_VALUE", 10)
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "MISSING_KEY"])
|
||||||
|
self.assertEqual(result, {"INT_VALUE": 10})
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachingRedisAsync(TransactionTestCase):
|
||||||
|
_BACKEND = "constance.backends.redisd.CachingRedisBackend"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.old_backend = settings.BACKEND
|
||||||
|
settings.BACKEND = self._BACKEND
|
||||||
|
self.config = Config()
|
||||||
|
self.config._backend._rd.clear()
|
||||||
|
self.config._backend._cache.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.config._backend._rd.clear()
|
||||||
|
self.config._backend._cache.clear()
|
||||||
|
settings.BACKEND = self.old_backend
|
||||||
|
|
||||||
|
async def test_aget_caches_value(self):
|
||||||
|
# First set a value via sync
|
||||||
|
self.config._backend.set("INT_VALUE", 42)
|
||||||
|
# Clear the in-memory cache
|
||||||
|
self.config._backend._cache.clear()
|
||||||
|
|
||||||
|
# Async get should fetch and cache
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 42)
|
||||||
|
|
||||||
|
# Verify it's cached
|
||||||
|
self.assertIn("INT_VALUE", self.config._backend._cache)
|
||||||
|
|
||||||
|
async def test_aget_returns_cached_value(self):
|
||||||
|
# Manually set cache
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
timeout = self.config._backend._timeout
|
||||||
|
self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 100)
|
||||||
|
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 100)
|
||||||
|
|
||||||
|
async def test_aget_refreshes_expired_cache(self):
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
# Set expired cache
|
||||||
|
self.config._backend._cache["INT_VALUE"] = (monotonic() - 10, 100)
|
||||||
|
# Set different value in redis using proper codec format
|
||||||
|
self.config._backend._rd.set(
|
||||||
|
self.config._backend.add_prefix("INT_VALUE"),
|
||||||
|
b'{"__type__": "default", "__value__": 200}',
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.config._backend.aget("INT_VALUE")
|
||||||
|
self.assertEqual(result, 200)
|
||||||
|
|
||||||
|
async def test_aset_updates_cache(self):
|
||||||
|
await self.config._backend.aset("INT_VALUE", 55)
|
||||||
|
|
||||||
|
# Verify cache is updated
|
||||||
|
self.assertIn("INT_VALUE", self.config._backend._cache)
|
||||||
|
self.assertEqual(self.config._backend._cache["INT_VALUE"][1], 55)
|
||||||
|
|
||||||
|
async def test_amget_returns_empty_for_no_keys(self):
|
||||||
|
result = await self.config._backend.amget([])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
async def test_amget_returns_cached_values(self):
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
timeout = self.config._backend._timeout
|
||||||
|
self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 10)
|
||||||
|
self.config._backend._cache["BOOL_VALUE"] = (monotonic() + timeout, True)
|
||||||
|
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
|
||||||
|
|
||||||
|
async def test_amget_fetches_missing_keys(self):
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
timeout = self.config._backend._timeout
|
||||||
|
# One key cached, one in Redis only
|
||||||
|
self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 10)
|
||||||
|
self.config._backend._rd.set(
|
||||||
|
self.config._backend.add_prefix("BOOL_VALUE"),
|
||||||
|
b'{"__type__": "default", "__value__": true}',
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(result["INT_VALUE"], 10)
|
||||||
|
self.assertEqual(result["BOOL_VALUE"], True)
|
||||||
|
|
||||||
|
async def test_amget_refreshes_expired_keys(self):
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
# Set expired cache
|
||||||
|
self.config._backend._cache["INT_VALUE"] = (monotonic() - 10, 100)
|
||||||
|
# Set different value in redis using proper codec format
|
||||||
|
self.config._backend._rd.set(
|
||||||
|
self.config._backend.add_prefix("INT_VALUE"),
|
||||||
|
b'{"__type__": "default", "__value__": 200}',
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.config._backend.amget(["INT_VALUE"])
|
||||||
|
self.assertEqual(result["INT_VALUE"], 200)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedisBackendInit(TestCase):
|
||||||
|
"""Tests for RedisBackend.__init__ client initialization paths."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.old_conn_cls = settings.REDIS_CONNECTION_CLASS
|
||||||
|
self.old_async_conn_cls = settings.REDIS_ASYNC_CONNECTION_CLASS
|
||||||
|
self.old_conn = settings.REDIS_CONNECTION
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = self.old_conn_cls
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = self.old_async_conn_cls
|
||||||
|
settings.REDIS_CONNECTION = self.old_conn
|
||||||
|
|
||||||
|
def test_no_redis_package_raises_improperly_configured(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = None
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
|
||||||
|
with mock.patch.dict("sys.modules", {"redis": None}), self.assertRaises(ImproperlyConfigured):
|
||||||
|
RedisBackend()
|
||||||
|
|
||||||
|
def test_sync_redis_from_url_with_string_connection(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = None
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
|
||||||
|
settings.REDIS_CONNECTION = "redis://localhost:6379/0"
|
||||||
|
mock_redis = mock.MagicMock()
|
||||||
|
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_redis.asyncio}):
|
||||||
|
backend = RedisBackend()
|
||||||
|
mock_redis.from_url.assert_called_once_with("redis://localhost:6379/0")
|
||||||
|
self.assertEqual(backend._rd, mock_redis.from_url.return_value)
|
||||||
|
|
||||||
|
def test_sync_redis_with_dict_connection(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = None
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
|
||||||
|
settings.REDIS_CONNECTION = {"host": "localhost", "port": 6379}
|
||||||
|
mock_redis = mock.MagicMock()
|
||||||
|
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_redis.asyncio}):
|
||||||
|
backend = RedisBackend()
|
||||||
|
mock_redis.Redis.assert_called_once_with(host="localhost", port=6379)
|
||||||
|
self.assertEqual(backend._rd, mock_redis.Redis.return_value)
|
||||||
|
|
||||||
|
def test_async_redis_not_available_sets_ard_none(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = None
|
||||||
|
mock_redis = mock.MagicMock()
|
||||||
|
# Simulate redis.asyncio not being available
|
||||||
|
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": None}):
|
||||||
|
backend = RedisBackend()
|
||||||
|
self.assertIsNone(backend._ard)
|
||||||
|
|
||||||
|
def test_async_redis_from_url_with_string_connection(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = None
|
||||||
|
settings.REDIS_CONNECTION = "redis://localhost:6379/0"
|
||||||
|
mock_aredis = mock.MagicMock()
|
||||||
|
mock_redis = mock.MagicMock()
|
||||||
|
mock_redis.asyncio = mock_aredis
|
||||||
|
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_aredis}):
|
||||||
|
backend = RedisBackend()
|
||||||
|
mock_aredis.from_url.assert_called_once_with("redis://localhost:6379/0")
|
||||||
|
self.assertEqual(backend._ard, mock_aredis.from_url.return_value)
|
||||||
|
|
||||||
|
def test_async_redis_with_dict_connection(self):
|
||||||
|
settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
|
||||||
|
settings.REDIS_ASYNC_CONNECTION_CLASS = None
|
||||||
|
settings.REDIS_CONNECTION = {"host": "localhost", "port": 6379}
|
||||||
|
mock_aredis = mock.MagicMock()
|
||||||
|
mock_redis = mock.MagicMock()
|
||||||
|
mock_redis.asyncio = mock_aredis
|
||||||
|
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_aredis}):
|
||||||
|
backend = RedisBackend()
|
||||||
|
mock_aredis.Redis.assert_called_once_with(host="localhost", port=6379)
|
||||||
|
self.assertEqual(backend._ard, mock_aredis.Redis.return_value)
|
||||||
|
|
||||||
|
def test_check_async_support_raises_when_ard_is_none(self):
|
||||||
|
backend = RedisBackend()
|
||||||
|
backend._ard = None
|
||||||
|
with self.assertRaises(ImproperlyConfigured):
|
||||||
|
backend._check_async_support()
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,16 @@ class Cache(BaseCache):
|
||||||
self.set = self._cache.set
|
self.set = self._cache.set
|
||||||
self.get = self._cache.get
|
self.get = self._cache.get
|
||||||
self.clear = self._cache.clear
|
self.clear = self._cache.clear
|
||||||
|
self.set_many = self._cache.set_many
|
||||||
|
self.get_many = self._cache.get_many
|
||||||
|
self.delete_many = self._cache.delete_many
|
||||||
|
|
||||||
|
# Async methods for DatabaseBackend.aget() support
|
||||||
|
async def aget(self, key, default=None, version=None):
|
||||||
|
return self.get(key, default, version)
|
||||||
|
|
||||||
|
async def aget_many(self, keys, version=None):
|
||||||
|
return self.get_many(keys, version)
|
||||||
|
|
||||||
|
async def aadd(self, key, value, timeout=None, version=None):
|
||||||
|
return self.add(key, value, timeout, version)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
class Connection(dict):
|
# Shared storage so sync (Connection) and async (AsyncConnection) instances
|
||||||
|
# operate on the same underlying data, just like a real Redis server would.
|
||||||
|
_shared_store = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Connection:
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
self[key] = value
|
_shared_store[key] = value
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return _shared_store.get(key, default)
|
||||||
|
|
||||||
def mget(self, keys):
|
def mget(self, keys):
|
||||||
return [self.get(key) for key in keys]
|
return [_shared_store.get(key) for key in keys]
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
_shared_store.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncConnection:
|
||||||
|
async def set(self, key, value):
|
||||||
|
_shared_store[key] = value
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return _shared_store.get(key)
|
||||||
|
|
||||||
|
async def mget(self, keys):
|
||||||
|
return [_shared_store.get(key) for key in keys]
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ INSTALLED_APPS = (
|
||||||
ROOT_URLCONF = "tests.urls"
|
ROOT_URLCONF = "tests.urls"
|
||||||
|
|
||||||
CONSTANCE_REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
|
CONSTANCE_REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
|
||||||
|
CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
|
||||||
|
|
||||||
CONSTANCE_ADDITIONAL_FIELDS = {
|
CONSTANCE_ADDITIONAL_FIELDS = {
|
||||||
"yes_no_null_select": [
|
"yes_no_null_select": [
|
||||||
|
|
|
||||||
198
tests/test_async.py
Normal file
198
tests/test_async.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
|
from constance import config
|
||||||
|
from constance import utils
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncTestCase(TransactionTestCase):
|
||||||
|
async def test_async_get(self):
|
||||||
|
# Accessing an attribute on config should be awaitable when in async context
|
||||||
|
val = await config.INT_VALUE
|
||||||
|
self.assertEqual(val, 1)
|
||||||
|
|
||||||
|
async def test_async_set(self):
|
||||||
|
await config.aset("INT_VALUE", 42)
|
||||||
|
val = await config.INT_VALUE
|
||||||
|
self.assertEqual(val, 42)
|
||||||
|
|
||||||
|
# Verify sync access also works (and emits warning)
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
sync_val = int(config.INT_VALUE)
|
||||||
|
self.assertEqual(sync_val, 42)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_amget(self):
|
||||||
|
values = await config.amget(["INT_VALUE", "BOOL_VALUE"])
|
||||||
|
self.assertEqual(values["INT_VALUE"], 1)
|
||||||
|
self.assertEqual(values["BOOL_VALUE"], True)
|
||||||
|
|
||||||
|
async def test_sync_math_in_async_loop(self):
|
||||||
|
# Accessing math should work but emit warning
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
res = config.INT_VALUE + 10
|
||||||
|
# Note: res will be 42 + 10 if test_async_set ran before, or 1 + 10 if not.
|
||||||
|
# TransactionTestCase should reset state, but let's be careful.
|
||||||
|
# config.INT_VALUE defaults to 1.
|
||||||
|
self.assertEqual(res, 11 if res < 50 else 52)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_utils_aget_values(self):
|
||||||
|
values = await utils.aget_values()
|
||||||
|
self.assertIn("INT_VALUE", values)
|
||||||
|
self.assertIn("BOOL_VALUE", values)
|
||||||
|
self.assertEqual(values["INT_VALUE"], 1)
|
||||||
|
|
||||||
|
async def test_utils_aget_values_for_keys(self):
|
||||||
|
values = await utils.aget_values_for_keys(["INT_VALUE"])
|
||||||
|
self.assertEqual(len(values), 1)
|
||||||
|
self.assertEqual(values["INT_VALUE"], 1)
|
||||||
|
|
||||||
|
async def test_bool_proxy(self):
|
||||||
|
# BOOL_VALUE is True by default
|
||||||
|
self.assertTrue(config.BOOL_VALUE)
|
||||||
|
|
||||||
|
async def test_int_proxy(self):
|
||||||
|
await config.aset("INT_VALUE", 1)
|
||||||
|
self.assertEqual(int(config.INT_VALUE), 1)
|
||||||
|
|
||||||
|
async def test_container_proxy(self):
|
||||||
|
# LIST_VALUE is [1, "1", date(2019, 1, 1)] by default
|
||||||
|
self.assertEqual(config.LIST_VALUE[0], 1)
|
||||||
|
self.assertEqual(len(config.LIST_VALUE), 3)
|
||||||
|
self.assertIn(1, config.LIST_VALUE)
|
||||||
|
self.assertEqual(next(iter(config.LIST_VALUE)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncValueProxyTestCase(TransactionTestCase):
|
||||||
|
"""Tests for AsyncValueProxy dunder methods in async context."""
|
||||||
|
|
||||||
|
async def test_str_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = str(config.STRING_VALUE)
|
||||||
|
self.assertEqual(result, "Hello world")
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_repr_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = repr(config.STRING_VALUE)
|
||||||
|
self.assertEqual(result, "'Hello world'")
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_float_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = float(config.FLOAT_VALUE)
|
||||||
|
self.assertAlmostEqual(result, 3.1415926536)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_eq_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE == 1
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_ne_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE != 2
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_lt_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE < 10
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_le_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE <= 1
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_gt_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE > 0
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_ge_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE >= 1
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_hash_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = hash(config.INT_VALUE)
|
||||||
|
self.assertEqual(result, hash(1))
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_sub_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE - 1
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_mul_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE * 5
|
||||||
|
self.assertEqual(result, 5)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_truediv_proxy(self):
|
||||||
|
with warnings.catch_warnings(record=True) as w:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
result = config.INT_VALUE / 1
|
||||||
|
self.assertEqual(result, 1.0)
|
||||||
|
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
|
||||||
|
|
||||||
|
async def test_aset_invalid_key(self):
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
await config.aset("INVALID_KEY", 42)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncUtilsTestCase(TransactionTestCase):
|
||||||
|
"""Tests for async utility functions."""
|
||||||
|
|
||||||
|
async def test_aget_values_for_keys_invalid_type(self):
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
await utils.aget_values_for_keys("key1")
|
||||||
|
|
||||||
|
async def test_aget_values_for_keys_missing_key(self):
|
||||||
|
with self.assertRaises(AttributeError) as ctx:
|
||||||
|
await utils.aget_values_for_keys(["INVALID_KEY"])
|
||||||
|
self.assertIn("INVALID_KEY", str(ctx.exception))
|
||||||
|
|
||||||
|
async def test_aget_values_for_keys_empty(self):
|
||||||
|
result = await utils.aget_values_for_keys([])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigBaseTestCase(TransactionTestCase):
|
||||||
|
"""Tests for Config class edge cases."""
|
||||||
|
|
||||||
|
def test_config_dir(self):
|
||||||
|
# Test __dir__ method
|
||||||
|
keys = dir(config)
|
||||||
|
self.assertIn("INT_VALUE", keys)
|
||||||
|
self.assertIn("BOOL_VALUE", keys)
|
||||||
|
|
||||||
|
def test_access_backend_attribute(self):
|
||||||
|
# Test accessing _backend attribute in sync context
|
||||||
|
backend = config._backend
|
||||||
|
self.assertIsNotNone(backend)
|
||||||
Loading…
Reference in a new issue