Filter concepts by levelShowing all levels.

Python · Section 45

CLI and Automation

Level
intermediate
Read
120 min
Concepts
8

Building a real command-line tool with argparse subcommands, the environment-variable and exit-code conventions automation depends on, running and chaining subprocesses safely, and the shape a script needs to run unattended — scheduled by cron, as a data migration, a batch job, or deployment and developer tooling.

This section

What is true here

  1. add_subparsers() structures a multi-command CLI so each subcommand owns its own arguments and handler.
  2. os.environ.get(key, default) reads configuration safely; sys.exit(code) is the one channel automation actually checks.
  3. subprocess.run(cmd, check=True) turns a failed step into a stopped script instead of one that silently continues.
  4. A script invoked by cron must log (not print), be idempotent, and signal outcome only through its exit code.
  5. Data migrations, batch jobs, and maintenance scripts share one shape: read/transform/write in chunks, with --dry-run and a summary count.

What you will be able to do

  • Structure a CLI with add_subparsers() instead of a flat, growing list of flags
  • Read configuration from os.environ safely and report success/failure through sys.exit(code)
  • Chain subprocess calls with check=True so a failed step stops the script instead of continuing unnoticed
  • Write a script that is safe for cron to invoke repeatedly — logged, idempotent, and correct about its own exit code
  • Design a data migration or batch job around fixed-size, independently retriable chunks with a dry-run pass
  • Gate a deployment or developer-tooling step on a wrapped tool's exit code rather than parsing its printed output

Building a CLI

Structuring a command-line tool with subcommands, and the environment-variable and exit-code conventions every CLI and automation script is expected to follow.

CLI application structure

coreintermediate

add_subparsers() splits one program into named subcommands (tool migrate, tool status), each with its own arguments and its own function — instead of one parser with a growing pile of flags.

Think of it as

A CLI with one flat argument list is one long form with every field visible at once, whether relevant or not. Subcommands split that into separate forms — migrate has --target, status has none — and each routes to its own handler function, the same way separate view functions handle separate URL routes.

python
parser = argparse.ArgumentParser(prog="tool")
sub = parser.add_subparsers(dest="command", required=True)

migrate_p = sub.add_parser("migrate")
migrate_p.add_argument("--target", default="latest")
migrate_p.set_defaults(func=cmd_migrate)

args = parser.parse_args()
args.func(args)   # dispatches without an if/elif chain

What we're doing: Build a two-subcommand CLI (migrate, status) where each subcommand owns its own arguments and dispatches to its own function.

dbtool.pypython
import argparse

def cmd_migrate(args):
    return f"migrating to {args.target}"

def cmd_status(args):
    return "status: ok"

parser = argparse.ArgumentParser(prog="dbtool")
sub = parser.add_subparsers(dest="command", required=True)

migrate_p = sub.add_parser("migrate", help="apply pending migrations")
migrate_p.add_argument("--target", default="latest")
migrate_p.set_defaults(func=cmd_migrate)

status_p = sub.add_parser("status", help="show migration status")
status_p.set_defaults(func=cmd_status)

args = parser.parse_args(["migrate", "--target", "0007"])
print(args.func(args))

args2 = parser.parse_args(["status"])
print(args2.func(args2))
9
add_subparsers(dest="command", required=True) turns dbtool into a program that needs a subcommand — dbtool migrate, dbtool status.
10
sub.add_parser("migrate") creates a full ArgumentParser scoped to migrate; --target added here does not exist under status.
13
set_defaults(func=cmd_migrate) attaches the handler directly to the parsed args — no if args.command == "migrate": chain needed.
18
args.func(args) calls whichever handler matched the subcommand that was actually given.
Output
migrating to 0007
status: ok

Why this works: Each subcommand gets its own ArgumentParser scoped under add_subparsers(), so migrate can require --target while status needs no arguments at all — and set_defaults(func=...) lets parse_args() itself carry the correct handler, replacing a manual if/elif dispatch on args.command.

