Skip to content

Commit f8f2ffe

Browse files
authored
Merge pull request #35 from fastapi-startkit/docs/fix-process-page
docs(process): rewrite for async-by-default API
2 parents 1ebace3 + a34a8b8 commit f8f2ffe

1 file changed

Lines changed: 131 additions & 49 deletions

File tree

docs/process.md

Lines changed: 131 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,29 @@ keywords: process, subprocess, shell, commands, async, pipes, pools, testing, fa
77

88
# Process
99

10-
Fastapi Startkit ships a `Process` facade that wraps Python's `subprocess` module behind a clean, fluent interface. It lets you run shell commands synchronously, stream output asynchronously, build pipelines, and run pools of concurrent processes — all with first-class support for test fakes so no real processes are spawned during your test suite.
10+
Fastapi Startkit ships a `Process` facade that wraps Python's `subprocess` module behind a clean, fluent interface. It lets you run shell commands asynchronously, stream output in the background, build pipelines, and run pools of concurrent processes — all with first-class support for test fakes so no real processes are spawned during your test suite.
11+
12+
## Async vs Sync
13+
14+
`Process.run()` is **async by default** — it must be awaited and is designed for use inside FastAPI request handlers and other async contexts:
15+
16+
```python
17+
result = await Process.run('ls -la')
18+
```
19+
20+
If you are writing a CLI script or Cleo command that runs outside an event loop, use the synchronous fallback instead:
21+
22+
```python
23+
result = Process.run_sync('ls -la')
24+
```
25+
26+
| Method | Execution model | Use when |
27+
|---|---|---|
28+
| `run()` | async (asyncio) | FastAPI handlers, async functions |
29+
| `run_sync()` | sync (subprocess) | CLI scripts, Cleo commands, no event loop |
30+
| `pipe()` | async (asyncio) | FastAPI handlers, async pipelines |
31+
| `pipe_sync()` | sync (subprocess) | CLI pipelines without an event loop |
32+
| `start()` | threads | Long-running background tasks, streaming output |
1133

1234
## Basic Usage
1335

@@ -16,7 +38,7 @@ Import `Process` and call `run()` with any shell command. It returns a `ProcessR
1638
```python
1739
from fastapi_startkit.process import Process
1840

19-
result = Process.run('ls -la')
41+
result = await Process.run('ls -la')
2042

2143
print(result.output()) # stdout as a string
2244
print(result.exit_code()) # integer exit code
@@ -25,7 +47,7 @@ print(result.exit_code()) # integer exit code
2547
### Handling Success and Failure
2648

