Skip to main content

A Rust-backed subprocess wrapper with split stdout/stderr streaming

Project description

running-process

PyPI Crates.io codecov

running-process is what you wished python's subprocess was. Blazing fast, highly concurrent, huge feature list, dead process tracking, pty support. Built in Rust with a thin python api.

Platform Build Lint Unit Test Integration Test
Linux x86 Build Lint Unit Test Integration Test
Linux ARM Build Lint Unit Test Integration Test
Windows x86 Build Lint Unit Test Integration Test
Windows ARM Build Lint Unit Test Integration Test
macOS x86 Build Lint Unit Test Integration Test
macOS ARM Build Lint Unit Test Integration Test

Why?

This project started off as a fix for python's sub process module. It was in python originally, but then moved to OS specific rust. Now it's blazing fast: using OS threads, atomics and proper signaling back to the python api. This library also allows stderr and stdout stream reading in parallel, something subprocess lacks. It also has cross platform process tracking, pty generation. It has zombie process tracking. It also has builtin expect for keyword event triggers, idle tracking (great for agent CLI's that dont' notifiy when they are done, they just stop sending data).

This libary is design for speed and correctness and portability. Usually terminal utilities are for windows or linux/mac. This is designed to run everywhere.

PTY Support Matrix

PTY support is a guaranteed part of the package contract on:

  • Windows
  • Linux
  • macOS

On those platforms, RunningProcess.pseudo_terminal(...), wait_for_expect(...), and wait_for_idle(...) are core functionality rather than optional extras.

Pty.is_available() remains as a compatibility shim and only reports False on unsupported platforms.

Pipe-backed API

from running_process import RunningProcess

process = RunningProcess(
    ["python", "-c", "import sys; print('out'); print('err', file=sys.stderr)"]
)

process.wait()

print(process.stdout)          # stdout only
print(process.stderr)          # stderr only
print(process.combined_output) # combined compatibility view

Captured data values stay plain str | bytes. Live stream handles are exposed separately:

if process.stdout_stream.available():
    print(process.stdout_stream.drain())

Process priority is a first-class launch option:

from running_process import CpuPriority, RunningProcess

process = RunningProcess(
    ["python", "-c", "import time; time.sleep(1)"],
    nice=CpuPriority.LOW,
)

nice= behavior:

  • accepts either a raw int niceness or a platform-neutral CpuPriority
  • on Unix, it maps directly to process niceness
  • on Windows, positive values map to below-normal or idle priority classes and negative values map to above-normal or high priority classes
  • 0 leaves the default scheduler priority unchanged
  • positive values are the portable default; negative values may require elevated privileges
  • the enum intentionally stops at HIGH; there is no realtime tier

Available helpers:

  • get_next_stdout_line(timeout)
  • get_next_stderr_line(timeout)
  • get_next_line(timeout) for combined compatibility reads
  • stream_iter(timeout) or for stdout, stderr, exit_code in process
  • drain_stdout()
  • drain_stderr()
  • drain_combined()
  • stdout_stream.available()
  • stderr_stream.available()
  • combined_stream.available()

stream_iter(...) yields tuple-like ProcessOutputEvent(stdout, stderr, exit_code) records. Only one stream payload is populated per nonterminal item. When both pipes are drained, it yields (EOS, EOS, exit_code) if the child has already exited, or (EOS, EOS, None) followed by a final (EOS, EOS, exit_code) if the child closed both pipes before it exited.

RunningProcess.run(...) supports common subprocess.run(...) style cases including:

  • capture_output=True
  • text=True
  • encoding=...
  • errors=...
  • shell=True
  • env=...
  • nice=...
  • stdin=subprocess.DEVNULL
  • input=... in text or bytes form

Unsupported subprocess.run(...) kwargs now fail loudly instead of being silently ignored.

Expect API

expect(...) is available on both the pipe-backed and PTY-backed process APIs.

import re
import subprocess
from running_process import RunningProcess