Growing one flat argument list instead of splitting into subcommands

Wrong

python
parser = argparse.ArgumentParser(prog="dbtool")
parser.add_argument("--migrate", action="store_true")
parser.add_argument("--status", action="store_true")
parser.add_argument("--target", default="latest")   # only relevant to --migrate

args = parser.parse_args(["--status"])
if args.migrate:
    print(f"migrating to {args.target}")
elif args.status:
    print("status: ok")
# --target shows in --help even when running --status, and nothing stops
# --migrate --status being passed together

Better

python
sub = parser.add_subparsers(dest="command", required=True)
migrate_p = sub.add_parser("migrate")
migrate_p.add_argument("--target", default="latest")
status_p = sub.add_parser("status")

args = parser.parse_args(["status"])
# --target simply does not exist for "status" -- no stray flag, no combining

What you see: --help lists every flag for every operation at once, unrelated flags can be combined by mistake (--migrate --status), and the handler needs an if/elif chain to figure out which operation actually ran.

Why: Boolean flags for each operation do not stop two of them being passed together, and a flag meaningful only to one operation (--target) still shows in --help for all of them. Subparsers make each subcommand its own scoped argument set — the ambiguity does not exist to guard against.

One CLI, two subcommands, two handlers
command =="migrate"command =="status"

sys.argv

dbtool migrate --target 0007

ArgumentParser

add_subparsers(dest="command")

migrate subparser

--target flag, func=cmd_migrate

status subparser

func=cmd_status

args.func(args)

runs the matched handler

  • sys.argv — dbtool migrate --target 0007
    • leads to ArgumentParser
  • ArgumentParser — add_subparsers(dest="command")
    • leads to migrate subparser (command == "migrate")
    • leads to status subparser (command == "status")
  • migrate subparser — --target flag, func=cmd_migrate
    • leads to args.func(args)
  • status subparser — func=cmd_status
    • leads to args.func(args)
  • args.func(args) — runs the matched handler

Structuring a multi-command CLI

Structuring a multi-command CLI
CallEffect
parser.add_subparsers(dest="command")enables tool <command> ... syntax; args.command holds which one ran
sub.add_parser("migrate")a full ArgumentParser scoped to the migrate subcommand only
migrate_p.set_defaults(func=cmd_migrate)args.func is cmd_migrate after parsing — call it directly, no dispatch chain
parser.add_argument_group("connection")groups related flags under a labeled heading in --help
required=True on add_subparsersparse_args() errors if no subcommand is given, instead of silently doing nothing

Together

python
import argparse

def cmd_migrate(args):
    return f"migrating to {args.target}"

def cmd_status(args):
    return "status: ok"

parser = argparse.ArgumentParser(prog="dbtool")
sub = parser.add_subparsers(dest="command", required=True)

migrate_p = sub.add_parser("migrate", help="apply pending migrations")
migrate_p.add_argument("--target", default="latest")
migrate_p.set_defaults(func=cmd_migrate)

status_p = sub.add_parser("status", help="show migration status")
status_p.set_defaults(func=cmd_status)

args = parser.parse_args(["migrate", "--target", "0007"])
print(args.func(args))

Remember: add_subparsers() turns one flat argument list into named subcommands, each with its own arguments and its own set_defaults(func=...) handler — no if/elif dispatch needed.

See also: argparse module · environment variables and exit codes · deployment and developer tooling

Environment variables and exit codes

standardintermediate

os.environ.get("KEY", default) reads configuration without crashing on a missing key; sys.exit(code) reports success (0) or failure (nonzero) to whatever called the script.

Think of it as

A CLI script has two channels a caller reads: its printed output, and its exit code. A human reads the output; a shell script, CI job, or cron wrapper reads only the exit code — os.environ is how configuration comes IN without hardcoding it, and sys.exit(code) is how success or failure goes OUT in a form automation can check.

