fancy-subprocess 2.4__tar.gz → 3.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.
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/Justfile +2 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/PKG-INFO +5 -5
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/README.md +1 -1
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_run_core.py +105 -83
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_run_param.py +17 -1
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_utils.py +0 -8
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/pyproject.toml +5 -4
- fancy_subprocess-3.0/tests/__init__.py +0 -0
- fancy_subprocess-3.0/tests/test_runerror_bugs.py +38 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/uv.lock +338 -261
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/.editorconfig +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/.gitignore +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/LICENSE +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/__init__.py +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_compat.py +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_exit_code.py +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_print.py +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_reconfigure.py +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/_run_wrappers.py +0 -0
- {fancy_subprocess-2.4 → fancy_subprocess-3.0}/fancy_subprocess/py.typed +0 -0
|
@@ -3,12 +3,14 @@ default:
|
|
|
3
3
|
|
|
4
4
|
[private]
|
|
5
5
|
verify_with_impl python_minor_version $UV_PROJECT_ENVIRONMENT:
|
|
6
|
+
@echo UV_PROJECT_ENVIRONMENT=${UV_PROJECT_ENVIRONMENT}
|
|
6
7
|
uv sync --python 3.{{python_minor_version}}
|
|
7
8
|
|
|
8
9
|
uv run -m mypy .
|
|
9
10
|
uv run ty check .
|
|
10
11
|
uv run ruff check --target-version py3{{python_minor_version}}
|
|
11
12
|
uv run ruff format --check --target-version py3{{python_minor_version}}
|
|
13
|
+
uv run -m pytest
|
|
12
14
|
|
|
13
15
|
verify_with python_minor_version="14": (verify_with_impl python_minor_version ".just_venv_3_"+python_minor_version)
|
|
14
16
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fancy-subprocess
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0
|
|
4
4
|
Summary: subprocess.run() with formatted output, detailed error messages and retry capabilities
|
|
5
5
|
Project-URL: Homepage, https://github.com/petamas/python-fancy-subprocess
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/petamas/python-fancy-subprocess/issues
|
|
@@ -20,10 +20,10 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.14
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: ntstatus<3,>=2.0
|
|
23
|
-
Requires-Dist: oslex<2
|
|
23
|
+
Requires-Dist: oslex<3,>=2.0
|
|
24
24
|
Requires-Dist: pathext<2,>=1.5
|
|
25
|
-
Requires-Dist: typeguard<5,>=4.
|
|
26
|
-
Requires-Dist: typing-extensions<5,>=4.
|
|
25
|
+
Requires-Dist: typeguard<5,>=4.5.2
|
|
26
|
+
Requires-Dist: typing-extensions<5,>=4.15
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# fancy-subprocess
|
|
@@ -56,7 +56,7 @@ Arguments (all of them except `cmd` are optional):
|
|
|
56
56
|
- The type of this argument is also aliased as `fancy_subprocess.Success`.
|
|
57
57
|
- `flush_before_subprocess: bool` - If `True`, flushes both the standard output and error streams before running the command. If unspecified or set to `None`, defaults to `True`.
|
|
58
58
|
- `trim_output_lines: bool` - If `True`, remove trailing whitespace from the lines of the output of the command before calling `print_output` and adding them to the `output` field of `RunResult`. If unspecified or set to `None`, defaults to `True`.
|
|
59
|
-
- `max_output_size: int` - Maximum number of characters to be recorded in the `output` field of `RunResult`. If the command produces more than `max_output_size` characters, only the last `max_output_size` will be recorded. If unspecified or set to `None`, defaults to 10,000,000.
|
|
59
|
+
- `max_output_size: int | NoLimit` - Maximum number of characters to be recorded in the `output` field of `RunResult`. If the command produces more than `max_output_size` characters, only the last `max_output_size` will be recorded. If set to `fancy_subprocess.NO_LIMIT`, then the full output will be recorded. If unspecified or set to `None`, defaults to 10,000,000.
|
|
60
60
|
- `retry: int` - Number of times to retry running the command on failure. Note that the total number of attempts is one greater than what's specified. (I.e. `retry=2` attempts to run the command 3 times.) If unspecified or set to `None`, defaults to 0.
|
|
61
61
|
- `retry_initial_sleep_seconds: float` - Number of seconds to wait before retrying for the first time. If unspecified or set to `None`, defaults to 10.
|
|
62
62
|
- `retry_backoff: float` - Factor used to increase wait times before subsequent retries. If unspecified or set to `None`, defaults to 2.
|
|
@@ -28,7 +28,7 @@ Arguments (all of them except `cmd` are optional):
|
|
|
28
28
|
- The type of this argument is also aliased as `fancy_subprocess.Success`.
|
|
29
29
|
- `flush_before_subprocess: bool` - If `True`, flushes both the standard output and error streams before running the command. If unspecified or set to `None`, defaults to `True`.
|
|
30
30
|
- `trim_output_lines: bool` - If `True`, remove trailing whitespace from the lines of the output of the command before calling `print_output` and adding them to the `output` field of `RunResult`. If unspecified or set to `None`, defaults to `True`.
|
|
31
|
-
- `max_output_size: int` - Maximum number of characters to be recorded in the `output` field of `RunResult`. If the command produces more than `max_output_size` characters, only the last `max_output_size` will be recorded. If unspecified or set to `None`, defaults to 10,000,000.
|
|
31
|
+
- `max_output_size: int | NoLimit` - Maximum number of characters to be recorded in the `output` field of `RunResult`. If the command produces more than `max_output_size` characters, only the last `max_output_size` will be recorded. If set to `fancy_subprocess.NO_LIMIT`, then the full output will be recorded. If unspecified or set to `None`, defaults to 10,000,000.
|
|
32
32
|
- `retry: int` - Number of times to retry running the command on failure. Note that the total number of attempts is one greater than what's specified. (I.e. `retry=2` attempts to run the command 3 times.) If unspecified or set to `None`, defaults to 0.
|
|
33
33
|
- `retry_initial_sleep_seconds: float` - Number of seconds to wait before retrying for the first time. If unspecified or set to `None`, defaults to 10.
|
|
34
34
|
- `retry_backoff: float` - Factor used to increase wait times before subsequent retries. If unspecified or set to `None`, defaults to 2.
|
|
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Optional
|
|
15
15
|
|
|
16
|
+
import oslex
|
|
16
17
|
from typing_extensions import Unpack
|
|
17
18
|
|
|
18
19
|
from fancy_subprocess._exit_code import stringify_exit_code
|
|
@@ -20,10 +21,12 @@ from fancy_subprocess._print import PrintFunction
|
|
|
20
21
|
from fancy_subprocess._print import default_print
|
|
21
22
|
from fancy_subprocess._print import silenced_print
|
|
22
23
|
from fancy_subprocess._run_param import AnyExitCode
|
|
24
|
+
from fancy_subprocess._run_param import EnvOverrides
|
|
25
|
+
from fancy_subprocess._run_param import MaxOutputSize
|
|
26
|
+
from fancy_subprocess._run_param import NoLimit
|
|
23
27
|
from fancy_subprocess._run_param import RunParams
|
|
24
28
|
from fancy_subprocess._run_param import Success
|
|
25
29
|
from fancy_subprocess._run_param import check_run_params
|
|
26
|
-
from fancy_subprocess._utils import oslex_join
|
|
27
30
|
from fancy_subprocess._utils import value_or
|
|
28
31
|
|
|
29
32
|
|
|
@@ -41,7 +44,6 @@ class RunResult:
|
|
|
41
44
|
output: str = ''
|
|
42
45
|
|
|
43
46
|
|
|
44
|
-
@dataclass(kw_only=True, frozen=True)
|
|
45
47
|
class RunError(Exception):
|
|
46
48
|
"""
|
|
47
49
|
`fancy_subprocess.run()` and similar functions raise `RunError` on error. There are two kinds of errors that result in a `RunError`:
|
|
@@ -62,6 +64,11 @@ class RunError(Exception):
|
|
|
62
64
|
- `oserror: OSError` - The `OSError` raised by `subprocess.Popen()`. Raises `ValueError` if `completed` is `True`.
|
|
63
65
|
"""
|
|
64
66
|
|
|
67
|
+
# See test_runerror_picklable() in test_runerror_bugs.py for why these arguments are positional only
|
|
68
|
+
def __init__(self, cmd: Sequence[str | Path], result: RunResult | OSError = RunResult(), /) -> None:
|
|
69
|
+
self.cmd = cmd
|
|
70
|
+
self.result = result
|
|
71
|
+
|
|
65
72
|
cmd: Sequence[str | Path]
|
|
66
73
|
result: RunResult | OSError = RunResult()
|
|
67
74
|
|
|
@@ -74,21 +81,21 @@ class RunError(Exception):
|
|
|
74
81
|
if isinstance(self.result, RunResult):
|
|
75
82
|
return self.result.exit_code
|
|
76
83
|
else:
|
|
77
|
-
raise ValueError('
|
|
84
|
+
raise ValueError('"exit_code" can only be queried if "completed" is True')
|
|
78
85
|
|
|
79
86
|
@property
|
|
80
87
|
def output(self) -> str:
|
|
81
88
|
if isinstance(self.result, RunResult):
|
|
82
89
|
return self.result.output
|
|
83
90
|
else:
|
|
84
|
-
raise ValueError('
|
|
91
|
+
raise ValueError('"output" can only be queried if "completed" is True')
|
|
85
92
|
|
|
86
93
|
@property
|
|
87
94
|
def oserror(self) -> OSError:
|
|
88
95
|
if isinstance(self.result, OSError):
|
|
89
96
|
return self.result
|
|
90
97
|
else:
|
|
91
|
-
raise ValueError('
|
|
98
|
+
raise ValueError('"oserror" can only be queried if "completed" is False')
|
|
92
99
|
|
|
93
100
|
@property
|
|
94
101
|
def message(self) -> str:
|
|
@@ -98,14 +105,93 @@ class RunError(Exception):
|
|
|
98
105
|
exit_code_comment = f' ({exit_code_str})'
|
|
99
106
|
else:
|
|
100
107
|
exit_code_comment = ''
|
|
101
|
-
return f'Command failed with exit code {self.exit_code}{exit_code_comment}: {
|
|
108
|
+
return f'Command failed with exit code {self.exit_code}{exit_code_comment}: {oslex.join(self.cmd)}'
|
|
102
109
|
else:
|
|
103
|
-
return f'Exception {type(self.result).__name__} with message "{str(self.result)}" was raised while trying to run command: {
|
|
110
|
+
return f'Exception {type(self.result).__name__} with message "{str(self.result)}" was raised while trying to run command: {oslex.join(self.cmd)}'
|
|
104
111
|
|
|
105
112
|
def __str__(self) -> str:
|
|
106
113
|
return self.message
|
|
107
114
|
|
|
108
115
|
|
|
116
|
+
class _ResolvedRunParams:
|
|
117
|
+
def __init__(self, cmd: Sequence[str | Path], **kwargs: Unpack[RunParams]) -> None:
|
|
118
|
+
self.message_quiet: bool = value_or(kwargs.get('message_quiet'), False)
|
|
119
|
+
self.output_quiet: bool = value_or(kwargs.get('output_quiet'), False)
|
|
120
|
+
self.default_description: str
|
|
121
|
+
if self.output_quiet:
|
|
122
|
+
self.default_description = f'Running command (output silenced): {oslex.join(cmd)}'
|
|
123
|
+
else:
|
|
124
|
+
self.default_description = f'Running command: {oslex.join(cmd)}'
|
|
125
|
+
self.description: str = value_or(kwargs.get('description'), self.default_description)
|
|
126
|
+
self.success: Success = value_or(kwargs.get('success'), [0])
|
|
127
|
+
self.flush_before_subprocess: bool = value_or(kwargs.get('flush_before_subprocess'), True)
|
|
128
|
+
self.trim_output_lines: bool = value_or(kwargs.get('trim_output_lines'), True)
|
|
129
|
+
self.max_output_size: MaxOutputSize = value_or(kwargs.get('max_output_size'), 10 * 1000 * 1000)
|
|
130
|
+
self.retry: int = value_or(kwargs.get('retry'), 0)
|
|
131
|
+
self.retry_initial_sleep_seconds: float = value_or(kwargs.get('retry_initial_sleep_seconds'), 10)
|
|
132
|
+
self.retry_backoff: float = value_or(kwargs.get('retry_backoff'), 2)
|
|
133
|
+
self.env_overrides: EnvOverrides = value_or(kwargs.get('env_overrides'), dict())
|
|
134
|
+
self.cwd: Optional[str | Path] = kwargs.get('cwd')
|
|
135
|
+
self.encoding: Optional[str] = kwargs.get('encoding')
|
|
136
|
+
self.errors: str = value_or(kwargs.get('errors'), 'replace')
|
|
137
|
+
self.replace_fffd_with_question_mark: bool = value_or(kwargs.get('replace_fffd_with_question_mark'), True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _attempt_run(
|
|
141
|
+
cmd: Sequence[str | Path],
|
|
142
|
+
*,
|
|
143
|
+
print_message: PrintFunction,
|
|
144
|
+
print_output: PrintFunction,
|
|
145
|
+
env: dict[str, str],
|
|
146
|
+
params: _ResolvedRunParams,
|
|
147
|
+
) -> RunResult:
|
|
148
|
+
print_message(params.description)
|
|
149
|
+
|
|
150
|
+
if params.flush_before_subprocess:
|
|
151
|
+
sys.stdout.flush()
|
|
152
|
+
sys.stderr.flush()
|
|
153
|
+
|
|
154
|
+
output = ''
|
|
155
|
+
try:
|
|
156
|
+
with subprocess.Popen(
|
|
157
|
+
cmd,
|
|
158
|
+
stdin=subprocess.DEVNULL,
|
|
159
|
+
stdout=subprocess.PIPE,
|
|
160
|
+
stderr=subprocess.STDOUT,
|
|
161
|
+
text=True,
|
|
162
|
+
bufsize=1,
|
|
163
|
+
cwd=params.cwd,
|
|
164
|
+
env=env,
|
|
165
|
+
encoding=params.encoding,
|
|
166
|
+
errors=params.errors,
|
|
167
|
+
) as proc:
|
|
168
|
+
assert proc.stdout is not None # passing stdout=subprocess.PIPE guarantees this
|
|
169
|
+
|
|
170
|
+
for line in iter(proc.stdout.readline, ''):
|
|
171
|
+
line = line.removesuffix('\n')
|
|
172
|
+
if params.trim_output_lines:
|
|
173
|
+
line = line.rstrip()
|
|
174
|
+
if params.replace_fffd_with_question_mark:
|
|
175
|
+
line = line.replace('\ufffd', '?')
|
|
176
|
+
|
|
177
|
+
print_output(line)
|
|
178
|
+
|
|
179
|
+
output += line + '\n'
|
|
180
|
+
if not isinstance(params.max_output_size, NoLimit):
|
|
181
|
+
if len(output) > params.max_output_size + 1:
|
|
182
|
+
output = output[-params.max_output_size - 1 :] # drop the beginning of the string
|
|
183
|
+
|
|
184
|
+
proc.wait()
|
|
185
|
+
result = RunResult(exit_code=proc.returncode, output=output.removesuffix('\n'))
|
|
186
|
+
except OSError as e:
|
|
187
|
+
raise RunError(cmd, e) from e
|
|
188
|
+
|
|
189
|
+
if isinstance(params.success, AnyExitCode) or result.exit_code in params.success:
|
|
190
|
+
return result
|
|
191
|
+
else:
|
|
192
|
+
raise RunError(cmd, result)
|
|
193
|
+
|
|
194
|
+
|
|
109
195
|
def run(
|
|
110
196
|
cmd: Sequence[str | Path],
|
|
111
197
|
*,
|
|
@@ -135,7 +221,7 @@ def run(
|
|
|
135
221
|
- `success: Sequence[int] | AnyExitCode` - List of exit codes that should be considered successful. If set to `fancy_subprocess.ANY_EXIT_CODE`, then all exit codes are considered successful. If unspecified or set to `None`, defaults to `[0]`. Note that 0 is not automatically included in the list of successful exit codes, so if a list without 0 is specified, then the function will consider 0 a failure.
|
|
136
222
|
- `flush_before_subprocess: bool` - If `True`, flushes both the standard output and error streams before running the command. If unspecified or set to `None`, defaults to `True`.
|
|
137
223
|
- `trim_output_lines: bool` - If `True`, remove trailing whitespace from the lines of the output of the command before calling `print_output` and adding them to the `output` field of `RunResult`. If unspecified or set to `None`, defaults to `True`.
|
|
138
|
-
- `max_output_size: int` - Maximum number of characters to be recorded in the `output` field of `RunResult`. If the command produces more than `max_output_size` characters, only the last `max_output_size` will be recorded. If unspecified or set to `None`, defaults to 10,000,000.
|
|
224
|
+
- `max_output_size: int | NoLimit` - Maximum number of characters to be recorded in the `output` field of `RunResult`. If the command produces more than `max_output_size` characters, only the last `max_output_size` will be recorded. If set to `fancy_subprocess.NO_LIMIT`, then the full output will be recorded. If unspecified or set to `None`, defaults to 10,000,000.
|
|
139
225
|
- `retry: int` - Number of times to retry running the command on failure. Note that the total number of attempts is one greater than what's specified. (I.e. `retry=2` attempts to run the command 3 times.) If unspecified or set to `None`, defaults to 0.
|
|
140
226
|
- `retry_initial_sleep_seconds: float` - Number of seconds to wait before retrying for the first time. If unspecified or set to `None`, defaults to 10.
|
|
141
227
|
- `retry_backoff: float` - Factor used to increase wait times before subsequent retries. If unspecified or set to `None`, defaults to 2.
|
|
@@ -148,92 +234,28 @@ def run(
|
|
|
148
234
|
|
|
149
235
|
check_run_params(**kwargs)
|
|
150
236
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if
|
|
154
|
-
default_description = f'Running command (output silenced): {oslex_join(cmd)}'
|
|
155
|
-
else:
|
|
156
|
-
default_description = f'Running command: {oslex_join(cmd)}'
|
|
157
|
-
description = value_or(kwargs.get('description'), default_description)
|
|
158
|
-
success: Success = value_or(kwargs.get('success'), [0])
|
|
159
|
-
flush_before_subprocess = value_or(kwargs.get('flush_before_subprocess'), True)
|
|
160
|
-
trim_output_lines = value_or(kwargs.get('trim_output_lines'), True)
|
|
161
|
-
max_output_size = value_or(kwargs.get('max_output_size'), 10 * 1000 * 1000)
|
|
162
|
-
retry = value_or(kwargs.get('retry'), 0)
|
|
163
|
-
retry_initial_sleep_seconds = value_or(kwargs.get('retry_initial_sleep_seconds'), 10)
|
|
164
|
-
retry_backoff = value_or(kwargs.get('retry_backoff'), 2)
|
|
165
|
-
env_overrides = value_or(kwargs.get('env_overrides'), dict())
|
|
166
|
-
cwd = kwargs.get('cwd')
|
|
167
|
-
encoding = kwargs.get('encoding')
|
|
168
|
-
errors = value_or(kwargs.get('errors'), 'replace')
|
|
169
|
-
replace_fffd_with_question_mark = value_or(kwargs.get('replace_fffd_with_question_mark'), True)
|
|
170
|
-
|
|
171
|
-
if message_quiet:
|
|
237
|
+
params = _ResolvedRunParams(cmd, **kwargs)
|
|
238
|
+
|
|
239
|
+
if params.message_quiet:
|
|
172
240
|
print_message = silenced_print
|
|
173
241
|
else:
|
|
174
242
|
print_message = value_or(print_message, default_print)
|
|
175
243
|
|
|
176
|
-
if output_quiet:
|
|
244
|
+
if params.output_quiet:
|
|
177
245
|
print_output = silenced_print
|
|
178
246
|
else:
|
|
179
247
|
print_output = value_or(print_output, default_print)
|
|
180
248
|
|
|
181
249
|
env = dict(os.environ)
|
|
182
250
|
if sys.platform == 'win32':
|
|
183
|
-
env.update((key.upper(), value) for key, value in env_overrides.items())
|
|
251
|
+
env.update((key.upper(), value) for key, value in params.env_overrides.items())
|
|
184
252
|
else:
|
|
185
|
-
env.update(env_overrides)
|
|
186
|
-
|
|
187
|
-
def attempt_run() -> RunResult:
|
|
188
|
-
print_message(description)
|
|
189
|
-
|
|
190
|
-
if flush_before_subprocess:
|
|
191
|
-
sys.stdout.flush()
|
|
192
|
-
sys.stderr.flush()
|
|
193
|
-
|
|
194
|
-
output = ''
|
|
195
|
-
try:
|
|
196
|
-
with subprocess.Popen(
|
|
197
|
-
cmd,
|
|
198
|
-
stdin=subprocess.DEVNULL,
|
|
199
|
-
stdout=subprocess.PIPE,
|
|
200
|
-
stderr=subprocess.STDOUT,
|
|
201
|
-
text=True,
|
|
202
|
-
bufsize=1,
|
|
203
|
-
cwd=cwd,
|
|
204
|
-
env=env,
|
|
205
|
-
encoding=encoding,
|
|
206
|
-
errors=errors,
|
|
207
|
-
) as proc:
|
|
208
|
-
assert proc.stdout is not None # passing stdout=subprocess.PIPE guarantees this
|
|
209
|
-
|
|
210
|
-
for line in iter(proc.stdout.readline, ''):
|
|
211
|
-
line = line.removesuffix('\n')
|
|
212
|
-
if trim_output_lines:
|
|
213
|
-
line = line.rstrip()
|
|
214
|
-
if replace_fffd_with_question_mark:
|
|
215
|
-
line = line.replace('\ufffd', '?')
|
|
216
|
-
|
|
217
|
-
print_output(line)
|
|
218
|
-
|
|
219
|
-
output += line + '\n'
|
|
220
|
-
if len(output) > max_output_size + 1:
|
|
221
|
-
output = output[-max_output_size - 1 :] # drop the beginning of the string
|
|
222
|
-
|
|
223
|
-
proc.wait()
|
|
224
|
-
result = RunResult(exit_code=proc.returncode, output=output.removesuffix('\n'))
|
|
225
|
-
except OSError as e:
|
|
226
|
-
raise RunError(cmd=cmd, result=e) from e
|
|
227
|
-
|
|
228
|
-
if isinstance(success, AnyExitCode) or result.exit_code in success:
|
|
229
|
-
return result
|
|
230
|
-
else:
|
|
231
|
-
raise RunError(cmd=cmd, result=result)
|
|
253
|
+
env.update(params.env_overrides)
|
|
232
254
|
|
|
233
|
-
sleep_seconds = retry_initial_sleep_seconds
|
|
234
|
-
for attempts_left in range(retry, 0, -1):
|
|
255
|
+
sleep_seconds = params.retry_initial_sleep_seconds
|
|
256
|
+
for attempts_left in range(params.retry, 0, -1):
|
|
235
257
|
try:
|
|
236
|
-
return
|
|
258
|
+
return _attempt_run(cmd, print_message=print_message, print_output=print_output, env=env, params=params)
|
|
237
259
|
except RunError as e:
|
|
238
260
|
print_message(str(e))
|
|
239
261
|
if attempts_left != 1:
|
|
@@ -242,6 +264,6 @@ def run(
|
|
|
242
264
|
plural = ''
|
|
243
265
|
print_message(f'Retrying in {sleep_seconds} seconds ({attempts_left} attempt{plural} left)...')
|
|
244
266
|
time.sleep(sleep_seconds)
|
|
245
|
-
sleep_seconds *= retry_backoff
|
|
267
|
+
sleep_seconds *= params.retry_backoff
|
|
246
268
|
|
|
247
|
-
return
|
|
269
|
+
return _attempt_run(cmd, print_message=print_message, print_output=print_output, env=env, params=params)
|
|
@@ -5,6 +5,9 @@ __all__ = [
|
|
|
5
5
|
'check_run_params',
|
|
6
6
|
'EnvOverrides',
|
|
7
7
|
'force_run_params',
|
|
8
|
+
'MaxOutputSize',
|
|
9
|
+
'NO_LIMIT',
|
|
10
|
+
'NoLimit',
|
|
8
11
|
'RunParams',
|
|
9
12
|
'Success',
|
|
10
13
|
]
|
|
@@ -31,6 +34,19 @@ ANY_EXIT_CODE = AnyExitCode()
|
|
|
31
34
|
|
|
32
35
|
Success = Sequence[int] | AnyExitCode
|
|
33
36
|
|
|
37
|
+
|
|
38
|
+
class NoLimit:
|
|
39
|
+
"""
|
|
40
|
+
Use an instance of this class (eg. fancy_subprocess.NO_LIMIT) as the 'max_output_size' argument to make run() and related functions not constrain the size of the output string.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
NO_LIMIT = NoLimit()
|
|
47
|
+
|
|
48
|
+
MaxOutputSize = int | NoLimit
|
|
49
|
+
|
|
34
50
|
EnvOverrides = Mapping[str, str]
|
|
35
51
|
|
|
36
52
|
|
|
@@ -41,7 +57,7 @@ class RunParams(TypedDict, total=False):
|
|
|
41
57
|
success: Optional[Success]
|
|
42
58
|
flush_before_subprocess: Optional[bool]
|
|
43
59
|
trim_output_lines: Optional[bool]
|
|
44
|
-
max_output_size: Optional[
|
|
60
|
+
max_output_size: Optional[MaxOutputSize]
|
|
45
61
|
retry: Optional[int]
|
|
46
62
|
retry_initial_sleep_seconds: Optional[float]
|
|
47
63
|
retry_backoff: Optional[float]
|
|
@@ -2,12 +2,8 @@ __all__ = [
|
|
|
2
2
|
'value_or',
|
|
3
3
|
]
|
|
4
4
|
|
|
5
|
-
from collections.abc import Sequence
|
|
6
|
-
from pathlib import Path
|
|
7
5
|
from typing import TypeVar
|
|
8
6
|
|
|
9
|
-
import oslex
|
|
10
|
-
|
|
11
7
|
T = TypeVar('T')
|
|
12
8
|
U = TypeVar('U')
|
|
13
9
|
|
|
@@ -17,7 +13,3 @@ def value_or(value: T | None, default: U) -> T | U:
|
|
|
17
13
|
return default
|
|
18
14
|
else:
|
|
19
15
|
return value
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def oslex_join(cmd: Sequence[str | Path]) -> str:
|
|
23
|
-
return oslex.join([str(arg) for arg in cmd])
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fancy-subprocess"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.0"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Tamás PEREGI", email="petamas@gmail.com" },
|
|
10
10
|
]
|
|
@@ -28,10 +28,10 @@ license = "MIT"
|
|
|
28
28
|
license-files = ["LICENSE"]
|
|
29
29
|
dependencies = [
|
|
30
30
|
"ntstatus>=2.0,<3",
|
|
31
|
-
"oslex>=0
|
|
31
|
+
"oslex>=2.0,<3",
|
|
32
32
|
"pathext>=1.5,<2",
|
|
33
|
-
"typeguard>=4.
|
|
34
|
-
"typing_extensions>=4.
|
|
33
|
+
"typeguard>=4.5.2,<5",
|
|
34
|
+
"typing_extensions>=4.15,<5",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
@@ -41,6 +41,7 @@ dependencies = [
|
|
|
41
41
|
[dependency-groups]
|
|
42
42
|
dev = [
|
|
43
43
|
"mypy>=1.14.1,<1.15",
|
|
44
|
+
"pytest>=8.3.5,<9",
|
|
44
45
|
"ruff>=0.15.12,<0.16",
|
|
45
46
|
"ty>=0.0.34,<0.1",
|
|
46
47
|
]
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from fancy_subprocess import RunError
|
|
8
|
+
from fancy_subprocess import RunResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_runerror_picklable() -> None:
|
|
12
|
+
"""
|
|
13
|
+
There are certain exceptions that cannot be pickled, see:
|
|
14
|
+
- https://github.com/python/cpython/issues/101159 (original issue with long discussion)
|
|
15
|
+
- https://github.com/python/cpython/issues/44791 (closed issue linking various related issues)
|
|
16
|
+
- https://github.com/python/cpython/issues/87626 (open issue that actually matches ours)
|
|
17
|
+
RunError's previous implementation (using @dataclass(kw_only=True)) was like that.
|
|
18
|
+
This test guards against the class becoming unpicklable again.
|
|
19
|
+
"""
|
|
20
|
+
original_error = RunError(['notepad.exe'], RunResult(exit_code=0))
|
|
21
|
+
pickled_error = pickle.loads(pickle.dumps(original_error))
|
|
22
|
+
assert original_error.cmd == pickled_error.cmd
|
|
23
|
+
assert original_error.result == pickled_error.result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_runerror_is_contextlib_contextmanager_compatible() -> None:
|
|
27
|
+
"""
|
|
28
|
+
On Python 3.11+, RunError's previous implementation (using @dataclass(frozen=True)) caused contextmanager() to raise an exception:
|
|
29
|
+
> dataclasses.FrozenInstanceError: cannot assign to field '__traceback__'
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def manager() -> Iterator[int]:
|
|
34
|
+
yield 3
|
|
35
|
+
|
|
36
|
+
with pytest.raises(RunError):
|
|
37
|
+
with manager() as exit_code:
|
|
38
|
+
raise RunError(['notepad.exe'], RunResult(exit_code=exit_code))
|