← All advanced lessons

Advanced Python / A06

Decorators — Course Draft

The complete decorator chapter draft with instructor feedback, assignments, and project.

Advanced Python - Decorators

Finkel feedback on Yu’s original draft

Advanced Python - 1. Decorators

1. Decorators

1.1 Functions as First Class Objects

1.2 Introduction to Decorators

1.3 Use the @ symbol to apply the decorator

1.4 Reusing Decorators

1.5 Decorating Functions With Arguments

1.6 Returning Values From Decorated Functions

1.7 Chaining Decorators

1.8 Common Use Cases for Decorators

Assignments

A1.1 Basic Decorator

A1.2 Watering plants

A1.3 Baking Cake

A1.4 Order Coffee

Project

P1.1 Password Validation

1. Decorators

1.1 Functions as First Class Objects

(might need to make 1.1 into its own chapter - Call it “Advanced Functions”)

Before diving deep into decorators, let us understand some concepts that will come in handy in learning the decorators. In Python, functions are first class objects, meaning they can be used or passed as arguments.

(brief one line explanation/review of what an argument is)

(review of anatomy of a function. IN/OUT/parameters/return type/parentheses)

Properties of first class functions:

(are we talking about first class objects or first class functions? Difference?)

(need many examples of all of the following - Build out this material for quizzes and assignments)

  1. A function is an instance of the Object type. (what does this really mean?)
  2. You can store the function in a variable.
  3. You can pass the function as a parameter to another function.
  4. You can return a function from a function.
  5. You can store them in data structures such as lists, dictionaries, etc.

Example #1: Treating Functions as Objects

def shout(text):
return text.upper()

yell = shout

print(shout('Have a good day!')) # Output: HAVE A GOOD DAY!
print(yell('Have a good day!')) # Output: HAVE A GOOD DAY!

Example #2: Passing the Function as an Argument

def say_hello(name):
return f"Hello {name}"

(adding space for clarity)
def greet_lucy(func):
return func("Lucy")

greet_lucy(say_hello) # Output: Hello Lucy

Note that greet_lucy(say_hello) refers to two functions, greet_lucy() and say_hello. say_hello function is named without parentheses, meaning that only a reference to the function is passed. (Might need even a more detailed walkthrough)

Example #3: Returning functions from another function (needs an explanation)

def create_adder(x):
def adder(y):
return x+y
return adder

add_10 = create_adder(10)

print(add_10(5)) # Output: 15

TO BE REVIEWED:

1.2 Introduction to Decorators

A decorator in Python is a special type of function that is used to modify the behavior of another function or method. It’s like adding extra features or functionality to a function without changing its actual code.

Example #4: Simple decorator

def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper

def say_whee():
print("Whee!")

say_whee = my_decorator(say_whee)

Can you guess what happens when you call say_whee()?

say_whee()

# Output:
# Something is happening before the function is called.
# Whee!
# Something is happening after the function is called.

To understand what’s going on here, look back at the previous examples. We are literally just applying everything you have learned so far.

The so-called decoration happens at the following line:

say_whee = my_decorator(say_whee)

In effect, the name say_whee now points to the wrapper() inner function. Remember that you return wrapper as a function when you call my_decorator(say_whee)

print(say_whee)

# Output:
# <function my_decorator.<locals>.wrapper at 0x7f3c5dfd42f0>

However, wrapper() has a reference to the original say_whee() as func, and calls that function between the two calls to print().

Put simply: decorators wrap a function, modifying its behavior.

1.3 Use the @ symbol to apply the decorator

In the previous section, we manually apply the decorator. We could also use the @ symbol to apply a decorator to a function. This is called “syntactic sugar” because it makes the code easier to read and write. In this case, @my_decorator is a shorter way of saying say_whee = my_decorator(say_whee).

1.4 Reusing Decorators

