Introduction
In this lab, you will gain a comprehensive understanding of decorators in Python, a powerful feature for modifying or enhancing functions and methods. We will begin by introducing the fundamental concept of decorators and exploring their basic usage through practical examples.
Building upon this foundation, you will learn how to effectively use functools.wraps to preserve important metadata of the decorated function. We will then delve into specific decorators like the property decorator, understanding its role in managing attribute access. Finally, the lab will clarify the distinctions between instance methods, class methods, and static methods, demonstrating how decorators are used in these contexts to control method behavior within classes.
Understanding Basic Decorators
In this step, we will introduce the concept of decorators and their basic usage. A decorator is a function that takes another function as an argument, adds some functionality, and returns another function, all without altering the source code of the original function.
First, locate the file decorator_basics.py in the file explorer on the left side of the WebIDE. Double-click to open it. We will write our first decorator in this file.
Copy and paste the following code into decorator_basics.py:
import datetime
def log_activity(func):
"""A simple decorator to log function calls."""
def wrapper(*args, **kwargs):
print(f"Calling function '{func.__name__}' at {datetime.datetime.now()}")
result = func(*args, **kwargs)
print(f"Function '{func.__name__}' finished.")
return result
return wrapper
@log_activity
def greet(name):
"""A simple function to greet someone."""
print(f"Hello, {name}!")
## Call the decorated function
greet("Alice")
## Let's inspect the function's metadata
print(f"\nFunction name: {greet.__name__}")
print(f"Function docstring: {greet.__doc__}")
Let's break down this code:
- We define a decorator function
log_activitywhich accepts a functionfuncas its argument. - Inside
log_activity, we define a nested functionwrapper. This function will contain the new behavior. It prints a log message, calls the original functionfunc, and then prints another log message. - The
log_activityfunction returns thewrapperfunction. - The
@log_activitysyntax above thegreetfunction is a shortcut forgreet = log_activity(greet). It applies our decorator to thegreetfunction.
Now, save the file (you can use Ctrl+S or Cmd+S). To run the script, open the integrated terminal at the bottom of the WebIDE and execute the following command:
python ~/project/decorator_basics.py
You will see the following output. Note that the datetime will vary.
Calling function 'greet' at 2023-10-27 10:30:00.123456
Hello, Alice!
Function 'greet' finished.
Function name: wrapper
Function docstring: None
Notice two things in the output. First, our greet function is now wrapped with the logging messages. Second, the function's name and docstring have been replaced by those of the wrapper function. This can be problematic for debugging and introspection. In the next step, we will learn how to fix this.
Preserving Function Metadata with functools.wraps
In the previous step, we observed that decorating a function replaces its original metadata (like __name__ and __doc__) with the metadata of the wrapper function. Python's functools module provides a solution for this: the wraps decorator.
The wraps decorator is used inside your own decorator to copy the metadata from the original function to the wrapper function.
Let's modify our code in decorator_basics.py. Open the file in the WebIDE and update it to use functools.wraps.
import datetime
from functools import wraps
def log_activity(func):
"""A simple decorator to log function calls."""
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling function '{func.__name__}' at {datetime.datetime.now()}")
result = func(*args, **kwargs)
print(f"Function '{func.__name__}' finished.")
return result
return wrapper
@log_activity
def greet(name):
"""A simple function to greet someone."""
print(f"Hello, {name}!")
## Call the decorated function
greet("Alice")
## Let's inspect the function's metadata again
print(f"\nFunction name: {greet.__name__}")
print(f"Function docstring: {greet.__doc__}")
The only changes are:
- We imported
wrapsfrom thefunctoolsmodule. - We added
@wraps(func)right above the definition of ourwrapperfunction.
Save the file and run it again from the terminal:
python ~/project/decorator_basics.py
Now, the output will be different:
Calling function 'greet' at 2023-10-27 10:35:00.543210
Hello, Alice!
Function 'greet' finished.
Function name: greet
Function docstring: A simple function to greet someone.
As you can see, the function name is correctly reported as greet, and its original docstring is preserved. Using functools.wraps is a best practice that makes your decorators more robust and professional.
Implementing Managed Attributes with @property
Python provides several built-in decorators. One of the most useful is @property, which allows you to turn a class method into a "managed attribute". This is ideal for adding logic like validation or computation to attribute access without changing the way users interact with your class.
Let's explore this by creating a Circle class. Open the file property_decorator.py from the file explorer.
Copy and paste the following code into property_decorator.py:
import math
class Circle:
def __init__(self, radius):
## The actual value is stored in a "private" attribute
self._radius = radius
@property
def radius(self):
"""The radius property."""
print("Getting radius...")
return self._radius
@radius.setter
def radius(self, value):
"""The radius setter with validation."""
print(f"Setting radius to {value}...")
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self):
"""A read-only computed property for the area."""
print("Calculating area...")
return math.pi * self._radius ** 2
## --- Let's test our Circle class ---
c = Circle(5)
## Access the radius like a normal attribute (triggers the getter)
print(f"Initial radius: {c.radius}\n")
## Change the radius (triggers the setter)
c.radius = 10
print(f"New radius: {c.radius}\n")
## Access the computed area property
print(f"Circle area: {c.area:.2f}\n")
## Try to set an invalid radius (triggers the setter's validation)
try:
c.radius = -2
except ValueError as e:
print(f"Error: {e}")
In this code:
@propertyon theradiusmethod defines a "getter". It's called when you accessc.radius.@radius.setterdefines a "setter" for theradiusproperty. It's called when you assign a value, likec.radius = 10. We've added validation here to prevent negative values.- The
areamethod also uses@propertybut has no setter, making it a read-only attribute. Its value is calculated every time it's accessed.
Save the file and run it from the terminal:
python ~/project/property_decorator.py
You should see the following output, demonstrating how the getter, setter, and validation logic are automatically invoked:
Getting radius...
Initial radius: 5
Setting radius to 10...
Getting radius...
New radius: 10
Calculating area...
Circle area: 314.16
Setting radius to -2...
Error: Radius cannot be negative
Differentiating Instance, Class, and Static Methods
In Python classes, methods can be bound to an instance, the class, or not bound at all. Decorators are used to define these different method types.
- Instance Methods: The default type. They receive the instance as the first argument, conventionally named
self. They operate on instance-specific data. - Class Methods: Marked with
@classmethod. They receive the class as the first argument, conventionally namedcls. They operate on class-level data and are often used as alternative constructors. - Static Methods: Marked with
@staticmethod. They do not receive any special first argument. They are essentially regular functions namespaced within a class and cannot access instance or class state.
Let's see all three in action. Open the file class_methods.py from the file explorer.
Copy and paste the following code into class_methods.py:
class MyClass:
class_variable = "I am a class variable"
def __init__(self, instance_variable):
self.instance_variable = instance_variable
## 1. Instance Method
def instance_method(self):
print("\n--- Calling Instance Method ---")
print(f"Can access instance data: self.instance_variable = '{self.instance_variable}'")
print(f"Can access class data: self.class_variable = '{self.class_variable}'")
## 2. Class Method
@classmethod
def class_method(cls):
print("\n--- Calling Class Method ---")
print(f"Can access class data: cls.class_variable = '{cls.class_variable}'")
## Note: Cannot access instance_variable without an instance
print("Cannot access instance data directly.")
## 3. Static Method
@staticmethod
def static_method(a, b):
print("\n--- Calling Static Method ---")
print("Cannot access instance or class data directly.")
print(f"Just a utility function: {a} + {b} = {a + b}")
## --- Let's test the methods ---
## Create an instance of the class
my_instance = MyClass("I am an instance variable")
## Call the instance method (requires an instance)
my_instance.instance_method()
## Call the class method (can be called on the class or an instance)
MyClass.class_method()
my_instance.class_method() ## Also works
## Call the static method (can be called on the class or an instance)
MyClass.static_method(10, 5)
my_instance.static_method(20, 8) ## Also works
Save the file and run it from the terminal:
python ~/project/class_methods.py
Examine the output carefully. It clearly demonstrates the capabilities and limitations of each method type.
--- Calling Instance Method ---
Can access instance data: self.instance_variable = 'I am an instance variable'
Can access class data: self.class_variable = 'I am a class variable'
--- Calling Class Method ---
Can access class data: cls.class_variable = 'I am a class variable'
Cannot access instance data directly.
--- Calling Class Method ---
Can access class data: cls.class_variable = 'I am a class variable'
Cannot access instance data directly.
--- Calling Static Method ---
Cannot access instance or class data directly.
Just a utility function: 10 + 5 = 15
--- Calling Static Method ---
Cannot access instance or class data directly.
Just a utility function: 20 + 8 = 28
This example provides a clear reference for when to use each type of method based on whether it needs access to instance state, class state, or neither.
Summary
In this lab, you have gained a practical understanding of decorators in Python. You started by learning how to create and apply a basic decorator to add functionality to a function. You then saw the importance of using functools.wraps to preserve the original function's metadata, a crucial best practice for writing clean and maintainable decorators.
Furthermore, you explored powerful built-in decorators. You learned to use the @property decorator to create managed attributes with custom getter and setter logic, enabling features like input validation. Finally, you distinguished between instance methods, class methods (@classmethod), and static methods (@staticmethod), understanding how each serves a different purpose within a class structure based on its access to instance and class state.



