pyallel 2.0.6__tar.gz → 2.1.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 (38) hide show
  1. pyallel-2.1.0/.vim/project.shada +0 -0
  2. {pyallel-2.0.6 → pyallel-2.1.0}/PKG-INFO +4 -3
  3. {pyallel-2.0.6 → pyallel-2.1.0}/README.md +3 -2
  4. {pyallel-2.0.6 → pyallel-2.1.0}/pyproject.toml +1 -1
  5. pyallel-2.1.0/src/pyallel/logging.py +14 -0
  6. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/main.py +9 -9
  7. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/parser.py +12 -3
  8. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/printer.py +42 -3
  9. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/process.py +0 -14
  10. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/process_group_manager.py +2 -2
  11. pyallel-2.1.0/tests/conftest.py +13 -0
  12. {pyallel-2.0.6 → pyallel-2.1.0}/tests/test_main.py +0 -88
  13. {pyallel-2.0.6 → pyallel-2.1.0}/tests/test_process.py +5 -36
  14. {pyallel-2.0.6 → pyallel-2.1.0}/tests/test_process_group.py +5 -3
  15. {pyallel-2.0.6 → pyallel-2.1.0}/tests/test_process_manager.py +61 -15
  16. {pyallel-2.0.6 → pyallel-2.1.0}/uv.lock +1 -1
  17. pyallel-2.0.6/.vim/project.shada +0 -0
  18. {pyallel-2.0.6 → pyallel-2.1.0}/.base_branch +0 -0
  19. {pyallel-2.0.6 → pyallel-2.1.0}/.github/workflows/build.yml +0 -0
  20. {pyallel-2.0.6 → pyallel-2.1.0}/.github/workflows/test.yml +0 -0
  21. {pyallel-2.0.6 → pyallel-2.1.0}/.gitignore +0 -0
  22. {pyallel-2.0.6 → pyallel-2.1.0}/Dockerfile +0 -0
  23. {pyallel-2.0.6 → pyallel-2.1.0}/Dockerfile.alpine +0 -0
  24. {pyallel-2.0.6 → pyallel-2.1.0}/LICENSE +0 -0
  25. {pyallel-2.0.6 → pyallel-2.1.0}/benchmark_process.py +0 -0
  26. {pyallel-2.0.6 → pyallel-2.1.0}/bin/ldd +0 -0
  27. {pyallel-2.0.6 → pyallel-2.1.0}/bin/pyinstaller.sh +0 -0
  28. {pyallel-2.0.6 → pyallel-2.1.0}/build.sh +0 -0
  29. {pyallel-2.0.6 → pyallel-2.1.0}/build_all.sh +0 -0
  30. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/__init__.py +0 -0
  31. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/colours.py +0 -0
  32. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/constants.py +0 -0
  33. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/errors.py +0 -0
  34. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/process_group.py +0 -0
  35. {pyallel-2.0.6 → pyallel-2.1.0}/src/pyallel/py.typed +0 -0
  36. {pyallel-2.0.6 → pyallel-2.1.0}/tests/assets/test_handle_multiple_signals.sh +0 -0
  37. {pyallel-2.0.6 → pyallel-2.1.0}/tests/assets/test_output.sh +0 -0
  38. {pyallel-2.0.6 → pyallel-2.1.0}/tests/test_printer.py +0 -0
Binary file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyallel
3
- Version: 2.0.6
3
+ Version: 2.1.0
4
4
  Summary: Run and handle the output of multiple executables in pyallel (as in parallel)
5
5
  Project-URL: Homepage, https://github.com/Danthewaann/pyallel
6
6
  Project-URL: Repository, https://github.com/Danthewaann/pyallel
@@ -39,7 +39,7 @@ pipx install pyallel
39
39
  Once installed, you can run `pyallel` to see usage information, like so:
40
40
 
