Python course Β· Module 3: Object-Oriented Programming

Decorators - adapting to the environment

13 min read
In this lesson12

Welcome to the final lesson in Module 3! Darwin here with an advanced final lesson.

In nature, animals adapt to their environment - chameleons gain the ability to change color, bats develop echolocation, migratory birds develop magnetic navigation. In Python, decorators allow a similar adaptation - they modify the behavior of functions and classes without changing their code!

1# Regular function
2def observe_species(name):
3    print(f"Observation: {name}")
4
5# Function with decorator - automatic logging!
6@log_observation
7def observe_species(name):
8    print(f"Observation: {name}")

The function body did not change. One line, @log_observation, above the definition is enough to log every call automatically. You will see how to write such a decorator in a moment.

What are decorators?

A decorator is a function that takes another function (or class) and returns a modified version.

Safari Analogy: It's like equipping a researcher - binoculars don't change the researcher, but they extend their observation capabilities!

1def my_decorator(func):
2    """Decorator - takes a function, returns modified version"""
3    def wrapper():
4        print("Something before the function")
5        func()  # Call the original function
6        print("Something after the function")
7    return wrapper
8
9@my_decorator
10def say_hello():
11    print("Hello!")
12
13say_hello()

Calling say_hello() prints:

1Something before the function
2Hello!
3Something after the function

The middle line comes from the original function, the other two from the wrapper that the decorator put in place of say_hello.

The @decorator syntax is shorthand for:

1def say_hello():
2    print("Hello!")
3
4say_hello = my_decorator(say_hello)  # Manual decorating

How do decorators work?

A decorator is a higher-order function - a function that operates on functions!

1def simple_decorator(func):
2    """
3    Decorator - wrapper pattern
4
5    1. Accept a function
6    2. Define wrapper
7    3. Return wrapper
8    """
9    def wrapper():
10        print(f"[BEFORE] Calling {func.__name__}")
11        result = func()
12        print(f"[AFTER] Finished {func.__name__}")
13        return result
14    return wrapper
15
16@simple_decorator
17def greet():
18    print("Hello!")
19    return "Done"
20
21# greet is now wrapper, not the original function!
22greet()

The program prints:

1[BEFORE] Calling greet
2Hello!
3[AFTER] Finished greet

The wrapper also returns the original function's result (result), so the value "Done" is not lost on the way.

Decorators with function arguments

For the decorator to work with functions that accept arguments, use *args and **kwargs:

1def log_decorator(func):
2    def wrapper(*args, **kwargs):
3        """Wrapper accepts any arguments"""
4        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
5        result = func(*args, **kwargs)
6        print(f"Result: {result}")
7        return result
8    return wrapper
9
10@log_decorator
11def add(a, b):
12    return a + b
13
14@log_decorator
15def greet(name, greeting="Hello"):
16    return f"{greeting}, {name}!"
17
18add(5, 3)
19
20greet("Darwin", greeting="Hi")

Both calls print:

1Calling add with args=(5, 3), kwargs={}
2Result: 8
3Calling greet with args=('Darwin',), kwargs={'greeting': 'Hi'}
4Result: Hi, Darwin!

The same wrapper handled a function with two positional arguments and a function with a keyword argument: *args collects the positional arguments and **kwargs the keyword ones.

functools.wraps - preserving metadata

Problem: the decorator changes the function's __name__ and __doc__!

1def my_decorator(func):
2    def wrapper(*args, **kwargs):
3        return func(*args, **kwargs)
4    return wrapper
5
6@my_decorator
7def important_function():
8    """This function is important"""
9    pass
10
11print(important_function.__name__)  # "wrapper" - wrong!
12print(important_function.__doc__)   # None - no documentation!

Solution: Use @functools.wraps!

1from functools import wraps
2
3def my_decorator(func):
4    @wraps(func)  # Preserve original function metadata
5    def wrapper(*args, **kwargs):
6        return func(*args, **kwargs)
7    return wrapper
8
9@my_decorator
10def important_function():
11    """This function is important"""
12    pass
13
14print(important_function.__name__)  # "important_function" - correct!
15print(important_function.__doc__)   # "This function is important" - preserved!

