In case you're trying to read this through your email, be warned: This post is large and may get clipped by services like Gmail and others.

This isn't just an article or a typical post: it is a Python crash course delivered to your inbox. However, since services like Gmail often clip larger emails, I strongly recommend opening it in your browser instead.

1. Why Object-Oriented Programming Matters in Network Engineering

As network engineers, we often think in terms of devices, links, interfaces, VRFs, tunnels, and protocols, all of which are entities with properties and behaviors. A router has interfaces. Each interface has a name, a speed, and an operational state. That router may also run BGP and maintain neighbor relationships. The network itself can be thought of as a system of interacting components.

This mindset of organizing systems by their nature and function is precisely what object-oriented programming (OOP) is about.

In most network automation tasks, engineers begin with procedural scripts that sequentially collect data from devices, analyze it, and produce results. These scripts work well in small, focused scenarios. But as systems grow with more device types, more vendors, more features, and more logic, things get messy. Variables multiply, functions become bloated, and shared logic gets copied and pasted across scripts. Bugs creep in, and complexity explodes.

Eventually, procedural scripts reach a tipping point where they become hard to maintain or extend. That’s when the discipline of software engineering becomes essential, and that’s where OOP enters the scene.

OOP is About Modeling, And Networks Are Already Models

In network engineering, Object-Oriented Programming helps you design systems that reflect the real-world structure of your network. In OOP, you define classes to represent objects (like routers or links) and encapsulate their behavior with methods. These objects can carry state (e.g., the hostname, the platform, the IP address), and perform actions (e.g., check reachability, generate configuration, validate interface health).

By doing this, you make your code more than just a series of instructions: you create reusable building blocks that can evolve and scale with the complexity of your infrastructure.

A Simple Comparison: Procedural vs OOP

Here’s a good example: imagine you want to validate the health of your core routers and edge routers, each with different vendors, interface naming conventions, and configuration rules.

In a procedural script, you might do something like:

if device["vendor"] == "Cisco":
    send_command("show ip interface brief")
elif device["vendor"] == "Juniper":
    send_command("show interfaces terse")

As your device list grows, this quickly becomes brittle. Now consider the same in an OOP style:

device = CiscoRouter(hostname="core1", ip="10.0.0.1")
device.check_interface_status()

Here, the behavior of the device is baked into its class. You don’t have to care how it works; you trust that the object knows how to act like a Cisco router. And if tomorrow you need to support another vendor? You subclass it. No need to edit all your logic. That’s polymorphism, and it’s one of the core strengths of OOP.

Object Reuse > Copy-Paste

In traditional scripting, you often find yourself copying logic around to handle similar cases with slight variations. This leads to fragile code that's hard to refactor.

In OOP, you write your logic once — inside a class — and reuse it across many instances. The behavior is centralized, tested, and abstracted from the rest of the system. If you need to change how BGP reachability is checked for all routers, you update one method in one class, and the change propagates across all devices that inherit from it.

That’s not just elegant. It’s maintainable and scalable.

From Toolmaker to System Designer

OOP encourages you to move from a toolmaking mindset ("let me write a script that works for today") to a system designer mindset ("let me build components I can reuse tomorrow and next year").

This shift is crucial in modern NetDevOps environments where automation is no longer a nice-to-have but the fabric that binds networks together. Whether you're writing inventory managers, change validators, observability pipelines, or config deployers, OOP helps you structure complexity, and that’s what software is all about.

In this edition, we’ll demystify the core building blocks of object-oriented programming, and show how they map naturally to the world you already know: routers, switches, links, and protocols.

2. Core Concepts of OOP (for Network Engineers Who Don’t Write Software)

If you’ve never studied object-oriented programming formally, don’t worry. You already think like an object-oriented developer, and you just don’t know it yet.

When you model a network in your head, or on a whiteboard, you’re naturally using OOP-style thinking: you talk about devices, links, interfaces, and protocols, each with its own properties and actions. OOP gives us a way to express this thinking in code.

Here are the four core building blocks of OOP, explained from a network engineer’s perspective:

1. Class and Object

  • A class is a blueprint. A reusable definition for something. Think of it as the template for a router or switch.

  • An object is an instance of that blueprint, like router1, router2, switch3. Each has its own hostname, IP, platform, and state.

Analogy: If Router is the design spec, then core-rtr01.dub.net is a physical device built from that spec.

In Python:

class Router:
    def __init__(self, hostname, ip):
        self.hostname = hostname
        self.ip = ip

core_router = Router("core-rtr01", "192.168.100.1")
print(core_router.hostname)  # core-rtr01

2. Attributes and Methods

  • Attributes represent the data associated with the object. For example: hostname, IP address, vendor, interfaces.

  • Methods represent the actions the object can perform. For example: check_bgp_neighbors(), generate_config(), get_uptime().

This is exactly how you think about devices. “This router has a hostname and an IP. It can fetch interface counters and reload itself.

In Python:

class Router:
    def __init__(self, hostname, ip):
        self.hostname = hostname
        self.ip = ip

    def reboot(self):
        print(f"{self.hostname} at {self.ip} is rebooting...")

rtr = Router("edge1", "10.1.1.1")
rtr.reboot() # edge1 at 10.1.1.1 is rebooting..

3. Encapsulation

Encapsulation is the practice of hiding the internal complexity of an object and only exposing a clean interface. This is what enables abstraction and clean design.

Real-World Example:
When you use netmiko.ConnectHandler or ncclient.Manager, you don’t care how SSH or NETCONF works under the hood, you just call .send_command() or .get_config(). That’s encapsulation.

