subprocess and os
subprocess and os
The boring way to run another program is subprocess.run with a list of arguments, capture_output=True, and check=True. The interpreter you want is sys.executable. shell=True is not the default. os.environ is how you read the process environment.
Mental model
subprocess.run starts a child, waits, and returns a CompletedProcess. capture_output=True fills stdout and stderr. text=True gives strings instead of bytes. check=True raises CalledProcessError on a non-zero exit.
A list ["echo", "7"] is one program and one argument. A shell string "echo 7; echo pwned" is a tiny program in another language. Do not interpolate desk input into that language.
os.environ is a mapping of environment variables. It is the right place for DESK_SHIFT. It is not a config file.
Worked examples
Case 1: Run this Python, capture stdout
Save as run_label.py. sys.executable is the interpreter that is already running the file (the same one uv run python selected).
# run_label.py
import subprocess
import sys
def main() -> None:
result = subprocess.run(
[sys.executable, "-c", "print('ticket 7')"],
capture_output=True,
text=True,
check=True,
)
print(result.stdout, end="")
if __name__ == "__main__":
main()Run:
uv run python run_label.pyOutput:
ticket 7
From a shell you can also write uv run python -c "print('ticket 7')". Inside Python, prefer sys.executable so you do not depend on PATH.
Case 2: check=True surfaces a failed child
Save as run_fail.py. Exit 2 is a failure. Catch CalledProcessError when you intend to handle it.
# run_fail.py
import subprocess
import sys
def main() -> None:
try:
subprocess.run(
[sys.executable, "-c", "raise SystemExit(2)"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as exc:
print(type(exc).__name__)
print(exc.returncode)
if __name__ == "__main__":
main()Run:
uv run python run_fail.pyOutput:
CalledProcessError
2
Without the try, the traceback is the API: the parent fails too.
Case 3: Read the environment
Save as shift_env.py. os.environ.get with a default is the boring lookup. Missing keys are not exceptions unless you want them to be (os.environ["DESK_SHIFT"]).
# shift_env.py
import os
def main() -> None:
shift = os.environ.get("DESK_SHIFT", "day")
print(shift)
print(os.environ.get("NOT_A_DESK_KEY", "missing"))
if __name__ == "__main__":
main()Run:
uv run python shift_env.pyOutput:
day
missing
If you already exported DESK_SHIFT, that value prints instead of day. Unset it to match this listing.
Case 4: Pass env into the child
Save as child_env.py. env= replaces the child’s environment. Copy os.environ first if the child still needs PATH.
# child_env.py
import os
import subprocess
import sys
def main() -> None:
env = os.environ.copy()
env["DESK_SHIFT"] = "night"
result = subprocess.run(
[sys.executable, "-c", "import os; print(os.environ['DESK_SHIFT'])"],
capture_output=True,
text=True,
check=True,
env=env,
)
print(result.stdout, end="")
if __name__ == "__main__":
main()Run:
uv run python child_env.pyOutput:
night
The trap
shell=True plus an interpolated string runs extra commands. A list does not.
Save as shell_trap.py:
# shell_trap.py
import subprocess
def main() -> None:
ticket = "7; echo pwned"
unsafe = subprocess.run(
f"echo {ticket}",
shell=True,
capture_output=True,
text=True,
check=True,
)
safe = subprocess.run(
["echo", ticket],
capture_output=True,
text=True,
check=True,
)
print("unsafe:", repr(unsafe.stdout))
print("safe:", repr(safe.stdout))
if __name__ == "__main__":
main()Run:
uv run python shell_trap.pyOutput:
unsafe: '7\npwned\n'
safe: '7; echo pwned\n'
The “safe” line is one argument that happens to contain a semicolon. The shell never sees it. Default to the list form. If you truly need a shell, you need a written reason.
The boring rule
subprocess.run([...], capture_output=True, text=True, check=True).- Child Python is
sys.executable(oruv run python -c ...from the terminal). - Never
shell=Trueas the default. Never format user input into a shell string. - Read config-like values from
os.environ. Do not mutateos.environglobally if you can passenv=to one child. - Files still belong to
pathlib.oshere is environment and process, notos.path.
Try this
- In
run_label.py, printresult.returncodeas well (it should be0). - In
run_fail.py, dropcheck=Trueand printreturncodewithout atry. - Run
DESK_SHIFT=swing uv run python shift_env.pyand confirm the first line changes.