Detailed documentation for the runner module (src/helper/runner.py).
The runner module provides a unified interface for executing shell commands with configurable error handling and output modes. It's the foundation for all command execution in Tuxgrade.
Custom exception raised when a command execution fails.
CommandError
└─ RuntimeError
└─ Exceptionfrom helper import runner
try:
runner.run(["false"]) # Command that fails
except runner.CommandError as e:
print(f"Command failed: {e}")Main command execution function with flexible configuration.
def run(
cmd: list[str],
show_live_output: bool = False,
check: bool = True
) -> subprocess.CompletedProcessThe command to execute as a list of strings.
Format: ["command", "arg1", "arg2", ...]
Examples:
# Good - list format (safe)
runner.run(["ls", "-la", "/home"])
runner.run(["dnf5", "check-upgrade", "-q", "kernel*"])
# Bad - string format (unsafe, don't use)
# runner.run("ls -la /home") # This won't work!Why list format?
- Prevents shell injection vulnerabilities
- Handles arguments with spaces correctly
- No need to escape special characters
Controls output display mode.
| Value | Behavior | Use Case |
|---|---|---|
False (default) |
Captures output, doesn't display | Silent mode, programmatic use |
True |
Displays output in real-time | Verbose mode, user feedback |
Examples:
# Silent mode - capture output
result = runner.run(["ls", "-la"])
print(result.stdout) # Access captured output
# Verbose mode - show live output
runner.run(["dnf", "update", "-y"], show_live_output=True)
# Output appears in terminal as it happensControls error handling behavior.
| Value | Behavior | Use Case |
|---|---|---|
True (default) |
Raises CommandError on failure |
Normal commands that must succeed |
False |
Returns result regardless of exit code | Checking command availability, special exit codes |
Examples:
# Standard usage - fail on error
try:
runner.run(["dnf5", "update", "-y"])
except runner.CommandError:
print("Update failed!")
# Check without failing
result = runner.run(["flatpak", "--version"], check=False)
if result.returncode == 0:
print("Flatpak is installed")
else:
print("Flatpak not found")Returns a subprocess.CompletedProcess object with:
returncode(int): Exit code (0 = success)stdout(str): Standard output (if captured)stderr(str): Standard error (if captured)args(list[str]): The command that was run
Example:
result = runner.run(["echo", "Hello"])
print(f"Exit code: {result.returncode}") # 0
print(f"Output: {result.stdout}") # "Hello\n"
print(f"Error: {result.stderr}") # ""Raised when a command fails (exit code != 0) and check=True.
Attributes:
- Inherits from
RuntimeError - Contains the original command in args
Handling:
try:
runner.run(["false"])
except runner.CommandError as e:
print(f"Command failed: {e}")
# Access the original exception if needed
# print(e.__cause__)Display package manager output in real-time during updates.
from helper import runner
# User sees progress as it happens
runner.run(
["sudo", "dnf5", "upgrade", "-y"],
show_live_output=True
)Behavior:
- Output streams to terminal in real-time
- User can see download progress, installation steps
stdoutandstderrare NOT captured- Good for long-running, interactive commands
Check if a tool is installed without failing.
from helper import runner
def check_flatpak_installed() -> bool:
result = runner.run(
["flatpak", "--version"],
check=False # Don't raise exception
)
return result.returncode == 0
if check_flatpak_installed():
print("Flatpak is available")Behavior:
- Returns
CompletedProcesseven if command fails - Allows checking exit code
- Good for optional dependencies
Handle commands with meaningful non-zero exit codes.
from helper import runner
def new_kernel_version() -> bool:
result = runner.run(
["dnf5", "check-upgrade", "-q", "kernel*"],
check=False
)
if result.returncode == 0:
return False # No updates
elif result.returncode == 100:
return True # Updates available
else:
raise runner.CommandError("Unexpected exit code")Behavior:
- Exit code 100 is valid (DNF5 "updates available")
- Can distinguish between different exit codes
- Good for commands with exit code conventions
def run(cmd, show_live_output, check):
logging.debug("Executing: %s", " ".join(cmd))
try:
if show_live_output:
# Real-time output mode
result = subprocess.run(cmd, check=check, text=True)
else:
# Capture mode
result = subprocess.run(
cmd,
check=check,
text=True,
capture_output=True
)
return result
except subprocess.CalledProcessError as e:
logging.error("Command failed: %s", " ".join(cmd))
if e.stderr:
logging.debug(e.stderr.strip())
raise CommandError(cmd) from e1. Always return CompletedProcess
- Consistent interface
- Access to returncode, stdout, stderr
- Easier testing
2. Text mode enabled
text=Truereturns strings, not bytes- Easier to work with in Python
- Assumes UTF-8 encoding
3. Logging integration
- Debug log shows all executed commands
- Error log shows failed commands
- Stderr logged on failure
from helper import runner
try:
runner.run(["some-command"])
print("Success!")
except runner.CommandError:
print("Failed!")from helper import runner
result = runner.run(["some-command"], check=False)
if result.returncode == 0:
print("Success!")
else:
print(f"Failed with code {result.returncode}")from helper import runner
result = runner.run(["command"], check=False)
match result.returncode:
case 0:
print("Success")
case 100:
print("Special success code")
case _:
raise runner.CommandError("Unexpected failure")from unittest.mock import patch
from subprocess import CompletedProcess
def test_my_function():
mock_result = CompletedProcess(
args=["echo", "test"],
returncode=0,
stdout="test output\n",
stderr=""
)
with patch('helper.runner.run', return_value=mock_result):
# Test your function that uses runner.run()
result = my_function()
assert result == expecteddef test_command_failure():
mock_result = CompletedProcess(
args=["false"],
returncode=1,
stdout="",
stderr="error"
)
with patch('helper.runner.run', return_value=mock_result):
result = runner.run(["false"], check=False)
assert result.returncode == 1Each runner.run() call:
- Spawns a new process
- Minimal overhead (~1-2ms for simple commands)
- Output capture adds negligible overhead
For long-running commands:
- Use
show_live_output=Truefor user feedback - Output is not buffered, streams in real-time
- Process can be interrupted with Ctrl+C
Runner doesn't support parallel execution. For multiple commands:
# Sequential (current)
runner.run(["command1"])
runner.run(["command2"])
# Parallel (not supported, future consideration)
# Would need separate implementation✅ Safe - List format prevents injection:
filename = "file; rm -rf /"
runner.run(["cat", filename]) # Safe - filename treated as literal❌ Unsafe - String format allows injection:
# Don't do this!
# os.system(f"cat {filename}") # Dangerous!Commands with sudo are explicit:
runner.run(["sudo", "dnf", "update"]) # Clear sudo usageNo implicit privilege escalation.
import logging
logging.basicConfig(level=logging.DEBUG)
from helper import runner
runner.run(["ls", "-la"])
# Logs: "Executing: ls -la"result = runner.run(["command"], check=False)
if result.returncode != 0:
print(f"Error output: {result.stderr}")- core/*: All core modules use runner for command execution
- sudo_keepalive.py: Uses subprocess directly (not runner)
- cli.py: Wraps runner calls with UI elements
- Python subprocess: https://docs.python.org/3/library/subprocess.html
- Shell injection: https://owasp.org/www-community/attacks/Command_Injection