Practical Decorators

Practical Decorators

April 6, 2025

From Reuven Lerner at PyCon 2019.

When you see this:

@once_per_minute
def add(a, b):
    return a + b

We should think this:

def add(a, b):
    return a + b

add = once_per_minute(add)

Example 1: Timing

How long does it take for a function to run?

def logtime(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = runc(*args, **kwargs)
        total_time = time.time() - start_time
        with open('timelog.txt', 'a') as outfile:
            outfile.write(f'{time.time{}}\t{func.__name__}\t{total_time}\n')
        return result
    return wrapper

To apply the decorator:

@logtime
def slow_add(a, b):
    time.sleep(2)
    return a + b

@logtime
def slow_mul(am b):
    time.sleep(3)
    return a * b

Example 2: Once per Minute

Raise an exception if we try to run a function more than once in 60 seconds.

def once_per_minue(func):
    last_invoked = 0

    def wrapper(*args, **kwargs):
        nonlocal last_invoked
        elapsed_time = time.time() - last_invoked

        if elasped_time < 60:
            raise CalledTooOftenError(f"Only {elapsed_time has passed")

        last_invoked = time.time()

        return func(*args, **kwargs)

    return wrapper

A nonlocal will make a variable non local and refer to the one in the outer function.

Example 3: Once per n

Raise an exception if we try to run a function more than once in n seconds.

When we see this:

@once_per_n(5)
def add(a, b):
    return a + b

We should think this:

def add(a, b):
    return a + b

add = once_per_n(5)(add)

There will be 4 callables.

def once_per_n(n):

    def middle(func):
        last_invoked = 0

        def wrapper(*args, **kwargs):
            nonlocal last_invoked
            elapsed_time = time.time() - last_invoked

            if elasped_time < 60:
                raise CalledTooOftenError(f"Only {elapsed_time has passed")

            last_invoked = time.time()

            return func(*args, **kwargs)

        return wrapper

    return middle

Example 4: Memoization

Cache the results of function calls, so we don’t need to call them again.

def memoize(func):
    cache = {}

    def wrapper(*args, **kwargs):
        if args not in cache:
            print(f"Caching NEW value for {func.__name__}{args}")
            cache[args] = func(*args, **kwargs)
        else:
            print(f"Using OLD value for {func.__name__}{args}")
        return cache[args]
    return wrapper

But this won’t work if it is non hash able. Instead, using pickle it will become hashable.

def memoize(func):
    cache = {}

    def wrapper(*args, **kwargs):
        t = (pickle.dumps(args), pickle.dumps(kwargs))
        if t not in cache:
            print(f"Caching NEW value for {func.__name__}{args}")
            cache[t] = func(*args, **kwargs)
        else:
            print(f"Using OLD value for {func.__name__}{args}")
        return cache[t]
    return wrapper

Conclusions

  • Decorators let you DRY (Don’t Repeat Yourself) up your callables.
  • Understanding how many callables are involved makes it easier to see what problems can be solved and how.
  • Decorators make it dramatically easier to do many things.
  • Of course, much of this depends on the fact that in Python, callables (functions and classes) are objects like any other – and can be passed and returned easily.