Caching¶
This page describes the
persista.cache package, which provides caching for values and
function calls, with both synchronous and asynchronous APIs.
Prerequisites: You'll need to know a bit of Python, and it helps to be familiar with the
store user guide since caches are backed by a BaseStore.
Overview¶
The persista.cache package provides two related ways to cache data:
Cache: an explicit cache object withget/set/clearmethods, backed by anyBaseStore(an in-memory store by default). Every method also has an async counterpart, prefixed witha(aget/aset/aclear/...), for use with an async backing store.cached/async_cached: decorators that cache the result of a function call using a shared defaultCache
An entry can optionally have an expiration time (TTL). Once a key's TTL has elapsed, get
behaves as if the key were never set.
Using Cache Directly¶
Create a Cache and use set/get like a dictionary, optionally with expiration:
>>> from persista.cache import Cache
>>> with Cache(default_ttl=60) as cache:
... cache.set("greeting", "hello")
... cache.get("greeting")
... cache.get("missing") is None
...
'hello'
True
default_ttl (in seconds) is used whenever set is called without an explicit ttl. It
defaults to None, meaning entries never expire unless a ttl is given. Pass ttl to set to
override it for a single entry; ttl=0 evicts the entry instead of storing it:
>>> from persista.cache import Cache
>>> with Cache() as cache:
... cache.set("greeting", "hello")
... cache.set("short-lived", "value", ttl=30)
...
contains checks whether a key is present and unexpired, without returning its value; delete
removes a single entry (unlike set with ttl=0, it doesn't require a value to be given):
>>> from persista.cache import Cache
>>> with Cache() as cache:
... cache.set("greeting", "hello")
... cache.contains("greeting")
... cache.delete("greeting")
... cache.contains("greeting")
...
True
False
get_many, set_many, contains_many, and delete_many operate on several keys at once, each
issuing a single batched call to the backing store instead of one call per key — this matters for
stores where each call is a network round trip (e.g. Redis, Postgres). A single ttl applies to
every item passed to set_many:
>>> from persista.cache import Cache
>>> with Cache() as cache:
... cache.set_many({"a": "hello", "b": "world"}, ttl=60)
... sorted(cache.get_many(["a", "b", "missing"]).items())
... cache.contains_many(["a", "missing"])
... cache.delete_many(["a", "b"])
... cache.get_many(["a", "b"])
... cache.try_get_many(["a", "b"])
...
[('a', 'hello'), ('b', 'world'), ('missing', None)]
[True, False]
{'a': None, 'b': None}
{}
clear removes every entry:
>>> from persista.cache import Cache
>>> with Cache() as cache:
... cache.set("greeting", "hello")
... cache.clear()
... cache.get("greeting") is None
...
True
By default, Cache stores entries in an InMemoryStore. Pass any other BaseStore to persist
cached values, e.g. to share a cache across processes with RedisStore:
from persista.cache import Cache
from persista.store import RedisStore
cache = Cache(store=RedisStore("redis://localhost:6379/0"), default_ttl=300)
Distinguishing a Cache Miss from a Cached None¶
get/aget return None both when a key is missing and when the cached value is itself None,
so cache.get("key") is None alone can't tell the two apart. Pass a default (like
dict.get(key, default)) to control what's returned on a miss:
>>> from persista.cache import Cache
>>> with Cache() as cache:
... cache.get("missing", "fallback")
...
'fallback'
To actually tell a miss apart from a cached None, pass the MISSING sentinel as default and
compare the result against it with is:
>>> from persista.cache import Cache, MISSING
>>> with Cache() as cache:
... cache.set("key", None)
... cache.get("key", MISSING) is MISSING
... cache.get("missing", MISSING) is MISSING
...
False
True
aget supports the same default parameter.
try_get returns the same (hit, value) pair get computes internally, without needing a
sentinel:
>>> from persista.cache import Cache
>>> with Cache() as cache:
... cache.set("key", None)
... cache.try_get("key")
... cache.try_get("missing")
...
(True, None)
(False, None)
atry_get is the async counterpart.
Computing a Value on a Cache Miss with Cache.get_or_compute¶
get_or_compute returns the cached value for a key, computing and storing it on a cache miss:
>>> from persista.cache import Cache
>>> calls = []
>>> def compute(x):
... calls.append(x)
... return x * 2
...
>>> with Cache() as cache:
... cache.get_or_compute("key", compute, (4,), {})
... cache.get_or_compute("key", compute, (4,), {}) # served from the cache
...
8
8
>>> calls
[4]
aget_or_compute is the async counterpart, for use with an async def function. The backing
store is still accessed synchronously; only the function is awaited:
>>> import asyncio
>>> from persista.cache import Cache
>>> calls = []
>>> async def compute(x):
... calls.append(x)
... return x * 2
...
>>> async def main():
... async with Cache() as cache:
... print(await cache.aget_or_compute("key", compute, (4,), {}))
... print(await cache.aget_or_compute("key", compute, (4,), {})) # cached
...
>>> asyncio.run(main())
8
8
>>> calls
[4]
Memoizing Functions with Cache.memoize¶
Cache.memoize is a decorator that caches a function's return value, keyed on the function name
and its arguments. It works on both sync and async def functions:
>>> from persista.cache import Cache
>>> calls = []
>>> with Cache() as cache:
... @cache.memoize(ttl=60)
... def square(x):
... calls.append(x)
... return x * x
... square(4)
... square(4) # served from the cache, not re-computed
...
16
16
>>> calls
[4]
memoize accepts two options that control how the cache key is computed from a call's arguments,
via make_key (see Cache Keys below):
strategy: either"json"(the default) or"pickle"."json"produces keys that are stable across Python versions and processes, but requires every argument to be JSON-serializable."pickle"supports a broader range of argument types, at the cost of a key that is only stable within a single Python version.ignore_non_serializable: ifTrue, positional arguments and keyword argument values that aren't serializable withstrategyare silently dropped before computing the key, instead of raising an error. This is useful when a function takes an argument that will never be serializable (e.g. a logger or a client instance) but shouldn't prevent caching — calls that differ only in that argument then share the same cache entry.
>>> from persista.cache import Cache
>>> calls = []
>>> with Cache() as cache:
... @cache.memoize(ttl=60, strategy="json", ignore_non_serializable=True)
... def greet(name, client=None):
... calls.append(name)
... return f"hello {name}"
... greet("Ann", client=object())
... greet(
... "Ann", client=object()
... ) # different (non-serializable) client, still a cache hit
...
'hello Ann'
'hello Ann'
>>> calls
['Ann']
Async Caching with Cache's a-prefixed Methods¶
Every Cache method has an async counterpart, prefixed with a, for use with an async backing
store (an InMemoryStore by default).
aget/aset/atry_get/acontains/aget_many/aset_many/acontains_many/adelete/adelete_many/aclear
mirror their sync counterparts but are coroutines, accessing the backing BaseStore through its
async (a-prefixed) methods:
>>> import asyncio
>>> from persista.cache import Cache
>>> async def main():
... async with Cache(default_ttl=60) as cache:
... await cache.aset("greeting", "hello")
... print(await cache.aget("greeting"))
... await cache.aclear()
... print(await cache.aget("greeting"))
...
>>> asyncio.run(main())
hello
None
aget_or_compute accepts either a sync or an async def function directly — awaiting it only
if the result is awaitable. The backing store is always accessed with await, via its async
(a-prefixed) methods:
>>> import asyncio
>>> from persista.cache import Cache
>>> calls = []
>>> def compute(x): # a plain sync function works too
... calls.append(x)
... return x * 2
...
>>> async def main():
... async with Cache() as cache:
... print(await cache.aget_or_compute("key", compute, (4,), {}))
... print(await cache.aget_or_compute("key", compute, (4,), {})) # cached
...
>>> asyncio.run(main())
8
8
>>> calls
[4]
amemoize decorates async def (or sync) functions, always returning a coroutine function:
>>> import asyncio
>>> from persista.cache import Cache
>>> calls = []
>>> async def main():
... async with Cache() as cache:
... @cache.amemoize(ttl=60)
... async def cube(x):
... calls.append(x)
... return x * x * x
... print(await cube(4))
... print(await cube(4)) # served from the cache, not re-computed
...
>>> asyncio.run(main())
64
64
>>> calls
[4]
Shared Default Caches: cached and async_cached¶
For simple cases, cached and async_cached avoid creating and threading a Cache instance
through your code. Both use the same shared module-level default Cache, retrieved with
get_cache — cached through its sync methods, async_cached through its async (a-prefixed)
methods:
>>> from persista.cache import cached
>>> calls = []
>>> @cached(ttl=60)
... def square(x):
... calls.append(x)
... return x * x
...
>>> square(4)
16
>>> square(4) # served from the cache, not re-computed
16
>>> calls
[4]
>>> import asyncio
>>> from persista.cache import async_cached
>>> calls = []
>>> @async_cached(ttl=60)
... async def cube(x):
... calls.append(x)
... return x * x * x
...
>>> async def main():
... print(await cube(4))
... print(await cube(4)) # served from the cache, not re-computed
...
>>> asyncio.run(main())
64
64
>>> calls
[4]
cached and async_cached accept the same strategy and ignore_non_serializable options as
Cache.memoize (see above), since they compute cache keys the same way:
>>> from persista.cache import cached
>>> calls = []
>>> @cached(ttl=60, strategy="json", ignore_non_serializable=True)
... def greet(name, client=None):
... calls.append(name)
... return f"hello {name}"
...
>>> greet("Ann", client=object())
'hello Ann'
>>> greet("Ann", client=object()) # different (non-serializable) client, still a cache hit
'hello Ann'
>>> calls
['Ann']
Use set_cache to replace the shared default cache, for example to change its backend or
default TTL globally. Since cached and async_cached both look up the same get_cache, this
affects both:
>>> from persista.cache import Cache
>>> from persista.cache import get_cache, set_cache
>>> previous = get_cache()
>>> new_cache = Cache(default_ttl=60)
>>> new_cache.open()
>>> set_cache(new_cache)
>>> get_cache().default_ttl
60
>>> set_cache(previous) # restore the previous default cache
Cache Keys¶
Internally, memoize, cached, and async_cached derive a cache key from the function's
qualified name and its arguments using make_key, which serializes (func, args, kwargs) with
sorted keyword argument keys and hashes the result. Calls with the same arguments (regardless of
keyword argument order) map to the same key:
>>> from persista.cache.utils import make_key
>>> make_key("add", (1, 2), {}) == make_key("add", (1, 2), {})
True
>>> make_key("add", (), {"a": 1, "b": 2}) == make_key("add", (), {"b": 2, "a": 1})
True
>>> make_key("add", (1, 2), {}) == make_key("add", (1, 3), {})
False
make_key supports two serialization strategies, selected with strategy:
"json"(the default): serializes withjsonbefore hashing, so keys are stable across Python versions and processes, but every argument must be JSON-serializable (dict,list,str,int,float,bool,None, and nested combinations thereof)."pickle": serializes withpicklebefore hashing. Supports a broader range of argument types (e.g. custom objects,datetimes) than"json", at the cost of a key that is only stable within a single Python version, since pickle's format can change across versions.
>>> from persista.cache.utils import make_key
>>> make_key("add", (1, 2), {}, strategy="json") == make_key(
... "add", (1, 2), {}, strategy="json"
... )
True
By default, an argument that isn't serializable with strategy raises an error when the key is
computed — meaning the decorated function can't be called with that argument at all. Pass
ignore_non_serializable=True to instead silently drop non-serializable positional arguments and
keyword argument values before computing the key:
>>> import threading
>>> from persista.cache.utils import make_key
>>> make_key("add", (1, threading.Lock()), {}, ignore_non_serializable=True) == make_key(
... "add", (1,), {}, ignore_non_serializable=True
... )
True
This is useful for arguments that will never be serializable (e.g. a logger or a client instance) but that shouldn't block caching. Note that since the argument is dropped rather than incorporated into the key, calls that differ only in such an argument are treated as the same call and share a cached result — make sure that's the behavior you want before enabling it for a given argument.
API Reference¶
See the reference documentation for the full API.