In your code, you can expose a simple method like router.check_reachability() without needing the rest of the program to know how ping or socket connections work.

4. Inheritance and Polymorphism

These are fancy names for very practical tools:

  • Inheritance allows one class to inherit from another. For example, you might create a base Router class, and then have CiscoRouter, JuniperRouter, and AristaRouter subclasses that extend it.

  • Polymorphism means the same method name can behave differently depending on the object. Each router might have its own version of generate_config(), and Python will call the correct one, depending on the vendor.

Example:

class Router:
    def generate_config(self):
        raise NotImplementedError

class CiscoRouter(Router):
    def generate_config(self):
        return "interface Gig0/1\n ip address dhcp"

class JuniperRouter(Router):
    def generate_config(self):
        return "set interfaces ge-0/0/1 unit 0 family inet dhcp"

# Usage
devices = [CiscoRouter(), JuniperRouter()]
for device in devices:
    print(device.generate_config())

You don’t have to know what type of router you’re working with, just that it can generate its config. That’s the power of polymorphism.

Why This Matters in Networking

  • You automate configuration and state validation for hundreds or thousands of devices. You don’t want if/else logic everywhere. You want a clean, extendable system.

  • You deal with vendor differences. Inheritance and polymorphism let you abstract those differences into isolated, reusable modules.

  • You’ll reuse logic across device types, protocols, or platforms. Encapsulation keeps your system modular and testable.

3. Practical Examples of OOP in Network Automation

Object-Oriented Programming (OOP) isn’t a theoretical luxury in network automation but rather a strategic enabler. As your automation grows from simple scripts to reusable tools and libraries, OOP becomes critical to keep your code clean, extensible, and maintainable.

Let’s now dive into realistic and relatable use cases where object-oriented design transforms the way you automate tasks as a network engineer.

Use Case 1: Modeling Devices with Attributes and Behaviors

Consider a script that configures 100 routers of various vendors. Instead of repeating logic or writing massive if/elif trees to handle platform-specific code, we can define a generic Router class and let each vendor subclass define its own behavior.

class Router:
    def __init__(self, hostname, mgmt_ip, vendor):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor
    
    def show_summary(self):
        return f"{self.hostname} ({self.vendor}) - {self.mgmt_ip}"

class CiscoRouter(Router):
    def generate_config(self):
        return f"hostname {self.hostname}\ninterface Loopback0\n ip address {self.mgmt_ip} 255.255.255.255"

class JuniperRouter(Router):
    def generate_config(self):
        return f"set system host-name {self.hostname}\nset interfaces lo0 unit 0 family inet address {self.mgmt_ip}/32"

# Instantiate devices
r1 = CiscoRouter("core-rtr01", "192.168.0.1", "Cisco")
r2 = JuniperRouter("edge-jun01", "10.10.10.10", "Juniper")

for device in [r1, r2]:
    print(device.show_summary())
    print(device.generate_config())

Why This Is Powerful:

  • You write the orchestration logic once.

  • You add new vendors or variations later without modifying existing code.

  • You increase testability and reuse.

Use Case 2: Encapsulating Interface Logic

Let’s say you want to model interfaces and perform validations such as interface states, descriptions, and speeds. Instead of treating this as raw JSON, model it. You can add the snippet below to the class Interface snippet of the previous example to test it:

class Interface:
    def __init__(self, name, status, speed):
        self.name = name
        self.status = status
        self.speed = speed
    
    def is_operational(self):
        return self.status == "up" and self.speed in ["1G", "10G"]

    def __str__(self):
        return f"{self.name}: {self.status}, {self.speed}"

# Test interfaces
intf1 = Interface("Gig0/1", "up", "1G")
intf2 = Interface("Gig0/2", "down", "10G")

for i in [intf1, intf2]:
    print(i)
    print("Operational?", i.is_operational())

Why This Is Powerful:

  • You move validation logic into the object itself.

  • Any other script or system using Interface gets this behavior for free.

  • You make your code more readable and expressive.

Use Case 3: Inventory as a System of Objects

Now imagine you have a structured inventory and you want to interact with each device:

class Device:
    def __init__(self, hostname, mgmt_ip, site):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.site = site
        self.interfaces = []

    def add_interface(self, iface):
        self.interfaces.append(iface)

    def get_interface_summary(self):
        return [str(i) for i in self.interfaces]

# Sample setup
r1 = Device("leaf01", "10.1.1.1", "DUB")
r1.add_interface(Interface("eth1", "up", "1G"))
r1.add_interface(Interface("eth2", "down", "10G"))

print(f"{r1.hostname} interface summary:")
for line in r1.get_interface_summary():
    print("  ", line)

📌 Why This Is Powerful:

  • You can now loop through a structured model of your network.

  • You can easily filter by site, interface types, state.

  • You can serialize and export this structure for reports or APIs.

Use Case 4: Simulation and Pre-Checks

Suppose you want to simulate whether a router can reach a peer or generate pre-change summaries. Encapsulate those capabilities inside methods:

class Router:
    def __init__(self, hostname, neighbors):
        self.hostname = hostname
        self.neighbors = neighbors

    def validate_neighbors(self, expected_count):
        actual = len(self.neighbors)
        if actual != expected_count:
            print(f"[WARNING] {self.hostname}: Expected {expected_count} neighbors but found {actual}")
        else:
            print(f"[OK] {self.hostname}: BGP neighbor count is valid.")

