7
\$\begingroup\$

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.

\$\endgroup\$
2
  • \$\begingroup\$ What do you want to happen if the cached function has different arguments? Or do you only cache functions that don't take arguments? \$\endgroup\$
    – RootTwo
    May 12, 2020 at 18:53
  • \$\begingroup\$ @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. \$\endgroup\$
    – Graipher
    May 12, 2020 at 21:31

1 Answer 1

5
\$\begingroup\$
  • 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
\$\endgroup\$
3
  • \$\begingroup\$ 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! \$\endgroup\$
    – Graipher
    May 12, 2020 at 13:36
  • 1
    \$\begingroup\$ @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. \$\endgroup\$
    – Peilonrayz
    May 12, 2020 at 13:43
  • \$\begingroup\$ 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 \$\endgroup\$
    – Graipher
    May 12, 2020 at 14:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.