Functions and Modules — Building Reusable Network Helpers

So far every example has been a few lines of straight‑line code. That’s fine in the REPL but unmaintainable the moment a real script comes along. Today extracts repeated logic into functions, then moves those functions into their own file — a module — that other scripts can import. The result is a tiny reusable net_utils.py with three helpers, plus a runner script that imports it. This is the leap from “can write Python” to “can build something.”

This is Day 6 of the 21‑post Python for Network Engineers series.

Defining a function

def banner(text, char="="):
    """Return a banner line of repeated `char` framing `text`."""
    line = char * (len(text) + 4)
    return f"{line}\n{char} {text} {char}\n{line}"

print(banner("Starting backup", char="*"))
# *********************
# * Starting backup *
# *********************

The shape: def name (parameters): indented body, optional return value. The first string inside the body is the docstring — Python tools (help(banner), IDE tooltips) pick it up automatically. Treat docstrings as a way to document why the function exists and what shape its inputs and outputs take.

A function with no return returns None. That’s fine for functions whose job is a side effect (printing, writing a file, sending a packet) — but when the caller is going to use the result, return it.

Parameter kinds worth knowing

def connect(host, username, password, port=22, secret=None):
    """Connect to a device. Required: host/username/password. Optional: port, secret."""
    # ... pretend to connect ...
    return f"Connected to {host}:{port} as {username}"

print(connect("10.0.0.1", "admin", "Cisco123"))
print(connect(host="10.0.0.1", username="admin", password="Cisco123", port=2222))
  • Positionalhost, username, password: order matters.
  • Keyword arguments with defaults — port=22, secret=None: the caller can omit them.
  • Any positional can be passed as a keyword, which makes call sites self‑documenting. Keyword args read better at call sites with more than two positional values.

Default arg gotcha: never use a mutable default like def f(items=[]). The list is created once at function definition and shared across all calls — a classic source of mysterious bugs. Use def f(items=None): items = items or [] instead.

*args and **kwargs — the two-asterisk dance

This shows up in every network library. It lets a function accept any number of extra positional or keyword arguments:

def open_session(**device_kwargs):
    print("Opening with:", device_kwargs)

device = {
    "device_type": "cisco_ios",
    "host": "10.0.0.1",
    "username": "admin",
    "password": "Cisco123",
}
open_session(**device)
# Opening with: {'device_type': 'cisco_ios', 'host': '10.0.0.1', ...}

The **device at the call site spreads the dict into keyword args. The **device_kwargs in the signature collects any keyword args into a dict. This is exactly how Netmiko’s ConnectHandler(**device) works — a YAML inventory becomes a Python dict becomes function arguments with no glue code in between.

Type hints — optional but worth using

def is_valid_vlan(vlan: int) -> bool:
    """Return True if vlan is in the user-assignable range (1-4094, excluding reserved)."""
    return 1 <= vlan <= 4094 and vlan not in {1002, 1003, 1004, 1005}

print(is_valid_vlan(100))    # True
print(is_valid_vlan(1003))   # False

Python doesn’t enforce type hints at runtime — they’re documentation and a hint to the editor and to mypy (a separate type checker). But they make code immediately more readable and catch a real percentage of bugs in IDE squiggles. They’re worth adding to anything that will be re‑read in a month.

Modules — when one file isn’t enough

A module is just a .py file. Anything defined in it (functions, classes, variables) can be imported from another .py file. Consider this folder:

~/python-net/
├── .venv/
├── net_utils.py
└── inventory_runner.py

net_utils.py:

"""Reusable helpers for network scripts."""

def normalize_mac(mac: str) -> str:
    """Return MAC as lowercase colon-separated."""
    clean = mac.lower().replace(":", "").replace("-", "").replace(".", "")
    if len(clean) != 12:
        raise ValueError(f"Not a 12-hex-digit MAC: {mac!r}")
    return ":".join(clean[i:i+2] for i in range(0, 12, 2))


def is_valid_vlan(vlan: int) -> bool:
    return 1 <= vlan <= 4094 and vlan not in {1002, 1003, 1004, 1005}


def filter_by_site(inventory: dict, site: str) -> list:
    """Return list of (name, attrs) for devices in `site`."""
    return [(n, a) for n, a in inventory.items() if a.get("site") == site]


if __name__ == "__main__":
    # Quick self-test when this file is run directly.
    print(normalize_mac("aabb.ccdd.eeff"))
    print(is_valid_vlan(100), is_valid_vlan(5000))

inventory_runner.py:

"""Walk the inventory and print HQ devices' MACs in normalized form."""
from net_utils import normalize_mac, filter_by_site

INVENTORY = {
    "core-sw01":  {"host": "10.0.0.1", "site": "hq", "mac": "AABB.CC00.0001"},
    "edge-rtr02": {"host": "10.0.0.2", "site": "hq", "mac": "aa:bb:cc:00:00:02"},
    "branch-sw01":{"host": "10.1.0.1", "site": "branch-a", "mac": "AABB-CC00-0003"},
}