rtr = Router("core-rtr01", ["10.0.0.1", "10.0.0.2"])
rtr.validate_neighbors(expected_count=3)

Why This Is Powerful:

  • Logic is no longer scattered; each behavior lives in its corresponding class.

  • You can simulate, test, and validate behavior offline.

These examples only scratch the surface. But they should spark ideas:

  • What if your devices were objects in a class?

  • What if you could run validation or provisioning logic directly from those objects?

  • What if your API wrappers, templates, and test tools were all powered by object hierarchies?

4. Defining Your First Network Class: A Simple Device Model

At the beginning of your journey into network automation, it's common to represent devices using flat data structures like dictionaries or JSON blocks. While this works for small tasks, the approach begins to crumble as soon as your logic becomes repetitive, your inventory grows, or your use cases require increasing levels of context and behavior. You find yourself duplicating logic, writing conditionals everywhere, and struggling to maintain consistency across scripts.

As previously mentioned, this is precisely where Object-Oriented Programming (OOP) starts to shine.

Let’s now take our first meaningful step into this paradigm shift by building a NetworkDevice class, a blueprint for how we can model routers, switches, and firewalls not as mere data blobs, but as intelligent, context-aware objects.

Step 1: Define the Class

We begin by defining what every network device should know about itself, and what actions it should be able to perform. For this simple model, we’ll use the following attributes:

  • hostname: the unique name of the device

  • mgmt_ip: the management IP used for out-of-band communication

  • vendor: the platform vendor (e.g., Cisco, Juniper, Arista)

And we'll implement two basic methods:

  • .ping(): to simulate a reachability test

  • .show_status(): to return a summary of the device

Here's our first version:

class NetworkDevice:
    def __init__(self, hostname, mgmt_ip, vendor):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor
    
    def ping(self):
        print(f"Pinging {self.mgmt_ip}... Success.")
    
    def show_status(self):
        return f"{self.hostname} [{self.vendor}] - {self.mgmt_ip}"

This is already powerful. With this class, we can now create real-world representations of devices:

device1 = NetworkDevice("core-sw1", "192.168.1.1", "Cisco")
device2 = NetworkDevice("edge-fw1", "192.168.100.1", "Fortinet")

You can now interact with these objects in a structured and expressive way:

device1.ping()  # Simulate a ping
print(device2.show_status())  # View summary

Let’s pause and reflect. This tiny example reveals something profound:

  • We're no longer manually formatting strings or repeating logic for each device.

  • The logic for what it means to "ping" or "show a summary" is encapsulated inside the device object itself.

  • If we add 100 more devices, the interaction logic doesn’t change; we’re working with intelligent agents, not raw data.

In traditional scripting, you'd end up with a loop and a set of conditionals like this:

devices = [
    {"hostname": "core-sw1", "mgmt_ip": "192.168.1.1", "vendor": "Cisco"},
    {"hostname": "edge-fw1", "mgmt_ip": "192.168.100.1", "vendor": "Fortinet"}
]

for d in devices:
    print(f"Pinging {d['mgmt_ip']}... Success.")
    print(f"{d['hostname']} [{d['vendor']}] - {d['mgmt_ip']}")

This works… but it scales poorly. Each new behavior means more code scattered across your logic. You don’t have a reusable unit of intelligence. You're processing data, and not modeling systems.

OOP turns that around. It treats devices as actors that know how to describe and operate themselves. This mirrors how you think as a network engineer, where devices aren't just rows in a spreadsheet, but interconnected systems with personality and behavior.

Trade-offs and Thought Process

Of course, creating classes requires a mindset shift. Here's how you might think about it as a network engineer:

Situation

Dictionary Approach

Class-Based Approach

Inventory summary for 10 devices

Simple loop

Simple loop

Add ping() behavior

Extra function with many parameters

Method on the object

Add support for vendor-specific config generation

Many if/elif trees

Subclass and override

Run validations or simulations

Requires passing extra data everywhere

Each object can carry its own logic

Need to serialize to JSON or export to YAML

Easy with dicts

Requires .to_dict() method

If you’re writing a one-off script for a single task, a dictionary might be fine.

But if your automation:

  • touches multiple systems,

  • evolves over time,

  • or needs to model behavior,

Then investing in classes becomes not only worthwhile, but it becomes a form of technical leverage.

5. Expanding the Model with Methods and Behaviors

Turning Passive Data into Active Devices

So far, our NetworkDevice class holds basic metadata and can display a status or simulate a ping. That's great for starters, but real-world network engineering involves a lot more than just knowing that a device exists. We care about its state, its reachability, its configuration, its health, and even its role in the infrastructure.

In this section, we’ll go beyond static attributes and teach our class how to act and how to simulate real device behaviors in a clean, modular, extensible way.

Let’s enrich the model with three practical methods:

  • .is_reachable() – Simulate or verify reachability, perhaps by pinging or querying a health API.

  • .generate_config_snippet() – Return a configuration fragment appropriate for the device's vendor and role.

  • .get_uptime() – Return the uptime in days, based on boot time or simulated data.

Each method showcases a new facet of object design: some depend only on internal data (self.vendor), while others interact with simulated external logic. This is a powerful bridge between modeling and automation, and we start writing real logic in a structured way.

Method 1: .is_reachable()

Let’s define a method that checks if a device is reachable. In real automation, this might use ping, API polling, or SNMP queries. Here, we simulate the logic for simplicity, but design it in a way that real checks could later replace.

import random

