# a simple example
def print_before_and_after(func):
def newfunc(*args, **kwargs):
print("BEFORE", func)
func(*args, **kwargs)
print("AFTER", func)
return newfunc12 Decorators
Decorators
A common pattern in functional programs, are functions that are built to “wrap” other functions.
This pattern allows one to attach behavior before or after a function call, making it possible to:
- log function calls
- validate or modify arguments (before)
- validate or modify return value (after)
- check performance or other characteristics of the function (e.g. time elapsed)
- replace the inner function wholesale, for any purpose
This function print_before_and_after is called a decorator, it takes a function, and returns a new function.
Typically this new function will call the original function, but that is not required!
def inner(a, b, c):
print("inner function", a, b, c)wrapped_inner = print_before_and_after(inner)
wrapped_inner(1, 2, 3)BEFORE <function inner at 0x103e08b80>
inner function 1 2 3
AFTER <function inner at 0x103e08b80>
# often we want to replace the function altogether
inner = print_before_and_after(inner)
inner(1, 2, 3)BEFORE <function inner at 0x103e08b80>
inner function 1 2 3
AFTER <function inner at 0x103e08b80>
Decorator Syntax
Wanting to replace a function with its decorated form is so common, we have a special syntax:
@print_before_and_after
def add_nums(a, b, c):
print(f"{a} + {b} + {c} =", a + b + c)
add_nums(1, 2, 3)
# same as add_nums = print_before_and_after(add_nums)BEFORE <function add_nums at 0x103e085e0>
1 + 2 + 3 = 6
AFTER <function add_nums at 0x103e085e0>
We can apply multiple decorators, here we’ll apply the same one twice:
@print_before_and_after
@print_before_and_after
def mult_nums(a, b, c):
print(f"{a} * {b} * {c} =", a * b * c)
mult_nums(1, 2, 3)
# same as mult_nums = print_before_and_after(print_before_and_after(add_nums))BEFORE <function print_before_and_after.<locals>.newfunc at 0x103e08540>
BEFORE <function mult_nums at 0x103e08680>
1 * 2 * 3 = 6
AFTER <function mult_nums at 0x103e08680>
AFTER <function print_before_and_after.<locals>.newfunc at 0x103e08540>
Creating a Cache
We saw that we can use enclosing scope to create a cache, now we can do that in a generic way that will work for any function:
def cache(func):
inner_cache = {}
def newfunc(*args):
if args not in inner_cache:
inner_cache[args] = func(*args)
return inner_cache[args]
return newfunc@cache
def expensive_calculation(a, b, *, c=0):
print(f"doing expensive calculation on {a} {b}...")
return a ** b
@cache
def cheap_calculation(a, b):
print(f"doing cheap calculation on {a} {b}...")
return a + bexpensive_calculation(4, 10)doing expensive calculation on 4 10...
1048576
expensive_calculation(4, 10)1048576
# independent cache
cheap_calculation(4, 10)doing cheap calculation on 4 10...
14
expensive_calculation(5, 6)doing expensive calculation on 5 6...
15625
expensive_calculation(5, 6)15625
Decorators with Arguments
Python has a built in functools.lru_cache function, which gives us a cache of a defined size:
@lru_cache(100)
def some_calc(a, b, c):
passTo write a decorator that takes additional arguments, you must write one more function.
We already had:
- decorator function: takes function, returns function
- inner function: takes *args, **kwargs typically, since outer function isn’t known, returns same type as wrapped func
Now we add one more outside that decorator function, a factory function that returns a decorator!
def repeat(n): # factory: takes integer, returns decorator
def repeat_decorator(func): # decorator: takes function, returns function
def newfunc(*args, **kwargs): # inner function: takes ?, returns ?
for i in range(n):
func(*args, **kwargs)
return newfunc
return repeat_decorator
@repeat(10)
def print_backwards(s):
print(s[::-1])
print_backwards("backwards")sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
sdrawkcab
# we can see the layers if we call one step at a time:
# first, obtain the decorator from the factory
repeat_10 = repeat(10)
print(repeat_10)
# apply the decorator to the wrapped function
print_backwards = repeat_10(print_backwards)<function repeat.<locals>.repeat_decorator at 0x103e08720>
Function Properties
Let’s look at functools.partial again:
https://docs.python.org/3/library/functools.html#functools.partial
import functools
print_hello_names = functools.partial(print, "Hello", sep=", ")print_hello_names("Scott", "Paul", "Lauren")
# "hello" already bound, as is sep, so this is the same as:
# print("Hello", "Scott", "Paul", "Lauren", sep=", ")Hello, Scott, Paul, Lauren
Partial stores attributes on the function, functions are mutable!
# bound args
print_hello_names.args('Hello',)
# bound keywords
print_hello_names.keywords{'sep': ', '}
# original func
print_hello_names.func<function print(*args, sep=' ', end='\n', file=None, flush=False)>
# since functions are objects, we can attach arbitrary values to them
def wrapper(func):
def newfunc(*args, **kwargs):
return func(*args, **kwargs)
# we can do whatever we like after defining newfunc, but before returning it
newfunc.xyz = "hello"*2
return newfunc# property is assigned to all wrapped functions
@wrapper
def our_function():
print("inside our function")
our_function.xyz'hellohello'
Writing our own partial
def our_partial(func, /, *args, **keywords):
def newfunc(*fargs, **fkeywords):
newkeywords = {**keywords, **fkeywords}
return func(*args, *fargs, **newkeywords)
# assign these properties from within the closure
newfunc.func = func
newfunc.args = args
newfunc.keywords = keywords
return newfuncprint_hello_names2 = our_partial(print, "Hello", sep=", ")
print_hello_names2("Scott", "Paul", "Lauren", end="!")Hello, Scott, Paul, Lauren!
#print_hello_names2 = our_partial(print, "Hello", sep=", ")
print_hello_names2("Scott", "Paul", "Lauren", end="!", sep="?")Hello?Scott?Paul?Lauren!
print_hello_names2.args('Hello',)
print_hello_names2.keywords{'sep': ', '}
print_hello_names2.func<function print(*args, sep=' ', end='\n', file=None, flush=False)>
More Decorator Examples
Not Calling the Wrapped Function
def disable(f):
def newfunc(*args, **kwargs):
print("would have called ", f.__name__)
return newfunc
@disable
def expensive_func(a, b):
print("expensive!")
expensive_func(1, 2)would have called expensive_func
Modifying Arguments within newfunc
def reverse_args(func):
def newfunc(*args):
# within newfunc we are able to write whatever we'd like
# we could decide that we want to reverse the arguments
reversed_args = reversed(args)
return func(*reversed_args)
return newfunc
@reverse_args
def print_args(*args):
for arg in args:
print(arg)print_args(1, 2, 3)3
2
1
@reverse_args
def divide(a, b):
return a / b
divide(10, 2)0.2
Modifying Keyword Parameters
# if we didn't want auth_user to be passed through, we'd make a small modification to newfunc
def auth_required(func):
allowed_users = ("lauren", "mitch")
def newfunc(*args, auth_user, **kwargs):
# newfunc now requires auth_user, and passes through all *other* parameters
if auth_user in allowed_users:
func(*args, **kwargs)
else:
print("ACCESS DENIED")
return newfunc
# auth_user is no longer seen in the definition of these functions
# but it can be passed in since the newfunc returned from auth_required accepts it
@auth_required
def withdraw_funds(account, amount):
print(f"withdrew {amount} funds from account={account}")
@auth_required
def delete_account(account):
print("deleted", account)withdraw_funds("jim", 100, auth_user="lauren")withdrew 100 funds from account=jim
delete_account("kevin", auth_user="jim")ACCESS DENIED