for name, attrs in filter_by_site(INVENTORY, "hq"):
    print(f"{name:<15} {attrs['host']:<12} {normalize_mac(attrs['mac'])}")

Run it:

python inventory_runner.py
# core-sw01       10.0.0.1     aa:bb:cc:00:00:01
# edge-rtr02      10.0.0.2     aa:bb:cc:00:00:02

Three concepts now added to the toolkit:

  1. from net_utils import normalize_mac, filter_by_site — pull specific names out of the module.
  2. if __name__ == "__main__": — code under this only runs when the file is executed directly, not when it’s imported. Use it for self‑tests inside library modules.
  3. Module‑level constants in CAPS (INVENTORY) by convention.

The standard library is already a giant set of network helpers

Before writing a custom helper, it’s worth checking what ships with Python. The ones this series uses:

  • ipaddress — Day 8
  • re — Day 9
  • subprocess — Day 10
  • socket — Day 11
  • json, logging, pathlib, os, sys, collections, itertools, datetime — used throughout

They import the same way: import json, then json.dumps(my_dict). No pip install required — they’re built in.

Exercises

  1. Write a function cidr_to_mask(prefix) that takes an integer like 24 and returns the dotted netmask string "255.255.255.0". Skip ipaddress — do it with bit math and string ops, just to flex.
  2. Write summarize_devices(devices) that takes a list of device dicts (each with at least a site key) and returns a dict mapping site → count. Example: summarize_devices([{"site": "hq"}, {"site": "hq"}, {"site": "lab"}]) == {"hq": 2, "lab": 1}.
  3. Add a connect(host, username, password, port=22, secret=None) function. When the caller supplies no password, raise ValueError("password is required").
  4. Move the function from exercise 1 (cidr_to_mask) into a file called net_utils.py. From a separate file day6.py, import it and call it for prefixes 8, 16, 24, 30. Print each on its own line.
  5. Stretch: add a __main__ block to net_utils.py that runs cidr_to_mask for prefixes 8, 16, 24, 30 as a self‑test, but ensure that block does not run when day6.py imports the module. Confirm by running both files.

Answers

Show answer 1
def cidr_to_mask(prefix: int) -> str:
    if not 0 <= prefix <= 32:
        raise ValueError("prefix must be 0..32")
    bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
    octets = [(bits >> (8 * i)) & 0xFF for i in (3, 2, 1, 0)]
    return ".".join(str(o) for o in octets)

print(cidr_to_mask(24))   # 255.255.255.0
print(cidr_to_mask(30))   # 255.255.255.252

Shift a 32‑bit all‑ones value left by the host bits, mask back to 32 bits, then split into four octets with a comprehension. The ipaddress module does the same thing more cleanly — that’s Day 8.

Show answer 2
def summarize_devices(devices: list) -> dict:
    counts = {}
    for d in devices:
        site = d.get("site", "unknown")
        counts[site] = counts.get(site, 0) + 1
    return counts

print(summarize_devices([{"site": "hq"}, {"site": "hq"}, {"site": "lab"}]))
# {'hq': 2, 'lab': 1}

The collections.Counter class does this in one line — Counter(d["site"] for d in devices) — but the explicit version is good control‑flow practice.

Show answer 3
def connect(host, username, password=None, port=22, secret=None):
    if not password:
        raise ValueError("password is required")
    return f"Connected to {host}:{port} as {username}"

print(connect("10.0.0.1", "admin", "Cisco123"))   # works
# connect("10.0.0.1", "admin")                    # raises ValueError

Default password=None plus an explicit check is friendlier than letting Python’s “missing positional argument” error fire, because the message names the actual missing field.

Show answer 4

net_utils.py:

def cidr_to_mask(prefix):
    bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
    return ".".join(str((bits >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))

day6.py:

from net_utils import cidr_to_mask

for p in (8, 16, 24, 30):
    print(f"/{p} = {cidr_to_mask(p)}")

Run with python day6.py:

/8 = 255.0.0.0
/16 = 255.255.0.0
/24 = 255.255.255.0
/30 = 255.255.255.252
Show answer 5

Add to net_utils.py:

if __name__ == "__main__":
    for p in (8, 16, 24, 30):
        print(f"/{p} = {cidr_to_mask(p)}")

Now python net_utils.py prints the four lines, but python day6.py only produces day6.py‘s output — the __main__ guard suppresses the self‑test on import. This is the standard Python idiom for “library file that’s also runnable for quick checks.”

Coming tomorrow

Day 7: Files, Exceptions, and Logging — Robust Scripts That Don’t Lie to You. Week 1 closes with the three things that turn a brittle script into a tool worth trusting: reading and writing files cleanly with with, catching errors without swallowing them, and using the logging module so a script reports what it actually did.