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.

Was this helpful?

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.

Was this helpful?

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.

Was this helpful?

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.

Was this helpful?

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.

Was this helpful?

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.

Was this helpful?

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.

Was this helpful?

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.

Was this helpful?

Spotted a mistake on this page? Tell us.