AI EngineeringZero to ProductionHome·About·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 essentialexpert 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.

Three commands cover most movement: pwd (where), ls (what), cd (go). Paths are either absolute (/home/you/x) or relative (./x, ../x).

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

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

The Unix superpower: small single-purpose tools chained with pipes (|). grep filters, sort/uniq/wc transform, >/>> redirect to files.

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

Real automation does file work in Python, portably, with pathlib. Runs top-to-bottom in a temp dir — 100% safe to execute.

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

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.

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

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.

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

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.

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

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.

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

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.

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 headerneeds 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