The Process Contract: How Automation Scripts Talk to Their Environment
A Python automation script has no framework standing between it and the outside world.
Where a web app inherits request and response semantics from FastAPI or Django, a script invents its own contract with whatever runs it - a shell, a cron daemon, a CI runner, or another script piping into it.
Scripting Basics shows the syntax for that contract: argparse, exit codes, logging setup.
This page is the model underneath it - why Unix expresses success, failure, input, and output through so few primitives, and why every other page in this section keeps returning to them.
Summary
- A script's entire interface to the world is a small, fixed set of Unix primitives - argv, environment variables, stdio streams, exit codes, and signals - not a framework-defined request cycle.
- Insight: Cron, CI runners, and shell pipelines only understand these primitives, so a script that gets them wrong is invisible or misleading to whatever is supposed to react to it.
- Key Concepts: exit code, stdio stream, environment variable, signal, idempotency.
- When to Use: Writing anything invoked by cron, a CI step, a shell pipeline, or another script rather than a human watching a terminal.
- Limitations/Trade-offs: These primitives are coarse - an exit code is a single integer, not a structured error - so expressive failure reporting has to be built deliberately on top of them.
- Related Topics: subprocess and shell interop, scheduling and cron, structured logging, robust script design.
Foundations
Shell scripting predates Python by decades, and a Python script inherits exactly the same contract as any other Unix executable, because the operating system does not know or care which language produced it.
To the kernel, python job.py and a compiled C binary look identical: a process with a handful of input and output channels.
A useful mental picture is a black box with five doors.
Two are inbound before the process starts: argv (command-line arguments) and the environment (inherited key-value strings).
One is inbound while running: stdin, if the script reads piped or typed input.
Two are outbound: the stdio streams stdout and stderr, and the exit code, a single integer the process returns when it terminates.
A sixth channel, signals, is asynchronous and bidirectional - the OS or another process can interrupt a running script at any moment, not just at start or end.
Every convention in this section - argparse for argv, os.environ for config, logging to stderr, raise SystemExit(code) - is a way of using these doors correctly, not an arbitrary style choice.
Mechanics & Interactions
Cron does not read your code, your docstrings, or your intentions - it reads exactly two things: the process's exit code, and whatever it wrote to stdout and stderr, usually mailed to an operator on nonzero exit.
That is the entire reason the convention "log to stderr, reserve stdout for machine-readable output" exists: a script piped into another tool (myjob.py | jq .) pollutes that pipeline the moment a log line lands on stdout instead of stderr.
CI systems apply the same rule at the step level - a nonzero exit code fails the step and often the whole pipeline, regardless of how informative the printed output was.
Signals complicate the picture because they arrive on their own schedule, not at a point in your code you chose.
SIGINT (Ctrl-C) and SIGTERM (a polite request to stop, sent by orchestrators, timeouts, and kill by default) can both be caught and handled, letting a script close files, release locks, or finish a write before exiting.
SIGKILL, in contrast, cannot be intercepted at all - the process is terminated immediately, mid-write if necessary, which is why any script writing files should assume a SIGKILL can land between two lines of code and design its writes to survive that.
import signal
import sys
def handle_sigterm(signum, frame):
# flush state, close files, release a lock file here
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)This same contract nests: when a script calls subprocess.run(...), the script becomes the "environment" for that child process, deciding what argv and env it receives and what to do with its exit code and captured output - subprocess & Shell Interop is this exact model applied one level deeper.
Idempotency, the theme of Robust Scripts, follows directly from this contract too: because cron does not retry failed jobs on its own, and workflow engines or on-call operators frequently do rerun a failed invocation manually, the contract implicitly requires that running the same script twice with the same inputs is safe.
Advanced Considerations & Applications
The primitive contract scales down to a five-line script and up to a fleet of scheduled jobs, but the tooling wrapped around it changes as the stakes rise.
A single cron line is fine for a low-stakes nightly job; a systemd timer adds structured logging via journalctl and dependency ordering; a workflow engine (Airflow, Prefect, Dagster) adds retries, backoff, and a UI, but underneath it is still invoking a process and reading its exit code.
Concurrency is a real risk once a job runs on any recurring schedule: if a job occasionally takes longer than its interval, two instances can overlap and corrupt shared state, which is why file-based or Redis-based locks are common even for "simple" cron jobs.
Environment variables, the conventional channel for secrets in scripts, are visible to anything that can read /proc/<pid>/environ on Linux or list process arguments, so a secret placed directly in argv is a weaker choice than one read from environment or a secrets file with restricted permissions.
| Invocation Model | Strength | Weakness | Best Fit |
|---|---|---|---|
| Plain cron | Zero extra infrastructure, universally available | No retries, weak observability, silent overlap risk | Simple, low-stakes periodic jobs |
| systemd timer | Structured logs, dependency ordering, restart policy | Linux-only, more config than cron | Single-host jobs that need better observability |
| Workflow engine (Airflow/Prefect) | Retries, backoff, dependency graphs, UI | Real infrastructure to run and maintain | Multi-step pipelines with failure handling needs |
| Message queue worker | Elastic scaling, natural retry via redelivery | Requires a queue and worker process to operate | High-volume, event-driven automation |
Common Misconceptions
- "Exit code 0 always means the job succeeded." It only means the process did not crash or call
sys.exitwith a nonzero value; a script can exit 0 while silently skipping its real work unless it explicitly checks and reports that. - "
print()is good enough for logging." Printed output cannot distinguish an operational log line from data a downstream pipe consumer expects on stdout, which is exactly whyloggingto stderr is the convention. - "Cron retries failed jobs automatically." Cron only runs jobs on schedule; if a run fails, cron will simply try again at the next scheduled time, not immediately, unless retry logic is built into the script or a wrapper.
- "
SIGTERMandSIGKILLare basically the same signal."SIGTERMcan be caught and handled for a graceful shutdown;SIGKILLcannot be intercepted under any circumstances, so it should be treated as "anything could be mid-write when this happens." - "A script that works when I run it manually will behave the same under cron." Cron runs with a minimal environment - no interactive shell profile, a different
PATH, noHOMEassumptions - so scripts relying on interactive shell setup can fail silently only under cron.
FAQs
What exactly counts as "the contract" for a script?
Argv, environment variables, stdin, stdout, stderr, the exit code, and signals - the fixed set of channels the OS and any invoking process (shell, cron, CI) actually observe.
Why does cron care about stdout separately from stderr?
Cron traditionally mails whatever a job writes to stdout and stderr to an operator, and any downstream pipe consumer only reads stdout, so mixing log noise into stdout breaks both use cases.
How does a script's exit code interact with shell `&&` and `||`?
&& runs the next command only if the previous exit code was 0, and || only if it was nonzero, which is why documenting and testing your script's exact exit codes matters for anything chained in shell logic.
Why might a script fail under cron but work fine when I run it by hand?
- Cron uses a minimal, non-interactive environment
PATHoften excludes directories your interactive shell adds- Relative paths resolve against cron's working directory, not your terminal's
What is the practical difference between SIGINT and SIGTERM for a script?
Both can be caught and handled the same way in Python via signal.signal, but SIGINT typically comes from an interactive Ctrl-C while SIGTERM is the standard polite-stop signal sent by process managers, orchestrators, and kill by default.
Does every short script need a signal handler?
No - a script that only reads data and exits in milliseconds has little exposed to interruption, but any script that writes files, holds a lock, or runs longer than a few seconds benefits from handling SIGTERM gracefully.
How does idempotency relate to this contract?
Because nothing in the contract guarantees a script runs exactly once - cron reruns on schedule, operators rerun failed jobs manually, workflow engines retry - a script has to make repeated runs with the same input safe on its own.
Why can't a script return richer error information through its exit code alone?
An exit code is one integer, conventionally 0-255, so it can only distinguish a small number of broad outcomes; anything more specific (which record failed, what the error was) has to travel through logs or a separate output channel.
How does `subprocess` nest this same model?
Your script becomes the "shell" for the child process: it sets the child's argv and environment, and then has to decide what to do with the child's exit code and captured stdout/stderr, exactly mirroring its own contract with its own caller.
Is there a standard set of exit codes worth following?
Many teams loosely follow the BSD sysexits.h conventions (0 success, 1 general failure, 2 usage error), which is enough consistency for operators and CI to reason about failures without inventing a scheme per script.
Does the contract change for a long-running script or daemon?
The same primitives apply, but signal handling becomes central rather than optional, since a long-running process is far more likely to receive SIGTERM mid-operation from a deploy, restart, or scaling event.
How do workflow engines like Airflow or Prefect relate to this model?
They still launch a process and read its exit code under the hood, but they add a scheduling, retry, and dependency layer on top so that the raw contract is rarely dealt with directly once a pipeline outgrows a handful of cron lines.
Related
- Scripting Basics - the argparse, logging, and exit code syntax this page's model sits underneath
- subprocess & Shell Interop - the same contract applied to a child process your script controls
- Scheduling & Cron - how the contract gets invoked on a recurring basis
- Robust Scripts - the idempotency and dry-run patterns this contract requires
- System Scripting Best Practices - the operability checklist distilled from this model
Stack versions: This page is conceptual and not tied to a specific stack version.