The command line & automation
The terminal you'll live in, and driving it from Python: navigation, pipes, pathlib, subprocess (safely), argparse CLIs, defensive scripts, logging, and shipping a team tool.
This chapter is a continuous climb: move around the shell, manipulate files, master pipes and redirection, then drive the shell from Python (subprocess), build real CLI tools with argparse, and finally package + ship team tooling. Shell runs in your terminal; every Python block runs offline as-is.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| shell | the program that runs typed commands (bash/zsh). |
| pipe | send one command's output into another: a | b. |
| subprocess | Python running shell commands + capturing output. |
| exit code | 0 = success, non-zero = failure; how scripts chain safely. |
| argparse | Python's standard library for building command-line interfaces. |
What you need before starting:
- A terminal + Python 3.10+.
- No prior terminal experience needed.
- Type the commands and run the Python — muscle memory is the point.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Navigate, inspect, and manipulate files fluently.
- Compose tools with pipes, redirection, and search.
- Do file/OS work from Python (pathlib, subprocess).
- Build, harden, and package a CLI tool for a team.
code/df1-command-line/. Python runs offline; configs are ready to use.1 · Navigation — where am I, what's here essential
The first time you open a terminal, it feels like being dropped into a dark house with no light switch. There's a blinking cursor, no icons, no folders you can see — just a prompt waiting. Almost everyone's instinct is to freeze, because a graphical file browser shows you where you are, and the terminal doesn't. But that missing map is exactly what these three commands hand back to you, and within five minutes the dark house lights up room by room.
Here's the mental model that makes it click: the shell is always standing in exactly one folder, called the working directory. Think of it like a person walking through a building — at any moment they're in one room, they can look around that room, and they can walk through a door into another. They can't be in two rooms at once. Every command you type runs as if you were standing in that folder, which is why "where am I?" is the single most important question on the command line — the answer silently changes what everything else does.
The common beginner mistake is confusing the two kinds of address a file can have. An absolute path (/home/you/x) starts from the very top of the filesystem — it's like a full postal address, unambiguous from anywhere on Earth. A relative path (./x, ../x) is measured from where you're currently standing — it's like saying "next door" or "upstairs", which only makes sense once you know which building you're in. The dot shorthands are worth burning into memory now: . means "here" and .. means "the folder above me". People lose hours debugging a script that "can't find a file", when the real problem is that a relative path pointed somewhere else because the working directory wasn't what they assumed.
Three commands cover most movement: pwd (where), ls (what), cd (go). Paths are either absolute (/home/you/x) or relative (./x, ../x).
/ and is the same from anywhere; a relative path like ../app.log is measured from the green "you are here" box — which is exactly why the same relative path means different things depending on where you stand.
Read the tree top-to-bottom: the root / is at the top, and each arrow descends into a folder nested inside its parent, ending at the green box — the folder your shell is currently standing in.
- The boxes are folders and files; the arrows are "contains", so
/contains/home, which containsyou, which containsprojects. - The green box is the working directory — your "you are here". Every command you type runs as if you were standing in it.
- The two labels on the right show the payoff: the same file
app.logcan be named by its absolute path (from the root, unambiguous) or a relative path (..= the folder above you) — the relative one only makes sense once you know the green box.
In short: pwd tells you which box is green; absolute paths ignore it, relative paths are measured from it.
nav.shpwd # /home/you
ls -la # long listing, incl. hidden (dot) files
cd projects # into a dir; cd .. up; cd ~ home; cd - previous
tree -L 1 # (if installed) a visual of the current level
The terminal always has a current folder (your "you are here"). These four commands are how you find out where you are, see what's around you, and move somewhere else — the absolute basics of getting around a shell.
pwdmeans print working directory — it prints the full path of the folder you're currently sitting in, like/home/you.ls -lalists what's in the current folder. The-lflag makes it a detailed "long" listing (sizes, dates, permissions) and-ashows all files, including hidden ones whose names start with a dot.cd projectschanges directory into a folder calledprojects. The comment lists the handy shortcuts:cd ..goes up one level,cd ~jumps to your home folder, andcd -hops back to the previous folder.tree -L 1draws a little picture of the folder one level deep — useful, but it's an extra tool you may need to install first.
What the output means: Nothing is changed on disk — pwd and ls just report, and cd only moves you. You end up standing inside projects.
Try this: Open a terminal and type pwd, then ls -la. Do you see files whose names begin with a dot (like .git)? Those were hidden until you added -a.
2 · Files — create, copy, move, remove essential
In a graphical file manager, every file operation comes with a safety net you never think about. You drag a file to the wrong place, you drag it back. You delete something, it sits in the Trash for weeks until you empty it. You right-click, you get "Undo Move". The command line strips all of that away — and the first time it bites you is unforgettable. There's a well-worn horror story every senior engineer has some version of: a stray space or a wrong variable turned rm -rf $DIR/ into rm -rf /, and a career's worth of files evaporated with no confirmation, no trash, no undo. That's not a reason to fear the terminal; it's a reason to understand exactly what each command does before you press Enter.
The way to hold these commands in your head is as a small vocabulary of verbs, each doing one blunt thing: mkdir makes a folder, touch creates an empty file, cp copies, mv moves or renames, rm removes. There's no "are you sure?" dialog between you and any of them — the shell assumes you meant what you typed. That directness is the trade: total speed and scriptability, zero hand-holding.
The one distinction that trips up almost every beginner is the two arrows: > versus >>. A single > means "point this output into that file" — and if the file already has contents, they are silently obliterated and replaced. A double >> means "add this output onto the end". Think of > as replacing the whole page and >> as writing another line at the bottom. Beginners reach for > out of habit, run it twice on a log file they meant to append to, and watch the first half of their data vanish. Read that difference carefully in the lab below, because it is the single most common way people destroy data they didn't mean to touch.
> replaces, >> adds. Both start from the same file, but a single > silently obliterates the existing contents while a double >> keeps them and writes onto the end — the one-character difference behind most accidental data loss.
Start at the top box — the same file log.txt holding two lines — then follow the two blue arrows down the left and right to see what each operator does to it.
- The top box is the original file with contents
line A · line B. Both branches begin from this identical starting point. - The left (red) path uses
>: it overwrites, so the old lines vanish and the file is left holding only the new text — that red box is the data-loss you didn't intend. - The right (green) path uses
>>: it appends, so the original lines stay and the new text is added at the bottom.
In short: reach for >> whenever you mean to add to a file; a bare > throws the old contents away.
files.shmkdir -p demo/sub && cd demo
touch a.txt
echo "hello" > a.txt # write (overwrite)
echo "world" >> a.txt # append
cat a.txt; wc -l a.txt # show; count lines
cp a.txt b.txt; mv b.txt sub/c.txt; rm a.txt
This block creates a folder, then writes, shows, copies, moves, and deletes a file — the everyday file chores you'd otherwise do by clicking around, done with typed commands.
mkdir -p demo/submakes a folderdemoand a foldersubinside it in one go;-pmeans "create parents too, and don't complain if they already exist".&&means "only run the next command if this one succeeded", thencd demomoves inside.touch a.txtcreates an empty file. The single>then writeshellointo it, replacing whatever was there; the double>>appends a new line instead of overwriting. That difference matters a lot.cat a.txtprints the file's contents to the screen;wc -l a.txtcounts the lines in it.cp a.txt b.txtcopies the file,mv b.txt sub/c.txtmoves (and renames) it into thesubfolder, andrm a.txtdeletes the original.
What the output means: After this runs you have demo/sub/c.txt containing the two lines, and the original a.txt is gone.
Try this: Run just the two echo lines, then cat a.txt. Now change the second one from >> to > and re-run — you'll see the file drop to a single line because > overwrites.
rm is permanentNo trash on the command line — rm deletes now, rm -rf deletes a whole tree silently. Double-check the path. When scripting deletes, prefer Python (pathlib) where you can log and guard first.3 · Pipes, redirection & search essential
Picture the moment a teammate pastes you a 400,000-line log file and asks "which IP addresses hit us the most yesterday?" In a spreadsheet, that's an afternoon of imports, formulas, and pivot tables. On the command line, it's one line you can type in fifteen seconds — and that line, the first time it works, is when the terminal stops feeling like a chore and starts feeling like a superpower. This section is where the whole chapter turns a corner: not "how do I move around" but "how do I compose an answer out of nothing but small, dumb tools."
Here's the philosophy underneath it, and it's genuinely beautiful once you see it. Unix was built on a bet: instead of one giant program that tries to do everything, make dozens of tiny programs that each do one thing perfectly, then give people a way to snap them together. grep only filters lines. sort only sorts. wc only counts. None of them knows what the others do — and that's the point. The connector that makes them a team is the pipe, the | symbol, which takes whatever the command on its left prints and feeds it straight into the command on its right as if you'd typed it.
The analogy that makes pipes stick is a factory assembly line. Raw material (your file's text) goes in one end; each station along the belt does one small transformation and passes the result to the next; a finished answer rolls out the far end. cat access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head is literally six stations in a row — extract the IP, line them up, count the duplicates, order by count, take the top few. You didn't write a program; you arranged existing tools in a line.
The mistake beginners make is trying to build the whole line at once, then staring at a wrong answer with no idea which stage broke. The fix is a habit: build the pipe one stage at a time, run it, look at the output, then add the next |. Also keep two things separate in your mind — a pipe (|) sends output to another command, while redirection (>, >>, 2>) sends output to a file. They look similar and get confused constantly; the difference is whether the destination is another program or a file on disk.
The Unix superpower: small single-purpose tools chained with pipes (|). grep filters, sort/uniq/wc transform, >/>> redirect to files.
| into an answer — extract the IP, line them up, count duplicates, order by count, take the top — no program written, just tools arranged in a row.
Read strictly left to right, following the blue arrows. The raw log enters on the far left and a finished answer rolls out the green box on the right — each station transforms the stream and hands it on.
- Each box is one tool doing exactly one job; the small
|between boxes is the pipe operator that connects them. - The blue arrows are the flow of data:
awkpulls out the IP,sortgroups identical lines together,uniq -ccounts each group,sort -rnorders those counts high-to-low, andheadkeeps only the top few. - The green box plus the dashed arrow back to the caption is the payoff — the busiest IPs — assembled without writing any program of your own.
In short: build the line one | at a time, checking the output at each stage, and small dumb tools compose into a real answer.
pipes.shgrep "ERROR" app.log | wc -l # how many errors?
grep -rn "TODO" src/ # recursive, with line numbers
cat access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head # top IPs
ls -la | grep "\.py$" > py_files.txt # save matches to a file
some_cmd 2> errors.txt # redirect stderr separately
A pipe (the | symbol) feeds one command's output straight into the next as its input. This is the Unix superpower: chain tiny single-purpose tools into an answer. Read each line left-to-right as a small assembly line.
grep "ERROR" app.logfinds every line containingERROR; piping| wc -lcounts those lines — so the whole line answers "how many errors?".grep -rn "TODO" src/searches recursively (-r, through every file undersrc/) and prints line numbers (-n) — great for finding leftover to-dos.- The
access.logline is a four-stage pipe: pull the first column (the IP) withawk,sortthem,uniq -ccounts duplicates,sort -rnorders by count high-to-low, andheadshows the top few — your busiest visitors. - The last two lines show redirection:
> py_files.txtsaves output into a file instead of the screen, and2> errors.txtsends only the error stream (stderr) to its own file.
What the output means: Each line prints its result to the screen, except the last two which quietly write their output into py_files.txt and errors.txt.
Try this: Build a pipe one stage at a time: run grep "ERROR" app.log alone, then add | wc -l. Watching the output change as you add each stage is the fastest way to understand pipes.
4 · File & OS work from Python (pathlib) intermediate
Shell one-liners are wonderful right up until the day they aren't. You write a tidy pipeline of grep and awk on your Mac, it works perfectly, you hand it to a teammate on Windows — and it detonates, because the path separators are backslashes, the tools aren't installed, and the quoting rules are different. Or your clever one-liner grows a third condition and an error case, and suddenly you're fighting shell syntax that was never meant to hold real logic. That's the wall this section gets you over: when file work becomes real automation, you move it into Python, where it's portable, readable, and easy to guard with checks and logging.
The tool for that job is pathlib, and the mental shift it asks of you is small but powerful: stop thinking of a path as a string, and start thinking of it as an object. Old-style code treated /home/you/logs/app.log as text you glued together with + and os.path.join — fragile, easy to get separators wrong, littered with special cases. A Path is a thing that knows it's a location: it can tell you its own name, its size, whether it exists, what's inside it. Think of the difference between having a scrap of paper with an address scribbled on it versus having a GPS pin you can actually navigate to.
The one piece of pathlib that surprises everyone is that it overloads the / operator to join paths: base / "logs" / "app.log" builds a path. The first time you see it, it looks like you're dividing folders, and that's a fair double-take — but it reads left-to-right exactly like the path it produces, and crucially Python inserts the right separator for whatever operating system you're on. That's the portability win in a single character. The common trap here is the opposite habit — reaching for string concatenation or hard-coded / or \\ out of muscle memory — which is exactly the brittleness pathlib exists to kill. The demo below runs entirely inside a throwaway temp directory, so you can execute it without any risk to your real files.
Real automation does file work in Python, portably, with pathlib. Runs top-to-bottom in a temp dir — 100% safe to execute.
Path is a location, not a string. Gluing paths together with + and hard-coded slashes breaks the moment it meets a different OS; a Path joined with / inserts the correct separator everywhere and can answer questions about itself — its name, size, and whether it exists.
Compare the two columns top-to-bottom: the left (red) column is the old string approach, the right (green) column is pathlib — each row is the same idea done both ways.
- The top row names each approach; the second row shows how a path is built — glued from text with
+on the left, joined with the/operator on the right. - The arrows lead to what each buys you: the string carries a hard-coded separator that is wrong on a different operating system, while the
Pathobject also exposes methods like.name,.stat().st_size, and.exists(). - The bottom row is the payoff: the string version breaks when it moves between machines, while the
Pathversion inserts the right separator for whatever OS you are on — that is the portability win in a single character.
In short: stop treating a path as text to concatenate; a Path is a location object that joins with /, stays portable, and knows things about itself.
pathlib_ops.pyfrom pathlib import Path
import tempfile
base = Path(tempfile.mkdtemp()) # throwaway dir
(base / "logs").mkdir()
f = base / "logs" / "app.log"
f.write_text("INFO ok\nERROR boom\nINFO fine\nERROR bad\n")
lines = f.read_text().splitlines()
errors = [l for l in lines if l.startswith("ERROR")]
print("total lines:", len(lines), "| errors:", len(errors))
for child in (base / "logs").glob("*.log"): # like `ls *.log`
print("found:", child.name, child.stat().st_size, "bytes")
total lines: 4 | errors: 2
found: app.log 34 bytes
Once automation gets serious you do file work in Python instead of shell, because it's portable and easy to guard. pathlib is Python's modern way to handle files and folders: a Path object represents a location, and you build sub-paths with the / operator.
Path(tempfile.mkdtemp())makes a fresh temporary folder just for this demo, so it can't touch your real files.(base / "logs").mkdir()creates alogsfolder inside it — notice/joins path pieces here, it is not division.f.write_text(...)writes four lines into a newapp.log. The\nmarkers are newlines, so it's really four separate log lines.f.read_text().splitlines()reads the file back and splits it into a list of lines. The next line keeps only the lines thatstartswith("ERROR")— the Python equivalent ofgrep.- The
glob("*.log")loop finds every file ending in.log(just likels *.log) and prints each one's name and size in bytes fromchild.stat().
What the output means: It prints total lines: 4 | errors: 2, then one found: line for app.log with its size — matching the console block shown below.
Try this: Add another ERROR line to the write_text string and re-run; the error count and total both go up. This is the same idea as grep ERROR | wc -l, but in Python.
5 · Driving the shell from Python (subprocess) intermediate
Sooner or later you hit a tool that only exists on the command line. There's no Python library for it — it's a binary someone else wrote: git, docker, ffmpeg, a company deploy script. You need to run it from your Python program, catch what it printed, and decide what to do based on whether it worked. This is the bridge between the two worlds you've been learning, and it's the beating heart of every build tool, deploy script, and CI runner on the planet: they are, at bottom, Python (or similar) orchestrating shell commands and reacting to the results.
The mental model is a manager delegating a task. Your Python code is the manager; subprocess.run hands a job to a worker (the command), then waits for the worker to come back with two things: what they produced (the output, on stdout) and whether they succeeded (the exit code). That exit code is the part beginners forget exists, and it matters enormously: by universal convention 0 means "success" and any non-zero number means "something went wrong." A command can print perfectly normal-looking output and still have failed — the only reliable way to know is to check the code. Code that runs a command and blindly trusts it worked is how a deploy script cheerfully continues past a failed build and ships nothing.
Now the safety issue, and this is the one that turns into a security incident if you get it wrong. There are two ways to hand a command to subprocess: as a list of separate arguments (["rm", name]), or as a single string with shell=True (f"rm {name}"). They look interchangeable. They are not. With the list form, your arguments go straight to the program with no shell in between — a filename containing spaces or semicolons or && is just a weird filename, harmless. With shell=True, a full shell parses your string first, and if any part of it came from a user, an attacker can smuggle in extra commands — a filename like foo; rm -rf ~ becomes two commands, and the second one is a disaster. The rule to internalize now, before the danger callout below spells it out: always pass a list, and never combine shell=True with input you didn't write yourself.
Build tools, deploy scripts, and CI runners all run commands from code and check the result. subprocess.run captures output and the exit code; check it and fail loudly.
["rm", name] hands arguments straight to the program, so a filename full of shell metacharacters stays a harmless (if weird) filename; shell=True lets a shell parse the string first, so an attacker's ; becomes a second, destructive command.
Compare the two columns top-to-bottom: the same hostile filename enters both, but the green (left) column is the list form and the red (right) column is shell=True — follow each down to its very different ending.
- The top row labels each approach; the second row shows how the command is built — a list of separate arguments on the left, a single formatted string on the right.
- The arrows lead to what actually happens: on the left no shell is involved, so the arguments pass directly to
rm; on the right a shell parses the string and the embedded;is treated as a command separator. - The bottom row is the payoff: the green box treats the whole thing as one odd filename (harmless), while the red box runs a second command that wipes the home directory — that's command injection.
In short: always pass a list, and never combine shell=True with input you didn't write yourself.
subprocess_run.pyimport subprocess
def run(cmd):
"""Run a command (list form!), return stdout, raise on failure."""
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"{' '.join(cmd)} failed ({r.returncode}): {r.stderr.strip()}")
return r.stdout.strip()
print("python:", run(["python3", "--version"]))
print("files: ", run(["ls"]).split()[:3]) # first 3 entries here
Build tools, deploy scripts and CI runners all need to run shell commands from code and react to whether they worked. subprocess.run is Python's way to do that: it runs a command, captures its output, and reports an exit code (0 means success, anything else means failure).
subprocess.run(cmd, capture_output=True, text=True)runs the command.capture_output=Truegrabs what it printed, andtext=Truehands it back as normal strings instead of raw bytes.if r.returncode != 0:checks the exit code. A non-zero code means the command failed, so the function raises an error with the details instead of silently continuing with bad data — this is "fail loudly".- On success it returns
r.stdout.strip()— the command's normal output with surrounding whitespace removed. - The two
printcalls pass commands as a list (["python3", "--version"],["ls"]). Each list item is one argument — no quoting or shell parsing needed.
What the output means: You see python: Python 3.x.x and the first three filenames from ls in the current folder.
Try this: Change a command to something that fails, like run(["ls", "/no/such/dir"]), and watch the function raise a clear RuntimeError with the exit code — exactly what you want a script to do when a step breaks.
shell=True with user inputsubprocess.run(f"rm {name}", shell=True) with an attacker-controlled name is command injection (xt1). Pass a list (["rm", name], no shell) and the shell can't be tricked.6 · Advanced — a real CLI with argparse advanced
Every useful script eventually escapes your laptop. You write a quick thing that counts lines in a file, a teammate asks for it, and now it needs to take options — which files, print a total or a breakdown, output JSON or text. The tempting move is to reach into sys.argv and start pulling arguments out by position: "the first one is the filename, the second is the mode…" That path leads directly to pain. You'll hand-write help text that drifts out of date, forget to validate types, produce cryptic errors when someone passes the wrong thing, and reinvent — badly — a wheel Python already ships perfectly round. argparse is that wheel.
Think of argparse as the difference between a handwritten note and a proper order form. A note ("bring milk maybe eggs?") is ambiguous and easy to misread. An order form has labelled fields, marks which are required, rejects nonsense before it reaches the kitchen, and comes with its own instructions printed on it. You describe your tool's inputs once — this argument is required, that one is a flag, this one must be an integer — and argparse handles the parsing, the type-checking, the error messages, and a generated --help screen for free. Users of professional tools like pip or git get that polished --help because those tools declared their arguments the same way you're about to.
The deeper lesson in this section isn't argparse itself — it's a design split that will follow you into every serious codebase: separate the logic from the wiring. Notice the code keeps the real work (count_lines) in a plain function that knows nothing about the command line, and lets a thin main() do nothing but translate command-line arguments into calls to that function. Why bother? Because a pure logic function is testable — you can call it directly with a list and check the result, no terminal needed — and reusable — another tool can import it. The beginner mistake is stuffing file-reading, argument-parsing, and printing all into one tangled function; it works once and can never be tested or reused. Keep the brain (logic) separate from the mouth and ears (I/O), and everything downstream gets easier.
Professional command-line tools parse flags, show --help, and return proper exit codes. argparse is the standard way. Keep the logic in a pure function (testable) and let main() wire the CLI.
main() that does nothing but translate flags into a call to a pure count_lines() — and because the logic is pure, a test or another tool can reach it directly, no terminal required.
Read the top row left to right — that's the normal path a user's command takes — then notice the two boxes underneath that reach the same logic by a shortcut.
- The solid blue arrows (top row) are the live path: raw command-line text enters
main(), which only parses flags with argparse, then hands the real work to the greencount_lines(). - The green box is the pure logic — it knows nothing about the command line, which is what makes it reusable.
- The dashed arrows from the bottom two boxes are the payoff: a unit test and another tool can call
count_lines()directly, bypassing the CLI entirely — impossible if file-reading, parsing, and printing were tangled into one function.
In short: a thin main() plus a pure logic function makes the tool testable and importable — the split is the whole design lesson.
linecount.pyimport argparse, sys
from pathlib import Path
def count_lines(paths): # PURE logic -> unit-testable
return {p: len(Path(p).read_text().splitlines())
for p in paths if Path(p).exists()}
def main(argv=None):
ap = argparse.ArgumentParser(prog="linecount", description="Count lines in files.")
ap.add_argument("paths", nargs="+")
ap.add_argument("-t", "--total", action="store_true", help="print grand total only")
args = ap.parse_args(argv)
counts = count_lines(args.paths)
if args.total:
print(sum(counts.values()))
else:
for p, n in counts.items(): print(f"{n:6} {p}")
return 0
Path("/tmp/df_cli.txt").write_text("a\nb\nc\n")
main(["/tmp/df_cli.txt"]) # normally: python linecount.py file ...
print("logic is testable:", count_lines(["/tmp/df_cli.txt"]))
3 /tmp/df_cli.txt
logic is testable: {'/tmp/df_cli.txt': 3}
A professional command-line tool reads flags, prints --help, and returns proper exit codes. argparse is Python's standard library for that. The key design idea shown here: keep the real work in a plain function, and let a thin main() handle the command-line wiring.
count_lines(paths)is the pure logic: given a list of file paths, it returns a dictionary of{path: line count}. It knows nothing about the command line, which makes it easy to test on its own.ArgumentParserdescribes the tool.add_argument("paths", nargs="+")says "accept one or more file paths", and the--totaloption withaction="store_true"is an on/off flag — present means true.args = ap.parse_args(argv)reads the command line intoargs. Then the code either prints the grand total (if--totalwas given) or one line per file.return 0signals success to the shell.- The last lines create a test file and call
main([...])directly so the demo runs here; normally you'd runpython linecount.py somefilefrom the terminal instead.
What the output means: It prints 3 /tmp/df_cli.txt then the same counts as a dictionary — see the console block below.
Try this: Add -t to the call: main(["-t", "/tmp/df_cli.txt"]). Now it prints just the total. That flag flowed from add_argument("--total") straight into your if args.total: branch.
7 · Advanced — robust shell scripts advanced
There's a specific, gut-dropping failure mode that has burned nearly every ops engineer once. A backup script runs on a schedule for months. One night a disk fills up, the cd into the backup folder fails — but the script doesn't notice, and the very next line, which was written assuming that cd worked, runs in the wrong directory. By default, bash's reaction to a failed command is a shrug: it prints an error and keeps going to the next line anyway. So the script marches on through steps that no longer make sense, and you discover the damage days later when you actually need the backup that was never made. Robust shell scripting is, more than anything, the discipline of refusing to let that happen.
The reason this catches people is that the shell's defaults are optimized for interactive use, not scripts. When you're typing commands by hand and one fails, you see it, so bash continuing is convenient. But a script has no human watching each line — so "keep going after an error" turns from convenient into dangerous. A good analogy is an assembly line with no quality check between stations: if station three jams and nobody stops the belt, stations four through ten keep bolting parts onto a broken product. You want the belt to halt the instant anything goes wrong, while the mistake is still one step, not ten.
That halt is exactly what the famous incantation set -euo pipefail buys you, and it belongs at the top of essentially every serious bash script. In plain terms: -e stops the script the moment any command fails, -u stops it if you reference a variable you never set (which catches the classic typo bug where $SOURCE becomes an empty string and rm -rf $SOURCE/ becomes rm -rf /), and pipefail makes a pipeline count as failed if any stage in it failed, not just the last one. The mistake here is philosophical, and worth naming: shell is superb glue for wiring tools together, but the moment your logic grows real branching, data structures, or error handling, that's your signal to switch to Python (section 5) — don't try to build a cathedral out of duct tape.
When the job is genuinely shell (glue, ops), write it defensively. set -euo pipefail makes it fail fast instead of limping on with bad state.
cd lets the next line run in the wrong place; set -euo pipefail stops the belt the instant anything fails — while the mistake is still one step, not ten.
Start at the top box — a cd that fails — then follow the two branches down: the left (red) is bash's default behaviour, the right (green) is with the safety flags on.
- The top box is the failing step: the script tried to
cdinto a directory and couldn't (say, the disk filled up). - The left red path is the default: bash shrugs and runs the next line anyway, so
tarexecutes in the wrong directory and the damage isn't noticed until days later. - The right green path shows
set -euo pipefail: the script halts on the spot with a non-zero exit code and a clear error, leaving no half-broken state behind.
In short: put set -euo pipefail at the top of every serious script so a failure stops the line instead of quietly corrupting everything downstream.
backup.sh#!/usr/bin/env bash
set -euo pipefail # exit on error / unset var / failed pipe
SRC="${1:?usage: ./backup.sh <folder>}" # require arg 1, else error
[ -d "$SRC" ] || { echo "not a directory: $SRC" >&2; exit 1; }
STAMP="$(date +%Y%m%d-%H%M%S)"
DEST="backup-${STAMP}.tar.gz"
tar -czf "$DEST" "$SRC"
echo "backed up $SRC -> $DEST ($(du -h "$DEST" | cut -f1))"
When the job really is shell (gluing tools together for ops), write it defensively so it stops the moment something goes wrong instead of continuing with a half-broken state. This little script makes a timestamped backup of a folder.
set -euo pipefailis the safety belt:-eexits on any failed command,-uerrors on an unset variable (a common typo bug), andpipefailmakes a pipe fail if any stage in it fails.SRC="${1:?usage...}"reads the first argument the user passed. The:?part means "if it's missing, print this usage message and stop" — so the script refuses to run without a folder.[ -d "$SRC" ]tests that$SRCis really a directory; if not, it prints an error to stderr (>&2) and exits with code1(failure).date +%Y%m%d-%H%M%Sbuilds a timestamp like20260906-141530for a unique filename, thentar -czfcreates a compressed.tar.gzarchive of the folder and the finalechoreports the result and its size.
What the output means: Run as ./backup.sh myfolder it prints something like backed up myfolder -> backup-20260906-141530.tar.gz (2.1M).
Try this: Run it with no argument at all. Thanks to ${1:?...} you'll get the usage message and the script stops immediately — that's the defensive style protecting you from a silent mistake.
8 · Professional — logging & observability in tools professional
Every tool starts its life littered with print statements. You sprinkle in print("got here") and print("value is", x) to see what's happening, it works, you ship it. Then it's 2 a.m., the tool has failed on a server you can't see, someone forwards you a wall of output, and you have no idea which line came from where, when it happened, or whether a given message was a routine note or the actual catastrophe. Every line looks equally important because print gives them all the same flat voice. This section is about the habit that separates a script from a tool: you stop printing and start logging.
The difference is like a scribbled sticky note versus a proper flight recorder. print just throws text at the screen. Logging attaches context to every message automatically: a timestamp (so you can line events up with an outage), a level that says how much this message matters — DEBUG for noise you only want while developing, INFO for normal progress, WARNING for "this is odd but survivable", ERROR for "something broke" — and a name saying which component spoke. The payoff is that in production you can turn the volume up or down without touching the code: run at WARNING and you hear only problems; flip to DEBUG when you're chasing a bug and the same tool narrates every step.
Then there's a subtlety that feels like trivia until the day it saves you: where the logs go. A Unix program has two output channels — stdout (standard out) for its real results, and stderr (standard error) for diagnostics. They look identical on your screen, so beginners assume they're the same thing. They're not, and the distinction is the whole game: if your tool's answer goes to stdout and its log chatter goes to stderr, then someone can pipe your tool into another (yourtool | wc -l) or redirect its output to a file, and get only the clean data — the timestamps and progress messages stay on the terminal where a human can watch them, without contaminating the machine-readable result. That's why the config below deliberately sends logs to stderr. The mistake is logging your diagnostics to stdout and quietly poisoning every pipeline your tool ever feeds.
Real tools log (not print) so ops can trace them: levels, timestamps, and to stderr so stdout stays parseable. This is the same discipline your services use.
Start at the purple box — your tool — and follow the two arrows down into its two separate output channels, then read what each channel enables in the row beneath it.
- The purple box produces two kinds of output at once: a real result and log messages.
- The left green path is
stdout— the tool's actual answer, clean data. The box below shows the payoff: you can pipe it intowc -lor redirect it to a file and get only that data. - The right amber path is
stderr— the timestamped diagnostics. Because it's a different channel, it stays visible on the terminal for a human even while stdout is being piped away.
In short: route the answer to stdout and the logs to stderr, and your tool composes cleanly into pipelines instead of poisoning them.
logging_tool.pyimport logging, sys
logging.basicConfig(
level=logging.INFO, stream=sys.stderr,
format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
log = logging.getLogger("mytool")
def process(items):
log.info("starting, %d items", len(items))
done = 0
for it in items:
try:
done += 1
except Exception:
log.exception("failed on %s", it) # logs the traceback
log.info("done, processed %d", done)
return done
print("result:", process(["a", "b", "c"]))
result: 3
Real tools log instead of using print, so operators can trace what happened: each message gets a level (INFO, ERROR) and a timestamp, and logs go to stderr so the tool's actual output on stdout stays clean and machine-readable.
logging.basicConfig(...)sets up logging once:level=logging.INFOshows INFO and above,stream=sys.stderrsends logs to the error stream, andformat=...puts a timestamp and level in front of every line.log = logging.getLogger("mytool")gets a named logger so messages are tagged with your tool's name.- Inside
process,log.info("starting, %d items", len(items))records progress. The%dis filled in with the count — logging does this substitution for you, which is cheaper than building the string yourself. - The
try/exceptguards each item;log.exception(...)logs the full error traceback if something throws, without crashing the whole run.
What the output means: The result: 3 line prints on stdout; the timestamped INFO lines print on stderr (shown mixed together on your screen, but they're separate streams).
Try this: Redirect stdout to a file when you run it (python logging_tool.py > out.txt) — the INFO log lines still appear on screen because they go to stderr, while out.txt holds only the clean result. That separation is the whole point.
9 · Tech-lead — ship the tool to the team tech-lead
Here's how a genuinely useful internal tool usually dies. You build something great. To run it, a colleague has to clone a repo, find the right folder, remember to activate a virtual environment, and type python scripts/tools/linecount.py with the full path every single time. Two people bother. Everyone else keeps doing the task by hand, because the friction of invoking your tool is higher than the pain it removes. The tool isn't bad — it's just not shippable, and a tech-lead's job is precisely to close that last mile so the whole team actually adopts it.
The goal is to make your tool behave like the commands people already trust. When you type git or black or pip, you don't specify where the program lives, don't activate anything, don't think about Python at all — you just type its name and it runs, from any directory. That "just type its name anywhere" magic isn't built into those tools; it's declared, in a package config file, by a single line called an entry point. It's the difference between a dish you cooked once and a recipe printed in a book anyone can follow: packaging is what turns your private script into shared infrastructure.
The mechanism is a pyproject.toml — the standard file that describes a Python package — and specifically its [project.scripts] section, which maps a command name to a function in your code. After a one-time pip install ., that name lands on everyone's PATH and behaves like any built-in command. Two things quietly make this possible, and they're the payoff of everything that came before: the tool must be a proper package (the packaging discipline you'll go deeper on in DF5), and its logic must be cleanly reachable through a main() entry point — which is exactly why section 6 insisted on separating pure logic from the CLI wiring. The common mistake is treating packaging as an afterthought bolted on at the end; in reality, the testable, importable structure you chose early is what makes shipping trivial now.
A lead makes the tool usable by everyone: an entry point so pip install puts it on the PATH, and the logic separated so it's testable (TQ). This is the DF5-packaging + testing payoff.
[project.scripts] line maps a name to your main(); after a one-time pip install . that name lands on everyone's PATH and runs from any directory — no virtualenv to activate, no full path to type, exactly like git or pip.
Read the top row left to right: the config file on the left becomes an installed command on the right, with the one-time install step in the middle.
- The left box is the
pyproject.tomlholding the single[project.scripts]line that maps the namelinecountto yourmain()function. - The middle box is the one-time
pip install .that builds and installs the package; the two blue arrows are "build" then "install". - The green box is the payoff — the command now lives on everyone's
PATH— and the bottom bar spells out why that matters: no virtualenv to activate, no full path to type, it just runs from any folder like the tools you already trust.
In short: declaring one entry point is what closes the last mile — it turns your private script into shared infrastructure the whole team can invoke by name.
pyproject.toml[project]
name = "team-tools"
version = "1.0.0"
requires-python = ">=3.10"
[project.scripts]
linecount = "team_tools.cli:main" # pip install . -> `linecount` on everyone's PATH
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The final step a team lead takes: make the tool installable so everyone can just type its name. pyproject.toml is the standard config file that describes a Python package and how to build it.
- The
[project]section is the package's identity card: itsname,version, and the minimum Python it needs (requires-python). - The
[project.scripts]section is the magic:linecount = "team_tools.cli:main"says "create a command calledlinecountthat runs themainfunction inteam_tools/cli.py". Afterpip install ., typinglinecountin any terminal just works. - The
[build-system]section names the tool that packages it up (hatchling) — standard boilerplate you rarely need to change.
Try this: This is why installed tools like pip or black are commands you can type anywhere: each one declared an entry point exactly like this [project.scripts] line.
count_lines() + a thin main() means the tool is unit-testable, importable by other tools, and safe to build on. That split is what turns a one-off script into shared infrastructure.Exercise DF1.1 — Build, harden & ship a CLI
Context: The payoff of this whole chapter is a single tool that is safe, observable, and installable by your whole team — the moment a one-off script becomes shared infrastructure.
Your task: Write a filestats CLI that reports line, word, and character counts with a --json flag, then harden and package it so it installs as a real command.
Requirements:
- Keep the counting in a pure function, separate from the CLI wiring
- Wire the flags (including
--json) inmain()with argparse - Run any untrusted paths through list-form
subprocess(nevershell=True) - Add
loggingto stderr so stdout carries only the result - Declare a
[project.scripts]entry point sopip installputs it on the PATH
💡 Hint: The pure-logic + thin-main() split is what makes the tool unit-testable and importable — decide the JSON-vs-text output in main, not in the counter.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Before you can do anything in a terminal you need to know where you are standing and what's around you. These three verbs are the ones you'll type thousands of times a day.
Your task: From a real terminal, print your current location, list everything in it (including hidden dot-files) in long form, then step into a subdirectory and back out again.
Requirements:
- Use
pwdto show the current working directory - List with
ls -laso hidden files and full details (perms, size, date) appear - Move in with
cd <dir>, then back up withcd .. - Confirm your location changed by running
pwdagain after each move - Know that
cd -jumps back to the previous directory
💡 Hint: Nothing here changes files on disk — pwd and ls only report, cd only moves you. Watch the prompt/path change as you go.
Show solution
Needs a terminal. The core navigation verbs:
pwd # print working directory: where am I?
ls -la # list all (-a incl. hidden) in long form (-l: perms, size, date)
cd project # move into ./project
pwd # now .../project
cd .. # back up one level
cd - # jump back to the previous directory
pwd / ls / cd are the three you use constantly; -la is
the flag combo worth memorizing.
Context: The Unix superpower is chaining tiny single-purpose tools with a pipe so one command's output becomes the next one's input. Counting and saving matching log lines is the canonical first taste of it.
Your task: In a terminal, count how many lines of app.log contain the word ERROR, and separately save just those matching lines into errors.txt.
Requirements:
- Filter with
grep ERROR app.logand pipe intowc -lto count - Use a pipe
|to feedgrep's output intowc - Redirect matching lines to a file with
grep ERROR app.log > errors.txt - Explain that
>overwrites while>>appends - Be able to state what each stage of the pipe contributes
💡 Hint: Build the pipe one stage at a time — run grep alone first, then add | wc -l — so you see the output change as each stage is added.
Show solution
Needs a terminal. A pipe (|) feeds one command’s output into the next;
> redirects output to a file:
grep ERROR app.log | wc -l # count ERROR lines (grep filters, wc -l counts)
grep ERROR app.log > errors.txt # write matching lines to a file (overwrites)
grep ERROR app.log | sort | uniq -c # bonus: how many of each distinct ERROR line
grep filters, wc -l counts, and > saves. Use >>
instead of > to append rather than overwrite.
Context: Once automation gets serious you do file work in Python instead of shell, because it is portable across Windows and Unix and easy to guard. pathlib is the modern way to do it.
Your task: Do file work portably from Python (not shell): find all .txt files under a directory tree and print each one's name and size in bytes. Make it self-contained so it runs clean.
Requirements:
- Use
pathlib.Path, not string path concatenation - Recurse and filter in one call with
rglob("*.txt") - Read each file's size from
Path.stat().st_size - Join paths with the
/operator so it works on any OS - Set up a temp directory so the demo needs no external files
💡 Hint: rglob recurses and filters in a single call; build the temp tree with tempfile.mkdtemp() so the block is fully runnable on its own.
Show solution
pathlib handles path joining/globbing portably (Windows and Unix) with no string
concatenation. This block runs clean (creates a temp dir, so no external files needed):
from pathlib import Path
import tempfile
# set up a temp tree so this is self-contained and runnable
root = Path(tempfile.mkdtemp())
(root / "a.txt").write_text("hello")
(root / "sub").mkdir()
(root / "sub" / "b.txt").write_text("world!!")
(root / "c.log").write_text("ignore me")
for p in sorted(root.rglob("*.txt")): # recursive glob, only .txt
print(p.relative_to(root), p.stat().st_size)
# a.txt 5
# sub/b.txt 7
rglob("*.txt") recurses and filters in one call; / joins paths safely — no
brittle string concatenation, no OS-specific separators.
Context: Build tools, deploy scripts, and CI runners all run shell commands from code and must react to whether they worked. Doing it safely is what separates a robust runner from a security hole.
Your task: Run a shell command from Python, capture its output, and handle a non-zero exit code without crashing — then explain why shell=True on user input is dangerous.
Requirements:
- Call
subprocess.runwith the command as a list of args, never a string - Pass
capture_output=Trueandtext=Trueto get stdout as a string - Inspect
returncodeand treat non-zero as failure instead of continuing - Demonstrate a failing command (e.g.
["false"]) handled gracefully - State that
shell=Truewith untrusted input invites command injection
💡 Hint: List-form args go straight to the program with no shell in between — that's both safer and more predictable than letting a shell parse a string.
Show solution
Use a list of args (not a string) and capture_output; check the return code.
shell=True on user-supplied input invites shell injection — avoid it. This runs clean:
import subprocess
# safe: args as a list, no shell parsing
r = subprocess.run(["echo", "hello from subprocess"],
capture_output=True, text=True)
print("stdout:", r.stdout.strip())
print("exit code:", r.returncode)
# handle failure without crashing:
r2 = subprocess.run(["false"], capture_output=True, text=True) # 'false' exits 1
if r2.returncode != 0:
print("command failed, code:", r2.returncode)
# DANGEROUS (do not do with untrusted input):
# subprocess.run(f"grep {user_input} file", shell=True) # shell injection risk
List-of-args passes arguments directly to the program with no shell in between, which is both safer and more
predictable than shell=True.
Context: A professional command-line tool reads flags, prints --help for free, and returns proper exit codes. The design trick is keeping the real work in a pure function so it stays testable.
Your task: Build a small CLI that takes a required --name and an optional --count (default 1), and prints a greeting that many times. Keep it runnable without a real terminal.
Requirements:
- Use
argparse.ArgumentParserwith--nameasrequired=True - Give
--countatype=intand adefaultof 1 - Factor the parser into its own function so
main(argv)is unit-testable - Pass args in-process (e.g.
main(["--name", "Sam", "--count", "2"])) so no terminal is needed - Get a generated
--helpfor free from argparse
💡 Hint: Let main accept an argv parameter and feed it a list; that's how you test argument handling without a shell.
Show solution
argparse gives you typed flags, defaults, validation, and a generated --help.
This block runs clean (it feeds args in-process so no terminal is required):
import argparse
def build_parser():
p = argparse.ArgumentParser(description="Greet someone N times.")
p.add_argument("--name", required=True, help="who to greet")
p.add_argument("--count", type=int, default=1, help="how many times")
return p
def main(argv=None):
args = build_parser().parse_args(argv)
for _ in range(args.count):
print(f"Hello, {args.name}!")
# normally: main() reads sys.argv. Here we pass args to keep it runnable/testable:
main(["--name", "Sam", "--count", "2"])
# Hello, Sam!
# Hello, Sam!
Factoring the parser out and letting main accept argv makes the CLI unit-testable —
you don’t need a real terminal to test argument handling.
Context: Real tools log instead of printing, so operators can trace them in production, and real shell glue fails fast instead of limping on with broken state. This is the production-hardening habit teams expect.
Your task: Make a tool team-ready: use the logging module (not print) with levels and timestamps, and show the shell-script header that makes a bash script fail safely.
Requirements:
- Configure
logging.basicConfigwith a level and a timestamped format - Emit at more than one level (e.g.
info,warning,error) - Log a caught exception with its traceback (
exc_info=True/log.exception) - Send logs to stderr so stdout stays clean and parseable
- Show a bash header with
set -euo pipefailand explain each flag
💡 Hint: Levels let the team dial verbosity in prod without code changes; set -euo pipefail is the one-line habit that turns a fragile script into a safe one.
Show solution
Logging gives levels, timestamps, and routing that print can’t — the Python block
runs clean:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("mytool")
log.info("starting run")
log.warning("input file was empty, using default")
try:
1 / 0
except ZeroDivisionError:
log.error("computation failed", exc_info=True) # logs the traceback
log.info("done")
Robust bash header — needs a terminal/bash — makes a shell script fail fast instead of limping on after an error:
#!/usr/bin/env bash
set -euo pipefail # -e: exit on error, -u: error on unset vars,
# pipefail: a failing command in a pipe fails the script
IFS=$'\n\t' # safer word-splitting
Levels (INFO/WARNING/ERROR) let the team dial verbosity in prod without code changes, and
set -euo pipefail is the one-line habit that turns a fragile script into a safe one.
✓ Checkpoint — you can move on when you can…
- Navigate and manipulate files; compose piped commands.
- Do file/OS work from Python with pathlib + subprocess (safely).
- Build a CLI with pure logic + argparse; write a defensive shell script.
- Add logging and package the tool for the team.
Knowledge check check yourself
When driving the shell from Python with subprocess.run, why pass the command as a list rather than using shell=True with user input?
Show answer
["rm", name]) means no shell parses the arguments, so a malicious value can't be interpreted as extra commands; shell=True with attacker-controlled input is command injection.Why does a robust CLI keep its logic in a pure function and use a thin main(), and why log to stderr rather than print to stdout?
Show answer
count_lines) is unit-testable and importable while a thin main() just wires up argparse; logging goes to stderr so the tool's real output on stdout stays clean and machine-parseable.