Using decorators in Python also ensures that your code is DRY(Don't Repeat Yourself). You could create a module where you store your decorators and that you can use in other functions.

Let’s create a file named decorators.py with the following content:

# decorators.py

def do_twice(func):
def wrapper_do_twice():
func()
func()
return wrapper_do_twice

Next, create another file named main.py where you want to use the do_twice decorator.

# main.py

from decorators import do_twice

@do_twice
def say_whee():
print("Whee!")

say_whee()

# Output:

# Whee!

# Whee!

This modular approach allows you to reuse the do_twice decorator in multiple places by simply importing it, making your code more organized and maintainable.

Note: You can name your inner function whatever you want, and a generic name like wrapper() is usually okay.

1.5 Decorating Functions With Arguments

Let’s say you have a function that takes an argument and you want to use do_twice decorator. What will happen with this code?

# main.py

from decorators import do_twice

@do_twice
def greet(name):
print(f"Hello {name}!")

say_whee(name="World")

This will raise an error:

TypeError: do_twice.<locals>.wrapper_do_twice() got an unexpected keyword argument 'name'

This is because our inner function wrapper_do_twice() doesn’t accept any arguments, yet we passed name="World" to it. One way to resolve this would be to allow wrapper_do_twice() to accept a single argument. However, this solution would cause it to fail when used with the say_whee() function created earlier, which does not require any arguments.

The solution is to use *args and **kwargs in the inner wrapper function. Then it will accept an arbitrary number of positional and keyword arguments.

Positional arguments are arguments that are passed to a function in a specific order.

Example #5: Positional arguments

def greet(name, age):
print(f"Hello, {name}. You are {age} years old.")

greet("Alice", 16)

In this example, “Alice” is the first positional argument assigned to name, and 16 is the second positional argument assigned to age.

Keyword arguments are passed to a function by explicitly stating the name of the parameter along with its value. The order of keyword arguments does not matter.

Example #6: Keyword arguments

def greet(name, age):
print(f"Hello, {name}. You are {age} years old.")

greet(name="Alice", age=16)
greet(age=16, name="Alice") # Order does not matter

Let’s rewrite decorators.py as follows:

def do_twice(func):
def wrapper_do_twice(*args, **kwargs):
func(*args, **kwargs)
func(*args, **kwargs)
return wrapper_do_twice

The wrapper_do_twice() inner function now accepts any number of arguments and passes them on to the function it decorates.

Then update main.py as follows:

# main.py
from decorators import do_twice

@do_twice
def say_whee():
print("Whee!")

@do_twice
def greet(name):
print(f"Hello, {name}!")

say_whee()
greet("World")

# Output:

# Whee!
# Whee!
# Hello World
# Hello World

Now you can use @do_twice to decorate different functions. This is one of the powers of decorators.

1.6 Returning Values From Decorated Functions

What if we decorate a function with a return value? Take a look at the following example and guess the output.

Example #7

# main.py
from decorators import do_twice

@do_twice
def return_greeting(name):
print("Creating greeting!")
return (f"Hello {name}")

print(return_greeting("Adam"))

# Output:

# Creating greeting!

# Creating greeting!

# None

Oops, the return value disappears.This is because wrapper_do_twice() does not explicitly return a value. As a result, calling return_greeting("Adam") ends up returning None.

To fix this, you need to make sure the wrapper function returns the return value of the decorated function. Change your decorators.py file:

def do_twice(func):
def wrapper_do_twice(*args, **kwargs):
func(*args, **kwargs)
return func(*args, **kwargs) # update this line
return wrapper_do_twice

1.7 Chaining Decorators

Chaining decorators means decorating a function with multiple decorators. Let’s take a look at the decorator order in the following example.

Example #8:

@decorator1
@decorator2
@decorator3
def function():
pass

# Is equivalent to:
function = decorator1(decorator2(decorator3(function)))

Example #9: Changing order of decorators

# decorators.py

def uppercase(func):
def wrapper_uppercase(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper_uppercase

def exclaim(func):
def wrapper_exclaim(*args, **kwargs):
result = func(*args, **kwargs)
Return result + "!"
return wrapper_exclaim

Now we use these decorators in main.py.

# main.py
from decorators import uppercase, exclaim

@uppercase

@exclaim

def greet(name):

return f"hello, {name}"

print(greet("world")) # Output: HELLO, WORLD!

@exclaim

@uppercase

def greet(name):

return f"hello, {name}"

print(greet("world")) # Output: HELLO, WORLD!!

Chaining decorators in Python involves applying multiple decorators to a single function, allowing for modular and reusable code enhancements.

1.8 Common Use Cases for Decorators

(1)Authorization and Logging: One practical use case is in web applications where you need to check if a user is authorized to access a resource and then log the access. For example, you can chain an authorization decorator with a logging decorator to ensure that authorization is checked before logging the access:

Example #10:

def authorize(func):
def wrapper(*args, **kwargs):
if not user_is_authorized():
raise PermissionError("User not authorized")
return func(*args, **kwargs)
return wrapper

def log_access(func):
def wrapper(*args, **kwargs):
print(f"Accessing {func.__name__}")
return func(*args, **kwargs)
return wrapper

@log_access
@authorize
def view_resource():
print("Resource content")

(2) Validation

Chaining decorators can also be used for input validation to ensure that function arguments meet certain criteria before execution.

Example #11:

def validate_positive(func):
def wrapper(arg):
if arg < 0:
raise ValueError("Input must be a positive value.")
return func(arg)
return wrapper

def validate_integer(func):
def wrapper(arg):
if not isinstance(arg, int):
raise ValueError("Input must be an integer.")
return func(arg)
return wrapper

@validate_positive
@validate_integer
def square(num):
return num * num

print(square(5))

# Output: 25

print(square(-5))

# Output: Raises ValueError: Input must be a positive value.

print(square(5.5))

# Output: Raises ValueError: Input must be an integer.

(3) Formatting and Timing

Decorators can be used to format the output of a function, such as converting it to JSON, adding HTML tags, or applying any other formatting.

Example #12:

import time

def format_output(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"Result: {result}"
return wrapper

def timing(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Execution time: {end_time - start_time} seconds")
return result
return wrapper

@format_output
@timing
def compute(x, y):
return x + y

print(compute(10, 20))

# Output:

# Execution time: 1.1920928955078125e-06 seconds

# Result: 30

In conclusion, Chaining decorators in Python allows for clean, modular, and reusable code, enhancing the functionality of functions in a structured manner.

Assignments

A1.1 Basic Decorator

Create a decorator named print_args that prints the arguments passed to a function. Apply print_args to a function add(a,b) that returns the sum of a and b. Test the function with print(add(3,6)).

Expected output:

Arguments: (3, 6), {}
9

Follow-up Question: why is there an empty dictionary {} in the output? Think of it.

Solution:

def print_args(func):
def wrapper(*args, **kwargs):
print(f"Arguments: {args}, {kwargs}")
return func(*args, **kwargs)
return wrapper

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

A1.2 Watering plants

Create a decorator named repeat_task that takes an argument n and repeats the execution of the decorated function n times. Apply repeat_task(n) to a function water_plants() that prints “Watering plants''. Test it with different n values.

Solution:

def repeat_task(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
func(*args, **kwargs)
return wrapper
return decorator

@repeat_task(3)
def water_plants():
print("Watering plants")
water_plants()

# Output:

# Watering plants

# Watering plants

# Watering plants

A1.3 Baking Cake

Create a decorator named time_it that measures the time taken by a function to execute and prints the duration. Apply time_it to a function bake_cake() that simulates baking a cake by sleeping for 2 seconds.

Hints:

Expected Output:

Baking cake...
Cake is ready!
Time taken: 2.0052578449249268 seconds

Solution:

import time

def time_it(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Time taken: {end_time - start_time} seconds")
return result
return wrapper

@time_it
def bake_cake():
print("Baking cake...")
time.sleep(2)
print("Cake is ready!")

# Test the function
bake_cake()

A1.4 Order Coffee

You're developing a system for a coffee shop where customers can customize their coffee orders through a digital interface. Your task is to implement a flexible coffee customization system using decorators. Each decorator should update both the name and the price of the coffee.The final function should return a dictionary with the complete coffee name and total price.

Instructions for decorators:

Hints: add_milk(milk_type) allows us to create a decorator that takes a parameter. Before you start to code, consider the structure of your decorator, what information do you need when applying the decorator? And think about the layers of functions, for example, which function should accept the function to be decorated?

Example Usage:

@add_milk("almond")
@add_sugar(2)
@add_syrup("vanilla")
def make_coffee():
return {"name": "Coffee", "price": 2.50}

print(make_coffee())

Expected Output:

{'name': 'Coffee with vanilla syrup, 2 spoons of sugar, and almond milk', 'price': 4.25}

Solution:

def add_milk(milk_type):
def decorator(func):
def wrapper():
coffee = func()
coffee['name'] += f" with {milk_type} milk"
coffee['price'] += 0.50
return coffee
return wrapper
return decorator

def add_sugar(spoons):
def decorator(func):
def wrapper():
if spoons < 0:
raise ValueError("Number of sugar spoons cannot be negative")
coffee = func()
coffee['name'] += f", {spoons} spoon(s) of sugar"
coffee['price'] += 0.25 * spoons
return coffee
return wrapper
return decorator

def add_syrup(flavor):
def decorator(func):
def wrapper():
coffee = func()
coffee['name'] += f" with {flavor} syrup"
coffee['price'] += 0.75
return coffee
return wrapper
return decorator

Project

P1.1 Password Validation

Create a password validation system using decorators. The system will ensure that passwords meet the following criteria before they are accepted. Simply printing a message such as “Password must have at least 8 characters” is okay.

1. Minimum Length: The password must be at least 8 characters long.

2. Uppercase Letter: The password must contain at least one uppercase letter.

3. Lowercase Letter: The password must contain at least one lowercase letter.

4. Digit: The password must contain at least one digit.

Test your implementation with different passwords to ensure all validation rules are enforced.

Example Usage:

passwords = [
"short",
"noUpperCase1",
"NOLOWERCASE1",
"NoDigitHere",
"Valid1Password"
]

for pwd in passwords:
print(f"Testing password: {pwd}")
validate_password(pwd)
print()

Expected output:

Testing password: short
Password must have at least 8 characters

Testing password: noUpperCase1
Password is valid

Testing password: NOLOWERCASE1
Password must contain at least one lowercase letter

Testing password: NoDigitHere
Password must contain at least one digit

Testing password: Valid1Password
Password is valid

Solution:

def min_length(func):
def wrapper(password):
if len(password) < 8:
print("Password must have at least 8 characters")
return False
return func(password)
return wrapper

def has_uppercase(func):
def wrapper(password):
if not any(char.isupper() for char in password):
print("Password must contain at least one uppercase letter")
return False
return func(password)
return wrapper

def has_lowercase(func):
def wrapper(password):
if not any(char.islower() for char in password):
print("Password must contain at least one lowercase letter")
return False
return func(password)
return wrapper

def has_digit(func):
def wrapper(password):
if not any(char.isdigit() for char in password):
print("Password must contain at least one digit")
return False
return func(password)
return wrapper

@min_length
@has_uppercase
@has_lowercase
@has_digit
def validate_password(password):
print("Password is valid")
return True