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-rtr012. 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
Routerclass, and then haveCiscoRouter,JuniperRouter, andAristaRoutersubclasses 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
Interfacegets 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 devicemgmt_ip: the management IP used for out-of-band communicationvendor: 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 summaryLet’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 | Extra function with many parameters | Method on the object |
Add support for vendor-specific config generation | Many | 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 |
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.9Now 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.daysExample:
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:
Create 3 devices of different vendors.
Add random boot times for each.
Call
.is_reachable()and.get_uptime()for all of them.Use
.generate_config_snippet()to output a config report.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: allowCode 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 |
|---|---|---|
|
| All subclasses |
|
| Only routers |
| Overridden by | Tailored per subclass |
|
| 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: 2Try 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/1orGig0/1Its operational status
Its speed, e.g.,
1G,10G, etc.A flag
is_uplinkto 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 |
|---|---|
|
|
Access is clunky: | Access is clean: |
No behavior | Has methods like |
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 ------ R2We’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... |
|---|---|---|
| Human-readable representation (end user) |
|
| Developer/debugging representation | Shell, REPL, logs, |
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,FirewallYour
InterfaceclassAny 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