2749
```python
28-
result = Process.run('git status')
50+
result = await Process.run('git status')
2951

3052
if result.successful():
3153
print("Command succeeded")
@@ -36,92 +58,103 @@ if result.failed():
3658

3759
## ProcessResult API
3860

39-
Every `run()` call returns a `ProcessResult` with the following methods:
61+
Every `run()` / `run_sync()` call returns a `ProcessResult` with the following methods:
4062

4163
| Method | Description |
4264
|---|---|
4365
| `output()` | Returns captured stdout as a string |
4466
| `error_output()` | Returns captured stderr as a string |
67+
| `error()` | Alias for `error_output()` — stderr as a string |
68+
| `output_json()` | Parses stdout as JSON and returns the decoded value |
4569
| `exit_code()` | Returns the integer exit code |
4670
| `successful()` | `True` if exit code is `0` |
4771
| `failed()` | `True` if exit code is non-zero |
4872
| `throw()` | Raises `ProcessFailedException` if the process failed, otherwise returns `self` |
4973
| `throw_if(condition)` | Raises `ProcessFailedException` if `condition` is truthy |
5074
| `command()` | Returns the original command string |
5175

52-
`throw()` is useful for asserting success in a chain:
76+
`throw()` is useful for asserting success after a run:
5377

5478
```python
55-
result = Process.run('python migrate.py').throw()
79+
result = await Process.run('python migrate.py')
80+
result.throw()
5681
# raises ProcessFailedException if migration fails
5782
```
5883

84+
Use `output_json()` when a command returns JSON on stdout:
85+
86+
```python
87+
result = await Process.run('aws s3api list-buckets')
88+
data = result.output_json()
89+
print(data['Buckets'])
90+
```
91+
5992
## Fluent Builder Options
6093

61-
Any method on the `Process` facade that is not `run()` or `start()` returns a `PendingProcess` builder. Chain options before calling `run()`:
94+
Any class method on `Process` that is not `run()`, `run_sync()`, `pipe()`, `pipe_sync()`, `start()`, or `pool()` returns a `PendingProcess` builder. Chain options before calling `run()`:
6295

6396
### `timeout(seconds)`
6497

6598
Sets how long to wait before killing the process and raising `ProcessTimedOutException`. The default is 60 seconds.
6699

67100
```python
68-
result = Process.timeout(10).run('bash slow_script.sh')
101+
result = await Process.timeout(10).run('bash slow_script.sh')
69102
```
70103

71104
### `forever()`
72105

73106
Disables the timeout entirely.
74107

75108
```python
76-
result = Process.forever().run('bash long_import.sh')
109+
result = await Process.forever().run('bash long_import.sh')
77110
```
78111

79112
### `quietly()`
80113

81114
Discards all stdout and stderr output. Useful when you only care about the exit code.
82115

83116
```python
84-
result = Process.quietly().run('npm install')
117+
result = await Process.quietly().run('npm install')
85118
```
86119

87120
### `tty()`
88121

89122
Passes stdin, stdout, and stderr directly through to the terminal. Output is not captured.
90123

91124
```python
92-
Process.tty().run('vim file.txt')
125+
await Process.tty().run('vim file.txt')
93126
```
94127

95128
### `env(variables)`
96129

97130
Merges additional environment variables into the process environment.
98131

99132
```python
100-
result = Process.env({'APP_ENV': 'production', 'DEBUG': '0'}).run('python app.py')
133+
result = await Process.env({'APP_ENV': 'production', 'DEBUG': '0'}).run('python app.py')
101134
```
102135

103136
### `path(directory)`
104137

105138
Sets the working directory for the process.
106139

107140
```python
108-
result = Process.path('/var/www/app').run('git pull origin main')
141+
result = await Process.path('/var/www/app').run('git pull origin main')
109142
```
110143

111144
### `input(data)`
112145

113146
Pipes a string into the process's stdin.
114147

115148
```python
116-
result = Process.input('yes\n').run('apt-get install -y some-package')
149+
result = await Process.input('yes\n').run('apt-get install -y some-package')
117150
```
118151

119152
### Combining Options
120153

121154
Options chain together — each returns the same `PendingProcess`:
122155

123156
```python
124-
result = (
157+
result = await (
125158
Process
126159
.timeout(30)
127160
.path('/var/www/app')
@@ -131,9 +164,76 @@ result = (
131164
)
132165
```
133166

134-
## Async Execution
167+
## Piping
168+
169+
`Process.pipe()` builds a shell pipeline from multiple commands. Pass a callback that receives a `Pipe` builder and calls `.command()` for each stage:
170+
171+
```python
172+
result = await Process.pipe(lambda p: (
173+
p.command('cat access.log'),
174+
p.command('grep "ERROR"'),
175+
p.command('wc -l'),
176+
))
177+
178+
print(result.output()) # count of ERROR lines
179+
```
180+
181+
The commands are joined with `|` and executed as a single shell command. All fluent builder options apply before calling `pipe()`:
182+
183+
```python
184+
result = await Process.path('/var/www/app').timeout(15).pipe(lambda p: (
185+
p.command('find . -name "*.py"'),
186+
p.command('xargs wc -l'),
187+
))
188+
```
189+
190+
## Sync Execution
135191

136-
Use `Process.start()` to launch a process without blocking. It returns an `InvokedProcess` that streams output as lines arrive via a callback:
192+
Use `run_sync()` and `pipe_sync()` when you are writing CLI scripts or Cleo commands that run outside an event loop.
193+
194+
### `run_sync()`
195+
196+
```python
197+
from fastapi_startkit.process import Process
198+
199+
result = Process.run_sync('ls -la')
200+
print(result.output())
201+
print(result.exit_code())
202+
```
203+
204+
All fluent builder options work the same way:
205+
206+
```python
207+
result = (
208+
Process
209+
.timeout(30)
210+
.path('/var/www/app')
211+
.quietly()
212+
.run_sync('bash deploy.sh')
213+
)
214+
```
215+
216+
### `pipe_sync()`
217+
218+
```python
219+
result = Process.pipe_sync(lambda p: (
220+
p.command('cat access.log'),
221+
p.command('grep "ERROR"'),
222+
p.command('wc -l'),
223+
))
224+
225+
print(result.output()) # count of ERROR lines
226+
```
227+
228+
::: tip When to use `run_sync` vs `run`
229+
- Use **`run()`** inside FastAPI route handlers and any `async def` context.
230+
- Use **`run_sync()`** in Cleo commands, standalone scripts, or any code that runs without an event loop.
231+
Calling `run()` outside an async context will raise a `RuntimeError` because there is no running event loop.
232+
:::
233+
234+
## Background Execution
235+
236+
Use `Process.start()` to launch a process in the background without blocking. It spawns a thread-backed `InvokedProcess` that streams output as lines arrive via a callback:
137237

138238
```python
139239
import time
@@ -152,6 +252,10 @@ result = invoked.wait()
152252
print("Exit code:", result.exit_code())
153253
```
154254

255+
::: info Background execution uses threads
256+
`Process.start()` launches the subprocess via `subprocess.Popen` and reads its output on background threads — it does **not** use asyncio. Use it for long-running tasks where you want to stream output in real time, not for general async usage inside FastAPI handlers (use `await Process.run()` for that).
257+
:::
258+
155259
### `InvokedProcess` API
156260

157261
| Method | Description |
@@ -163,32 +267,9 @@ print("Exit code:", result.exit_code())
163267
| `signal(sig)` | Sends a signal (e.g. `signal.SIGTERM`) to the process |
164268
| `ensure_not_timed_out()` | Raises `ProcessTimedOutException` if the configured timeout has elapsed |
165269

166-
## Piping
167-
168-
`Process.pipe()` builds a shell pipeline from multiple commands. Pass a callback that receives a `Pipe` builder and calls `.command()` for each stage:
169-
170-
```python
171-
result = Process.pipe(lambda p: (
172-
p.command('cat access.log'),
173-
p.command('grep "ERROR"'),
174-
p.command('wc -l'),
175-
))
176-
177-
print(result.output()) # count of ERROR lines
178-
```
179-
180-
The commands are joined with `|` and executed as a single shell command. All fluent builder options apply before calling `pipe()`:
181-
182-
```python
183-
result = Process.path('/var/www/app').timeout(15).pipe(lambda p: (
184-
p.command('find . -name "*.py"'),
185-
p.command('xargs wc -l'),
186-
))
187-
```
188-
189270
## Pools (Concurrent Processes)
190271

191-
`Process.pool()` runs multiple commands concurrently. Pass a callback that adds commands to a `Pool`, then call `.start()` to launch all of them and `.wait()` to collect results:
272+
`Process.pool()` runs multiple commands concurrently using background threads. Pass a callback that adds commands to a `Pool`, then call `.start()` to launch all of them and `.wait()` to collect results:
192273

193274
```python
194275
pool = Process.pool(lambda p: (
@@ -239,12 +320,12 @@ results = pool.wait()
239320

240321
## Testing with Fakes
241322

242-
Call `Process.fake()` at the start of a test to intercept all process calls. No real subprocesses are spawned.
323+
Call `Process.fake()` at the start of a test to intercept all process calls. No real subprocesses are spawned. Because `Process.run()` is async, test functions must be declared with `async def` and await every call.
243324

244325
```python
245326
from fastapi_startkit.process import Process
246327

247-
def test_deploy_script():
328+
async def test_deploy_script():
248329
fake = Process.fake()
249330

250331
# ... call code that internally runs Process.run('bash deploy.sh')
@@ -266,11 +347,11 @@ fake = Process.fake({
266347
'bash rollback.sh': Process.describe().error_output('Rollback failed').exit_code(1),
267348
})
268349

269-
result = Process.run('bash deploy.sh')
350+
result = await Process.run('bash deploy.sh')
270351
assert result.output() == 'Deployed successfully'
271352
assert result.successful()
272353

273-
result = Process.run('bash rollback.sh')
354+
result = await Process.run('bash rollback.sh')
274355
assert result.failed()
275356
```
276357

@@ -319,8 +400,8 @@ def fake_process():
319400
yield fake
320401
Process.reset_fake()
321402

322-
def test_my_command(fake_process):
323-
Process.run('echo hello')
403+
async def test_my_command(fake_process):
404+
await Process.run('echo hello')
324405
fake_process.assert_ran('echo hello')
325406
```
326407

@@ -334,7 +415,8 @@ Raised by `result.throw()` when the process exits with a non-zero code. It expos
334415
from fastapi_startkit.process.exception import ProcessFailedException
335416

336417
try:
337-
Process.run('bash risky.sh').throw()
418+
result = await Process.run('bash risky.sh')
419+
result.throw()
338420
except ProcessFailedException as e:
339421
print(e.result.error_output())
340422
print("Exit code:", e.result.exit_code())
@@ -348,7 +430,7 @@ Raised when a process exceeds its configured timeout. It exposes `.command` —
348430
from fastapi_startkit.process.exception import ProcessTimedOutException
349431

350432
try:
351-
Process.timeout(5).run('sleep 60')
433+
result = await Process.timeout(5).run('sleep 60')
352434
except ProcessTimedOutException as e:
353435
print("Timed out:", e.command)
354436
```

0 commit comments

Comments
 (0)