fancy-subprocess 2.3__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.3 → fancy_subprocess-3.0}/.gitignore +1 -0
- fancy_subprocess-3.0/Justfile +29 -0
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/PKG-INFO +17 -5
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/README.md +12 -1
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/__init__.py +1 -0
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_compat.py +5 -2
- fancy_subprocess-3.0/fancy_subprocess/_exit_code.py +53 -0
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_print.py +7 -2
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_reconfigure.py +5 -1
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_run_core.py +119 -76
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_run_param.py +31 -6
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_run_wrappers.py +11 -4
- fancy_subprocess-3.0/fancy_subprocess/_utils.py +15 -0
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/pyproject.toml +43 -4
- fancy_subprocess-3.0/tests/__init__.py +0 -0
- fancy_subprocess-3.0/tests/test_runerror_bugs.py +38 -0
- fancy_subprocess-3.0/uv.lock +338 -0
- fancy_subprocess-2.3/fancy_subprocess/_utils.py +0 -50
- fancy_subprocess-2.3/grab_output.py +0 -26
- fancy_subprocess-2.3/uv.lock +0 -96
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/.editorconfig +0 -0
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/LICENSE +0 -0
- {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/py.typed +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
default:
|
|
2
|
+
just --list --justfile "{{justfile()}}"
|
|
3
|
+
|
|
4
|
+
[private]
|
|
5
|
+
verify_with_impl python_minor_version $UV_PROJECT_ENVIRONMENT:
|
|
6
|
+
@echo UV_PROJECT_ENVIRONMENT=${UV_PROJECT_ENVIRONMENT}
|
|
7
|
+
uv sync --python 3.{{python_minor_version}}
|
|
8
|
+
|
|
9
|
+
uv run -m mypy .
|
|
10
|
+
uv run ty check .
|
|
11
|
+
uv run ruff check --target-version py3{{python_minor_version}}
|
|
12
|
+
uv run ruff format --check --target-version py3{{python_minor_version}}
|
|
13
|
+
uv run -m pytest
|
|
14
|
+
|
|
15
|
+
verify_with python_minor_version="14": (verify_with_impl python_minor_version ".just_venv_3_"+python_minor_version)
|
|
16
|
+
|
|
17
|
+
verify: (verify_with "10") (verify_with "11") (verify_with "12") (verify_with "13") (verify_with "14")
|
|
18
|
+
|
|
19
|
+
format:
|
|
20
|
+
uv run ruff check --select I --fix
|
|
21
|
+
uv run ruff format
|
|
22
|
+
|
|
23
|
+
build_no_verify:
|
|
24
|
+
uv build
|
|
25
|
+
|
|
26
|
+
build: verify build_no_verify
|
|
27
|
+
|
|
28
|
+
publish: build
|
|
29
|
+
uv publish
|
|
@@ -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
|
|
@@ -17,12 +17,13 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
21
|
Requires-Python: >=3.10
|
|
21
22
|
Requires-Dist: ntstatus<3,>=2.0
|
|
22
|
-
Requires-Dist: oslex<2
|
|
23
|
+
Requires-Dist: oslex<3,>=2.0
|
|
23
24
|
Requires-Dist: pathext<2,>=1.5
|
|
24
|
-
Requires-Dist: typeguard<5,>=4.
|
|
25
|
-
Requires-Dist: typing-extensions<5,>=4.
|
|
25
|
+
Requires-Dist: typeguard<5,>=4.5.2
|
|
26
|
+
Requires-Dist: typing-extensions<5,>=4.15
|
|
26
27
|
Description-Content-Type: text/markdown
|
|
27
28
|
|
|
28
29
|
# fancy-subprocess
|
|
@@ -55,7 +56,7 @@ Arguments (all of them except `cmd` are optional):
|
|
|
55
56
|
- The type of this argument is also aliased as `fancy_subprocess.Success`.
|
|
56
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`.
|
|
57
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`.
|
|
58
|
-
- `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.
|
|
59
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.
|
|
60
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.
|
|
61
62
|
- `retry_backoff: float` - Factor used to increase wait times before subsequent retries. If unspecified or set to `None`, defaults to 2.
|
|
@@ -282,6 +283,17 @@ Exception FileNotFoundError with message "[Errno 2] No such file or directory: '
|
|
|
282
283
|
|
|
283
284
|
Calls `sys.stdout.reconfigure()` and `sys.stderr.reconfigure()` with the provided parameters. Raises `TypeError` if either `sys.stdout` or `sys.stderr` is not an instance of `io.TextIOWrapper`.
|
|
284
285
|
|
|
286
|
+
### `fancy_subprocess.stringify_exit_code()`
|
|
287
|
+
|
|
288
|
+
Takes an exit code, and tries to format it in a way to help users understand what went wrong. If it succeeds, returns the explanation as a string. If not, returns `None`.
|
|
289
|
+
|
|
290
|
+
Some examples:
|
|
291
|
+
- On POSIX platforms, decodes signals to their string representation.
|
|
292
|
+
- On Windows, if the code matches an NTSTATUS error, it returns the name of the NTSTATUS.
|
|
293
|
+
- On Windows, if it is a 32-bit value, returns it as a hex number (because that's usually how you can find them on Google).
|
|
294
|
+
|
|
295
|
+
Used as part of the error message in `RunError`.
|
|
296
|
+
|
|
285
297
|
## Licensing
|
|
286
298
|
|
|
287
299
|
This library is licensed under the MIT license.
|
|
@@ -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.
|
|
@@ -255,6 +255,17 @@ Exception FileNotFoundError with message "[Errno 2] No such file or directory: '
|
|
|
255
255
|
|
|
256
256
|
Calls `sys.stdout.reconfigure()` and `sys.stderr.reconfigure()` with the provided parameters. Raises `TypeError` if either `sys.stdout` or `sys.stderr` is not an instance of `io.TextIOWrapper`.
|
|
257
257
|
|
|
258
|
+
### `fancy_subprocess.stringify_exit_code()`
|
|
259
|
+
|
|
260
|
+
Takes an exit code, and tries to format it in a way to help users understand what went wrong. If it succeeds, returns the explanation as a string. If not, returns `None`.
|
|
261
|
+
|
|
262
|
+
Some examples:
|
|
263
|
+
- On POSIX platforms, decodes signals to their string representation.
|
|
264
|
+
- On Windows, if the code matches an NTSTATUS error, it returns the name of the NTSTATUS.
|
|
265
|
+
- On Windows, if it is a 32-bit value, returns it as a hex number (because that's usually how you can find them on Google).
|
|
266
|
+
|
|
267
|
+
Used as part of the error message in `RunError`.
|
|
268
|
+
|
|
258
269
|
## Licensing
|
|
259
270
|
|
|
260
271
|
This library is licensed under the MIT license.
|
|
@@ -6,13 +6,16 @@ __all__ = [
|
|
|
6
6
|
'which',
|
|
7
7
|
]
|
|
8
8
|
|
|
9
|
-
from pathext import checked_which
|
|
9
|
+
from pathext import checked_which
|
|
10
|
+
from pathext import which
|
|
10
11
|
|
|
11
|
-
from fancy_subprocess._run_core import RunError
|
|
12
|
+
from fancy_subprocess._run_core import RunError
|
|
13
|
+
from fancy_subprocess._run_core import RunResult
|
|
12
14
|
|
|
13
15
|
RunProcessError = RunError
|
|
14
16
|
RunProcessResult = RunResult
|
|
15
17
|
|
|
18
|
+
|
|
16
19
|
def SILENCE(msg: str) -> None:
|
|
17
20
|
"""
|
|
18
21
|
Helper function that takes a string, and does nothing with it. Meant to be passed as the print_message or print_output argument of run() and related functions to silence the corresponding output stream.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'stringify_exit_code',
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
if sys.platform == 'win32':
|
|
9
|
+
from ntstatus import NtStatus
|
|
10
|
+
from ntstatus import NtStatusSeverity
|
|
11
|
+
from ntstatus import ThirtyTwoBits
|
|
12
|
+
else:
|
|
13
|
+
import signal
|
|
14
|
+
|
|
15
|
+
def _signal_name(signal_value: int) -> Optional[str]:
|
|
16
|
+
try:
|
|
17
|
+
return signal.Signals(signal_value).name
|
|
18
|
+
except ValueError:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def stringify_exit_code(exit_code: int) -> Optional[str]:
|
|
23
|
+
if sys.platform == 'win32':
|
|
24
|
+
# Windows
|
|
25
|
+
if exit_code == 3:
|
|
26
|
+
# abort() results in exit code 3: https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/abort
|
|
27
|
+
# While exit code 3 does not necessarily mean aborted (because applications may use it as a generic error code),
|
|
28
|
+
# it's common enough to be worth handling. "?" included to signal the uncertainty.
|
|
29
|
+
return 'aborted?'
|
|
30
|
+
|
|
31
|
+
if not ThirtyTwoBits.check(exit_code):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
status = NtStatus.decode(exit_code)
|
|
36
|
+
if NtStatus.severity(status) != NtStatusSeverity.STATUS_SEVERITY_SUCCESS:
|
|
37
|
+
return status.name
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
return f'0x{ThirtyTwoBits(exit_code).unsigned_value:08X}'
|
|
42
|
+
else:
|
|
43
|
+
# POSIX
|
|
44
|
+
if exit_code < 0:
|
|
45
|
+
return _signal_name(-exit_code) or 'unknown signal'
|
|
46
|
+
elif exit_code == 126:
|
|
47
|
+
return 'COULD_NOT_EXECUTE'
|
|
48
|
+
elif exit_code == 127:
|
|
49
|
+
return 'COMMAND_NOT_FOUND'
|
|
50
|
+
elif exit_code in range(129, 160):
|
|
51
|
+
return _signal_name(exit_code - 128) or 'unknown signal'
|
|
52
|
+
|
|
53
|
+
return None
|
|
@@ -16,24 +16,29 @@ PrintFunction = Callable[[str], None]
|
|
|
16
16
|
|
|
17
17
|
Indent = int | str
|
|
18
18
|
|
|
19
|
+
|
|
19
20
|
def silenced_print(line: str) -> None:
|
|
20
21
|
pass
|
|
21
22
|
|
|
23
|
+
|
|
22
24
|
def indented_print(line: str, indent: Optional[Indent] = None) -> None:
|
|
23
25
|
if indent is None:
|
|
24
|
-
real_indent = 4*' '
|
|
26
|
+
real_indent = 4 * ' '
|
|
25
27
|
elif isinstance(indent, int):
|
|
26
|
-
real_indent = indent*' '
|
|
28
|
+
real_indent = indent * ' '
|
|
27
29
|
else:
|
|
28
30
|
real_indent = indent
|
|
29
31
|
|
|
30
32
|
print(f'{real_indent}{line}', flush=True)
|
|
31
33
|
|
|
34
|
+
|
|
32
35
|
def indented_print_factory(indent: Optional[Indent] = None) -> PrintFunction:
|
|
33
36
|
return lambda line: indented_print(line, indent)
|
|
34
37
|
|
|
38
|
+
|
|
35
39
|
def default_print(line: str) -> None:
|
|
36
40
|
indented_print(line, indent='')
|
|
37
41
|
|
|
42
|
+
|
|
38
43
|
def error_print(line: str) -> None:
|
|
39
44
|
print(line, file=sys.stderr, flush=True)
|
|
@@ -4,10 +4,12 @@ __all__ = [
|
|
|
4
4
|
|
|
5
5
|
import io
|
|
6
6
|
import sys
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from typing import TypedDict
|
|
8
9
|
|
|
9
10
|
from typing_extensions import Unpack
|
|
10
11
|
|
|
12
|
+
|
|
11
13
|
class ReconfigureParams(TypedDict, total=False):
|
|
12
14
|
encoding: Optional[str]
|
|
13
15
|
errors: Optional[str]
|
|
@@ -15,6 +17,7 @@ class ReconfigureParams(TypedDict, total=False):
|
|
|
15
17
|
line_buffering: Optional[bool]
|
|
16
18
|
write_through: Optional[bool]
|
|
17
19
|
|
|
20
|
+
|
|
18
21
|
def _reconfigure_standard_stream(stream: object, name: str, **kwargs: Unpack[ReconfigureParams]) -> None:
|
|
19
22
|
if stream is None:
|
|
20
23
|
raise TypeError(f'{name} is None')
|
|
@@ -24,6 +27,7 @@ def _reconfigure_standard_stream(stream: object, name: str, **kwargs: Unpack[Rec
|
|
|
24
27
|
|
|
25
28
|
stream.reconfigure(**kwargs)
|
|
26
29
|
|
|
30
|
+
|
|
27
31
|
def reconfigure_standard_output_streams(**kwargs: Unpack[ReconfigureParams]) -> None:
|
|
28
32
|
"""
|
|
29
33
|
Calls `sys.stdout.reconfigure()` and `sys.stderr.reconfigure()` with the provided parameters. Raises `TypeError` if either `sys.stdout` or `sys.stderr` is not an instance of `io.TextIOWrapper`.
|
|
@@ -13,11 +13,22 @@ 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
|
-
from fancy_subprocess.
|
|
19
|
-
from fancy_subprocess.
|
|
20
|
-
from fancy_subprocess.
|
|
19
|
+
from fancy_subprocess._exit_code import stringify_exit_code
|
|
20
|
+
from fancy_subprocess._print import PrintFunction
|
|
21
|
+
from fancy_subprocess._print import default_print
|
|
22
|
+
from fancy_subprocess._print import silenced_print
|
|
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
|
|
27
|
+
from fancy_subprocess._run_param import RunParams
|
|
28
|
+
from fancy_subprocess._run_param import Success
|
|
29
|
+
from fancy_subprocess._run_param import check_run_params
|
|
30
|
+
from fancy_subprocess._utils import value_or
|
|
31
|
+
|
|
21
32
|
|
|
22
33
|
@dataclass(kw_only=True, frozen=True)
|
|
23
34
|
class RunResult:
|
|
@@ -32,7 +43,7 @@ class RunResult:
|
|
|
32
43
|
exit_code: int = 0
|
|
33
44
|
output: str = ''
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
|
|
36
47
|
class RunError(Exception):
|
|
37
48
|
"""
|
|
38
49
|
`fancy_subprocess.run()` and similar functions raise `RunError` on error. There are two kinds of errors that result in a `RunError`:
|
|
@@ -53,6 +64,11 @@ class RunError(Exception):
|
|
|
53
64
|
- `oserror: OSError` - The `OSError` raised by `subprocess.Popen()`. Raises `ValueError` if `completed` is `True`.
|
|
54
65
|
"""
|
|
55
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
|
+
|
|
56
72
|
cmd: Sequence[str | Path]
|
|
57
73
|
result: RunResult | OSError = RunResult()
|
|
58
74
|
|
|
@@ -65,21 +81,21 @@ class RunError(Exception):
|
|
|
65
81
|
if isinstance(self.result, RunResult):
|
|
66
82
|
return self.result.exit_code
|
|
67
83
|
else:
|
|
68
|
-
raise ValueError('
|
|
84
|
+
raise ValueError('"exit_code" can only be queried if "completed" is True')
|
|
69
85
|
|
|
70
86
|
@property
|
|
71
87
|
def output(self) -> str:
|
|
72
88
|
if isinstance(self.result, RunResult):
|
|
73
89
|
return self.result.output
|
|
74
90
|
else:
|
|
75
|
-
raise ValueError('
|
|
91
|
+
raise ValueError('"output" can only be queried if "completed" is True')
|
|
76
92
|
|
|
77
93
|
@property
|
|
78
94
|
def oserror(self) -> OSError:
|
|
79
95
|
if isinstance(self.result, OSError):
|
|
80
96
|
return self.result
|
|
81
97
|
else:
|
|
82
|
-
raise ValueError('
|
|
98
|
+
raise ValueError('"oserror" can only be queried if "completed" is False')
|
|
83
99
|
|
|
84
100
|
@property
|
|
85
101
|
def message(self) -> str:
|
|
@@ -89,13 +105,93 @@ class RunError(Exception):
|
|
|
89
105
|
exit_code_comment = f' ({exit_code_str})'
|
|
90
106
|
else:
|
|
91
107
|
exit_code_comment = ''
|
|
92
|
-
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)}'
|
|
93
109
|
else:
|
|
94
|
-
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)}'
|
|
95
111
|
|
|
96
112
|
def __str__(self) -> str:
|
|
97
113
|
return self.message
|
|
98
114
|
|
|
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
|
+
|
|
99
195
|
def run(
|
|
100
196
|
cmd: Sequence[str | Path],
|
|
101
197
|
*,
|
|
@@ -125,7 +221,7 @@ def run(
|
|
|
125
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.
|
|
126
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`.
|
|
127
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`.
|
|
128
|
-
- `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.
|
|
129
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.
|
|
130
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.
|
|
131
227
|
- `retry_backoff: float` - Factor used to increase wait times before subsequent retries. If unspecified or set to `None`, defaults to 2.
|
|
@@ -138,89 +234,36 @@ def run(
|
|
|
138
234
|
|
|
139
235
|
check_run_params(**kwargs)
|
|
140
236
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
default_description = f'Running command (output silenced): {oslex_join(cmd)}'
|
|
145
|
-
else:
|
|
146
|
-
default_description = f'Running command: {oslex_join(cmd)}'
|
|
147
|
-
description = value_or(kwargs.get('description'), default_description)
|
|
148
|
-
success: Success = value_or(kwargs.get('success'), [0])
|
|
149
|
-
flush_before_subprocess = value_or(kwargs.get('flush_before_subprocess'), True)
|
|
150
|
-
trim_output_lines = value_or(kwargs.get('trim_output_lines'), True)
|
|
151
|
-
max_output_size = value_or(kwargs.get('max_output_size'), 10*1000*1000)
|
|
152
|
-
retry = value_or(kwargs.get('retry'), 0)
|
|
153
|
-
retry_initial_sleep_seconds = value_or(kwargs.get('retry_initial_sleep_seconds'), 10)
|
|
154
|
-
retry_backoff = value_or(kwargs.get('retry_backoff'), 2)
|
|
155
|
-
env_overrides = value_or(kwargs.get('env_overrides'), dict())
|
|
156
|
-
cwd = kwargs.get('cwd')
|
|
157
|
-
encoding = kwargs.get('encoding')
|
|
158
|
-
errors = value_or(kwargs.get('errors'), 'replace')
|
|
159
|
-
replace_fffd_with_question_mark = value_or(kwargs.get('replace_fffd_with_question_mark'), True)
|
|
160
|
-
|
|
161
|
-
if message_quiet:
|
|
237
|
+
params = _ResolvedRunParams(cmd, **kwargs)
|
|
238
|
+
|
|
239
|
+
if params.message_quiet:
|
|
162
240
|
print_message = silenced_print
|
|
163
241
|
else:
|
|
164
242
|
print_message = value_or(print_message, default_print)
|
|
165
243
|
|
|
166
|
-
if output_quiet:
|
|
244
|
+
if params.output_quiet:
|
|
167
245
|
print_output = silenced_print
|
|
168
246
|
else:
|
|
169
247
|
print_output = value_or(print_output, default_print)
|
|
170
248
|
|
|
171
249
|
env = dict(os.environ)
|
|
172
|
-
if sys.platform=='win32':
|
|
173
|
-
env.update((key.upper(), value) for key,value in env_overrides.items())
|
|
250
|
+
if sys.platform == 'win32':
|
|
251
|
+
env.update((key.upper(), value) for key, value in params.env_overrides.items())
|
|
174
252
|
else:
|
|
175
|
-
env.update(env_overrides)
|
|
176
|
-
|
|
177
|
-
def attempt_run() -> RunResult:
|
|
178
|
-
print_message(description)
|
|
179
|
-
|
|
180
|
-
if flush_before_subprocess:
|
|
181
|
-
sys.stdout.flush()
|
|
182
|
-
sys.stderr.flush()
|
|
183
|
-
|
|
184
|
-
output = ''
|
|
185
|
-
try:
|
|
186
|
-
with subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, cwd=cwd, env=env, encoding=encoding, errors=errors) as proc:
|
|
187
|
-
assert proc.stdout is not None # passing stdout=subprocess.PIPE guarantees this
|
|
188
|
-
|
|
189
|
-
for line in iter(proc.stdout.readline, ''):
|
|
190
|
-
line = line.removesuffix('\n')
|
|
191
|
-
if trim_output_lines:
|
|
192
|
-
line = line.rstrip()
|
|
193
|
-
if replace_fffd_with_question_mark:
|
|
194
|
-
line = line.replace('\ufffd', '?')
|
|
195
|
-
|
|
196
|
-
print_output(line)
|
|
197
|
-
|
|
198
|
-
output += line + '\n'
|
|
199
|
-
if len(output)>max_output_size+1:
|
|
200
|
-
output = output[-max_output_size-1:] # drop the beginning of the string
|
|
201
|
-
|
|
202
|
-
proc.wait()
|
|
203
|
-
result = RunResult(exit_code=proc.returncode, output=output.removesuffix('\n'))
|
|
204
|
-
except OSError as e:
|
|
205
|
-
raise RunError(cmd=cmd, result=e) from e
|
|
206
|
-
|
|
207
|
-
if isinstance(success, AnyExitCode) or result.exit_code in success:
|
|
208
|
-
return result
|
|
209
|
-
else:
|
|
210
|
-
raise RunError(cmd=cmd, result=result)
|
|
253
|
+
env.update(params.env_overrides)
|
|
211
254
|
|
|
212
|
-
sleep_seconds = retry_initial_sleep_seconds
|
|
213
|
-
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):
|
|
214
257
|
try:
|
|
215
|
-
return
|
|
258
|
+
return _attempt_run(cmd, print_message=print_message, print_output=print_output, env=env, params=params)
|
|
216
259
|
except RunError as e:
|
|
217
260
|
print_message(str(e))
|
|
218
|
-
if attempts_left!=1:
|
|
261
|
+
if attempts_left != 1:
|
|
219
262
|
plural = 's'
|
|
220
263
|
else:
|
|
221
264
|
plural = ''
|
|
222
265
|
print_message(f'Retrying in {sleep_seconds} seconds ({attempts_left} attempt{plural} left)...')
|
|
223
266
|
time.sleep(sleep_seconds)
|
|
224
|
-
sleep_seconds *= retry_backoff
|
|
267
|
+
sleep_seconds *= params.retry_backoff
|
|
225
268
|
|
|
226
|
-
return
|
|
269
|
+
return _attempt_run(cmd, print_message=print_message, print_output=print_output, env=env, params=params)
|
|
@@ -5,17 +5,23 @@ __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
|
]
|
|
11
14
|
|
|
12
|
-
from collections.abc import Mapping
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from collections.abc import Sequence
|
|
13
17
|
from pathlib import Path
|
|
14
|
-
from typing import Optional
|
|
18
|
+
from typing import Optional
|
|
19
|
+
from typing import TypedDict
|
|
15
20
|
|
|
16
21
|
import typeguard
|
|
17
22
|
from typing_extensions import Unpack
|
|
18
23
|
|
|
24
|
+
|
|
19
25
|
class AnyExitCode:
|
|
20
26
|
"""
|
|
21
27
|
Use an instance of this class (eg. fancy_subprocess.ANY_EXIT_CODE) as the 'success' argument to make run() and related functions treat any exit code as success.
|
|
@@ -23,12 +29,27 @@ class AnyExitCode:
|
|
|
23
29
|
|
|
24
30
|
pass
|
|
25
31
|
|
|
32
|
+
|
|
26
33
|
ANY_EXIT_CODE = AnyExitCode()
|
|
27
34
|
|
|
28
35
|
Success = Sequence[int] | AnyExitCode
|
|
29
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
|
+
|
|
30
50
|
EnvOverrides = Mapping[str, str]
|
|
31
51
|
|
|
52
|
+
|
|
32
53
|
class RunParams(TypedDict, total=False):
|
|
33
54
|
message_quiet: Optional[bool]
|
|
34
55
|
output_quiet: Optional[bool]
|
|
@@ -36,7 +57,7 @@ class RunParams(TypedDict, total=False):
|
|
|
36
57
|
success: Optional[Success]
|
|
37
58
|
flush_before_subprocess: Optional[bool]
|
|
38
59
|
trim_output_lines: Optional[bool]
|
|
39
|
-
max_output_size: Optional[
|
|
60
|
+
max_output_size: Optional[MaxOutputSize]
|
|
40
61
|
retry: Optional[int]
|
|
41
62
|
retry_initial_sleep_seconds: Optional[float]
|
|
42
63
|
retry_backoff: Optional[float]
|
|
@@ -46,11 +67,14 @@ class RunParams(TypedDict, total=False):
|
|
|
46
67
|
errors: Optional[str]
|
|
47
68
|
replace_fffd_with_question_mark: Optional[bool]
|
|
48
69
|
|
|
70
|
+
|
|
49
71
|
def check_run_params(**kwargs: Unpack[RunParams]) -> None:
|
|
50
72
|
try:
|
|
51
73
|
typeguard.check_type(kwargs, RunParams, collection_check_strategy=typeguard.CollectionCheckStrategy.ALL_ITEMS)
|
|
52
74
|
except typeguard.TypeCheckError as e:
|
|
53
|
-
|
|
75
|
+
# we don't wanna expose the stacktrace from typeguard to be able to replace it with another library if needed
|
|
76
|
+
raise ValueError(str(e)) from None
|
|
77
|
+
|
|
54
78
|
|
|
55
79
|
def change_default_run_params(params: RunParams, **new_defaults: Unpack[RunParams]) -> None:
|
|
56
80
|
check_run_params(**params)
|
|
@@ -59,7 +83,8 @@ def change_default_run_params(params: RunParams, **new_defaults: Unpack[RunParam
|
|
|
59
83
|
for key in new_defaults.keys():
|
|
60
84
|
if params.get(key) is None:
|
|
61
85
|
# It's safe to ignore the TypedDict-related checks here because of the check_run_params() calls
|
|
62
|
-
params[key] = new_defaults[key]
|
|
86
|
+
params[key] = new_defaults[key] # type: ignore[literal-required] # ty: ignore[invalid-key]
|
|
87
|
+
|
|
63
88
|
|
|
64
89
|
def force_run_params(params: RunParams, **forced_values: Unpack[RunParams]) -> None:
|
|
65
90
|
check_run_params(**params)
|
|
@@ -70,4 +95,4 @@ def force_run_params(params: RunParams, **forced_values: Unpack[RunParams]) -> N
|
|
|
70
95
|
raise ValueError(f'Trying to override forced keyword parameter {key} is disallowed')
|
|
71
96
|
else:
|
|
72
97
|
# It's safe to ignore the TypedDict-related checks here because of the check_run_params() calls
|
|
73
|
-
params[key] = forced_values[key]
|
|
98
|
+
params[key] = forced_values[key] # type: ignore[literal-required] # ty: ignore[invalid-key]
|
|
@@ -9,10 +9,16 @@ from typing import Optional
|
|
|
9
9
|
|
|
10
10
|
from typing_extensions import Unpack
|
|
11
11
|
|
|
12
|
-
from fancy_subprocess._print import Indent
|
|
13
|
-
from fancy_subprocess.
|
|
14
|
-
from fancy_subprocess.
|
|
15
|
-
from fancy_subprocess.
|
|
12
|
+
from fancy_subprocess._print import Indent
|
|
13
|
+
from fancy_subprocess._print import PrintFunction
|
|
14
|
+
from fancy_subprocess._print import indented_print_factory
|
|
15
|
+
from fancy_subprocess._print import silenced_print
|
|
16
|
+
from fancy_subprocess._run_core import RunResult
|
|
17
|
+
from fancy_subprocess._run_core import run
|
|
18
|
+
from fancy_subprocess._run_param import RunParams
|
|
19
|
+
from fancy_subprocess._run_param import check_run_params
|
|
20
|
+
from fancy_subprocess._run_param import force_run_params
|
|
21
|
+
|
|
16
22
|
|
|
17
23
|
def run_silenced(
|
|
18
24
|
cmd: Sequence[str | Path],
|
|
@@ -42,6 +48,7 @@ def run_silenced(
|
|
|
42
48
|
**forwarded_args,
|
|
43
49
|
)
|
|
44
50
|
|
|
51
|
+
|
|
45
52
|
def run_indented(
|
|
46
53
|
cmd: Sequence[str | Path],
|
|
47
54
|
*,
|