Python for Network Engineers — Part 4: Functions, Regular Expressions, and Modules
Every script we’ve written so far works, but it’s all in one file and none of it is reusable. If you wanted to use the VLAN loader from Part 3 in a different script, you’d copy and paste it. That’s how bugs multiply.
This post covers the three things that fix that: functions to encapsulate logic, modules to share it across files, and regular expressions to extract structured data from CLI output that wasn’t designed to be parsed. By the end, we’ll have a small multi-file project that parses show ip interface brief output — and a structure you can apply to every script from here on.
Regex tends to be where network engineers either click with Python or bounce off it. We’ll take it slowly.
Functions
A function is a named, reusable block of code. You define it once and call it wherever you need it.
def greet(name):
print(f"Hello, {name}")
greet("network engineer")
# Hello, network engineer
def introduces the function, name is a parameter (the input), and the indented block is the body. Functions don’t have to take arguments or return values, but they usually do both.
Return Values
Use return to send a value back to the caller:
def is_private_ip(ip_str):
import ipaddress
ip = ipaddress.ip_address(ip_str)
return ip.is_private
result = is_private_ip("192.168.1.1")
print(result) # True
A function without a return statement returns None implicitly. If you find yourself writing a function that only prints, ask whether it should return the value instead — printing is the caller’s job.
Default Parameter Values
Parameters can have defaults — if the caller doesn’t supply a value, the default is used:
def connect(host, port=22, timeout=30):
print(f"Connecting to {host}:{port} with {timeout}s timeout")
connect("192.168.1.1") # uses port=22, timeout=30
connect("192.168.1.1", timeout=60) # overrides just timeout
connect("192.168.1.1", 830, 60) # positional: NETCONF port
Calling by keyword name (timeout=60) is clearer than relying on position, especially when a function has several parameters.
One important rule: never use a mutable object (list, dict) as a default value. Python creates default values once at function definition time, not at each call — so a mutable default is shared across all calls:
# BAD — all calls share the same list
def add_device(name, device_list=[]):
device_list.append(name)
return device_list
# GOOD — use None as sentinel, create fresh each time
def add_device(name, device_list=None):
if device_list is None:
device_list = []
device_list.append(name)
return device_list
*args and **kwargs
Sometimes you don’t know in advance how many arguments a function will receive. *args collects extra positional arguments as a tuple; **kwargs collects extra keyword arguments as a dict:
def show_commands(*commands):
"""Accept any number of show commands."""
for cmd in commands:
print(f"Running: {cmd}")
show_commands("show version", "show ip route", "show interfaces")
def connect_device(**params):
"""Accept connection parameters as keyword arguments."""
host = params.get("host", "unknown")
port = params.get("port", 22)
print(f"Connecting to {host} on port {port}")
connect_device(host="192.168.1.1", port=22, username="admin")
In practice, you’ll use **kwargs most when writing wrapper functions that pass parameters through to another function — for example, a function that adds logging around a Netmiko connection and needs to pass any connection parameters through to Netmiko unchanged.
Docstrings
A docstring is a string immediately after the def line. It describes what the function does, its parameters, and what it returns:
def parse_interface_brief(output):
"""Parse 'show ip interface brief' output into a list of dicts.
Args:
output: Raw string output from the show command.
Returns:
List of dicts, each with keys: interface, ip, ok, method,
status, protocol.
"""
...
Write docstrings for every function. They show up in the REPL via help() and in VS Code’s hover tooltips. More importantly, they force you to think clearly about what a function actually does before you write it.
Lambda
A lambda is a small, anonymous function — one expression, no def:
# Regular function
def get_hostname(device):
return device["hostname"]
# Equivalent lambda
get_hostname = lambda device: device["hostname"]
Lambda is mainly useful as a sort key:
devices = [
{"hostname": "spine-02", "ip": "10.0.0.2"},
{"hostname": "core-sw-01", "ip": "192.168.1.1"},
{"hostname": "spine-01", "ip": "10.0.0.1"},
]
# Sort devices alphabetically by hostname
sorted_devices = sorted(devices, key=lambda d: d["hostname"])
# Sort by IP address numerically
import ipaddress
sorted_by_ip = sorted(devices, key=lambda d: ipaddress.ip_address(d["ip"]))
Keep lambdas to single expressions. If you need multiple lines, write a real function.
functools.lru_cache
lru_cache memoises a function — the first time you call it with a given set of arguments, Python stores the result. Every subsequent call with the same arguments returns the cached result without re-running the function.
This is useful for functions that do expensive lookups and are called repeatedly with the same inputs:
from functools import lru_cache
import socket
@lru_cache(maxsize=256)
def resolve_hostname(hostname):
"""Resolve a hostname to an IP. Cached so we don't hammer DNS."""
return socket.gethostbyname(hostname)
The @lru_cache(maxsize=256) line is a decorator — a way of wrapping a function with extra behaviour. We’ll cover decorators more in the classes posts; for now, think of it as “attach this caching behaviour to this function.”
Regular Expressions
Regular expressions (regex) are patterns that match text. Python’s re module implements them. The core use case in network automation: extracting structured data from CLI output that was designed to be read by humans, not parsed by software.
Before TextFSM existed and before vendor REST APIs were common, regex was how you got data out of a router. Even now, you’ll encounter show commands with no TextFSM template, vendor-specific output formats, and one-off parsing tasks where regex is the right tool.
The re Module
Import it and use one of four main functions:
| Function | Use |
|---|---|
re.search(pattern, string) | Find first match anywhere in string |
re.match(pattern, string) | Match only at the start of string |
re.findall(pattern, string) | Return all matches as a list |
re.finditer(pattern, string) | Return all matches as iterator of match objects |
re.search() is what you’ll reach for most. It returns a match object if found, or None if not — so you can use it directly in an if:
import re
line = "GigabitEthernet0/0/1 10.0.0.1 YES manual up up"
match = re.search(r"(\d+\.\d+\.\d+\.\d+)", line)
if match:
print(match.group(1)) # 10.0.0.1
The r"..." prefix means raw string — backslashes are treated literally, which is what regex expects. Always use raw strings for patterns.
Special Characters
| Pattern | Matches |
|---|---|
. | Any character except newline |
\d | A digit (0–9) |
\D | Not a digit |
\w | A word character (letter, digit, underscore) |
\W | Not a word character |
\s | Whitespace (space, tab, newline) |
\S | Not whitespace |
^ | Start of string (or line with MULTILINE) |
$ | End of string (or line with MULTILINE) |
* | Zero or more of the preceding |
+ | One or more of the preceding |
? | Zero or one of the preceding |
{n} | Exactly n of the preceding |
{n,m} | Between n and m of the preceding |
[abc] | Any of a, b, or c |
[^abc] | Any character except a, b, or c |
| | Either the pattern on the left or right |
In practice you’ll use \d, \w, \s, ., +, *, and ? constantly, and rarely reach for the others.
Capture Groups
Parentheses create a capture group — they extract the part of the match you actually want:
line = "GigabitEthernet0/0/1 10.0.0.1 YES manual up up"
# Match an IP address and capture it
match = re.search(r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", line)
if match:
print(match.group(0)) # entire match: "10.0.0.1"
print(match.group(1)) # first capture group: "10.0.0.1"
Multiple groups:
# Capture interface name and IP
match = re.search(r"^(\S+)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", line)
if match:
interface = match.group(1) # GigabitEthernet0/0/1
ip = match.group(2) # 10.0.0.1
\S+ matches one or more non-whitespace characters — a reliable way to capture a token like an interface name or hostname.
Named Capture Groups
Named groups — (?P<name>pattern) — let you access matches by name rather than position. When a pattern has many groups, this makes the code far more readable:
pattern = r"""
^(?P<interface>\S+) # interface name
\s+
(?P<ip>[\d.]+|unassigned) # IP address or 'unassigned'
\s+
(?P<ok>\S+) # OK? field
\s+
(?P<method>\S+) # method
\s+
(?P<status>[\w ]+?) # status (may contain spaces)
\s{2,}
(?P<protocol>\S+) # protocol
"""
match = re.search(pattern, line, re.VERBOSE)
if match:
print(match.group("interface")) # GigabitEthernet0/0/1
print(match.group("ip")) # 10.0.0.1
print(match.group("status")) # up
re.VERBOSE (used above) ignores whitespace and comments inside the pattern string, which lets you break a complex pattern across multiple lines with explanations. When a pattern gets long, use it.
match.groupdict() returns all named groups as a dict — a clean bridge between regex and the rest of your code:
data = match.groupdict()
# {'interface': 'GigabitEthernet0/0/1', 'ip': '10.0.0.1', 'ok': 'YES', ...}
findall and finditer
re.findall() returns all non-overlapping matches. If your pattern has one capture group, it returns a list of the captured strings; if it has multiple groups, it returns a list of tuples:
text = "Hosts: 192.168.1.1, 10.0.0.1, 172.16.0.1"
# Find all IP addresses
ips = re.findall(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", text)
# ['192.168.1.1', '10.0.0.1', '172.16.0.1']
re.finditer() returns an iterator of match objects — more useful when you need the full match context (start position, groups, etc.) for each result, and avoids building the entire list in memory:
for match in re.finditer(r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", text):
print(f"Found IP at position {match.start()}: {match.group(1)}")
re.MULTILINE
By default, ^ and $ match the start and end of the entire string. With re.MULTILINE, they match at the start and end of each line. Useful when scanning a config or show output line by line without splitting first:
config = """
interface GigabitEthernet0/0/1
description WAN uplink
ip address 10.0.0.1 255.255.255.252
interface GigabitEthernet0/0/2
ip address 192.168.1.1 255.255.255.0
"""
# Find all interface names at the start of a line
interfaces = re.findall(r"^interface (\S+)", config, re.MULTILINE)
# ['GigabitEthernet0/0/1', 'GigabitEthernet0/0/2']
Compiling Patterns
If you use the same pattern many times — in a loop over hundreds of lines — compile it first with re.compile(). Python caches a small number of patterns internally anyway, but compiling explicitly is a good habit and makes intent clear:
IP_PATTERN = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")
for line in output_lines:
match = IP_PATTERN.search(line)
if match:
print(match.group(0))
Modules and Imports
A module is any .py file. When you import it, Python runs the file and makes its names available.
Basic Imports
import re # import the re module
import ipaddress # import the ipaddress module
# Use with the module name as prefix
match = re.search(r"\d+", line)
ip = ipaddress.ip_address("192.168.1.1")
from x import y imports just one name — no prefix needed:
from ipaddress import ip_address, ip_network
from collections import defaultdict
ip = ip_address("192.168.1.1") # no 'ipaddress.' prefix
Both styles are fine. Explicit import module makes it clear where a name comes from when you’re reading code; from module import name is cleaner for heavily-used functions.
Avoid from module import * — it dumps everything from the module into your namespace with no indication of origin.
if name == “main”
Every Python file has a special variable __name__. When you run the file directly, __name__ equals "__main__". When the file is imported by another file, __name__ equals the module’s name.
# parsers.py
def parse_interface_brief(output):
...
if __name__ == "__main__":
# This block only runs when you do: python3 parsers.py
# It does NOT run when another file does: import parsers
test_output = "GigabitEthernet0/0/1 10.0.0.1 YES manual up up"
print(parse_interface_brief(test_output))
This pattern lets a file serve double duty: importable as a module, and runnable directly for quick testing. Use it in every file.
Structuring a Multi-File Project
Splitting code across files is how you keep any project navigable. A common structure for a network automation project:
network-tools/
├── parsers.py # functions that extract data from CLI output
├── formatters.py # functions that format data for display
├── inventory.py # functions for loading/saving device lists
└── main.py # entry point — ties everything together
Files in the same directory can import each other directly:
# main.py
from parsers import parse_interface_brief
from formatters import print_interface_table
from inventory import load_devices
Standard Library Modules Worth Knowing
Beyond re and ipaddress, these appear constantly in network automation:
| Module | Use |
|---|---|
os | File system operations, environment variables |
sys | Command-line arguments (sys.argv), sys.exit() |
pathlib | Modern file paths (prefer over os.path) |
json | Parse and produce JSON |
csv | Read and write CSV files |
datetime | Timestamps for logs and reports |
subprocess | Run external commands (ping, traceroute) |
logging | Structured log output (better than print for scripts) |
argparse | Parse command-line arguments |
None of these need installing — they ship with Python. You’ll encounter most of them before the end of this series.
Putting It Together: Interface Brief Parser
Here’s a multi-file project that parses show ip interface brief output using everything from this post.
Create the project structure:
mkdir interface-parser
cd interface-parser
uv venv
source .venv/bin/activate
git init
Create sample_output.txt with realistic show command output, including an administratively down interface:
Interface IP-Address OK? Method Status Protocol
GigabitEthernet0/0/1 10.0.0.1 YES manual up up
GigabitEthernet0/0/2 unassigned YES unset administratively down down
GigabitEthernet0/0/3 192.168.1.1 YES DHCP up up
Loopback0 1.1.1.1 YES manual up up
Vlan1 unassigned YES unset down down
Vlan10 10.10.10.1 YES manual up up
Create parsers.py:
"""Functions for parsing Cisco IOS show command output."""
import re
from typing import Optional
# Pre-compiled pattern for show ip interface brief
# Named groups make the result dict self-documenting
_INTF_BRIEF_PATTERN = re.compile(
r"^(?P<interface>\S+)" # interface name — no whitespace
r"\s+"
r"(?P<ip>[\d.]+|unassigned)" # IP or the literal 'unassigned'
r"\s+"
r"(?P<ok>\S+)" # YES/NO
r"\s+"
r"(?P<method>\S+)" # manual/DHCP/etc
r"\s+"
r"(?P<status>[\w ]+?)" # up / down / administratively down
r"\s{2,}" # at least two spaces before protocol
r"(?P<protocol>\S+)", # up / down
re.MULTILINE,
)
def parse_interface_brief(output: str) -> list[dict]:
"""Parse 'show ip interface brief' output into a list of dicts.
Each dict has keys: interface, ip, ok, method, status, protocol.
The header line and blank lines are skipped automatically.
Args:
output: Raw string from the show command.
Returns:
List of interface dicts, one per interface found.
"""
interfaces = []
for match in _INTF_BRIEF_PATTERN.finditer(output):
entry = match.groupdict()
# Normalise: 'unassigned' → None for the IP field
if entry["ip"] == "unassigned":
entry["ip"] = None
interfaces.append(entry)
return interfaces
def is_up(interface_dict: dict) -> bool:
"""Return True if an interface is operationally up."""
return (
interface_dict["status"] == "up"
and interface_dict["protocol"] == "up"
)
def has_ip(interface_dict: dict) -> bool:
"""Return True if an interface has an IP address assigned."""
return interface_dict["ip"] is not None
if __name__ == "__main__":
# Quick self-test when run directly
sample = (
"GigabitEthernet0/0/1 10.0.0.1 YES manual up up\n"
"GigabitEthernet0/0/2 unassigned YES unset administratively down down\n"
)
for intf in parse_interface_brief(sample):
print(intf)
Create formatters.py:
"""Functions for displaying parsed interface data."""
def print_interface_table(interfaces: list[dict], show_down_only: bool = False) -> None:
"""Print a formatted table of interfaces.
Args:
interfaces: List of interface dicts from parse_interface_brief().
show_down_only: If True, only print down or no-IP interfaces.
"""
if show_down_only:
interfaces = [
i for i in interfaces
if i["status"] != "up" or i["protocol"] != "up"
]
if not interfaces:
print(" (no interfaces to display)")
return
header = f"{'Interface':<30} {'IP Address':<16} {'Status':<22} {'Protocol'}"
print(header)
print("─" * len(header))
for intf in interfaces:
ip_display = intf["ip"] or "—"
status_display = intf["status"]
print(
f"{intf['interface']:<30} "
f"{ip_display:<16} "
f"{status_display:<22} "
f"{intf['protocol']}"
)
def summarise_interfaces(interfaces: list[dict]) -> dict:
"""Return a summary dict: total, up, down, no_ip counts."""
total = len(interfaces)
up = sum(1 for i in interfaces if i["status"] == "up" and i["protocol"] == "up")
no_ip = sum(1 for i in interfaces if i["ip"] is None)
return {"total": total, "up": up, "down": total - up, "no_ip": no_ip}
Create main.py:
"""Entry point: parse a show ip interface brief file and report."""
import sys
from pathlib import Path
from parsers import parse_interface_brief
from formatters import print_interface_table, summarise_interfaces
DEFAULT_FILE = "sample_output.txt"
def main():
# Accept an optional filename argument from the command line
filepath = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(DEFAULT_FILE)
if not filepath.exists():
print(f"ERROR: File not found: {filepath}")
sys.exit(1)
raw_output = filepath.read_text(encoding="utf-8")
interfaces = parse_interface_brief(raw_output)
print(f"\nAll interfaces from {filepath.name}:\n")
print_interface_table(interfaces)
summary = summarise_interfaces(interfaces)
print(
f"\nSummary: {summary['total']} total — "
f"{summary['up']} up, "
f"{summary['down']} down, "
f"{summary['no_ip']} without IP"
)
down = [i for i in interfaces if i["status"] != "up" or i["protocol"] != "up"]
if down:
print(f"\nInterfaces needing attention:\n")
print_interface_table(down)
if __name__ == "__main__":
main()
Run it:
python3 main.py
Expected output:
All interfaces from sample_output.txt:
Interface IP Address Status Protocol
────────────────────────────────────────────────────────────────────────────────
GigabitEthernet0/0/1 10.0.0.1 up up
GigabitEthernet0/0/2 — administratively down down
GigabitEthernet0/0/3 192.168.1.1 up up
Loopback0 1.1.1.1 up up
Vlan1 — down down
Vlan10 10.10.10.1 up up
Summary: 6 total — 4 up, 2 down, 2 without IP
Interfaces needing attention:
Interface IP Address Status Protocol
────────────────────────────────────────────────────────────────────────────────
GigabitEthernet0/0/2 — administratively down down
Vlan1 — down down
And because main.py accepts a filename argument, you can point it at any output file you saved from a real device:
python3 main.py /path/to/core-sw-01-interfaces.txt
This three-file structure — parsers, formatters, main — is one you can reuse as a scaffold for every tool you build in this series. The parser knows nothing about display; the formatter knows nothing about parsing. Each file can be tested and imported independently.
What’s Coming in Part 5
Part 5 is where we stop working with files and start working with devices. We’ll use Netmiko to open real SSH connections, run show commands, push configuration changes, and handle the errors that real devices throw at you. The Containerlab topology appears for the first time — a small multi-vendor lab you can run on a laptop.
No Containerlab topology is needed for this post — all examples run without network devices.