class NetworkDevice:
    def __init__(self, hostname, mgmt_ip, vendor):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor

    def is_reachable(self):
        # Simulate 90% success rate
        return random.random() < 0.9

Now you can ask any device whether it's up:

core = NetworkDevice("core-r1", "192.168.0.1", "Cisco")

if core.is_reachable():
    print(f"{core.hostname} is reachable.")
else:
    print(f"{core.hostname} is NOT reachable.")

Why this matters:

In a traditional script, you’d manage reachability checks with a mix of raw function calls and state dictionaries. But when .is_reachable() lives inside the object, any device can decide for itself whether it's reachable, using whatever logic is appropriate.

This is not just cleaner; it’s more intuitive, easier to test, and trivial to extend (e.g., override it in subclasses for different vendors or platforms).

Method 2: .generate_config_snippet()

A very common task in NetDevOps workflows is per-device configuration generation. Whether you’re pushing hostname, NTP, loopbacks, or ACLs, you want device-aware logic that can adapt to differences.

Let’s add a method to generate a config snippet tailored to the device’s vendor:

    def generate_config_snippet(self):
        if self.vendor == "Cisco":
            return f"hostname {self.hostname}\nip domain-name lab.local"
        elif self.vendor == "Juniper":
            return f"system {{\n  host-name {self.hostname};\n}}"
        else:
            return f"# Config for {self.hostname} [{self.vendor}] is not yet supported."

Usage:

devices = [
    NetworkDevice("dist1", "10.1.1.1", "Cisco"),
    NetworkDevice("pe1", "10.1.2.1", "Juniper"),
    NetworkDevice("fw1", "10.1.3.1", "PaloAlto")
]

for d in devices:
    print(f"Config for {d.hostname}:\n{d.generate_config_snippet()}\n")

This will print appropriate configuration fragments or a fallback if the platform is unknown.

Why this matters:

Instead of scattering if vendor == ... logic across your codebase, you're centralizing behavior where it belongs: inside the device itself. Each device can render its own config, enabling elegant template expansion, test generation, and even GitOps integration later on.

Method 3: .get_uptime()

In the field, uptime is one of the simplest but most critical indicators of system stability. Let’s simulate uptime based on a boot timestamp:

from datetime import datetime, timedelta
import random

class NetworkDevice:
    def __init__(self, hostname, mgmt_ip, vendor, boot_time=None):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor
        self.boot_time = boot_time or datetime.now() - timedelta(days=random.randint(1, 200))

    def get_uptime(self):
        now = datetime.now()
        delta = now - self.boot_time
        return delta.days

Example:

d = NetworkDevice("agg-sw2", "192.168.10.5", "Cisco")
print(f"{d.hostname} has been up for {d.get_uptime()} days.")

Why this matters:

You’re no longer just storing metadata; you’re capturing time-based state and exposing that through expressive methods. This is incredibly useful for creating health dashboards, pre-change checks, or drift detection tools.

Why Modeling Matters More Than You Think

Let’s zoom out. These method expansions are about more than syntax. They represent a shift in how you think about automation:

Old Mental Model

New Mental Model

Functions act on data

Objects act on themselves

Separate logic for each use case

Unified behaviors within the device

Lots of conditionals

Clean class design

Hard to extend

Easy to override and specialize

You’re creating software that maps to reality. Routers can be pinged. Firewalls can show uptime. Each behavior feels natural, and that’s a sign you’re modeling well.

Practice What You’ve Modeled

Try this small challenge to reinforce your learning:

  1. Create 3 devices of different vendors.

  2. Add random boot times for each.

  3. Call .is_reachable() and .get_uptime() for all of them.

  4. Use .generate_config_snippet() to output a config report.

  5. Wrap all of this in a loop and think: could I do this with just dicts? Would it scale? Would it feel right?

You’ll see how powerful and enjoyable it is to treat your network like a living, programmable system.

6. Using Inheritance for Device-Specific Logic (Routers, Switches, Firewalls)

Why Inheritance Matters in Network Automation

In any real-world infrastructure, your devices don’t all behave the same. Routers speak BGP and manage routing tables. Switches maintain VLAN tables and port mappings. Firewalls enforce policies and track session states.

Yet, they all share common traits: they have hostnames, management IPs, vendors, uptime, and reachability. These shared characteristics form the foundation of a base class, and the distinct behaviors form the specializations we can encapsulate in subclasses.

This is where inheritance helps us a lot: you build the common logic once, in a parent class (NetworkDevice), and then extend it into specific roles like Router, Switch, or Firewall, where each gets its own additional powers.

This approach keeps your code DRY (Don’t Repeat Yourself), while allowing high-fidelity modeling of device-specific behaviors.

Step 1: Starting with the Base Class

Let’s recap the foundation:

from datetime import datetime, timedelta
import random

class NetworkDevice:
    def __init__(self, hostname, mgmt_ip, vendor):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor
        self.boot_time = datetime.now() - timedelta(days=random.randint(1, 200))

    def is_reachable(self):
        return random.random() < 0.9

    def get_uptime(self):
        return (datetime.now() - self.boot_time).days

    def generate_config_snippet(self):
        return f"# Generic config for {self.hostname} [{self.vendor}]"

Step 2: Creating Role-Specific Subclasses

Now, let’s break this out into specialized devices. Each will inherit from NetworkDevice but define or override logic relevant to its domain.

Router: BGP Validation and Route Summary

