AI EngineeringZero to ProductionHome·About·What’s new·Contact
Developer Foundations · Chapter DF1

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.

⏱️ ~3.5 hours🧪 11 labs🎯 Beginner→Tech-lead
🌱 Start here — from zero The command line, from scratch — the terminal is just typing commands — and Python can drive it, which is where automation begins.

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):

TermWhat it actually means
shellthe program that runs typed commands (bash/zsh).
pipesend one command's output into another: a | b.
subprocessPython running shell commands + capturing output.
exit code0 = success, non-zero = failure; how scripts chain safely.
argparsePython'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.
▶ Runnable companionCode saved under code/df1-command-line/. Python runs offline; configs are ready to use.

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).

the shell always stands in exactly one folder — that's the working directory / /home /etc you projects ← you are here app.log absolute path: /home/you/app.log — from the root relative path: ../app.log — from where you stand Every command runs from one folder. An absolute path starts at the root / 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.
🗺️ How to read this diagram

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 contains you, which contains projects.
  • 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.log can 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
shell · move around
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
▶ How this works

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.

  1. pwd means print working directory — it prints the full path of the folder you're currently sitting in, like /home/you.
  2. ls -la lists what's in the current folder. The -l flag makes it a detailed "long" listing (sizes, dates, permissions) and -a shows all files, including hidden ones whose names start with a dot.
  3. cd projects changes directory into a folder called projects. The comment lists the handy shortcuts: cd .. goes up one level, cd ~ jumps to your home folder, and cd - hops back to the previous folder.
  4. tree -L 1 draws 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.

same starting file, two operators, very different results log.txt (before) line A · line B echo X > log.txt echo X >> log.txt OVERWRITE line A and line B are gone file now = just: X APPEND line A · line B kept file now = A · B · X one arrow replaces the whole page; two arrows add a line at the bottom > 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.
🗺️ How to read this diagram

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.

shell · manipulate files
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
▶ How this works

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.

  1. mkdir -p demo/sub makes a folder demo and a folder sub inside it in one go; -p means "create parents too, and don't complain if they already exist". && means "only run the next command if this one succeeded", then cd demo moves inside.
  2. touch a.txt creates an empty file. The single > then writes hello into it, replacing whatever was there; the double >> appends a new line instead of overwriting. That difference matters a lot.
  3. cat a.txt prints the file's contents to the screen; wc -l a.txt counts the lines in it.
  4. cp a.txt b.txt copies the file, mv b.txt sub/c.txt moves (and renames) it into the sub folder, and rm a.txt deletes 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.

a pipe (|) feeds each command's output straight into the next as input cat log raw text awk pick IP sort line up uniq -c count dups sort -rn by count head top few | | | | | each station does ONE thing and passes the result along the belt out: the busiest IP addresses A pipe is an assembly line. Six tiny tools, none knowing what the others do, snap together with | 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.
🗺️ How to read this diagram

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: awk pulls out the IP, sort groups identical lines together, uniq -c counts each group, sort -rn orders those counts high-to-low, and head keeps 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.

shell · compose tools
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
▶ How this works

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.

  1. grep "ERROR" app.log finds every line containing ERROR; piping | wc -l counts those lines — so the whole line answers "how many errors?".
  2. grep -rn "TODO" src/ searches recursively (-r, through every file under src/) and prints line numbers (-n) — great for finding leftover to-dos.
  3. The access.log line is a four-stage pipe: pull the first column (the IP) with awk, sort them, uniq -c counts duplicates, sort -rn orders by count high-to-low, and head shows the top few — your busiest visitors.
  4. The last two lines show redirection: > py_files.txt saves output into a file instead of the screen, and 2> errors.txt sends 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.

a path as a string vs a path as an object string — brittle text Path — an object base + "/logs/" + name glued with + and hard slashes base / "logs" / name joined with the / operator wrong separator on Windows .name · .stat().st_size · .exists() breaks across machines right separator for any OS A 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.
🗺️ How to read this diagram

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 Path object 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 Path version 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.

Python · pathlib operations (runs)
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
▶ How this works

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.

  1. Path(tempfile.mkdtemp()) makes a fresh temporary folder just for this demo, so it can't touch your real files. (base / "logs").mkdir() creates a logs folder inside it — notice / joins path pieces here, it is not division.
  2. f.write_text(...) writes four lines into a new app.log. The \n markers are newlines, so it's really four separate log lines.
  3. f.read_text().splitlines() reads the file back and splits it into a list of lines. The next line keeps only the lines that startswith("ERROR") — the Python equivalent of grep.
  4. The glob("*.log") loop finds every file ending in .log (just like ls *.log) and prints each one's name and size in bytes from child.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.

a hostile filename: rm ; rm -rf ~ — two ways to hand it to subprocess SAFE · list form DANGER · shell=True ["rm", name] f"rm {name}" no shell — args go straight to the program a shell PARSES the string the ; starts a 2nd command "rm ; rm -rf ~" = one weird filename home directory deleted The list form has no shell to trick. Passing ["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.
🗺️ How to read this diagram

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.

Python · run commands safely (runs)
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
▶ How this works

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).

  1. subprocess.run(cmd, capture_output=True, text=True) runs the command. capture_output=True grabs what it printed, and text=True hands it back as normal strings instead of raw bytes.
  2. 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".
  3. On success it returns r.stdout.strip() — the command's normal output with surrounding whitespace removed.
  4. The two print calls 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.