python
import os, sys

api_key = os.environ.get("API_KEY")
if api_key is None:
    print("API_KEY is not set", file=sys.stderr)
    sys.exit(1)

sys.exit(0)   # success

What we're doing: Read a required setting from the environment with a fallback, and exit with a distinct code depending on how a subprocess run finishes.

check_config.pypython
import os
import sys
import subprocess

os.environ["APP_ENV"] = "production"
env = os.environ.get("APP_ENV", "development")
print("env:", env)

missing = os.environ.get("MISSING_VAR", "default-value")
print("missing:", missing)

ok = subprocess.run([sys.executable, "-c", "import sys; sys.exit(0)"])
print("ok exit code:", ok.returncode)

failed = subprocess.run([sys.executable, "-c", "import sys; sys.exit(1)"])
print("fail exit code:", failed.returncode)
6
os.environ.get("APP_ENV", "development") returns the real value when set — "production" here, since line 5 set it.
9
MISSING_VAR was never set, so .get() returns "default-value" instead of raising KeyError.
12
A subprocess that exits cleanly (sys.exit(0)) reports returncode 0 back to the parent — the standard success signal.
15
A subprocess that calls sys.exit(1) reports returncode 1 — the parent can branch on this without reading any output.
Output
env: production
missing: default-value
ok exit code: 0
fail exit code: 1

Why this works: os.environ.get() with a default is the safe read for optional configuration; every subprocess or script that finishes runs sys.exit(code) implicitly or explicitly, and that integer — not anything printed — is what a shell, CI pipeline, or cron wrapper actually inspects to decide what happened.

Remember: os.environ.get("KEY", default) reads config without crashing on a missing key; sys.exit(0) means success, any nonzero code means failure — that is what automation actually checks, not the printed text.

See also: os sys subprocess · cli application structure · subprocesses and shell integration

Subprocesses and shell integration

coreintermediate

subprocess.run(cmd, check=True) runs an external command and raises CalledProcessError on a nonzero exit, instead of leaving a script to silently continue after a failed step — the pattern real automation scripts use to fail loudly.

Think of it as

A Python script automating shell work is a conductor, not a participant — it starts other programs, waits for each to finish, and reads back what happened. check=True is the difference between a conductor who notices a missed cue and one who plays on regardless: without it, a failed step's nonzero exit code is silently ignored and the script proceeds as if nothing went wrong.

python
import subprocess

try:
    subprocess.run(["some-tool", "--flag"], check=True)
except subprocess.CalledProcessError as e:
    print("failed with code", e.returncode)

What we're doing: Capture one subprocess's stdout, filter it in Python (replacing a shell pipeline), and use check=True to catch a failing step.

pipeline.pypython
import subprocess
import sys

listing = subprocess.run(
    [sys.executable, "-c", "print('apple\nbanana\navocado\ncherry')"],
    capture_output=True, text=True,
)
lines = listing.stdout.splitlines()
filtered = [line for line in lines if line.startswith("a")]
print(filtered)

try:
    subprocess.run([sys.executable, "-c", "import sys; sys.exit(2)"], check=True)
except subprocess.CalledProcessError as e:
    print("caught:", e.returncode)
4
The first subprocess prints four lines; capture_output=True, text=True captures them as one string, not to the terminal.
8
listing.stdout.splitlines() turns the captured output into a list of lines, exactly like piping into a filter command would.
9
The filtering itself happens in Python, not a shell | grep — this is the same result with no shell dependency.
12
check=True makes this call raise instead of returning a CompletedProcess with returncode=2 that nothing inspects.
Output
['apple', 'avocado']
caught: 2

Why this works: Capturing stdout as text and processing it with normal Python (splitlines, a list comprehension) replaces a shell pipeline's | without spawning a shell — and check=True converts a silently-ignored nonzero exit code into a Python exception the script is forced to handle or crash on, matching how a well-written automation script should fail.

