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.