Put Output, Errors, and Exit Status in the Right Place
Build a pipe-friendly command that reads standard input, preserves machine-readable stdout, explains anticipated failures on stderr, and reports success through a documented exit status.
You will learn: Route data and diagnostics to separate streams, translate anticipated failures at the command boundary, return meaningful status, and compare direct stream checks with a real child-process check.
Practice in: Google Colab or JupyterLab for StringIO, plus a local terminal for pipes, redirects, and exit status
Our parser can describe trail-report check - --status closed. The dash says that report lines should arrive through standard input. The next program in a pipeline expects JSON Lines, while a person still needs to know why a damaged line was rejected.
One print() for everything cannot satisfy both readers. This lesson gives each result a deliberate channel.
As you work, answer these questions:
What belongs on stdin, stdout, and stderr?
Why is stable stdout an interface for another program rather than a screen decoration?
How is a returned data value different from a process exit status?
Which exceptions should a command boundary translate into concise messages?
What does a subprocess check prove that a direct function call cannot?
1. Follow one line through a pipeline
A running command normally begins with three text streams:
Stream
Python object
Direction
Typical CLI role
standard input
sys.stdin
into the process
records from a pipe, redirected file, or keyboard
standard output
sys.stdout
out of the process
the promised result, suitable for another program
standard error
sys.stderr
out of the process
diagnostics and operational logs
The process also finishes with an integer exit status. That status is not a text stream and not the result data. It is a compact signal to the shell or calling process.
The two outgoing text streams and the exit status are independent observations; a caller can capture each without mixing them.
flowchart LR
A["stdin text"] --> B["trail-report process"]
B --> C["stdout data"]
B --> D["stderr diagnostics"]
B --> E["integer exit status"]
This shell pipeline sends the first command’s stdout into the second command’s stdin:
Syntax varies between shells, especially for redirecting stderr. The Python design should not depend on the operator using a particular redirect. It should simply honor the stream contract.
2. Read text without assuming a file path
Core record processing can accept any iterable of text lines:
def cleaned_lines(lines):"""Yield non-blank input lines without their surrounding whitespace."""for raw_line in lines: line = raw_line.strip()if line:yield line
It also works when lines is an open text file or sys.stdin. That is the benefit of accepting the narrow behavior needed—iteration over text—rather than opening a global path inside the core.
At the command boundary, choose the source and own its lifetime:
from contextlib import nullcontextfrom pathlib import Pathdef open_input(input_name, stdin):"""Return a context manager for stdin or a UTF-8 text file."""if input_name =="-":return nullcontext(stdin)return Path(input_name).open(encoding="utf-8")
nullcontext(stdin) lets both branches appear in one with statement without closing the process’s standard input. The file branch does close the file it opened. This applies the resource-ownership rule from Unit 9.
3. Reserve stdout for the promised data
Suppose each valid trail record becomes a dictionary:
def parse_trail_record(line):"""Return a validated trail record parsed from one text line.""" parts = [part.strip() for part in line.split("|", maxsplit=2)]iflen(parts) !=3:raiseValueError("expected CHECKPOINT|STATUS|NOTE") checkpoint, status, note = partsifnot checkpoint:raiseValueError("checkpoint cannot be blank")if status notin {"open", "limited", "closed"}:raiseValueError(f"unknown status {status!r}")ifnot note:raiseValueError("note cannot be blank")return {"checkpoint": checkpoint, "status": status, "note": note}
JSON Lines puts one JSON value on each physical line. It is useful for streaming because a consumer need not wait for one giant array:
The exact key ordering above is part of this teaching example because sort_keys=True requests it. A real format contract may care only that keys and values are correct. State which details are stable rather than relying on an accidental representation.
Only the middle line is JSON. Progress announcements belong on stderr or in logging, if they belong at all. Decorative colors, headings, and tables are also unsuitable for a machine-readable stdout mode.
That final newline is desirable in JSONL. It lets another line follow without joining two JSON objects.
Decide whether partial output is part of the contract
A streaming command may emit two valid records and then discover a damaged third record. It cannot pull bytes back from a pipe. In this lesson, valid results already written remain on stdout, the diagnostic identifies the later failure, and status 1 says the complete request was not successful.
That policy supports large inputs and visible progress, but it means a caller must inspect status rather than assume every captured line represents a fully successful run. Document it and check it.
Some tasks require all-or-nothing output. They must validate everything before writing, buffer a bounded result, or write to a temporary file and replace the destination only after success. Those designs use more memory or storage and delay the first output. Do not promise atomic output while streaming directly to stdout; the mechanisms contradict the promise.
--strict also does not roll back earlier output. It changes what happens after the first rejection: stop rather than continue. Output produced before that line remains valid evidence. This distinction will matter in the unit challenge.
Buffering can also affect when a downstream process sees text, even though the logical channel is correct. Newline-terminated print() calls are a clear baseline for this course. A long-running interactive pipeline may need an explicit flush policy, but flushing every tiny write has a performance cost. Treat delivery timing as another documented requirement instead of adding flush=True everywhere without evidence.
An anticipated invalid record should identify its location and reason:
def write_rejection(stderr, line_number, error):"""Write one stable, concise record diagnostic."""print(f"trail-report: line {line_number}: {error}", file=stderr)
Check the exact text:
import ioerrors = io.StringIO()write_rejection(errors, 3, "unknown status 'maybe'")assert errors.getvalue() =="trail-report: line 3: unknown status 'maybe'\n"
The message tells a user where to look and what rule failed. It does not include a full traceback because invalid external data is anticipated. A traceback is valuable when debugging an unexpected programming error; it is usually noise when a record merely violates a published input contract.
Do not echo an entire sensitive or enormous record into the diagnostic. A line number, field name, safe identifier, and concise reason are often enough.
Does 1 mean “the valid count is one” or “the operation failed”? The caller cannot know. Return data as data and use an exception for an invalid conversion:
def parse_count(text):"""Return an integer count or raise ValueError for invalid text."""returnint(text)
The command boundary can translate that anticipated exception:
def count_main(text, stderr):"""Display no result here; return a command status."""try: count = parse_count(text)exceptValueError:print(f"count-demo: invalid count {text!r}", file=stderr)return1print(f"accepted count {count}")return0
The reusable parser has one honest return meaning. The boundary has one status meaning. At the actual process entry point, connect the returned integer to the process:
return 1 inside main() does not by itself terminate Python with status 1 if someone merely calls and ignores it. raise SystemExit(main()) is the adapter between the return value and process termination. Keeping it in the entry-point guard also allows another module to call main() and inspect its integer.
Use a small documented policy
For this course artifact:
0: every requested record was processed successfully;
1: the command understood the request but an anticipated setting, file, or record failure prevented complete success;
2: argparse rejected the command grammar.
There is no universal requirement that all CLIs use exactly those application codes. There is a strong requirement to document and use one policy consistently. Do not invent a different code for every error sentence unless a real calling program needs those distinctions.
6. Catch only failures the command can explain
This boundary translates known failures:
from pathlib import Pathdef read_text_for_command(path, stderr):try:return Path(path).read_text(encoding="utf-8"), 0exceptFileNotFoundError:print(f"trail-report: input not found: {path}", file=stderr)return"", 1exceptPermissionError:print(f"trail-report: permission denied: {path}", file=stderr)return"", 1exceptUnicodeDecodeErroras error:print(f"trail-report: {path}: invalid UTF-8 near byte {error.start}",file=stderr, )return"", 1
Each branch can offer a useful next step. An unexpected AttributeError caused by a programming mistake is not included. Letting it retain a traceback helps a developer find the defect.
This valid Python is bad boundary design. It discards the exception type, message, traceback, and user diagnostic. It may turn a programming defect into a silent status that is hard to investigate. Catch narrow anticipated exceptions and preserve context when raising a more useful one.
Do not catch BaseException in an ordinary CLI boundary. That also catches termination signals such as KeyboardInterrupt and SystemExit, interfering with normal process control.
StringIO proves the function routes exact text correctly when given these collaborators. It does not prove that module execution, shell argument passing, or the operating system’s process status works.
9. Inspect a real child process
subprocess.run() can launch a command with an explicit argument list. This self-contained experiment uses Python’s -c option so no project file is required:
sys.executable uses the same Python interpreter running the notebook or script;
the list passes arguments without asking a shell to reinterpret them;
input= supplies the child’s stdin;
text=True uses strings rather than bytes;
capture_output=True keeps stdout and stderr separate;
check=False lets us inspect an expected nonzero result without CalledProcessError; and
timeout=5 prevents a broken experiment from waiting forever.
Avoid shell=True for ordinary Python process checks. It adds shell parsing, platform differences, and an injection risk when untrusted text is composed into a command. A shell is useful when the shell itself is the behavior under study; it is unnecessary here.
Write two StringIO checks. Ordinary mode should emit north-ridge and river-gate; strict mode should emit only north-ridge. Both should report line 2 and return 1. Then write a tiny temporary module and repeat one case through subprocess.run().
Hint 1: preserve evidence gathered before failure
Do not erase stdout when an error occurs. Keep a had_error boolean, write the diagnostic, and decide only whether to break or continue.
Hint 2: inject every stream
Let the runner receive lines, stdout, and stderr. Construct fresh StringIO objects for each case so ordinary-mode output cannot leak into the strict-mode observation.
Hint 3: assert exact channel boundaries
In addition to finding expected checkpoint names, assert that diagnostics do not appear in stdout and JSON records do not appear in stderr. A correct total with polluted channels is still a broken CLI.
Show the complete runner after attempting the lab
The process entry point should call this function with opened input and real or injected output streams, return its integer, and use raise SystemExit(main()) only under the entry-point guard.
Key points
Stdin carries input text, stdout carries the promised result, stderr carries diagnostics and logs, and the exit status is a separate integer signal.
Stable machine-readable stdout makes a CLI composable; progress chatter and diagnostics must not corrupt it.
Return application status from main() and connect it to process termination with raise SystemExit(main()) at the entry point.
Keep data and failure meanings separate. Let core conversions raise useful exceptions and translate anticipated boundary failures narrowly.
Resolve default streams from None at call time rather than capturing global stream objects in function defaults.
StringIO checks direct behavior; subprocess.run() checks entry-point and operating-system process behavior.