Source code for cache_results.lazy

"""
Django's cache, imported only once Django is ready.

Importing ``django.core.cache`` while Django is still starting can leave it on
an unexpected backend, such as a local-memory cache that silently persists
nothing: sessions are lost, and cached results are recomputed in every
process. :data:`lazy_cache` has the api of ``django.core.cache.cache``, and
imports it only on first use, once Django's apps are ready.

.. code-block:: python

    from cache_results import lazy_cache as cache

    cache.set("my_key", "my_value", timeout=300)
    cache.get("my_key")  # "my_value"

On first use it also checks the cache: it writes and reads a test key, and
warns when the ``default`` cache is a local-memory or dummy backend, which is
usually a misconfiguration in production.
"""

import logging

from django.apps import apps

from .conf import cache_results_settings

logger = logging.getLogger(__name__)
LOGGER_PREFIX = f"{__name__}.LazyCache"

_DIAGNOSTIC_KEY = "cache_results.lazy.diagnostic"

_DEFAULT_TIMEOUT = object()
"""The default of every ``timeout``: the cache's own timeout.

``None``, as in Django, means never expire.
"""

_NON_PERSISTENT_BACKENDS = (
    "django.core.cache.backends.locmem.LocMemCache",
    "django.core.cache.backends.dummy.DummyCache",
)
"""Backends that keep nothing across processes, which ``default`` falls back to when misconfigured."""


def _prefix(method: str) -> str:
    """
    Return the formatted start of a log message.

    :param method: The method that logs, e.g. ``cache``.
    :type method: str
    :return: The prefix.
    :rtype: str
    """
    return cache_results_settings.log_prefix(f"{LOGGER_PREFIX}.{method}()")


[docs] class LazyCache: """ Django's default cache, imported on first use. Use the module's :data:`lazy_cache` instance. Every method passes its arguments to the method of the same name of ``django.core.cache.cache``. """ def __init__(self) -> None: self._cache = None @property def ready(self) -> bool: """ Whether the cache can be used yet: True once Django's apps are ready. Until then, :attr:`cache` returns ``None`` rather than initialize Django's cache prematurely, so callers that may run during startup check this first, as ``@cache_results`` does. :rtype: bool """ return apps.ready @property def cache(self): """ Import and return ``django.core.cache.cache``, checking it on first use. :return: The cache, or ``None`` before Django's apps are ready. :rtype: Optional[django.core.cache.backends.base.BaseCache] """ if self._cache is not None: return self._cache if not apps.ready: logger.warning( "%s the cache was used before Django's apps are ready, which can leave it on the wrong backend.", _prefix("cache"), ) return None # imported here: importing django.core.cache while Django is still starting # can leave the cache on the wrong backend. from django.core.cache import cache # pylint: disable=import-outside-toplevel self._cache = cache self._check(cache) return cache def _check(self, cache) -> None: """ Write and read a test key, and warn when the cache persists nothing. :param cache: The cache. :type cache: django.core.cache.backends.base.BaseCache """ try: cache.set(_DIAGNOSTIC_KEY, "ready", timeout=5) if cache.get(_DIAGNOSTIC_KEY) == "ready": logger.debug("%s the cache is ready.", _prefix("cache")) else: logger.error("%s the cache did not return a test value that was just written.", _prefix("cache")) except Exception as e: # pylint: disable=broad-except logger.error("%s the cache could not be read or written: %s", _prefix("cache"), e) backend = f"{type(cache).__module__}.{type(cache).__qualname__}" if backend in _NON_PERSISTENT_BACKENDS: logger.warning( "%s the default cache is %s, which persists nothing across processes.", _prefix("cache"), backend )
[docs] def get(self, key, default=None, version=None): """Fetch a key, or ``default`` if it is not cached.""" return self.cache.get(key, default, version=version)
[docs] def set(self, key, value, timeout=_DEFAULT_TIMEOUT, version=None): """Set a value. Without ``timeout``, the cache's own timeout is used, and ``None`` never expires. """ if timeout is _DEFAULT_TIMEOUT: return self.cache.set(key, value, version=version) return self.cache.set(key, value, timeout=timeout, version=version)
[docs] def add(self, key, value, timeout=_DEFAULT_TIMEOUT, version=None) -> bool: """Set a value only if the key is not cached yet.""" if timeout is _DEFAULT_TIMEOUT: return self.cache.add(key, value, version=version) return self.cache.add(key, value, timeout=timeout, version=version)
[docs] def touch(self, key, timeout=_DEFAULT_TIMEOUT, version=None) -> bool: """Give a key a new timeout.""" if timeout is _DEFAULT_TIMEOUT: return self.cache.touch(key, version=version) return self.cache.touch(key, timeout=timeout, version=version)
[docs] def delete(self, key, version=None) -> bool: """Delete a key.""" return self.cache.delete(key, version=version)
[docs] def has_key(self, key, version=None) -> bool: """Whether a key is cached.""" return self.cache.has_key(key, version=version)
[docs] def incr(self, key, delta: int = 1, version=None) -> int: """Add ``delta`` to a cached number.""" return self.cache.incr(key, delta, version=version)
[docs] def decr(self, key, delta: int = 1, version=None) -> int: """Subtract ``delta`` from a cached number.""" return self.cache.decr(key, delta, version=version)
[docs] def get_many(self, keys, version=None) -> dict: """Fetch several keys, as a dict of those that are cached.""" return self.cache.get_many(keys, version=version)
[docs] def set_many(self, data: dict, timeout=_DEFAULT_TIMEOUT, version=None) -> list | None: """Set several values. Returns what the backend does: Django's own backends, the keys that could not be set. """ if timeout is _DEFAULT_TIMEOUT: return self.cache.set_many(data, version=version) return self.cache.set_many(data, timeout=timeout, version=version)
[docs] def delete_many(self, keys, version=None) -> None: """Delete several keys.""" return self.cache.delete_many(keys, version=version)
[docs] def incr_version(self, key, delta: int = 1, version=None) -> int: """Move a key to a higher version.""" return self.cache.incr_version(key, delta, version=version)
[docs] def decr_version(self, key, delta: int = 1, version=None) -> int: """Move a key to a lower version.""" return self.cache.decr_version(key, delta, version=version)
[docs] def clear(self) -> None: """Delete every key in the cache, not only those of this application.""" return self.cache.clear()
[docs] def close(self, **kwargs) -> None: """Close the connection to the cache.""" return self.cache.close(**kwargs)
lazy_cache = LazyCache() """Django's default cache, imported on first use, with the api of ``django.core.cache.cache``.""" __all__ = ["LazyCache", "lazy_cache"]