Passing a single shell-syntax string to subprocess.run() without shell=True

Wrong

python
import subprocess

# treated as ONE literal program name, not parsed into words
subprocess.run("ls -la /tmp")   # FileNotFoundError

Better

python
import subprocess

# a list -- each argument is separate, no shell parsing needed
subprocess.run(["ls", "-la", "/tmp"])

# OR, only if a real shell feature (pipes, globbing) is required:
subprocess.run("ls -la /tmp", shell=True)

What you see: FileNotFoundError: no such file or directory — the whole string "ls -la /tmp" is treated as one program name to find.

Why: Without shell=True, subprocess.run expects a list where the first item is the program and the rest are its arguments, exactly as the OS process-creation API expects them — it does not parse a string into words the way a shell does.

A script chaining two subprocess steps, failing loudly on step 2
exit code 0exit code!= 0

step 1

subprocess.run(..., capture_output=True)

captured stdout

read into Python as a string

step 2

subprocess.run(cmd2, check=True)

script continues

CalledProcessError raised

script stops, nonzero exit propagates

  • step 1 — subprocess.run(..., capture_output=True)
    • leads to captured stdout
  • captured stdout — read into Python as a string
    • leads to step 2
  • step 2 — subprocess.run(cmd2, check=True)
    • leads to script continues (exit code 0)
    • on error, leads to CalledProcessError raised (exit code != 0)
  • script continues
  • CalledProcessError raised — script stops, nonzero exit propagates

subprocess patterns for automation scripts

subprocess patterns for automation scripts
CallEffect
subprocess.run(cmd, check=True)raises CalledProcessError if the command exits nonzero — the script cannot silently continue
except subprocess.CalledProcessError as ecatches a failed command; e.returncode holds its exit code
subprocess.run(cmd, capture_output=True, text=True).stdoutcaptures the command's output as a string for the script to process further
subprocess.run("cmd1 | cmd2", shell=True)runs a real shell pipeline — only when the shell feature itself is required
subprocess.run([sys.executable, "-c", code])runs another Python process — useful for isolating a step's environment or crash

Together

python
import subprocess, sys

listing = subprocess.run(
    [sys.executable, "-c", "print('apple\nbanana\navocado\ncherry')"],
    capture_output=True, text=True,
)
filtered = [line for line in listing.stdout.splitlines() if line.startswith("a")]
print(filtered)

try:
    subprocess.run([sys.executable, "-c", "import sys; sys.exit(2)"], check=True)
except subprocess.CalledProcessError as e:
    print("caught:", e.returncode)

Remember: subprocess.run(cmd, check=True) raises on a failed command instead of silently continuing; pass a list of args (not a shell string) unless shell=True is a deliberate, justified choice.

See also: os sys subprocess · environment variables and exit codes · automation and maintenance scripts

Advertisement

Unattended and scheduled work

What changes when a script runs on a schedule or in a pipeline instead of in front of a person: cron-safety, idempotency, logging over printing.

Scheduling and cron

standardintermediate

cron runs a script on a schedule from outside the process; a script invoked by cron must be idempotent and exit nonzero on failure. A long-running process instead schedules its own work in-process, typically with APScheduler.

Think of it as

There are two different places a schedule can live: outside the process, as an OS-level trigger that starts a new script run (cron) — or inside a long-running process, as a library that fires callbacks on a timer without the process ever exiting (APScheduler). Cron does not know or care what the script does; the script is responsible for being safe to run again, cleanly, if the last run partly failed.

python
# cron-invoked script: log, don't print; exit nonzero on failure
import logging, sys

logging.basicConfig(filename="/var/log/cleanup.log", level=logging.INFO)

try:
    run_cleanup()
except Exception:
    logging.exception("cleanup failed")
    sys.exit(1)
sys.exit(0)

What we're doing: Write a script designed to be invoked by cron: it logs instead of printing, is safe to re-run, and reports failure through its exit code.