41
41
  ```
42
- usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
42
+ usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [--debug] [commands ...]
43
43
 
44
44
  run and handle the output of multiple executables in pyallel (as in parallel)
45
45
 
@@ -98,6 +98,7 @@ options:
98
98
  -V, --version print version and exit
99
99
  --colour {yes,no,auto}
100
100
  colour terminal output, defaults to "auto"
101
+ --debug enable debug mode, which logs debug info to a "pyallel.log" file in the current directory
101
102
  ```
102
103
 
103
104
  Currently you can provide a variable number of `commands` to run to `pyallel`, like so:
@@ -158,7 +159,7 @@ pipx install uv && \
158
159
  before a given command can start)
159
160
  - [x] Add support to state how many lines a command can use for it's output in interactive mode
160
161
  - [x] Improve printing of output performance by only printing lines that have changed
161
- - [ ] Add a debug mode that logs debug information to a log file
162
+ - [x] Add a debug mode that logs debug information to a log file
162
163
  - [ ] Maybe add support to allow the user to provide stdin for commands that request it
163
164
  (such as a REPL)
164
165
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
@@ -21,7 +21,7 @@ pipx install pyallel
21
21
  Once installed, you can run `pyallel` to see usage information, like so:
22
22
 
23
23
  ```
24
- usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
24
+ usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [--debug] [commands ...]
25
25
 
26
26
  run and handle the output of multiple executables in pyallel (as in parallel)
27
27
 
@@ -80,6 +80,7 @@ options:
80
80
  -V, --version print version and exit
81
81
  --colour {yes,no,auto}
82
82
  colour terminal output, defaults to "auto"
83
+ --debug enable debug mode, which logs debug info to a "pyallel.log" file in the current directory
83
84
  ```
84
85
 
85
86
  Currently you can provide a variable number of `commands` to run to `pyallel`, like so:
@@ -140,7 +141,7 @@ pipx install uv && \
140
141
  before a given command can start)
141
142
  - [x] Add support to state how many lines a command can use for it's output in interactive mode
142
143
  - [x] Improve printing of output performance by only printing lines that have changed
143
- - [ ] Add a debug mode that logs debug information to a log file
144
+ - [x] Add a debug mode that logs debug information to a log file
144
145
  - [ ] Maybe add support to allow the user to provide stdin for commands that request it
145
146
  (such as a REPL)
146
147
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyallel"
3
- version = "2.0.6"
3
+ version = "2.1.0"
4
4
  description = "Run and handle the output of multiple executables in pyallel (as in parallel)"
5
5
  authors = [{ name = "Daniel Black", email = "danielcrblack@gmail.com" }]
6
6
  requires-python = ">=3.8"
@@ -0,0 +1,14 @@
1
+ import logging
2
+ import os
3
+
4
+
5
+ def configure_logging(*, debug: bool = False) -> None:
6
+ if debug:
7
+ logging.basicConfig(
8
+ filename="pyallel.log",
9
+ format="%(asctime)s:%(name)s:%(lineno)d:%(message)s",
10
+ datefmt="%Y-%m-%dT%H:%M:%S",
11
+ level=logging.DEBUG,
12
+ )
13
+ else:
14
+ logging.basicConfig(handlers=[logging.StreamHandler(stream=open(os.devnull, "w"))]) # noqa: PTH123, SIM115
@@ -9,6 +9,7 @@ import traceback
9
9
  from pyallel import constants
10
10
  from pyallel.colours import Colours
11
11
  from pyallel.errors import PyallelError
12
+ from pyallel.logging import configure_logging
12
13
  from pyallel.parser import Arguments, create_parser
