Learning Goals
3 minBy the end of this lesson you can:
- Capture a command's output with
capture_output=Trueandtext=True. - Read
result.stdoutandresult.stderras strings. - Send data to a command's standard input with
input=. - Parse captured output into Python data you can use.
Warm-Up · The Three Streams
5 minEvery command-line program has three text streams:
stdin (0) input fed INTO the program stdout (1) the program's normal OUTPUT stderr (2) error/diagnostic messages, kept separate from stdout
Last lesson we only checked the exit code. Now we capture stdout — the actual results — and read stderr for error messages, keeping the two apart. We can also push text in via stdin. With these, any CLI tool becomes a function: input goes in, structured output comes back.
New Concept · Capturing & Feeding
14 minCapturing output as text
import subprocess result = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], capture_output=True, # grab stdout and stderr text=True, # decode bytes → str (instead of raw bytes) ) print("commit:", result.stdout.strip()) # e.g. "abc1234"
capture_output=Truecollectsstdoutandstderrinstead of letting them print to your terminal.text=Truedecodes the bytes to a normal string. Without it you getbytesand have to.decode()yourself.- Output usually ends in a newline —
.strip()is your friend.
stdout vs. stderr
result = subprocess.run( ["python", "-c", "import sys; print('hi'); print('oops', file=sys.stderr)"], capture_output=True, text=True, ) print(repr(result.stdout)) # 'hi\n' print(repr(result.stderr)) # 'oops\n'
Keeping them separate is powerful: you can parse the real results from stdout while logging problems from stderr, without them tangling together.
The everyday shorthand
# capture_output=True is just shorthand for these two: result = subprocess.run( ["ls"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, )
You'll see both forms in the wild; capture_output=True is the modern, concise version.
Feeding input on stdin
result = subprocess.run( ["sort"], # 'sort' reads lines from stdin input="banana\napple\ncherry\n", capture_output=True, text=True, ) print(result.stdout) # apple / banana / cherry, sorted
input= sends a string straight to the program's stdin — exactly like typing it (or piping a file). Great for tools that read from stdin.
Combine with check=True for safety
try: r = subprocess.run(["git", "log", "-1", "--format=%s"], capture_output=True, text=True, check=True) print("last commit:", r.stdout.strip()) except subprocess.CalledProcessError as e: print("git failed:", e.stderr) # the error text is right here
When check=True raises, the exception still carries e.stdout and e.stderr — so you can report exactly what went wrong.
To mimic cat file | grep error, capture the first command's stdout and pass it as the second's input. No need for shell=True: subprocess.run(["grep", "error"], input=first.stdout, ...). You stay safe and in control of each step.
Worked Example · A Git Repo Dashboard
12 minGoal: gather a few facts about the current git repo by capturing the output of several git commands, and present them as a tidy summary.
import subprocess def git(*args: str) -> str: """Run a git command and return its trimmed stdout.""" result = subprocess.run(["git", *args], capture_output=True, text=True, check=True) return result.stdout.strip() def dashboard() -> None: branch = git("rev-parse", "--abbrev-ref", "HEAD") commit = git("rev-parse", "--short", "HEAD") author = git("log", "-1", "--format=%an") subject = git("log", "-1", "--format=%s") # count files changed but not committed status = git("status", "--porcelain") dirty = len(status.splitlines()) if status else 0 print(f"Branch: {branch}") print(f"Latest commit: {commit} by {author}") print(f"Message: {subject}") print(f"Uncommitted: {dirty} file(s)") dashboard()
Branch: develop-python Latest commit: 277a0e8 by aljay Message: updated full level 3 lessons Uncommitted: 2 file(s)
Read the code
The git() helper captures and trims output once, so each fact is a clean one-liner. We parse git status --porcelain (one line per changed file) just by counting lines — captured text becomes Python data we can measure. This is the whole pattern of Level 7: run a tool, capture its output, turn it into something your program understands. Print these to a file and you have a daily repo report.
Try It Yourself
13 minRun python --version with capture, and print just the version number (strip the word "Python"). Note: some Python versions print to stdout, older ones to stderr — check both.
Feed five unsorted names into the sort command via input= and print the sorted result. (On Windows, use ["sort"] too — it exists in cmd.) If sort isn't available, sort in Python and compare.
Hint
import subprocess names = "zoe\nadam\nmaya\nben\nlee\n" r = subprocess.run(["sort"], input=names, capture_output=True, text=True) print(r.stdout)
Without shell=True, replicate git log --oneline | grep fix: capture the log's stdout, then pass it as input to grep fix. Print the matching commits.
Hint
import subprocess log = subprocess.run(["git", "log", "--oneline"], capture_output=True, text=True, check=True) hits = subprocess.run(["grep", "fix"], input=log.stdout, capture_output=True, text=True) print(hits.stdout or "no matches")
Mini-Challenge · The Ping Monitor
8 minWrite is_up(host) that runs the system ping once against a host, captures the output, and returns True if reachable. Then loop over a list of hosts and print an up/down report. (Ping flags differ: -n 1 on Windows, -c 1 elsewhere — detect with sys.platform.)
Show a sample solution
import subprocess, sys def is_up(host: str) -> bool: flag = "-n" if sys.platform.startswith("win") else "-c" try: r = subprocess.run(["ping", flag, "1", host], capture_output=True, text=True, timeout=5) return r.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): return False for host in ["example.com", "localhost", "no.such.host.invalid"]: print(f"{'UP ' if is_up(host) else 'DOWN'} {host}")
Non-negotiables: platform-aware ping flag, captured output, timeout guard, up/down report.
Recap
3 minAdd capture_output=True, text=True to subprocess.run and you get the command's result.stdout and result.stderr as strings — kept separate so results and errors don't tangle. Use input= to feed text to a program's stdin, and chain tools by passing one command's captured stdout as the next command's input (no shell=True needed). Combined with check=True — whose exception still carries stderr — you can run any CLI tool, read its results, and turn them into Python data. That makes the entire ecosystem of command-line programs callable from your scripts.
Vocabulary Card
- stdout / stderr
- A program's normal output stream and its separate error stream.
- capture_output
- Tells
runto collect output instead of printing it to the terminal. - text=True
- Decodes captured bytes into strings automatically.
- input=
- A string sent to the launched program's standard input.
Homework
4 minBuild sysinfo.py that captures output from a few system commands appropriate to your OS (e.g. python --version, pip list, git --version) and writes a clean Markdown report to sysinfo.md with a section per command. Handle missing tools gracefully (note them as "not installed") and never use shell=True.
Sample · sysinfo.py
import subprocess from pathlib import Path COMMANDS = { "Python": ["python", "--version"], "pip": ["pip", "--version"], "Git": ["git", "--version"], } def capture(cmd: list[str]) -> str: try: r = subprocess.run(cmd, capture_output=True, text=True, check=True) return (r.stdout + r.stderr).strip() except FileNotFoundError: return "_not installed_" except subprocess.CalledProcessError as e: return f"_error: {e.returncode}_" lines = ["# System Info\n"] for title, cmd in COMMANDS.items(): lines.append(f"## {title}\n") lines.append("```\n" + capture(cmd) + "\n```\n") Path("sysinfo.md").write_text("\n".join(lines), encoding="utf-8") print("Wrote sysinfo.md")
Non-negotiables: captured output, graceful missing-tool handling, a Markdown file, no shell=True.