cron_cleanup.pypython
import logging
import sys
from io import StringIO

log_stream = StringIO()
logger = logging.getLogger("cleanup_script")
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler(log_stream))

processed = 0
errors = 0
items = ["file1.tmp", "file2.tmp", "bad.lock"]
for item in items:
    if item.endswith(".tmp"):
        processed += 1
        logger.info("removed %s", item)
    else:
        errors += 1
        logger.warning("skipped %s", item)

print(f"processed={processed} errors={errors}")
exit_code = 0 if errors == 0 else 1
print("exit_code:", exit_code)
6
A named logger, not print(), so a cron-invoked run leaves a record even with no terminal attached — cron typically redirects stdout to /dev/null or a mail digest.
15
Deleting only .tmp files, keyed off a stable condition, is what makes a second identical run safe — it does not re-delete or double-count anything.
21
The script computes its own exit code from what actually happened, rather than always exiting 0 regardless of outcome.
Output
processed=2 errors=1
exit_code: 1

Why this works: cron reads only the exit code to decide whether the job succeeded — exit_code = 0 if errors == 0 else 1 makes that decision reflect what actually happened, instead of a script that always exits 0 because nothing computed otherwise. The logger records what a human reviewing later needs, since nothing was watching the terminal at run time.

A cron-invoked script that always exits 0, hiding real failures

Wrong

python
def main():
    try:
        run_cleanup()
    except Exception as e:
        print(f"error: {e}")   # printed output is discarded by cron
    # falls through to a normal exit — exit code 0 either way

if __name__ == "__main__":
    main()

Better

python
import sys

def main():
    try:
        run_cleanup()
    except Exception:
        logging.exception("cleanup failed")
        sys.exit(1)   # cron's failure-notification logic can now see it

if __name__ == "__main__":
    main()

What you see: cron never sends a failure notification because the job "succeeded" from its point of view — the exit code was always 0, no matter what happened inside.

Why: cron's only signal for success or failure is the process exit code, not anything printed — printed text with no terminal attached is typically discarded or buried in a rarely-read mail digest. A script that catches every exception and always falls through to exit 0 makes every cron failure invisible.

Remember: A cron-invoked script must log (not print), be safe to re-run, and exit nonzero on failure — cron's failure detection reads only the exit code. A long-running process schedules its own work in-process instead, typically with APScheduler.

See also: environment variables and exit codes · automation and maintenance scripts · os sys subprocess

Automation scripts and maintenance scripts

standardintermediate

An automation or maintenance script (cleanup, log rotation, cache eviction) is designed to run unattended and repeatedly — it logs what it did, is safe to re-run, and reports a count instead of relying on a human watching it.

Think of it as

An interactive script is written for a person watching the terminal in real time; an automation script is written for nobody watching at all. That changes what "good" looks like: instead of a friendly prompt, it needs a log line for every action taken, a final summary count, and behavior that is safe if the previous run was interrupted halfway through.

python
import logging

logger = logging.getLogger("cleanup")
processed = errors = 0
for item in items_to_check():
    if is_stale(item):
        remove(item)
        processed += 1
        logger.info("removed %s", item)
logger.info("done: processed=%d errors=%d", processed, errors)

What we're doing: Write a maintenance script that logs each action, tracks a processed/errors count, and computes its exit code from that count.

cleanup_tmp_files.pypython
import logging
from io import StringIO

log_stream = StringIO()
logger = logging.getLogger("cleanup_script")
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler(log_stream))

processed = 0
errors = 0
items = ["file1.tmp", "file2.tmp", "bad.lock"]
for item in items:
    if item.endswith(".tmp"):
        processed += 1
        logger.info("removed %s", item)
    else:
        errors += 1
        logger.warning("skipped %s", item)