13
14
  from pyallel.printer import (
14
15
  InteractiveConsolePrinter,
@@ -17,13 +18,6 @@ from pyallel.printer import (
17
18
  )
18
19
  from pyallel.process_group_manager import ProcessGroupManager
19
20
 
20
- logging.basicConfig(
21
- filename="pyallel.log",
22
- format="%(asctime)s:%(levelname)s:%(name)s:%(lineno)d[%(threadName)s]:%(message)s",
23
- datefmt="%Y-%m-%dT%H:%M:%S",
24
- level=logging.DEBUG,
25
- )
26
-
27
21
  logger = logging.getLogger(__name__)
28
22
 
29
23
 
@@ -41,6 +35,8 @@ def entry_point(*args: str) -> int:
41
35
  parser.print_help()
42
36
  return 2
43
37
 
38
+ configure_logging(debug=parsed_args.debug)
39
+
44
40
  colours = Colours.from_colour(parsed_args.colour)
45
41
  printer: Printer
46
42
  if not parsed_args.interactive or not constants.IN_TTY:
@@ -54,19 +50,24 @@ def entry_point(*args: str) -> int:
54
50
  print(f"{colours.red_bold}Error: {e!s}{colours.reset_colour}")
55
51
  return 1
56
52
 
53
+ logger.debug("starting run with arguments:\n%s", parsed_args)
57
54
  try:
58
55
  exit_code = run(process_group_manager, printer)
59
56
  except Exception:
57
+ logger.exception("failed run with arguments:\n%s", parsed_args)
60
58
  print(f"{colours.red_bold}Error: {traceback.format_exc()}{colours.reset_colour}")
61
59
  return 1
62
60
 
63
61
  if exit_code == 1:
62
+ logger.error("failed run with arguments:\n%s", parsed_args)
64
63
  process_group = process_group_manager.get_cur_process_group_output()
65
64
  print(f"\n{colours.red_bold}ERROR: the following commands failed{colours.reset_colour}")
66
65
  for process_output in process_group.processes:
67
66
  process_poll = process_output.process.poll()
68
67
  if process_poll and process_poll > 0:
69
68
  print(f" {colours.red_bold}{process_output.process.command}{colours.reset_colour}")
69
+ else:
70
+ logger.debug("finished run with arguments:\n%s", parsed_args)
70
71
 
71
72
  return exit_code
72
73
 
@@ -81,8 +82,7 @@ def run(process_group_manager: ProcessGroupManager, printer: Printer) -> int:
81
82
  if poll is not None:
82
83
  # If we still have new output to print after the process group manager has completed,
83
84
  # make sure to print it here before continuing
84
- has_output = process_group_manager.stream().has_output()
85
- if has_output:
85
+ if process_group_manager.stream().has_output():
86
86
  printer.print(process_group_manager)
87
87
 
88
88
  if poll > 0:
@@ -10,13 +10,16 @@ class Arguments:
10
10
  interactive: bool
11
11
  timer: bool
12
12
  version: bool
13
+ debug: bool
13
14
 
14
15
  def __repr__(self) -> str:
15
- msg = "Arguments:\n"
16
+ msg = ""
16
17
  padding = len(sorted(self.__dict__.keys(), key=len, reverse=True)[0]) + 1
17
18
  for field, value in self.__dict__.items():
18
- msg += f" {field: <{padding}}: {value}\n"
19
- return msg
19
+ if field == "commands":
20
+ value = " ".join(value) # noqa: PLW2901
21
+ msg += f"{field: <{padding}}: {value}\n"
22
+ return msg.strip()
20
23
 
21
24
 
22
25
  DESCRIPTION = r"""run and handle the output of multiple executables in %(prog)s (as in parallel)
@@ -108,5 +111,11 @@ def create_parser() -> ArgumentParser:
108
111
  choices=("yes", "no", "auto"),
109
112
  default="auto",
110
113
  )
114
+ parser.add_argument(
115
+ "--debug",
116
+ help='enable debug mode, which logs debug info to a "pyallel.log" file in the current directory',
117
+ action="store_true",
118
+ default=False,
119
+ )
111
120
 
112
121
  return parser
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import time
4
5
  from typing import TYPE_CHECKING, Protocol
5
6
 
@@ -12,6 +13,9 @@ if TYPE_CHECKING:
12
13
  from pyallel.process_group_manager import ProcessGroupManager
13
14
 
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+
15
19
  class Printer(Protocol):
16
20
  def print(self, process_group_manager: ProcessGroupManager) -> None:
17
21
  """Print output obtained from the provided process group manager.
@@ -195,7 +199,7 @@ class ConsolePrinter:
195
199
 
196
200
  return self._to_print
197
201
 
198
- def set_process_lines(
202
+ def set_process_lines( # noqa: PLR0915
199
203
  self,
200
204
  output: ProcessGroupOutput,
201
205
  interrupt_count: int = 0,
@@ -205,8 +209,10 @@ class ConsolePrinter:
205
209
  if interrupt_count:
206
210
  lines -= 2
207
211
 
212
+ logger.debug("initial available lines in screen = %d", lines)
208
213
  # Allocate lines to processes that have a fixed percentage of lines set
209
214
  allocated_process_lines = lines // len(output.processes)
215
+ logger.debug("initial allocated_process_lines = %d", allocated_process_lines)
210
216
  processes_with_dynamic_lines: list[ProcessOutput] = []
211
217
  used_lines = 0
212
218
  for process_output in output.processes:
@@ -220,6 +226,7 @@ class ConsolePrinter:
220
226
 
221
227
  # Remove the used lines from the total available lines
222
228
  lines -= used_lines
229
+ logger.debug("available lines after allocating percentage lines = %d", lines)
223
230
 
224
231
  while lines:
225
232
  # Calculate how many lines each process should have based on how many processes and lines are left
@@ -231,9 +238,21 @@ class ConsolePrinter:
231
238
  # If the number of lines in this process output is less than how many terminal lines we would allocate it,
232
239
  # Set it's allocated terminal lines to the exact number of lines in its output and remove this number from
233
240
  # the total available terminal lines
241
+ logger.debug(
242
+ "process [%s] lines = %d, allocated = %d",
243
+ process_output.process.command,
244
+ process_output.lines,
245
+ allocated_process_lines,
246
+ )
234
247
  if process_output.lines < allocated_process_lines:
248
+ logger.debug(
249
+ "process [%s] lines less than allocated, reducing allocated lines to %s",
250
+ process_output.process.command,
251
+ process_output.lines,
252
+ )
235
253
  process_output.process.lines = process_output.lines
236
254
  lines -= process_output.process.lines
255
+ logger.debug("new available screen lines = %d", lines)
237
256
  recalculate_lines = True
238
257
  continue
239
258
 
@@ -243,17 +262,23 @@ class ConsolePrinter:
243
262
  # contains less lines than what we would normally allocate it. This is done so we can allocate these extra lines to the
244
263
  # other processes that contain more lines of output.
245
264
  if recalculate_lines:
265
+ logger.debug("recalcuting available screen lines")
246
266
  processes_with_dynamic_lines = processes_with_excess_output
247
267
  else:
248
268
  # All remaining processes exceed the number of terminal lines we will allocate them, so allocate them
249
269
  # their terminal lines as normal and break out of the while loop
250
270
  for process_output in processes_with_excess_output:
271
+ logger.debug(
272
+ "allocating %d lines to process [%s]", allocated_process_lines, process_output.process.command
273
+ )
251
274
  process_output.process.lines = allocated_process_lines
252
275
  lines -= allocated_process_lines
276
+ logger.debug("new available screen lines = %d", lines)
253
277
 
254
278
  # If there is any lines left, allocate them to the process that currently contains the most lines in its output, or
255
279
  # allocate them to the first process if no process contains enough lines
256
280
  if lines:
281
+ logger.debug("remaining lines after allocation to all processes = %d", lines)
257
282
  process_with_most_lines: ProcessOutput | None = None
258
283
  most_lines = 0
259
284
  for process_output in output.processes:
@@ -262,12 +287,26 @@ class ConsolePrinter:
262
287
  most_lines = process_output.process.lines
263
288
 
264
289
  if not process_with_most_lines:
265
- output.processes[0].process.lines += lines
290
+ logger.debug(
291
+ "no process found with most output, allocating remaining lines to first process [%s]",
292
+ process_output.process.command,
293
+ )
294
+ process = output.processes[0].process
295
+ process.lines += lines
296
+ logger.debug("process [%s] allocated lines = %d", process.command, process.lines)
266
297
  else:
267
- process_with_most_lines.process.lines += lines
298
+ logger.debug(
299
+ "found process [%s] with most output, allocating remaining lines",
300
+ process_output.process.command,
301
+ )
302
+ process = process_with_most_lines.process
303
+ process.lines += lines
304
+ logger.debug("process [%s] allocated lines = %d", process.command, process.lines)
268
305
 
269
306
  break
270
307
 
308
+ logger.debug("all screen lines have been allocated")
309
+
271
310
  def get_num_lines(self, line: str, columns: int | None = None) -> int:
272
311
  lines = 0
273
312
  columns = columns or constants.columns()
@@ -72,20 +72,6 @@ class Process:
72
72
 
73
73
  return buffer
74
74
 
75
- def readline(self) -> bytes:
76
- with self._lock:
77
- buffer = self._buffer
78
- if not buffer:
79
- return b""
80
-
81
- newline_index = buffer.find(b"\n")
82
- if newline_index == -1:
83
- self._buffer = b""
84
- return buffer
85
-
86
- self._buffer = buffer[newline_index + 1 :]
87
- return buffer[: newline_index + 1]
88
-
89
75
  def return_code(self) -> int | None:
90
76
  return self._process.returncode
91
77
 
@@ -108,8 +108,8 @@ class ProcessGroupManager:
108
108
  return poll
109
109
 
110
110
  def handle_signal(self, signum: int, _frame: Any) -> None:
111
- for process_group in self._process_groups:
112
- process_group.handle_signal(signum)
111
+ if self._cur_process_group is not None:
112
+ self._cur_process_group.handle_signal(signum)
113
113
 
114
114
  self._exit_code = 128 + signum
115
115
  self._interrupt_count += 1
@@ -0,0 +1,13 @@
1
+ import signal
2
+ from typing import Generator
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def mock_signal() -> Generator[MagicMock]:
10
+ # Make sure we mock the signal module so interrupts work normally when running
11
+ # the test suite via pytest
12
+ with patch.object(signal, "signal") as mock:
13
+ yield mock
@@ -1,9 +1,5 @@
1
1
  import difflib
2
- import os
3
2
  import re
4
- import signal
5
- import subprocess
6
- import time
7
3
  from typing import Sequence
8
4
 
9
5
  import pytest
@@ -135,42 +131,6 @@ class TestInteractiveMode:
135
131
  ],
136
132
  )
137
133
 
138
- @pytest.mark.parametrize(("signal", "exit_code"), [(signal.SIGINT, 130), (signal.SIGTERM, 143)])
139
- def test_handles_multiple_signals(self, signal: int, exit_code: int) -> None:
140
- process = subprocess.Popen(
141
- [
142
- "pyallel",
143
- "./tests/assets/test_handle_multiple_signals.sh",
144
- ],
145
- env=os.environ.copy(),
146
- stdout=subprocess.PIPE,
147
- stderr=subprocess.STDOUT,
148
- )
149
- time.sleep(0.3)
150
- process.send_signal(signal)
151
- time.sleep(0.1)
152
- process.send_signal(signal)
153
- assert process.wait() == exit_code
154
-
155
- @pytest.mark.parametrize(("signal", "exit_code"), [(signal.SIGINT, 130), (signal.SIGTERM, 143)])
156
- def test_handles_multiple_signals_with_dependant_commands(self, signal: int, exit_code: int) -> None:
157
- process = subprocess.Popen(
158
- [
159
- "pyallel",
160
- "./tests/assets/test_handle_multiple_signals.sh",
161
- ":::",
162
- "./tests/assets/test_handle_multiple_signals.sh",
163
- ],
164
- env=os.environ.copy(),
165
- stdout=subprocess.PIPE,
166
- stderr=subprocess.STDOUT,
167
- )
168
- time.sleep(0.3)
169
- process.send_signal(signal)
170
- time.sleep(0.1)
171
- process.send_signal(signal)
172
- assert process.wait() == exit_code
173
-
174
134
 
175
135
  class TestNonInteractiveMode:
176
136
  def test_run_single_command(self, capsys: pytest.CaptureFixture[str]) -> None:
@@ -409,51 +369,3 @@ class TestNonInteractiveMode:
409
369
  f"[printf hi; sleep {wait}; echo bye] done ✔",
410
370
  ],
