NetAuto Academy

Advanced Python Automation for Network Engineers

Dataclasses, Type Hints, and Custom Exceptions for Automation Results

Advanced14 min read

What you'll learn

  • How @dataclass replaces an ad hoc result dict with a real type
  • How type hints document what a function actually expects and returns
  • How a custom exception hierarchy separates operational failures from programming bugs
  • That this generalizes Reliable Automation's status strings, not repeats them
🎓 Teach This Lesson

Listen to a guided explanation of this lesson.

Spoken narration isn't supported in this browser.

The dict every capstone has used

{"hostname": "SW2", "status": "changed", "detail": "..."} — this exact shape, or something close to it, has appeared in every JSON report since Phase 2.6. It works, but Python enforces nothing about it: a typo in a key name, a missing field, an inconsistent value — none of it is caught until something downstream breaks.

network_automation/models.py
from dataclasses import dataclass, field
from enum import Enum


class Status(Enum):
  ALREADY_COMPLIANT = "already-compliant"
  CHANGED = "changed"
  UNREACHABLE = "unreachable"
  AUTH_FAILED = "auth-failed"
  VALIDATION_FAILED = "validation-failed"


@dataclass
class Vlan:
  id: int
  name: str


@dataclass
class Device:
  hostname: str
  required_vlans: list[Vlan] = field(default_factory=list)


@dataclass
class AutomationResult:
  hostname: str
  status: Status
  detail: str

@dataclass generates __init__, __repr__, and equality comparison automatically -- Status is a small Enum, replacing bare status strings with a fixed, checkable set of values.

Quick checkConcept check
What does @dataclass actually generate for a class like AutomationResult?

Real, verified construction

try_it.py
d = Device(hostname="SW1", required_vlans=[Vlan(id=10, name="USERS"), Vlan(id=20, name="VOICE")])
print(d)

r = AutomationResult(hostname="SW1", status=Status.ALREADY_COMPLIANT, detail="no change needed")
print(r)
print(r.status.value)

type hints on every field (hostname: str, id: int) document what's expected -- not enforced at runtime by Python itself, but readable by every editor and by the next engineer.

output
Device(hostname='SW1', required_vlans=[Vlan(id=10, name='USERS'), Vlan(id=20, name='VOICE')])
AutomationResult(hostname='SW1', status=<Status.ALREADY_COMPLIANT: 'already-compliant'>, detail='no change needed')
already-compliant

Real, verified output. r.status.value unwraps the Enum back to its plain string, e.g. for writing into a JSON report.

Quick checkPredict output
Given r = AutomationResult(hostname="SW1", status=Status.AUTH_FAILED, detail="bad creds"), what does r.status.value evaluate to?

A real exception hierarchy

Reliable Automation classified outcomes with status strings compared by hand (if status == "unreachable"). This lesson gives those same failure categories real Python types.

network_automation/exceptions.py
class AutomationError(Exception):
  """Base class for expected, operational automation failures."""


class DeviceUnreachableError(AutomationError):
  """The device could not be reached at all."""


class AuthenticationError(AutomationError):
  """The device rejected the credentials."""


class ValidationError(AutomationError):
  """The device responded, but its state doesn't match what's required."""

A flat hierarchy -- one base class, three direct subclasses. Not deep, not academic: each one names exactly one of the failure categories Reliable Automation already established.

What this hierarchy deliberately does NOT catch

AutomationError and its subclasses represent expected, operational failures -- a device being down is not a bug. A real programming mistake (a KeyError from a typo, a TypeError from a wrong argument) should never be caught as an AutomationError -- it should surface as itself, so it gets fixed instead of silently logged as "the device's fault."

Quick checkScenario
A function catches `except Exception:` broadly, intending to handle DeviceUnreachableError. A genuine bug elsewhere raises a KeyError from a typo'd dict key. What happens?

Inventory, upgraded

With real types in place, load_inventory() from Lesson 2 gets one small upgrade — returning Device objects instead of plain dicts:

network_automation/inventory.py (upgraded)
import csv
from network_automation.models import Device, Vlan

REQUIRED_VLANS = {
  "SW1": [Vlan(id=10, name="USERS"), Vlan(id=20, name="VOICE")],
  "SW2": [Vlan(id=10, name="USERS")],
  "SW3": [Vlan(id=10, name="USERS"), Vlan(id=30, name="MGMT")],
  "SW4": [Vlan(id=10, name="USERS")],
  "SW5": [Vlan(id=10, name="USERS")],
}

def load_inventory(path):
  with open(path) as f:
      rows = list(csv.DictReader(f))
  devices = []
  for row in rows:
      hostname = row.get("hostname")
      if not hostname:
          continue
      devices.append(Device(hostname=hostname, required_vlans=REQUIRED_VLANS.get(hostname, [])))
  return devices

Real, verified. Same devices.csv, same five hostnames — now returning a list of real Device objects instead of dicts, so a typo'd field name fails immediately instead of silently producing None somewhere downstream.

Quick checkIdentify the correct code
Which change turned load_inventory()'s return value from a list of dicts into a list of typed objects?

Key takeaways

  • @dataclass generates a working __init__, __repr__, and equality comparison from a field list — verified directly, not assumed magic.
  • A small Status Enum replaces bare status strings with a fixed, checkable set of values.
  • A flat exception hierarchy (AutomationError → DeviceUnreachableError/AuthenticationError/ValidationError) gives Reliable Automation's failure categories real types instead of compared strings.
  • The hierarchy deliberately does not catch real bugs (KeyError, TypeError) — only the named operational failure categories.
  • load_inventory() now returns real Device objects — the same devices.csv, a stricter, self-checking result shape.