process = RunningProcess(
    ["python", "-c", "print('prompt>'); import sys; print('echo:' + sys.stdin.readline().strip())"],
    stdin=subprocess.PIPE,
)

process.expect("prompt>", timeout=5, action="hello\n")
match = process.expect(re.compile(r"echo:(.+)"), timeout=5)
print(match.groups)

Supported action= forms:

  • str or bytes: write to stdin
  • "interrupt": send Ctrl-C style interrupt when supported
  • "terminate"
  • "kill"

Pipe-backed expect(...) matches line-delimited output. If the child writes prompts without trailing newlines, use the PTY API instead.

PTY API

Use RunningProcess.pseudo_terminal(...) for interactive terminal sessions. It is chunk-oriented by design and preserves carriage returns and terminal control flow instead of normalizing it away.

from running_process import ExpectRule, RunningProcess

pty = RunningProcess.pseudo_terminal(
    ["python", "-c", "import sys; sys.stdout.write('name?'); sys.stdout.flush(); print('hello ' + sys.stdin.readline().strip())"],
    text=True,
    expect=[ExpectRule("name?", "world\n")],
    expect_timeout=5,
)

print(pty.output)

PTY behavior:

  • accepts str and list[str] commands
  • auto-splits simple string commands into argv when shell syntax is not present
  • uses shell mode automatically when shell metacharacters are present
  • is guaranteed on supported Windows, Linux, and macOS builds
  • keeps output chunk-buffered by default
  • preserves \r for redraw-style terminal output
  • supports write(...), read(...), drain(), available(), expect(...), resize(...), and send_interrupt()
  • supports nice=... at launch
  • supports interrupt_and_wait(...) for staged interrupt escalation
  • supports wait_for_idle(...) with activity filtering
  • exposes exit_reason, interrupt_count, interrupted_by_caller, and exit_status

wait_for_idle(...) has two modes:

  • default fast path: built-in PTY activity rules and optional process metrics
  • slow path: IdleDetection(idle_reached=...), where your Python callback receives an IdleInfoDiff delta and returns IdleDecision.DEFAULT, IdleDecision.ACTIVE, IdleDecision.BEGIN_IDLE, or IdleDecision.IS_IDLE

There is also a compatibility alias: RunningProcess.psuedo_terminal(...).

You can also inspect the intended interactive launch semantics without launching a child:

from running_process import RunningProcess

spec = RunningProcess.interactive_launch_spec("console_isolated")
print(spec.ctrl_c_owner)
print(spec.creationflags)

Supported launch specs:

  • pseudo_terminal
  • console_shared
  • console_isolated

For an actual launch, use RunningProcess.interactive(...):

process = RunningProcess.interactive(
    ["python", "-c", "print('hello from interactive mode')"],
    mode="console_shared",
    nice=5,
)
process.wait()

Abnormal Exits

By default, nonzero exits stay subprocess-like: you get a return code and can inspect exit_status.

process = RunningProcess(["python", "-c", "import sys; sys.exit(3)"])
process.wait()
print(process.exit_status)

If you want abnormal exits to raise, opt in:

from running_process import ProcessAbnormalExit, RunningProcess

try:
    RunningProcess.run(
        ["python", "-c", "import sys; sys.exit(3)"],
        capture_output=True,
        raise_on_abnormal_exit=True,
    )
except ProcessAbnormalExit as exc:
    print(exc.status.summary)

Notes:

  • keyboard interrupts still raise KeyboardInterrupt
  • kill -9 / SIGKILL is classified as an abnormal signal exit
  • possible OOM conditions are exposed as a hint on exit_status.possible_oom
  • OOM cannot be identified perfectly across platforms from exit status alone, so it is best-effort rather than guaranteed

Text and bytes

Pipe mode is byte-safe internally:

  • invalid UTF-8 does not break capture
  • text mode decodes with UTF-8 and errors="replace" by default
  • binary mode returns bytes unchanged
  • \r\n is normalized as a line break in pipe mode
  • bare \r is preserved