411
371
  )
412
-
413
- @pytest.mark.parametrize(("signal", "exit_code"), [(signal.SIGINT, 130), (signal.SIGTERM, 143)])
414
- def test_handles_multiple_signals(self, signal: int, exit_code: int) -> None:
415
- process = subprocess.Popen(
416
- [
417
- "pyallel",
418
- "./tests/assets/test_handle_multiple_signals.sh",
419
- "-n",
420
- "-t",
421
- "--colour",
422
- "no",
423
- ],
424
- env=os.environ.copy(),
425
- stdout=subprocess.PIPE,
426
- stderr=subprocess.STDOUT,
427
- )
428
- time.sleep(0.3)
429
- process.send_signal(signal)
430
- time.sleep(0.1)
431
- process.send_signal(signal)
432
- assert process.stdout is not None
433
- out = process.stdout.read()
434
- assert process.wait() == exit_code, prettify_error(out.decode())
435
-
436
- @pytest.mark.parametrize(("signal", "exit_code"), [(signal.SIGINT, 130), (signal.SIGTERM, 143)])
437
- def test_handles_multiple_signals_with_dependant_commands(self, signal: int, exit_code: int) -> None:
438
- process = subprocess.Popen(
439
- [
440
- "pyallel",
441
- "./tests/assets/test_handle_multiple_signals.sh",
442
- ":::",
443
- "./tests/assets/test_handle_multiple_signals.sh",
444
- "-n",
445
- "-t",
446
- "--colour",
447
- "no",
448
- ],
449
- env=os.environ.copy(),
450
- stdout=subprocess.PIPE,
451
- stderr=subprocess.STDOUT,
452
- )
453
- time.sleep(0.3)
454
- process.send_signal(signal)
455
- time.sleep(0.1)
456
- process.send_signal(signal)
457
- assert process.stdout is not None
458
- out = process.stdout.read()
459
- assert process.wait() == exit_code, prettify_error(out.decode())
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
3
+ import subprocess
4
+ from unittest.mock import MagicMock, patch
4
5
 
