Skip to content

Getting Started

This guide covers the most common cachebox patterns. All cache classes behave like Python dictionaries unless noted otherwise.

Using the @cached Decorator

The simplest way to memoize a function's return value:

import cachebox

@cachebox.cached(cachebox.FIFOCache(maxsize=128))
def factorial(number: int) -> int:
    fact = 1
    for num in range(2, number + 1):
        fact *= num
    return fact

assert factorial(5) == 120
assert factorial(5) == 120  # served from cache

The first argument is the cache instance used for storage. Pass None (or omit it) to get an unbounded LRUCache. A plain dict is also accepted and converted to an unbounded LRUCache.

@cachebox.cached()                       # unbounded LRUCache
def f(x): ...

@cachebox.cached(cachebox.LRUCache(128)) # bounded LRUCache
def g(x): ...

Async Functions

Coroutines are supported out of the box. Stampede prevention uses asyncio.Lock automatically:

import cachebox

@cachebox.cached(cachebox.LRUCache(maxsize=128))
async def make_request(method: str, url: str) -> dict:
    response = await client.request(method, url)
    return response.json()

Custom Key Makers

By default @cached uses make_key, which builds a hashable key from positional and keyword arguments. You can supply your own:

import cachebox

def path_key(request):
    return request.path

@cachebox.cached(
    cachebox.LRUCache(128),
    key_maker=path_key,
)
async def request_handler(request):
    return Response("hello")
import cachebox

@cachebox.cached(
    cachebox.LRUCache(128),
    key_maker=lambda request: request.path,
)
async def request_handler(request):
    return Response("hello")

Built-in key makers:

Function Behavior
make_key Default. Fast path for a single int/str; otherwise a tuple of args (+ kwargs).
make_typed_key Like make_key, but includes runtime types so f(1) and f(1.0) are distinct.
make_hash_key Stores hash(args…) only — smaller keys, risk of rare collisions.

Callbacks on Cache Events

Pass a callback to observe every hit and miss:

import cachebox

def on_cache_event(event: int, key, value):
    if event == cachebox.EVENT_MISS:
        print(f"MISS  key={key}")
    elif event == cachebox.EVENT_HIT:
        print(f"HIT   key={key}")

@cachebox.cached(
    cachebox.LRUCache(0),
    callback=on_cache_event,
)
def add(a, b):
    return a + b

add(1, 2)   # MISS  key=(1, 2)
add(1, 2)   # HIT   key=(1, 2)

EVENT_MISS is 1 and EVENT_HIT is 2. In async contexts the callback may be a coroutine; it is awaited automatically.

Postprocessors

A postprocessor transforms the cached value before it is returned to the caller. This is how cachebox protects against accidental mutation of cached dict/list/set objects.

The default is postprocess_copy_mutables: dict, list, and set results are shallow-copied on every return; other types are returned as-is.

import cachebox

@cachebox.cached(cachebox.LRUCache(128))
def make_dict(name: str, age: int) -> dict:
    return {"name": name, "age": age}

d = make_dict("cachebox", 10)
d["new-key"] = "new-value"

d2 = make_dict("cachebox", 10)
# Without copying, d2 would also contain "new-key"
assert d2 == {"name": "cachebox", "age": 10}

Ready-to-use postprocessors:

Function Behavior
None Return the cached object as-is (no copy).
postprocess_copy_mutables Shallow-copy dict/list/set only (default).
postprocess_copy Shallow-copy every value.
postprocess_deepcopy_mutables Deep-copy dict/list/set only.
postprocess_deepcopy Deep-copy every value.
@cachebox.cached(
    cachebox.LRUCache(0),
    postprocess=cachebox.postprocess_deepcopy,
)
def build_tree():
    return {"children": [{"id": 1}]}

Bypass the Cache for a Call

Pass cachebox__ignore=True to execute the function without reading or writing the cache:

import cachebox

@cachebox.cached(cachebox.LRUCache(128))
def add(a, b):
    print("computing...")
    return a + b

add(1, 2)  # computing...
add(1, 2)  # from cache

add(1, 2, cachebox__ignore=True)  # computing...
# Only this call is uncached; future calls still use the cache

Caching Instance Methods

For instance methods, each object usually needs its own cache. Pass a callable that receives self and returns the cache:

import cachebox

class MyService:
    def __init__(self, multiplier: int):
        self.multiplier = multiplier
        self._cache = cachebox.TTLCache(20, global_ttl=10)

    @cachebox.cached(lambda self: self._cache)
    def compute(self, char: str):
        return char * self.multiplier

svc1 = MyService(2)
svc2 = MyService(5)

assert svc1.compute("x") == "xx"
assert svc2.compute("x") == "xxxxx"
# Entries created by svc1 are not visible to svc2

When the cache is a callable, self/cls is excluded from the cache key automatically.

No helper attributes on methods

When you pass a lambda/callable as cache, the wrapper does not attach .cache, .cache_info(), or .cache_clear(). Manage the cache object yourself (for example via self._cache).

Caching @staticmethods

Static methods do not receive self or cls. Provide a cache instance directly; it is shared by all callers:

