Building a Polished CLI Tool with Click and Rich: Packaging Network Automation for Other Humans
The script-to-tool gap
There is a particular kind of network automation script that exists in many places: it works, the author uses it daily, it lives in a Git repo somewhere, and nobody else has ever managed to run it. Always for the same reasons. The arguments are positional and undocumented. Configuration is hard-coded near the top of the file. Errors look like Python tracebacks. Output is a wall of text. There is no --help, no --version, no JSON mode for piping into other tools, and no way to install it that does not start with git clone.
This post is about closing that gap. The skill being taught is “take a working script and turn it into a tool”. The components are Click for the CLI surface, Rich for the output, environment variables and dotenv for configuration, and pyproject.toml plus pip install -e . for packaging. None of it is hard. The combination of all of it is what separates a script your team uses from a script your team avoids.
This is for network engineers who already write Python automation and want it to feel like a real tool. The techniques are general — they apply to any CLI, not just network automation — but the examples are network-flavoured.
Why Click
Python’s argparse is fine. It works, it ships with the standard library, and a small CLI written with it is perfectly reasonable. Click is what you reach for once you have more than one command, more than three options, or any subcommand structure. It is also dramatically more pleasant to read.
pip install click
The argparse version of “run a command across an inventory”:
import argparse
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="cmd", required=True)
audit = sub.add_parser("audit")
audit.add_argument("--inventory", required=True)
audit.add_argument("--commands", required=True)
audit.add_argument("--workers", type=int, default=10)
audit.add_argument("--out", default="results.csv")
apply_cfg = sub.add_parser("apply")
apply_cfg.add_argument("--inventory", required=True)
apply_cfg.add_argument("--config-file", required=True)
apply_cfg.add_argument("--dry-run", action="store_true")
args = p.parse_args()
if args.cmd == "audit":
do_audit(args.inventory, args.commands, args.workers, args.out)
elif args.cmd == "apply":
do_apply(args.inventory, args.config_file, args.dry_run)
The Click version:
import click
@click.group()
def cli():
"""Network automation tools."""
@cli.command()
@click.option("--inventory", required=True, type=click.Path(exists=True))
@click.option("--commands", required=True, type=click.Path(exists=True))
@click.option("--workers", default=10, show_default=True)
@click.option("--out", default="results.csv", show_default=True)
def audit(inventory, commands, workers, out):
"""Run show commands across an inventory and write a CSV."""
do_audit(inventory, commands, workers, out)
@cli.command()
@click.option("--inventory", required=True, type=click.Path(exists=True))
@click.option("--config-file", required=True, type=click.Path(exists=True))
@click.option("--dry-run/--apply", default=True)
def apply(inventory, config_file, dry_run):
"""Apply config from a file across an inventory."""
do_apply(inventory, config_file, dry_run)
if __name__ == "__main__":
cli()
A few things to notice. The docstrings become --help text. The decorators describe both the CLI surface and the function signature. Click validates that paths exist before your code runs. The --dry-run/--apply pattern gives you a default-safe boolean flag that makes the safe path the obvious one. The whole thing reads like documentation that happens to also be code.
netops --help prints the group help. netops audit --help prints the audit command’s help. Both are auto-generated from the decorators. There is no equivalent ergonomic gain in argparse.
Subcommand structure
A real network-automation tool grows a tree of subcommands. Click handles this with nested groups:
@click.group()
def cli(): ...
@cli.group()
def show():
"""Read-only operations."""
@cli.group()
def config():
"""Configuration changes (use with care)."""
@show.command(name="interfaces")
@click.option("--site")
def show_interfaces(site):
"""Show interface state across the inventory."""
...
@config.command(name="ntp")
@click.option("--dry-run/--apply", default=True)
def config_ntp(dry_run):
"""Apply NTP configuration."""
...
The CLI surface becomes:
netops show interfaces --site manchester
netops config ntp --apply
Verb-noun structure. Read operations under one group, write operations under another. Future you will appreciate the discipline.
Rich for output
Rich turns CLI output from “wall of text” into “thing humans can read”. It does tables, progress bars, syntax highlighting, JSON pretty-printing, and live-updating displays.
pip install rich
The dumbest thing it does is colour, and even that is enough to be worth installing:
from rich.console import Console
c = Console()
c.print("[green]OK[/green] csr1: configuration applied")
c.print("[red]FAIL[/red] csr2: connection timeout")
The actually useful thing is tables. Here is the result of an interface audit rendered as a Rich table:
from rich.table import Table
t = Table(title="Interfaces with CRC errors")
t.add_column("Host")
t.add_column("Interface")
t.add_column("CRC", justify="right", style="red")
for r in rows:
t.add_row(r["host"], r["intf"], str(r["crc"]))
c.print(t)
That is a CSV file’s worth of data, rendered cleanly in a terminal, with column alignment, a title, and red highlighting on the CRC column. It is also the right tool for almost every network automation report — the answer is usually a table.
For a long-running task, Rich’s progress bar is a one-liner:
from rich.progress import track
for device in track(devices, description="Polling devices..."):
do_work(device)
That replaces the print-a-dot-per-device pattern that everyone writes once and never refines. The bar updates in place, shows ETA, and looks professional with no extra effort.
A small note on dual-purpose output. Rich’s tables look beautiful in a terminal and useless when piped to a file. The clean pattern is to detect whether stdout is a TTY and switch output modes:
import sys
if sys.stdout.isatty():
# human is watching; render Rich table
c.print(t)
else:
# piped to a file or another tool; emit JSON or CSV
print(json.dumps(rows))
Or, more declaratively, give the user an explicit --output json|table|csv flag and let them choose. That is the right pattern for any tool that might be used both interactively and in pipelines.
Configuration and secrets
A working tool has a configuration model. The minimum viable one is “environment variables for secrets, command-line flags for behaviour, sensible defaults for everything else”. Most network-automation tools reach this complexity:
import os
from pathlib import Path
CONFIG = {
"inventory_path": os.environ.get("NETOPS_INVENTORY", "inventory/hosts.yaml"),
"username": os.environ.get("NETOPS_USERNAME", os.environ.get("USER")),
"password": os.environ.get("NETOPS_PASSWORD"),
"tacacs_secret": os.environ.get("NETOPS_TACACS_SECRET"),
"log_dir": Path(os.environ.get("NETOPS_LOG_DIR", "logs")),
}
For local development, a .env file plus python-dotenv is convenient:
pip install python-dotenv
from dotenv import load_dotenv
load_dotenv()
This loads .env into the environment when the program starts. Make sure .env is in .gitignore. Always.
For real production, secrets come from a secret manager (Vault, AWS Secrets Manager, Azure Key Vault). The tool should read them from environment variables — and the wrapper that runs the tool is responsible for fetching them from the secret manager and exporting them. This separation keeps the tool itself simple and lets you change secret backends without changing the code.
A good pattern: provide a --credentials-from {env,prompt,vault} flag that selects the credential source explicitly. Default to env. The prompt mode uses click.prompt(hide_input=True) to ask the user. The vault mode calls out to your secret backend.
@cli.command()
@click.option("--credentials-from", type=click.Choice(["env", "prompt", "vault"]), default="env")
def audit(credentials_from, ...):
creds = load_credentials(credentials_from)
...
Error handling that reads like a tool
Default Python error handling — print a stack trace and exit non-zero — is fine for development. It is hostile to colleagues who do not write Python.
The pattern that has worked for me:
class NetopsError(Exception):
"""User-facing error. The message is shown without a traceback."""
@click.group()
@click.option("--verbose", "-v", is_flag=True)
@click.pass_context
def cli(ctx, verbose):
ctx.obj = {"verbose": verbose}
def main():
try:
cli(standalone_mode=False)
except NetopsError as e:
click.echo(f"[red]Error:[/red] {e}", err=True)
sys.exit(1)
except Exception:
# In verbose mode, show the traceback. Otherwise, friendly message.
if "-v" in sys.argv or "--verbose" in sys.argv:
raise
click.echo("[red]Unexpected error.[/red] Run with -v for full trace.", err=True)
sys.exit(2)
Now NetopsError is the thing you raise for “the user did something wrong” and unexpected exceptions are caught with a friendly message that points to -v for the trace. The user does not see a Python traceback unless they ask for one.
A complete example
Pulling it together. The file netops/cli.py:
import csv, sys, os
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from rich.progress import track
from dotenv import load_dotenv
from .audit import collect_crc
from .config_ops import apply_config
load_dotenv()
console = Console()
@click.group()
@click.version_option(version="0.4.0")
def cli():
"""netops — network automation tools."""
@cli.group()
def show():
"""Read-only operations."""
@cli.group()
def config():
"""Configuration changes."""
@show.command(name="crc")
@click.option("--inventory", default=lambda: os.environ.get("NETOPS_INVENTORY"),
type=click.Path(exists=True), required=True)
@click.option("--site")
@click.option("--output", type=click.Choice(["table", "csv", "json"]), default="table")
def show_crc(inventory, site, output):
"""Audit interfaces for CRC errors across the inventory."""
rows = []
devices = load_inventory(inventory, site=site)
for d in track(devices, description="Polling..."):
try:
rows.extend(collect_crc(d))
except Exception as e:
console.print(f"[red]FAIL[/red] {d['host']}: {e}")
if output == "table":
t = Table(title="CRC errors")
for col in ("host", "intf", "crc"):
t.add_column(col, justify="right" if col == "crc" else "left")
for r in rows:
t.add_row(r["host"], r["intf"], str(r["crc"]))
console.print(t)
elif output == "csv":
w = csv.DictWriter(sys.stdout, fieldnames=["host", "intf", "crc"])
w.writeheader()
w.writerows(rows)
else:
import json
click.echo(json.dumps(rows, indent=2))
@config.command(name="ntp")
@click.option("--inventory", required=True, type=click.Path(exists=True))
@click.option("--site")
@click.option("--dry-run/--apply", default=True)
def config_ntp(inventory, site, dry_run):
"""Apply NTP configuration to all devices in the (filtered) inventory."""
devices = load_inventory(inventory, site=site)
for d in track(devices, description="Applying..." if not dry_run else "Previewing..."):
diff = apply_config(d, "ntp.cfg", dry_run=dry_run)
if diff:
console.print(f"[yellow]CHANGE[/yellow] {d['host']}")
console.print(diff)
else:
console.print(f"[green]OK[/green] {d['host']}: no change")
if __name__ == "__main__":
cli()
That is one file, around 80 lines, that gives you netops show crc --site manchester --output table, netops show crc --output csv > crc.csv, netops config ntp --dry-run, netops config ntp --apply, and a clean --help for every level of the command tree. It uses Rich for human-readable output, falls through to CSV/JSON for pipelines, takes credentials from environment variables, and reports failures cleanly without dumping tracebacks.
Packaging for pip install
Once the tool works, package it. The bar for “a colleague can install this” should be pip install -e ./netops (for a local checkout) or pip install netops (from a private package index). Anything more is friction that means people will not bother.
pyproject.toml:
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netops"
version = "0.4.0"
description = "Network automation tools."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"click>=8.1",
"rich>=13.0",
"netmiko>=4.0",
"napalm>=4.0",
"python-dotenv>=1.0",
]
[project.scripts]
netops = "netops.cli:cli"
[tool.setuptools.packages.find]
include = ["netops*"]
The [project.scripts] block is the magic. pip install reads it and creates a netops executable in the user’s PATH that calls netops.cli:cli. After installation, the user runs netops show crc from anywhere — they do not have to be in the source directory, they do not have to invoke python explicitly, and they do not have to set PYTHONPATH.
For development:
pip install -e .
The -e (editable) install means changes to the source code are immediately reflected without reinstalling. This is the right install mode for the author. Colleagues running stable versions install without -e.
Distribution
You have three reasonable options for getting the tool into colleagues’ hands:
-
A private Git repo. They run
pip install git+https://github.com/your-org/netops.git. Works, lightweight, requires Git access. Fine for small teams. -
A private PyPI index. Self-hosted with
pypiserverordevpi, or a managed one via Artifactory or similar. They runpip install netops --index-url https://your-pypi/. The right answer for an organisation that builds several internal tools. -
A pre-built artefact. A Docker image, a single-file executable via PyInstaller, or a
pipx-installed tool that includes its own venv. Useful when colleagues do not have Python or do not want to manage virtual environments.
pipx is worth special mention. It installs a CLI tool into its own isolated venv and exposes the commands globally. For colleagues who use Python tools but do not write Python, pipx install netops is the right answer. They get the tool without polluting their system Python and without learning anything about virtualenvs.
Testing the tool
Click has a test runner that does not require a real shell:
from click.testing import CliRunner
from netops.cli import cli
def test_show_crc_help():
runner = CliRunner()
result = runner.invoke(cli, ["show", "crc", "--help"])
assert result.exit_code == 0
assert "CRC errors" in result.output
def test_show_crc_missing_inventory():
runner = CliRunner()
result = runner.invoke(cli, ["show", "crc"])
assert result.exit_code != 0
assert "Inventory" in result.output or "inventory" in result.output
For tests that would actually hit a device, mock the connection at the boundary — patch ConnectHandler to return a fake object that yields canned output. The tests run fast, run in CI, and catch the kind of regression that was undetectable when the tool was a script.
A short list of patterns that have aged well
A few things I keep doing across projects.
Default to safe. Boolean flags should default to the non-destructive option. --dry-run/--apply defaults to dry-run. --force is opt-in. The path of least resistance is the safe path.
Make verbose tracing easy. A -v / --verbose flag at the top level is invaluable for debugging. Wire it through to your logger and to the exception handler.
Always have a --version. click.version_option does this for free. Future you debugging “what version of the tool was running on Wednesday” will thank you.
Idempotent reporting. Running the same audit twice should produce the same output (modulo timestamps). This is what makes diffs across runs meaningful.
Per-host failure isolation. One bad device should never take down the whole run. Trap exceptions at the per-device boundary, log them, and keep going.
JSON output for everything. Even if the human-facing output is a table, support --output json for piping into other tools. CLI composability is what makes a tool genuinely useful.
What this gets you
A pip install-able tool with a tab-completable CLI, sane help text, structured output for both humans and pipelines, environment-loaded secrets, and a packaging story your colleagues can actually follow. Adding a new command is a single decorator and a function. Adding a new output format is a few lines. Adding a new option is a single decorator argument. The maintenance cost is low and the usefulness compounds.
That is the end of this five-post Python-for-network-engineers series. The arc was deliberate: Netmiko for the basics, parsing for structure, NAPALM for vendor abstraction, Nornir for scale, Click and Rich for the part where it stops being a personal script and starts being a tool other people use. Each layer plugs into the previous one. None of them require throwing away what you already have. Done well, the result is a small library of tools that grows with you and stays useful for years.