5
6
  import pytest
6
7
 
@@ -58,42 +59,10 @@ def test_from_command_with_lines_modifier_handles_multiple_separators() -> None:
58
59
  assert process.percentage_lines == 0.5
59
60
 
60
61
 
61
- def test_read() -> None:
62
+ @patch.object(subprocess, "Popen")
63
+ def test_read(popen_mock: MagicMock) -> None:
64
+ popen_mock.return_value.stdout.read1.side_effect = [b"first\nsecond\n", b""]
62
65
  process = Process(1, "echo first; echo second")
63
66
  process.run()
64
- time.sleep(0.01)
65
67
  output = process.read()
66
68
  assert output == b"first\nsecond\n"
67
-
68
-
69
- def test_readline() -> None:
70
- process = Process(1, "echo first; echo second")
71
- process.run()
72
- time.sleep(0.01)
73
- output = process.readline()
74
- assert output == b"first\n"
75
- output = process.readline()
76
- assert output == b"second\n"
77
-
78
-
79
- def test_readline_with_read() -> None:
80
- process = Process(1, "echo first; echo second")
81
- process.run()
82
- output = process.readline()
83
- assert output == b""
84
- time.sleep(0.01)
85
- output = process.readline()
86
- assert output == b"first\n"
87
- output = process.read()
88
- assert output == b"second\n"
89
-
90
-
91
- def test_readline_handles_delayed_newline() -> None:
92
- process = Process(1, "printf first; sleep 0.1; echo second")
93
- process.run()
94
- time.sleep(0.01)
95
- output = process.readline()
96
- assert output == b"first"
97
- time.sleep(0.3)
98
- output = process.readline()
99
- assert output == b"second\n"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import time
3
+ import subprocess
4
+ from unittest.mock import MagicMock, patch
4
5
 