IMPORTANT: Always use @wraps(func) in your decorators!

Decorators with parameters

For a decorator to accept parameters, you need three levels of functions:

1def repeat(times):
2    """Decorator with parameter - repeat function N times"""
3    def decorator(func):
4        @wraps(func)
5        def wrapper(*args, **kwargs):
6            for i in range(times):
7                result = func(*args, **kwargs)
8            return result
9        return wrapper
10    return decorator
11
12@repeat(times=3)  # Decorator parameter!
13def say_hello():
14    print("Hello!")
15
16say_hello()

A single call to say_hello() now prints Hello! three times, because the wrapper runs the original function in a for loop as many times as you pass in times.

Structure:

  1. repeat(times) - returns the decorator
  2. decorator(func) - the actual decorator
  3. wrapper(*args, **kwargs) - the function wrapper

Built-in decorators

You already know some of Python's built-in decorators!

@property, @classmethod, @staticmethod

You know all three from earlier lessons. They are ordinary decorators built into Python: each one changes how the method below it is called.

1class Species:
2    def __init__(self, name, population):
3        self._name = name
4        self._population = population
5
6    @property  # Getter
7    def name(self):
8        return self._name
9
10    @property
11    def population(self):
12        return self._population
13
14    @population.setter  # Setter
15    def population(self, value):
16        if value < 0:
17            raise ValueError("Population cannot be negative")
18        self._population = value
19
20    @classmethod  # Class method
21    def from_dict(cls, data):
22        return cls(data['name'], data['population'])
23
24    @staticmethod  # Static method
25    def is_valid_name(name):
26        return len(name) > 0

Practical decorator examples

1. Timing decorator - measuring execution time

This decorator records the time before and after the call and prints the difference in seconds. The measured function does not need any changes:

1import time
2from functools import wraps
3
4def timer(func):
5    """Measure function execution time"""
6    @wraps(func)
7    def wrapper(*args, **kwargs):
8        start = time.time()
9        result = func(*args, **kwargs)
10        end = time.time()
11        print(f"⏱ {func.__name__} took {end - start:.4f}s")
12        return result
13    return wrapper
14
15@timer
16def analyze_dna_sequence(sequence):
17    """Simulate DNA analysis - time-consuming operation"""
18    time.sleep(2)  # Simulation
19    return f"Analyzed {len(sequence)} nucleotides"
20
21result = analyze_dna_sequence("ATCGATCG")  # ⏱ analyze_dna_sequence took 2.0015s

2. Logger decorator - automatic logging

The logging decorator prints the date and time, the function name and the arguments of every call, then the result or the error. It doesn't swallow the exception but passes it on with raise:

1from functools import wraps
2from datetime import datetime
3
4def log_calls(func):
5    """Log every function call"""
6    @wraps(func)
7    def wrapper(*args, **kwargs):
8        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
9        print(f"[{timestamp}] Call: {func.__name__}")
10        print(f"  Arguments: args={args}, kwargs={kwargs}")
11
12        try:
13            result = func(*args, **kwargs)
14            print(f"  Success: {result}")
15            return result
16        except Exception as e:
17            print(f"  Error: {e}")
18            raise
19    return wrapper
20
21@log_calls
22def observe_species(name, location, count):
23    """Record a species observation"""
24    if count < 0:
25        raise ValueError("Count cannot be negative")
26    return f"Recorded: {count}x {name} in {location}"
27
28observe_species("Lion", "Serengeti", 12)

The console shows (with the current date and time):

1[2024-01-15 14:30:45] Call: observe_species
2  Arguments: args=('Lion', 'Serengeti', 12), kwargs={}
3  Success: Recorded: 12x Lion in Serengeti

If the function raised an error, for example for a negative count, you would see an Error line instead of Success, and the exception would travel on to the calling code.

