pyallel 1.3.4__tar.gz → 1.3.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyallel
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: Run and handle the output of multiple executables in pyallel (as in parallel)
5
5
  Home-page: https://github.com/Danthewaann/pyallel
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyallel"
3
- version = "1.3.4"
3
+ version = "1.3.6"
4
4
  description = "Run and handle the output of multiple executables in pyallel (as in parallel)"
5
5
  authors = ["Daniel Black <danielcrblack@gmail.com>"]
6
6
  license = "MIT"
@@ -13,11 +13,6 @@ class Colours:
13
13
  blue_bold: str = "\033[1;34m"
14
14
  red_bold: str = "\033[1;31m"
15
15
  yellow_bold: str = "\033[1;33m"
16
- clear_line: str = "\033[2K"
17
- clear_screen: str = "\033[2J"
18
- save_cursor: str = "\033[s"
19
- restore_cursor: str = "\033[u"
20
- up_line: str = "\033[1F"
21
16
  reset_colour: str = "\033[0m"
22
17
  dim_on: str = "\033[2m"
23
18
  dim_off: str = "\033[22m"
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import sys
5
+ import time
6
+ import traceback
7
+
8
+ from pyallel import constants
9
+ from pyallel.colours import Colours
10
+ from pyallel.errors import InvalidLinesModifierError
11
+ from pyallel.parser import Arguments, create_parser
12
+ from pyallel.printer import (
13
+ InteractiveConsolePrinter,
14
+ NonInteractiveConsolePrinter,
15
+ Printer,
16
+ )
17
+ from pyallel.process_group_manager import ProcessGroupManager
18
+
19
+
20
+ def entry_point(*args: str) -> int:
21
+ args = args or tuple(sys.argv[1:])
22
+ parser = create_parser()
23
+ parsed_args = parser.parse_args(args=args, namespace=Arguments())
24
+
25
+ if parsed_args.version:
26
+ my_version = importlib.metadata.version("pyallel")
27
+ print(my_version)
28
+ return 0
29
+
30
+ if not parsed_args.commands:
31
+ parser.print_help()
32
+ return 2
33
+
34
+ colours = Colours.from_colour(parsed_args.colour)
35
+ printer: Printer
36
+ if not parsed_args.interactive or not constants.IN_TTY:
37
+ printer = NonInteractiveConsolePrinter(colours, timer=parsed_args.timer)
38
+ else:
39
+ printer = InteractiveConsolePrinter(colours, timer=parsed_args.timer)
40
+
41
+ try:
42
+ process_group_manager = ProcessGroupManager.from_args(*parsed_args.commands)
43
+ except InvalidLinesModifierError as e:
44
+ print(f"{colours.red_bold}Error: {str(e)}{colours.reset_colour}")
45
+ return 1
46
+
47
+ try:
48
+ exit_code = run(process_group_manager, printer)
49
+ except Exception:
50
+ print(
51
+ f"{colours.red_bold}Error: {traceback.format_exc()}{colours.reset_colour}"
52
+ )
53
+ return 1
54
+
55
+ if exit_code == 1:
56
+ print(f"{colours.red_bold}\nFailed!{colours.reset_colour}")
57
+ elif exit_code == 0:
58
+ print(f"{colours.green_bold}\nDone!{colours.reset_colour}")
59
+
60
+ return exit_code
61
+
62
+
63
+ def run(process_group_manager: ProcessGroupManager, printer: Printer) -> int:
64
+ process_group_manager.run()
65
+ while True:
66
+ process_group_manager.stream()
67
+ printer.print(process_group_manager)
68
+
69
+ poll = process_group_manager.poll()
70
+ if poll is not None:
71
+ # If we still have new output to print after the process group manager has completed,
72
+ # make sure to print it here before continuing
73
+ if process_group_manager.stream().has_output():
74
+ printer.print(process_group_manager)
75
+
76
+ if poll > 0:
77
+ return poll
78
+
79
+ process_group_manager.run()
80
+ if not process_group_manager.next():
81
+ return 0
82
+
83
+ time.sleep(0.1)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(entry_point())
@@ -0,0 +1,437 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Protocol
5
+
6
+ from pyallel import constants
7
+ from pyallel.colours import Colours
8
+ from pyallel.process import Process, ProcessOutput
9
+ from pyallel.process_group import ProcessGroupOutput
10
+ from pyallel.process_group_manager import ProcessGroupManager
11
+
12
+
13
+ class Printer(Protocol):
14
+ def print(self, process_group_manager: ProcessGroupManager) -> None:
15
+ """Print output obtained from the provided process group manager.
16
+
17
+ Args:
18
+ process_group_manager: manager to obtain output from
19
+ """
20
+
21
+
22
+ class ConsolePrinter:
23
+ def __init__(self, colours: Colours | None = None, timer: bool = False) -> None:
24
+ self._colours = colours or Colours()
25
+ self._timer = timer
26
+ self._prefix = f"{self._colours.dim_on}=>{self._colours.dim_off} "
27
+ self._icon = 0
28
+ self._to_print: list[tuple[bool, str, str]] = []
29
+
30
+ def write(
31
+ self,
32
+ line: str,
33
+ include_prefix: bool = False,
34
+ end: str = "\n",
35
+ flush: bool = False,
36
+ truncate: bool = False,
37
+ columns: int | None = None,
38
+ ) -> None:
39
+ truncate_num = 0
40
+ prefix = self._prefix if include_prefix else ""
41
+ columns = columns or constants.COLUMNS()
42
+ if prefix:
43
+ truncate_num = 6
44
+ if truncate:
45
+ columns = columns - truncate_num
46
+ if self.get_num_lines(line, columns) > 1:
47
+ line = self.truncate_line(line, columns)
48
+ print(f"{prefix}{line}", end=end, flush=flush)
49
+
50
+ def generate_process_output(
51
+ self,
52
+ output: ProcessOutput,
53
+ tail_output: bool = False,
54
+ include_cmd: bool = True,
55
+ include_output: bool = True,
56
+ include_progress: bool = True,
57
+ include_timer: bool | None = None,
58
+ append_newlines: bool = False,
59
+ ) -> list[tuple[bool, str, str]]:
60
+ out: list[tuple[bool, str, str]] = []
61
+ line_parts: tuple[bool, str, str]
62
+
63
+ if tail_output and output.process.lines == 0:
64
+ return out
65
+
66
+ if include_cmd:
67
+ status = self.generate_process_output_status(
68
+ output, include_progress, include_timer
69
+ )
70
+ line_parts = (False, status, "\n")
71
+ out.append(line_parts)
72
+ self._to_print.append(line_parts)
73
+
74
+ if include_output:
75
+ lines = output.data.splitlines(keepends=True)
76
+
77
+ if tail_output:
78
+ output_lines = output.process.lines - 1
79
+ if output_lines == 0:
80
+ lines = []
81
+ else:
82
+ lines = lines[-output_lines:]
83
+
84
+ for line in lines:
85
+ prefix = True
86
+ end = line[-1]
87
+ if append_newlines and end != "\n":
88
+ end = "\n"
89
+ else:
90
+ line = line[:-1]
91
+
92
+ try:
93
+ prev_line = self._to_print[-1]
94
+ except IndexError:
95
+ pass
96
+ else:
97
+ if prev_line[2] != "\n":
98
+ prefix = False
99
+
100
+ line_parts = (prefix, line, end)
101
+ out.append(line_parts)
102
+ self._to_print.append(line_parts)
103
+
104
+ return out
105
+
106
+ def generate_process_output_status(
107
+ self,
108
+ output: ProcessOutput,
109
+ include_progress: bool = True,
110
+ include_timer: bool | None = None,
111
+ ) -> str:
112
+ include_timer = include_timer if include_timer is not None else self._timer
113
+
114
+ passed = None
115
+ icon = ""
116
+ poll = output.process.poll()
117
+ if include_progress:
118
+ icon = constants.ICONS[self._icon]
119
+ if poll is not None:
120
+ passed = poll == 0
121
+
122
+ if passed is True:
123
+ colour = self._colours.green_bold
124
+ msg = "done"
125
+ icon = constants.TICK
126
+ elif passed is False:
127
+ colour = self._colours.red_bold
128
+ msg = "failed"
129
+ icon = constants.X
130
+ else:
131
+ colour = self._colours.white_bold
132
+ msg = "running"
133
+
134
+ if not icon:
135
+ msg += "..."
136
+
137
+ timer = ""
138
+ if include_timer:
139
+ end = output.process.end
140
+ if not output.process.end:
141
+ end = time.perf_counter()
142
+ elapsed = end - output.process.start
143
+ timer = f"({self.format_time_taken(elapsed)})"
144
+
145
+ command = output.process.command
146
+ if self.get_num_lines(output.process.command) > 1:
147
+ columns = constants.COLUMNS() - (len(msg) + len(timer) + 9)
148
+ command = self.truncate_line(command, columns)
149
+
150
+ out = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}"
151
+
152
+ if timer:
153
+ out += f" {self._colours.dim_on}{timer}{self._colours.dim_off}"
154
+
155
+ return out
156
+
157
+ def generate_process_group_output(
158
+ self,
159
+ output: ProcessGroupOutput,
160
+ interrupt_count: int = 0,
161
+ tail_output: bool = True,
162
+ ) -> list[tuple[bool, str, str]]:
163
+ self.set_process_lines(output, interrupt_count)
164
+
165
+ for out in output.processes:
166
+ self.generate_process_output(out, tail_output, append_newlines=True)
167
+
168
+ if interrupt_count == 1:
169
+ self._to_print.append((False, "", "\n"))
170
+ self._to_print.append(
171
+ (
172
+ False,
173
+ f"{self._colours.yellow_bold}Interrupt!{self._colours.reset_colour}",
174
+ "\n",
175
+ )
176
+ )
177
+ elif interrupt_count == 2:
178
+ self._to_print.append((False, "", "\n"))
179
+ self._to_print.append(
180
+ (
181
+ False,
182
+ f"{self._colours.red_bold}Abort!{self._colours.reset_colour}",
183
+ "\n",
184
+ )
185
+ )
186
+
187
+ self._icon += 1
188
+ if self._icon == len(constants.ICONS):
189
+ self._icon = 0
190
+
191
+ return self._to_print
192
+
193
+ def set_process_lines(
194
+ self,
195
+ output: ProcessGroupOutput,
196
+ interrupt_count: int = 0,
197
+ lines: int = 0,
198
+ ) -> None:
199
+ lines = lines or constants.LINES() - 1
200
+ if interrupt_count:
201
+ lines -= 2
202
+
203
+ # Allocate lines to processes that have a fixed percentage of lines set
204
+ allocated_process_lines = lines // len(output.processes)
205
+ processes_with_dynamic_lines: list[ProcessOutput] = []
206
+ used_lines = 0
207
+ for process_output in output.processes:
208
+ # This process output doesn't have percentage_lines set, so skip it
209
+ if not process_output.process.percentage_lines:
210
+ processes_with_dynamic_lines.append(process_output)
211
+ continue
212
+
213
+ process_output.process.lines = int(
214
+ lines * process_output.process.percentage_lines
215
+ )
216
+ used_lines += process_output.process.lines
217
+
218
+ # Remove the used lines from the total available lines
219
+ lines -= used_lines
220
+
221
+ while lines:
222
+ # Calculate how many lines each process should have based on how many processes and lines are left
223
+ num_processes = len(processes_with_dynamic_lines) or 1
224
+ allocated_process_lines = lines // num_processes
225
+ processes_with_excess_output: list[ProcessOutput] = []
226
+ recalculate_lines = False
227
+ for process_output in processes_with_dynamic_lines:
228
+ # If the number of lines in this process output is less than how many terminal lines we would allocate it,
229
+ # Set it's allocated terminal lines to the exact number of lines in its output and remove this number from
230
+ # the total available terminal lines
231
+ if process_output.lines < allocated_process_lines:
232
+ process_output.process.lines = process_output.lines
233
+ lines -= process_output.process.lines
234
+ recalculate_lines = True
235
+ continue
236
+
237
+ processes_with_excess_output.append(process_output)
238
+
239
+ # We need to re-calcuate how many terminal lines we can allocate to each process if the output of at least one process
240
+ # contains less lines than what we would normally allocate it. This is done so we can allocate these extra lines to the
241
+ # other processes that contain more lines of output.
242
+ if recalculate_lines:
243
+ processes_with_dynamic_lines = processes_with_excess_output
244
+ else:
245
+ # All remaining processes exceed the number of terminal lines we will allocate them, so allocate them
246
+ # their terminal lines as normal and break out of the while loop
247
+ for process_output in processes_with_excess_output:
248
+ process_output.process.lines = allocated_process_lines
249
+ lines -= allocated_process_lines
250
+
251
+ # If there is any lines left, allocate them to the process that currently contains the most lines in its output, or
252
+ # allocate them to the first process if no process contains enough lines
253
+ if lines:
254
+ process_with_most_lines: ProcessOutput | None = None
255
+ most_lines = 0
256
+ for process_output in output.processes:
257
+ if process_output.process.lines > most_lines:
258
+ process_with_most_lines = process_output
259
+ most_lines = process_output.process.lines
260
+
261
+ if not process_with_most_lines:
262
+ output.processes[0].process.lines += lines
263
+ else:
264
+ process_with_most_lines.process.lines += lines
265
+
266
+ break
267
+
268
+ def get_num_lines(self, line: str, columns: int | None = None) -> int:
269
+ lines = 0
270
+ columns = columns or constants.COLUMNS()
271
+ line = constants.ANSI_ESCAPE.sub("", line)
272
+ length = len(line)
273
+ line_lines = 1
274
+ if length > columns:
275
+ line_lines = length // columns
276
+ remainder = length % columns
277
+ if remainder:
278
+ line_lines += 1
279
+ lines += 1 * line_lines
280
+ return lines
281
+
282
+ def truncate_line(self, line: str, columns: int | None = None) -> str:
283
+ columns = columns or constants.COLUMNS()
284
+ escaped_line = constants.ANSI_ESCAPE.sub("", line)
285
+ return "".join(escaped_line[:columns]) + "..."
286
+
287
+ def format_time_taken(self, time_taken: float) -> str:
288
+ time_taken = round(time_taken, 1)
289
+ seconds = time_taken % (24 * 3600)
290
+
291
+ return f"{seconds}s"
292
+
293
+
294
+ class InteractiveConsolePrinter(ConsolePrinter):
295
+ def __init__(self, colours: Colours | None = None, timer: bool = False) -> None:
296
+ super().__init__(colours, timer)
297
+ self._last_printed: list[tuple[bool, str, str]] = []
298
+
299
+ def print(self, process_group_manager: ProcessGroupManager) -> None:
300
+ output = process_group_manager.get_cur_process_group_output()
301
+ self.print_process_group_output(output, process_group_manager._interrupt_count)
302
+
303
+ poll = process_group_manager.poll()
304
+ if poll is not None:
305
+ self.clear_last_printed_lines()
306
+ self.reset()
307
+ self.print_process_group_output(
308
+ output, process_group_manager._interrupt_count, tail_output=False
309
+ )
310
+ self.reset()
311
+
312
+ def print_process_group_output(
313
+ self,
314
+ output: ProcessGroupOutput,
315
+ interrupt_count: int = 0,
316
+ tail_output: bool = True,
317
+ ) -> None:
318
+ columns = constants.COLUMNS()
319
+ self.generate_process_group_output(output, interrupt_count, tail_output)
320
+
321
+ num_lines_to_print = len(self._to_print)
322
+ num_last_printed_lines = len(self._last_printed)
323
+
324
+ # If we don't have any last printed lines or we don't want to tail the output,
325
+ # we just print all the new lines
326
+ if not num_last_printed_lines or not tail_output:
327
+ for include_prefix, line, end in self._to_print:
328
+ self.write(
329
+ line, include_prefix, end, truncate=tail_output, columns=columns
330
+ )
331
+ else:
332
+ # Compare the number of last lines and new lines and only update what has changed.
333
+ #
334
+ # Move the cursor up the amount the lines that were last printed so we can start
335
+ # comparing the last printed lines with the new lines that were generated
336
+ print(f"\033[{num_last_printed_lines}A", end="")
337
+ cursor_line = 0
338
+ for cur_line, line_parts in enumerate(self._last_printed):
339
+ # If the current line is not the same as it's newly generated version, we update the line
340
+ if line_parts[1] != self._to_print[cur_line][1]:
341
+ include_prefix, line, end = self._to_print[cur_line]
342
+ # Jump to the line that needs to be changed
343
+ lines_to_jump = cur_line - cursor_line
344
+ if lines_to_jump:
345
+ print(f"\033[{lines_to_jump}B\r", end="")
346
+ # Clear the current line
347
+ print(f"{constants.CLEAR_LINE}\r", end="")
348
+ # Write the new line, this will move the cursor to the next line automatically
349
+ self.write(
350
+ line, include_prefix, end, truncate=tail_output, columns=columns
351
+ )
352
+ # Need to set the cursor_line to be the current line + 1 as the above write
353
+ # will move the cursor to the next line
354
+ cursor_line = cur_line + 1
355
+
356
+ if num_lines_to_print > num_last_printed_lines:
357
+ # Jump to the start of the new lines that needs to be printed
358
+ lines_to_jump = num_last_printed_lines - cursor_line
359
+ if lines_to_jump:
360
+ print(f"\033[{lines_to_jump}B\r", end="")
361
+
362
+ # Just print the new lines as normal
363
+ for line_parts in self._to_print[num_last_printed_lines:]:
364
+ include_prefix, line, end = line_parts
365
+ self.write(
366
+ line, include_prefix, end, truncate=tail_output, columns=columns
367
+ )
368
+ else:
369
+ # Jump to the end of the output
370
+ lines_to_jump = num_lines_to_print - cursor_line
371
+ if lines_to_jump:
372
+ print(f"\033[{lines_to_jump}B\r", end="")
373
+
374
+ # Force a flush to return the cursor to the bottom immediately
375
+ print("", end="", flush=True)
376
+
377
+ self._last_printed = self._to_print.copy()
378
+ self._to_print.clear()
379
+
380
+ def clear_last_printed_lines(self) -> None:
381
+ # Clear all the lines that were just printed
382
+ print(
383
+ f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}"
384
+ * len(self._last_printed),
385
+ end="",
386
+ )
387
+
388
+ def reset(self) -> None:
389
+ self._last_printed.clear()
390
+ self._to_print.clear()
391
+
392
+
393
+ class NonInteractiveConsolePrinter(ConsolePrinter):
394
+ def __init__(self, colours: Colours | None = None, timer: bool = False) -> None:
395
+ super().__init__(colours, timer)
396
+ self._current_process: Process | None = None
397
+
398
+ def print(self, process_group_manager: ProcessGroupManager) -> None:
399
+ outputs = process_group_manager.cur_output
400
+ for pg in outputs.process_group_outputs.values():
401
+ for output in pg.processes:
402
+ if self._current_process is None:
403
+ self._current_process = output.process
404
+ output = process_group_manager.get_process(output.id)
405
+ self.print_process_output(
406
+ output, include_progress=False, include_timer=False
407
+ )
408
+ elif self._current_process is not output.process:
409
+ continue
410
+ else:
411
+ self.print_process_output(output, include_cmd=False)
412
+
413
+ if output.process.poll() is not None:
414
+ self.print_process_output(output, include_output=False)
415
+ self._current_process = None
416
+
417
+ def print_process_output(
418
+ self,
419
+ output: ProcessOutput,
420
+ tail_output: bool = False,
421
+ include_cmd: bool = True,
422
+ include_output: bool = True,
423
+ include_progress: bool = True,
424
+ include_timer: bool | None = None,
425
+ ) -> None:
426
+ for include_prefix, line, end in self.generate_process_output(
427
+ output,
428
+ tail_output,
429
+ include_cmd,
430
+ include_output,
431
+ include_progress,
432
+ include_timer,
433
+ ):
434
+ self.write(line, include_prefix, end)
435
+
436
+ # Force a flush otherwise lines that don't end in a newline character will not get printed as they are read
437
+ print("", end="", flush=True)
@@ -24,6 +24,14 @@ class ProcessGroupManagerOutput:
24
24
  else:
