The other tracks on this site are mostly about the front of a command-line tool: how it is packaged and installed, how its commands are structured, how it parses input and presents output. This track is about the back — everything that happens after the arguments have been parsed and the tool starts doing its job. A real CLI runs other programs, reads and writes files it must not corrupt, calls web APIs that are sometimes slow or down, does several things at once, handles credentials that must never leak, and sometimes runs for hours under a supervisor or on a schedule. Each of those is a boundary between your Python code and the rest of the system, and each has its own failure modes that a quick manual test will never reveal.
It is written for the people who build internal tools, DevOps automation and data tooling in Python: tools that deploy things, sync things, migrate things and check things. Those tools tend to start as a script, gain users, and then fail in production in ways that come down to the same handful of mistakes — a command string handed to a shell, a file overwritten in place, a request with no timeout, a thread pool that ignores Ctrl+C, a token printed in a debug log, a cron job that silently stopped running. The six topics below take each boundary in turn and show the patterns that make it dependable.
What you will learn
This section is organised into six topics, each with a detailed overview and four or five in-depth guides with runnable, tested code:
- Running subprocesses from Python CLIs — calling external programs safely with argument lists, streaming their output, enforcing timeouts that kill whole process trees, translating their failures into your own exit codes, wrapping tools like git in typed modules, and closing the shell-injection hole.
- Filesystem paths and atomic writes — writing files that cannot be left half-written, using
pathlibso paths work on every platform, putting your tool's config, cache and state where the operating system expects them, creating temporary files safely, and locking against concurrent runs. - Calling HTTP APIs from Python CLIs — building an API client module over
httpx, choosing timeouts, retrying with backoff only when it is safe, paginating without silently truncating, logging in with the OAuth device flow, and downloading large files with progress and resume. - Concurrency and async in Python CLIs — choosing between threads, asyncio and processes, bounding concurrency, running async code from Typer and Click, cancelling everything cleanly on Ctrl+C, and staying inside an API's rate limits.
- Secrets and credentials in Python CLIs — storing tokens in the system keychain, accepting secrets from environment variables and mounted files, prompting securely, redacting secrets from logs and tracebacks, and supporting several accounts through profiles.
- Long-running and watch-mode CLIs — shutting down gracefully on
SIGTERM, building a debounced watch mode, running reliably from cron and systemd timers, and exposing health so that stalled processes and missed runs get noticed.
One pattern, six boundaries
Before the topics, one idea that runs through all of them. Every boundary in this section — a child process, the filesystem, the network, the credential store, the operating system's signals — deserves the same treatment, and applying it consistently is what keeps a growing CLI understandable.
Wrap each boundary in one module. All calls to git go through git.py; all HTTP calls go through api.py; all writes of important files go through files.py; all credential lookups go through credentials.py. Commands never call subprocess.run, httpx.get or open(path, "w") directly. That one rule has three payoffs. Fixes apply everywhere at once — when you discover that a child needs LC_ALL=C or that a request needs a longer read timeout, you change one line. Tests have exactly one seam to replace per boundary, so command tests run offline and in milliseconds. And the command layer reads like the requirements rather than like plumbing.
Give every boundary a limit. Subprocesses get timeouts; HTTP requests get connect and read timeouts; concurrent work gets a pool size and a rate limit; long-running loops get a grace period for shutdown. An operation without a limit is a potential hang, and a CLI that hangs is worse than one that fails, because a failure at least says something.
Translate failures into one error type per module, then into exit codes. The user asked to run your command. A CalledProcessError, an httpx.ConnectError or a FileNotFoundError from deep inside is an implementation detail; what they need is a sentence explaining what went wrong and what to do, and scripts need an exit code that distinguishes "try again later" from "fix your configuration".
The codes in that table are conventions other tools already use: 124 for timeouts (from timeout(1)), 127 for missing programs (from shells), 69 and 75 from sysexits.h for "service unavailable" and "temporary failure", and 128 plus the signal number for signals. Using them means a script wrapping your tool can react sensibly without reading your documentation. The general theory is in choosing exit codes for CLI tools.
Running other programs
Almost every automation CLI eventually shells out: to git, rsync, docker, terraform, kubectl, a compiler or a linter. The subprocess module makes it easy to start a program and surprisingly easy to get the details wrong.
The most important rule is to pass argument lists, not command strings. subprocess.run(["git", "log", "-n", str(n)]) executes git directly with exactly those arguments; subprocess.run(f"git log -n {n}", shell=True) hands a string to /bin/sh, which will interpret any semicolon, backtick or $(...) that ends up in it. Argument lists fix the security problem and the everyday bug — paths with spaces — at the same time. The second rule is to decide every behaviour on purpose: check the exit code (check=True), decode output with an explicit encoding, set a timeout, and close stdin for children that should not prompt.
import subprocess
def git(*args: str) -> str:
return subprocess.run(
["git", *args], check=True, capture_output=True, text=True,
encoding="utf-8", timeout=60, stdin=subprocess.DEVNULL,
).stdout.strip()
Beyond the basics, the subprocess topic covers the problems that appear as tools mature: output that arrives in bursts because the child buffers when writing to a pipe, deadlocks when reading two pipes naively, timeouts that kill npm but leave its node children running, and signal deaths reported as negative return codes. Start with calling external commands safely with subprocess, then streaming subprocess output in real time and handling subprocess timeouts and exit codes. If your tool calls one program heavily, wrapping git and other tools from a Python CLI shows how to turn it into a small typed library, and avoiding shell injection in Python CLIs covers the security layer in depth.
Files that survive crashes and platforms
A CLI's worst bugs are the ones that damage data, and file handling is where that happens. Opening a file with "w" truncates it immediately; a crash, a Ctrl+C or a full disk before the write completes leaves it empty. The fix is the atomic write: write to a temporary file in the same directory, flush and fsync it, then os.replace it over the original, so readers only ever see the complete old version or the complete new one.
import os
import tempfile
from pathlib import Path
def write_atomic(path: Path, text: str) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
The rest of the filesystem topic deals with portability and placement. pathlib removes a whole family of platform bugs caused by treating paths as strings. platformdirs puts your tool's own files — config the user edits, state the tool keeps, caches it can rebuild — in the directories Linux, macOS and Windows each designate for them, instead of scattering dot-files across the home directory or the user's projects. tempfile creates scratch space with unpredictable names that cleans up after itself, even on Ctrl+C. And when two runs of your tool can touch the same state — cron overlapping with itself, two terminals, a CI matrix — an operating-system lock prevents lost updates that atomic writes alone cannot. The guides are writing files atomically in Python CLIs, cross-platform paths with pathlib, storing app data with platformdirs, safe temporary files and directories and file locking for concurrent CLI runs.
Talking to web APIs
A large share of internal CLIs are front ends to an API. The patterns that keep them dependable are straightforward once named. Create one httpx.Client per run — configured with a base URL, authentication, timeouts and a User-Agent that includes your tool's version — and reuse it so connections are pooled. Put every endpoint behind a function in an API module that returns typed objects and raises one error type, so commands can render tables or JSON without knowing HTTP exists. Set connect and read timeouts deliberately, because a request without one is a potential hang.
When things go wrong, respond according to who caused it. Client errors (4xx) are reported, clearly, using the server's own message where it helps. Server errors, rate limits and network failures are retried — a few times, with capped exponential backoff and jitter, honouring Retry-After, and only for requests that are safe to repeat. Lists are fetched with generators that follow the API's pagination, so a --limit stops early and --all streams rather than collecting everything first. For authentication, the OAuth device flow gives a CLI SSO-compatible login without ever handling a password. And large downloads stream to a partial file with a progress bar, verify a checksum and rename into place, resuming with a Range request if interrupted.
Each of those has a guide: building an API client CLI with httpx, retries and backoff for CLI HTTP calls, paginating API results in a CLI, OAuth device flow login for CLIs and downloading files with progress in Python.
Doing several things at once
Concurrency is the most effective performance tool a CLI has — checking 300 URLs with sixteen workers is roughly sixteen times faster than one at a time — and the one most likely to make it misbehave. The concurrency topic starts from a simple choice: threads or asyncio for work that waits, processes for work that computes. Standard CPython's global interpreter lock means threads do not speed up pure-Python computation, but they are excellent for overlapping network and disk waits, and they let you keep existing synchronous code.
Three habits make concurrent commands safe. Keep all concurrency inside one function that returns ordinary results, so commands stay synchronous and testable. Bound it with a pool size or semaphore exposed as --jobs. And collect failures as values — one bad item should not sink a batch of three hundred — reporting them together at the end with an exit code that reflects the whole run.
Stopping is where concurrent CLIs most often disappoint. Ctrl+C should cancel queued work, let running work clean up, report what was done and exit with 130; with asyncio, modern Python turns the first Ctrl+C into cancellation that propagates through every task in a TaskGroup, provided your code does not swallow CancelledError. And when concurrency meets a rate-limited API, a token bucket keeps you under the quota instead of provoking a stream of 429 responses. The guides: running async code in Typer and Click, parallelising CLI work with thread pools, multiprocessing for CPU-bound CLI tasks, cancelling async tasks on Ctrl+C and rate-limiting concurrent requests in CLIs.
Keeping credentials secret
CLIs handle powerful credentials and have more ways to leak them than most software. Command-line arguments are visible to every user on the machine and saved in shell history; config files get backed up and published in dotfile repositories; debug logs and tracebacks get pasted into issue trackers; environment variables are inherited by every child process. The secrets topic addresses each channel.
For people, tokens belong in the operating system's keychain via keyring, obtained through mytool auth login from a hidden prompt, stdin or a browser-based device flow, never from an argument. For automation, credentials come from environment variables and — better, for containers — mounted files, using the _FILE convention operators already know. Inside the tool, secrets are wrapped in a type whose string form is masked, so accidental printing is harmless, and a redacting logging filter masks registered values and common token patterns in everything written to stderr or log files, including third-party library output and tracebacks. For people with several accounts, named profiles keep settings in config and each account's token in its own keychain entry, with the active profile always visible and production guarded by confirmation.
The guides are storing tokens with keyring, reading secrets from env and files, prompting for passwords securely, redacting secrets from CLI output and logs and supporting multiple profiles and accounts.
Commands that keep running
Finally, some commands do not exit after a second: watch modes, workers under systemd or Kubernetes, and jobs run nightly from a scheduler. They are stopped by signals rather than by finishing — and Python's default response to SIGTERM, the signal every supervisor sends, is to die immediately without running any cleanup. The long-running topic shows how to treat SIGTERM like Ctrl+C, structure loops around bounded units of work with interruptible waits, exit with the conventional 128 + signal codes, and remember that as PID 1 in a container you get no default signal handling at all.
For developers, a good --watch flag debounces file events into one rebuild per save, ignores its own output directory so it cannot loop, and keeps watching after a failed build. For scheduled runs, commands must be non-interactive, idempotent, overlap-safe and explicit about paths, and systemd timers add logging, catch-up of missed runs and overlap prevention on top of cron. And because a process that exists is not necessarily a process that works, a heartbeat written after each unit of work plus a health command gives probes and people a way to tell the difference, while a dead-man switch catches the scheduled job that silently stopped running. The guides: handling SIGTERM and graceful shutdown, building a watch mode with watchfiles, running a CLI on a schedule with cron and systemd and health checks and heartbeats for long-running CLIs.
Testing code that touches the system
Code at these boundaries has a reputation for being hard to test, and it is — if the boundary calls are scattered through the commands. With each boundary wrapped in one module, testing splits cleanly into two kinds.
Unit tests replace the boundary. A command that deploys by calling git.current_branch(), api.list_builds() and files.write_atomic() can be tested by substituting fakes for those three functions and asserting on what the command did with them. No subprocess runs, no network is touched, no real file is written, and the test takes milliseconds. httpx.MockTransport replaces the network below your API module; monkeypatch.setattr replaces a runner function; tmp_path gives every test a private directory; keyring.set_keyring() installs an in-memory credential store; and injected clock and sleep functions make retry, rate-limit and timeout logic deterministic.
import httpx
from typer.testing import CliRunner
from mytool import cli
from mytool.api import make_client
def test_status_command_offline(monkeypatch, tmp_path):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"name": "web", "owner": "ana", "build_count": 3}])
monkeypatch.setattr(cli, "client_factory",
lambda url, token: make_client(url, token, transport=httpx.MockTransport(handler)))
monkeypatch.setenv("MYTOOL_TOKEN", "test-token")
monkeypatch.setenv("MYTOOL_STATE_DIR", str(tmp_path))
result = CliRunner().invoke(cli.app, ["projects", "--json"])
assert result.exit_code == 0
A few integration tests use the real thing. Parsing git's porcelain output should be tested against a real repository created in tmp_path; signal handling should be tested by sending a real SIGTERM to a real subprocess; a watch mode should see a real file change. Keep these few, fast and clearly marked, so they can be skipped where the tool or platform is unavailable — a Windows runner has no SIGTERM, a slim container may have no git.
Each guide in this section ends with a "Testing the behaviour" section in exactly this spirit, and the general techniques are collected in mocking filesystem and network in CLI tests.
How this fits with the rest of the site
This section assumes a CLI that is already well structured and well packaged, and it connects back to the other tracks at many points:
- The command structure these patterns plug into — thin commands, shared state on the Click context, dependency injection at the edge — is covered in Modern Python CLI Frameworks & Architecture, especially dependency injection patterns for CLI commands.
- The user-facing side of every failure described here — messages, exit codes, logging, TTY detection — lives in Advanced Input Parsing & User Experience, particularly error handling and exit codes and structured logging for CLI apps.
- Testing the boundaries without touching the real system — mock transports, temporary directories, in-memory keyrings, fake clocks — builds on testing Python CLI applications.
- Installing the tool so schedulers and supervisors can find it at a stable absolute path is covered in Project Setup & Dependency Management, including installing and distributing CLIs with pipx.
A suggested path through the section
If you are reading the section as a whole rather than looking for one answer, this order builds each habit on the one before:
Start with subprocesses, because argument lists, explicit timeouts and error translation are the simplest form of the boundary pattern. Files come next, because atomic writes and locks are needed by nearly everything after. HTTP builds the full client-module pattern. Concurrency multiplies whatever the HTTP and subprocess layers do, so it helps to have those solid first. Secrets cut across everything, and long-running commands pull every previous topic together — signals, locks, retries, credentials from the environment and health reporting.
Key takeaways
- Every boundary your CLI crosses — processes, files, network, credentials, signals — deserves one wrapping module, a limit, and one error type mapped to a meaningful exit code.
- Pass argument lists to
subprocess, never command strings, and set timeouts that stop whole process trees. - Write important files atomically, keep your tool's files in
platformdirslocations, and lock shared state. - Use one configured
httpx.Client, deliberate timeouts, safe retries with backoff, and generator-based pagination. - Match concurrency to the work, bound it, collect failures as values and design for Ctrl+C from the start.
- Keep secrets out of argv, config files and logs; use the keychain for people and env vars or files for automation.
- Treat
SIGTERMlike Ctrl+C, make scheduled commands idempotent and overlap-safe, and publish a heartbeat.
Frequently asked questions
Do I need all of this for a small internal script?
No. For a fifty-line script run by its author, most of this is overkill. The patterns start paying for themselves when a tool has other users, runs unattended, or touches anything important: that is when a torn config file, a hung request or a leaked token stops being a curiosity and becomes an incident. Adopt them boundary by boundary as the tool grows.
Why does the site favour httpx, keyring, platformdirs and watchfiles?
Each is the current, actively maintained, cross-platform default for its job, with a small API and good test support. None is mandatory: requests, appdirs, watchdog and others work, and the patterns — one client module, the right directories, debounced events — transfer directly.
How do these patterns affect startup time?
Libraries like httpx, keyring and Rich each add import time that you pay on every invocation, including --help and shell completion. Import them inside the modules that need them and load those lazily from the commands that use them, as described in CLI startup performance and lazy loading.
Is Windows supported by everything here?
Mostly. Subprocess argument lists, pathlib, platformdirs, tempfile, filelock, httpx, keyring and asyncio all work on Windows. The exceptions are POSIX-specific: process groups and SIGTERM handling, fcntl-level details, and cron and systemd. Each guide calls out the Windows differences where they matter.
Related
- Down: Running subprocesses from Python CLIs
- Down: Filesystem paths and atomic writes
- Down: Calling HTTP APIs from Python CLIs
- Down: Concurrency and async in Python CLIs
- Down: Secrets and credentials in Python CLIs
- Down: Long-running and watch-mode CLIs
- Sideways: Modern Python CLI Frameworks & Architecture
- Sideways: Advanced Input Parsing & User Experience
- Sideways: Project Setup & Dependency Management