3. Cache decorator - memoization

The cache decorator remembers the result for every set of arguments. On a later call with the same arguments it returns the stored value instead of computing it again:

1from functools import wraps
2
3def cache(func):
4    """Cache results - don't recalculate the same thing twice"""
5    cached_results = {}
6
7    @wraps(func)
8    def wrapper(*args):
9        if args in cached_results:
10            print(f"Cache hit for {args}")
11            return cached_results[args]
12
13        print(f"Computing for {args}")
14        result = func(*args)
15        cached_results[args] = result
16        return result
17
18    return wrapper
19
20@cache
21def fibonacci(n):
22    """Calculate the n-th Fibonacci number"""
23    if n < 2:
24        return n
25    return fibonacci(n - 1) + fibonacci(n - 2)
26
27print(fibonacci(5))

The program prints:

1Computing for (5,)
2Computing for (4,)
3Computing for (3,)
4Computing for (2,)
5Computing for (1,)
6Computing for (0,)
7Cache hit for (1,)
8Cache hit for (2,)
9Cache hit for (3,)
105

Every value from 0 to 5 is computed only once, and the second calls for 1, 2 and 3 take the result from the cache. Without the decorator the same function would run 15 times, and for a larger n the number of calls explodes.

Note: Python has a built-in @functools.lru_cache!

1from functools import lru_cache
2
3@lru_cache(maxsize=128)  # Remembers the last 128 results
4def fibonacci(n):
5    if n < 2:
6        return n
7    return fibonacci(n - 1) + fibonacci(n - 2)

4. Access control decorator - authorization

This decorator takes a parameter, the expected code, and checks the first argument of the call. The function receives the remaining arguments only when the code matches:

1from functools import wraps
2
3def require_authorization(authorization_code):
4    """Decorator with parameter - requires authorization"""
5    def decorator(func):
6        @wraps(func)
7        def wrapper(*args, **kwargs):
8            # Check if the first argument is a valid code
9            if len(args) == 0 or args[0] != authorization_code:
10                raise PermissionError(f"Unauthorized access to {func.__name__}")
11
12            # Remove authorization code from arguments
13            return func(*args[1:], **kwargs)
14        return wrapper
15    return decorator
16
17@require_authorization("SAFARI_2024")
18def access_classified_data(species_name):
19    """Access to classified data - requires authorization"""
20    return f"Classified data about {species_name}: [CLASSIFIED]"
21
22# Correct authorization
23print(access_classified_data("SAFARI_2024", "Black Rhinoceros"))
24# "Classified data about Black Rhinoceros: [CLASSIFIED]"
25
26# No authorization - error!
27try:
28    print(access_classified_data("WRONG_CODE", "Black Rhinoceros"))
29except PermissionError as e:
30    print(f"Error: {e}")

5. Retry decorator - automatic retrying

A connection to a remote research station sometimes drops. The retry decorator catches the exception, waits a moment and tries again until it runs out of attempts:

1from functools import wraps
2import time
3
4def retry(max_attempts=3, delay=1):
5    """Retry operation on error"""
6    def decorator(func):
7        @wraps(func)
8        def wrapper(*args, **kwargs):
9            for attempt in range(1, max_attempts + 1):
10                try:
11                    return func(*args, **kwargs)
12                except Exception as e:
13                    print(f"Attempt {attempt}/{max_attempts} failed: {e}")
14                    if attempt < max_attempts:
15                        print(f"Retrying in {delay}s...")
16                        time.sleep(delay)
17                    else:
18                        print("All attempts exhausted")
19                        raise
20        return wrapper
21    return decorator
22
23@retry(max_attempts=3, delay=0.5)
24def unstable_connection(success_rate=0.3):
25    """Simulate an unstable connection"""
26    import random
27    if random.random() > success_rate:
28        raise ConnectionError("Connection interrupted")
29    return "Success!"
30
31# Automatically retries on error
32result = unstable_connection(success_rate=0.8)

Stacking multiple decorators

Decorators can be stacked - apply multiple at once!

1@decorator1
2@decorator2
3@decorator3
4def my_function():
5    pass

This works the same as wrapping by hand: my_function = decorator1(decorator2(decorator3(my_function))). decorator3 is closest to the function, so it wraps it first.

Order matters - executed bottom to top!

1from functools import wraps
2
3def bold(func):
4    @wraps(func)
5    def wrapper(*args, **kwargs):
6        return f"**{func(*args, **kwargs)}**"
7    return wrapper
8
9def italic(func):
10    @wraps(func)
11    def wrapper(*args, **kwargs):
12        return f"_{func(*args, **kwargs)}_"
13    return wrapper
14
15@bold
16@italic
17def greet(name):
18    return f"Hello, {name}"
19
20print(greet("Darwin"))  # "**_Hello, Darwin_**"

First italic wrapped the result in underscores, then bold added the asterisks. The order of execution is greet β†’ italic β†’ bold.

Class decorators

Decorators can also modify entire classes!

1def singleton(cls):
2    """Class decorator - Singleton pattern"""
3    instances = {}
4
5    @wraps(cls)
6    def get_instance(*args, **kwargs):
7        if cls not in instances:
8            instances[cls] = cls(*args, **kwargs)
9        return instances[cls]
10
11    return get_instance
12
13@singleton
14class DatabaseConnection:
15    def __init__(self, host):
16        self.host = host
17        print(f"Connecting to {host}...")
18
19# First instance
20db1 = DatabaseConnection("localhost")  # Prints: "Connecting to localhost..."
21
22# "Second" instance - actually the same one!
23db2 = DatabaseConnection("localhost")  # Prints nothing
24
25print(db1 is db2)  # True - same object!

Safari example - complete system with decorators

Finally, a species class with five decorators at work: logging and timing of observations, population validation, authorization for deleting, and a cache for calculations.

