|
| 1 | +# Coming from `subprocess` |
| 2 | + |
| 3 | +[‹ docs index](README.md) |
| 4 | + |
| 5 | +You already know `subprocess` (or `asyncio.subprocess`). This guide maps the |
| 6 | +patterns you write today onto their `processkit` equivalents, so porting existing |
| 7 | +code is mechanical — and then shows the one thing the stdlib can't do that is the |
| 8 | +reason to switch: **containing the whole process tree**. |
| 9 | + |
| 10 | +Every snippet assumes `from processkit import ...`. For the full treatment of any |
| 11 | +verb, follow the links into [Running commands](commands.md). |
| 12 | + |
| 13 | +## The mental-model shift |
| 14 | + |
| 15 | +`subprocess` couples *running* a command with *deciding whether it failed*: |
| 16 | +`run(...)` gives you a `returncode` to inspect, `run(..., check=True)` raises. In |
| 17 | +`processkit` those are two different verbs: |
| 18 | + |
| 19 | +- `Command(...).output()` **captures** the result — a non-zero exit, a timeout, and |
| 20 | + a signal-kill are all **data** on a `ProcessResult`, never an exception. |
| 21 | +- `Command(...).run()` **requires success** — it returns trimmed stdout and raises a |
| 22 | + typed exception on a non-zero exit, a timeout, or a signal-kill. |
| 23 | + |
| 24 | +Pick the verb by what you want; you no longer thread a `check=` flag through. |
| 25 | +See [Picking a verb](commands.md#picking-a-verb) for the full set. |
| 26 | + |
| 27 | +## Running a command (sync) |
| 28 | + |
| 29 | +| You wrote (`subprocess`) | Now write (`processkit`) | |
| 30 | +|---|---| |
| 31 | +| `run(cmd, capture_output=True, text=True)` → inspect `.returncode` / `.stdout` | `Command(prog, args).output()` → `ProcessResult` (`.code`, `.stdout`, `.is_success`, `.timed_out`) | |
| 32 | +| `run(cmd, capture_output=True, text=True, check=True).stdout` | `Command(prog, args).run()` (returns **trimmed** stdout, raises on failure) | |
| 33 | +| `run(cmd).returncode` | `Command(prog, args).exit_code()` | |
| 34 | +| `run(cmd).returncode == 0` | `Command(prog, args).probe()` (`True`/`False`) | |
| 35 | +| `run(cmd, capture_output=True).stdout` (bytes) | `Command(prog, args).output_bytes()` → `BytesResult` (`.stdout` is `bytes`) | |
| 36 | + |
| 37 | +```python |
| 38 | +from processkit import Command |
| 39 | + |
| 40 | +# subprocess: subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True) |
| 41 | +result = Command("git", ["rev-parse", "HEAD"]).output() |
| 42 | +print(result.stdout.strip(), result.code, result.is_success) |
| 43 | + |
| 44 | +# subprocess: subprocess.run([...], check=True, capture_output=True, text=True).stdout |
| 45 | +commit = Command("git", ["rev-parse", "HEAD"]).run() # trimmed stdout, raises on failure |
| 46 | +``` |
| 47 | + |
| 48 | +Note the two differences from `run()` in `subprocess`: `.output().stdout` is the |
| 49 | +**full** captured text (not stripped — strip it yourself), while `.run()` returns |
| 50 | +it **trimmed**; and a non-zero exit is only an error for `.run()`, never for |
| 51 | +`.output()`. |
| 52 | + |
| 53 | +## The common flags |
| 54 | + |
| 55 | +| `subprocess` keyword | `processkit` builder | |
| 56 | +|---|---| |
| 57 | +| `timeout=5` | `.timeout(5.0)` — captured on `.output()` (`result.timed_out`), raised by `.run()` | |
| 58 | +| `input="text"` / `input=b"..."` | `.stdin_text("text")` / `.stdin_bytes(b"...")` | |
| 59 | +| `cwd="/path"` | `.cwd("/path")` | |
| 60 | +| `env={...}` (**replaces** the whole environment) | `.env_clear().envs({...})` | |
| 61 | +| add/override one variable on the inherited env | `.env("KEY", "value")` / `.envs({...})` | |
| 62 | +| — (no equivalent) | `.success_codes([0, 1])` — treat listed codes as success (`grep`/`diff`) | |
| 63 | + |
| 64 | +```python |
| 65 | +# subprocess: subprocess.run(["slow"], timeout=5) -> raises TimeoutExpired |
| 66 | +Command("slow").timeout(5.0).run() # raises Timeout on expiry |
| 67 | +result = Command("slow").timeout(5.0).output() # result.timed_out is True instead |
| 68 | + |
| 69 | +# subprocess: subprocess.run(["tr","a-z","A-Z"], input="hello\n", text=True) |
| 70 | +Command("tr", ["a-z", "A-Z"]).stdin_text("hello\n").run() |
| 71 | + |
| 72 | +# subprocess: subprocess.run(["grep","x","f"], check=True) # exit 1 = "no match" -> would raise |
| 73 | +Command("grep", ["x", "f"]).success_codes([0, 1]).run() # 1 (no match) is not a failure |
| 74 | +``` |
| 75 | + |
| 76 | +`env=` in `subprocess` **replaces** the entire environment; the direct equivalent |
| 77 | +is `.env_clear().envs({...})`. To *add to* the inherited environment (the more |
| 78 | +common intent), use `.env(...)` / `.envs(...)` without `env_clear()`. More in |
| 79 | +[Environment and sandboxing](commands.md#environment-and-sandboxing). |
| 80 | + |
| 81 | +## Shell pipelines, without the shell |
| 82 | + |
| 83 | +`subprocess` pipelines usually mean `shell=True` (and a shell-injection footgun) or |
| 84 | +hand-wiring two `Popen`s. `processkit` pipes are shell-free: |
| 85 | + |
| 86 | +```python |
| 87 | +# subprocess: subprocess.run("ps aux | grep python", shell=True) |
| 88 | +from processkit import Command |
| 89 | + |
| 90 | +out = (Command("ps", ["aux"]) | Command("grep", ["python"])).run() |
| 91 | +``` |
| 92 | + |
| 93 | +See [Pipelines](pipelines.md) for pipefail attribution and binary tails. |
| 94 | + |
| 95 | +## Async |
| 96 | + |
| 97 | +If you reach for `asyncio.subprocess`, every verb has an `a`-prefixed twin that |
| 98 | +shares the same types: |
| 99 | + |
| 100 | +```python |
| 101 | +# asyncio: proc = await asyncio.create_subprocess_exec("git","status", stdout=PIPE) |
| 102 | +# out, _ = await proc.communicate() |
| 103 | +result = await Command("git", ["status", "--short"]).aoutput() |
| 104 | + |
| 105 | +# Streaming stdout line by line (asyncio-native): |
| 106 | +proc = await Command("my-build", ["--watch"]).astart() |
| 107 | +async for line in proc.stdout_lines(): |
| 108 | + print(line) |
| 109 | +finished = await proc.finish() |
| 110 | +``` |
| 111 | + |
| 112 | +Streaming, interactive stdin, and readiness probes are covered in |
| 113 | +[Streaming & interactive I/O](streaming.md). |
| 114 | + |
| 115 | +## Exceptions |
| 116 | + |
| 117 | +The exception hierarchy is independent, but the three that mirror a stdlib builtin |
| 118 | +also *subclass* it — so your existing `except` clauses keep working: |
| 119 | + |
| 120 | +| `subprocess` raises | `processkit` raises | Also a subclass of | |
| 121 | +|---|---|---| |
| 122 | +| `CalledProcessError` (from `check=True`) | `NonZeroExit` (`.code`, `.stderr`) | — | |
| 123 | +| `TimeoutExpired` | `Timeout` (`.timeout_seconds`) | `TimeoutError` | |
| 124 | +| `FileNotFoundError` (missing program) | `ProcessNotFound` (`.program`) | `FileNotFoundError` | |
| 125 | +| `PermissionError` | `PermissionDenied` (`.program`) | `PermissionError` | |
| 126 | + |
| 127 | +```python |
| 128 | +# This subprocess-style handler keeps working, because ProcessNotFound *is* a |
| 129 | +# FileNotFoundError and Timeout *is* a TimeoutError: |
| 130 | +from processkit import Command |
| 131 | + |
| 132 | +try: |
| 133 | + Command("mytool").timeout(5.0).run() |
| 134 | +except FileNotFoundError: |
| 135 | + print("not installed") |
| 136 | +except TimeoutError: |
| 137 | + print("timed out") |
| 138 | +``` |
| 139 | + |
| 140 | +Every exception derives from `ProcessError`; see [Errors](commands.md#errors). |
| 141 | + |
| 142 | +## What you actually gain: containing the tree |
| 143 | + |
| 144 | +Everything above is convenience — the *reason* to switch is that `subprocess` and |
| 145 | +`asyncio.subprocess` reach only the **direct child**. The processes *it* spawns (a |
| 146 | +build tool's compilers, the real payload behind a `sh -c` wrapper, a test's helper |
| 147 | +servers) survive a timeout, an exception, or a cancelled task and keep running as |
| 148 | +orphans. `processkit` spawns every child into the operating system's own |
| 149 | +containment primitive, so teardown is one kernel operation over the whole tree: |
| 150 | + |
| 151 | +```python |
| 152 | +from processkit import Command, ProcessGroup |
| 153 | + |
| 154 | +with ProcessGroup() as group: |
| 155 | + group.start(Command("dev-server")) |
| 156 | + group.start(Command("worker")) |
| 157 | + # ... use them ... |
| 158 | +# leaving the block reaps the whole tree — grandchildren included |
| 159 | +``` |
| 160 | + |
| 161 | +Even a single one-shot verb gets this for free: `Command(...).output()` runs inside |
| 162 | +a private group that dies with the call, and cancelling an awaited `aoutput()` |
| 163 | +reaps its tree. On top of the guarantee you also get whole-tree **resource limits** |
| 164 | +(memory / process-count / CPU caps) for sandboxing untrusted children — something |
| 165 | +`subprocess` cannot express at all. See [Process groups](process-groups.md) and |
| 166 | +[Resource limits](process-groups.md#resource-limits-the-sandbox). |
| 167 | + |
| 168 | +## When to stay with `subprocess` |
| 169 | + |
| 170 | +`processkit` earns its place when you run process *trees*, need them reaped |
| 171 | +reliably, or want resource-limited sandboxes. If you only ever run leaf commands |
| 172 | +that never spawn children of their own, don't need async cancellation to be |
| 173 | +leak-safe, and want zero third-party dependencies, the stdlib is a perfectly good |
| 174 | +choice — `processkit` is deliberately **not** a general `subprocess`-convenience |
| 175 | +replacement. The wedge is the no-orphan guarantee. |
0 commit comments