PTY mode is intentionally more conservative:

  • output is handled as chunks, not lines
  • redraw-oriented \r is preserved
  • no automatic terminal-output normalization is applied

Development

./install
./lint
./test

./install bootstraps rustup into the shared user locations (~/.cargo and ~/.rustup, or CARGO_HOME / RUSTUP_HOME if you override them), then installs the exact toolchain pinned in rust-toolchain.toml. Toolchain installs are serialized with a lock so concurrent repo bootstraps do not race the same shared version.

./lint applies cargo fmt and Ruff autofixes before running the remaining lint checks, so fixable issues are rewritten in place.

./test runs the Rust tests, rebuilds the native extension with the unoptimized dev profile, runs the non-live Python tests, and then runs the @pytest.mark.live coverage that exercises real OS process and signal behavior.

On local developer machines, ./test also runs the Linux Docker preflight so Windows and macOS development catches Linux wheel, lint, and non-live pytest regressions before push. GitHub-hosted Actions skip that Docker-only preflight and run the native platform suite directly.

If you want to invoke pytest directly, set RUNNING_PROCESS_LIVE_TESTS=1 and run uv run pytest -m live.

For direct Rust commands, prefer the repo trampolines, which prepend the shared rustup proxy location:

./_cargo check --workspace
./_cargo fmt --all --check
./_cargo clippy --workspace --all-targets -- -D warnings

On Windows, native rebuilds that compile bundled C code should run from a Visual Studio developer shell. When the environment is ambiguous, point maturin at the MSVC toolchain binaries directly rather than relying on the generic cargo proxy.

For local extension rebuilds, prefer:

uv run build.py

That defaults to building a dev-profile wheel and reinstalling it into the repo's uv environment, which keeps the native extension in site-packages instead of copying it into src/. For publish-grade artifacts, use:

uv run build.py --release

Process Containment

ContainedProcessGroup ensures all child processes are killed when the group is dropped, using OS-level mechanisms (Job Objects on Windows, process groups + SIGKILL on Unix).

from running_process import ContainedProcessGroup

with ContainedProcessGroup() as group:
    proc = group.spawn(["sleep", "3600"])
# all children killed on exit, even on crash

Crash-resilient orphan discovery

When a parent crashes, its in-process registry is lost. ContainedProcessGroup can stamp every child with an environment variable that survives parent death:

from running_process import ContainedProcessGroup, find_processes_by_originator

# At launch: tag children with your tool name
with ContainedProcessGroup(originator="MYTOOL") as group:
    proc = group.spawn(["long-running-worker"])

# Later (from any process, any session): find orphans
stale = find_processes_by_originator("MYTOOL")
for info in stale:
    if not info.parent_alive:
        print(f"Orphaned PID {info.pid} from dead parent {info.parent_pid}")

The env var RUNNING_PROCESS_ORIGINATOR=TOOL:PID is inherited by all descendants. The scanner uses process start times to guard against PID reuse.

Tracked PID Cleanup

RunningProcess, InteractiveProcess, and PTY-backed launches register their live PIDs in a SQLite database. The default location is:

  • Windows: %LOCALAPPDATA%\\running-process\\tracked-pids.sqlite3
  • Override: RUNNING_PROCESS_PID_DB=/custom/path/tracked-pids.sqlite3

If a bad run leaves child processes behind, terminate everything still tracked in the database:

python scripts/terminate_tracked_processes.py

Notes

  • stdout and stderr are no longer merged by default.
  • combined_output exists for compatibility when you need the merged view.
  • RunningProcess(..., use_pty=True) is no longer the preferred path; use RunningProcess.pseudo_terminal(...) for PTY sessions.
  • On supported Windows builds, PTY support is provided by the native Rust extension rather than a Python winpty fallback.
  • The test suite checks that running_process.__version__, package metadata, and manifest versions stay in sync.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

