ok-subprocess-runner 0.3__py3-none-any.whl

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,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,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,5 @@
1
+ ok_subprocess_runner/__init__.py,sha256=JDUc31xQze-_ue4bW2pwBVoMgCOpFp0hZu4BSr4uQlY,4257
2
+ ok_subprocess_runner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ ok_subprocess_runner-0.3.dist-info/WHEEL,sha256=iCTolw4aw2dP3yfM-EQCGTDsFCXL_ymmbYnBRVH7plA,81
4
+ ok_subprocess_runner-0.3.dist-info/METADATA,sha256=-1ynDiMGadY5q8gpQkl1Wh8VWxWI8ezWkJy-7zSsHWk,4160
5
+ ok_subprocess_runner-0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any