# Decorator to cache a function result for some time

I recently played around with a script that got some data from the Google API. As I didn't want to spam requests at the service (and potentially get blocked), I made this decorator, which caches the result of a function for a specified amount of time. Any call after the time-to-live (TTL) will call the function again.

This is the first decorator I wrote that takes an optional argument (the time to keep the cache). That code was taken from this StackOverflow answer by @Eric. I also couldn't abstain from using the new walrus operator (Python 3.8+), since I'm always looking for opportunities to use it in order to get a better feel for it.

Any and all advice on how to make this better or more readable are welcome.

from datetime import datetime
from functools import wraps

DEBUG = True

def temporary_cache(*args, ttl=60):
"""A decorator that ensures that the result of the function call
is cached for ttl seconds (default: 60).

Warning: The returned object is stored directly, mutating it also mutates the
cached object. Make a copy if you want to avoid that.
"""
def decorator(func):
func.cache = None
func.cache_time = datetime.fromordinal(1)

@wraps(func)
def inner(*args, **kwargs):
if ((now := datetime.now()) - func.cache_time).total_seconds() > ttl:
func.cache = func(*args, **kwargs)
func.cache_time = now
elif DEBUG:
# for debugging, disable in production
print("Cached", func.__name__)
return func.cache
return inner
if len(args) == 1 and callable(args[0]):
return decorator(args[0])
elif args:
raise ValueError("Must supply the decorator arguments as keywords.")
return decorator


Example usages:

import time

@temporary_cache
def f():
return datetime.now()

@temporary_cache(ttl=1)
def g():
return datetime.now()

if __name__ == "__main__":
print(f())
# 2020-05-12 10:41:18.633386
time.sleep(2)
print(f())
# Cached f
# 2020-05-12 10:41:18.633386

print(g())
# 2020-05-12 10:41:20.635594
time.sleep(2)
print(g())
# 2020-05-12 10:41:22.636782


Note that f was still cached, while g was not, because the TTL is shorter than the time between calls.

• What do you want to happen if the cached function has different arguments? Or do you only cache functions that don't take arguments? – RootTwo May 12 '20 at 18:53
• @RootTwo: Good question! The decorator allows arguments, but ignores them. This is fine for the usecase I had (a function with no arguments), but in order to generalize it I would have to make cache and cache_time dictionaries of the arguments. – Graipher May 12 '20 at 21:31

• Rather than using *args you can supply a default positional only argument.

def temporary_cache(fn=None, *, ttl=60):
...
if fn is not None:
return decorator(fn)
return decorator

• If you feel following "flat is better than nested" is best, we can use functools.partial to remove the need to define decorator.

def temporary_cache(fn=None, *, ttl=60):
if fn is None:
return functools.partial(temporary_cache, ttl=ttl)

@functools.wraps(fn)
def inner(*args, **kwargs):
...

• for debugging, disable in production

You can use logging for this. I will leave actually implementing this as an exercise.

• I also couldn't abstain from using the new walrus operator (Python 3.8+), since I'm always looking for opportunities to use it in order to get a better feel for it.

A very reasonable thing to do. Abuse the new feature until you know what not to do. +1

However, I don't think this is a good place for it. Given all the brackets in such a small space I'm getting bracket blindness. I can't tell where one bracket ends and the others starts.

• I am not a fan of func.cache = ... and func.cache_time. You can stop assigning to a function by using nonlocal.

Bringing this all together, and following some of my personal style guide, gets the following. I'm not really sure which is better, but it's food for thought.

from datetime import datetime
import functools

def temporary_cache(fn=None, *, ttl=60):
if fn is None:
return functools.partial(temporary_cache, ttl=ttl)

cache = None
cache_time = datetime.fromordinal(1)

@functools.wraps(fn)
def inner(*args, **kwargs):
nonlocal cache, cache_time
now = datetime.now()
if ttl < (now - cache_time).total_seconds():
cache = fn(*args, **kwargs)
cache_time = now
elif DEBUG:
# for debugging, disable in production
print("Cached", fn.__name__)
return cache
return inner

• I was playing around with making ttl keyword only, but when preceeded by *args it insisted on args having to be empty(?). This is a nice way around it. I also previously had nonlocal, but liked func.cache more because then it is possible to get the value from outside. Didn't know you could assign multiple variables nonlocal at once, though! – Graipher May 12 '20 at 13:36
• @Graipher That sounds strange, I really don't know what was going on there :O I'm not entirely sure why you want to get func.cache from the outside, but me understanding isn't really important :) However since you do, might I suggest inner.cache over func.cache. – Peilonrayz May 12 '20 at 13:43
• Well, whenever I was using caching decorators in the past, there came the time (during development) where I needed to have a look what was actually in the cache, so it's just convenience, I guess :D – Graipher May 12 '20 at 14:16