print(f"processed={processed} errors={errors}")
exit_code = 0 if errors == 0 else 1
print("exit_code:", exit_code)
12
Only files ending in .tmp are removed — a narrow, explicit condition rather than "everything in this directory".
14
Every removal is logged individually, so a later review can see exactly which files were touched.
16
An item that does not match is counted as skipped and logged as a warning, not silently ignored.
19
The exit code is derived from the errors count computed during the run, not hardcoded.
Output
processed=2 errors=1

Why this works: Tracking processed/errors counters while iterating, rather than just performing the actions, is what turns "the script ran" into "the script did N things and skipped M" — the difference a human reviewing a log actually needs to trust an unattended run.

A cleanup script with no dry-run mode, tested for the first time against real data

Wrong

python
for item in items_to_check():
    if is_stale(item):
        remove(item)   # first time this runs against real data is production

Better

python
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()

for item in items_to_check():
    if is_stale(item):
        if args.dry_run:
            logger.info("would remove %s", item)
        else:
            remove(item)
            logger.info("removed %s", item)

What you see: A staleness bug (is_stale() flags the wrong items) is discovered only after the destructive action already ran against real data, with no way to preview what would have happened.

Why: --dry-run lets the exact same selection logic run and log its decisions without performing the destructive step — the first real-data run of a maintenance script should always be one that only reports, given how costly a wrong deletion is to undo.

Remember: A maintenance script logs each action, tracks a processed/errors summary, supports --dry-run for anything destructive, and is safe to run twice — write for nobody watching, not for a live terminal.

See also: scheduling and cron · subprocesses and shell integration · batch jobs · logging module

Advertisement

Applied automation

Where these patterns show up in practice: transforming existing data, processing large inputs in chunks, and automating a deployment or developer workflow.

Data migrations

referenceintermediate

A data migration script transforms existing records from an old shape to a new one — read, transform, write — and should support --dry-run and a way to verify results before the change is irreversible.

Think of it as

