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))
- Positional —
host, 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:
from net_utils import normalize_mac, filter_by_site— pull specific names out of the module.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.- 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 8re— Day 9subprocess— Day 10socket— Day 11json,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
- Write a function
cidr_to_mask(prefix)that takes an integer like24and returns the dotted netmask string"255.255.255.0". Skipipaddress— do it with bit math and string ops, just to flex. - Write
summarize_devices(devices)that takes a list of device dicts (each with at least asitekey) and returns a dict mapping site → count. Example:summarize_devices([{"site": "hq"}, {"site": "hq"}, {"site": "lab"}]) == {"hq": 2, "lab": 1}. - Add a
connect(host, username, password, port=22, secret=None)function. When the caller supplies no password, raiseValueError("password is required"). - Move the function from exercise 1 (
cidr_to_mask) into a file callednet_utils.py. From a separate fileday6.py, import it and call it for prefixes 8, 16, 24, 30. Print each on its own line. - Stretch: add a
__main__block tonet_utils.pythat runscidr_to_maskfor prefixes 8, 16, 24, 30 as a self‑test, but ensure that block does not run whenday6.pyimports 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.