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.
Files changed (23) hide show
  1. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/.gitignore +1 -0
  2. fancy_subprocess-3.0/Justfile +29 -0
  3. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/PKG-INFO +17 -5
  4. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/README.md +12 -1
  5. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/__init__.py +1 -0
  6. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_compat.py +5 -2
  7. fancy_subprocess-3.0/fancy_subprocess/_exit_code.py +53 -0
  8. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_print.py +7 -2
  9. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_reconfigure.py +5 -1
  10. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_run_core.py +119 -76
  11. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_run_param.py +31 -6
  12. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/_run_wrappers.py +11 -4
  13. fancy_subprocess-3.0/fancy_subprocess/_utils.py +15 -0
  14. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/pyproject.toml +43 -4
  15. fancy_subprocess-3.0/tests/__init__.py +0 -0
  16. fancy_subprocess-3.0/tests/test_runerror_bugs.py +38 -0
  17. fancy_subprocess-3.0/uv.lock +338 -0
  18. fancy_subprocess-2.3/fancy_subprocess/_utils.py +0 -50
  19. fancy_subprocess-2.3/grab_output.py +0 -26
  20. fancy_subprocess-2.3/uv.lock +0 -96
  21. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/.editorconfig +0 -0
  22. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/LICENSE +0 -0
  23. {fancy_subprocess-2.3 → fancy_subprocess-3.0}/fancy_subprocess/py.typed +0 -0
@@ -122,6 +122,7 @@ celerybeat.pid
122
122
  # Environments
123
123
  .env
124
124
  .venv
125
+ .just_venv_*
125
126
  env/
126
127
  venv/
127
128
  ENV/
@@ -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: 2.3
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,>=0.1.3
23
+ Requires-Dist: oslex<3,>=2.0
23
24
  Requires-Dist: pathext<2,>=1.5
24
- Requires-Dist: typeguard<5,>=4.4.2
25
- Requires-Dist: typing-extensions<5,>=4.14
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.
@@ -1,4 +1,5 @@
1
1
  from fancy_subprocess._compat import *
2
+ from fancy_subprocess._exit_code import *
2
3
  from fancy_subprocess._print import *
3
4
  from fancy_subprocess._reconfigure import *
4
5
  from fancy_subprocess._run_core import *
@@ -6,13 +6,16 @@ __all__ = [
6
6
  'which',
7
7
  ]
8
8
 
9
- from pathext import checked_which, which
9
+ from pathext import checked_which
10
+ from pathext import which
10
11
 
11
- from fancy_subprocess._run_core import RunError, RunResult
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, TypedDict
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._print import default_print, PrintFunction, silenced_print
19
- from fancy_subprocess._run_param import AnyExitCode, check_run_params, RunParams, Success
20
- from fancy_subprocess._utils import oslex_join, stringify_exit_code, value_or
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
- @dataclass(kw_only=True, frozen=True)
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}: {oslex_join(self.cmd)}'
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: {oslex_join(self.cmd)}'
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
- message_quiet = value_or(kwargs.get('message_quiet'), False)
142
- output_quiet = value_or(kwargs.get('output_quiet'), False)
143
- if output_quiet:
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 attempt_run()
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 attempt_run()
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, Sequence
15
+ from collections.abc import Mapping
16
+ from collections.abc import Sequence
13
17
  from pathlib import Path
14
- from typing import Optional, TypedDict
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[int]
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
- raise ValueError(str(e)) from None # we don't wanna expose the stacktrace from typeguard to be able to replace it with another library if needed
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] # type: ignore[literal-required]
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] # type: ignore[literal-required]
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, indented_print_factory, PrintFunction, silenced_print
13
- from fancy_subprocess._run_core import run, RunResult
14
- from fancy_subprocess._run_param import check_run_params, force_run_params, RunParams
15
- from fancy_subprocess._utils import oslex_join
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
  *,