Skip to content

Python Decorators

A decorator in Python is a design pattern that allows you to add new functionality to an existing object without modifying its structure, wrapping the object (function or class) into another function.

The syntax can get a little confusing as their are many options to create decorators.

  • Simple with no arguments
  • With arguments
  • Configurable Decorator with arguments
  • On classes
  • On methods within a class

Warning

Do not concentrate too much on the syntax, creating decorators yourself is rare and can be found quickly with Google. The point of this lesson is to understand how and when to use decorators, not to become experts at building them.

Simple with no arguments

Let's first understand what is happening by building the same functionality as a decorator, without building a decorator... ok, that sounds confusing but let's take a look.

import datetime
from netutils.ping import tcp_ping

def simple_decorator(func):
    def wrapper():
        print(f"Function {func.__name__} Start time:{datetime.datetime.now()}.")
        func()
        print(f"Function {func.__name__} Finish time:{datetime.datetime.now()}.")
    return wrapper

def ping_google():
    if tcp_ping('google.com', 443):
        print("Ping Successful")
    else:
        print("Ping Failed")

simple_decorator(ping_google)()
# Function ping_google Start time:2023-07-18 03:43:31.138341.
# Ping Successful
# Function ping_google Finish time:2023-07-18 03:43:31.140858.

What simple_decorator(ping_google)() is saying is to:

  • Call the function simple_decorator and send it the object ping_google
  • Return the object wrapper and call that object ()
  • Run the wrapper function, which has an object called func
  • Call that func object

This can be simplified using the standard decorator syntax, which is just a shorthand for the previously shown syntax, applied to the function that is just below it.

import datetime
from netutils.ping import tcp_ping

def simple_decorator(func):
    def wrapper():
        print(f"Function {func.__name__} Start time:{datetime.datetime.now()}.")
        func()
        print(f"Function {func.__name__} Finish time:{datetime.datetime.now()}.")
    return wrapper

@simple_decorator
def ping_google():
    if tcp_ping('google.com', 443):
        print("Ping Successful")
    else:
        print("Ping Failed")

ping_google()
# Function ping_google Start time:2023-07-18 03:55:27.090432.
# Ping Successful
# Function ping_google Finish time:2023-07-18 03:55:27.093724.

Simple with no arguments - LAB

  • Create the decorator that only prints the time after the function has ran (not before)
  • Call the simple_decorator directly using the syntax without the decorator

Decorator with Arguments

This decorator was taken from the netutils library. It shows how you can pass around parameters.

from netutils.mac import is_valid_mac

def valid_mac(func):
    def decorated(*args, **kwargs):
        if kwargs.get("mac"):
            mac = kwargs.get("mac")
        else:
            mac = args[0]
        assert isinstance(mac, str)
        if not is_valid_mac(mac):
            raise ValueError(f"There was not a valid mac address in: `{mac}`")
        return func(*args, **kwargs)
    return decorated

@valid_mac
def mac_print(mac):
    print(f"Your valid mac {mac}")
    return mac

mac_print("aa.aa.aa.aa.aa.aa") # Your valid mac aa.aa.aa.aa.aa.aa
mac_print(mac="aa:aa:aa:aa:aa:aa") # Your valid mac aa:aa:aa:aa:aa:aa
mac_print("kenn.aaaa.aaaa")    # Traceback
mac_print("not.a.mac")         # Traceback

In this example we can see that:

  • We leverage args, *kwargs
  • We account for mac to be sent as a positional or keyword argument
  • Demonstrate how to return the values

Decorator with Arguments - LAB

Using the same strategy, develop the decorator for valid_ip that will validate an IP address that you will send it. You can apply it to the ip_print function.

Decorator with Parameters

Decorators can be built to accept parameters, enhancing their flexibility. In this example, we will provide the number of retries, while simulating failures with a random choice between pass/fail.

import time
import random

def retry_on_failure(retries=3, delay=5):
    def decorator(func):
        def wrapper(*args, **kwargs):
            device = kwargs.get('device')
            if device is None and len(args) > 0:
                device = args[0]
            for attempt in range(retries):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Failed to connect to: {device}")
                    if attempt < retries - 1:
                        time.sleep(delay)
                        continue
                    else:
                        raise e
        return wrapper
    return decorator

@retry_on_failure(retries=5, delay=1)
def fetch_device_configuration(device):
    # Your logic to fetch configuration from a device.
    output = ["fail", "fail", "pass"]
    if random.choice(output) == "fail":
        raise
    print(f"Connected to {device}")


fetch_device_configuration("csr1")

You end up needing 3 levels of nested functions.

  • The outer level (retry_on_failure) is the decorator factory, which produces your decorator based on some input values.
  • The second level (decorator) is the decorator which is a function taking a function as argument and returns a new function which wraps the original function.
  • The inner level is the wrapper, which is the function which will replace the original function.

Decorator with Parameters - LAB

  • Add the retry function to your napalm or netmiko script.
  • Test with a single device
  • Provide a bad password to see the retries taking effect

Warning

It may be easier to simply print a counter vs trying to get each parameter in your Netmiko or NAPALM script.

Advanced Topics

  • functools.wraps provides some metadata about the functions
  • Class based decorators