Skip to content

Commit ae783ab

Browse files
committed
docs: add a "Coming from subprocess" migration guide
1 parent 138ad9c commit ae783ab

5 files changed

Lines changed: 187 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
162162
- Runnable [`examples/`](examples/) — self-contained, cross-platform programs, one
163163
per target niche (whole-tree no-orphan teardown, a readiness-gated server,
164164
supervision-until-healthy, a resource-limited sandbox). Each is exercised in CI.
165+
- Docs: a **"Coming from subprocess"** guide that maps `subprocess` /
166+
`asyncio.subprocess` patterns onto their processkit equivalents (verbs, flags,
167+
pipelines, the exception mapping) and shows the whole-tree containment the stdlib
168+
can't express.
165169

166170
### Changed
167171
- Renamed `Command.ok_codes()`**`success_codes()`** (clearer that it is the

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ to …" tasks to working snippets — then read
161161
| Guide | Covers |
162162
|---|---|
163163
| [Cookbook](docs/cookbook.md) | Task → snippet recipes for everything below; the fastest way in |
164+
| [Coming from subprocess](docs/migrating.md) | Translating your `subprocess` / `asyncio.subprocess` code, and what containment adds |
164165
| [Running commands](docs/commands.md) | The full `Command` builder and every consuming verb, with error semantics |
165166
| [Process groups](docs/process-groups.md) | Containment, teardown, signals, suspend/resume, members, limits, stats |
166167
| [Streaming & interactive I/O](docs/streaming.md) | Line streaming, conversational stdin, readiness probes, per-run profiling |

docs/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ and the same no-orphan guarantee.
3434

3535
**New here?** Start with the [Cookbook](cookbook.md) — short task-to-snippet
3636
recipes for everything the package does — then read [Running commands](commands.md)
37-
end to end (it's the vocabulary every other guide builds on). Reach for the rest
38-
as the need arises, and keep [Platform support](platforms.md) handy before you
39-
ship: it collects every per-OS caveat in one place.
37+
end to end (it's the vocabulary every other guide builds on). Coming from the
38+
standard library? [Coming from subprocess](migrating.md) maps your existing
39+
`subprocess` / `asyncio.subprocess` patterns onto their processkit equivalents.
40+
Reach for the rest as the need arises, and keep [Platform support](platforms.md)
41+
handy before you ship: it collects every per-OS caveat in one place.
4042

4143
| Guide | Covers |
4244
|---|---|
4345
| [Cookbook](cookbook.md) | "I want to …" → working snippet, for every capability; the fastest way in |
46+
| [Coming from subprocess](migrating.md) | Side-by-side translation of `subprocess` / `asyncio.subprocess` patterns, the exception mapping, and the whole-tree containment the stdlib can't give |
4447
| [Running commands](commands.md) | The `Command` builder end to end — args, env/sandboxing, stdin, stdout/stderr redirection, encodings, output caps, timeouts, privileges — and every consuming verb (`output`, `run`, `probe`, …) with its error semantics |
4548
| [Process groups](process-groups.md) | Kill-on-drop containment: creating groups, spawning, teardown, whole-tree signals, suspend/resume, member listing, resource limits, stats |
4649
| [Streaming & interactive I/O](streaming.md) | `astart()` and the live `RunningProcess`: line streaming, interactive stdin, readiness probes (`wait_for_line` / `wait_for_port` / `wait_for`), per-run profiling |

docs/migrating.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ theme:
4141
nav:
4242
- Home: README.md
4343
- Cookbook: cookbook.md
44+
- Coming from subprocess: migrating.md
4445
- Running commands: commands.md
4546
- Process groups: process-groups.md
4647
- Streaming & interactive I/O: streaming.md

0 commit comments

Comments
 (0)