Object-Oriented Programming
OOP in Python is practical and flexible. Learn to design clean abstractions for infrastructure automation.
Classes Basics
Definition
class ISENode:
"""Represents an ISE deployment node."""
def __init__(self, hostname: str, ip: str, roles: list[str]):
"""Initialize node with required attributes."""
self.hostname = hostname
self.ip = ip
self.roles = roles
self.status = "unknown"
def is_pan(self) -> bool:
"""Check if node is Primary Admin Node."""
return "PAN" in self.roles
def is_psn(self) -> bool:
"""Check if node is Policy Service Node."""
return "PSN" in self.roles
def __str__(self) -> str:
"""Human-readable string."""
return f"{self.hostname} ({self.ip}) - {', '.join(self.roles)}"
def __repr__(self) -> str:
"""Developer string (for debugging)."""
return f"ISENode({self.hostname!r}, {self.ip!r}, {self.roles!r})"
# Usage
node = ISENode("ise-01", "10.50.1.20", ["PAN", "MNT"])
print(node) # ise-01 (10.50.1.20) - PAN, MNT
print(node.is_pan()) # True
print(repr(node)) # ISENode('ise-01', '10.50.1.20', ['PAN', 'MNT'])
Class vs Instance Attributes
class Endpoint:
# Class attributes (shared by all instances)
default_group = "Registered_Devices"
endpoint_count = 0
def __init__(self, mac: str, group: str | None = None):
# Instance attributes (unique per instance)
self.mac = mac
self.group = group or Endpoint.default_group
Endpoint.endpoint_count += 1
@classmethod
def get_count(cls) -> int:
"""Get total endpoint count."""
return cls.endpoint_count
@classmethod
def from_dict(cls, data: dict) -> "Endpoint":
"""Create endpoint from dictionary."""
return cls(mac=data["mac"], group=data.get("group"))
@staticmethod
def validate_mac(mac: str) -> bool:
"""Validate MAC format (no instance needed)."""
import re
return bool(re.match(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", mac))
# Usage
ep1 = Endpoint("00:11:22:33:44:55")
ep2 = Endpoint("00:11:22:33:44:56", "Employees")
print(ep1.group) # Registered_Devices
print(ep2.group) # Employees
print(Endpoint.endpoint_count) # 2
print(Endpoint.get_count()) # 2
# From dict
data = {"mac": "00:11:22:33:44:57", "group": "Guests"}
ep3 = Endpoint.from_dict(data)
# Static method
print(Endpoint.validate_mac("00:11:22:33:44:55")) # True
print(Endpoint.validate_mac("invalid")) # False
Properties
class NetworkDevice:
def __init__(self, hostname: str, ip: str):
self._hostname = hostname
self._ip = ip
self._status = "unknown"
@property
def hostname(self) -> str:
"""Get hostname (read-only)."""
return self._hostname
@property
def ip(self) -> str:
"""Get IP address."""
return self._ip
@ip.setter
def ip(self, value: str) -> None:
"""Set IP with validation."""
if not self._validate_ip(value):
raise ValueError(f"Invalid IP: {value}")
self._ip = value
@property
def fqdn(self) -> str:
"""Computed property."""
return f"{self._hostname}.inside.domusdigitalis.dev"
@property
def status(self) -> str:
return self._status
@status.setter
def status(self, value: str) -> None:
valid = {"unknown", "online", "offline", "maintenance"}
if value not in valid:
raise ValueError(f"Invalid status: {value}")
self._status = value
@staticmethod
def _validate_ip(ip: str) -> bool:
parts = ip.split(".")
if len(parts) != 4:
return False
return all(p.isdigit() and 0 <= int(p) <= 255 for p in parts)
# Usage
device = NetworkDevice("ise-01", "10.50.1.20")
print(device.hostname) # ise-01
print(device.fqdn) # ise-01.inside.domusdigitalis.dev
device.ip = "10.50.1.21" # OK
# device.ip = "invalid" # ValueError
device.status = "online" # OK
# device.status = "broken" # ValueError
Inheritance
Basic Inheritance
class NetworkDevice:
"""Base class for network devices."""
def __init__(self, hostname: str, ip: str):
self.hostname = hostname
self.ip = ip
def connect(self) -> bool:
"""Connect to device."""
print(f"Connecting to {self.hostname}")
return True
def get_info(self) -> dict:
return {"hostname": self.hostname, "ip": self.ip}
class ISENode(NetworkDevice):
"""ISE-specific network device."""
def __init__(self, hostname: str, ip: str, roles: list[str]):
super().__init__(hostname, ip) # Call parent __init__
self.roles = roles
def connect(self) -> bool:
"""Override: ISE-specific connection."""
print(f"Connecting to ISE node {self.hostname} via ERS API")
return super().connect() # Can still call parent
def get_info(self) -> dict:
"""Extend parent method."""
info = super().get_info()
info["roles"] = self.roles
info["type"] = "ISE"
return info
def get_sessions(self) -> list[dict]:
"""ISE-specific method."""
return []
class WLCDevice(NetworkDevice):
"""Wireless LAN Controller."""
def __init__(self, hostname: str, ip: str, model: str):
super().__init__(hostname, ip)
self.model = model
def get_info(self) -> dict:
info = super().get_info()
info["model"] = self.model
info["type"] = "WLC"
return info
def get_access_points(self) -> list[dict]:
"""WLC-specific method."""
return []
# Usage
ise = ISENode("ise-01", "10.50.1.20", ["PAN", "MNT"])
wlc = WLCDevice("wlc-01", "10.50.1.30", "C9800-CL")
print(ise.get_info()) # {'hostname': 'ise-01', 'ip': '10.50.1.20', 'roles': ['PAN', 'MNT'], 'type': 'ISE'}
print(wlc.get_info()) # {'hostname': 'wlc-01', 'ip': '10.50.1.30', 'model': 'C9800-CL', 'type': 'WLC'}
# Polymorphism
devices: list[NetworkDevice] = [ise, wlc]
for device in devices:
device.connect() # Each uses its own implementation
Abstract Base Classes
from abc import ABC, abstractmethod
class APIClient(ABC):
"""Abstract base for API clients."""
def __init__(self, host: str, username: str, password: str):
self.host = host
self.username = username
self.password = password
self._session = None
@abstractmethod
def connect(self) -> bool:
"""Connect to API. Must be implemented."""
pass
@abstractmethod
def disconnect(self) -> None:
"""Disconnect from API. Must be implemented."""
pass
@property
@abstractmethod
def base_url(self) -> str:
"""Return base URL. Must be implemented."""
pass
def get(self, endpoint: str) -> dict:
"""GET request (shared implementation)."""
if not self._session:
raise RuntimeError("Not connected")
# Implementation
return {}
class ISEClient(APIClient):
"""ISE ERS API client."""
@property
def base_url(self) -> str:
return f"https://{self.host}:9060/ers/config"
def connect(self) -> bool:
print(f"Connecting to ISE at {self.base_url}")
self._session = True
return True
def disconnect(self) -> None:
self._session = None
def get_endpoints(self) -> list[dict]:
"""ISE-specific method."""
return self.get("endpoint")
class WLCClient(APIClient):
"""WLC REST API client."""
@property
def base_url(self) -> str:
return f"https://{self.host}/restconf/data"
def connect(self) -> bool:
print(f"Connecting to WLC at {self.base_url}")
self._session = True
return True
def disconnect(self) -> None:
self._session = None
def get_access_points(self) -> list[dict]:
"""WLC-specific method."""
return self.get("Cisco-IOS-XE-wireless-access-point-oper:access-point-oper-data")
# Can't instantiate abstract class
# client = APIClient("host", "user", "pass") # TypeError
# Must use concrete implementation
ise = ISEClient("ise-01", "admin", "password")
ise.connect()
Magic Methods
Comparison
from functools import total_ordering
@total_ordering # Generates other comparisons from __eq__ and __lt__
class Version:
"""Semantic version with comparison support."""
def __init__(self, version_string: str):
parts = version_string.split(".")
self.major = int(parts[0])
self.minor = int(parts[1]) if len(parts) > 1 else 0
self.patch = int(parts[2]) if len(parts) > 2 else 0
def __eq__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
def __lt__(self, other: "Version") -> bool:
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
# Usage
v1 = Version("3.1.0")
v2 = Version("3.2.0")
v3 = Version("3.1.0")
print(v1 < v2) # True
print(v1 == v3) # True
print(v1 >= v3) # True (from total_ordering)
versions = [Version("3.2.0"), Version("3.1.0"), Version("3.3.0")]
print([str(v) for v in sorted(versions)]) # ['3.1.0', '3.2.0', '3.3.0']
Container Behavior
class EndpointCollection:
"""Collection of endpoints with dict-like access."""
def __init__(self):
self._endpoints: dict[str, dict] = {}
def add(self, mac: str, data: dict) -> None:
self._endpoints[mac.upper()] = data
def __getitem__(self, mac: str) -> dict:
"""Get endpoint by MAC: collection["00:11:22:33:44:55"]"""
return self._endpoints[mac.upper()]
def __setitem__(self, mac: str, data: dict) -> None:
"""Set endpoint: collection["00:11:22:33:44:55"] = {...}"""
self._endpoints[mac.upper()] = data
def __delitem__(self, mac: str) -> None:
"""Delete endpoint: del collection["00:11:22:33:44:55"]"""
del self._endpoints[mac.upper()]
def __contains__(self, mac: str) -> bool:
"""Check membership: "00:11:22:33:44:55" in collection"""
return mac.upper() in self._endpoints
def __len__(self) -> int:
"""Get count: len(collection)"""
return len(self._endpoints)
def __iter__(self):
"""Iterate over MACs: for mac in collection"""
return iter(self._endpoints)
def __bool__(self) -> bool:
"""Truthiness: if collection"""
return bool(self._endpoints)
# Usage
endpoints = EndpointCollection()
endpoints.add("00:11:22:33:44:55", {"group": "Employees"})
endpoints["00:11:22:33:44:56"] = {"group": "Guests"}
print("00:11:22:33:44:55" in endpoints) # True
print(len(endpoints)) # 2
print(endpoints["00:11:22:33:44:55"]) # {'group': 'Employees'}
for mac in endpoints:
print(mac)
if endpoints:
print("Collection has endpoints")
Context Manager
class ISESession:
"""ISE connection with automatic cleanup."""
def __init__(self, host: str, username: str, password: str):
self.host = host
self.username = username
self.password = password
self._connected = False
def __enter__(self) -> "ISESession":
"""Called when entering 'with' block."""
print(f"Connecting to {self.host}")
self._connected = True
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
"""Called when exiting 'with' block."""
print(f"Disconnecting from {self.host}")
self._connected = False
# Return True to suppress exceptions, False to propagate
if exc_type is not None:
print(f"Error occurred: {exc_val}")
return False
def get_endpoints(self) -> list[dict]:
if not self._connected:
raise RuntimeError("Not connected")
return [{"mac": "00:11:22:33:44:55"}]
# Usage with automatic cleanup
with ISESession("ise-01", "admin", "password") as session:
endpoints = session.get_endpoints()
print(f"Got {len(endpoints)} endpoints")
# Automatically disconnects here
# Even on exception
try:
with ISESession("ise-01", "admin", "password") as session:
raise ValueError("Something went wrong")
except ValueError:
pass # Session still cleaned up
Callable Objects
class APIRequest:
"""Callable that makes API requests."""
def __init__(self, base_url: str, auth: tuple[str, str]):
self.base_url = base_url
self.auth = auth
def __call__(self, method: str, endpoint: str, **kwargs) -> dict:
"""Make request: request("GET", "/endpoint")"""
url = f"{self.base_url}/{endpoint}"
print(f"{method} {url}")
# Would use requests library
return {"status": "ok"}
# Usage
request = APIRequest("https://ise-01:9060/ers/config", ("admin", "password"))
result = request("GET", "endpoint") # Calls __call__
result = request("POST", "endpoint", json={"mac": "00:11:22:33:44:55"})
Dataclasses
Basic Dataclass
from dataclasses import dataclass, field
@dataclass
class Endpoint:
"""Endpoint with auto-generated __init__, __repr__, __eq__."""
mac: str
ip: str
vlan: int
group: str = "Registered_Devices"
status: str = "active"
# Auto-generates:
# - __init__(self, mac, ip, vlan, group="Registered_Devices", status="active")
# - __repr__(self) -> "Endpoint(mac='...', ip='...', ...)"
# - __eq__(self, other) -> compares all fields
ep = Endpoint("00:11:22:33:44:55", "10.50.10.100", 10)
print(ep)
# Endpoint(mac='00:11:22:33:44:55', ip='10.50.10.100', vlan=10, group='Registered_Devices', status='active')
ep2 = Endpoint("00:11:22:33:44:55", "10.50.10.100", 10)
print(ep == ep2) # True
Advanced Dataclass
from dataclasses import dataclass, field, asdict, astuple
from typing import Optional
import time
@dataclass
class ISENode:
hostname: str
ip: str
roles: list[str] = field(default_factory=list) # Mutable default
status: str = "unknown"
created_at: float = field(default_factory=time.time)
_session: Optional[object] = field(default=None, repr=False, compare=False)
def __post_init__(self):
"""Called after auto-generated __init__."""
self.hostname = self.hostname.lower()
if not self.roles:
self.roles = ["STANDALONE"]
@property
def fqdn(self) -> str:
return f"{self.hostname}.inside.domusdigitalis.dev"
def is_pan(self) -> bool:
return "PAN" in self.roles
# Usage
node = ISENode("ISE-01", "10.50.1.20", ["PAN", "MNT"])
print(node.hostname) # ise-01 (lowercased in __post_init__)
# Convert to dict/tuple
print(asdict(node))
# {'hostname': 'ise-01', 'ip': '10.50.1.20', 'roles': ['PAN', 'MNT'], ...}
Frozen Dataclass (Immutable)
@dataclass(frozen=True)
class VLANConfig:
"""Immutable VLAN configuration."""
id: int
name: str
subnet: str
gateway: str
def __hash__(self) -> int:
"""Hashable - can be used in sets/dicts."""
return hash((self.id, self.name))
# Usage
vlan = VLANConfig(10, "Data", "10.50.10.0/24", "10.50.10.1")
# Can't modify
# vlan.id = 20 # FrozenInstanceError
# Can use in sets
vlans = {vlan, VLANConfig(20, "Voice", "10.50.20.0/24", "10.50.20.1")}
Dataclass with Validation
@dataclass
class Endpoint:
mac: str
ip: str
vlan: int
def __post_init__(self):
# Validate MAC
import re
if not re.match(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", self.mac):
raise ValueError(f"Invalid MAC: {self.mac}")
# Normalize MAC to uppercase
self.mac = self.mac.upper()
# Validate VLAN
if not 1 <= self.vlan <= 4094:
raise ValueError(f"Invalid VLAN: {self.vlan}")
# Usage
ep = Endpoint("00:11:22:33:44:55", "10.50.10.100", 10) # OK
# ep = Endpoint("invalid", "10.50.10.100", 10) # ValueError
Protocols (Structural Typing)
from typing import Protocol, runtime_checkable
@runtime_checkable
class Connectable(Protocol):
"""Protocol for connectable devices."""
def connect(self) -> bool: ...
def disconnect(self) -> None: ...
@property
def is_connected(self) -> bool: ...
class ISENode:
"""Implements Connectable without inheriting."""
def __init__(self, hostname: str):
self.hostname = hostname
self._connected = False
def connect(self) -> bool:
self._connected = True
return True
def disconnect(self) -> None:
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected
class WLCDevice:
"""Also implements Connectable."""
def __init__(self, hostname: str):
self.hostname = hostname
self._connected = False
def connect(self) -> bool:
self._connected = True
return True
def disconnect(self) -> None:
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected
# Both work with Connectable
def check_device(device: Connectable) -> str:
if device.connect():
status = "online" if device.is_connected else "offline"
device.disconnect()
return status
return "unreachable"
# Runtime check
ise = ISENode("ise-01")
print(isinstance(ise, Connectable)) # True
# Type checker knows these are valid
devices: list[Connectable] = [ISENode("ise-01"), WLCDevice("wlc-01")]
for device in devices:
print(check_device(device))
Practical Patterns
Repository Pattern
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Endpoint:
id: str
mac: str
group: str
class EndpointRepository(ABC):
"""Abstract repository for endpoints."""
@abstractmethod
def get(self, id: str) -> Endpoint | None: ...
@abstractmethod
def get_by_mac(self, mac: str) -> Endpoint | None: ...
@abstractmethod
def save(self, endpoint: Endpoint) -> None: ...
@abstractmethod
def delete(self, id: str) -> bool: ...
@abstractmethod
def list_all(self) -> list[Endpoint]: ...
class ISEEndpointRepository(EndpointRepository):
"""ISE API implementation."""
def __init__(self, client: "ISEClient"):
self.client = client
def get(self, id: str) -> Endpoint | None:
data = self.client.get(f"endpoint/{id}")
return Endpoint(**data) if data else None
def get_by_mac(self, mac: str) -> Endpoint | None:
data = self.client.get(f"endpoint?filter=mac.EQ.{mac}")
return Endpoint(**data[0]) if data else None
def save(self, endpoint: Endpoint) -> None:
self.client.put(f"endpoint/{endpoint.id}", asdict(endpoint))
def delete(self, id: str) -> bool:
return self.client.delete(f"endpoint/{id}")
def list_all(self) -> list[Endpoint]:
data = self.client.get("endpoint")
return [Endpoint(**e) for e in data]
# Usage hides implementation details
repo = ISEEndpointRepository(ise_client)
endpoint = repo.get_by_mac("00:11:22:33:44:55")
Builder Pattern
@dataclass
class PolicyRule:
name: str
conditions: list[str]
actions: list[str]
enabled: bool = True
priority: int = 100
class PolicyRuleBuilder:
"""Fluent builder for policy rules."""
def __init__(self, name: str):
self._name = name
self._conditions: list[str] = []
self._actions: list[str] = []
self._enabled = True
self._priority = 100
def with_condition(self, condition: str) -> "PolicyRuleBuilder":
self._conditions.append(condition)
return self
def with_action(self, action: str) -> "PolicyRuleBuilder":
self._actions.append(action)
return self
def enabled(self, value: bool = True) -> "PolicyRuleBuilder":
self._enabled = value
return self
def priority(self, value: int) -> "PolicyRuleBuilder":
self._priority = value
return self
def build(self) -> PolicyRule:
if not self._conditions:
raise ValueError("At least one condition required")
if not self._actions:
raise ValueError("At least one action required")
return PolicyRule(
name=self._name,
conditions=self._conditions,
actions=self._actions,
enabled=self._enabled,
priority=self._priority
)
# Fluent usage
rule = (
PolicyRuleBuilder("Employee_Wired_Access")
.with_condition("RADIUS:NAS-Port-Type == Ethernet")
.with_condition("AD:memberOf CONTAINS Employees")
.with_action("Permit Access")
.with_action("VLAN = 10")
.priority(50)
.build()
)
Next Module
Modules & Packages - Imports, packages, pyproject.toml, and uv.