Memoize¶
Cache a function's result per argument values, so repeated calls with the same arguments skip the work.
from python_wrap import memoize
calls = []
@memoize
def square(n: int) -> int:
calls.append(n)
return n * n
assert square(4) == 16
assert square(n=4) == 16 # arguments are bound to the signature: same entry
assert calls == [4]
assert square.cache_info().hits == 1
When to use it¶
- A function is pure (or close enough) and called repeatedly with the same arguments.
- An answer is good for a while: add
ttl; addstale_ttlto keep serving it while it refreshes in the background. - Not for one result regardless of arguments: use
once. - Not for a per-object attribute: use
cached_property. - Not when you only want to merge calls that are running right now: use
singleflight.
How keys work¶
- Arguments are bound to the function signature and defaults are applied, so
f(1),f(x=1)andf(1, y=<default>)share one entry. - Lists, dicts and sets (also nested) are converted to hashable forms automatically, and
[1]and(1,)do not collide. - Any other unhashable argument raises
TypeErrorat call time. Passkey=to build the key yourself. - Exceptions are not cached by default; see Caching exceptions.
from python_wrap import memoize
@memoize
def total(items: list[int]) -> int:
return sum(items)
assert total([1, 2, 3]) == 6
assert total([1, 2, 3]) == 6
assert total.cache_info().hits == 1
A custom key¶
Ignore arguments that do not change the answer, or accept arguments that are not hashable:
from python_wrap import memoize
class Client: ...
@memoize(key=lambda client, user_id: user_id) # ignore the client object
def fetch_user(client: Client, user_id: int) -> dict:
return {"id": user_id}
fetch_user(Client(), 1)
fetch_user(Client(), 1)
assert fetch_user.cache_info().hits == 1
Expiry and size¶
import time
from python_wrap import memoize
calls = []
@memoize(maxsize=2, ttl=0.02)
def load(key: str) -> str:
calls.append(key)
return key.upper()
load("a"); load("b"); load("c") # "a" is evicted (least recently used)
assert load.cache_info().currsize == 2
assert load.cache_info().maxsize == 2
time.sleep(0.03) # entries expire after ttl
assert load.cache_info().currsize == 0
load("c")
assert calls == ["a", "b", "c", "c"]
| Option | Meaning |
|---|---|
maxsize |
Maximum entries; least recently used are evicted. None is unbounded. |
ttl |
Seconds (or a datetime.timedelta) an entry stays valid. None never expires. |
typed |
If True, f(1) and f(1.0) are cached separately. |
key |
Custom key(*args, **kwargs) returning a hashable key. |
cache_exceptions |
Exception class (or tuple) to cache like a result. |
exception_ttl |
Seconds a cached exception stays valid (defaults to ttl). |
stale_ttl |
Seconds after ttl during which a stale value is still served while it is refreshed in the background. See Serving stale values. |
store |
A CacheStore to keep entries in instead of the built-in dict. |
per_instance |
For methods: one cache per instance, without keeping instances alive. See Per-instance method caches. |
logger |
Logger (or name) for failed background refreshes. |
Per-item TTL¶
ttl may also be a function of the result, evaluated when the result is stored. Return None for "never expires"; a zero or negative value means "do not store".
from python_wrap import memoize
@memoize(ttl=lambda token: token["expires_in"])
def fetch_token(scope: str) -> dict:
return {"scope": scope, "expires_in": 3600}
fetch_token("read")
assert fetch_token.cache_contains("read")
Caching exceptions¶
List the exceptions that are a stable answer (a missing record, a rejected input) in cache_exceptions. They are stored for exception_ttl seconds (default: ttl) and re-raised on every hit as the same exception instance, so its traceback grows with each raise. asyncio.CancelledError is never cached. When ttl is a function you must pass exception_ttl too.
from python_wrap import memoize
calls = []
@memoize(cache_exceptions=KeyError, exception_ttl=30)
def lookup(key: str) -> str:
calls.append(key)
raise KeyError(key)
for _ in range(3):
try:
lookup("x")
except KeyError:
pass
assert calls == ["x"]
Controlling the cache¶
Every memoized function has these helpers:
from python_wrap import memoize
@memoize
def double(n: int) -> int:
return n * 2
double(1); double(2)
assert double.cache_info().currsize == 2 # CacheInfo(hits, misses, maxsize, currsize)
assert double.cache_delete(1) is True # drop one entry
assert double.cache_delete(1) is False # it was not there
double.cache_clear() # drop everything and reset the statistics
assert double.cache_info() == (0, 0, None, 0)
Inspect without side effects (nothing is computed, counted or moved in the LRU order):
from python_wrap import memoize
@memoize(maxsize=8)
def triple(n: int) -> int:
return n * 3
assert not triple.cache_contains(2)
triple(2)
assert triple.cache_contains(2)
assert triple.cache_get(2) == 6 # raises KeyError when absent
assert triple.cache_parameters()["maxsize"] == 8
cache_get is typed Any because for async def functions it returns the awaited result, not a coroutine.
Serving stale values while refreshing¶
With stale_ttl, an entry older than ttl is stale but not gone. For stale_ttl more seconds a call still returns it immediately, and the call that finds it stale starts one background refresh. Once a value has been loaded, callers never wait for a slow backend again; after the refresh lands they get the new value.
import time
from python_wrap import memoize
version = 1
@memoize(ttl=0.05, stale_ttl=60)
def settings() -> int:
return version # imagine a slow HTTP call
assert settings() == 1 # miss: computed in the caller
version = 2
time.sleep(0.06) # older than ttl: now stale
assert settings() == 1 # served at once; a refresh starts in the background
time.sleep(0.05)
assert settings() == 2 # the refreshed value
- Sync functions refresh in a daemon thread, at most one per key, running in a copy of the caller's
contextvarscontext (so request ids and similar context reach it). async deffunctions refresh in a task on the running event loop. It shares the in-flight bookkeeping, so concurrent stale hits and misses of one key start a single task.- A failed refresh keeps the stale value. It is logged at
WARNING(onlogger, defaultpython_wrap), emitscache.refresh_failed, and the next call starts another refresh. - Past
ttl + stale_ttlthe entry is dropped and the next call is an ordinary miss, computed in the caller. stale_ttlrequiresttl(a number, atimedeltaor a function of the result). Cached exceptions are never served stale.
Refresh decisions read the package clock, so fake_clock drives them in tests without waiting:
import threading
from python_wrap import memoize
from python_wrap.testing import fake_clock
calls = []
@memoize(ttl=10, stale_ttl=50)
def rates() -> int:
calls.append(1)
return len(calls)
with fake_clock() as clock:
assert rates() == 1
clock.advance(15)
assert rates() == 1 # stale: served, refresh started
for t in threading.enumerate():
if t.name.startswith("python_wrap-refresh-"):
t.join()
assert rates() == 2
Custom stores¶
By default entries live in a dict inside the decorator. Pass store= to keep them somewhere else: a dict you own, a shared registry, Redis. A store is any object with these five methods (the CacheStore protocol; no base class needed):
| Method | Contract |
|---|---|
get(key) |
The entry stored under key, or None. |
set(key, entry, ttl) |
Store entry. ttl is the number of seconds after which it is useless and may be discarded (None: keep it). |
delete(key) |
Remove the entry; return True if there was one. |
clear() |
Remove every entry. |
__len__() |
The number of stored entries (cache_info().currsize). |
from python_wrap import memoize
class DictStore:
def __init__(self) -> None:
self.data = {}
def get(self, key):
return self.data.get(key)
def set(self, key, entry, ttl):
self.data[key] = entry
def delete(self, key):
return self.data.pop(key, None) is not None
def clear(self):
self.data.clear()
def __len__(self):
return len(self.data)
store = DictStore()
@memoize(store=store, ttl=300)
def price(sku: str) -> float:
return 9.99
price("A-1"); price("A-1")
assert len(store) == 1
assert price.cache_info().hits == 1
assert price.cache_get("A-1") == 9.99 # the cache_* helpers all work
price.cache_clear()
assert len(store) == 0
What a store can rely on, and what it must do:
- Entries are opaque. Return exactly what was stored and never look inside. A store that serialises (Redis, files) must round-trip entries, for example with
pickle. Entries are neverNone. - Entries carry their own deadlines, as Unix timestamps, and
memoizechecks them. A store may forget an entry at any time (that is just a miss) and does not have to implement expiry, though honouringttlkeeps it from filling up. Processes sharing a store agree on expiry as long as their clocks agree. - Keys are hashable, but not strings. They are tuples of the normalised arguments (or whatever
key=returns). A store that needsstrorbyteskeys must serialise them itself, with a prefix per function when several functions share one backend. - The store must be thread-safe.
memoizedoes not hold its lock while calling the store, so a network round trip never blocks other callers. Singledictoperations are atomic in CPython, so the store above qualifies. - Methods are synchronous. For
async deffunctions they run on the event loop, so keep them fast. - Exceptions raised by the store propagate to the caller.
maxsize(LRU) only applies to the built-in store: passing bothstore=andmaxsize=raisesTypeError.
A sketch of a Redis adapter, using the redis package (not a python-wrap dependency):
import pickle
import redis
from python_wrap import memoize
class RedisStore:
def __init__(self, client: redis.Redis, prefix: str) -> None:
self.client, self.prefix = client, prefix
def _key(self, key) -> bytes:
return self.prefix.encode() + pickle.dumps(key) # keys are tuples, not strings
def get(self, key):
raw = self.client.get(self._key(key))
return None if raw is None else pickle.loads(raw)
def set(self, key, entry, ttl):
expire = None if ttl is None else max(1, int(ttl) + 1)
self.client.set(self._key(key), pickle.dumps(entry), ex=expire)
def delete(self, key):
return bool(self.client.delete(self._key(key)))
def clear(self):
for k in self.client.scan_iter(match=self.prefix + "*"):
self.client.delete(k)
def __len__(self):
return sum(1 for _ in self.client.scan_iter(match=self.prefix + "*"))
@memoize(store=RedisStore(redis.Redis(), prefix="pricing:"), ttl=300)
def price(sku: str) -> float: ...
pickle.dumps of a key is only stable when the arguments pickle deterministically (strings, numbers, tuples of them), so prefer simple arguments or a key= that returns a string. Only unpickle data from a store you trust.
Per-instance method caches¶
On a plain @memoize method, self is part of the key: the cache holds a strong reference to every instance, keeping it alive, and instances must be hashable. per_instance=True fixes both. Each instance gets its own cache, found through a weak reference and keyed by the arguments after self:
import dataclasses
import gc
import weakref
from python_wrap import memoize
@dataclasses.dataclass # eq=True: instances are unhashable
class Account:
id: int
@memoize(per_instance=True, maxsize=100)
def balance(self, currency: str) -> float:
return 100.0
a, b = Account(1), Account(1) # equal, but two objects: two caches
a.balance("EUR"); a.balance("EUR"); b.balance("EUR")
assert Account.balance.cache_info().hits == 1
assert Account.balance.cache_info().currsize == 2 # summed over live instances
ref = weakref.ref(a)
del a
gc.collect()
assert ref() is None # the cache did not keep it alive
assert Account.balance.cache_info().currsize == 1 # its entries went with it
- Identity, not equality. Caches belong to objects, so two equal instances never share results, and unhashable classes (an
eq=Truedataclass) work. When an instance is garbage-collected its cache is dropped, so a later object that gets the sameid()starts empty. - Instances must be weak-referenceable. A class with
__slots__must list"__weakref__"; otherwise the call raisesTypeError. maxsizeapplies to each instance.cache_info()adds up hits, misses and sizes over every live instance andcache_clear()clears them all.cache_delete,cache_getandcache_containstake the instance first, as on any method:obj.balance.cache_get(obj, "EUR").- A custom
key=still receivesself; leave it out of the key, since the cache is already per instance. - A cached result that references its own instance (returning
self, a bound method, a closure overself) keeps that instance alive. per_instanceuses the built-in store and cannot be combined withstore=.
Async and methods¶
Async. Concurrent awaits of a missing key share one in-flight task per event loop, so the function runs once.
import asyncio
from python_wrap import memoize
calls = []
@memoize
async def square(n: int) -> int:
calls.append(n)
await asyncio.sleep(0.01)
return n * n
async def main() -> None:
results = await asyncio.gather(square(4), square(4), square(n=4))
assert results == [16, 16, 16]
assert calls == [4]
asyncio.run(main())
Methods. self is part of the key, so results are per instance. On a method, cache_delete, cache_contains and cache_get take the instance as the first argument, because obj.method is an ordinary bound method and its attributes are not bound: obj.method.cache_get(obj, x).
from python_wrap import memoize
class Repo:
@memoize
def load(self, key: int) -> int:
return key * 2
repo = Repo()
repo.load(42)
assert repo.load.cache_contains(repo, 42)
assert repo.load.cache_get(repo, 42) == 84
assert repo.load.cache_delete(repo, 42) is True
The cache alias¶
cache(duration, maxsize=None) is v0.1's name for memoize(ttl=duration, maxsize=maxsize), kept for compatibility and soft-deprecated. Prefer memoize(ttl=...) in new code (cache also clashes with functools.cache).
import datetime
from python_wrap import cache
@cache(datetime.timedelta(minutes=5))
def load() -> int:
return 42
assert load() == 42
Unlike v0.1, every decorated function gets its own store, even when one cache(...) result is applied to several functions.
Events¶
memoize (and cache) emit cache.hit, cache.miss and cache.evicted (field reason: "lru" or "ttl") to python_wrap.events listeners, with the function's qualified name. With stale_ttl they also emit cache.stale (a stale value was served and a background refresh started; the hit itself is reported as cache.hit) and cache.refresh_failed (field exception). With no listener registered this costs one list check. See Metrics for recipes.
Testing¶
Use python_wrap.testing.disable_caching() to bypass every cache in tests: nothing is read, written or refreshed. ttl and stale_ttl run on the package clock, so python_wrap.testing.fake_clock() makes entries expire or turn stale without waiting (see the example under Serving stale values).
Gotchas¶
Two threads can compute the same key
The cache is guarded by a lock, but the function is never called while holding it. For sync functions, two threads that miss on the same key at the same moment may both compute the value, and the last result stored wins. If that is not acceptable, stack singleflight under memoize.
Methods keep instances alive
self is part of the key and the cache holds a strong reference to every instance it has seen, which keeps them alive. Use per_instance=True on methods of many short-lived objects (or maxsize, ttl, or a key= that ignores self). See Limitations.
Unhashable instances
A method of a class whose instances are unhashable (for example an eq=True dataclass) fails with a TypeError naming self. id(self) is deliberately not used as a fallback because ids are reused after garbage collection. Use per_instance=True (keyed by identity through a weak reference), pass key=, or use cached_property instead.