← Home

136. Subprocess (App V0.0)

RAM Manager v0.0 — subprocess shell script

136. Subprocess (App V0.0)

Aryan’s first Python attempt reads shell output directly.

🤔 Socratic question: You know ps aux works in the terminal. Can’t you just… run that from Python? Yes. But should you? What happens on Windows? What if the output format changes? This chapter is about learning why the obvious approach fails.

He knows ps aux works. His first instinct: run it from Python.

import subprocess

result = subprocess.run(
    ['ps', 'aux'],
    capture_output=True, text=True
)
lines = result.stdout.splitlines()
# Sort by RSS (column index 5 on Linux)
processes = sorted(lines[1:], key=lambda l: int(l.split()[5] or 0), reverse=True)
for line in processes[:10]:
    parts = line.split(None, 10)
    print(f"PID {parts[1]:>6}  RSS {int(parts[5])//1024:>5} MB  {parts[10][:30]}")

It works. On his Mac. On Linux the column indices are slightly different. On Windows, ps doesn’t exist at all. And when a process exits mid-run, he gets a ValueError trying to parse the garbled row.

He also notices that subprocess.run() without check=True silently returns exit code 1 on failure. Without capture_output=True the output goes straight to the terminal instead of into result.stdout.

He uses it enough to understand it. But he knows there’s a better way.

💡 Real-world: DevOps engineers still use subprocess all the time — for running git commands, calling system utilities, orchestrating shell scripts. It’s not wrong, it’s just the wrong tool for this job. Every tool in your belt has its place.

💡 Fun fact: The free command on Linux reads /proc/meminfo — a virtual file that the kernel keeps updated in real-time. When you run free -m, you’re not running a program that inspects memory; you’re reading a file that the kernel writes. psutil (which you’ll use in v0.1) reads /proc/meminfo directly in C, skipping the subprocess entirely and working on all platforms.

⚠️ Watch out: bare except: pass is one of the worst patterns in Python. It catches everythingKeyboardInterrupt, SystemExit, MemoryError — and silently swallows it. Your program appears to run fine while something is deeply wrong. At minimum, catch specific exceptions like subprocess.CalledProcessError and print a message with sys.exit(1).

🤔 Think about it: v0.0 only works on Linux. v0.1 (psutil) works on Linux, macOS, and Windows. What does that mean for a developer who writes a tool on a Mac and deploys it on a Linux server? Why is cross-platform compatibility a hidden cost of using subprocess for system monitoring?


🐚 Aryan writes his first RAM monitor using subprocess to call shell commands. It works on Linux but the output parsing is fragile, errors are silently swallowed, and it crashes on macOS. Fix the bugs so it at least works reliably on Linux.

Learning objectives

  • Use capture_output=True + text=True for shell command output
  • Parse free -m and ps aux output
  • Handle FileNotFoundError for missing commands
  • Use sys.exit(1) on unrecoverable errors
  • Understand why subprocess is fragile across OSes

Key concepts

  • subprocess.run(capture_output=True, text=True) — capture output as str
  • result.stdout.split(‘\n’) — parse command output
  • FileNotFoundError — command not found
  • sys.exit(1) — exit with error code

Try it

Concept detail

App v0.0 — The subprocess approach

This is the “naive” version. It works on Linux but breaks on macOS (no free command) and Windows. We’ll fix this in v0.1 with psutil.

# What free -m outputs:
              total   used   free   shared   buff/cache   available
Mem:          15987   8234   2156      512         5597        6841
Swap:          2047      0   2047
# What ps aux --sort=-%mem outputs:
USER   PID  %CPU  %MEM    VSZ    RSS  TTY  STAT  START   TIME  COMMAND
aryan  812   2.1  12.4  6.2G   2.0G  ?    Sl   10:23   0:45  chrome

The problem: These commands only exist on Linux. On macOS: vm_stat, top -l 1 — completely different format. On Windows: entirely different tools.

The fix: psutil (v0.1) reads /proc and OS APIs directly.

Solution

import subprocess
import sys

def get_ram_percent() -> float:
    result = subprocess.run(
        ['free', '-m'],
        capture_output=True,
        text=True,
        check=True
    )
    lines = result.stdout.strip().split('\n')
    mem_line = lines[1].split()   # Mem: total used free ...
    total = int(mem_line[1])
    used = int(mem_line[2])
    return (used / total) * 100

def get_top_processes(n: int = 5) -> list[str]:
    result = subprocess.run(
        ['ps', 'aux', '--sort=-%mem'],
        capture_output=True,
        text=True,
        check=True
    )
    lines = result.stdout.strip().split('\n')
    return lines[1:n + 1]   # skip header row

def main():
    try:
        percent = get_ram_percent()
        print(f'RAM usage: {percent:.1f}%')
        if percent > 80:
            print('WARNING: High RAM usage!')
        procs = get_top_processes(5)
        print('\nTop processes:')
        for p in procs:
            print(' ', p)
    except subprocess.CalledProcessError as e:
        print(f'Command failed: {e}', file=sys.stderr)
        sys.exit(1)
    except FileNotFoundError as e:
        print(f'Command not found — this script requires Linux: {e}', file=sys.stderr)
        sys.exit(1)

if __name__ == '__main__':
    main()

Tests

import subprocess
import inspect
import pytest
from unittest.mock import patch, MagicMock

FREE_M_OUTPUT = (
            '              total        used        free      shared  buff/cache   available\n'
            'Mem:          15987        8234        2156         512        5597        6841\n'
            'Swap:          2047           0        2047\n'
)
PS_OUTPUT = (
    'USER   PID %CPU %MEM    VSZ    RSS TTY  STAT START   TIME COMMAND\n'
    'aryan  812  2.1 12.4 6291456 2048000 ?  Sl   10:23   0:45 /usr/bin/chrome\n'
    'aryan  421  0.5  4.2 1048576  671000 ?  S    09:10   0:12 python3\n'
    'root     1  0.0  0.1   12345    1234 ?  Ss   08:00   0:01 systemd\n'
)

def _fake_run(cmd, **kwargs):
    result = MagicMock()
    result.returncode = 0
    if 'free' in cmd:
        result.stdout = FREE_M_OUTPUT
    else:
        result.stdout = PS_OUTPUT
    result.stderr = ''
    return result

def test_get_ram_percent_uses_capture_output():
    src = inspect.getsource(get_ram_percent)
    assert 'capture_output' in src, 'Must use capture_output=True'

def test_get_ram_percent_uses_text_mode():
    src = inspect.getsource(get_ram_percent)
    assert 'text=True' in src, 'Must use text=True to get str not bytes'

def test_get_ram_percent_returns_float():
    with patch('subprocess.run', side_effect=_fake_run):
        result = get_ram_percent()
    assert isinstance(result, float)
    assert 0.0 <= result <= 100.0

def test_get_top_processes_returns_list():
    with patch('subprocess.run', side_effect=_fake_run):
        result = get_top_processes(2)
    assert isinstance(result, list)
    assert len(result) <= 2

def test_main_handles_file_not_found():
    with patch('subprocess.run', side_effect=FileNotFoundError('free not found')):
        with pytest.raises(SystemExit):
            main()

def test_exception_not_silenced():
    src = inspect.getsource(main)
    assert 'except Exception:\n        pass' not in src and \
           'except:\n        pass' not in src, (
        'Do not silently swallow exceptions with bare except/pass'
    )

Resources