running_process-3.0.17.tar.gz (148.3 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

running_process-3.0.17-cp310-abi3-win_arm64.whl (1.2 MB view details)

Uploaded CPython 3.10+Windows ARM64

running_process-3.0.17-cp310-abi3-win_amd64.whl (1.3 MB view details)

Uploaded CPython 3.10+Windows x86-64

running_process-3.0.17-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.6 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

running_process-3.0.17-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (2.5 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

running_process-3.0.17-cp310-abi3-macosx_11_0_arm64.whl (1.3 MB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

running_process-3.0.17-cp310-abi3-macosx_10_12_x86_64.whl (1.3 MB view details)

Uploaded CPython 3.10+macOS 10.12+ x86-64

File details

Details for the file running_process-3.0.17.tar.gz.

File metadata

  • Download URL: running_process-3.0.17.tar.gz
  • Upload date:
  • Size: 148.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.11

File hashes

Hashes for running_process-3.0.17.tar.gz
Algorithm Hash digest
SHA256 9c80e699a9f2dd45e44b31eaa0e1b21f013cd43293ea99b9bda265aa2776a488
MD5 976244f4edf4708eb2c17a6372c982b2
BLAKE2b-256 c434e6ace3f94008005c548514383b6759804bf28e16e5e48d89b5d91d4b0312

See more details on using hashes here.

File details

Details for the file running_process-3.0.17-cp310-abi3-win_arm64.whl.

File metadata

File hashes

Hashes for running_process-3.0.17-cp310-abi3-win_arm64.whl
Algorithm Hash digest
SHA256 2952c8743f0dd0e10d2ecb53ea92a738190ef35cc54dfdbd496adf384e12929f
MD5 14f6fe4586ccb8724eae7f0324408cf3
BLAKE2b-256 10ecde8f359f3720bcefd47103e247a72296545d561dc3d08a8d3e5989fa9c1d

See more details on using hashes here.

File details

Details for the file running_process-3.0.17-cp310-abi3-win_amd64.whl.

File metadata

File hashes

Hashes for running_process-3.0.17-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 7e8cc265eff9e490c68fcfd9001b8bc7bb2780fd25a2ffc21957c9e28398c8f0
MD5 dcdf0e8696c06db8c478a78892f3d6c5
BLAKE2b-256 9bc01214fd106d97a7208ed45305b1e518a7acae1f9a76ee701ae6c700954191

See more details on using hashes here.

File details

Details for the file running_process-3.0.17-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for running_process-3.0.17-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 a2e18e9f71b319c85b4d89b13c2c2facc11918057d6492962f1e0db71371db82
MD5 3a996a1b2d063ab7a90d10572f550f5d
BLAKE2b-256 086af6a1e150ad80eff32a121910556e9b35d29d0ff5ede1a7444310114937e2

See more details on using hashes here.

File details

Details for the file running_process-3.0.17-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for running_process-3.0.17-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 3024450a5c29f006c3f00590583c36f58c2d74b791c61b6b18830d221a0c1a66
MD5 8b5752f5ad115a62605f9d7153adaeed
BLAKE2b-256 04d079c47482f097c0add24bb0ed1661e936138b0ec1e44c327f0a75a10975ba

See more details on using hashes here.

File details

Details for the file running_process-3.0.17-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for running_process-3.0.17-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 ba2dfd33945e7dfd86da2adef5d0dfae8976b5b2b9ae7dbf11790379f3f0e3cf
MD5 9102b7907bf208554c29cad8186a397f
BLAKE2b-256 dc9dd871e9e018c4e4d353a7ec491118bd35ba5c36435e42102ac360a9018902

See more details on using hashes here.

File details

Details for the file running_process-3.0.17-cp310-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for running_process-3.0.17-cp310-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 0cbce22a43dee37148bf4523150737092a493f88deee412b2b1ec13bcbc45d79
MD5 8a307da7bfe338a0665348682b0049d0
BLAKE2b-256 154334545c758f8c6204647e2139993757050286b81adb8d61cf1b2eec03d5d3

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page