Bash or Python?¶
Level 2 · Chapter 6 · ⏱️ ~40 min read · Prerequisites: Arguments and getopts, basic Python
Bash is superb at gluing programs together and terrible at almost everything else. This chapter gives you a practical rule for choosing, rewrites one realistic task in both languages side by side, shows how to run shell commands from Python without opening security holes, and how to call Python from shell scripts.
Why it matters¶
Alex's export.sh started as 15 lines: dump a table, gzip it, copy it to a share. Over a year it grew to 600 lines. It now parses CSV with IFS=,, does money math with bc, builds JSON with printf, keeps state in four associative arrays, and has a retry loop with exponential backoff.
Then a customer named "Doe, Alex" shows up in the data. The CSV parsing splits the name in two, every column after it shifts by one, and the revenue report is wrong for a week before anyone notices. Fixing it properly in bash means writing a CSV parser in bash.
In Python, the fix is import csv. The real mistake wasn't the bug. It was not noticing, around line 150, that the script had stopped being glue and become a program. This chapter teaches you to notice.
Concepts¶
What bash is good at¶
Bash is a glue language. Its job is to start other programs and connect them. It's the best tool available when the work is mostly:
- Running commands in sequence with simple checks between them: stop a service, copy files, start it again.
- Connecting programs with pipes and redirections:
grep | sort | uniq -c. Nothing else expresses this as compactly. - Working with files and processes at the OS level: globbing, permissions, environment variables, exit codes, signals.
- Running anywhere with zero setup. Bash exists on every Linux machine, in every container image with a shell, and in every CI runner. No packages, no virtual environment.
- Wrapping a tool with a few defaults or checks: a five-line wrapper around
rsyncorpg_dump. - Bootstrapping: install scripts, entrypoints, and the first thing that runs before Python is even available.
The examples in this level (backups, log searches, file sorting, locking) are bash's sweet spot.
Where bash hurts¶
Bash's design is "everything is a string, and words split on spaces." That's great for command lines and painful for data:
| Need | Bash | Python |
|---|---|---|
| Decimal math | Integers only. Shell out to bc or awk |
Built in, plus decimal for money |
| Structured data (JSON, YAML, nested records) | Strings and flat arrays. Needs jq |
dict, list, json, yaml |
| Real CSV (quoted fields, embedded commas, newlines) | Easy to get subtly wrong | csv module |
| Error handling | Exit codes, set -e with many exceptions |
Exceptions with tracebacks |
| Functions returning data | Print to stdout and capture | return any object |
| Testing | Possible (bats), but awkward | pytest, built-in unittest |
| Libraries (HTTP, databases, cloud APIs, dates) | External tools: curl, psql, date |
Huge standard library, plus PyPI |
| Readability at 500+ lines | Poor | Good |
| Performance in loops | Slow: every external command is a new process | Fast enough for most jobs |
| Portability between machines | bash version, GNU vs BSD tools | Python version, packages |
The performance row deserves a note. Every external command (sed, cut, date, stat) costs a fork() and an exec() (Chapter 1). That's around a millisecond each. A loop that runs three external commands for each of 100,000 lines takes minutes. The same loop in Python, or as one awk program, takes about a second.
A rule of thumb¶
Switch from bash to Python (or another general-purpose language) when any of these become true:
- The script is past roughly 100–200 lines, or growing steadily.
- You need data structures beyond a flat list or a simple key-value map: records, nesting, lists of lists.
- You're parsing structured formats: CSV with quoting, JSON, XML, YAML, or HTML.
- You need floating-point or decimal math.
- You need real error handling: retries with different behavior per error type, partial failures, or cleanup that depends on what failed.
- You call APIs, databases, or cloud SDKs with more than a single
curlorpsqlcommand. - Other people need to maintain and test it.
- You keep writing
while readloops that call external commands on every line.
And stay in bash when the script is mostly a list of commands, especially when it must run before Python exists (installers, container entrypoints) or is a thin wrapper around one tool.
flowchart TD
A["New script"] --> B{"Mostly running other<br/>commands and pipes?"}
B -- no --> P["Python"]
B -- yes --> C{"Parsing JSON/CSV/YAML,<br/>decimals, or nested data?"}
C -- yes --> P
C -- no --> D{"Under ~150 lines and<br/>likely to stay small?"}
D -- yes --> S["Bash"]
D -- no --> P
The middle ground is fine too. A bash script can call one small Python snippet for the hard part, and a Python program can call rsync for the part rsync does best. The examples cover both directions.
Python's tools that replace bash habits¶
When you move a script to Python, four standard-library modules cover almost everything bash was doing:
| Bash habit | Python module | What it gives you |
|---|---|---|
Running commands, $?, $( ) |
subprocess |
subprocess.run([...], check=True, capture_output=True, text=True) |
Paths, globs, [[ -f ]], stat, ${f%.csv} |
pathlib |
Path("x").glob("*.csv"), .is_file(), .stat(), .stem, .suffix |
getopts, usage(), validation |
argparse |
Short and long options, types, choices, defaults, and -h generated for you |
log() with timestamps, VERBOSE |
logging |
Levels, formats, timestamps, and output to stderr or files |
subprocess.run deserves a closer look, since it's the bridge between the two worlds:
| Argument | Meaning | Bash equivalent |
|---|---|---|
["gzip", "--", path] |
The command as a list: program plus arguments, no shell involved | gzip -- "$path" with perfect quoting |
check=True |
Raise CalledProcessError if the exit status isn't 0 |
set -e, but reliable |
capture_output=True |
Collect stdout and stderr instead of printing them | out=$(cmd 2>err) |
text=True |
Decode output to str instead of bytes |
|
timeout=30 |
Kill the command and raise TimeoutExpired after 30 s |
timeout 30 cmd |
cwd="/srv", env={...} |
Working directory and environment for the child | (cd /srv && VAR=x cmd) |
result .returncode, .stdout, .stderr |
What happened | $?, captured output |
Calling the shell from Python safely¶
subprocess can run a command in two ways:
- A list of arguments, no shell (the default):
subprocess.run(["wc", "-l", filename]). Python passes the list straight to the kernel'sexecve(). Each element becomes exactly one argument. There is no word splitting, no globbing, and no interpretation of;,|,$( ), or quotes. The file name can contain anything and stays one argument. - A string, with
shell=True:subprocess.run(f"wc -l {filename}", shell=True). Python starts/bin/sh -c "wc -l ...", and the shell parses the string with all its rules. Anything insidefilenamethat the shell finds meaningful gets interpreted.
The second form is a command injection vulnerability whenever any part of the string comes from outside your code: a file name, a user's input, a web form, a database row, an environment variable. A file named report.csv; rm -rf ~ is perfectly legal on Linux.
flowchart LR
subgraph safe["List, no shell"]
L["['wc','-l', name]"] --> K1["execve(wc)<br/>argv = wc, -l, name"]
end
subgraph unsafe["String, shell=True"]
S["f'wc -l {name}'"] --> SH["/bin/sh parses<br/>; | $() * ..."] --> K2["may run ANY<br/>command in name"]
end
The rules:
- Use the list form. It's also faster (no extra shell process) and avoids quoting puzzles.
- Never use
shell=Truewith any untrusted input. - If you genuinely need shell features (a pipeline, a glob, redirection), prefer doing that part in Python:
Path.glob()for globs,open()for redirection,subprocess.PIPEbetween twoPopencalls for pipes. - If you must build a shell string, escape every outside value with
shlex.quote(), which wraps it in single quotes safely. Treat that as a last resort. - Remember that
/bin/shon Mint is dash. Bash syntax in ashell=Truestring fails.
The same idea applies the other way: never build a command by gluing strings together and then eval it in bash.
Calling Python from the shell¶
Python fits into shell pipelines like any other command:
python3 -c 'code' args...for one-liners. The arguments arrive insys.argv[1:].python3 - args... <<'EOF'followed by code andEOF, for longer inline code. The-means "read the program from stdin," and the quoted heredoc stops bash from expanding$inside the Python code.- A separate
.pyfile once the snippet grows past a dozen lines, so it can be tested and linted. python3 -m modulefor useful built-in tools:python3 -m json.toolpretty-prints JSON, andpython3 -m http.serverserves the current directory.
The contract between them is the usual Unix one: arguments in, stdout out, stderr for messages, exit status for success. sys.exit(n) sets the exit status. An uncaught exception prints a traceback to stderr and exits with status 1, so set -e in the calling bash script stops correctly.
Pass data as arguments or stdin, never by pasting bash variables into the Python source text. python3 -c "print('$name')" breaks the moment $name contains a quote, and it's the same injection problem as shell=True.
For scripts with third-party dependencies, give each tool a virtual environment (an isolated folder with its own Python packages, created with python3 -m venv) and point the shebang at that environment's interpreter: #!/home/alex/.venvs/tools/bin/python. On Mint, pip install into the system Python is blocked by design. Level 3 covers package installation.
Commands and examples¶
The task: compress old CSV exports¶
A directory receives daily CSV exports. Files older than N days should be gzipped to save space. The tool needs a dry-run mode, a verbose mode, timestamped logs on stderr, a summary of space saved, and it must handle file names with spaces.
The test directory:
total 1624
-rw-rw-r-- 1 alex alex 3 Aug 13 11:00 notes.txt
-rw-rw-r-- 1 alex alex 328894 Aug 23 11:00 orders-2026-09-01.csv
-rw-rw-r-- 1 alex alex 328894 Sep 12 11:00 orders-2026-09-15.csv
-rw-rw-r-- 1 alex alex 328894 Oct 2 11:00 orders-2026-10-01.csv
-rw-rw-r-- 1 alex alex 328894 Sep 23 11:00 q3 summary.csv
-rw-rw-r-- 1 alex alex 328894 Sep 20 11:00 users-2026-09-20.csv
The bash version¶
#!/usr/bin/env bash
#
# compress-old.sh - gzip CSV files older than N days in a directory.
#
set -euo pipefail
readonly PROG=${0##*/}
days=7 dry_run=0 verbose=0
log() { printf '%(%Y-%m-%d %H:%M:%S)T %-5s %s\n' -1 "$1" "$2" >&2; }
die() { log ERROR "$*"; exit 1; }
usage() { echo "Usage: $PROG [-d DAYS] [-n] [-v] DIR"; }
while getopts ":d:nvh" opt; do
case $opt in
d) days=$OPTARG ;;
n) dry_run=1 ;;
v) verbose=1 ;;
h) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
(( $# == 1 )) || { usage >&2; exit 2; }
dir=$1
[[ $days =~ ^[0-9]+$ ]] || die "-d must be a non-negative integer"
[[ -d $dir ]] || die "not a directory: $dir"
count=0 saved=0
while IFS= read -r -d '' file; do
before=$(stat -c %s -- "$file")
if (( dry_run )); then
log INFO "would compress $file ($before bytes)"
else
gzip -- "$file" || die "gzip failed on $file"
after=$(stat -c %s -- "$file.gz")
saved=$(( saved + before - after ))
if (( verbose )); then log DEBUG "compressed $file: $before -> $after bytes"; fi
fi
count=$(( count + 1 ))
done < <(find "$dir" -maxdepth 1 -type f -name '*.csv' -mtime +"$days" -print0)
log INFO "$count file(s) processed, $(( saved / 1024 )) KiB saved"
2026-10-02 11:00:15 INFO would compress exports/orders-2026-09-15.csv (328894 bytes)
2026-10-02 11:00:15 INFO would compress exports/orders-2026-09-01.csv (328894 bytes)
2026-10-02 11:00:15 INFO would compress exports/q3 summary.csv (328894 bytes)
2026-10-02 11:00:15 INFO would compress exports/users-2026-09-20.csv (328894 bytes)
2026-10-02 11:00:15 INFO 4 file(s) processed, 0 KiB saved
2026-10-02 11:00:15 DEBUG compressed exports/orders-2026-09-15.csv: 328894 -> 49944 bytes
2026-10-02 11:00:15 DEBUG compressed exports/orders-2026-09-01.csv: 328894 -> 49944 bytes
2026-10-02 11:00:15 DEBUG compressed exports/users-2026-09-20.csv: 328894 -> 49943 bytes
2026-10-02 11:00:15 INFO 3 file(s) processed, 817 KiB saved
notes.txt
orders-2026-09-01.csv.gz
orders-2026-09-15.csv.gz
orders-2026-10-01.csv
q3 summary.csv
users-2026-09-20.csv.gz
43 lines, and every piece is something you learned in this level: getopts, validation, find -print0 with read -d '', process substitution, and logging to stderr. find does the hard part (age filtering), and gzip does the compression. Bash just connects them. That's a good fit for bash.
The Python version¶
#!/usr/bin/env python3
"""compress_old.py - gzip CSV files older than N days in a directory."""
import argparse
import logging
import subprocess
import sys
import time
from pathlib import Path
log = logging.getLogger("compress_old")
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="gzip CSV files older than N days in a directory.")
parser.add_argument("dir", type=Path, help="directory to scan")
parser.add_argument("-d", "--days", type=int, default=7,
help="minimum age in days (default: %(default)s)")
parser.add_argument("-n", "--dry-run", action="store_true",
help="show what would be done, change nothing")
parser.add_argument("-v", "--verbose", action="store_true",
help="log every file")
args = parser.parse_args(argv)
if args.days < 0:
parser.error("--days must be non-negative")
if not args.dir.is_dir():
parser.error(f"not a directory: {args.dir}")
return args
def old_csv_files(directory: Path, days: int):
cutoff = time.time() - days * 86400
for path in sorted(directory.glob("*.csv")):
if path.is_file() and path.stat().st_mtime < cutoff:
yield path
def compress(path: Path) -> int:
"""gzip one file; return the number of bytes saved."""
before = path.stat().st_size
subprocess.run(["gzip", "--", str(path)], check=True)
after = path.with_name(path.name + ".gz").stat().st_size
return before - after
def main(argv=None) -> int:
args = parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-5s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
count = saved = 0
for path in old_csv_files(args.dir, args.days):
if args.dry_run:
log.info("would compress %s (%d bytes)", path, path.stat().st_size)
else:
try:
delta = compress(path)
except subprocess.CalledProcessError as exc:
log.error("gzip failed on %s (exit %d)", path, exc.returncode)
return 1
saved += delta
log.debug("compressed %s: saved %d bytes", path, delta)
count += 1
log.info("%d file(s) processed, %d KiB saved", count, saved // 1024)
return 0
if __name__ == "__main__":
sys.exit(main())
Run on a fresh copy of the same directory:
2026-10-02 11:00:31 INFO would compress exports/orders-2026-09-01.csv (328894 bytes)
2026-10-02 11:00:31 INFO would compress exports/orders-2026-09-15.csv (328894 bytes)
2026-10-02 11:00:31 INFO would compress exports/q3 summary.csv (328894 bytes)
2026-10-02 11:00:31 INFO would compress exports/users-2026-09-20.csv (328894 bytes)
2026-10-02 11:00:31 INFO 4 file(s) processed, 0 KiB saved
2026-10-02 11:00:31 DEBUG compressed exports/orders-2026-09-01.csv: saved 278950 bytes
2026-10-02 11:00:31 DEBUG compressed exports/orders-2026-09-15.csv: saved 278950 bytes
2026-10-02 11:00:31 DEBUG compressed exports/users-2026-09-20.csv: saved 278951 bytes
2026-10-02 11:00:31 INFO 3 file(s) processed, 817 KiB saved
Same behavior, same output format. Bad input and help come for free from argparse:
usage: compress_old.py [-h] [-d DAYS] [-n] [-v] dir
compress_old.py: error: argument -d/--days: invalid int value: 'ten'
exit=2
usage: compress_old.py [-h] [-d DAYS] [-n] [-v] dir
gzip CSV files older than N days in a directory.
positional arguments:
dir directory to scan
options:
-h, --help show this help message and exit
-d DAYS, --days DAYS minimum age in days (default: 7)
-n, --dry-run show what would be done, change nothing
-v, --verbose log every file
Comparing them, section by section¶
| Concern | Bash | Python |
|---|---|---|
| Option parsing | getopts loop plus case, manual integer check, hand-written usage |
argparse: types, defaults, long options, -h, and error messages generated |
| Finding old files | find -mtime +N -print0 and read -d '' |
Path.glob() and stat().st_mtime < cutoff |
| Running gzip | gzip -- "$file" || die |
subprocess.run([...], check=True) raising on failure |
| Logging | Custom log() with printf '%(...)T' |
logging.basicConfig(format=..., level=...) |
| Error flow | set -e, || die, exit codes |
Exceptions, caught where you can do something useful |
| Testability | Run it and check files | main(["-n", "dir"]) can be called from a unit test |
| Length | 43 lines | 72 lines |
Neither is wrong. For this task, the bash version is shorter and perfectly clear, because the work is "find files, run gzip." The Python version starts to win as soon as requirements grow. Upload to S3 after compressing, write a JSON report, skip files listed in a YAML config, and retry uploads with backoff: each of those is a few lines in Python and a struggle in bash.
Common mistake: find -mtime +7 is not 'older than 7 days'
find measures age in whole days and drops the fraction, so -mtime +7 means "8 or more full days old." The Python version compares exact seconds, so its --days 7 means "more than 7 × 24 hours." For a file 7.5 days old they disagree. When you port a script, port its exact semantics and test the edge cases. Off-by-one-day bugs in retention scripts delete data a day early.
Where bash breaks: quoted CSV¶
Bash with IFS=,:
while IFS=, read -r id customer amount; do
printf '%s | %s | %s\n' "$id" "$customer" "$amount"
done < <(tail -n +2 payments.csv)
Python's csv module understands quoting, and summing decimals is trivial:
import csv
with open("payments.csv", newline="") as f:
rows = list(csv.DictReader(f))
for r in rows:
print(r["id"], "|", r["customer"], "|", r["amount"])
print("total:", sum(float(r["amount"]) for r in rows))
(For real money, use decimal.Decimal instead of float to avoid rounding surprises.)
Calling shell commands from Python: the injection demo¶
import subprocess
filename = "report.csv; echo INJECTED: this could have been rm -rf ~"
print("--- shell=True with an f-string (DANGEROUS)")
subprocess.run(f"wc -l {filename}", shell=True)
print("--- list of arguments (safe)")
subprocess.run(["wc", "-l", filename])
--- shell=True with an f-string (DANGEROUS)
3 report.csv
INJECTED: this could have been rm -rf /home/alex
--- list of arguments (safe)
wc: 'report.csv; echo INJECTED: this could have been rm -rf ~': No such file or directory
With shell=True, the shell saw ; and ran a second command taken from the data. It even expanded ~ to your home directory. With the list form, wc received the whole string as one file name and reported, correctly, that no such file exists.
Everyday subprocess usage:
import subprocess
# Capture output as text, raise if the command fails.
result = subprocess.run(
["df", "--output=pcent", "/"],
capture_output=True, text=True, check=True,
)
print("stdout:", repr(result.stdout))
used = int(result.stdout.splitlines()[1].strip().rstrip("%"))
print("root filesystem used:", used, "%")
# A command that fails: check=True turns it into an exception.
try:
subprocess.run(["ls", "/nope"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as exc:
print("failed with", exc.returncode, "stderr:", exc.stderr.strip())
# Without check=True you inspect returncode yourself.
r = subprocess.run(["grep", "-q", "ERROR", "report.csv"])
print("grep returncode:", r.returncode)
# A timeout stops runaway commands.
try:
subprocess.run(["sleep", "5"], timeout=1)
except subprocess.TimeoutExpired:
print("sleep timed out after 1s")
stdout: 'Use%\n 33%\n'
root filesystem used: 33 %
failed with 2 stderr: ls: cannot access '/nope': No such file or directory
grep returncode: 1
sleep timed out after 1s
grep returning 1 ("no match") is not an error, which is why that call leaves out check=True. Exactly like || true in bash.
When you truly need a shell string, quote outside values with shlex.quote. Better yet, build the pipeline without a shell:
import shlex
import subprocess
filename = "report.csv; echo INJECTED"
cmd = f"wc -l {shlex.quote(filename)} 2>&1 | tr a-z A-Z"
print(cmd)
subprocess.run(cmd, shell=True)
# The same pipeline with no shell at all:
grep = subprocess.Popen(["grep", "-v", "^id", "report.csv"], stdout=subprocess.PIPE)
wc = subprocess.run(["wc", "-l"], stdin=grep.stdout, capture_output=True, text=True)
grep.stdout.close()
grep.wait()
print("rows:", wc.stdout.strip())
wc -l 'report.csv; echo INJECTED' 2>&1 | tr a-z A-Z
WC: 'REPORT.CSV; ECHO INJECTED': NO SUCH FILE OR DIRECTORY
rows: 2
shlex.quote wrapped the value in single quotes, so the ; stayed inside the file name. The Popen version connects grep's stdout to wc's stdin directly. That's a real pipe, set up by Python instead of a shell. (Level 5 explains pipes at the system-call level.)
The bad-to-good rewrite you'll do most often:
Calling Python from shell scripts¶
One-liners with arguments:
JSON on stdin. Bash has no JSON parser, but Python does:
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["host"], d["port"])' <<< '{"host": "mint", "port": 5432}'
echo '{"name":"alex","roles":["dev","ops"]}' | python3 -m json.tool
(The jq tool, installed with sudo apt install jq, is the shell-native alternative for JSON.)
A longer snippet as a quoted heredoc, with the result captured into a bash variable:
#!/usr/bin/env bash
# avg.sh - average of the "amount" column, using Python for the CSV and the math.
set -euo pipefail
file=${1:?usage: avg.sh FILE.csv}
avg=$(python3 - "$file" <<'EOF'
import csv
import sys
with open(sys.argv[1], newline="") as f:
amounts = [float(row["amount"]) for row in csv.DictReader(f)]
print(f"{sum(amounts) / len(amounts):.2f}")
EOF
)
echo "average amount: $avg"
average amount: 13.58
Traceback (most recent call last):
File "<stdin>", line 6, in <module>
ZeroDivisionError: division by zero
exit=1
Notice:
python3 - "$file"passes the file name as an argument. It's never pasted into the code.<<'EOF'is quoted, so bash leaves the Python source alone.$and backslashes pass through untouched.- When Python failed, its exit status 1 made the command substitution fail, and
set -estopped the script before it printed a wrong average.
Exit codes cross the boundary as you'd expect:
Exercises¶
Exercise 1: Pick the tool (easy)¶
For each task, choose bash or Python and give a one-line reason:
- A container entrypoint that waits for the database port, runs migrations with an existing CLI, then starts the app.
- Merge three JSON API responses, compute average order value per country, and write an Excel-friendly CSV.
- A nightly job that runs
pg_dump, gzips the output, and deletes dumps older than 14 days. - A tool that reads a YAML list of 40 servers, checks each one's HTTPS certificate expiry in parallel, and posts a summary to a chat webhook.
- A wrapper that runs
rsyncwith your standard flags and a log file name containing the date.
Solution
- Bash: it's a sequence of commands, runs before anything else, and the image may not even have Python.
- Python: JSON, grouping, decimal math, and CSV quoting are exactly bash's weak spots.
- Bash: three commands glued together, each one doing the real work. (This is the capstone.)
- Python: structured config, concurrency, TLS/date handling, and an HTTP POST with JSON. All of those are libraries in Python and painful in bash.
- Bash: a thin wrapper around one tool is bash's home turf.
Exercise 2: Borrow Python's math (easy)¶
Bash can't average decimals. Write avg.sh FILE.csv that prints the average of the amount column of payments.csv (with the quoted names) to two decimal places, by running an inline Python heredoc. Pass the file name as an argument, not by inserting it into the code. Confirm that an empty CSV makes the script exit non-zero.
Solution
This is the avg.sh from the examples:
#!/usr/bin/env bash
set -euo pipefail
file=${1:?usage: avg.sh FILE.csv}
avg=$(python3 - "$file" <<'EOF'
import csv
import sys
with open(sys.argv[1], newline="") as f:
amounts = [float(row["amount"]) for row in csv.DictReader(f)]
print(f"{sum(amounts) / len(amounts):.2f}")
EOF
)
echo "average amount: $avg"
The empty file raises ZeroDivisionError, Python exits 1, and set -e stops the script. A friendlier version would check if not amounts: sys.exit("no rows"), which prints the message to stderr and exits 1.
Exercise 3: Fix the injection (medium)¶
This function is called with paths that come from a web form. Explain two ways it can go wrong, then rewrite it safely.
import subprocess
def disk_usage(path):
out = subprocess.check_output(f"du -sh {path}", shell=True, text=True)
return out.split()[0]
Solution
Problems:
- Injection: a path like
x; rm -rf ~runs a second command, because the shell parses;. - Breakage: a legitimate path with a space, like
q3 data, becomes two arguments:
du: cannot access 'q3': No such file or directory
du: cannot access 'data': No such file or directory
...
subprocess.CalledProcessError: Command 'du -sh q3 data' returned non-zero exit status 1.
Safe version:
import subprocess
def disk_usage(path: str) -> str:
result = subprocess.run(
["du", "-sh", "--", path],
capture_output=True, text=True, check=True,
)
return result.stdout.split("\t", 1)[0]
The list form passes the path as exactly one argument. -- stops du from treating a path starting with - as an option. du separates size and path with a tab, so splitting on the first tab works even if the path has spaces. A path of q3 data; echo pwned now simply doesn't exist, and check=True raises an error.
Exercise 4: Port the sales summary (medium)¶
Rewrite Chapter 3's sales-summary.sh (total units per customer from sales.csv, sorted by total, highest first, with a row count) in Python using argparse, csv.DictReader, and collections.Counter. A missing file should produce a clean error and exit 2.
Solution
#!/usr/bin/env python3
"""sales_summary.py - total units per customer from a CSV."""
import argparse
import csv
import sys
from collections import Counter
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("file", type=argparse.FileType("r", encoding="utf-8"))
args = parser.parse_args()
units = Counter()
rows = 0
with args.file as f:
for row in csv.DictReader(f):
if not row.get("customer"):
continue # skip blank lines
units[row["customer"]] += int(row["units"])
rows += 1
print(f"rows: {rows}")
for customer, total in units.most_common():
print(f"{customer:<6} {total:3d}")
return 0
if __name__ == "__main__":
sys.exit(main())
rows: 5
sam 9
alex 5
kim 1
usage: sales_summary.py [-h] file
sales_summary.py: error: argument file: can't open 'nope.csv': [Errno 2] No such file or directory: 'nope.csv'
exit=2
Counter.most_common() replaces the | sort -k2,2nr pipeline. argparse.FileType opens the file and turns a failure into a usage error. Quoted names with commas now work too.
Exercise 5: Port logsearch.sh (hard)¶
Port Chapter 5's logsearch.sh to Python. Requirements: one or more files; -l/--level with choices DEBUG, INFO, WARN, ERROR, case-insensitive input, default ERROR; -n/--max (non-negative int, 0 = unlimited); -i/--ignore-case; -v/--verbose printing == file == to stderr. An unreadable file prints an error, the remaining files are still searched, and the exit status is 2. Compare the error messages with the bash version.
Solution
#!/usr/bin/env python3
"""logsearch.py - show log lines at a given level from one or more log files."""
import argparse
import re
import sys
LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"]
def parse_args():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("files", nargs="+", metavar="FILE", help="log files to search")
p.add_argument("-l", "--level", default="ERROR", type=str.upper,
choices=LEVELS, help="level to show (default: %(default)s)")
p.add_argument("-n", "--max", type=int, default=0, metavar="MAX",
help="at most MAX matches per file (default: all)")
p.add_argument("-i", "--ignore-case", action="store_true")
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args()
if args.max < 0:
p.error("--max must be non-negative")
return args
def main() -> int:
args = parse_args()
flags = re.IGNORECASE if args.ignore_case else 0
pattern = re.compile(rf" {args.level} ", flags)
status = 0
for name in args.files:
if args.verbose:
print(f"== {name} ==", file=sys.stderr)
try:
with open(name, encoding="utf-8", errors="replace") as f:
shown = 0
for line in f:
if pattern.search(line):
sys.stdout.write(line)
shown += 1
if args.max and shown >= args.max:
break
except OSError as exc:
print(f"logsearch.py: {exc}", file=sys.stderr)
status = 2
return status
if __name__ == "__main__":
sys.exit(main())
./logsearch.py --level=warn app.log
./logsearch.py -l FATAL app.log; echo "exit=$?"
./logsearch.py nope.log app.log; echo "exit=$?"
2026-10-01 10:00:09 WARN api: slow request /orders 2.1s
usage: logsearch.py [-h] [-l {DEBUG,INFO,WARN,ERROR}] [-n MAX] [-i] [-v]
FILE [FILE ...]
logsearch.py: error: argument -l/--level: invalid choice: 'FATAL' (choose from 'DEBUG', 'INFO', 'WARN', 'ERROR')
exit=2
logsearch.py: [Errno 2] No such file or directory: 'nope.log'
2026-10-01 10:00:05 ERROR db: connection refused (host=db1)
2026-10-01 10:01:13 ERROR db: connection refused (host=db1)
exit=2
The last command used the default level, ERROR. type=str.upper normalizes before choices is checked, replacing ${level^^} plus the case. Long options, --level=warn, and the help text come free. Unlike the bash version, which validated all files up front, this one reports the bad file and keeps going. Choose whichever behavior your users need, and document it.
Check yourself¶
-
Name three signs that a bash script should become a Python program.
Answer
Any three of: it's past ~100–200 lines and growing; it needs nested or structured data; it parses JSON/CSV/YAML; it needs decimal math; it needs nuanced error handling and retries; it talks to APIs or databases beyond one CLI call; others must maintain and test it; it loops over many lines calling external commands each time.
-
Why are loops that call external commands on every line slow in bash?
Answer
Every external command requires creating a new process (fork plus exec), which costs roughly a millisecond. Over hundreds of thousands of lines that adds up to minutes. Python (or a single
awkprogram) processes lines inside one process. -
What's the difference between
subprocess.run(["rm", path])andsubprocess.run(f"rm {path}", shell=True)?Answer
The list form runs
rmdirectly withpathas exactly one argument; no shell interprets it. The string form runs/bin/sh -c, which word-splits, globs, and interprets;,|,$( )inpath, so a crafted or merely unusual path can break the command or run arbitrary commands (command injection). -
When might you use
shlex.quote(), and what's usually better?Answer
When you must build a shell command string (for
shell=Trueor ansshremote command) containing outside values;shlex.quotemakes each value a single safe shell word. Usually better: avoid the shell entirely with the list form, and do globbing, redirection, and pipes in Python. -
What do
check=True,capture_output=True, andtext=Truedo insubprocess.run?Answer
check=TrueraisesCalledProcessErroron a non-zero exit status.capture_output=Truecollects stdout and stderr into the result instead of letting them print.text=Truedecodes them to strings instead of bytes. -
How do you pass a bash variable into inline Python code safely?
Answer
As an argument (
python3 -c '...' "$var"orpython3 - "$var" <<'EOF', read viasys.argv), via stdin, or via an exported environment variable read withos.environ. Never by interpolating it into the Python source text, which breaks on quotes and allows code injection. -
How does a Python script signal failure to the bash script that called it?
Answer
Through its exit status:
sys.exit(n)with non-zeron, or an uncaught exception (which exits with status 1 after printing a traceback to stderr). Bash sees it in$?,if,&&/||, andset -e.
Key takeaways¶
- Bash is glue: sequences of commands, pipes, files, and processes, with zero setup. Keep scripts short and focused.
- Move to Python for data structures, real parsing, decimal math, rich error handling, APIs, tests, or more than ~150 lines.
subprocesswith a list of arguments pluspathlib,argparse, andloggingreplace nearly every bash habit.- Never use
shell=Truewith untrusted input. If you must build a shell string,shlex.quoteevery outside value. - Call Python from bash with
python3 -cor a quoted heredoc. Pass data as arguments or stdin, and rely on exit codes. - When porting, port the exact semantics (like
find -mtimerounding) and test the edge cases.
Next¶
You've finished the Level 2 chapters. Prove the skills with the Level 2 capstone: a real backup script with options, logging, rotation, and a dry-run mode.