1from functools import wraps
2from datetime import datetime
3import time
4from typing import Callable, Any
5
6# === DECORATORS ===
7
8def log_observation(func: Callable) -> Callable:
9    """Log all species observations"""
10    @wraps(func)
11    def wrapper(*args, **kwargs):
12        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
13        print(f"[{timestamp}] Observation: {func.__name__}")
14        result = func(*args, **kwargs)
15        print(f"   Recorded: {result}")
16        return result
17    return wrapper
18
19def measure_time(func: Callable) -> Callable:
20    """Measure operation execution time"""
21    @wraps(func)
22    def wrapper(*args, **kwargs):
23        start = time.time()
24        result = func(*args, **kwargs)
25        elapsed = time.time() - start
26        print(f"   ⏱ Time: {elapsed:.3f}s")
27        return result
28    return wrapper
29
30def validate_population(func: Callable) -> Callable:
31    """Validate population data before saving"""
32    @wraps(func)
33    def wrapper(self, population: int, *args, **kwargs):
34        if not isinstance(population, int):
35            raise TypeError("Population must be an integer")
36        if population < 0:
37            raise ValueError("Population cannot be negative")
38        return func(self, population, *args, **kwargs)
39    return wrapper
40
41def require_auth(authorization_level: str):
42    """Require authorization level"""
43    def decorator(func: Callable) -> Callable:
44        @wraps(func)
45        def wrapper(self, auth_code: str, *args, **kwargs):
46            if auth_code != f"SAFARI_{authorization_level}":
47                raise PermissionError(
48                    f"Required level: {authorization_level}"
49                )
50            return func(self, *args, **kwargs)
51        return wrapper
52    return decorator
53
54def cache_result(func: Callable) -> Callable:
55    """Cache computation results"""
56    cache = {}
57
58    @wraps(func)
59    def wrapper(*args):
60        if args in cache:
61            print(f"   Cache hit")
62            return cache[args]
63
64        result = func(*args)
65        cache[args] = result
66        return result
67
68    return wrapper
69
70# === CLASS WITH DECORATORS ===
71
72class Species:
73    """
74    Species class with Safari decorators
75
76    Demonstration: logging, timing, validation, authorization, cache
77    """
78
79    _registry = {}
80
81    def __init__(self, scientific_name: str, common_name: str, population: int):
82        self.scientific_name = scientific_name
83        self.common_name = common_name
84        self._population = population
85        self.observations = []
86
87        Species._registry[scientific_name] = self
88
89    @property
90    def population(self) -> int:
91        """Getter - regular property"""
92        return self._population
93
94    @population.setter
95    @validate_population  # Validation decorator!
96    def population(self, value: int) -> None:
97        """Setter with automatic validation"""
98        self._population = value
99
100    @log_observation  # Automatic logging
101    @measure_time     # Time measurement
102    def add_observation(self, location: str, count: int, date: str) -> str:
103        """
104        Add observation - automatically logged and timed
105
106        Decorators: @log_observation, @measure_time
107        """
108        time.sleep(0.1)  # Simulate operation
109        obs = {
110            "location": location,
111            "count": count,
112            "date": date
113        }
114        self.observations.append(obs)
115        return f"{count}x {self.common_name} in {location}"
116
117    @cache_result  # Cache results
118    def calculate_biodiversity_score(self) -> float:
119        """
120        Calculate biodiversity score - with cache
121
122        Time-consuming operation - cache saves time!
123        """
124        print(f"   Computing score for {self.common_name}...")
125        time.sleep(0.5)  # Simulate complex computations
126
127        total_obs = sum(obs["count"] for obs in self.observations)
128        unique_locations = len(set(obs["location"] for obs in self.observations))
129
130        return (total_obs * unique_locations) / (self.population + 1)
131
132    @require_auth("ADMIN")  # Requires ADMIN authorization
133    def delete_all_observations(self) -> str:
134        """
135        Delete all observations - requires ADMIN authorization
136
137        Decorator: @require_auth("ADMIN")
138        """
139        count = len(self.observations)
140        self.observations.clear()
141        return f"Deleted observations: {count}"
142
143    @classmethod
144    def get_total_population(cls) -> int:
145        """Total population of all species"""
146        return sum(s.population for s in cls._registry.values())
147
148    @staticmethod
149    @cache_result  # Even static methods can be cached!
150    def calculate_extinction_risk(population: int, habitat_loss: float) -> str:
151        """
152        Calculate extinction risk - with cache
153
154        Args:
155            population: Number of individuals
156            habitat_loss: Habitat loss (0.0-1.0)
157        """
158        print(f"   Computing risk...")
159        time.sleep(0.3)
160
161        risk_score = (1000 - population) * habitat_loss
162
163        if risk_score > 800:
164            return "Critical"
165        elif risk_score > 500:
166            return "High"
167        elif risk_score > 200:
168            return "Medium"
169        else:
170            return "Low"
171
172    def __repr__(self) -> str:
173        return f"Species('{self.common_name}', pop={self.population})"
174
175# === DEMONSTRATION ===
176
177print("=== SAFARI SYSTEM WITH DECORATORS ===\n")
178
179# Creating species
180lion = Species("Panthera leo", "Lion", 120)
181rhino = Species("Diceros bicornis", "Rhinoceros", 45)
182
183# 1. Observations - automatic logging and timing
184print("\n1. OBSERVATIONS (with @log_observation and @measure_time):")
185lion.add_observation("Serengeti", 12, "2024-01-15")
186lion.add_observation("Masai Mara", 8, "2024-01-16")
187
188# 2. Population validation
189print("\n2. VALIDATION (@validate_population):")
190try:
191    lion.population = -10  # Error - negative!
192except ValueError as e:
193    print(f"Validation error: {e}")
194
195try:
196    lion.population = "hundred"  # Error - not int!
197except TypeError as e:
198    print(f"Type error: {e}")
199
200lion.population = 125  # OK
201print(f"Population updated: {lion.population}")
202
203# 3. Cache - biodiversity score
204print("\n3. CACHE (@cache_result):")
205print("First call:")
206score1 = lion.calculate_biodiversity_score()
207print(f"Score: {score1:.2f}")
208
209print("\nSecond call (from cache):")
210score2 = lion.calculate_biodiversity_score()
211print(f"Score: {score2:.2f}")
212
213# 4. Authorization
214print("\n4. AUTHORIZATION (@require_auth):")
215try:
216    lion.delete_all_observations("WRONG_CODE")
217except PermissionError as e:
218    print(f"Access denied: {e}")
219
220result = lion.delete_all_observations("SAFARI_ADMIN")
221print(f"{result}")
222
223# 5. Cache in static method
224print("\n5. CACHE IN STATIC METHOD:")
225print("First call:")
226risk1 = Species.calculate_extinction_risk(50, 0.8)
227print(f"Risk: {risk1}")
228
229print("\nSecond call (from cache):")
230risk2 = Species.calculate_extinction_risk(50, 0.8)
231print(f"Risk: {risk2}")
232
233print("\nDifferent parameters (new computation):")
234risk3 = Species.calculate_extinction_risk(500, 0.3)
235print(f"Risk: {risk3}")