25
25
  self.process_group_outputs[key] = value
26
26
 
27
+ def has_output(self) -> bool:
28
+ for pg in self.process_group_outputs.values():
29
+ for process in pg.processes:
30
+ if process.data:
31
+ return True
32
+
33
+ return False
34
+
27
35
 
28
36
  class ProcessGroupManager:
29
37
  def __init__(self, process_groups: list[ProcessGroup]) -> None:
@@ -31,7 +39,7 @@ class ProcessGroupManager:
31
39
  self._interrupt_count = 0
32
40
  self._cur_process_group: ProcessGroup | None = None
33
41
  self._process_groups = process_groups
34
- self._output = ProcessGroupManagerOutput(
42
+ self._all_output = ProcessGroupManagerOutput(
35
43
  process_group_outputs={
36
44
  pg.id: ProcessGroupOutput(
37
45
  id=pg.id,
@@ -40,6 +48,7 @@ class ProcessGroupManager:
40
48
  for pg in self._process_groups
41
49
  }
42
50
  )
51
+ self.cur_output = ProcessGroupManagerOutput()
43
52
 
44
53
  def run(self) -> None:
45
54
  if self._process_groups:
@@ -62,18 +71,19 @@ class ProcessGroupManager:
62
71
  },
63
72
  )
64
73
 
