Signal9Signal9 — home
online
all lessons

Linux+ Python Basics for Automation

Beginner30 min6 steps
What you'll learn
  • Write a runnable Python script with the correct shebang and signal failure with sys.exit so cron and CI notice
  • Use Python's two workhorse data types — list and dict — and read why 4-space indentation defines a block
  • Run system commands safely with subprocess.run and an argument list, and parse their output with json.loads
  • Decide when a job belongs in Bash and when it has outgrown Bash and belongs in Python
  1. Why a Bash person should reach for Python

    Frame Python as the next tool up when a Bash script starts fighting you.

    You already automate with Bash, and for stringing commands together with pipes it is unbeatable. But every Bash veteran hits the same wall: the moment a script needs to hold structured data, handle an error gracefully, or make a real decision, the code turns into a thicket of awk, cut, sed, and brittle string-mangling.

    Think of it like the kitchen drawer of tools. Bash is the chef's knife — fast, always within reach, perfect for chopping one thing into the next. Python is the food processor you pull out when the knife is no longer the right tool: when you have a pile of structured ingredients to process the same way and you want the result to be reliable, not improvised.

    This lesson is not "learn Python." It is the minimum Python a sysadmin needs to write automation that a cron job or CI pipeline can trust.

  2. Your first script: shebang and exit codes

    Write a runnable Python script and make it report success or failure correctly.

    A Python script begins exactly like a Bash script: with a shebang line telling the kernel which interpreter to run the file with. For Bash you wrote #!/bin/bash. For Python the portable form is:

    `

    #!/usr/bin/env python3

    `

    Why /usr/bin/env python3 instead of a hard-coded /usr/bin/python3? Because env looks up python3 on the user's PATH, so the script works whether Python lives in /usr/bin, a virtualenv, or /opt. Hard-coding the path is the thing that breaks the same script on the next machine.

    Example — not runnable on your lab box

    cat check.py
    #!/usr/bin/env python3
    import sys
    
    free_pct = 8
    if free_pct < 10:
        print(f"disk low: {free_pct}% free")
        sys.exit(1)
    
    print("disk ok")
    The shebang names the interpreter. import sys pulls in the standard-library module that owns sys.exit. The script prints a message and then calls sys.exit(1) — a non-zero exit when the disk is low.
    chmod +x check.py; ./check.py; echo "exit=$?"
    disk low: 8% free
    exit=1
    chmod +x
    Set the execute bit so the file can run as ./check.py (same as for a Bash script).
    $?
    The shell's last-exit-status variable — the Bash habit you already have, used here to read Python's exit code.
    This is the punchline: sys.exit(1) set the process exit code to 1, and $? confirms it. To cron, CI, make, or a && chain, 0 means success and any non-zero means failure — and that contract is identical to a Bash exit code. Your existing exit-code intuition transfers directly.

    Check yourself

    A scheduled Python script detects a failure, prints "ERROR: backup failed", but has no sys.exit call. What exit code does cron see, and does it consider the run a success or failure?

  3. Data types: list, dict, and the indentation rule

    Hold real data in the two core types and read why whitespace defines a block.

    Bash variables really only hold text (and arrays, awkwardly). Python's whole advantage starts here: it has proper data types, and two carry most automation work.

    • A `list` is an ordered sequence: hosts = ["web1", "web2", "db1"]. Think of it as a numbered guest list — order matters, and you reach items by position (hosts[0] is "web1").
    • A `dict` (dictionary) maps keys to values: svc = {"name": "nginx", "port": 80}. Think of it as a labelled filing cabinet — you fetch a value by its key (svc["port"]), not by position.
    python3 -c 'hosts = ["web1", "web2", "db1"]; svc = {"name": "nginx", "port": 80}; print(hosts[0], "->", svc["port"])'
    web1 -> 80
    -c
    Run the Python code given as a string argument, instead of from a file — Python's equivalent of bash -c.
    hosts[0] pulls the first list element by position; svc["port"] pulls a value by its key. That is the practical difference: lists are addressed by where, dicts by what. Almost every config, API response, or command result you automate is some nesting of these two.

    Now the rule that surprises every Bash refugee: Python has no `do`/`done`, `then`/`fi`, or `{ }`. A block — the body of an if, a for, a function — is defined purely by indentation, conventionally 4 spaces. Where Bash uses keywords to close a block, Python uses the indent level itself.

    python3 -c 'for h in ["web1", "web2"]:
        print("checking", h)'
    checking web1
    checking web2
    The print is indented 4 spaces under the for, so it is the loop body. There is no done to close it — out-denting back to the left would end the loop. The colon : opens the block; the indentation is the block.

    Check yourself

    You need to fetch the value 80 from svc = {"name": "nginx", "port": 80}. Do you address it by position or by key, and what is the exact expression?

  4. Run system commands safely with subprocess

    Shell out from Python without opening a command-injection hole, then parse structured output.

    The reason you shell out from Python at all is to keep using the Linux tools you already know — systemctl, ip, df — while doing the logic in Python. The standard way is `subprocess.run`.

    The single most important habit: pass the command as an argument list, not a string. subprocess.run(["systemctl", "is-active", "nginx"]) hands the kernel an explicit list of arguments with no shell in between. There is nothing to interpret ;, |, $(), or &&, so untrusted input cannot break out and run a second command.

    python3 -c 'import subprocess; r = subprocess.run(["systemctl", "is-active", "nginx"], capture_output=True, text=True); print("rc:", r.returncode); print("out:", r.stdout.strip())'
    rc: 0
    out: active
    ["systemctl", "is-active", "nginx"]
    The command as a list of separate arguments — no shell parses it, so injection has no surface.
    capture_output=True
    Capture the command's stdout and stderr onto the result object instead of letting them print.
    text=True
    Decode output as a str (text) rather than raw bytes — so you can call .strip() and compare it directly.
    subprocess.run returns a result object. r.returncode is the command's exit code (your $?, now a Python int you can branch on); r.stdout is its captured output. .strip() trims the trailing newline. Now the Bash command's result is data your Python logic can act on.

    When a command can emit JSON, take it — structured output beats screen-scraping. json.loads turns a JSON string straight into Python lists and dicts, no awk required. Many Linux tools support --json / -j / -o json (here, ip -j addr).

    python3 -c 'import subprocess, json; r = subprocess.run(["ip", "-j", "addr"], capture_output=True, text=True); data = json.loads(r.stdout); print([i["ifname"] for i in data])'
    ['lo', 'eth0']
    ip -j addr
    Ask the ip command for JSON output (-j) instead of human-formatted text.
    json.loads
    Parse a JSON string into native Python objects — here a list of dicts, one per interface.
    json.loads(r.stdout) gives a list of dicts, exactly the two types from the previous step. [i["ifname"] for i in data] is a list comprehension that pulls the ifname key out of each interface dict. This is the payoff: no fragile grep | awk pipeline — you index real data by name.

    Check yourself

    Why is subprocess.run(["systemctl", "is-active", name]) safe against injection even when name comes from an untrusted user, while subprocess.run(f"systemctl is-active {name}", shell=True) is not?

  5. The dividing line: Bash or Python?

    Decide which tool fits a task before you start writing it.

    Now the judgement call this whole lesson exists to teach. Neither language wins outright; they win at different jobs.

    Reach for Bash when the task is glue: chaining commands, moving files, a short pipeline, anything that is mostly running other programs in sequence. tar czf backup.tar.gz /data && scp backup.tar.gz host: should never be a Python script.

    Reach for Python the moment you need one of these three:

    • Structured data — parsing JSON/CSV, walking nested config, or anything where awk/cut is starting to nest.
    • Real error handling — try/except, retries, distinguishing kinds of failure rather than just checking $? after every line.
    • Non-trivial logic — real loops over data, conditionals with several branches, functions, arithmetic beyond integer counting.

    Check yourself

    You must call a cloud API that returns a JSON list of servers, keep only the ones tagged "prod", and email a report — with retries if the API call fails. Bash or Python, and which one of the three triggers makes the call?

  6. Try it in your live terminal

    Do it, don't just read it — run these on the real shell to your right.

    You have a real shell with python3 installed. Run these one-liners in order. Each maps to one concept from the lesson — interpreter, data types, and a safe subprocess call.

    python3 -c 'import sys; print(sys.version.split()[0])'
    Confirm Python 3 is present and see its exact version. sys.version is a long string; .split()[0] takes just the version number (e.g. 3.11.2). This is the same import sys you used for sys.exit.
    python3 -c 'hosts = ["web1", "web2", "db1"]; svc = {"name": "nginx", "port": 80}; print("first host:", hosts[0]); print("port:", svc["port"]); print("hosts:", len(hosts))'
    A list and a dict in one line: index the list by position (hosts[0]), the dict by key (svc["port"]), and count the list with len(). These two types are the backbone of every automation script you will write.
    python3 -c 'import subprocess; r = subprocess.run(["uname", "-r"], capture_output=True, text=True); print("kernel:", r.stdout.strip(), "(rc", r.returncode, end=")\n")'
    A safe subprocess call: run uname -r as an argument list (no shell=True), capture its output as text, and print the kernel release plus the return code. This is the exact pattern you would wrap real logic around in a script.
