Python Decorators

Decorator patterns for function wrapping, class enhancement, and functools utilities.

Function Decorators

Basic decorator — wraps a function to add behavior before/after the call
from functools import wraps

def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_call
def ping(host: str) -> bool:
    return os.system(f"ping -c 1 {host}") == 0
Why @wraps matters — without it, func.name and func.doc point to wrapper, not the original
from functools import wraps

def timer(func):
    @wraps(func)                    # preserves __name__, __doc__, __module__
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__}: {time.time() - start:.3f}s")
        return result
    return wrapper

@timer
def backup_configs():
    """Backup all device configurations."""
    ...

backup_configs.__name__    # "backup_configs" (not "wrapper")
backup_configs.__doc__     # "Backup all device configurations."

Decorator with Arguments

Parameterized decorator — three levels: factory returns decorator returns wrapper
def retry(max_attempts: int = 3, delay: float = 1.0):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"{func.__name__} failed (attempt {attempt + 1}): {e}")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=5, delay=2.0)
def fetch_device_config(host: str) -> str:
    return ssh_client.exec_command(host, "show running-config")
Why three levels — the outer function captures parameters, middle captures the function, inner runs it
# @retry(max_attempts=5) is equivalent to:
# 1. retry(max_attempts=5) -> returns decorator
# 2. decorator(fetch_device_config) -> returns wrapper
# 3. wrapper("10.50.1.10") -> executes with retry logic

Built-in Decorators

@property — turn a method into a read-only attribute, access as obj.attr not obj.attr()
class Switch:
    def __init__(self, hostname: str, interfaces: list[str]):
        self.hostname = hostname
        self.interfaces = interfaces

    @property
    def port_count(self) -> int:
        return len(self.interfaces)

    @property
    def is_core(self) -> bool:
        return self.hostname.startswith("sw-core")

sw = Switch("sw-core-01", ["Gi1/0/1", "Gi1/0/2"])
sw.port_count    # 2 -- no parentheses
sw.is_core       # True
@property with setter — controlled attribute assignment with validation
class Device:
    def __init__(self, hostname: str):
        self._hostname = hostname

    @property
    def hostname(self) -> str:
        return self._hostname

    @hostname.setter
    def hostname(self, value: str):
        if not value or " " in value:
            raise ValueError(f"Invalid hostname: {value!r}")
        self._hostname = value.lower()

d = Device("SW1")
d.hostname = "sw-core-01"    # triggers setter, stores lowercase
d.hostname = ""              # raises ValueError
@staticmethod and @classmethod — no instance needed vs alternate constructor
class Device:
    def __init__(self, hostname: str, ip: str):
        self.hostname = hostname
        self.ip = ip

    @staticmethod
    def is_valid_ip(ip: str) -> bool:
        """No self needed -- pure utility that belongs to the class."""
        parts = ip.split(".")
        return len(parts) == 4 and all(0 <= int(p) <= 255 for p in parts)

    @classmethod
    def from_csv(cls, line: str) -> "Device":
        """cls is the class itself -- enables subclass-safe construction."""
        hostname, ip = line.strip().split(",")
        return cls(hostname, ip)

Device.is_valid_ip("10.50.1.20")           # True -- call on class
sw = Device.from_csv("sw1,10.50.1.10")     # alternate constructor

functools Decorators

@lru_cache — memoize expensive function calls, cache keyed by arguments
from functools import lru_cache

@lru_cache(maxsize=256)
def dns_lookup(hostname: str) -> str:
    """Cache DNS results -- same hostname returns cached IP."""
    return socket.gethostbyname(hostname)

dns_lookup("ise-01.inside.domusdigitalis.dev")   # network call
dns_lookup("ise-01.inside.domusdigitalis.dev")   # cache hit

dns_lookup.cache_info()    # CacheInfo(hits=1, misses=1, maxsize=256, currsize=1)
dns_lookup.cache_clear()   # reset the cache
functools.partial — freeze arguments to create a specialized version of a function
from functools import partial

def connect(host: str, port: int, timeout: int = 30):
    ...

connect_ise = partial(connect, host="10.50.1.20", port=9060)
connect_ise(timeout=10)    # equivalent to connect("10.50.1.20", 9060, timeout=10)

Class Decorators

Class decorator — modify or wrap a class itself, runs once at class creation time
def add_logging(cls):
    """Add a log method to any class."""
    import logging
    cls.log = logging.getLogger(cls.__name__)
    return cls

@add_logging
class Switch:
    def __init__(self, hostname: str):
        self.hostname = hostname

sw = Switch("sw1")
sw.log.info("Switch initialized")    # logger named "Switch"
@dataclass — the most common class decorator, auto-generates boilerplate
from dataclasses import dataclass

@dataclass
class Endpoint:
    hostname: str
    ip: str
    port: int = 443

# Auto-generates __init__, __repr__, __eq__
# Endpoint("sw1", "10.50.1.10") works immediately

Stacking Decorators

Multiple decorators — applied bottom-up (closest to function runs first)
@log_call           # 3rd: wraps the retry-wrapped timer-wrapped function
@retry(max_attempts=3)  # 2nd: wraps the timer-wrapped function
@timer               # 1st: wraps the original function
def fetch_config(host: str) -> str:
    ...

# Execution order: log_call -> retry -> timer -> fetch_config

Practical Decorator: Network Automation

Decorator for SSH connection handling — connect before, disconnect after
def with_ssh_session(func):
    """Open SSH session before function, close after."""
    @wraps(func)
    def wrapper(host: str, *args, **kwargs):
        session = paramiko.SSHClient()
        session.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        session.connect(host, username="admin")
        try:
            return func(host, *args, session=session, **kwargs)
        finally:
            session.close()
    return wrapper

@with_ssh_session
def get_running_config(host: str, *, session=None) -> str:
    stdin, stdout, stderr = session.exec_command("show running-config")
    return stdout.read().decode()