65
- self._output.merge(output)
74
+ self._all_output.merge(output)
75
+ self.cur_output = output
66
76
 
67
77
  return output
68
78
 
69
79
  def get_cur_process_group_output(self) -> ProcessGroupOutput:
70
80
  if self._cur_process_group:
71
- return self._output.process_group_outputs[self._cur_process_group.id]
81
+ return self._all_output.process_group_outputs[self._cur_process_group.id]
72
82
 
73
83
  raise KeyError("no current process group output")
74
84
 
75
85
  def get_process(self, id: int) -> ProcessOutput:
76
- for pg in self._output.process_group_outputs.values():
86
+ for pg in self._all_output.process_group_outputs.values():
77
87
  for process in pg.processes:
78
88
  if process.id == id:
79
89
  return process
@@ -1,137 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import importlib.metadata
4
- import sys
5
- import traceback
6
- import time
7
-
8
- from pyallel import constants
9
- from pyallel.colours import Colours
10
- from pyallel.errors import InvalidLinesModifierError
11
- from pyallel.parser import Arguments, create_parser
12
- from pyallel.printer import Printer
13
- from pyallel.process_group_manager import ProcessGroupManager
14
-
15
-
16
- def run_interactive(
17
- process_group_manager: ProcessGroupManager, printer: Printer
18
- ) -> int:
19
- while True:
20
- process_group_manager.stream()
21
-
22
- output = process_group_manager.get_cur_process_group_output()
23
- printer.print_progress_group_output(
24
- output, process_group_manager._interrupt_count
25
- )
26
-
27
- poll = process_group_manager.poll()
28
- if poll is not None:
29
- printer.clear_last_printed_lines()
30
- printer.reset()
31
- printer.print_progress_group_output(
32
- output, process_group_manager._interrupt_count, tail_output=False
33
- )
34
-
35
- if poll > 0:
36
- return poll
37
-
38
- printer.reset()
39
- process_group_manager.run()
40
- if not process_group_manager.next():
41
- return 0
42
-
43
- time.sleep(0.1)
44
-
45
-
46
- def run_non_interactive(
47
- process_group_manager: ProcessGroupManager, printer: Printer
48
- ) -> int:
49
- current_process = None
50
-
51
- while True:
52
- outputs = process_group_manager.stream()
53
-
54
- for pg in outputs.process_group_outputs.values():
55
- for output in pg.processes:
56
- if current_process is None:
57
- current_process = output.process
58
- output = process_group_manager.get_process(output.id)
59
- printer.print_process_output(
60
- output, include_progress=False, include_timer=False
61
- )
62
- elif current_process is not output.process:
63
- continue
64
- else:
65
- printer.print_process_output(output, include_cmd=False)
66
-
67
- if output.process.poll() is not None:
68
- printer.print_process_output(output, include_output=False)
69
- current_process = None
70
-
71
- poll = process_group_manager.poll()
72
- if poll is not None:
73
- if poll > 0:
74
- return poll
75
-
76
- process_group_manager.run()
77
- if not process_group_manager.next():
78
- return 0
79
-
80
- time.sleep(0.1)
81
-
82
-
83
- def run(*args: str) -> int:
84
- parser = create_parser()
85
- parsed_args = parser.parse_args(args=args, namespace=Arguments())
86
-
87
- if parsed_args.version:
88
- my_version = importlib.metadata.version("pyallel")
89
- print(my_version)
90
- return 0
91
-
92
- if not parsed_args.commands:
93
- parser.print_help()
94
- return 2
95
-
96
- colours = Colours.from_colour(parsed_args.colour)
97
- printer = Printer(colours, timer=parsed_args.timer)
98
-
99
- interactive = True
100
- if not parsed_args.interactive:
101
- interactive = False
102
- elif not constants.IN_TTY:
103
- interactive = False
104
-
105
- message = None
106
- try:
107
- process_group_manager = ProcessGroupManager.from_args(*parsed_args.commands)
108
- process_group_manager.run()
109
-
110
- if interactive:
111
- exit_code = run_interactive(process_group_manager, printer)
112
- else:
113
- exit_code = run_non_interactive(process_group_manager, printer)
114
- except InvalidLinesModifierError as e:
115
- exit_code = 1
116
- message = str(e)
117
- except Exception:
118
- exit_code = 1
119
- message = traceback.format_exc()
120
-
121
- if exit_code == 1:
122
- if not message:
123
- printer.error("\nFailed!")
124
- else:
125
- printer.error(f"Error: {message}")
126
- elif exit_code == 0:
127
- printer.ok("\nDone!")
128
-
129
- return exit_code
130
-
131
-
132
- def entry_point() -> None:
133
- sys.exit(run(*sys.argv[1:]))
134
-
135
-
136
- if __name__ == "__main__":
137
- entry_point()
@@ -1,392 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import time
4
-
5
- from pyallel import constants
6
- from pyallel.colours import Colours
7
- from pyallel.process import ProcessOutput
8
- from pyallel.process_group import ProcessGroupOutput
9
-
10
-
11
- class Printer:
12
- def __init__(self, colours: Colours | None = None, timer: bool = False) -> None:
13
- self._colours = colours or Colours()
14
- self._timer = timer
15
- self._prefix = f"{self._colours.dim_on}=>{self._colours.dim_off} "
16
- self._icon = 0
17
- self._last_printed: list[tuple[bool, str, str]] = []
18
- self._to_print: list[tuple[bool, str, str]] = []
19
-
20
- def write(
21
- self,
22
- line: str,
23
- include_prefix: bool = False,
24
- end: str = "\n",
25
- flush: bool = False,
26
- truncate: bool = False,
27
- columns: int | None = None,
28
- ) -> None:
29
- truncate_num = 0
30
- prefix = self._prefix if include_prefix else ""
31
- columns = columns or constants.COLUMNS()
32
- if prefix:
33
- truncate_num = 6
34
- if truncate:
35
- columns = columns - truncate_num
36
- if get_num_lines(line, columns) > 1:
37
- line = truncate_line(line, columns)
38
- print(f"{prefix}{line}", end=end, flush=flush)
39
-
40
- def info(self, msg: str) -> None:
41
- self.write(
42
- f"{self._colours.white_bold}{msg}{self._colours.reset_colour}",
43
- include_prefix=False,
44
- )
45
-
46
- def ok(self, msg: str) -> None:
47
- self.write(
48
- f"{self._colours.green_bold}{msg}{self._colours.reset_colour}",
49
- include_prefix=False,
50
- )
51
-
52
- def warn(self, msg: str) -> None:
53
- self.write(
54
- f"{self._colours.yellow_bold}{msg}{self._colours.reset_colour}",
55
- include_prefix=False,
56
- )
57
-
58
- def error(self, msg: str) -> None:
59
- self.write(
60
- f"{self._colours.red_bold}{msg}{self._colours.reset_colour}",
61
- include_prefix=False,
62
- )
63
-
64
- def generate_process_output(
65
- self,
66
- output: ProcessOutput,
67
- tail_output: bool = False,
68
- include_cmd: bool = True,
69
- include_output: bool = True,
70
- include_progress: bool = True,
71
- include_timer: bool | None = None,
72
- append_newlines: bool = False,
73
- ) -> list[tuple[bool, str, str]]:
74
- out: list[tuple[bool, str, str]] = []
75
- line_parts: tuple[bool, str, str]
76
-
77
- if tail_output and output.process.lines == 0:
78
- return out
79
-
80
- if include_cmd:
81
- status = self.generate_process_output_status(
82
- output, include_progress, include_timer
83
- )
84
- line_parts = (False, status, "\n")
85
- out.append(line_parts)
86
- self._to_print.append(line_parts)
87
-
88
- if include_output:
89
- lines = output.data.splitlines(keepends=True)
90
-
91
- if tail_output:
92
- output_lines = output.process.lines - 1
93
- if output_lines == 0:
94
- lines = []
95
- else:
96
- lines = lines[-output_lines:]
97
-
98
- for line in lines:
99
- prefix = True
100
- end = line[-1]
101
- if append_newlines and end != "\n":
102
- end = "\n"
103
- else:
104
- line = line[:-1]
105
-
106
- try:
107
- prev_line = self._to_print[-1]
108
- except IndexError:
109
- pass
110
- else:
111
- if prev_line[2] != "\n":
112
- prefix = False
113
-
114
- line_parts = (prefix, line, end)
115
- out.append(line_parts)
116
- self._to_print.append(line_parts)
117
-
118
- return out
119
-
120
- def generate_process_output_status(
121
- self,
122
- output: ProcessOutput,
123
- include_progress: bool = True,
124
- include_timer: bool | None = None,
125
- ) -> str:
126
- include_timer = include_timer if include_timer is not None else self._timer
127
-
128
- passed = None
129
- icon = ""
130
- poll = output.process.poll()
131
- if include_progress:
132
- icon = constants.ICONS[self._icon]
133
- if poll is not None:
134
- passed = poll == 0
135
-
136
- if passed is True:
137
- colour = self._colours.green_bold
138
- msg = "done"
139
- icon = constants.TICK
140
- elif passed is False:
141
- colour = self._colours.red_bold
142
- msg = "failed"
143
- icon = constants.X
144
- else:
145
- colour = self._colours.white_bold
146
- msg = "running"
147
-
148
- if not icon:
149
- msg += "..."
150
-
151
- timer = ""
152
- if include_timer:
153
- end = output.process.end
154
- if not output.process.end:
155
- end = time.perf_counter()
156
- elapsed = end - output.process.start
157
- timer = f"({format_time_taken(elapsed)})"
158
-
159
- command = output.process.command
160
- if get_num_lines(output.process.command) > 1:
161
- columns = constants.COLUMNS() - (len(msg) + len(timer) + 9)
162
- command = truncate_line(command, columns)
163
-
164
- out = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}"
165
-
166
- if timer:
167
- out += f" {self._colours.dim_on}{timer}{self._colours.dim_off}"
168
-
169
- return out
170
-
171
- def generate_process_group_output(
172
- self,
173
- output: ProcessGroupOutput,
174
- interrupt_count: int = 0,
175
- tail_output: bool = True,
176
- ) -> list[tuple[bool, str, str]]:
177
- set_process_lines(output, interrupt_count)
178
-
179
- for out in output.processes:
180
- self.generate_process_output(out, tail_output, append_newlines=True)
181
-
182
- if interrupt_count == 1:
183
- self._to_print.append((False, "", "\n"))
184
- self._to_print.append(
185
- (
186
- False,
187
- f"{self._colours.yellow_bold}Interrupt!{self._colours.reset_colour}",
188
- "\n",
189
- )
190
- )
191
- elif interrupt_count == 2:
192
- self._to_print.append((False, "", "\n"))
193
- self._to_print.append(
194
- (
195
- False,
196
- f"{self._colours.red_bold}Abort!{self._colours.reset_colour}",
197
- "\n",
198
- )
199
- )
200
-
201
- self._icon += 1
202
- if self._icon == len(constants.ICONS):
203
- self._icon = 0
204
-
205
- return self._to_print
206
-
207
- def print_process_output(
208
- self,
209
- output: ProcessOutput,
210
- tail_output: bool = False,
211
- include_cmd: bool = True,
212
- include_output: bool = True,
213
- include_progress: bool = True,
214
- include_timer: bool | None = None,
215
- ) -> None:
216
- for include_prefix, line, end in self.generate_process_output(
217
- output,
218
- tail_output,
219
- include_cmd,
220
- include_output,
221
- include_progress,
222
- include_timer,
223
- ):
224
- self.write(line, include_prefix, end)
225
-
226
- # Force a flush otherwise lines that don't end in a newline character will not get printed as they are read
227
- print("", end="", flush=True)
228
-
229
- def print_progress_group_output(
230
- self,
231
- output: ProcessGroupOutput,
232
- interrupt_count: int = 0,
233
- tail_output: bool = True,
234
- ) -> None:
235
- columns = constants.COLUMNS()
236
- self.generate_process_group_output(output, interrupt_count, tail_output)
237
-
238
- num_last_printed_lines = len(self._last_printed)
239
-
240
- # If we don't have any last printed lines or we don't want to tail the output,
241
- # we just print all the new lines
242
- if not num_last_printed_lines or not tail_output:
243
- for include_prefix, line, end in self._to_print:
244
- self.write(
245
- line, include_prefix, end, truncate=tail_output, columns=columns
246
- )
247
- else:
248
- # Compare the number of last lines and new lines and only update what has changed.
249
- #
250
- # Move the cursor up the amount the lines that were last printed so we can start
251
- # comparing the last printed lines with the new lines that were generated
252
- print(f"\033[{num_last_printed_lines}A", end="")
253
- for i, line_parts in enumerate(self._to_print):
254
- # If this is a completely new line, just print it
255
- if i >= num_last_printed_lines:
256
- include_prefix, line, end = line_parts
257
- self.write(
258
- line, include_prefix, end, truncate=tail_output, columns=columns
259
- )
260
- # If the current line is not the same as it's newly generated version, we update the line
261
- elif line_parts[1] != self._last_printed[i][1]:
262
- include_prefix, line, end = line_parts
263
- # Clear the current line
264
- print(f"{constants.CLEAR_LINE}\r", end="")
265
- # Write the new line, this will move the cursor to the next line automatically
266
- self.write(
267
- line, include_prefix, end, truncate=tail_output, columns=columns
268
- )
269
- else:
270
- # Move on to the next line as this one doesn't need to be updated
271
- print(constants.DOWN_LINE, end="")
272
-
273
- # Force a flush to return the cursor to the bottom immediately
274
- print("", end="", flush=True)
275
-
276
- self._last_printed = self._to_print.copy()
277
- self._to_print.clear()
278
-
279
- def clear_last_printed_lines(self) -> None:
280
- # Clear all the lines that were just printed
281
- print(
282
- f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}"
283
- * len(self._last_printed),
284
- end="",
285
- )
286
-
287
- def reset(self) -> None:
288
- self._last_printed.clear()
289
- self._to_print.clear()
290
-
291
-
292
- def set_process_lines(
293
- output: ProcessGroupOutput,
294
- interrupt_count: int = 0,
295
- lines: int = 0,
296
- ) -> None:
297
- lines = lines or constants.LINES() - 1
298
- if interrupt_count:
299
- lines -= 2
300
-
301
- # Allocate lines to processes that have a fixed percentage of lines set
302
- allocated_process_lines = lines // len(output.processes)
303
- processes_with_dynamic_lines: list[ProcessOutput] = []
304
- used_lines = 0
305
- for process_output in output.processes:
306
- # This process output doesn't have percentage_lines set, so skip it
307
- if not process_output.process.percentage_lines:
308
- processes_with_dynamic_lines.append(process_output)
309
- continue
310
-
311
- process_output.process.lines = int(
312
- lines * process_output.process.percentage_lines
313
- )
314
- used_lines += process_output.process.lines
315
-
316
- # Remove the used lines from the total available lines
317
- lines -= used_lines
318
-
319
- while lines:
320
- # Calculate how many lines each process should have based on how many processes and lines are left
321
- num_processes = len(processes_with_dynamic_lines) or 1
322
- allocated_process_lines = lines // num_processes
323
- processes_with_excess_output: list[ProcessOutput] = []
324
- recalculate_lines = False
325
- for process_output in processes_with_dynamic_lines:
326
- # If the number of lines in this process output is less than how many terminal lines we would allocate it,
327
- # Set it's allocated terminal lines to the exact number of lines in its output and remove this number from
328
- # the total available terminal lines
329
- if process_output.lines < allocated_process_lines:
330
- process_output.process.lines = process_output.lines
331
- lines -= process_output.process.lines
332
- recalculate_lines = True
333
- continue
334
-
335
- processes_with_excess_output.append(process_output)
336
-
337
- # We need to re-calcuate how many terminal lines we can allocate to each process if the output of at least one process
338
- # contains less lines than what we would normally allocate it. This is done so we can allocate these extra lines to the
339
- # other processes that contain more lines of output.
340
- if recalculate_lines:
341
- processes_with_dynamic_lines = processes_with_excess_output
342
- else:
343
- # All remaining processes exceed the number of terminal lines we will allocate them, so allocate them
344
- # their terminal lines as normal and break out of the while loop
345
- for process_output in processes_with_excess_output:
346
- process_output.process.lines = allocated_process_lines
347
- lines -= allocated_process_lines
348
-
349
- # If there is any lines left, allocate them to the process that currently contains the most lines in its output, or
350
- # allocate them to the first process if no process contains enough lines
351
- if lines:
352
- process_with_most_lines: ProcessOutput | None = None
353
- most_lines = 0
354
- for process_output in output.processes:
355
- if process_output.process.lines > most_lines:
356
- process_with_most_lines = process_output
357
- most_lines = process_output.process.lines
358
-
359
- if not process_with_most_lines:
360
- output.processes[0].process.lines += lines
361
- else:
362
- process_with_most_lines.process.lines += lines
363
-
364
- break
365
-
366
-
367
- def get_num_lines(line: str, columns: int | None = None) -> int:
368
- lines = 0
369
- columns = columns or constants.COLUMNS()
370
- line = constants.ANSI_ESCAPE.sub("", line)
371
- length = len(line)
372
- line_lines = 1
373
- if length > columns:
374
- line_lines = length // columns
375
- remainder = length % columns
376
- if remainder:
377
- line_lines += 1
378
- lines += 1 * line_lines
379
- return lines
380
-
381
-
382
- def truncate_line(line: str, columns: int | None = None) -> str:
383
- columns = columns or constants.COLUMNS()
384
- escaped_line = constants.ANSI_ESCAPE.sub("", line)
385
- return "".join(escaped_line[:columns]) + "..."
386
-
387
-
388
- def format_time_taken(time_taken: float) -> str:
389
- time_taken = round(time_taken, 1)
390
- seconds = time_taken % (24 * 3600)
391
-
392
- return f"{seconds}s"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes