Linux+ Python Basics for Automation
- 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
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
cronjob or CI pipeline can trust.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 python3instead of a hard-coded/usr/bin/python3? Becauseenvlooks uppython3on the user'sPATH, 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 syspulls in the standard-library module that ownssys.exit. The script prints a message and then callssys.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 to1, and$?confirms it. Tocron, CI,make, or a&&chain, 0 means success and any non-zero means failure — and that contract is identical to a Bashexitcode. 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?
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, afor, 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 printis indented 4 spaces under thefor, so it is the loop body. There is nodoneto 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?
- A `list` is an ordered sequence:
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.runreturns a result object.r.returncodeis the command's exit code (your$?, now a Python int you can branch on);r.stdoutis 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.loadsturns a JSON string straight into Python lists and dicts, noawkrequired. 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 theifnamekey out of each interface dict. This is the payoff: no fragilegrep | awkpipeline — 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?
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/cutis 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?
- Structured data — parsing JSON/CSV, walking nested config, or anything where
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
python3installed. 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.versionis a long string;.split()[0]takes just the version number (e.g.3.11.2). This is the sameimport sysyou used forsys.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 withlen(). 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 -ras an argument list (noshell=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.
- 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. listis ordered (index by position),dictis 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; avoidshell=Truewith interpolated input, which is command injection. Parse structured output withjson.loads. - Use Bash for glue and pipelines; reach for Python the moment you need structured data, real error handling, or non-trivial logic.
| Bash | Python | Notes |
|---|---|---|
#!/bin/bash | #!/usr/bin/env python3 | Shebang; env keeps it portable across machines |
exit 1 | sys.exit(1) | Non-zero = failure; identical contract to cron/CI |
$? | r.returncode | Last 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 array | d = {"k": "v"} | Key/value dict; index by key d["k"] |
do … done / { } | 4-space indentation | Whitespace defines the block — no closing keyword |
out=$(cmd) | subprocess.run([...], capture_output=True, text=True) | Argument list, no shell=True |
cmd | jq .x | json.loads(r.stdout)["x"] | Parse structured output into real objects |
