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
Three commands cover most movement: pwd (where), ls (what), cd (go). Paths are either absolute (/home/you/x) or relative (./x, ../x).
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
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
The Unix superpower: small single-purpose tools chained with pipes (|). grep filters, sort/uniq/wc transform, >/>> redirect to files.
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
Real automation does file work in Python, portably, with pathlib. Runs top-to-bottom in a temp dir — 100% safe to execute.
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
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.
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
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.
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
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.
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
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.
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
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.
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.