Summary

In this lesson you learned:

  • What decorators are and how they work
  • The @decorator syntax
  • Creating decorators with *args and **kwargs
  • Using @functools.wraps to preserve metadata
  • Decorators with parameters (three levels of functions)
  • Built-in decorators (@property, @classmethod, @staticmethod)
  • Practical examples: timing, logging, cache, authorization, retry
  • Stacking multiple decorators
  • Class decorators
  • Complete Safari system with decorators

Checkpoint

Before moving on:

  • You understand how decorators modify functions
  • You can create a simple decorator
  • You know when to use @wraps
  • You understand decorators with parameters
  • You can stack multiple decorators

Safari Analogy: Decorators are evolutionary adaptations - a chameleon with chromatophores, a bat with echolocation - they extend capabilities without changing the fundamental nature!

Congratulations! You've completed Module 3 - Object-Oriented Programming! You've mastered classes, inheritance, encapsulation, magic methods, type hints, and decorators. You're ready for the next challenges in Python Safari!

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. A decorator in Python is:

  2. 2. By convention, what is the first parameter of a method marked with @classmethod called?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the elements in the correct order:

  • Click in order

    Click elements in the correct order:

  • Code editor

    Create a @classmethod from_dict(cls, data) that creates an Animal from a dictionary with the keys 'name' and 'species', e.g. Animal.from_dict({'name': 'Simba', 'species': 'Lion'})

  • Code editor

    Create a @staticmethod is_dangerous(species) that returns True for the species 'Lion', 'Crocodile' and 'Hippo', and False for any other

  • Horizontal ordering

    Arrange the header of the class method from_dict (the line under the @classmethod decorator):

  • Vertical ordering

    Arrange what happens after calling print(lion) on an object with a __str__ method:

  • Click in order

    Click elements in the correct order:

  • Code editor

    Create classes: Animal (base), Mammal(Animal), Bird(Animal), Tiger(Mammal), Eagle(Bird)

  • Code editor

    In the Animal(name, age, health_status) class, store age and health_status in protected or private attributes (self._age or self.__age) and add the properties age and health_status

  • Vertical ordering

    Arrange the steps for creating a child class:

  • Code editor

    Create a Zoo class with the methods add_animal(animal), remove_animal(name) (removes the animal with that name), find_by_species(species) and list_all() (both return lists of animals). Animals are Animal(name, species) objects

  • Code editor

    Combine Animal and Zoo into a program: create zoo = Zoo() and in a while loop read the choice with input(): 1 - add (name, species), 2 - remove (name), 3 - search by species and print the names, 0 - exit

  • Vertical ordering

    Arrange the method types by their first parameter: first those that receive the object (self), then the class (cls), and last those with neither self nor cls:

Useful articles