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:

FunctionUse
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

PatternMatches
.Any character except newline
\dA digit (0–9)
\DNot a digit
\wA word character (letter, digit, underscore)
\WNot a word character
\sWhitespace (space, tab, newline)
\SNot 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:

ModuleUse
osFile system operations, environment variables
sysCommand-line arguments (sys.argv), sys.exit()
pathlibModern file paths (prefer over os.path)
jsonParse and produce JSON
csvRead and write CSV files
datetimeTimestamps for logs and reports
subprocessRun external commands (ping, traceroute)
loggingStructured log output (better than print for scripts)
argparseParse 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.