bombshell 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bombshell-0.1.0/.gitignore +11 -0
- bombshell-0.1.0/LICENSE +21 -0
- bombshell-0.1.0/PKG-INFO +232 -0
- bombshell-0.1.0/README.md +205 -0
- bombshell-0.1.0/bombshell/__init__.py +3 -0
- bombshell-0.1.0/bombshell/core.py +451 -0
- bombshell-0.1.0/bombshell/stream.py +18 -0
- bombshell-0.1.0/pyproject.toml +75 -0
bombshell-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lilellia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
bombshell-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bombshell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A library for easily running shell commands, whether standalone or piped.
|
|
5
|
+
Project-URL: repository, https://github.com/lilellia/bombshell
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/lilellia/bombshell/issues
|
|
7
|
+
Author-email: Lily Ellington <lilell_@outlook.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: bash,command,command-line,pipe,shell,subprocess,zsh
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: typing-extensions>=4.0; python_version < '3.11'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# bombshell
|
|
29
|
+
|
|
30
|
+
A library for easily running subprocesses in Python, whether single or piped.
|
|
31
|
+
|
|
32
|
+
## Why?
|
|
33
|
+
|
|
34
|
+
Python's `subprocess` library is capable of running whatever you need it to, but isn't always the most friendly or readable option, even when running a single process:
|
|
35
|
+
|
|
36
|
+
```py
|
|
37
|
+
res = subprocess.run(("echo", "1"), capture_output=True, text=True)
|
|
38
|
+
print(res.stdout) # "1\n"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Needing to pass `capture_output=True, text=True` all the time is annoying when those are probably the most common default. Plus, the command has to be passed as a tuple/list, rather than just the arguments themselves.
|
|
42
|
+
|
|
43
|
+
```py
|
|
44
|
+
res = bombshell.Process("echo", "1").exec()
|
|
45
|
+
print(res.stdout) # "1\n"
|
|
46
|
+
print(type(res.stdout)) # <class 'str'>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
But if you want bytes, then you can have bytes:
|
|
50
|
+
|
|
51
|
+
```py
|
|
52
|
+
res = bombshell.Process("echo", "1").exec(mode=bytes)
|
|
53
|
+
print(res.stdout) # b"1\n"
|
|
54
|
+
print(type(res.stdout)) # <class 'bytes'>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`subprocess` is also really picky about the types of arguments you pass in:
|
|
58
|
+
|
|
59
|
+
```py
|
|
60
|
+
res = subprocess.run(("echo", 1))
|
|
61
|
+
TypeError: "expected str, bytes or os.PathLike object, not int"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Why, though? `bombshell` automatically calls `str()` on every argument passed to it.
|
|
65
|
+
|
|
66
|
+
```py
|
|
67
|
+
res = bombshell.Process("echo", 1).exec()
|
|
68
|
+
print(res.stdout) # "1\n"
|
|
69
|
+
print(res.exit_code) # 0
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`subprocess` also makes piping commands way more difficult than it needs to be. What's easy in Bash...
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
res=$(echo "hello\nworld\ngoodbye" | grep "l")
|
|
76
|
+
echo "$res" # "hello\nworld"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
...is way more complicated with `subprocess` since you have to individually manage both sides of the pipe.
|
|
80
|
+
|
|
81
|
+
```py
|
|
82
|
+
parent = subprocess.Popen(("echo", "hello\nworld\ngoodbye"), stdout=subprocess.PIPE)
|
|
83
|
+
child = subprocess.Popen(("grep", "l"), stdin=parent.stdout, capture_output=True, text=True)
|
|
84
|
+
stdout, _ = child.communicate()
|
|
85
|
+
|
|
86
|
+
print(stdout) # "hello\nworld"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
There must be a better way.
|
|
90
|
+
|
|
91
|
+
```py
|
|
92
|
+
res = bombshell.Process("echo", "hello\nworld\ngoodbye").pipe_into("grep", "l")
|
|
93
|
+
print(res.stdout) # "hello\nworld"
|
|
94
|
+
|
|
95
|
+
# Process supports .__or__, so we can also do
|
|
96
|
+
p1 = bombshell.Process("echo", "hello\nworld\ngoodbye")
|
|
97
|
+
p2 = bombshell.Process("grep", "l")
|
|
98
|
+
res = (p1 | p2).exec()
|
|
99
|
+
print(res.stdout) # "hello\nworld"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
We can also pass environment variables to individual commands:
|
|
103
|
+
|
|
104
|
+
```py
|
|
105
|
+
res = subprocess.run(("printenv", "FOO"), capture_output=True, text=True, env={"FOO": "bar"})
|
|
106
|
+
print(res.stdout) # "bar\n"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
res = bombshell.Process("printenv", "FOO").with_env(FOO="bar").exec()
|
|
110
|
+
print(res.stdout) # "bar\n"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`subprocess` also makes it somewhat difficult to chain commands (`command1 && command2`), preferring:
|
|
114
|
+
|
|
115
|
+
```py
|
|
116
|
+
# only "echo 1" and "echo 2" will successfully run; "echo 3" will not
|
|
117
|
+
procs = [("echo", "1"), ("echo", "2"), ("false",), ("echo", "3")]
|
|
118
|
+
for proc in procs:
|
|
119
|
+
res = subprocess.run(proc, capture_output=True, text=True)
|
|
120
|
+
if res.returncode:
|
|
121
|
+
break
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
whereas we can do
|
|
125
|
+
|
|
126
|
+
```py
|
|
127
|
+
res = bombshell.Process("echo", 1).then("echo", 2).then("false").then("echo", "3")
|
|
128
|
+
print(res.command) # echo 1 && echo 2 && false && echo 3
|
|
129
|
+
print(res.stdout) # "1\n2\n"
|
|
130
|
+
print(res.exit_code) # 1
|
|
131
|
+
print(res.exit_codes) # [0, 0, 1] <-- indicating that the first two echo commands exited with 0, then false exited with 1
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Installation
|
|
135
|
+
|
|
136
|
+
`bombshell` is supported on Python 3.10 and newer and can be easily installed with a package manager such as:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# using pip
|
|
140
|
+
$ pip install bombshell
|
|
141
|
+
|
|
142
|
+
# using uv
|
|
143
|
+
$ uv add bombshell
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`bombshell` has no other external dependencies (except `typing_extensions`, only on Python 3.10).
|
|
147
|
+
|
|
148
|
+
## Documentation
|
|
149
|
+
|
|
150
|
+
### `PipelineError`
|
|
151
|
+
|
|
152
|
+
An error that is thrown by `CompletedProcess.check()` when the pipeline has errored. It stores the calling process under its `.process` attribute.
|
|
153
|
+
|
|
154
|
+
```py
|
|
155
|
+
try:
|
|
156
|
+
bombshell.Process("false").exec().check()
|
|
157
|
+
except bombshell.PipelineError as err:
|
|
158
|
+
# err.process == bombshell.Process("false").exec()
|
|
159
|
+
print(err.process.command) # "false"
|
|
160
|
+
print(err.process.exit_codes) # [1]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `CompletedProcess[S]`
|
|
164
|
+
|
|
165
|
+
An object that stores the state of a completed process. In particular, its attributes are:
|
|
166
|
+
|
|
167
|
+
- `args: tuple[tuple[str, ...], ...]`: the arguments that were passed to the process(es) that gave this result
|
|
168
|
+
- `command: str`: a string representation of the command as would be run on the command line
|
|
169
|
+
- `exit_codes: list[int]`: all of the exit codes for the various processes in the pipeline
|
|
170
|
+
- `exit_code: int`: the exit code of the last executed part of the pipeline (and thus the exit code for the entire pipeline)
|
|
171
|
+
- `stdout: str | bytes`: the contents of the stdout pipes, if captured. `.exec(mode=str)` (the default) means that this will be a string; `.exec(mode=bytes)` means this will be a byte string. Note: `(p1 | p2).exec().stdout` will contain only the stdout for `p2`; `p1.then(p2).exec().stdout` will contain both.
|
|
172
|
+
- `stderr: str | bytes`: the contents of the stderr pipes, if captured. `.exec(mode=str)` (the default) means that this will be a string; `.exec(mode=bytes)` means this will be a byte string. This will always include the combination of all stderr pipes, if captured.
|
|
173
|
+
|
|
174
|
+
```py
|
|
175
|
+
res = (
|
|
176
|
+
bombshell.Process("echo", 1)
|
|
177
|
+
.pipe_into("echo", 2)
|
|
178
|
+
.pipe_into("false")
|
|
179
|
+
.pipe_into("echo", 3)
|
|
180
|
+
.exec()
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
print(res.args) # (("echo", "1"), ("echo", "2"), ("false",), ("echo", "3"))
|
|
184
|
+
print(res.command) # "echo 1 | echo 2 | false | echo 3"
|
|
185
|
+
print(res.exit_codes) # [0, 0, 1, 0]
|
|
186
|
+
print(res.exit_code) # 0
|
|
187
|
+
print(res.stdout) # "3\n"
|
|
188
|
+
print(res.stderr) # ""
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
This class also defines a `.check` method:
|
|
192
|
+
|
|
193
|
+
```py
|
|
194
|
+
res = (
|
|
195
|
+
bombshell.Process("echo", 1)
|
|
196
|
+
.pipe_into("echo", 2)
|
|
197
|
+
.pipe_into("false")
|
|
198
|
+
.pipe_into("echo", 3)
|
|
199
|
+
.exec()
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
res.check() # passes since the final exit code was zero
|
|
203
|
+
res.check(strict=True) # raises PipelineError since there was a failure along the pipeline
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### `Process`
|
|
207
|
+
|
|
208
|
+
A `Process` object takes a command to run as arguments, along with (optionally) an `env` mapping to use for it. The object defines:
|
|
209
|
+
|
|
210
|
+
- `exec(self, stdin: S | None = None, *, capture: bool = True, mode: type[S] = str, merge_stderr: bool = False) -> CompletedProcess[S]`: Run the given command. `S` is either `str` or `bytes` (but must match in all cases). `stdin` is a str/bytes value (not a pipe/file) to pass as stdin to this command. `capture=True` (default) means that stdout and stderr will be captured in the resulting CompletedProcess object. `mode` determines whether the output is of type `str` or `bytes`. If `merge_stderr` is True, then stderr is redirected to stdout (meaning that `exec().stdout` will contain both streams and `.stderr` will be empty).
|
|
211
|
+
|
|
212
|
+
- `__call__(...)`: an alias for `.exec(...)`.
|
|
213
|
+
|
|
214
|
+
- `with_env(self, **kwargs) -> Self`: return a new Process object with the updated environment variables. Note that this updates the current environment, rather than replacing it.
|
|
215
|
+
|
|
216
|
+
- `pipe_into(self, *args: Any, env: Mapping[str, str] = None | None) -> Pipeline`: return a new Pipeline object that represents `command1 | command2`. The given `args` can eithe ra series of values to use as a command (such as `Process("echo", 1).pipe_into("echo", 2)`, equivalent to `echo 1 | echo 2`), or it can be a single `Process` object (such as `Process("echo", 1).pipe_into(Process("echo", 2))`.)
|
|
217
|
+
|
|
218
|
+
- `__or__(self, other: Self) -> Pipeline`: an alias for `.pipe_into`, but requires that the other object is a `Process` object.
|
|
219
|
+
|
|
220
|
+
- `then(self, *args: Any) -> CommandChain`: return a CommandChain object that represents `command1 && command2`. The given `args` can be either a series of values to use as a command (such as `Process("echo", 1).then("echo", 2)`, equivalent to `echo 1 && echo 2`), or it can be a single Process/Pipeline/CommandChain object (such as `Process("echo", 1).then(Process("echo", 2)).)
|
|
221
|
+
|
|
222
|
+
### `Pipeline`
|
|
223
|
+
|
|
224
|
+
A `Pipeline` is an object that represents a piped series of commands. It provides the same methods to provide parity with `Process`, though `Pipeline.pipe_into` and `Pipeline.__or__` both support `Pipeline` as an object.
|
|
225
|
+
|
|
226
|
+
In practice, it is unlikely that you would create Pipeline objects directly, but rather as `Process(...).pipe_into(...)`.
|
|
227
|
+
|
|
228
|
+
### `CommandChain`
|
|
229
|
+
|
|
230
|
+
Like `Pipeline`, this is an object that represents a chained series of commands. It also provides the same methods to provide parity with `Process`.
|
|
231
|
+
|
|
232
|
+
It is unlikely that you would create CommandChain objects directly, but rather as `Process(...).then(...)`.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# bombshell
|
|
2
|
+
|
|
3
|
+
A library for easily running subprocesses in Python, whether single or piped.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
Python's `subprocess` library is capable of running whatever you need it to, but isn't always the most friendly or readable option, even when running a single process:
|
|
8
|
+
|
|
9
|
+
```py
|
|
10
|
+
res = subprocess.run(("echo", "1"), capture_output=True, text=True)
|
|
11
|
+
print(res.stdout) # "1\n"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Needing to pass `capture_output=True, text=True` all the time is annoying when those are probably the most common default. Plus, the command has to be passed as a tuple/list, rather than just the arguments themselves.
|
|
15
|
+
|
|
16
|
+
```py
|
|
17
|
+
res = bombshell.Process("echo", "1").exec()
|
|
18
|
+
print(res.stdout) # "1\n"
|
|
19
|
+
print(type(res.stdout)) # <class 'str'>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
But if you want bytes, then you can have bytes:
|
|
23
|
+
|
|
24
|
+
```py
|
|
25
|
+
res = bombshell.Process("echo", "1").exec(mode=bytes)
|
|
26
|
+
print(res.stdout) # b"1\n"
|
|
27
|
+
print(type(res.stdout)) # <class 'bytes'>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`subprocess` is also really picky about the types of arguments you pass in:
|
|
31
|
+
|
|
32
|
+
```py
|
|
33
|
+
res = subprocess.run(("echo", 1))
|
|
34
|
+
TypeError: "expected str, bytes or os.PathLike object, not int"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Why, though? `bombshell` automatically calls `str()` on every argument passed to it.
|
|
38
|
+
|
|
39
|
+
```py
|
|
40
|
+
res = bombshell.Process("echo", 1).exec()
|
|
41
|
+
print(res.stdout) # "1\n"
|
|
42
|
+
print(res.exit_code) # 0
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`subprocess` also makes piping commands way more difficult than it needs to be. What's easy in Bash...
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
res=$(echo "hello\nworld\ngoodbye" | grep "l")
|
|
49
|
+
echo "$res" # "hello\nworld"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
...is way more complicated with `subprocess` since you have to individually manage both sides of the pipe.
|
|
53
|
+
|
|
54
|
+
```py
|
|
55
|
+
parent = subprocess.Popen(("echo", "hello\nworld\ngoodbye"), stdout=subprocess.PIPE)
|
|
56
|
+
child = subprocess.Popen(("grep", "l"), stdin=parent.stdout, capture_output=True, text=True)
|
|
57
|
+
stdout, _ = child.communicate()
|
|
58
|
+
|
|
59
|
+
print(stdout) # "hello\nworld"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
There must be a better way.
|
|
63
|
+
|
|
64
|
+
```py
|
|
65
|
+
res = bombshell.Process("echo", "hello\nworld\ngoodbye").pipe_into("grep", "l")
|
|
66
|
+
print(res.stdout) # "hello\nworld"
|
|
67
|
+
|
|
68
|
+
# Process supports .__or__, so we can also do
|
|
69
|
+
p1 = bombshell.Process("echo", "hello\nworld\ngoodbye")
|
|
70
|
+
p2 = bombshell.Process("grep", "l")
|
|
71
|
+
res = (p1 | p2).exec()
|
|
72
|
+
print(res.stdout) # "hello\nworld"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
We can also pass environment variables to individual commands:
|
|
76
|
+
|
|
77
|
+
```py
|
|
78
|
+
res = subprocess.run(("printenv", "FOO"), capture_output=True, text=True, env={"FOO": "bar"})
|
|
79
|
+
print(res.stdout) # "bar\n"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
res = bombshell.Process("printenv", "FOO").with_env(FOO="bar").exec()
|
|
83
|
+
print(res.stdout) # "bar\n"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`subprocess` also makes it somewhat difficult to chain commands (`command1 && command2`), preferring:
|
|
87
|
+
|
|
88
|
+
```py
|
|
89
|
+
# only "echo 1" and "echo 2" will successfully run; "echo 3" will not
|
|
90
|
+
procs = [("echo", "1"), ("echo", "2"), ("false",), ("echo", "3")]
|
|
91
|
+
for proc in procs:
|
|
92
|
+
res = subprocess.run(proc, capture_output=True, text=True)
|
|
93
|
+
if res.returncode:
|
|
94
|
+
break
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
whereas we can do
|
|
98
|
+
|
|
99
|
+
```py
|
|
100
|
+
res = bombshell.Process("echo", 1).then("echo", 2).then("false").then("echo", "3")
|
|
101
|
+
print(res.command) # echo 1 && echo 2 && false && echo 3
|
|
102
|
+
print(res.stdout) # "1\n2\n"
|
|
103
|
+
print(res.exit_code) # 1
|
|
104
|
+
print(res.exit_codes) # [0, 0, 1] <-- indicating that the first two echo commands exited with 0, then false exited with 1
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Installation
|
|
108
|
+
|
|
109
|
+
`bombshell` is supported on Python 3.10 and newer and can be easily installed with a package manager such as:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# using pip
|
|
113
|
+
$ pip install bombshell
|
|
114
|
+
|
|
115
|
+
# using uv
|
|
116
|
+
$ uv add bombshell
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`bombshell` has no other external dependencies (except `typing_extensions`, only on Python 3.10).
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
### `PipelineError`
|
|
124
|
+
|
|
125
|
+
An error that is thrown by `CompletedProcess.check()` when the pipeline has errored. It stores the calling process under its `.process` attribute.
|
|
126
|
+
|
|
127
|
+
```py
|
|
128
|
+
try:
|
|
129
|
+
bombshell.Process("false").exec().check()
|
|
130
|
+
except bombshell.PipelineError as err:
|
|
131
|
+
# err.process == bombshell.Process("false").exec()
|
|
132
|
+
print(err.process.command) # "false"
|
|
133
|
+
print(err.process.exit_codes) # [1]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `CompletedProcess[S]`
|
|
137
|
+
|
|
138
|
+
An object that stores the state of a completed process. In particular, its attributes are:
|
|
139
|
+
|
|
140
|
+
- `args: tuple[tuple[str, ...], ...]`: the arguments that were passed to the process(es) that gave this result
|
|
141
|
+
- `command: str`: a string representation of the command as would be run on the command line
|
|
142
|
+
- `exit_codes: list[int]`: all of the exit codes for the various processes in the pipeline
|
|
143
|
+
- `exit_code: int`: the exit code of the last executed part of the pipeline (and thus the exit code for the entire pipeline)
|
|
144
|
+
- `stdout: str | bytes`: the contents of the stdout pipes, if captured. `.exec(mode=str)` (the default) means that this will be a string; `.exec(mode=bytes)` means this will be a byte string. Note: `(p1 | p2).exec().stdout` will contain only the stdout for `p2`; `p1.then(p2).exec().stdout` will contain both.
|
|
145
|
+
- `stderr: str | bytes`: the contents of the stderr pipes, if captured. `.exec(mode=str)` (the default) means that this will be a string; `.exec(mode=bytes)` means this will be a byte string. This will always include the combination of all stderr pipes, if captured.
|
|
146
|
+
|
|
147
|
+
```py
|
|
148
|
+
res = (
|
|
149
|
+
bombshell.Process("echo", 1)
|
|
150
|
+
.pipe_into("echo", 2)
|
|
151
|
+
.pipe_into("false")
|
|
152
|
+
.pipe_into("echo", 3)
|
|
153
|
+
.exec()
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
print(res.args) # (("echo", "1"), ("echo", "2"), ("false",), ("echo", "3"))
|
|
157
|
+
print(res.command) # "echo 1 | echo 2 | false | echo 3"
|
|
158
|
+
print(res.exit_codes) # [0, 0, 1, 0]
|
|
159
|
+
print(res.exit_code) # 0
|
|
160
|
+
print(res.stdout) # "3\n"
|
|
161
|
+
print(res.stderr) # ""
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This class also defines a `.check` method:
|
|
165
|
+
|
|
166
|
+
```py
|
|
167
|
+
res = (
|
|
168
|
+
bombshell.Process("echo", 1)
|
|
169
|
+
.pipe_into("echo", 2)
|
|
170
|
+
.pipe_into("false")
|
|
171
|
+
.pipe_into("echo", 3)
|
|
172
|
+
.exec()
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
res.check() # passes since the final exit code was zero
|
|
176
|
+
res.check(strict=True) # raises PipelineError since there was a failure along the pipeline
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `Process`
|
|
180
|
+
|
|
181
|
+
A `Process` object takes a command to run as arguments, along with (optionally) an `env` mapping to use for it. The object defines:
|
|
182
|
+
|
|
183
|
+
- `exec(self, stdin: S | None = None, *, capture: bool = True, mode: type[S] = str, merge_stderr: bool = False) -> CompletedProcess[S]`: Run the given command. `S` is either `str` or `bytes` (but must match in all cases). `stdin` is a str/bytes value (not a pipe/file) to pass as stdin to this command. `capture=True` (default) means that stdout and stderr will be captured in the resulting CompletedProcess object. `mode` determines whether the output is of type `str` or `bytes`. If `merge_stderr` is True, then stderr is redirected to stdout (meaning that `exec().stdout` will contain both streams and `.stderr` will be empty).
|
|
184
|
+
|
|
185
|
+
- `__call__(...)`: an alias for `.exec(...)`.
|
|
186
|
+
|
|
187
|
+
- `with_env(self, **kwargs) -> Self`: return a new Process object with the updated environment variables. Note that this updates the current environment, rather than replacing it.
|
|
188
|
+
|
|
189
|
+
- `pipe_into(self, *args: Any, env: Mapping[str, str] = None | None) -> Pipeline`: return a new Pipeline object that represents `command1 | command2`. The given `args` can eithe ra series of values to use as a command (such as `Process("echo", 1).pipe_into("echo", 2)`, equivalent to `echo 1 | echo 2`), or it can be a single `Process` object (such as `Process("echo", 1).pipe_into(Process("echo", 2))`.)
|
|
190
|
+
|
|
191
|
+
- `__or__(self, other: Self) -> Pipeline`: an alias for `.pipe_into`, but requires that the other object is a `Process` object.
|
|
192
|
+
|
|
193
|
+
- `then(self, *args: Any) -> CommandChain`: return a CommandChain object that represents `command1 && command2`. The given `args` can be either a series of values to use as a command (such as `Process("echo", 1).then("echo", 2)`, equivalent to `echo 1 && echo 2`), or it can be a single Process/Pipeline/CommandChain object (such as `Process("echo", 1).then(Process("echo", 2)).)
|
|
194
|
+
|
|
195
|
+
### `Pipeline`
|
|
196
|
+
|
|
197
|
+
A `Pipeline` is an object that represents a piped series of commands. It provides the same methods to provide parity with `Process`, though `Pipeline.pipe_into` and `Pipeline.__or__` both support `Pipeline` as an object.
|
|
198
|
+
|
|
199
|
+
In practice, it is unlikely that you would create Pipeline objects directly, but rather as `Process(...).pipe_into(...)`.
|
|
200
|
+
|
|
201
|
+
### `CommandChain`
|
|
202
|
+
|
|
203
|
+
Like `Pipeline`, this is an object that represents a chained series of commands. It also provides the same methods to provide parity with `Process`.
|
|
204
|
+
|
|
205
|
+
It is unlikely that you would create CommandChain objects directly, but rather as `Process(...).then(...)`.
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import itertools
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from threading import Thread
|
|
11
|
+
from typing import Any, cast, Generic, IO, overload, TypeVar
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
from typing import Self
|
|
15
|
+
else:
|
|
16
|
+
from typing_extensions import Self
|
|
17
|
+
|
|
18
|
+
from .stream import consume_stream, feed_stream
|
|
19
|
+
|
|
20
|
+
S = TypeVar("S", str, bytes)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PipelineError(Exception, Generic[S]):
|
|
24
|
+
def __init__(self, process: CompletedProcess[S]) -> None:
|
|
25
|
+
msg = f"Pipeline exited with non-zero exit code(s): {process.exit_codes}"
|
|
26
|
+
super().__init__(msg)
|
|
27
|
+
self.process: CompletedProcess[S] = process
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class CompletedProcess(Generic[S]):
|
|
32
|
+
args: tuple[tuple[str, ...], ...]
|
|
33
|
+
command: str
|
|
34
|
+
exit_codes: list[int]
|
|
35
|
+
stdout: S
|
|
36
|
+
stderr: S
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def exit_code(self) -> int:
|
|
40
|
+
return self.exit_codes[-1]
|
|
41
|
+
|
|
42
|
+
def check(self, *, strict: bool = False) -> None:
|
|
43
|
+
"""Raise a PipelineError if the pipeline exited with a non-zero exit code.
|
|
44
|
+
|
|
45
|
+
:arg strict:
|
|
46
|
+
If True, raise PipelineError if any process exited with a non-zero exit code.
|
|
47
|
+
If False, raise PipelineError if the last process exited with a non-zero exit code.
|
|
48
|
+
|
|
49
|
+
:return: None
|
|
50
|
+
:raise PipelineError: If any (strict=True) or the last (strict=False) process exited with a non-zero exit code.
|
|
51
|
+
"""
|
|
52
|
+
if strict and any(ec != 0 for ec in self.exit_codes):
|
|
53
|
+
raise PipelineError(self)
|
|
54
|
+
|
|
55
|
+
if not strict and self.exit_code != 0:
|
|
56
|
+
raise PipelineError(self)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Process:
|
|
60
|
+
def __init__(self, *args: Any, env: Mapping[str, str] | None = None):
|
|
61
|
+
self.args = tuple(str(arg) for arg in args)
|
|
62
|
+
self.env = {**os.environ, **(env or {})}
|
|
63
|
+
|
|
64
|
+
def with_env(self, **kwargs: str) -> Self:
|
|
65
|
+
return type(self)(*self.args, env={**self.env, **kwargs})
|
|
66
|
+
|
|
67
|
+
def then(self, *args: Any) -> CommandChain:
|
|
68
|
+
match args:
|
|
69
|
+
case ():
|
|
70
|
+
raise ValueError(".then requires at least one argument")
|
|
71
|
+
case [obj] if isinstance(obj, (type(self), Pipeline, CommandChain)):
|
|
72
|
+
# Process("echo", 1).then(Process("echo", 2))
|
|
73
|
+
return CommandChain(self, obj)
|
|
74
|
+
case _:
|
|
75
|
+
# Process("echo", 1).then("echo", 2)
|
|
76
|
+
return CommandChain(self, Process(*args))
|
|
77
|
+
|
|
78
|
+
@overload
|
|
79
|
+
def exec(
|
|
80
|
+
self,
|
|
81
|
+
stdin: str | None = None,
|
|
82
|
+
*,
|
|
83
|
+
capture: bool = True,
|
|
84
|
+
mode: type[str] = str,
|
|
85
|
+
merge_stderr: bool = False,
|
|
86
|
+
) -> CompletedProcess[str]: ...
|
|
87
|
+
|
|
88
|
+
@overload
|
|
89
|
+
def exec(
|
|
90
|
+
self,
|
|
91
|
+
stdin: bytes | None = None,
|
|
92
|
+
*,
|
|
93
|
+
capture: bool = True,
|
|
94
|
+
mode: type[bytes],
|
|
95
|
+
merge_stderr: bool = False,
|
|
96
|
+
) -> CompletedProcess[bytes]: ...
|
|
97
|
+
|
|
98
|
+
def exec(
|
|
99
|
+
self,
|
|
100
|
+
stdin: S | None = None,
|
|
101
|
+
*,
|
|
102
|
+
capture: bool = True,
|
|
103
|
+
mode: type[S] = str, # type: ignore
|
|
104
|
+
merge_stderr: bool = False,
|
|
105
|
+
) -> CompletedProcess[S]:
|
|
106
|
+
return Pipeline(self)(stdin=stdin, capture=capture, mode=mode, merge_stderr=merge_stderr)
|
|
107
|
+
|
|
108
|
+
@overload
|
|
109
|
+
def __call__(
|
|
110
|
+
self,
|
|
111
|
+
stdin: str | None = None,
|
|
112
|
+
*,
|
|
113
|
+
capture: bool = True,
|
|
114
|
+
mode: type[str] = str,
|
|
115
|
+
merge_stderr: bool = False,
|
|
116
|
+
) -> CompletedProcess[str]: ...
|
|
117
|
+
|
|
118
|
+
@overload
|
|
119
|
+
def __call__(
|
|
120
|
+
self,
|
|
121
|
+
stdin: bytes | None = None,
|
|
122
|
+
*,
|
|
123
|
+
capture: bool = True,
|
|
124
|
+
mode: type[bytes],
|
|
125
|
+
merge_stderr: bool = False,
|
|
126
|
+
) -> CompletedProcess[bytes]: ...
|
|
127
|
+
|
|
128
|
+
def __call__(
|
|
129
|
+
self,
|
|
130
|
+
stdin: S | None = None,
|
|
131
|
+
*,
|
|
132
|
+
capture: bool = True,
|
|
133
|
+
mode: type[S] = str, # type: ignore
|
|
134
|
+
merge_stderr: bool = False,
|
|
135
|
+
) -> CompletedProcess[S]:
|
|
136
|
+
return self.exec(stdin=stdin, capture=capture, mode=mode, merge_stderr=merge_stderr)
|
|
137
|
+
|
|
138
|
+
def __or__(self, other: Self) -> Pipeline:
|
|
139
|
+
"""Create a pipeline between this process and other. Example: Process("ls", "-1") | Process("tail", 5)"""
|
|
140
|
+
if isinstance(other, type(self)):
|
|
141
|
+
return Pipeline(self, other)
|
|
142
|
+
|
|
143
|
+
return NotImplemented
|
|
144
|
+
|
|
145
|
+
def pipe_into(self, *args: str, env: Mapping[str, str] | None = None) -> Pipeline:
|
|
146
|
+
"""Create a pipeline between this process and a command. Example: Process("ls", "-1").pipe_into("tail", 5)"""
|
|
147
|
+
match args:
|
|
148
|
+
case ():
|
|
149
|
+
raise ValueError(".pipe_into requires at least one argument")
|
|
150
|
+
case [obj] if isinstance(obj, type(self)):
|
|
151
|
+
# Process("echo", 1).pipe_into(Process("echo", 2))
|
|
152
|
+
return Pipeline(self, obj)
|
|
153
|
+
case _:
|
|
154
|
+
# Process("echo", 1).pipe_into("echo", 2)
|
|
155
|
+
return Pipeline(self, Process(*args))
|
|
156
|
+
|
|
157
|
+
if args:
|
|
158
|
+
return Pipeline(self, Process(*args, env=env))
|
|
159
|
+
|
|
160
|
+
raise NotImplementedError
|
|
161
|
+
|
|
162
|
+
def __str__(self) -> str:
|
|
163
|
+
return shlex.join(self.args)
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
args = ", ".join(repr(arg) for arg in self.args)
|
|
167
|
+
return f"{self.__class__.__name__}({args})"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Pipeline:
|
|
171
|
+
def __init__(self, *processes: Process) -> None:
|
|
172
|
+
self.processes = processes
|
|
173
|
+
|
|
174
|
+
def pipe_into(self, *args: Any, env: Mapping[str, str] | None = None) -> Self:
|
|
175
|
+
match args:
|
|
176
|
+
case ():
|
|
177
|
+
raise ValueError(".pipe_into requires at least one argument")
|
|
178
|
+
case [obj] if isinstance(obj, Process):
|
|
179
|
+
return type(self)(*self.processes, obj)
|
|
180
|
+
case [obj] if isinstance(obj, type(self)):
|
|
181
|
+
return type(self)(*self.processes, *obj.processes)
|
|
182
|
+
case _:
|
|
183
|
+
# Process("echo", 1).pipe_into("echo", 2)
|
|
184
|
+
return type(self)(*self.processes, Process(*args))
|
|
185
|
+
|
|
186
|
+
def __or__(self, other: Process) -> Self:
|
|
187
|
+
return self.pipe_into(other)
|
|
188
|
+
|
|
189
|
+
def then(self, *args: Any) -> CommandChain:
|
|
190
|
+
match args:
|
|
191
|
+
case ():
|
|
192
|
+
raise ValueError(".then requires at least one argument")
|
|
193
|
+
case [obj] if isinstance(obj, (Process, type(self), CommandChain)):
|
|
194
|
+
# Process("echo", 1).then(Process("echo", 2))
|
|
195
|
+
return CommandChain(self, obj)
|
|
196
|
+
case _:
|
|
197
|
+
# Process("echo", 1).then("echo", 2)
|
|
198
|
+
return CommandChain(self, Process(*args))
|
|
199
|
+
|
|
200
|
+
@overload
|
|
201
|
+
def _setup_chain(
|
|
202
|
+
self, stdin: str | None, capture: bool, mode: type[str], merge_stderr: bool
|
|
203
|
+
) -> list[subprocess.Popen[str]]: ...
|
|
204
|
+
|
|
205
|
+
@overload
|
|
206
|
+
def _setup_chain(
|
|
207
|
+
self, stdin: bytes | None, capture: bool, mode: type[bytes], merge_stderr: bool
|
|
208
|
+
) -> list[subprocess.Popen[bytes]]: ...
|
|
209
|
+
|
|
210
|
+
def _setup_chain(
|
|
211
|
+
self, stdin: S | None, capture: bool, mode: type[S], merge_stderr: bool
|
|
212
|
+
) -> list[subprocess.Popen[S]]:
|
|
213
|
+
procs: list[subprocess.Popen[S]] = []
|
|
214
|
+
is_text = mode is str
|
|
215
|
+
|
|
216
|
+
for i, proc in enumerate(self.processes):
|
|
217
|
+
# determine where to get input
|
|
218
|
+
proc_stdin: int | IO[Any] | None
|
|
219
|
+
if i == 0 and stdin is not None:
|
|
220
|
+
proc_stdin = subprocess.PIPE
|
|
221
|
+
elif i > 0:
|
|
222
|
+
proc_stdin = procs[i - 1].stdout
|
|
223
|
+
else:
|
|
224
|
+
proc_stdin = None
|
|
225
|
+
|
|
226
|
+
# determine where to send output
|
|
227
|
+
if capture or i < len(self.processes) - 1:
|
|
228
|
+
proc_stdout = subprocess.PIPE
|
|
229
|
+
else:
|
|
230
|
+
proc_stdout = None
|
|
231
|
+
|
|
232
|
+
# determine where to send stderr
|
|
233
|
+
if i < len(self.processes) - 1:
|
|
234
|
+
# intermediate process
|
|
235
|
+
proc_stderr = subprocess.PIPE if capture else None
|
|
236
|
+
elif capture:
|
|
237
|
+
# final process
|
|
238
|
+
proc_stderr = subprocess.STDOUT if merge_stderr else subprocess.PIPE
|
|
239
|
+
else:
|
|
240
|
+
proc_stderr = None
|
|
241
|
+
|
|
242
|
+
p = subprocess.Popen(
|
|
243
|
+
proc.args, stdin=proc_stdin, stdout=proc_stdout, stderr=proc_stderr, text=is_text, env=proc.env
|
|
244
|
+
)
|
|
245
|
+
procs.append(p)
|
|
246
|
+
|
|
247
|
+
# close the stdout of the previous process in order to allow it to receive SIGPIPE
|
|
248
|
+
if i > 0 and (prev_stdout := procs[i - 1].stdout):
|
|
249
|
+
prev_stdout.close()
|
|
250
|
+
|
|
251
|
+
return procs
|
|
252
|
+
|
|
253
|
+
@overload
|
|
254
|
+
def exec(
|
|
255
|
+
self,
|
|
256
|
+
stdin: str | None = None,
|
|
257
|
+
*,
|
|
258
|
+
capture: bool = True,
|
|
259
|
+
mode: type[str] = str,
|
|
260
|
+
merge_stderr: bool = False,
|
|
261
|
+
) -> CompletedProcess[str]: ...
|
|
262
|
+
|
|
263
|
+
@overload
|
|
264
|
+
def exec(
|
|
265
|
+
self,
|
|
266
|
+
stdin: bytes | None = None,
|
|
267
|
+
*,
|
|
268
|
+
capture: bool = True,
|
|
269
|
+
mode: type[bytes],
|
|
270
|
+
merge_stderr: bool = False,
|
|
271
|
+
) -> CompletedProcess[bytes]: ...
|
|
272
|
+
|
|
273
|
+
def exec(
|
|
274
|
+
self,
|
|
275
|
+
stdin: S | None = None,
|
|
276
|
+
*,
|
|
277
|
+
capture: bool = True,
|
|
278
|
+
mode: type[S] = str, # type: ignore
|
|
279
|
+
merge_stderr: bool = False,
|
|
280
|
+
) -> CompletedProcess[S]:
|
|
281
|
+
procs = self._setup_chain(stdin=stdin, capture=capture, mode=mode, merge_stderr=merge_stderr)
|
|
282
|
+
|
|
283
|
+
# --- set up stdout/stderr handlers --- #
|
|
284
|
+
stdout_block: list[S] = []
|
|
285
|
+
stderr_blocks: list[list[S]] = [[] for _ in procs]
|
|
286
|
+
threads: list[Thread] = []
|
|
287
|
+
|
|
288
|
+
for idx, proc in enumerate(procs):
|
|
289
|
+
if proc.stderr:
|
|
290
|
+
t = Thread(target=consume_stream, args=(proc.stderr, stderr_blocks[idx]))
|
|
291
|
+
t.start()
|
|
292
|
+
threads.append(t)
|
|
293
|
+
|
|
294
|
+
if procs[-1].stdout:
|
|
295
|
+
t = Thread(target=consume_stream, args=(procs[-1].stdout, stdout_block))
|
|
296
|
+
t.start()
|
|
297
|
+
threads.append(t)
|
|
298
|
+
|
|
299
|
+
# --- handle stdin to first process --- #
|
|
300
|
+
if stdin is not None:
|
|
301
|
+
assert procs[0].stdin is not None
|
|
302
|
+
t = Thread(target=feed_stream, args=(procs[0].stdin, stdin))
|
|
303
|
+
t.start()
|
|
304
|
+
threads.append(t)
|
|
305
|
+
|
|
306
|
+
# --- wait for all processes to finish --- #
|
|
307
|
+
for t in threads:
|
|
308
|
+
t.join()
|
|
309
|
+
|
|
310
|
+
exit_codes: list[int] = []
|
|
311
|
+
for p in procs:
|
|
312
|
+
p.wait()
|
|
313
|
+
exit_codes.append(p.returncode)
|
|
314
|
+
|
|
315
|
+
# --- build completed process object --- #
|
|
316
|
+
stdout = mode().join(stdout_block) if capture else mode()
|
|
317
|
+
stderr = mode().join(mode().join(block) for block in stderr_blocks) if capture and not merge_stderr else mode()
|
|
318
|
+
args = tuple(proc.args for proc in self.processes)
|
|
319
|
+
|
|
320
|
+
return CompletedProcess(args=args, command=str(self), exit_codes=exit_codes, stdout=stdout, stderr=stderr)
|
|
321
|
+
|
|
322
|
+
@overload
|
|
323
|
+
def __call__(
|
|
324
|
+
self,
|
|
325
|
+
stdin: str | None = None,
|
|
326
|
+
*,
|
|
327
|
+
capture: bool = True,
|
|
328
|
+
mode: type[str] = str,
|
|
329
|
+
merge_stderr: bool = False,
|
|
330
|
+
) -> CompletedProcess[str]: ...
|
|
331
|
+
|
|
332
|
+
@overload
|
|
333
|
+
def __call__(
|
|
334
|
+
self,
|
|
335
|
+
stdin: bytes | None = None,
|
|
336
|
+
*,
|
|
337
|
+
capture: bool = True,
|
|
338
|
+
mode: type[bytes],
|
|
339
|
+
merge_stderr: bool = False,
|
|
340
|
+
) -> CompletedProcess[bytes]: ...
|
|
341
|
+
|
|
342
|
+
def __call__(
|
|
343
|
+
self,
|
|
344
|
+
stdin: S | None = None,
|
|
345
|
+
*,
|
|
346
|
+
capture: bool = True,
|
|
347
|
+
mode: type[S] = str, # type: ignore
|
|
348
|
+
merge_stderr: bool = False,
|
|
349
|
+
) -> CompletedProcess[S]:
|
|
350
|
+
return self.exec(stdin=stdin, capture=capture, mode=mode, merge_stderr=merge_stderr)
|
|
351
|
+
|
|
352
|
+
def __str__(self) -> str:
|
|
353
|
+
return " | ".join(str(process) for process in self.processes)
|
|
354
|
+
|
|
355
|
+
def __repr__(self) -> str:
|
|
356
|
+
args = ", ".join(repr(process) for process in self.processes)
|
|
357
|
+
return f"{self.__class__.__name__}({args})"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class CommandChain:
|
|
361
|
+
def __init__(self, *items: Process | Pipeline | Self) -> None:
|
|
362
|
+
self.items: list[Process | Pipeline] = []
|
|
363
|
+
|
|
364
|
+
for item in items:
|
|
365
|
+
if isinstance(item, type(self)):
|
|
366
|
+
self.items.extend(item.items)
|
|
367
|
+
else:
|
|
368
|
+
item = cast(Process | Pipeline, item)
|
|
369
|
+
self.items.append(item)
|
|
370
|
+
|
|
371
|
+
def then(self, *args: Any) -> Self:
|
|
372
|
+
match args:
|
|
373
|
+
case ():
|
|
374
|
+
raise ValueError(".then requires at least one argument")
|
|
375
|
+
case [obj] if isinstance(obj, (Process, Pipeline, type(self))):
|
|
376
|
+
# Process("echo", 1).then(Process("echo", 2))
|
|
377
|
+
return type(self)(self, obj)
|
|
378
|
+
case _:
|
|
379
|
+
# Process("echo", 1).then("echo", 2)
|
|
380
|
+
return type(self)(self, Process(*args))
|
|
381
|
+
|
|
382
|
+
@overload
|
|
383
|
+
def exec(
|
|
384
|
+
self, stdin: str | None = None, *, capture: bool = True, mode: type[str] = str, merge_stderr: bool = False
|
|
385
|
+
) -> CompletedProcess[str]: ...
|
|
386
|
+
|
|
387
|
+
@overload
|
|
388
|
+
def exec(
|
|
389
|
+
self, stdin: bytes | None = None, *, capture: bool = True, mode: type[bytes], merge_stderr: bool = False
|
|
390
|
+
) -> CompletedProcess[bytes]: ...
|
|
391
|
+
|
|
392
|
+
def exec(
|
|
393
|
+
self,
|
|
394
|
+
stdin: S | None = None,
|
|
395
|
+
*,
|
|
396
|
+
capture: bool = True,
|
|
397
|
+
mode: type[S] = str, # type: ignore
|
|
398
|
+
merge_stderr: bool = False,
|
|
399
|
+
) -> CompletedProcess[S]:
|
|
400
|
+
all_args: list[tuple[tuple[str, ...], ...]] = []
|
|
401
|
+
all_exit_codes: list[int] = []
|
|
402
|
+
|
|
403
|
+
stdout_parts: list[S] = []
|
|
404
|
+
stderr_parts: list[S] = []
|
|
405
|
+
|
|
406
|
+
for idx, item in enumerate(self.items):
|
|
407
|
+
proc_stdin = stdin if idx == 0 else None
|
|
408
|
+
res = item.exec(stdin=proc_stdin, capture=capture, mode=mode, merge_stderr=merge_stderr)
|
|
409
|
+
|
|
410
|
+
all_args.append(res.args)
|
|
411
|
+
all_exit_codes.extend(res.exit_codes)
|
|
412
|
+
stdout_parts.append(res.stdout)
|
|
413
|
+
stderr_parts.append(res.stderr)
|
|
414
|
+
|
|
415
|
+
if res.exit_code != 0:
|
|
416
|
+
break
|
|
417
|
+
|
|
418
|
+
return CompletedProcess(
|
|
419
|
+
args=tuple(itertools.chain.from_iterable(all_args)),
|
|
420
|
+
command=str(self),
|
|
421
|
+
exit_codes=all_exit_codes,
|
|
422
|
+
stdout=mode().join(stdout_parts),
|
|
423
|
+
stderr=mode().join(stderr_parts),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
@overload
|
|
427
|
+
def __call__(
|
|
428
|
+
self, stdin: str | None = None, *, capture: bool = True, mode: type[str] = str, merge_stderr: bool = False
|
|
429
|
+
) -> CompletedProcess[str]: ...
|
|
430
|
+
|
|
431
|
+
@overload
|
|
432
|
+
def __call__(
|
|
433
|
+
self, stdin: bytes | None = None, *, capture: bool = True, mode: type[bytes], merge_stderr: bool = False
|
|
434
|
+
) -> CompletedProcess[bytes]: ...
|
|
435
|
+
|
|
436
|
+
def __call__(
|
|
437
|
+
self,
|
|
438
|
+
stdin: S | None = None,
|
|
439
|
+
*,
|
|
440
|
+
capture: bool = True,
|
|
441
|
+
mode: type[S] = str, # type: ignore
|
|
442
|
+
merge_stderr: bool = False,
|
|
443
|
+
) -> CompletedProcess[S]:
|
|
444
|
+
return self.exec(stdin=stdin, capture=capture, mode=mode, merge_stderr=merge_stderr)
|
|
445
|
+
|
|
446
|
+
def __str__(self) -> str:
|
|
447
|
+
return " && ".join(str(item) for item in self.items)
|
|
448
|
+
|
|
449
|
+
def __repr__(self) -> str:
|
|
450
|
+
args = ", ".join(repr(item) for item in self.items)
|
|
451
|
+
return f"{self.__class__.__name__}({args})"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import IO, TypeVar
|
|
2
|
+
|
|
3
|
+
S = TypeVar("S", str, bytes)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def consume_stream(stream: IO[S], buffer: list[S]) -> None:
|
|
7
|
+
try:
|
|
8
|
+
while block := stream.read():
|
|
9
|
+
buffer.append(block)
|
|
10
|
+
finally:
|
|
11
|
+
stream.close()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def feed_stream(stream: IO[S], content: S) -> None:
|
|
15
|
+
try:
|
|
16
|
+
stream.write(content)
|
|
17
|
+
finally:
|
|
18
|
+
stream.close()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bombshell"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A library for easily running shell commands, whether standalone or piped."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typing-extensions>=4.0; python_version < '3.11'"
|
|
13
|
+
]
|
|
14
|
+
authors = [
|
|
15
|
+
{ name = "Lily Ellington", email = "lilell_@outlook.com" }
|
|
16
|
+
]
|
|
17
|
+
keywords = [
|
|
18
|
+
"shell",
|
|
19
|
+
"command",
|
|
20
|
+
"command-line",
|
|
21
|
+
"bash",
|
|
22
|
+
"zsh",
|
|
23
|
+
"subprocess",
|
|
24
|
+
"pipe"
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python",
|
|
32
|
+
"Programming Language :: Python :: 3",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Programming Language :: Python :: 3.14",
|
|
38
|
+
"Topic :: Software Development :: Libraries",
|
|
39
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
40
|
+
"Topic :: Utilities"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 120
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F"]
|
|
49
|
+
ignore = ["I001"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.isort]
|
|
52
|
+
known-local-folder = ["bombshell"]
|
|
53
|
+
force-single-line = false
|
|
54
|
+
lines-after-imports = 1
|
|
55
|
+
force-sort-within-sections = true
|
|
56
|
+
order-by-type = false
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
python_version = "3.10"
|
|
60
|
+
strict = true
|
|
61
|
+
|
|
62
|
+
[dependency-groups]
|
|
63
|
+
dev = [
|
|
64
|
+
"mypy>=1.19.1",
|
|
65
|
+
"pytest>=9.0.2",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[project.urls]
|
|
69
|
+
repository = "https://github.com/lilellia/bombshell"
|
|
70
|
+
"Bug Tracker" = "https://github.com/lilellia/bombshell/issues"
|
|
71
|
+
|
|
72
|
+
[tool.hatch.build]
|
|
73
|
+
include = [
|
|
74
|
+
"bombshell"
|
|
75
|
+
]
|