class Router(NetworkDevice):
    def __init__(self, hostname, mgmt_ip, vendor, bgp_neighbors):
        super().__init__(hostname, mgmt_ip, vendor)
        self.bgp_neighbors = bgp_neighbors  # dict of neighbor_ip: state

    def check_bgp_peers(self):
        down_peers = [peer for peer, state in self.bgp_neighbors.items() if state != "Established"]
        if not down_peers:
            return f"{self.hostname}: All BGP peers are up."
        return f"{self.hostname}: BGP DOWN with peers {', '.join(down_peers)}"

Usage:

r1 = Router("edge-rtr1", "10.10.1.1", "Cisco", {
    "192.0.2.1": "Established",
    "192.0.2.2": "Idle"
})

print(r1.check_bgp_peers())

Switch: VLAN and Port Visibility

class Switch(NetworkDevice):
    def __init__(self, hostname, mgmt_ip, vendor, vlans):
        super().__init__(hostname, mgmt_ip, vendor)
        self.vlans = vlans  # list of VLAN IDs

    def list_vlans(self):
        return f"{self.hostname} has VLANs: {', '.join(str(v) for v in self.vlans)}"

    def generate_config_snippet(self):  # overriding base method
        vlan_config = "\n".join([f"vlan {v}\n name VLAN_{v}" for v in self.vlans])
        return f"hostname {self.hostname}\n{vlan_config}"

Usage:

sw1 = Switch("dc-sw1", "10.10.2.1", "Juniper", [10, 20, 30])
print(sw1.list_vlans())
print(sw1.generate_config_snippet())

Firewall: Policy Listing and Rule Lookup

class Firewall(NetworkDevice):
    def __init__(self, hostname, mgmt_ip, vendor, policies):
        super().__init__(hostname, mgmt_ip, vendor)
        self.policies = policies  # list of tuples: (src, dst, action)

    def list_policies(self):
        return "\n".join([f"{src} -> {dst} : {action}" for src, dst, action in self.policies])

    def find_policy(self, src_ip, dst_ip):
        for src, dst, action in self.policies:
            if src == src_ip and dst == dst_ip:
                return f"{self.hostname}: Policy FOUND - {src} to {dst}: {action}"
        return f"{self.hostname}: No matching policy for {src_ip} -> {dst_ip}"

Usage:

fw1 = Firewall("fw-core", "10.10.3.1", "PaloAlto", [
    ("10.1.1.0/24", "172.16.0.0/16", "allow"),
    ("10.1.1.0/24", "192.168.1.0/24", "deny")
])

print(fw1.list_policies())
print(fw1.find_policy("10.1.1.0/24", "172.16.0.0/16"))

If you were to consolidate all the above snippets into one single code and run it for testing or experimentation, you'd get:

edge-rtr1: BGP DOWN with peers 192.0.2.2
dc-sw1 has VLANs: 10, 20, 30
hostname dc-sw1
vlan 10
 name VLAN_10
vlan 20
 name VLAN_20
vlan 30
 name VLAN_30
10.1.1.0/24 -> 172.16.0.0/16 : allow
10.1.1.0/24 -> 192.168.1.0/24 : deny
fw-core: Policy FOUND - 10.1.1.0/24 to 172.16.0.0/16: allow

Code Reuse, Not Duplication

Inheritance helps keep the shared behaviors centralized and the device-specific logic neatly scoped. Imagine the nightmare of writing a different .is_reachable() method for every type of device in your codebase. With inheritance, you get reuse and structure:

Function

Lives In

Used By

get_uptime()

NetworkDevice

All subclasses

check_bgp_peers()

Router

Only routers

generate_config_snippet()

Overridden by Switch

Tailored per subclass

find_policy()

Firewall

Only firewalls

This is where code becomes infrastructure-aware.

Instead of managing sprawling logic that handles devices by type, you let the object handle itself, guided by role-specific behavior. If tomorrow you add a LoadBalancer class or SDWANGateway, your loop can stay the same, your model grows, and your code remains clean.

The advantage isn't just elegance: it's testability, reusability, and collaboration. Teams can extend and reuse the base model without stepping on each other’s code.

Optional Practice Challenge

Write a DeviceInventory class that holds a list of mixed Router, Switch, and Firewall objects. Implement a method called .run_diagnostics() that calls .is_reachable() and one device-specific method per type.

Example output:

[Router] edge-rtr1 is reachable. BGP DOWN with peers 192.0.2.2
[Switch] dc-sw1 is reachable. VLANs active: 10, 20, 30
[Firewall] fw-core is reachable. Policy count: 2

Try this and you’ll internalize the core benefit of inheritance: unified orchestration of diverse devices through shared abstractions.

7. Composition Over Inheritance: Interfaces as Objects

Why Composition Matters in Network Engineering

So far, we’ve built a class hierarchy where different types of network devices (e.g., Router, Switch, Firewall) inherit from a common parent class, NetworkDevice. This pattern works well when we want to model roles or specializations.

But what about components?

In the real world, a router isn’t a subtype of an interface: it has interfaces. This subtle distinction between “is-a” and “has-a” is what differentiates inheritance from composition:

  • A router is a network device → Use inheritance

  • A router has many interfaces → Use composition

By embracing composition, we move from flat models to nested, real-world structures that better reflect how actual networks behave.

Step 1: Define the Interface Class

Let’s build a reusable, simple class to represent any physical or logical interface:

class Interface:
    def __init__(self, name, status="down", speed="1G", is_uplink=False):
        self.name = name
        self.status = status
        self.speed = speed
        self.is_uplink = is_uplink

    def is_operational(self):
        return self.status == "up"

    def __str__(self):
        return f"{self.name} [{self.status}, {self.speed}, uplink={self.is_uplink}]"

This object cleanly encapsulates everything about a single interface:

  • Its name, like ge-0/0/1 or Gig0/1

  • Its operational status

  • Its speed, e.g., 1G, 10G, etc.

  • A flag is_uplink to help automate core-edge topologies

Step 2: Add Interfaces to Devices

Let’s now revise our NetworkDevice class so that each device can hold a list of Interface objects.

class NetworkDevice:
    def __init__(self, hostname, mgmt_ip, vendor):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor
        self.interfaces = []

    def add_interface(self, interface_obj):
        self.interfaces.append(interface_obj)

    def show_interfaces(self):
        for iface in self.interfaces:
            print(str(iface))

We just turned a flat device into a container of components, making the model scalable and realistic.

Step 3: Simulate Realistic Devices

Let’s simulate a router with a few interfaces and use logic to evaluate their behavior.

# Create device
rtr = NetworkDevice("core-rtr1", "192.168.0.1", "Juniper")

# Add interfaces
rtr.add_interface(Interface("ge-0/0/0", status="up", speed="10G", is_uplink=True))
rtr.add_interface(Interface("ge-0/0/1", status="down", speed="10G", is_uplink=True))
rtr.add_interface(Interface("ge-0/0/2", status="up", speed="1G", is_uplink=False))

# Evaluate status of uplinks
for iface in rtr.interfaces:
    if iface.is_uplink and not iface.is_operational():
        print(f"{rtr.hostname} - {iface.name} is DOWN!")

Output:

core-rtr1 - ge-0/0/1 is DOWN!

Notice how readable, extensible, and maintainable this logic is. No need to model interfaces as nested dictionaries. We treat them as real entities with behavior.

Why This Matters

Let’s draw the comparison:

Flat Dictionary Model

Composition Model

{"name": "ge-0/0/1", "status": "up"}

Interface("ge-0/0/1", status="up")

Access is clunky: iface["status"]

Access is clean: iface.status

No behavior

Has methods like .is_operational()

Repetition of structure

Shared, reusable blueprint

Composition lets you:

  • Group logic and data in one place

  • Avoid bugs from repeated keys or typos

  • Scale to dozens or hundreds of interfaces per device

  • Simulate real-world systems like interface flaps, access port checks, or link validation

Going Further: Grouping Interfaces

We can go further by filtering subsets of interfaces. Imagine you want to find all down uplinks:

down_uplinks = [iface for iface in rtr.interfaces if iface.is_uplink and not iface.is_operational()]

for iface in down_uplinks:
    print(f"{rtr.hostname}: {iface.name} needs attention")

Or calculate bandwidth inventory:

uplink_bandwidth = sum(
    int(iface.speed.replace("G", "")) for iface in rtr.interfaces if iface.is_uplink
)

print(f"Total uplink capacity for {rtr.hostname}: {uplink_bandwidth}G")

This is how NetDevOps engineers reason about systems: groupings, conditions, state evaluation, all wrapped in well-modeled objects.

A Challenge for You

Build a class called Switch (inherits from NetworkDevice) and populate it with 10 interfaces — 2 uplinks and 8 access ports.

Then:

  • Print the number of interfaces that are down

  • Count how many uplinks are not operational

  • Show a report of port speeds grouped by status

Some Extra Thoughts Here…

Composition reflects how real devices are built and operated:

  • Routers have BGP peers.

  • Firewalls have security zones.

  • Switches have interfaces and VLANs.

By modeling these parts as objects, you gain clarity, modularity, and extensibility. Instead of wrestling with nested dictionaries, you begin to speak in terms of actual entities; Interface, Policy, Route, Peer.

This is how great software engineers build abstractions. And as a network engineer evolving into infrastructure-as-code, you now have this power.

8. Use Case: Building a Topology Map from OOP Data

