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()