5
6
  import pytest
6
7
 
@@ -64,7 +65,9 @@ def test_from_commands_with_lines_modifier_exceeds_100() -> None:
64
65
  )
65
66
 
66
67
 
67
- def test_stream() -> None:
68
+ @patch.object(subprocess, "Popen")
69
+ def test_stream(popen_mock: MagicMock) -> None:
70
+ popen_mock.return_value.stdout.read1.return_value = b""
68
71
  process_group = ProcessGroup(
69
72
  id=1,
70
73
  processes=[
@@ -74,7 +77,6 @@ def test_stream() -> None:
74
77
  ],
75
78
  )
76
79
  process_group.run()
77
- time.sleep(0.1)
78
80
  output = process_group.stream()
79
81
  assert len(output.processes) == 3
80
82
 
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import signal
4
+ import subprocess
3
5
  import time
6
+ from unittest.mock import MagicMock, call, patch
4
7
 
5
8
  import pytest
6
9
 
@@ -10,7 +13,10 @@ from pyallel.process_group import ProcessGroup
10
13
  from pyallel.process_group_manager import ProcessGroupManager
11
14
 
12
15
 
13
- def test_stream() -> None:
16
+ @patch.object(subprocess, "Popen")
17
+ def test_stream(popen_mock: MagicMock) -> None:
18
+ popen_mock.return_value.stdout.read1.return_value = b""
19
+ popen_mock.return_value.poll.return_value = 0
14
20
  pg_manager = ProcessGroupManager(
15
21
  process_groups=[
16
22
  ProcessGroup(
@@ -31,7 +37,6 @@ def test_stream() -> None:
31
37
  )
32
38
  pg_manager.run()
33
39
  pg_manager.get_cur_process_group_output()
34
- time.sleep(0.1)
35
40
  output = pg_manager.stream()
36
41
  assert len(output.process_group_outputs) == 1
37
42
  assert output.process_group_outputs[1].id == 1
@@ -39,7 +44,6 @@ def test_stream() -> None:
39
44
  assert pg_manager.poll() == 0
40
45
  pg_manager.run()
41
46
  pg_manager.get_cur_process_group_output()
42
- time.sleep(0.1)
43
47
  output = pg_manager.stream()
44
48
  assert len(output.process_group_outputs) == 1
45
49
  assert output.process_group_outputs[2].id == 2
@@ -47,8 +51,8 @@ def test_stream() -> None:
47
51
  assert pg_manager.poll() == 0
48
52
 
49
53
 
50
- def test_from_args() -> None:
51
- expected_process_group_manager = ProcessGroupManager(
54
+ def test_from_args(mock_signal: MagicMock) -> None:
55
+ expected_pg_manager = ProcessGroupManager(
52
56
  process_groups=[
53
57
  ProcessGroup(
54
58
  id=1,
@@ -59,18 +63,26 @@ def test_from_args() -> None:
59
63
  )
60
64
  ]
61
65
  )
62
- process_group_manager = ProcessGroupManager.from_args("sleep 0.1", "::", "sleep 0.2")
63
- assert len(process_group_manager._process_groups) == len(expected_process_group_manager._process_groups)
66
+ pg_manager = ProcessGroupManager.from_args("sleep 0.1", "::", "sleep 0.2")
67
+ assert len(pg_manager._process_groups) == len(expected_pg_manager._process_groups)
64
68
 
65
69
  for pg1, pg2 in zip(
66
- expected_process_group_manager._process_groups,
67
- process_group_manager._process_groups,
70
+ expected_pg_manager._process_groups,
71
+ pg_manager._process_groups,
68
72
  ):
69
73
  assert len(pg1.processes) == len(pg2.processes)
70
74
 
75
+ assert mock_signal.call_count == 2
76
+ mock_signal.assert_has_calls(
77
+ [
78
+ call(signal.SIGINT, pg_manager.handle_signal),
79
+ call(signal.SIGTERM, pg_manager.handle_signal),
80
+ ]
81
+ )
82
+
71
83
 
72
84
  @pytest.mark.parametrize(
73
- ("args", "expected_process_group_manager"),
85
+ ("args", "expected_pg_manager"),
74
86
  [
75
87
  (
76
88
  ["sleep 0.1", ":::", "sleep 0.2", "::", "sleep 0.3", ":::", "sleep 0.4"],
@@ -171,13 +183,13 @@ def test_from_args() -> None:
171
183
  ),
172
184
  ],
173
185
  )
174
- def test_from_args_with_separators(args: list[str], expected_process_group_manager: ProcessGroupManager) -> None:
175
- process_group_manager = ProcessGroupManager.from_args(*args)
176
- assert len(process_group_manager._process_groups) == len(expected_process_group_manager._process_groups)
186
+ def test_from_args_with_separators(args: list[str], expected_pg_manager: ProcessGroupManager) -> None:
187
+ pg_manager = ProcessGroupManager.from_args(*args)
188
+ assert len(pg_manager._process_groups) == len(expected_pg_manager._process_groups)
177
189
 
178
190
  for pg1, pg2 in zip(
179
- expected_process_group_manager._process_groups,
180
- process_group_manager._process_groups,
191
+ expected_pg_manager._process_groups,
192
+ pg_manager._process_groups,
181
193
  ):
182
194
  assert len(pg1.processes) == len(pg2.processes)
183
195
 
@@ -188,3 +200,37 @@ def test_from_args_with_bad_separator() -> None:
188
200
  match=r"no commands provided for process group 1, did you forgot to provide them before the ::: symbol?",
189
201
  ):
190
202
  ProcessGroupManager.from_args(":::", "echo hi")
203
+
204
+
205
+ @patch.object(subprocess, "Popen")
206
+ def test_handle_signal(popen_mock: MagicMock) -> None:
207
+ popen_mock.return_value.stdout.read1.side_effect = lambda _: time.sleep(1)
208
+ pg_manager = ProcessGroupManager.from_args("sleep 0.1", "::", "sleep 0.2")
209
+ pg_manager.run()
210
+
211
+ pg_manager.handle_signal(signum=signal.SIGINT, _frame=MagicMock())
212
+
213
+ send_signal_mock: MagicMock = popen_mock.return_value.send_signal
214
+ assert send_signal_mock.call_count == 2
215
+ send_signal_mock.assert_has_calls([call(signal.SIGINT), call(signal.SIGINT)])
216
+
217
+
218
+ @patch.object(subprocess, "Popen")
219
+ def test_handle_signal_multiple(popen_mock: MagicMock) -> None:
220
+ popen_mock.return_value.stdout.read1.side_effect = lambda _: time.sleep(1)
221
+ pg_manager = ProcessGroupManager.from_args("sleep 0.1", "::", "sleep 0.2")
222
+ pg_manager.run()
223
+
224
+ pg_manager.handle_signal(signum=signal.SIGINT, _frame=MagicMock())
225
+ pg_manager.handle_signal(signum=signal.SIGINT, _frame=MagicMock())
226
+
227
+ send_signal_mock: MagicMock = popen_mock.return_value.send_signal
228
+ assert send_signal_mock.call_count == 4
229
+ send_signal_mock.assert_has_calls(
230
+ [
231
+ call(signal.SIGINT),
232
+ call(signal.SIGINT),
233
+ call(signal.SIGKILL),
234
+ call(signal.SIGKILL),
235
+ ]
236
+ )
@@ -175,7 +175,7 @@ wheels = [
175
175
 
176
176
  [[package]]
177
177
  name = "pyallel"
178
- version = "2.0.6"
178
+ version = "2.1.0"
179
179
  source = { editable = "." }
180
180
 
181
181
  [package.dev-dependencies]
Binary file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes