Running shell commands is a common part of DevOps automation. Python becomes useful when a script needs to run external tools, capture their output, enforce timeouts, handle errors consistently, and optionally produce structured output for other systems.
This post walks through a small Python command runner that wraps shell commands with predictable behavior.
Source code: github.com/sreevamsiy/devops-python-toolkit/tree/main/command-runner
The Goal
Build a CLI that can run a shell command and report the result cleanly.
Example usage:
python3 command_runner.py "echo hello"
python3 command_runner.py "ls missing-file"
python3 command_runner.py "sleep 5" --timeout 1
python3 command_runner.py "echo hello" --json
The tool should handle:
- command success
- command failure
- timeout
- invalid timeout input
- human-readable output
- JSON output
- correct exit codes
Why Wrap Shell Commands?
For a single command, running it directly is usually simpler:
echo hello
A wrapper becomes useful when automation needs consistent behavior around the command:
- capture
stdoutandstderr - inspect the return code
- fail cleanly on timeout
- convert results into JSON
- reuse the same command-running logic across scripts
This is useful when wrapping tools such as kubectl, docker, terraform, aws, curl, or systemctl.
Running Commands With subprocess
The core of the tool is subprocess.run().
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
The important options are:
shell=True: runs the command through the shellcapture_output=True: captures bothstdoutandstderrtext=True: returns strings instead of bytestimeout=timeout: stops commands that run too long
For this tool, the command is accepted as a single CLI string:
python3 command_runner.py "echo hello"
Returning a Structured Result
The command runner returns a dictionary instead of printing directly from the function.
def run_command(command, timeout):
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return {
"success": False,
"stdout": "",
"stderr": f"Error: command timed out after {timeout} seconds",
"returncode": 124,
}
return {
"success": result.returncode == 0,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
"returncode": result.returncode,
}
This makes the behavior easy to test and reuse.
Example success result:
{
"success": True,
"stdout": "hello",
"stderr": "",
"returncode": 0,
}
Example failure result:
{
"success": False,
"stdout": "",
"stderr": "ls: missing-file: No such file or directory",
"returncode": 1,
}
Timeout Handling
Timeouts are handled with subprocess.TimeoutExpired.
except subprocess.TimeoutExpired:
return {
"success": False,
"stdout": "",
"stderr": f"Error: command timed out after {timeout} seconds",
"returncode": 124,
}
The tool uses return code 124 for timeouts. That convention is commonly used by timeout-style command wrappers.
Example:
python3 command_runner.py "sleep 5" --timeout 1
Output:
Error: command timed out after 1 seconds
Exit code:
124
Building the CLI
The CLI uses argparse.
parser = argparse.ArgumentParser(description="Run shell commands with timeout handling")
parser.add_argument("command", help="Command to run")
parser.add_argument("--timeout", type=int, default=10, help="Timeout in seconds")
parser.add_argument("--json", action="store_true", help="Print output in JSON")
The command is positional:
python3 command_runner.py "echo hello"
The timeout is optional:
python3 command_runner.py "sleep 5" --timeout 1
Validating Input
The timeout must be greater than zero.
if args.timeout <= 0:
print("Error: --timeout must be greater than 0", file=sys.stderr)
sys.exit(1)
This prevents invalid calls such as:
python3 command_runner.py "echo hello" --timeout 0
Error output goes to stderr, and the tool exits with code 1.
Human-Readable Output
On success, the tool prints stdout.
if result["success"]:
if result["stdout"]:
print(result["stdout"])
sys.exit(0)
On failure, the tool prints stderr and exits with the command’s return code.
if result["stderr"]:
print(result["stderr"], file=sys.stderr)
else:
print(f"Error: command failed with exit code {result['returncode']}", file=sys.stderr)
sys.exit(result["returncode"])
This keeps the command runner aligned with normal CLI behavior:
- normal output goes to
stdout - errors go to
stderr - exit codes represent success or failure
JSON Output
For automation, the --json flag prints the full structured result.
if args.json:
print(json.dumps(result, indent=2))
sys.exit(result["returncode"])
Example:
python3 command_runner.py "echo hello" --json
Output:
{
"success": true,
"stdout": "hello",
"stderr": "",
"returncode": 0
}
For a failed command:
python3 command_runner.py "ls missing-file" --json
Output:
{
"success": false,
"stdout": "",
"stderr": "ls: missing-file: No such file or directory",
"returncode": 1
}
In JSON mode, the tool prints only JSON. It does not also print the normal human-readable output.
Testing Function Behavior
The first tests call run_command() directly.
def test_run_command_success():
result = run_command("echo hello", 5)
assert result["success"] is True
assert result["stdout"] == "hello"
assert result["stderr"] == ""
assert result["returncode"] == 0
Failure behavior:
def test_run_command_failure():
result = run_command("ls missing-file", 5)
assert result["success"] is False
assert result["returncode"] != 0
assert "missing-file" in result["stderr"]
Timeout behavior:
def test_run_command_timeout():
result = run_command("sleep 5", 1)
assert result["success"] is False
assert result["stderr"] == "Error: command timed out after 1 seconds"
assert result["returncode"] == 124
These tests verify the reusable Python function without going through the CLI.
Testing CLI Behavior
The CLI is tested with subprocess.run().
def test_cli_success():
result = subprocess.run(
[sys.executable, "command_runner.py", "echo hello"],
capture_output=True,
text=True,
cwd=TOOL_DIR,
)
assert result.stderr == ""
assert result.stdout.strip() == "hello"
assert result.returncode == 0
cwd=TOOL_DIR ensures the test runs from the tool directory, even when pytest is launched from the repository root.
Invalid timeout:
def test_cli_rejects_invalid_timeout():
result = subprocess.run(
[sys.executable, "command_runner.py", "echo hello", "--timeout", "0"],
capture_output=True,
text=True,
cwd=TOOL_DIR,
)
assert result.returncode == 1
assert "Error: --timeout must be greater than 0" in result.stderr
JSON output:
def test_cli_json_success():
result = subprocess.run(
[sys.executable, "command_runner.py", "echo hello", "--json"],
capture_output=True,
text=True,
cwd=TOOL_DIR,
)
data = json.loads(result.stdout)
assert result.returncode == 0
assert data["success"] is True
assert data["stdout"] == "hello"
assert data["stderr"] == ""
assert data["returncode"] == 0
assert result.stderr == ""
This checks the JSON structure instead of matching raw text.
Final Test Result
The toolkit test suite now includes both the log parser and command runner tests:
18 passed
The command runner tests cover:
- function success
- function failure
- function timeout
- CLI success
- CLI failure
- invalid timeout validation
- CLI timeout
- JSON success
- JSON failure
Key Takeaways
This command runner demonstrates several useful DevOps Python patterns:
- use
subprocess.run()to call external tools - capture
stdoutandstderr - preserve command return codes
- enforce timeouts
- validate CLI input
- write errors to
stderr - support JSON for automation
- separate reusable logic from CLI behavior
- test both functions and real CLI execution
The result is a small but practical wrapper that can become a building block for larger automation scripts.