diff --git a/AUTHORS b/AUTHORS index 2431dcd..2f3b936 100644 --- a/AUTHORS +++ b/AUTHORS @@ -32,6 +32,7 @@ Merijn Bertels Omer Katz Petr Knap Philip Neustrom +Philipp Thumfart Pierre-Olivier Marec Roman Krejcik Silvan Spross diff --git a/constance/backends/__init__.py b/constance/backends/__init__.py index d5cb90d..3d5f8b6 100644 --- a/constance/backends/__init__.py +++ b/constance/backends/__init__.py @@ -1,21 +1,48 @@ """Defines the base constance backend.""" +from abc import ABC +from abc import abstractmethod -class Backend: + +class Backend(ABC): + @abstractmethod def get(self, key): """ Get the key from the backend store and return the value. 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): """ Get the keys from the backend store and return a list of the values. 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): """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.""" + ... diff --git a/constance/backends/database.py b/constance/backends/database.py index e2cbdd5..9849266 100644 --- a/constance/backends/database.py +++ b/constance/backends/database.py @@ -85,6 +85,46 @@ class DatabaseBackend(Backend): self._cache.add(key, 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): key = self.add_prefix(key) created = False @@ -119,6 +159,13 @@ class DatabaseBackend(Backend): 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): if self._cache and not created: keys = [self.add_prefix(k) for k in settings.CONFIG] diff --git a/constance/backends/memory.py b/constance/backends/memory.py index 2c40b45..c6d9c0e 100644 --- a/constance/backends/memory.py +++ b/constance/backends/memory.py @@ -19,6 +19,10 @@ class MemoryBackend(Backend): with self._lock: 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): if not keys: return None @@ -30,8 +34,18 @@ class MemoryBackend(Backend): result.append((key, value)) 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): with self._lock: old_value = self._storage.get(key) self._storage[key] = 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) diff --git a/constance/backends/redisd.py b/constance/backends/redisd.py index 22e8f33..0870257 100644 --- a/constance/backends/redisd.py +++ b/constance/backends/redisd.py @@ -1,3 +1,4 @@ +import asyncio from threading import RLock from time import monotonic @@ -17,27 +18,59 @@ class RedisBackend(Backend): super().__init__() self._prefix = settings.REDIS_PREFIX 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)() else: try: import redis except ImportError: 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: - 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): 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): value = self._rd.get(self.add_prefix(key)) if value: return loads(value) 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): if not keys: return @@ -46,15 +79,37 @@ class RedisBackend(Backend): if 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): old_value = self.get(key) self._rd.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_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): _sentinel = object() _lock = RLock() + _async_lock = None # Lazy-initialized asyncio.Lock def __init__(self): super().__init__() @@ -62,6 +117,12 @@ class CachingRedisBackend(RedisBackend): self._cache = {} 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): return value[0] <= monotonic() @@ -79,11 +140,41 @@ class CachingRedisBackend(RedisBackend): 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): with self._lock: super().set(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): if not keys: return @@ -91,3 +182,38 @@ class CachingRedisBackend(RedisBackend): value = self.get(key) if value is not None: 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 diff --git a/constance/base.py b/constance/base.py index 92e6dfa..6dbcb8a 100644 --- a/constance/base.py +++ b/constance/base.py @@ -1,31 +1,149 @@ +import asyncio +import warnings + from . import settings 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: """The global config wrapper that handles the backend.""" def __init__(self): 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): + if key == "_backend": + return super().__getattribute__(key) try: if len(settings.CONFIG[key]) not in (2, 3): raise AttributeError(key) default = settings.CONFIG[key][0] except KeyError as e: raise AttributeError(key) from e - result = self._backend.get(key) - if result is None: - result = default - setattr(self, key, default) - return result - return result + + try: + asyncio.get_running_loop() + except RuntimeError: + return self._get_sync_value(key, default) + return AsyncValueProxy(key, self, default) def __setattr__(self, key, value): + if key == "_backend": + super().__setattr__(key, value) + return if key not in settings.CONFIG: raise AttributeError(key) 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): return settings.CONFIG.keys() diff --git a/constance/settings.py b/constance/settings.py index 988ca84..59dfa18 100644 --- a/constance/settings.py +++ b/constance/settings.py @@ -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_ASYNC_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS", None) + REDIS_CONNECTION = getattr(settings, "CONSTANCE_REDIS_CONNECTION", {}) SUPERUSER_ONLY = getattr(settings, "CONSTANCE_SUPERUSER_ONLY", True) diff --git a/constance/utils.py b/constance/utils.py index 87dfe85..0fabe16 100644 --- a/constance/utils.py +++ b/constance/utils.py @@ -22,6 +22,16 @@ def get_values(): 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): """ 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 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) diff --git a/docs/conf.py b/docs/conf.py index df6d637..9cc7030 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,10 +11,11 @@ from datetime import datetime def get_version(): # Try to get version from installed package metadata try: + from importlib.metadata import PackageNotFoundError from importlib.metadata import version return version("django-constance") - except Exception: + except (ImportError, PackageNotFoundError): pass # Fall back to setuptools_scm generated version file diff --git a/docs/index.rst b/docs/index.rst index 7d13d58..ee7bed8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -292,6 +292,36 @@ object and accessing the variables with attribute lookups:: if config.THE_ANSWER == 42: 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 ^^^^^^^^^^^^^^^^ diff --git a/tests/backends/test_database.py b/tests/backends/test_database.py index f83c21e..4da6e0d 100644 --- a/tests/backends/test_database.py +++ b/tests/backends/test_database.py @@ -1,6 +1,8 @@ from django.test import TestCase +from django.test import TransactionTestCase from constance import settings +from constance.base import Config from tests.storage import StorageTestsMixin @@ -52,3 +54,95 @@ class TestDatabaseWithCache(StorageTestsMixin, TestCase): def tearDown(self): settings.BACKEND = self.old_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) diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 0895c36..d8bbfff 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -1,6 +1,8 @@ from django.test import TestCase +from django.test import TransactionTestCase from constance import settings +from constance.base import Config from tests.storage import StorageTestsMixin @@ -14,3 +16,43 @@ class TestMemory(StorageTestsMixin, TestCase): def tearDown(self): self.config._backend._storage = {} 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}) diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index 578fc83..bbc1a7e 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -1,6 +1,12 @@ +from unittest import mock + +from django.core.exceptions import ImproperlyConfigured from django.test import TestCase +from django.test import TransactionTestCase from constance import settings +from constance.backends.redisd import RedisBackend +from constance.base import Config from tests.storage import StorageTestsMixin @@ -17,6 +23,241 @@ class TestRedis(StorageTestsMixin, TestCase): self.config._backend._rd.clear() 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): _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() diff --git a/tests/cache_mockup.py b/tests/cache_mockup.py index 6be4fea..64a79ad 100644 --- a/tests/cache_mockup.py +++ b/tests/cache_mockup.py @@ -10,3 +10,16 @@ class Cache(BaseCache): self.set = self._cache.set self.get = self._cache.get 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) diff --git a/tests/redis_mockup.py b/tests/redis_mockup.py index 3b1743e..c46f2eb 100644 --- a/tests/redis_mockup.py +++ b/tests/redis_mockup.py @@ -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): - self[key] = value + _shared_store[key] = value + + def get(self, key, default=None): + return _shared_store.get(key, default) 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] diff --git a/tests/settings.py b/tests/settings.py index 5b8a225..13bdcb0 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -50,6 +50,7 @@ INSTALLED_APPS = ( ROOT_URLCONF = "tests.urls" CONSTANCE_REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection" +CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection" CONSTANCE_ADDITIONAL_FIELDS = { "yes_no_null_select": [ diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..bd1b2e5 --- /dev/null +++ b/tests/test_async.py @@ -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)