Why Topology Matters (It Sounds Obvious, Doesn't It?)

A network topology isn't just a pretty diagram in Visio or NetBox: it's a live graph of operational dependencies. Whether Layer 2 or Layer 3, your network is built from connected devices, and those connections (physical or logical) carry meaning:

  • Do I have redundant paths?

  • Which links are down, and what’s impacted?

  • Can I automatically validate the topology and alert on mismatches?

  • Can I generate a JSON/YAML file to feed a network visualization dashboard?

All these tasks become far more intuitive when your infrastructure is modeled as connected objects, not flat CSVs or spreadsheets.

Let’s build such a model together.

Step 1: Enhancing the Interface Class with Peer Awareness

Previously, we defined Interface objects with attributes like name, status, and speed. Now, we give interfaces a sense of peer relationships, just like in the real world, where interfaces connect to other interfaces.

But, before that, let's define our NetworkDevice to work with the new Interface:

class NetworkDevice:
    def __init__(self, hostname, mgmt_ip, vendor="Unknown", device_type="Router"):
        self.hostname = hostname
        self.mgmt_ip = mgmt_ip
        self.vendor = vendor
        self.device_type = device_type
        self.interfaces = []
        self.status = "up"
    
    def add_interface(self, interface):
        """Add an interface to this device"""
        self.interfaces.append(interface)
        interface.device = self  # Back-reference to device
    
    def get_interface_by_name(self, name):
        """Get interface by name"""
        for iface in self.interfaces:
            if iface.name == name:
                return iface
        return None
    
    def get_operational_interfaces(self):
        """Get list of operational interfaces"""
        return [iface for iface in self.interfaces if iface.is_operational()]
    
    def get_uplink_interfaces(self):
        """Get list of uplink interfaces"""
        return [iface for iface in self.interfaces if iface.is_uplink]
    
    def health_status(self):
        """Calculate device health based on interface status"""
        if not self.interfaces:
            return 0.0
        operational_count = len(self.get_operational_interfaces())
        return (operational_count / len(self.interfaces)) * 100
    
    def is_reachable(self):
        """Check if device has at least one operational uplink"""
        uplinks = self.get_uplink_interfaces()
        return any(uplink.link_status() == "Up" for uplink in uplinks)
    
    def __str__(self):
        return f"{self.hostname} ({self.vendor} {self.device_type}) - {self.mgmt_ip}"

Next, let's define our class Interface properly:

class Interface:
    def __init__(self, name, status="down", speed="1G", is_uplink=False):
        self.name = name
        self.status = status
        self.speed = speed
        self.is_uplink = is_uplink
        self.connected_to = None  # This will be another Interface object

    def is_operational(self):
        return self.status == "up"

    def link_status(self):
        if not self.connected_to:
            return "Unconnected"
        if self.is_operational() and self.connected_to.is_operational():
            return "Up"
        return "Down"

    def __str__(self):
        peer = self.connected_to.name if self.connected_to else "None"
        return f"{self.name} -> {peer} [{self.status}, {self.speed}]"

This simple connected_to attribute enables us to build a network graph in memory.

Step 2: Connect Devices with Bi-Directional Links

Let’s simulate a real-world mini topology. Imagine two routers and a switch, interconnected like this:

R1 ------ SW1 ------ R2

We’ll use our object model to create and link these nodes.

# Devices
r1 = NetworkDevice("R1", "10.0.0.1", "Cisco")
r2 = NetworkDevice("R2", "10.0.0.2", "Juniper")
sw1 = NetworkDevice("SW1", "10.0.0.10", "Arista")

# Interfaces
r1_g0 = Interface("Gig0/0", status="up", speed="10G", is_uplink=True)
r2_g0 = Interface("Gig0/0", status="up", speed="10G", is_uplink=True)
sw1_g1 = Interface("Eth1", status="up", speed="10G", is_uplink=True)
sw1_g2 = Interface("Eth2", status="up", speed="10G", is_uplink=True)

# Connect interfaces
r1_g0.connected_to = sw1_g1
sw1_g1.connected_to = r1_g0

r2_g0.connected_to = sw1_g2
sw1_g2.connected_to = r2_g0

# Add interfaces to devices
r1.add_interface(r1_g0)
r2.add_interface(r2_g0)
sw1.add_interface(sw1_g1)
sw1.add_interface(sw1_g2)

We’ve now simulated an actual topology, where interfaces know who they’re connected to — all in memory.

Step 3: Traversing the Graph to Build a Topology Map

Let’s write a function to loop through every device, every interface, and list their connections. This is the core logic behind topology discovery tools.

def display_topology(devices):
    print("== TOPOLOGY MAP ==")
    seen_links = set()

    for device in devices:
        for iface in device.interfaces:
            peer = iface.connected_to
            if peer and peer.device:
                # Create a consistent tuple to avoid duplicate display
                link = tuple(sorted([
                    (device.hostname, iface.name),
                    (peer.device.hostname, peer.name)
                ]))
                if link not in seen_links:
                    status = iface.link_status()
                    print(f"{device.hostname}:{iface.name} <--> {peer.device.hostname}:{peer.name} [{status}]")
                    seen_links.add(link)
def display_device_summary(devices):
    print("\n== DEVICE SUMMARY ==")
    for device in devices:
        print(f"\n{device}")
        print(f"  Health: {device.health_status():.1f}%")
        print(f"  Reachable: {device.is_reachable()}")
        print("  Interfaces:")
        for iface in device.interfaces:
            print(f"    {iface}")

Output, once you complete these steps:

== TOPOLOGY MAP ==
R1:Gig0/0 <--> SW1:Eth1 [Up]
R2:Gig0/0 <--> SW1:Eth2 [Up]

Notice how we’re using a seen_links set to prevent bi-directional duplicates.

Step 4: Validate Topology Status

We can also extend this logic to validate health:

def check_link_health(devices):
    for device in devices:
        for iface in device.interfaces:
            peer = iface.connected_to
            if peer:
                if iface.status != "up" or peer.status != "up":
                    print(f"⚠️ Link between {device.hostname}:{iface.name} and {peer.device.hostname}:{peer.name} is DOWN")

Now we’re turning our model into an operational monitoring tool.

Exporting for Visualization

Let’s say you want to export this topology to a format like JSON or DOT (Graphviz). Your object model makes it trivial:

topology_data = []

for device in [r1, sw1, r2]:
    for iface in device.interfaces:
        peer = iface.connected_to
        if peer:
            link = {
                "from": f"{device.hostname}:{iface.name}",
                "to": f"{peer.device.hostname}:{peer.name}",
                "status": iface.link_status()
            }
            topology_data.append(link)

import json
print(json.dumps(topology_data, indent=2))

# Test the implementation
if __name__ == "__main__":
    # Create devices
    devices = [r1, r2, sw1]
    
    # Display topology and device summaries
    display_topology(devices)
    display_device_summary(devices)

You’ve just turned OOP into data modeling, and this can plug into dashboards, databases, or config generators.

Why This Is So Valuable

Real network topologies aren’t static diagrams: they evolve. Links flap, interfaces change speed, and devices get added. By modeling your infrastructure using connected objects, you can:

  • Validate real-time health

  • Simulate failure scenarios

  • Automatically detect single points of failure

  • Export structured data for Grafana, NetBox, or custom UIs

This approach moves you from spreadsheet-driven ops to intent-driven, object-modeled engineering.

What to Build Next

Try this challenge:

Add another device (e.g., a firewall) connected to SW1. Randomly set one interface to down. Write a function that:

  • Walks the topology

  • Identifies broken links

  • Counts total operational and down links

This simulates your first topology health checker, built from scratch.

What we’ve done here is monumental. We've gone from:

  • Individual classes (NetworkDevice, Interface)

  • To relationships (composition)

  • To bi-directional modeling (topology)

  • To traversal, health validation, and data export

This mirrors what tools like LLDP-based discovery, SNMP topology mappers, or network CMDBs do under the hood. You're not just learning Python, you're learning to think like an automation system.

9. Using __str__() and __repr__() for Debugging and Readable Output

As a network engineer embracing Python, you might start printing objects to inspect their attributes; maybe you're looping through a list of devices, checking interface states, or logging health checks. But what happens when you do something like this?

print(device)

If you haven’t told Python how to represent your object, you’ll get something like:

<__main__.NetworkDevice object at 0x7f9c33d9f580>

That tells you nothing useful. Now imagine seeing 20 of these printed in a log file. Useless.

This is where __str__() and __repr__() become powerful allies in debugging, logging, and even CLI tool design.

The Purpose of __str__() vs __repr__()

Method

Purpose

Used by...

__str__()

Human-readable representation (end user)

print(), str()

__repr__()

Developer/debugging representation

Shell, REPL, logs, repr()

Ideally:

  • __str__() gives a clean, friendly view

  • __repr__() gives an accurate, complete technical view

Adding __str__() and __repr__() to NetworkDevice

Let’s enhance our NetworkDevice class:

class NetworkDevice:
    def __init__(self, hostname, ip_address, vendor):
        self.hostname = hostname
        self.ip_address = ip_address
        self.vendor = vendor
        self.interfaces = []

    def add_interface(self, interface):
        interface.device = self
        self.interfaces.append(interface)

    def __str__(self):
        return f"{self.hostname} ({self.vendor}) - {self.ip_address}"

    def __repr__(self):
        return f"NetworkDevice(hostname='{self.hostname}', ip='{self.ip_address}', vendor='{self.vendor}')"

Now look what happens when we print:

device = NetworkDevice("R1", "10.0.0.1", "Cisco")

print(device)          # Uses __str__
print(repr(device))    # Uses __repr__

Output:

R1 (Cisco) - 10.0.0.1
NetworkDevice(hostname='R1', ip='10.0.0.1', vendor='Cisco')

The first one is friendly for CLI or user output. The second is rich and debug-ready.

Making Interfaces Equally Informative

Let’s improve the Interface class too:

class Interface:
    def __init__(self, name, status="down", speed="1G", is_uplink=False):
        self.name = name
        self.status = status
        self.speed = speed
        self.is_uplink = is_uplink
        self.connected_to = None
        self.device = None  # Set when added to device

    def __str__(self):
        return f"{self.device.hostname}:{self.name} [{self.status}, {self.speed}]"

    def __repr__(self):
        return f"Interface(name='{self.name}', status='{self.status}', speed='{self.speed}')"

device = NetworkDevice("R1", "10.0.0.1", "Cisco")

# Create some interfaces
iface1 = Interface("Gig0/0", status="up", speed="10G", is_uplink=True)
iface2 = Interface("Gig0/1", status="up", speed="1G")
iface3 = Interface("Gig0/2", status="down", speed="1G")

# Add interfaces to the device
device.add_interface(iface1)
device.add_interface(iface2)
device.add_interface(iface3)

Now, when we loop over device interfaces:

for iface in device.interfaces:
    print(iface)

You’ll see:

R1:Gig0/0 [up, 10G]
R1:Gig0/1 [up, 1G]
R1:Gig0/2 [down, 1G]

So much more informative than default object memory addresses.

Practical Benefits for Network Automation

By implementing these methods, you unlock a series of real-world advantages:

Better Logging

logger.info(f"Device health check passed: {device}")

This logs a meaningful entry like:

Device health check passed: SW1 (Arista) - 10.0.0.10

Richer REPL and Test Output

When testing or prototyping in the Python shell or notebooks, printing objects gives instant insight.

CLI Tool Readability

If you're building a small tool with argparse, click, or even simple print-based output, these representations make your tool feel professional and clean.

Easier Debugging and Tracebacks

Ever hit an exception and have dozens of unknown objects in a traceback? With __repr__() in place, you know what those objects are. No guesswork.

Tips and Best Practices

  • Always implement both: __str__() for human eyes, __repr__() for developer eyes.

  • Make __repr__() unambiguous and ideally copy-pasteable into code (eval(repr(obj)) should work (if possible).

  • Keep __str__() short and meaningful — think dashboards or log lines.

Want to Practice?

Modify your classes from earlier in this series. Add __str__() and __repr__() to:

  • Router, Switch, Firewall

  • Your Interface class

  • Any custom topology or CMDB objects

Then build a quick logger or CLI command to print a report on all devices and interfaces. You’ll immediately see how much more helpful and readable your tooling becomes.

This has been a fairly lengthy edition, but I want to stay on topic to cover additional aspects of OOP. So... see you in Part 2 of this exciting topic!

Leonardo Furtado

Keep Reading