subprocess: Wrapping ping, traceroute, and nslookup in Python

Some tools you should not reimplement. ping, traceroute, nslookup, dig — they are battle-tested, they live on every box, and you already trust their output. Python’s subprocess module lets you run them from inside a script, capture what they print, check whether they succeeded, and react. That is the bridge between your shell muscle memory and real automation.

This is also the most dangerous module so far, because running external commands carelessly is how scripts get command-injection bugs. We will do it the safe way from the first example.

subprocess.run: The One Function to Learn

Forget the older os.system and os.popen. Modern Python has one front door: subprocess.run().

import subprocess

result = subprocess.run(
    ["ping", "-c", "3", "8.8.8.8"],   # command as a LIST, not a string
    capture_output=True,               # grab stdout and stderr
    text=True,                         # decode bytes to str for us
    timeout=10,                        # never hang forever
)

print("Return code:", result.returncode)   # 0 means success
print("STDOUT:\n", result.stdout)
print("STDERR:\n", result.stderr)

Four habits to lock in, visible in that one call:

  • Command as a list. ["ping", "-c", "3", target] — never one big string. This is the single most important safety rule (next section).
  • capture_output=True so you get the text instead of it scrolling past.
  • text=True so stdout is a str, not raw bytes.
  • timeout so a black-holed host cannot freeze your run forever.

The returncode Is Your Pass/Fail

Unix convention: 0 means success, anything else means failure. ping follows it — reachable host gives 0, unreachable gives non-zero. That makes a reachability check trivial:

import subprocess

def is_reachable(host, count=2, timeout=5):
    result = subprocess.run(
        ["ping", "-c", str(count), "-W", "1", host],
        capture_output=True, text=True, timeout=timeout,
    )
    return result.returncode == 0

for host in ["8.8.8.8", "192.0.2.1"]:   # second is TEST-NET, won't answer
    status = "UP" if is_reachable(host) else "DOWN"
    print(f"{host:15} {status}")

Note str(count) — every element of the command list must be a string. Passing an int raises a TypeError.

Why the List Form Matters: Shell Injection

You will see examples online using shell=True with a single string. Here is why that is a trap:

# DANGEROUS — do not do this with untrusted input
host = "8.8.8.8; rm -rf ~"          # imagine this came from a CSV
subprocess.run(f"ping -c 1 {host}", shell=True)   # runs the rm too!

# SAFE — the list form treats host as a single argument, always
subprocess.run(["ping", "-c", "1", host])         # ping just fails, nothing deleted

With the list form there is no shell to interpret ;, |, or $(), so a malicious or malformed value can only ever be a (bad) argument to ping. Default to the list form. Only use shell=True when you genuinely need shell features and fully control the input.

check=True and Catching Failures

If you would rather have a failed command raise an exception (like everything else in Python does on error), pass check=True and catch CalledProcessError:

import subprocess

try:
    subprocess.run(["traceroute", "-m", "5", "192.0.2.1"],
                   capture_output=True, text=True,
                   timeout=15, check=True)
except subprocess.CalledProcessError as e:
    print(f"Command failed (rc={e.returncode})")
except subprocess.TimeoutExpired:
    print("Command timed out")

This pairs perfectly with the exception handling from Week 1. TimeoutExpired and CalledProcessError are the two you will catch most.

Cisco Context: A Mini Reachability Sweep with DNS

Combine ping and nslookup to sweep a device list, resolve names, and flag anything down — the kind of pre-change sanity check you run before a maintenance window.

import subprocess

def resolve(name):
    try:
        out = subprocess.run(["nslookup", name], capture_output=True,
                             text=True, timeout=5).stdout
        for line in out.splitlines():
            if line.startswith("Address:") and not line.endswith("#53"):
                return line.split()[-1]
    except subprocess.TimeoutExpired:
        return None
    return None

def reachable(host):
    return subprocess.run(["ping", "-c", "2", "-W", "1", host],
                          capture_output=True, text=True).returncode == 0

devices = ["dns.google", "one.one.one.one"]
for d in devices:
    ip = resolve(d)
    state = "UP" if (ip and reachable(ip)) else "DOWN/UNRESOLVED"
    print(f"{d:20} {str(ip):16} {state}")

A Note on Live vs. Captured Output

Sometimes you want output to stream to your terminal in real time (a long traceroute) rather than be captured. Just omit capture_output=True — the child process inherits your terminal and you see output as it happens. You lose programmatic access to the text, so pick based on whether your script needs to read the output or just show it.

Exercises

  1. Warm-up. Run ping -c 1 against 127.0.0.1 and print only the return code.
  2. Reachability. Write ping_count(host) that returns how many of 4 pings succeeded by parsing the summary line (hint: look for "received" in the output).
  3. Safe wrapper. Write run_cmd(args_list) that runs any command safely with a 10-second timeout and returns a tuple (ok, stdout, stderr) where ok is a bool. Make sure a timeout returns (False, "", "timeout") instead of crashing.
  4. Sweep. Given a list of 5 IPs, ping each and print a two-column report of address and UP/DOWN, then print a final count of how many were up.
  5. Challenge. Run traceroute to a host and extract just the ordered list of hop IP addresses (skip the * * * timeouts). Reuse your regex skills from yesterday.

Answers

Show answers

1. Warm-up

import subprocess
r = subprocess.run(["ping", "-c", "1", "127.0.0.1"],
                   capture_output=True, text=True)
print(r.returncode)   # 0

2. Reachability count

import re, subprocess
def ping_count(host):
    out = subprocess.run(["ping", "-c", "4", "-W", "1", host],
                         capture_output=True, text=True).stdout
    m = re.search(r"(\d+) received", out)
    return int(m.group(1)) if m else 0

print(ping_count("127.0.0.1"))   # 4

3. Safe wrapper

import subprocess
def run_cmd(args_list):
    try:
        r = subprocess.run(args_list, capture_output=True,
                           text=True, timeout=10)
        return (r.returncode == 0, r.stdout, r.stderr)
    except subprocess.TimeoutExpired:
        return (False, "", "timeout")
    except FileNotFoundError:
        return (False, "", "command not found")

print(run_cmd(["echo", "hello"]))   # (True, 'hello\n', '')

Catching FileNotFoundError too is a nice touch — it fires when the binary is not installed.

4. Sweep

import subprocess
ips = ["127.0.0.1", "8.8.8.8", "192.0.2.1", "192.0.2.2", "1.1.1.1"]
up = 0
for ip in ips:
    ok = subprocess.run(["ping", "-c", "1", "-W", "1", ip],
                        capture_output=True).returncode == 0
    print(f"{ip:15} {'UP' if ok else 'DOWN'}")
    up += ok
print(f"{up}/{len(ips)} up")

up += ok works because True is 1 and False is 0 in Python — a clean way to count booleans.

5. Challenge

import re, subprocess
out = subprocess.run(["traceroute", "-m", "10", "8.8.8.8"],
                     capture_output=True, text=True, timeout=30).stdout
hops = []
for line in out.splitlines()[1:]:          # skip header line
    m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)", line)
    if m:
        hops.append(m.group(1))
print(hops)

traceroute prints each responding hop’s IP in parentheses; the regex grabs exactly those and naturally skips * * * lines that have no address.


Previously: Regex with re. Coming tomorrow — Socket programming: building a tiny TCP port reachability checker so you stop reaching for telnet host 443.

Leave a Reply