A schema migration changes the shape of a table; a data migration changes what is already stored inside it (renaming a field's values, backfilling a new column, merging duplicate records). It runs once, or a small number of times, and unlike a maintenance script, its correctness cannot be re-checked afterward by simply rerunning it — get it wrong, and the "before" data is already gone.

python
def migrate_record(record):
    return {**record, "status": record["status"].lower()}   # transform

for batch in chunked(fetch_records(), size=500):
    transformed = [migrate_record(r) for r in batch]
    if not dry_run:
        write_batch(transformed)
    logger.info("migrated batch of %d", len(batch))

Remember: A data migration is read/transform/write over existing records — run it with --dry-run first, process in batches, and keep a way to verify the result, since the pre-migration data may not come back.

See also: batch jobs · automation and maintenance scripts

Batch jobs

referenceintermediate

A batch job processes a large collection in fixed-size chunks (e.g. 500 records at a time) instead of one at a time or all in memory at once — bounding both memory use and the size of a single failure.

Think of it as

Processing one record at a time is safe but slow — every record pays its own overhead. Loading everything at once is fast but risky — a crash partway through loses all progress and a large enough input runs out of memory. Batching splits the difference: chunk-sized units of work, each committed or logged independently, so a failure loses at most one chunk's progress.

python
def chunked(iterable, size):
    chunk = []
    for item in iterable:
        chunk.append(item)
        if len(chunk) == size:
            yield chunk
            chunk = []
    if chunk:
        yield chunk

for batch in chunked(records, size=500):
    process_batch(batch)

What we're doing: Split ten records into fixed-size batches of three and process each batch, tracking a running total.

batch_process.pypython
def chunked(iterable, size):
    chunk = []
    for item in iterable:
        chunk.append(item)
        if len(chunk) == size:
            yield chunk
            chunk = []
    if chunk:
        yield chunk

records = list(range(1, 11))
batches = list(chunked(records, 3))
total_processed = 0
for i, batch in enumerate(batches, start=1):
    total_processed += len(batch)
    print(f"batch {i}: {batch} -> processed {len(batch)}")
print("total_processed:", total_processed)
1
chunked() is a generator — it yields one batch at a time instead of building the full list of batches in memory first.
12
10 records split into batches of 3 gives four batches: three full ones and one final partial batch of 1.
13
Each batch is processed and logged independently — a failure on batch 3 would still leave batches 1 and 2 done.
Output
batch 1: [1, 2, 3] -> processed 3
batch 2: [4, 5, 6] -> processed 3
batch 3: [7, 8, 9] -> processed 3
batch 4: [10] -> processed 1
total_processed: 10

Why this works: chunked() as a generator means only one batch (at most `size` items) is ever in memory at a time, regardless of how large `records` grows — and processing each batch as its own unit of work means the running total, and any failure, is scoped to one batch instead of the entire input.

Remember: A batch job processes fixed-size chunks — bounded memory, and a failure loses at most one chunk's worth of progress instead of the whole run.

See also: data migrations · automation and maintenance scripts

Deployment tooling and developer tooling

standardintermediate

Deployment and developer-tooling scripts wrap other command-line tools (a build, a linter, git) with subprocess, and gate their own next step on that tool's exit code — not by parsing its printed output.

Think of it as

A deployment or dev-tooling script is a coordinator over tools that already exist — it does not reimplement what git, a build tool, or a linter already does. Its job is sequencing: run this step, check whether it succeeded, and only then run the next one — the exit code is the interface between steps, not scraped text.

python
steps = [
    ["python", "-m", "build"],
    ["twine", "check", "dist/*"],
    ["twine", "upload", "dist/*"],
]
for step in steps:
    logger.info("running: %s", " ".join(step))
    subprocess.run(step, check=True)   # stops at the first failing step

What we're doing: Check a required tool's version, then gate the next deployment step on a prior check's exit code.

deploy_gate.pypython
import subprocess
import sys

result = subprocess.run([sys.executable, "--version"], capture_output=True, text=True)
print(result.stdout.strip() or result.stderr.strip())

# simulate a pre-deploy check: gate on its exit code, not its text
check = subprocess.run([sys.executable, "-c", "import sys; sys.exit(0)"])
if check.returncode == 0:
    print("check passed, proceeding")
else:
    print("check failed, aborting")
4
Confirming the tool's version before running steps that assume it — the same pattern as checking a required Python or build-tool version in CI.
8
The pre-deploy check runs as its own subprocess; only check.returncode is inspected, not any text it printed.
9
The deployment logic branches directly on the exit code — 0 means proceed, anything else means abort.
Output
Python 3.14.3
check passed, proceeding

Why this works: Gating on check.returncode == 0 rather than scanning stdout for a string like "PASSED" is what keeps the script correct if the wrapped tool ever changes its wording — the exit code is the one part of a CLI tool's interface meant to be depended on by callers.

Deciding success by scanning a wrapped tool's printed output instead of its exit code

Wrong

python
result = subprocess.run(["pytest"], capture_output=True, text=True)
if "failed" not in result.stdout:   # brittle -- depends on exact wording
    deploy()

Better

python
result = subprocess.run(["pytest"])
if result.returncode == 0:   # pytest's documented contract: 0 means all passed
    deploy()

What you see: A deploy proceeds even though tests failed, because the word "failed" happened not to appear in stdout that run — or a deploy is blocked by a false positive because a test name itself contained the word "failed".

Why: A tool's exit code is its documented, stable success/failure contract; its printed text is not — wording, color codes, and verbosity can all change between versions. Scripts that gate on scraped text break silently the moment the wrapped tool's output format changes.

Remember: A deployment or dev-tooling script chains subprocess calls and gates each next step on the previous one's exit code (check=True or returncode == 0) — never on scraping printed text, which is not a stable contract.

See also: subprocesses and shell integration · cli application structure · automation and maintenance scripts

Advertisement