diff --git a/cachebox/utils.py b/cachebox/utils.py index 560ea8c..82e6292 100644 --- a/cachebox/utils.py +++ b/cachebox/utils.py @@ -1,6 +1,10 @@ import functools import inspect import typing +import _thread +import asyncio +import threading +import warnings from copy import copy as _shallow_copy from copy import deepcopy as _deep_copy from collections.abc import Callable, Hashable @@ -68,55 +72,57 @@ def make_key(*args: typing.Any, **kwds: typing.Any) -> Hashable: """ Default cache key. - Fast-path: a single ``int`` or ``str`` argument is returned as-is. + Fast-path: a single `int or str argument is returned as-is. Otherwise a plain tuple (plus a kwargs sentinel when needed) is returned. """ + if not kwds: if len(args) == 1 and type(args[0]) in _FAST_TYPES: return args[0] return args - key = args + (_KWDS_MARK,) - for item in kwds.items(): - key += item - - return key - def make_hash_key(*args: typing.Any, **kwds: typing.Any) -> int: """ - Key as the hash of all positional and keyword arguments. + Return the hash of all positional and keyword arguments. - Avoids storing the raw argument tuple, at the cost of potential hash - collisions mapping distinct inputs to the same cache slot. + Note: + The returned integer is not collision-free. Distinct argument + combinations may produce the same hash value. """ if not kwds: return hash(args) - key = args + (_KWDS_MARK,) + + key = [*args, _KWDS_MARK] + for item in kwds.items(): - key += item - return hash(key) + key.extend(item) + + return hash(tuple(key)) def make_typed_key(*args: typing.Any, **kwds: typing.Any) -> tuple[typing.Any, ...]: """ - Key that includes the runtime type of every argument. + Key that includes the exact runtime type of every argument. Ensures ``f(1)`` and ``f(1.0)`` are cached separately even though ``1 == 1.0``. """ - key: tuple = args + key = [*args] + if kwds: - key += (_KWDS_MARK,) + key.append(_KWDS_MARK) + for item in kwds.items(): - key += item + key.extend(item) + + key.extend(type(value) for value in args) - key += tuple(type(v) for v in args) if kwds: - key += tuple(type(v) for v in kwds.values()) + key.extend(type(value) for value in kwds.values()) - return key + return tuple(key) class Frozen(BaseCacheImpl[KT, VT]): # pragma: no cover @@ -155,8 +161,13 @@ def __init__(self, cls: BaseCacheImpl[KT, VT], ignore: bool = False) -> None: Args: cls: The underlying cache implementation to be frozen. - ignore: If ``True``, silently ignores modification attempts; if ``False``, raises - ``TypeError`` when modification is attempted. Default is ``False``. + ignore: If ``True``, silently ignores modification attempts. + If ``False``, raises ``TypeError`` when modification is + attempted. Defaults to ``False``. + + Raises: + TypeError: If ``cls`` is not a ``BaseCacheImpl`` instance or + is already a ``Frozen`` cache. """ if not isinstance(cls, BaseCacheImpl): raise TypeError( @@ -170,36 +181,42 @@ def __init__(self, cls: BaseCacheImpl[KT, VT], ignore: bool = False) -> None: self.ignore = ignore def _guard(self) -> None: + """ + Guard against modification attempts. + + Raises: + TypeError: If the cache is frozen and ``ignore`` is ``False``. + """ if not self.ignore: raise TypeError("This cache is frozen.") @property def cache(self) -> BaseCacheImpl[KT, VT]: - """Returns the wrapped cache implementation.""" + """Return the wrapped cache implementation.""" return self.__cache @property def maxsize(self) -> int: - """The configured ``maxsize``.""" + """Return the configured maximum cache size.""" return self.__cache.maxsize @property - def getsizeof(self) -> Callable[[KT, VT], int] | None: - """Callable or None: The configured ``getsizeof`` function.""" + def getsizeof(self) -> typing.Callable[[KT, VT], int] | None: + """Return the configured ``getsizeof`` callable, or ``None``.""" return self.__cache.getsizeof def current_size(self) -> int: """ - Returns the current total cumulative size of all stored entries. + Return the current cumulative size of all stored entries. Returns: - The sum of sizes of all entries currently in the cache. + The sum of the sizes of all entries currently stored in the cache. """ return self.__cache.current_size() def remaining_size(self) -> int: """ - Returns the remaining available size. + Return the remaining available cache size. Returns: The result of ``maxsize - current_size``. @@ -208,61 +225,66 @@ def remaining_size(self) -> int: def capacity(self) -> int: """ - Returns the number of elements the map can hold without reallocating. + Return the current allocated cache capacity. Returns: - The current allocated capacity. + The number of elements the underlying map can hold without + reallocating. """ return self.__cache.capacity() def __len__(self) -> int: """ - Returns the number of entries currently in the cache. + Return the number of entries currently stored in the cache. Returns: - The number of entries in the cache. + The number of entries currently in the cache. """ return len(self.__cache) def __sizeof__(self) -> int: + """Return the memory size reported by the underlying cache.""" return self.__cache.__sizeof__() def __bool__(self) -> bool: + """Return ``True`` if the underlying cache contains any entries.""" return bool(self.__cache) def __contains__(self, key: KT) -> bool: + """Return whether ``key`` exists in the underlying cache.""" return self.__cache.contains(key) def contains(self, key: KT) -> bool: """ - Returns ``True`` if the cache contains an entry for ``key``. + Return whether ``key`` exists in the cache. - Equivalent to ``key in self``. Prefer this method over ``key in self`` - to keep code compatible across different cache policies. + This is equivalent to ``key in self`` and is provided for + compatibility across different cache policies. Args: key: The key to look up. Returns: - ``True`` if the key exists in the cache, ``False`` otherwise. + ``True`` if the key exists in the cache, otherwise ``False``. """ return self.__cache.contains(key) def is_empty(self) -> bool: """ - Returns ``True`` if the cache is empty. + Return whether the cache is empty. Returns: - ``True`` if the cache contains no entries. + ``True`` if the cache contains no entries, otherwise ``False``. """ return self.__cache.is_empty() def is_full(self) -> bool: """ - Returns ``True`` when the cumulative size has reached the maxsize limit. + Return whether the cache has reached its maximum size. Returns: - ``True`` if the cache is at capacity. + ``True`` if the cumulative size has reached the ``maxsize`` limit, + otherwise ``False``. """ return self.__cache.is_full() @@ -272,11 +294,35 @@ def insert( value: VT, *args: typing.Any, **kwargs: typing.Any, - ) -> VT | None: - return self._guard() + ) -> typing.Optional[VT]: + """ + Attempt to insert an item into the cache. + + Since this cache is frozen, the operation is never forwarded to the + underlying cache. + + Args: + key: The key associated with the value. + value: The value to insert. + *args: Additional positional arguments accepted for compatibility. + **kwargs: Additional keyword arguments accepted for compatibility. + + Returns: + ``None`` when ``ignore=True``. + + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() def __setitem__(self, key: KT, value: VT) -> None: - return self._guard() + """ + Attempt to assign an item using subscription syntax. + + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() def update( self, @@ -284,12 +330,51 @@ def update( *args: typing.Any, **kwargs: typing.Any, ) -> None: - return self._guard() + """ + Attempt to update the cache with multiple items. + + The operation is never forwarded to the underlying cache. - def get(self, key: KT, default: DT | None = None) -> VT | DT: + Args: + iterable: An iterable containing cache entries. + *args: Additional positional arguments accepted for compatibility. + **kwargs: Additional keyword arguments accepted for compatibility. + + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() + + def get( + self, + key: KT, + default: typing.Optional[DT] = None, + ) -> typing.Union[VT, DT]: + """ + Return the value associated with ``key`` without modifying the cache. + + Args: + key: The key to look up. + default: The value returned when ``key`` is not present. + + Returns: + The cached value or ``default`` when the key is absent. + """ return self.__cache.get(key, default) def __getitem__(self, key: KT) -> VT: + """ + Return the value associated with ``key``. + + Args: + key: The key to look up. + + Returns: + The cached value. + + Raises: + KeyError: If ``key`` does not exist in the cache. + """ return self.__cache[key] def setdefault( @@ -298,77 +383,148 @@ def setdefault( default: DT | None = None, *args: typing.Any, **kwargs: typing.Any, - ) -> VT | DT | None: - return self._guard() + ) -> typing.Optional[VT | DT]: + """ + Attempt to insert ``default`` when ``key`` is missing. + + Since this cache is frozen, no modification is performed. + + Args: + key: The key to look up. + default: The value that would normally be inserted. + *args: Additional positional arguments accepted for compatibility. + **kwargs: Additional keyword arguments accepted for compatibility. + + Returns: + ``None`` when ``ignore=True``. - def pop(self, key: KT, default: DT | None = None) -> VT | DT: + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() + + def pop( + self, + key: KT, + default: DT = None, + ) -> typing.Union[VT, DT]: """ - Removes the specified key and returns the corresponding value. + Attempt to remove and return the value associated with ``key``. + + Since this cache is frozen, the operation is never performed. Args: key: The key to remove. default: Value to return if the key is not found. Returns: - The value associated with ``key``, or ``default`` if not found. + ``None`` when ``ignore=True``. Raises: - KeyError: If the key is not found and no ``default`` is provided. + TypeError: If ``ignore=False``. + KeyError: Normally raised when ``key`` is missing and no default + is provided, but the frozen cache blocks the operation first. """ - return self._guard() # type: ignore[return-value] + self._guard() # type: ignore[return-value] def __delitem__(self, key: KT) -> None: - return self._guard() + """ + Attempt to delete ``key`` from the cache. - def popitem(self) -> tuple[KT, VT]: - return self._guard() # type: ignore[return-value] + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() + + def popitem(self) -> typing.Tuple[KT, VT]: + """ + Attempt to remove and return an arbitrary cache entry. + + Returns: + ``None`` when ``ignore=True``. + + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() # type: ignore[return-value] def drain(self, n: int) -> int: """ - Calls ``popitem()`` ``n`` times and returns the count of removed items. + Attempt to remove up to ``n`` entries from the cache. Args: - n: The number of items to remove. + n: The maximum number of entries to remove. Returns: - The number of items successfully removed. + ``None`` when ``ignore=True``. + + Raises: + TypeError: If ``ignore=False``. """ - return self._guard() # type: ignore[return-value] + self._guard() # type: ignore[return-value] def shrink_to_fit(self) -> None: - """Shrinks the internal allocation as close to the current length as possible.""" - return self._guard() + """ + Attempt to release unused internal allocation. + + Raises: + TypeError: If ``ignore=False``. + """ + self._guard() def clear(self, *, reuse: bool = False) -> None: """ - Removes all items from the cache. + Attempt to remove all entries from the cache. Args: - reuse: If ``True``, retains the allocated memory for future reuse - rather than freeing it. Defaults to ``False``. + reuse: If ``True``, the underlying allocation would normally be + retained for future reuse. Since this cache is frozen, no + modification is performed. + + Raises: + TypeError: If ``ignore=False``. """ - return self._guard() + self._guard() - def items(self) -> typing.Iterable[tuple[KT, VT]]: + def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + """Return an iterable over the cache's key-value pairs.""" return self.__cache.items() def values(self) -> typing.Iterable[VT]: + """Return an iterable over the cache's values.""" return self.__cache.values() def keys(self) -> typing.Iterable[KT]: + """Return an iterable over the cache's keys.""" return self.__cache.keys() def __iter__(self) -> typing.Iterator[KT]: + """Return an iterator over the cache's keys.""" return iter(self.__cache) def copy(self) -> "Frozen[KT, VT]": + """ + Return a frozen copy of the underlying cache. + + Returns: + A new ``Frozen`` instance containing a copy of the underlying + cache and preserving the current ``ignore`` setting. + """ return Frozen(self.__cache.copy(), ignore=self.ignore) def __copy__(self) -> "Frozen[KT, VT]": + """ + Return a shallow copy of this frozen cache wrapper. + + Returns: + A new ``Frozen`` instance containing a copy of the underlying + cache and preserving the current ``ignore`` setting. + """ return Frozen(self.__cache.copy(), ignore=self.ignore) def __repr__(self) -> str: - return "Frozen(%s)" % repr(self.__cache) + """Return the string representation of the frozen cache.""" + return f"Frozen({self.__cache!r})" def _cast_lock( @@ -384,10 +540,21 @@ def _cast_lock( | type[AbstractAsyncContextManager] | None ): - import _thread - import asyncio - import threading + """ + Validate and normalize the lock configuration for a cached wrapper. + + Args: + iscoroutinefunction: Whether the wrapped function is asynchronous. + lock: Lock type, context manager type, or a boolean controlling + automatic lock selection. + + Returns: + The appropriate lock type, or ``None`` when locking is disabled. + Raises: + TypeError: If an incompatible lock is supplied for the wrapped + function type. + """ if lock is None or lock is False: return None @@ -402,7 +569,7 @@ def _cast_lock( return typing.cast(typing.Type[AbstractAsyncContextManager], lock) - # threading.Lock, threading.RLock and _thread.allocate_lock are function + # threading.Lock, threading.RLock and _thread.allocate_lock are functions. if ( lock is threading.Lock or lock is threading.RLock @@ -411,7 +578,9 @@ def _cast_lock( return typing.cast(typing.Type[AbstractContextManager], lock) if not hasattr(lock, "__enter__"): - raise TypeError("For sync functions, you cannot use a asynchronous lock.") + raise TypeError( + "For sync functions, you cannot use a asynchronous lock." + ) return typing.cast(typing.Type[AbstractContextManager], lock) @@ -437,35 +606,49 @@ def cached( cache: Cache instance, ``dict``, or callable ``(self) -> cache`` for per-instance caches. ``None`` defaults to an unbounded :class:`LRUCache`. + key_maker: Converts ``(args, kwds)`` to a hashable key. Built-ins: :func:`make_key` (default), :func:`make_hash_key`, :func:`make_typed_key`. + clear_reuse: Pass ``reuse=True`` to ``cache.clear()`` when :func:`cache_clear` is called. + callback: Called as ``callback(event, key, value)`` on every hit/miss. May be a coroutine in async contexts. - copy_level: It has been deprecated and no longer has any effect. Use - the postprocess parameter instead. + + copy_level: Deprecated and no longer has any effect. Use the + ``postprocess`` parameter instead. + postprocess: Optional ``(value) -> value`` transform applied before returning a result to the caller. Ready-to-use options: * ``None`` - return the cached object as-is. * :func:`postprocess_copy` - shallow-copy. - * :func:`postprocess_copy_mutables` - shallow-copy only `dict`, `list` and `set` (default). + * :func:`postprocess_copy_mutables` - shallow-copy only + ``dict``, ``list`` and ``set`` (default). * :func:`postprocess_deepcopy` - deep-copy. - * :func:`postprocess_deepcopy_mutables` - deep-copy only `dict`, `list` and `set`. - lock: If ``None`` or ``False``, cache stampede prevention get disabled, but process is still thread-safe. - If ``True``, will use ``threading.Lock`` or ``asyncio.Lock`` depends on wrapped function. - Also you can pass anything that implemented ``contextlib.AbstractContextManager`` - (or ``contextlib.AbstractAsyncContextManager`` for async functions). - (default is ``True``). See [cache stampede prevention](http://awolverp.github.io/cachebox/tips/#cache-stampede-prevention) - for more. + * :func:`postprocess_deepcopy_mutables` - deep-copy only + ``dict``, ``list`` and ``set``. + + lock: If ``None`` or ``False``, cache stampede prevention is disabled, + while the underlying cache remains thread-safe. + + If ``True``, ``threading.Lock`` is used for synchronous functions + and ``asyncio.Lock`` for asynchronous functions. + + A compatible context manager type may also be supplied. Tip: Pass ``cachebox__ignore=True`` at call-time to bypass the cache. - If *cache* isn't a lambda/function, these attributes will be attached to - your function: ``cache`` (property), ``cache_info`` (callable), ``clear_cache`` (callable), - and ``callback`` (property). + + If *cache* is not a lambda/function, the following attributes are + attached to the decorated function: + + * ``cache`` - the underlying cache. + * ``cache_info`` - cache statistics callable. + * ``clear_cache`` - cache clearing callable. + * ``callback`` - callback property. Examples:: @@ -483,10 +666,9 @@ def compute(self, n): return n * 2 """ if copy_level != 1: - import warnings - warnings.warn( - "`copy_level` parameter has been deprecated and no longer has any effect. Use the `postprocess` parameter instead", + "`copy_level` parameter has been deprecated and no longer has any " + "effect. Use the `postprocess` parameter instead", category=DeprecationWarning, ) @@ -496,8 +678,11 @@ def compute(self, n): cache = LRUCache(0, cache) # type: ignore[arg-type] cache_is_fn = callable(cache) + if not isinstance(cache, BaseCacheImpl) and not cache_is_fn: - raise TypeError("expected a cachebox cache or a callable, got %r" % (cache,)) + raise TypeError( + "expected a cachebox cache or a callable, got %r" % (cache,) + ) def decorator(func: FT) -> FT: iscoroutinefunction = inspect.iscoroutinefunction(func) @@ -509,7 +694,11 @@ def decorator(func: FT) -> FT: ) if lock_type: - builder = _async_cached_wrapper if iscoroutinefunction else _cached_wrapper + builder = ( + _async_cached_wrapper + if iscoroutinefunction + else _cached_wrapper + ) wrapper = builder( func, @@ -526,6 +715,7 @@ def decorator(func: FT) -> FT: if iscoroutinefunction else _cached_wrapper_without_lock ) + wrapper = builder( func, cache, # type: ignore @@ -535,7 +725,10 @@ def decorator(func: FT) -> FT: postprocess, ) - return functools.update_wrapper(wrapper, func) # type: ignore[return-value] + return functools.update_wrapper( + wrapper, + func, + ) # type: ignore[return-value] return decorator @@ -545,62 +738,74 @@ def is_cached(func: object) -> bool: Return ``True`` if *func* was decorated with :func:`cached`. Args: - func: an object or function to check. - """ - return hasattr(func, "cache") and isinstance(func.cache, BaseCacheImpl) # type: ignore[union-attr] + func: Object or function to inspect. + Returns: + ``True`` when *func* exposes a cache managed by :func:`cached`, + otherwise ``False``. + """ + return hasattr(func, "cache") and isinstance( + func.cache, + BaseCacheImpl, + ) # type: ignore[union-attr] def get_cached_cache(cached_func: object) -> BaseCacheImpl: """ - A way to get ``cached_func.cache``, without type-hint warnings. + Return the cache attached to a cached function without type-checker warnings. Args: - cached_func: a function decorated with :func:`cached`. + cached_func: A function decorated with :func:`cached`. - Warning: - If *func* wasn't decorated with :func:`cached`, or you passed a lambda/function as *cache* - to :func:`cached` decorator, raises ``AttributeError``. + Returns: + The underlying :class:`BaseCacheImpl` instance. + + Raises: + AttributeError: If *cached_func* was not decorated with :func:`cached` + or uses a callable cache factory. """ return cached_func.cache # type: ignore def get_cached_cache_info(cached_func: object) -> CacheInfo: """ - A way to get ``cached_func.cache_info()``, without type-hint warnings. + Return cache statistics for a cached function without type-checker warnings. Args: - cached_func: a function decorated with :func:`cached`. + cached_func: A function decorated with :func:`cached`. + + Returns: + The :class:`CacheInfo` object returned by ``cache_info()``. - Warning: - If *func* wasn't decorated with :func:`cached`, or you passed a lambda/function as *cache* - to :func:`cached` decorator, raises ``AttributeError``. + Raises: + AttributeError: If *cached_func* does not expose ``cache_info``. """ return cached_func.cache_info() # type: ignore def get_cached_callback(cached_func: object) -> _Callback | None: """ - A way to get ``cached_func.callback``, without type-hint warnings. + Return the callback attached to a cached function. Args: - cached_func: a function decorated with :func:`cached`. + cached_func: A function decorated with :func:`cached`. + + Returns: + The configured callback, or ``None`` when no callback is configured. - Warning: - If *func* wasn't decorated with :func:`cached`, or you passed a lambda/function as *cache* - to :func:`cached` decorator, raises ``AttributeError``. + Raises: + AttributeError: If *cached_func* does not expose ``callback``. """ return cached_func.callback # type: ignore def clear_cached_cache(cached_func: object) -> None: """ - A way to call ``cached_func.cache_clear()``, without type-hint warnings. + Clear the cache attached to a cached function. Args: - cached_func: a function decorated with :func:`cached`. + cached_func: A function decorated with :func:`cached`. - Warning: - If *func* wasn't decorated with :func:`cached`, or you passed a lambda/function as *cache* - to :func:`cached` decorator, raises ``AttributeError``. + Raises: + AttributeError: If *cached_func* does not expose ``cache_clear``. """ - return cached_func.cache_clear() # type: ignore + cached_func.cache_clear() # type: ignore