The first six days built up to a script that runs. Today closes out Week 1 with the three things that turn a script into a tool worth trusting against production gear: reading and writing files without leaking handles, catching errors without silently swallowing them, and using the logging module so runs leave a clear paper trail. The result is a small script that reads a device list from a file, logs per‑device outcomes to both console and a log file, and exits cleanly when something explodes.
This is Day 7 of the 21‑post Python for Network Engineers series — the last day of foundations before the network‑specific libraries begin next week.
Reading files with with open(...)
with open("hosts.txt") as f:
for line in f:
print(line.rstrip()) # rstrip removes the trailing \n
The with statement is a context manager. It guarantees the file is closed when the block exits — even if an exception fires partway through. It should always be used. The old f = open(...); f.close() pattern leaks file handles when something goes wrong in between, and on long‑running scripts that bites.
Iterating the file object directly (for line in f) is the right way to read large files — it streams one line at a time instead of loading the whole file into memory. f.read() reads the entire file as one string; f.readlines() returns a list of lines. Use read() for small config snippets, the iterator for log files.
Writing files
with open("backup.cfg", "w") as f: # "w" overwrites, "a" appends
f.write("hostname core-sw01\n")
f.write("interface Gi0/1\n description Uplink\n")
# Or write a list of lines in one call:
config_lines = ["vlan 10", "vlan 20", "vlan 30"]
with open("vlans.cfg", "w") as f:
f.write("\n".join(config_lines) + "\n")
The common modes: "r" read (default), "w" write (truncates), "a" append, "rb"/"wb" for binary. Adding encoding="utf-8" on Windows avoids surprises when writing anything other than pure ASCII.
pathlib — the modern way to handle paths
Concatenating path strings with / by hand is a habit worth dropping. The pathlib module provides Path objects that handle Windows vs. Linux separators, file existence, and a clean API:
from pathlib import Path
backup_dir = Path("backups")
backup_dir.mkdir(exist_ok=True) # like mkdir -p
target = backup_dir / "core-sw01.cfg" # joins with the right separator
target.write_text("hostname core-sw01\n") # one-liner write
print(target.exists(), target.stat().st_size)
Path objects play nicely with open(...) too — open(target) works without conversion. New code should default to pathlib over os.path.
Exceptions — catch what you mean
Things go wrong: a file doesn’t exist, a device times out, an API returns a 401, a key is missing. Python’s mechanism for those is the exception. The wrong way to handle them:
# Don't do this — swallows everything, including bugs worth seeing
try:
do_stuff()
except:
pass
The right way:
try:
with open("hosts.txt") as f:
hosts = [line.strip() for line in f if line.strip()]
except FileNotFoundError:
print("hosts.txt missing — create it with one IP per line")
sys.exit(1)
except PermissionError as e:
print(f"can't read hosts.txt: {e}")
sys.exit(1)
Catch specific exception types. Let unexpected ones bubble up with a real traceback so they can be fixed. The exception types that show up most in network code:
| Exception | When it fires |
|---|---|
FileNotFoundError |
Opening a path that doesn’t exist |
PermissionError |
Reading/writing a file without access |
KeyError |
Indexing a dict with a missing key |
ValueError |
A function got a value it can’t handle (e.g. int("abc")) |
TypeError |
Wrong type (e.g. "x" + 1) |
TimeoutError |
SSH/socket call ran out of time |
ConnectionError |
Network‑level failure |
The full try / except / else / finally shape:
try:
n = int(user_input)
except ValueError:
print("not a number")
else:
print(f"got {n}") # runs only if try succeeded
finally:
print("always runs") # cleanup, runs whether or not an exception fired
Most production code uses try / except. finally earns its keep when a resource must be released (close a session, drop a lock); else is rarer but useful for “do this only on success” without burying it in the try.
Raising custom exceptions
def parse_vlan(text: str) -> int:
try:
v = int(text)
except ValueError as e:
raise ValueError(f"VLAN must be an integer, got {text!r}") from e
if not 1 <= v <= 4094:
raise ValueError(f"VLAN {v} out of range 1..4094")
return v
raise ... from e chains the original exception so the traceback shows both — invaluable when wrapping someone else’s library and adding context without losing the root cause.
Logging — print() doesn’t scale
print() is fine in a run‑once script. The moment the goal becomes any of:
- see only WARNING and above on the console, but DEBUG in a file,
- add timestamps without writing them by hand,
- rotate log files so they don’t fill the disk,
- or let helpers in various modules log without each one knowing where the log goes,
…the standard library’s logging module is the answer. A minimal sane setup:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.FileHandler("backup.log"),
logging.StreamHandler(), # console
],
)
log = logging.getLogger("backup")
log.info("starting backup run")
log.warning("device timeout: 10.0.0.1")
log.error("backup failed: %s", "permission denied")
log.debug("this won't show — level is INFO")
Levels in increasing severity: DEBUG, INFO, WARNING, ERROR, CRITICAL. Set the level once, use the right level at each call site, and switching from “show me everything” to “show me only failures” later becomes a one‑line change.
Idiom: always pass log arguments as separate parameters (log.error("failed: %s", err)) rather than using f‑strings (log.error(f"failed: {err}")). The first form skips the string formatting entirely when the level is filtered out — a real win in tight loops.
Putting it all together
A script that reads a device list, “processes” each one with a fake function that occasionally fails, logs per‑device outcomes to both console and file, and exits with the right status code:
"""day7_backup.py — read hosts.txt, "back up" each one, log results."""
import logging
import random
import sys
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[logging.FileHandler("backup.log"), logging.StreamHandler()],
)
log = logging.getLogger("backup")
def fake_backup(host: str) -> str:
"""Pretend to fetch a config. Fails ~20% of the time."""
if random.random() < 0.2:
raise ConnectionError(f"timeout connecting to {host}")
return f"hostname {host}\ninterface Gi0/0\n no shutdown\n"
def main():
hosts_file = Path("hosts.txt")
try:
hosts = [h.strip() for h in hosts_file.read_text().splitlines() if h.strip()]
except FileNotFoundError:
log.error("%s not found — create it with one IP per line", hosts_file)
sys.exit(1)
backup_dir = Path("backups")
backup_dir.mkdir(exist_ok=True)
ok = fail = 0
for host in hosts:
try:
cfg = fake_backup(host)
except ConnectionError as e:
log.warning("%s: %s", host, e)
fail += 1
continue
(backup_dir / f"{host}.cfg").write_text(cfg)
log.info("%s: backed up", host)
ok += 1
log.info("done: %d ok, %d failed", ok, fail)
sys.exit(0 if fail == 0 else 2)
if __name__ == "__main__":
main()
Drop a hosts.txt with a few IPs in it, run python day7_backup.py, and the console shows timestamped per‑device results and so does backup.log, plus saved config files land in ./backups/. Replacing fake_backup with a real Netmiko call on Day 13 turns this into a first usable backup tool.
Exercises
- Write a snippet that reads
hosts.txt(create it first with a few IPs, one per line) and prints each non‑blank, stripped line. Usewith openand aforloop. - Use
pathlibto: create a directory./outif it doesn’t exist, then write the string"test\n"to./out/result.txt. Then printTrueorFalsefor whether the file exists. - Wrap
int(input("VLAN? "))in atry / exceptthat catchesValueError. On bad input, print"not a number"and exit cleanly with code 1. - Configure logging to write only to
app.logat DEBUG level (no console). Then issue onelog.debug, onelog.info, onelog.warning. Confirm all three appear inapp.log. - Stretch: modify the
day7_backup.pyexample so it retries up to 2 extra times onConnectionError(with a shorttime.sleepbetween attempts) before giving up. Log each retry asWARNINGand the final failure asERROR.
Answers
Show answer 1
with open("hosts.txt") as f:
for line in f:
line = line.strip()
if line:
print(line)
Strip first, then test for truthiness — empty strings (and the trailing newline) are filtered out.
Show answer 2
from pathlib import Path
out = Path("out")
out.mkdir(exist_ok=True)
target = out / "result.txt"
target.write_text("test\n")
print(target.exists()) # True
One method per line — no string concatenation, works the same on Linux and Windows.
Show answer 3
import sys
try:
vlan = int(input("VLAN? "))
except ValueError:
print("not a number")
sys.exit(1)
print(f"got VLAN {vlan}")
Only catch the specific exception that can be acted on. Letting other exceptions (Ctrl+C, etc.) propagate is a feature, not a bug.
Show answer 4
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler("app.log")],
)
log = logging.getLogger("app")
log.debug("a debug message")
log.info("an info message")
log.warning("a warning")
Passing only a FileHandler in handlers= suppresses console output. Open app.log and all three lines should be present, level‑tagged.
Show answer 5
import time
def backup_with_retry(host, attempts=3, delay=1):
for n in range(1, attempts + 1):
try:
return fake_backup(host)
except ConnectionError as e:
if n == attempts:
log.error("%s: giving up after %d tries (%s)", host, attempts, e)
raise
log.warning("%s: attempt %d failed (%s) — retrying in %ds", host, n, e, delay)
time.sleep(delay)
Then in main, replace the inner try body with cfg = backup_with_retry(host) and catch ConnectionError the same way. The pattern — finite retries with backoff and clear logging on each — is the spine of every robust network‑facing script.
That’s Week 1
The toolbox now holds: Python installed, a venv per project, the syntax, the four core containers, control flow, functions and modules, files, exceptions, and logging. That’s a complete enough foundation to write real scripts — and enough that the network‑specific libraries in the coming week read like English instead of magic.
Coming next week
Day 8: The ipaddress Module — Subnets, Supernets, and Host Iteration. Week 2 opens with the network‑specific library that gets used weekly. By the end: summarizing prefixes, generating host lists, and answering “is this address inside that subnet?” in two lines.