List form, never 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.

keep the brain (logic) separate from the mouth and ears (I/O) command line --total file … main() — thin only wires argparse count_lines() PURE logic parse call unit test no terminal needed another tool import count_lines Logic separate from wiring. The command line flows through a thin 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.
🗺️ How to read this diagram

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 green count_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.

Python · a CLI with pure logic + thin main (runs)
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}
▶ How this works

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.

  1. 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.
  2. ArgumentParser describes the tool. add_argument("paths", nargs="+") says "accept one or more file paths", and the --total option with action="store_true" is an on/off flag — present means true.
  3. args = ap.parse_args(argv) reads the command line into args. Then the code either prints the grand total (if --total was given) or one line per file. return 0 signals success to the shell.
  4. The last lines create a test file and call main([...]) directly so the demo runs here; normally you'd run python linecount.py somefile from 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.

step 2 (cd into backup dir) FAILS — what happens next? cd backups ✗ default bash set -euo pipefail shrugs — runs next line HALTS immediately tar runs in WRONG dir; damage found days later nonzero exit, clear error, no half-broken state Fail fast, not limp on. By default bash prints an error and keeps going, so a failed 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.
🗺️ How to read this diagram

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 cd into 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 tar executes 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.

shell · a safe backup script
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))"
▶ How this works

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.

  1. set -euo pipefail is the safety belt: -e exits on any failed command, -u errors on an unset variable (a common typo bug), and pipefail makes a pipe fail if any stage in it fails.
  2. 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.
  3. [ -d "$SRC" ] tests that $SRC is really a directory; if not, it prints an error to stderr (>&2) and exits with code 1 (failure).
  4. date +%Y%m%d-%H%M%S builds a timestamp like 20260906-141530 for a unique filename, then tar -czf creates a compressed .tar.gz archive of the folder and the final echo reports 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.

one tool, two separate channels — that's the whole trick your tool result + log messages stdout · the answer clean data stderr · the diagnostics 12:03 INFO started … | wc -l or > out.txt — stays clean stays on the terminal for a human Answer on stdout, chatter on stderr. Sending the real result to stdout and the timestamped log messages to stderr means someone can pipe or redirect the tool and get only the clean data — the diagnostics stay on the terminal without contaminating the machine-readable output.
🗺️ How to read this diagram

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 into wc -l or 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.

Python · a tool that logs properly (runs)
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
▶ How this works

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.

  1. logging.basicConfig(...) sets up logging once: level=logging.INFO shows INFO and above, stream=sys.stderr sends logs to the error stream, and format=... puts a timestamp and level in front of every line.
  2. log = logging.getLogger("mytool") gets a named logger so messages are tagged with your tool's name.
  3. Inside process, log.info("starting, %d items", len(items)) records progress. The %d is filled in with the count — logging does this substitution for you, which is cheaper than building the string yourself.
  4. The try/except guards 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.

one config line → a command everyone can type anywhere pyproject.toml [project.scripts] linecount = cli:main pip install . one time on the PATH $ linecount … runs from any folder build install no venv to activate · no full path · just like git / pip An entry point turns a script into a command. One [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.
🗺️ How to read this diagram

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.toml holding the single [project.scripts] line that maps the name linecount to your main() 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.

config · pyproject entry point
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"
▶ How this works

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.

  1. The [project] section is the package's identity card: its name, version, and the minimum Python it needs (requires-python).
  2. The [project.scripts] section is the magic: linecount = "team_tools.cli:main" says "create a command called linecount that runs the main function in team_tools/cli.py". After pip install ., typing linecount in any terminal just works.
  3. 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.

Separate logic from I/O — the reusable moveA pure 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) in main() with argparse
  • Run any untrusted paths through list-form subprocess (never shell=True)
  • Add logging to stderr so stdout carries only the result
  • Declare a [project.scripts] entry point so pip install puts 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.

Exercise 1 · Navigate and listBeginner

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 pwd to show the current working directory
  • List with ls -la so hidden files and full details (perms, size, date) appear
  • Move in with cd <dir>, then back up with cd ..
  • Confirm your location changed by running pwd again 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.

Exercise 2 · Pipes, redirection & searchIntermediate

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.log and pipe into wc -l to count
  • Use a pipe | to feed grep's output into wc
  • 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.

Exercise 3 · File & OS work from Python with pathlibAdvanced

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.

Exercise 4 · Drive the shell from Python with subprocessExpert

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.run with the command as a list of args, never a string
  • Pass capture_output=True and text=True to get stdout as a string
  • Inspect returncode and treat non-zero as failure instead of continuing
  • Demonstrate a failing command (e.g. ["false"]) handled gracefully
  • State that shell=True with 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.

Exercise 5 · A real CLI with argparseProfessional

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.ArgumentParser with --name as required=True
  • Give --count a type=int and a default of 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 --help for 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.

Exercise 6 · Ship the tool: logging + a robust scriptIndustry scenario

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.basicConfig with 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 pipefail and 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

✓ Knowledge check

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
Passing a list (e.g. ["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.
✓ Knowledge check

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
A pure logic function (e.g. 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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in