Python Decorators
By: Cam Wohlfeil
Published: 2018-09-09 2330 EDT
Category: Programming
Tags:
python
Decorators in Python were confusing when I first came across them, but the concept is simple enough and very powerful. Note that Python decorators are not to be confused with the design pattern of the same name. In Python, everything is an object and functions are first class citizens. Those are both deep, complicated topics, but suffice it to say that because of this, everything in Python can be treated like a value, including functions, classes, and modules. You can bind names to these objects, pass them as arguments to functions, define functions inside of functions, and return them from functions. For example:
# Passing a function
def is_even(value):
"""Returns True if value is even."""
return (value % 2) == 0
def count_occurrences(target_list, predicate):
""" Takes a list and a predicate function, returns the number of times
the predicate returns True for elements in the list."""
return sum([1 for e in target_list if predicate(e)])
# Bind the name my_predicate to is_even function itself
my_predicate = is_even
my_list = [2, 4, 6, 7, 9, 11]
# Pass my_list and my_predicate to count_occurrences
result = count_occurrences(my_list, my_predicate)
print(result)
# Returning a function
def surround_with(surrounding):
"""Return a function that takes a single argument."""
def surround_with_value(word):
return f"{surrounding}, {word}, {surrounding}"
return surround_with_value
def transform_words(content, targets, transform):
"""Return a string based on content, but with each occurrence of words
in targets replaced with the result of applying transform to it."""
result = ""
for word in content.split():
if word in targets:
result += f" {transform(word)}"
else:
result += f" {word}"
return result
string = "My name is Alex and I like Python"
# Pass the string, a list of target words, and surround_with as the transform function
string_italicized = transform_words(string, ['Python', 'Alex'], surround_with('*'))
print(string_italicized)
surround_with doesn't ever return a normal value, it will always returns a new function. surround_with doesn't actually execute the function itself, it just creates a function that can do it. surround_with_value takes advantage of nested functions having access to the scope in which it is created, it doesn't need anything special to access this scope, it can use it as required.
These concepts relate to decorators because this is exactly what they do. Decorators are a function or a class that wraps (decorates) a function or method by taking it as passing it, performing some logic, and returning a function. The decorator function will replace the original function or method and can modify behavior. While this can be done manually thanks to Python's first class functions and the decorator keyword, there's a much better way.
# Basic Pythonic syntax
def foo():
# do something
def decorator(func):
# manipulate func
return func
foo = decorator(foo) # Manually decorate
@decorator
def bar():
# Do something
# bar() is decorated
# A more concrete example
def currency(f):
"""We don't know the parameters the function we're wrapping may take,
and the wrapper needs to call that function, so we accept all possible
arguments as parameters and pass them to the function call. """
def wrapper(*args, **kwargs):
# Convert arguments to a string and prepend '$'
return f"$ {str(f(*args, **kwargs))}"
return wrapper
class Product(db.Model):
name = db.StringColumn
price = db.FloatColumn
@currency
def price_with_tax(self, tax_rate_percentage):
"""Return the price with tax_rate_percentage applied.
tax_rate_percentage is the tax rate expressed as a float, i.e.
"7.0" for a 7% tax rate."""
return price * (1 + (tax_rate_percentage * .01))
We were able to prepend $ to the result of price_with_tax without making any changes to the function. This mechanism is useful for separating concerns, code re-use, and not polluting the core logic of the function or method. Some examples of whem this may come in handy are handling connections and requests, caching, logging, and checking permissions. One issue here is that that wrapping price_with_tax with currency changes its .__name__ and .__doc__ to that of currency. The functools module contains a useful tool to restore these values called wraps.
from functools import wraps
def currency(f):
@wraps(f)
def wrapper(*args, **kwargs):
return f"$ {str(f(*args, **kwargs))}"
return wrapper
Since they are just a regular function, the normal rules and tools of code reuse apply. You can move a decorator to it's own module and import it.
# decorators.py
from functools import wraps
def currency(f):
@wraps(f)
def wrapper(*args, **kwargs):
return f"$ {str(f(*args, **kwargs))}"
return wrapper
# Product.py
from decorators import currency
class Product(db.Model):
name = db.StringColumn
price = db.FloatColumn
@currency
def price_with_tax(self, tax_rate_percentage):
"""Return the price with tax_rate_percentage applied.
tax_rate_percentage is the tax rate expressed as a float, i.e.
"7.0" for a 7% tax rate."""
return price * (1 + (tax_rate_percentage * .01))
Here's another practical example, using a decorator to cache the results of a function call so it's only generated once.
# Manual caching
import functools
def cache(func):
"""Keep a cache of previous function calls"""
@functools.wraps(func)
def wrapper_cache(*args, **kwargs):
cache_key = args + tuple(kwargs.items())
if cache_key not in wrapper_cache.cache:
wrapper_cache.cache[cache_key] = func(*args, **kwargs)
return wrapper_cache.cache[cache_key]
wrapper_cache.cache = dict()
return wrapper_cache
@cache
def fibonacci(num):
if num < 2:
return num
return fibonacci(num - 1) + fibonacci(num - 2)
# Using more efficient built-in methods
import functools
@functools.lru_cache(maxsize=4)
def fibonacci(num):
print(f"Calculating fibonacci({num})")
if num < 2:
return num
return fibonacci(num - 1) + fibonacci(num - 2)
The full power of decorators is in passing arguments to a decorator and applying multiple decorators to one function.
# Passing arguments
def trace(func):
def wrapper(*args, **kwargs):
print(f'TRACE: calling {func.__name__}() with {args}, {kwargs}')
original_result = func(*args, **kwargs)
print(f'TRACE: {func.__name__}() returned {original_result!r}')
return original_result
return wrapper
@trace
def say(name, line):
return f'{name}: {line}'
>>> say('Jane', 'Hello, World')
'TRACE: calling say() with ("Jane", "Hello, World"), {}'
'TRACE: say() returned "Jane: Hello, World"'
'Jane: Hello, World'
# Applying multiple decorators
def strong(func):
def wrapper():
return '<strong>' + func() + '</strong>'
return wrapper
def emphasis(func):
def wrapper():
return '<em>' + func() + '</em>'
return wrapper
@strong
@emphasis
def greet():
return 'Hello!'
>>> greet()
'<strong><em>Hello!</em></strong>'
References
- https://jeffknupp.com/blog/2013/11/29/improve-your-python-decorators-explained/
- https://realpython.com/primer-on-python-decorators/
- https://dbader.org/blog/python-first-class-functions
- https://gist.github.com/Zearin/2f40b7b9cfc51132851a
- https://www.codementor.io/sheena/introduction-to-decorators-du107vo5c
- https://www.codementor.io/sheena/advanced-use-python-decorators-class-function-du107nxsv