Recap
  • Start a script with #!/usr/bin/env python3 (env finds python3 on PATH). sys.exit(non-zero) signals failure to cron/CI exactly like a Bash exit code — printing an error is not the same as failing.
  • list is ordered (index by position), dict is key/value (index by key). Blocks are defined by 4-space indentation, not braces — a bad indent or mixed tabs/spaces is a syntax error, not a style nit.
  • Run commands with subprocess.run([...], capture_output=True, text=True) using an argument list; avoid shell=True with interpolated input, which is command injection. Parse structured output with json.loads.
  • Use Bash for glue and pipelines; reach for Python the moment you need structured data, real error handling, or non-trivial logic.
Bash idiom → Python equivalent
BashPythonNotes
#!/bin/bash#!/usr/bin/env python3Shebang; env keeps it portable across machines
exit 1sys.exit(1)Non-zero = failure; identical contract to cron/CI
$?r.returncodeLast exit status, now an int you can branch on
arr=(a b c)lst = ["a", "b", "c"]Ordered list; index by position lst[0]
assoc arrayd = {"k": "v"}Key/value dict; index by key d["k"]
do … done / { }4-space indentationWhitespace defines the block — no closing keyword
out=$(cmd)subprocess.run([...], capture_output=True, text=True)Argument list, no shell=True
cmd | jq .xjson.loads(r.stdout)["x"]Parse structured output into real objects