So far every example has been a few lines of straight‑line code. That’s fine in the REPL but unmaintainable the moment you have a real script. Today we extract repeated logic into functions, then move those functions into their own file — a module — that other scripts can import. By the end of today you’ll have 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 are.
If you don’t return anything, the function returns None. That’s fine for functions whose job is a side effect (printing, writing a file, sending a packet) — but if the caller is going to use the result, return it.
Parameter kinds you actually need
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: caller can omit them. - You can pass any positional as a keyword, which makes call sites self‑documenting. Prefer keyword args at call sites when there are 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
You’ll see this 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 — your YAML inventory becomes a Python dict becomes function arguments without you doing any glue.
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 your 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. Add them to anything you’ll 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. Create a folder:
~/python-net/
├── .venv/
├── net_utils.py
└── inventory_runner.py
net_utils.py:
"""Reusable helpers for our 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 in your 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 your own, check what ships with Python. The ones we’ll use this series:
ipaddress— Day 8re— Day 9subprocess— Day 10socket— Day 11json,logging,pathlib,os,sys,collections,itertools,datetime— used throughout
You import them 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". Don’t importipaddress— 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. If the caller doesn’t supply a 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 make sure 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 but cleaner — we’ll see that on 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 practice for control flow.
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 runs 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. We close out Week 1 with the three things that turn a brittle script into a tool you trust: reading and writing files cleanly with with, catching errors without swallowing them, and using the logging module so your script tells you what it actually did.