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.
@@ -0,0 +1,11 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .python-version
@@ -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.
@@ -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,3 @@
1
+ from .core import CommandChain, CompletedProcess, Pipeline, PipelineError, Process
2
+
3
+ __all__ = ["Process", "Pipeline", "CompletedProcess", "PipelineError", "CommandChain"]
@@ -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
+ ]