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.
@@ -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: 2.4
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,>=0.1.3
23
+ Requires-Dist: oslex<3,>=2.0
24
24
  Requires-Dist: pathext<2,>=1.5
25
- Requires-Dist: typeguard<5,>=4.4.2
26
- Requires-Dist: typing-extensions<5,>=4.14
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}: {oslex_join(self.cmd)}'
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: {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)}'
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
- message_quiet = value_or(kwargs.get('message_quiet'), False)
152
- output_quiet = value_or(kwargs.get('output_quiet'), False)
153
- if output_quiet:
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 attempt_run()
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 attempt_run()
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[int]
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 = "2.4"
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.1.3,<2",
31
+ "oslex>=2.0,<3",
32
32
  "pathext>=1.5,<2",
33
- "typeguard>=4.4.2,<5",
34
- "typing_extensions>=4.14,<5",
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))