ok-subprocess-runner 0.3__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,94 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ok-subprocess-runner
|
|
3
|
+
Version: 0.3
|
|
4
|
+
Summary: Wrapper for subprocess.run() with defaults and logging
|
|
5
|
+
Author: Dan Egnor
|
|
6
|
+
Author-email: Dan Egnor <egnor@ofb.net>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Project-URL: Homepage, https://github.com/egnor/ok-py-subprocess-runner#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/egnor/ok-py-subprocess-runner.git
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# ok-subprocess-runner for Python
|
|
13
|
+
|
|
14
|
+
Trivial wrapper for [Python subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run) with defaults and logging.
|
|
15
|
+
|
|
16
|
+
You probably won't want to use this. Just call `subprocess.run` directly (it's perfectly lovely), write your own trivial helper, or use one of these libraries:
|
|
17
|
+
|
|
18
|
+
- [sh](https://github.com/amoffat/sh) - call any shell command as if it were a function
|
|
19
|
+
- [Plumbum](https://github.com/tomerfiliba/plumbum) - shell-like syntax for Python
|
|
20
|
+
- [zxpy](https://github.com/tusharsadhwani/zxpy) - `~` string operator to run shell commands
|
|
21
|
+
- [shellpy](https://github.com/lamerman/shellpy) - `\`` string operator to run shell commands
|
|
22
|
+
- [shell](https://github.com/toastdriven/shell) - another wrapper for subprocess
|
|
23
|
+
- [pipepy](https://github.com/kbairak/pipepy) - pipe operators and function wrappers for shell commands
|
|
24
|
+
- [python-shell](https://github.com/ATCode-space/python-shell) - another shell command runner
|
|
25
|
+
|
|
26
|
+
But, this is _my_ wrapper, and it does these things:
|
|
27
|
+
|
|
28
|
+
- Checks command return (`check=True`) by default
|
|
29
|
+
- Uses explicit argument vectors (`shell=False`) by default
|
|
30
|
+
- Includes easy-peasy methods to capture stdout as text or lines
|
|
31
|
+
- Logs all commands run, escaped for cut-and-paste rerunning
|
|
32
|
+
- Lets you set defaults for `cwd` and `env` (merged with `os.environ`)
|
|
33
|
+
- Converts [Path-like](https://docs.python.org/3/library/pathlib.html) arguments to strings
|
|
34
|
+
- Passes extra keyword arguments through to `subprocess.run`
|
|
35
|
+
|
|
36
|
+
Collectively, this is what I want for subprocesses -- tweaks to `subprocess.run` (or `subprocess.check_call`) to make it super easy to [never](https://databio.org/posts/shell_scripts.html) [write](https://news.ycombinator.com/item?id=26682981) [shell](https://samgrayson.me/essays/stop-writing-shell-scripts/) [scripts](https://pythonspeed.com/articles/shell-scripts/) [again](https://dev.to/taikedz/your-bash-scripts-are-rubbish-use-another-language-5dh7). Your mileage will almost certainly vary!
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
Add this package as a dependency:
|
|
41
|
+
|
|
42
|
+
- `pip install ok-subprocess-runner`
|
|
43
|
+
- OR just copy the `ok_subprocess_runner/` module (it has no dependencies)
|
|
44
|
+
|
|
45
|
+
Import the module, create an `ok_subprocess_runner.SubprocessRunner` object, and call it to run commands:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import logging
|
|
49
|
+
import ok_subprocess_runner
|
|
50
|
+
...
|
|
51
|
+
sub = ok_subprocess_runner.SubprocessRunner()
|
|
52
|
+
...
|
|
53
|
+
logging.basicConfig(level=logging.INFO) # to show the logging
|
|
54
|
+
...
|
|
55
|
+
sub("echo", "Hello World!")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Command arguments are individual function arguments; otherwise, usage is identical to [subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run) including keyword arguments and return value.
|
|
59
|
+
|
|
60
|
+
The logging output looks like this:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
$ python test.py
|
|
64
|
+
INFO:root:🐚 echo 'Hello World!'
|
|
65
|
+
Hello World!
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Note that arguments are escaped so you can cut-and-paste the command.
|
|
69
|
+
|
|
70
|
+
## Configuring defaults
|
|
71
|
+
|
|
72
|
+
`SubprocessRunner` objects have properties that set defaults:
|
|
73
|
+
|
|
74
|
+
- `.args_prefix` (list of string or Path-like) - prepended to all commands run
|
|
75
|
+
- `.check` (bool) - default for `check` arg (default true)
|
|
76
|
+
- `.cwd` (string or Path-like) - default for `cwd` arg (default empty)
|
|
77
|
+
- `.env` (string dict) - merged with `os.environ` as default `env` arg
|
|
78
|
+
- `.log_level` (int) - level for command logging (default `logging.INFO`)
|
|
79
|
+
|
|
80
|
+
## Capturing output
|
|
81
|
+
|
|
82
|
+
`SubprocessRunner` objects have some utility wrappers to capture output:
|
|
83
|
+
|
|
84
|
+
- `.stdout_text(args, ...)` - returns captured stdout as a text string
|
|
85
|
+
- `.stdout_lines(args, ...)` - returns captured stdout split into lines
|
|
86
|
+
|
|
87
|
+
## Pass-through
|
|
88
|
+
|
|
89
|
+
All calls pass keyword arguments through to `subprocess.run`.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
sub = ok_subprocess_runner.SubprocessRunner()
|
|
93
|
+
sub("echo", "Hello World!", check=False, cwd="/tmp", env={"FOO": "BAR"})
|
|
94
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# ok-subprocess-runner for Python
|
|
2
|
+
|
|
3
|
+
Trivial wrapper for [Python subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run) with defaults and logging.
|
|
4
|
+
|
|
5
|
+
You probably won't want to use this. Just call `subprocess.run` directly (it's perfectly lovely), write your own trivial helper, or use one of these libraries:
|
|
6
|
+
|
|
7
|
+
- [sh](https://github.com/amoffat/sh) - call any shell command as if it were a function
|
|
8
|
+
- [Plumbum](https://github.com/tomerfiliba/plumbum) - shell-like syntax for Python
|
|
9
|
+
- [zxpy](https://github.com/tusharsadhwani/zxpy) - `~` string operator to run shell commands
|
|
10
|
+
- [shellpy](https://github.com/lamerman/shellpy) - `\`` string operator to run shell commands
|
|
11
|
+
- [shell](https://github.com/toastdriven/shell) - another wrapper for subprocess
|
|
12
|
+
- [pipepy](https://github.com/kbairak/pipepy) - pipe operators and function wrappers for shell commands
|
|
13
|
+
- [python-shell](https://github.com/ATCode-space/python-shell) - another shell command runner
|
|
14
|
+
|
|
15
|
+
But, this is _my_ wrapper, and it does these things:
|
|
16
|
+
|
|
17
|
+
- Checks command return (`check=True`) by default
|
|
18
|
+
- Uses explicit argument vectors (`shell=False`) by default
|
|
19
|
+
- Includes easy-peasy methods to capture stdout as text or lines
|
|
20
|
+
- Logs all commands run, escaped for cut-and-paste rerunning
|
|
21
|
+
- Lets you set defaults for `cwd` and `env` (merged with `os.environ`)
|
|
22
|
+
- Converts [Path-like](https://docs.python.org/3/library/pathlib.html) arguments to strings
|
|
23
|
+
- Passes extra keyword arguments through to `subprocess.run`
|
|
24
|
+
|
|
25
|
+
Collectively, this is what I want for subprocesses -- tweaks to `subprocess.run` (or `subprocess.check_call`) to make it super easy to [never](https://databio.org/posts/shell_scripts.html) [write](https://news.ycombinator.com/item?id=26682981) [shell](https://samgrayson.me/essays/stop-writing-shell-scripts/) [scripts](https://pythonspeed.com/articles/shell-scripts/) [again](https://dev.to/taikedz/your-bash-scripts-are-rubbish-use-another-language-5dh7). Your mileage will almost certainly vary!
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Add this package as a dependency:
|
|
30
|
+
|
|
31
|
+
- `pip install ok-subprocess-runner`
|
|
32
|
+
- OR just copy the `ok_subprocess_runner/` module (it has no dependencies)
|
|
33
|
+
|
|
34
|
+
Import the module, create an `ok_subprocess_runner.SubprocessRunner` object, and call it to run commands:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import logging
|
|
38
|
+
import ok_subprocess_runner
|
|
39
|
+
...
|
|
40
|
+
sub = ok_subprocess_runner.SubprocessRunner()
|
|
41
|
+
...
|
|
42
|
+
logging.basicConfig(level=logging.INFO) # to show the logging
|
|
43
|
+
...
|
|
44
|
+
sub("echo", "Hello World!")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Command arguments are individual function arguments; otherwise, usage is identical to [subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run) including keyword arguments and return value.
|
|
48
|
+
|
|
49
|
+
The logging output looks like this:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
$ python test.py
|
|
53
|
+
INFO:root:🐚 echo 'Hello World!'
|
|
54
|
+
Hello World!
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Note that arguments are escaped so you can cut-and-paste the command.
|
|
58
|
+
|
|
59
|
+
## Configuring defaults
|
|
60
|
+
|
|
61
|
+
`SubprocessRunner` objects have properties that set defaults:
|
|
62
|
+
|
|
63
|
+
- `.args_prefix` (list of string or Path-like) - prepended to all commands run
|
|
64
|
+
- `.check` (bool) - default for `check` arg (default true)
|
|
65
|
+
- `.cwd` (string or Path-like) - default for `cwd` arg (default empty)
|
|
66
|
+
- `.env` (string dict) - merged with `os.environ` as default `env` arg
|
|
67
|
+
- `.log_level` (int) - level for command logging (default `logging.INFO`)
|
|
68
|
+
|
|
69
|
+
## Capturing output
|
|
70
|
+
|
|
71
|
+
`SubprocessRunner` objects have some utility wrappers to capture output:
|
|
72
|
+
|
|
73
|
+
- `.stdout_text(args, ...)` - returns captured stdout as a text string
|
|
74
|
+
- `.stdout_lines(args, ...)` - returns captured stdout split into lines
|
|
75
|
+
|
|
76
|
+
## Pass-through
|
|
77
|
+
|
|
78
|
+
All calls pass keyword arguments through to `subprocess.run`.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
sub = ok_subprocess_runner.SubprocessRunner()
|
|
82
|
+
sub("echo", "Hello World!", check=False, cwd="/tmp", env={"FOO": "BAR"})
|
|
83
|
+
```
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minor utilities and wrappers for the Python subprocess library
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass
|
|
13
|
+
class SubprocessRunner:
|
|
14
|
+
"""Wrapper for subprocess.run, plus some convenience methods.
|
|
15
|
+
|
|
16
|
+
sub = SubprocessRunner()
|
|
17
|
+
sub("echo", "Hello", "World")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
args_prefix: list = dataclasses.field(default_factory=list)
|
|
21
|
+
"""Arguments (including command) to prepend to args for run methods."""
|
|
22
|
+
|
|
23
|
+
check: bool = True
|
|
24
|
+
"""Default subprocess.run setting, True to raise on non-zero exit codes."""
|
|
25
|
+
|
|
26
|
+
cwd: str = ""
|
|
27
|
+
"""Default directory for subprocess."""
|
|
28
|
+
|
|
29
|
+
env: dict = dataclasses.field(default_factory=dict)
|
|
30
|
+
"""Default environment variables **added** to os.environ for subprocess."""
|
|
31
|
+
|
|
32
|
+
log_level: int = logging.INFO
|
|
33
|
+
"""Logging level to print commands, or logging.NOTSET to disable."""
|
|
34
|
+
|
|
35
|
+
def __call__(self, *args, **kw):
|
|
36
|
+
"""Wraps subprocess.run (with args directly listed), logging the
|
|
37
|
+
command (per log_level) and applying defaults from this object."""
|
|
38
|
+
|
|
39
|
+
cwd = _path_str(self.cwd) if self.cwd else None
|
|
40
|
+
|
|
41
|
+
env = None
|
|
42
|
+
if self.env:
|
|
43
|
+
env = {**os.environ, **self.env}
|
|
44
|
+
env = {k: _path_str(v) for k, v in env.items() if v is not None}
|
|
45
|
+
|
|
46
|
+
run_args = [_path_str(a) for a in [*self.args_prefix, *args]]
|
|
47
|
+
run_kw = {"check": self.check, "cwd": cwd, "env": env, **kw}
|
|
48
|
+
|
|
49
|
+
if self.log_level and self.log_level > logging.NOTSET:
|
|
50
|
+
_log_command(self.log_level, run_args, run_kw)
|
|
51
|
+
|
|
52
|
+
return subprocess.run(run_args, **run_kw)
|
|
53
|
+
|
|
54
|
+
def stdout_text(self, *args, **kw):
|
|
55
|
+
"""Like run, but captures and directly returns stdout text."""
|
|
56
|
+
|
|
57
|
+
kw = {"stdout": subprocess.PIPE, "text": True, **kw}
|
|
58
|
+
return self(*args, **kw).stdout
|
|
59
|
+
|
|
60
|
+
def stdout_lines(self, *args, **kw):
|
|
61
|
+
"""Like stdout_text, but splits the text into lines."""
|
|
62
|
+
|
|
63
|
+
return self.stdout_text(*args, **kw).splitlines()
|
|
64
|
+
|
|
65
|
+
def copy(self):
|
|
66
|
+
"""Returns a copy of this object with the same defaults."""
|
|
67
|
+
|
|
68
|
+
return dataclasses.replace(
|
|
69
|
+
self, args_prefix=self.args_prefix.copy(), env=self.env.copy()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _path_str(path_or_str: os.PathLike | str) -> str:
|
|
74
|
+
if isinstance(path_or_str, str):
|
|
75
|
+
return path_or_str
|
|
76
|
+
if isinstance(path_or_str, os.PathLike):
|
|
77
|
+
return str(path_or_str)
|
|
78
|
+
raise TypeError(f"Expected str or os.PathLike, got {path_or_str!r}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _log_command(log_level, args, kw):
|
|
82
|
+
cd_parts = []
|
|
83
|
+
if new_cwd := kw.get("cwd"):
|
|
84
|
+
old_path = os.path.realpath(os.getcwd())
|
|
85
|
+
new_path = os.path.realpath(new_cwd)
|
|
86
|
+
if new_path != old_path:
|
|
87
|
+
try:
|
|
88
|
+
rel_path = os.path.relpath(new_path, old_path)
|
|
89
|
+
if len(rel_path) < len(new_path):
|
|
90
|
+
new_path = rel_path
|
|
91
|
+
except ValueError:
|
|
92
|
+
pass # different drives on Windows
|
|
93
|
+
cd_parts = ["cd", shlex.quote(str(new_path)), "&&"]
|
|
94
|
+
|
|
95
|
+
env_parts = []
|
|
96
|
+
if new_env := kw.get("env"):
|
|
97
|
+
old_env = os.environ
|
|
98
|
+
repeats, updates = [], []
|
|
99
|
+
for k, new_v in new_env.items():
|
|
100
|
+
old_v = old_env.get(k)
|
|
101
|
+
v_quoted = shlex.quote(new_v) if new_v else ""
|
|
102
|
+
v_parts = new_v.split(old_v) if old_v else [new_v]
|
|
103
|
+
if len(v_parts) > 1:
|
|
104
|
+
v_parts_quoted = [shlex.quote(p) if p else "" for p in v_parts]
|
|
105
|
+
v_spliced = f"${{{k}}}".join(v_parts_quoted)
|
|
106
|
+
if len(v_spliced) < len(v_quoted):
|
|
107
|
+
v_quoted = v_spliced
|
|
108
|
+
|
|
109
|
+
(repeats if old_v == new_v else updates).append(f"{k}={v_quoted}")
|
|
110
|
+
|
|
111
|
+
def parts_len(parts):
|
|
112
|
+
return sum(len(p) for p in parts)
|
|
113
|
+
|
|
114
|
+
env_parts = updates
|
|
115
|
+
if del_keys := old_env.keys() - new_env.keys():
|
|
116
|
+
env_parts = ["env", *(f"-u{k}" for k in del_keys), *updates, "--"]
|
|
117
|
+
env_reset_parts = ["env -i", *repeats, *updates, "--"]
|
|
118
|
+
if parts_len(env_reset_parts) < parts_len(env_parts):
|
|
119
|
+
env_parts = env_reset_parts
|
|
120
|
+
|
|
121
|
+
command_text = " ".join(shlex.quote(arg) for arg in args)
|
|
122
|
+
log_parts = [*cd_parts, *env_parts, command_text]
|
|
123
|
+
logging.log(log_level, "🐚 %s", " ".join(log_parts))
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
authors = [{ name = "Dan Egnor", email = "egnor@ofb.net" }]
|
|
3
|
+
description = "Wrapper for subprocess.run() with defaults and logging"
|
|
4
|
+
name = "ok-subprocess-runner"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
urls.Homepage = "https://github.com/egnor/ok-py-subprocess-runner#readme"
|
|
8
|
+
urls.Repository = "https://github.com/egnor/ok-py-subprocess-runner.git"
|
|
9
|
+
version = "0.3"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["uv_build>=0.9.13,<0.12.0"]
|
|
13
|
+
build-backend = "uv_build"
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = ["mypy", "pytest", "ruff"]
|
|
17
|
+
|
|
18
|
+
[tool.mypy]
|
|
19
|
+
exclude_gitignore = true
|
|
20
|
+
files = "."
|
|
21
|
+
|
|
22
|
+
[tool.ruff]
|
|
23
|
+
line-length = 80
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-root = ""
|