import cachebox

class TextUtils:
    @staticmethod
    @cachebox.cached(cachebox.LRUCache(128))
    def normalize(text: str) -> str:
        print("normalizing...")
        return text.strip().lower()

TextUtils.normalize(" Hello ")
TextUtils.normalize(" Hello ")  # cached

Caching @classmethods

Class methods receive cls. The cache can live on the class and be selected dynamically:

import cachebox

class UserRepository:
    _cache = cachebox.LRUCache(128)

    @classmethod
    @cachebox.cached(lambda cls: cls._cache)
    def get_user(cls, user_id: int):
        print("loading user...")
        return {"id": user_id}

UserRepository.get_user(1)
UserRepository.get_user(1)  # cached

With inheritance, each subclass can own its cache while sharing the method:

import cachebox

class BaseRepository:
    _cache = cachebox.LRUCache(128)

    @classmethod
    @cachebox.cached(lambda cls: cls._cache)
    def get_item(cls, item_id):
        return f"{cls.__name__}:{item_id}"

class ProductRepository(BaseRepository):
    _cache = cachebox.LRUCache(128)

class OrderRepository(BaseRepository):
    _cache = cachebox.LRUCache(128)

Using Cache Classes Directly

You can use every cache implementation without @cached. They support the usual dict operations ([], get, in, len, keys/values/items, …) plus cache-specific methods.

from cachebox import FIFOCache

cache = FIFOCache(maxsize=128)
cache["key"] = "value"
assert cache["key"] == "value"
assert cache.get("missing", "default") == "default"

Prefer .insert(key, value) over __setitem__ when you need the previous value or want code that stays consistent across policies.

old = cache.insert("key", "new-value")  # returns previous value or None

Common Constructor Parameters

All cache classes accept:

Parameter Meaning
maxsize Capacity limit. 0 means unbounded (sys.maxsize internally).
iterable Optional initial data (dict, another cache, or (key, value) pairs).
capacity Pre-allocate the hash table to reduce reallocations.
getsizeof Callable (key, value) -> int for weighted sizing. Default: every entry has size 1.

TTLCache and VTTLCache add TTL-related parameters — see Choosing a Cache and the API reference.

Capacity, Size, and Weighted Entries

import cachebox
import sys

# maxsize counts entries (each entry size = 1 by default)
cache = cachebox.LRUCache(maxsize=100)
cache.insert("a", 1)
assert cache.current_size() == 1
assert cache.remaining_size() == 99
assert not cache.is_full()
assert not cache.is_empty()

# Weighted: size is computed by getsizeof
def entry_size(key, value):
    return sys.getsizeof(key) + sys.getsizeof(value)

weighted = cachebox.LRUCache(maxsize=10_000, getsizeof=entry_size)
weighted.insert("user:1", {"name": "Ada"})
print(weighted.current_size())   # sum of entry sizes
print(weighted.remaining_size()) # maxsize - current_size

When the cache is full, policy-based classes evict items; plain Cache raises OverflowError.

setdefault and setdefault_with

cache = cachebox.Cache(maxsize=10)

# Insert only if missing
value = cache.setdefault("key", "default")

# Lazy factory — called only on miss (lock is released while it runs)
value = cache.setdefault_with("key", lambda: expensive_compute())

Concurrent misses

If two threads miss the same key, factory may run more than once. The first successful insert wins. For single-flight computation, prefer @cached with stampede prevention enabled.

Drain, Clear, and Shrink

cache = cachebox.FIFOCache(10, {i: i for i in range(10)})

# Evict n items according to the policy (FIFO here: oldest first)
removed = cache.drain(3)
assert removed == 3

cache.clear()              # free memory
cache.clear(reuse=True)    # keep allocation for reuse
cache.shrink_to_fit()      # shrink allocation close to current length

Inspecting Capacity

cache = cachebox.LRUCache(maxsize=1000, capacity=1000)
print(cache.capacity())  # slots without reallocation
print(cache.maxsize)     # configured maxsize
print(len(cache))        # number of entries

Immutable (Frozen) Caches

Wrap any cache with Frozen to block further writes:

from cachebox import Frozen, LRUCache

cache = LRUCache(10, {1: "a", 2: "b"})
frozen = Frozen(cache, ignore=False)

frozen[3] = "c"  # TypeError: This cache is frozen.

# With ignore=True, mutations are silently ignored
frozen = Frozen(cache, ignore=True)
frozen[3] = "c"  # no-op
assert 3 not in frozen

# The underlying cache remains mutable
cache[3] = "c"
assert frozen[3] == "c"

TTL expiry still runs on the underlying TTLCache / VTTLCache even when frozen.

Saving a Cache to Disk

All cache classes support pickle:

import cachebox
import pickle

cache = cachebox.LRUCache(100, {i: i for i in range(50)})

with open("cache.pkl", "wb") as f:
    pickle.dump(cache, f)

with open("cache.pkl", "rb") as f:
    loaded = pickle.load(f)

assert cache == loaded

Do not use a lambda as getsizeof if you need to pickle the cache — picklable callables only.

Next Steps