pyallel 1.3.3__tar.gz → 1.3.5__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.3
3
+ Version: 1.3.5
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
@@ -156,6 +156,7 @@ python -m venv .venv && \
156
156
  - [x] Add support to have commands depend on other commands (some commands must complete
157
157
  before a given command can start)
158
158
  - [x] Add support to state how many lines a command can use for it's output in interactive mode
159
+ - [x] Improve printing of output performance by only printing lines that have changed
159
160
  - [ ] Add a debug mode that logs debug information to a log file
160
161
  - [ ] Maybe add support to allow the user to provide stdin for commands that request it
161
162
  (such as a REPL)
@@ -132,6 +132,7 @@ python -m venv .venv && \
132
132
  - [x] Add support to have commands depend on other commands (some commands must complete
133
133
  before a given command can start)
134
134
  - [x] Add support to state how many lines a command can use for it's output in interactive mode
135
+ - [x] Improve printing of output performance by only printing lines that have changed
135
136
  - [ ] Add a debug mode that logs debug information to a log file
136
137
  - [ ] Maybe add support to allow the user to provide stdin for commands that request it
137
138
  (such as a REPL)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyallel"
3
- version = "1.3.3"
3
+ version = "1.3.5"
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"
@@ -3,8 +3,11 @@ import shutil
3
3
  import sys
4
4
 
5
5
  IN_TTY = sys.stdout.isatty()
6
+
7
+ # From: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797
6
8
  CLEAR_LINE = "\033[2K"
7
- UP_LINE = "\033[1F"
9
+ UP_LINE = "\033[1A\r"
10
+ DOWN_LINE = "\033[1B\r"
8
11
  ANSI_ESCAPE = re.compile(r"(\x9B|\x1B\[|\x1B\()[0-?]*[ -\/]*[@-~]")
9
12
 
10
13
  if IN_TTY:
@@ -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,419 @@
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_progress_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_progress_group_output(
308
+ output, process_group_manager._interrupt_count, tail_output=False
309
+ )
310
+ self.reset()
311
+
312
+ def print_progress_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_last_printed_lines = len(self._last_printed)
322
+
323
+ # If we don't have any last printed lines or we don't want to tail the output,
324
+ # we just print all the new lines
325
+ if not num_last_printed_lines or not tail_output:
326
+ for include_prefix, line, end in self._to_print:
327
+ self.write(
328
+ line, include_prefix, end, truncate=tail_output, columns=columns
329
+ )
330
+ else:
331
+ # Compare the number of last lines and new lines and only update what has changed.
332
+ #
333
+ # Move the cursor up the amount the lines that were last printed so we can start
334
+ # comparing the last printed lines with the new lines that were generated
335
+ print(f"\033[{num_last_printed_lines}A", end="")
336
+ for i, line_parts in enumerate(self._to_print):
337
+ # If this is a completely new line, just print it
338
+ if i >= num_last_printed_lines:
339
+ include_prefix, line, end = line_parts
340
+ self.write(
341
+ line, include_prefix, end, truncate=tail_output, columns=columns
342
+ )
343
+ # If the current line is not the same as it's newly generated version, we update the line
344
+ elif line_parts[1] != self._last_printed[i][1]:
345
+ include_prefix, line, end = line_parts
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
+ else:
353
+ # Move on to the next line as this one doesn't need to be updated
354
+ print(constants.DOWN_LINE, end="")
355
+
356
+ # Force a flush to return the cursor to the bottom immediately
357
+ print("", end="", flush=True)
358
+
359
+ self._last_printed = self._to_print.copy()
360
+ self._to_print.clear()
361
+
362
+ def clear_last_printed_lines(self) -> None:
363
+ # Clear all the lines that were just printed
364
+ print(
365
+ f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}"
366
+ * len(self._last_printed),
367
+ end="",
368
+ )
369
+
370
+ def reset(self) -> None:
371
+ self._last_printed.clear()
372
+ self._to_print.clear()
373
+
374
+
375
+ class NonInteractiveConsolePrinter(ConsolePrinter):
376
+ def __init__(self, colours: Colours | None = None, timer: bool = False) -> None:
377
+ super().__init__(colours, timer)
378
+ self._current_process: Process | None = None
379
+
380
+ def print(self, process_group_manager: ProcessGroupManager) -> None:
381
+ outputs = process_group_manager.cur_output
382
+ for pg in outputs.process_group_outputs.values():
383
+ for output in pg.processes:
384
+ if self._current_process is None:
385
+ self._current_process = output.process
386
+ output = process_group_manager.get_process(output.id)
387
+ self.print_process_output(
388
+ output, include_progress=False, include_timer=False
389
+ )
390
+ elif self._current_process is not output.process:
391
+ continue
392
+ else:
393
+ self.print_process_output(output, include_cmd=False)
394
+
395
+ if output.process.poll() is not None:
396
+ self.print_process_output(output, include_output=False)
397
+ self._current_process = None
398
+
399
+ def print_process_output(
400
+ self,
401
+ output: ProcessOutput,
402
+ tail_output: bool = False,
403
+ include_cmd: bool = True,
404
+ include_output: bool = True,
405
+ include_progress: bool = True,
406
+ include_timer: bool | None = None,
407
+ ) -> None:
408
+ for include_prefix, line, end in self.generate_process_output(
409
+ output,
410
+ tail_output,
411
+ include_cmd,
412
+ include_output,
413
+ include_progress,
414
+ include_timer,
415
+ ):
416
+ self.write(line, include_prefix, end)
417
+
418
+ # Force a flush otherwise lines that don't end in a newline character will not get printed as they are read
419
+ 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
- printer.clear_printed_lines()
23
- output = process_group_manager.get_cur_process_group_output()
24
- printer.print_progress_group_output(
25
- output, process_group_manager._interrupt_count
26
- )
27
-
28
- poll = process_group_manager.poll()
29
- if poll is not None:
30
- printer.clear_printed_lines()
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,352 +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._printed: list[tuple[bool, str, str]] = []
18
-
19
- def write(
20
- self,
21
- line: str,
22
- include_prefix: bool = False,
23
- end: str = "\n",
24
- flush: bool = False,
25
- truncate: bool = False,
26
- ) -> None:
27
- truncate_num = 0
28
- prefix = self._prefix if include_prefix else ""
29
- if prefix:
30
- truncate_num = 6
31
- if truncate:
32
- columns = constants.COLUMNS() - truncate_num
33
- if get_num_lines(line, columns) > 1:
34
- line = truncate_line(line, columns)
35
- print(f"{prefix}{line}", end=end, flush=flush)
36
-
37
- def info(self, msg: str) -> None:
38
- self.write(
39
- f"{self._colours.white_bold}{msg}{self._colours.reset_colour}",
40
- include_prefix=False,
41
- )
42
-
43
- def ok(self, msg: str) -> None:
44
- self.write(
45
- f"{self._colours.green_bold}{msg}{self._colours.reset_colour}",
46
- include_prefix=False,
47
- )
48
-
49
- def warn(self, msg: str) -> None:
50
- self.write(
51
- f"{self._colours.yellow_bold}{msg}{self._colours.reset_colour}",
52
- include_prefix=False,
53
- )
54
-
55
- def error(self, msg: str) -> None:
56
- self.write(
57
- f"{self._colours.red_bold}{msg}{self._colours.reset_colour}",
58
- include_prefix=False,
59
- )
60
-
61
- def generate_process_output(
62
- self,
63
- output: ProcessOutput,
64
- tail_output: bool = False,
65
- include_cmd: bool = True,
66
- include_output: bool = True,
67
- include_progress: bool = True,
68
- include_timer: bool | None = None,
69
- append_newlines: bool = False,
70
- ) -> list[tuple[bool, str, str]]:
71
- out: list[tuple[bool, str, str]] = []
72
- line_parts: tuple[bool, str, str]
73
-
74
- if tail_output and output.process.lines == 0:
75
- return out
76
-
77
- if include_cmd:
78
- status = self.generate_process_output_status(
79
- output, include_progress, include_timer
80
- )
81
- line_parts = (False, status, "\n")
82
- out.append(line_parts)
83
- self._printed.append(line_parts)
84
-
85
- if include_output:
86
- lines = output.data.splitlines(keepends=True)
87
-
88
- if tail_output:
89
- output_lines = output.process.lines - 1
90
- if output_lines == 0:
91
- lines = []
92
- else:
93
- lines = lines[-output_lines:]
94
-
95
- for line in lines:
96
- prefix = True
97
- end = line[-1]
98
- if append_newlines and end != "\n":
99
- end = "\n"
100
- else:
101
- line = line[:-1]
102
-
103
- try:
104
- prev_line = self._printed[-1]
105
- except IndexError:
106
- pass
107
- else:
108
- if prev_line[2] != "\n":
109
- prefix = False
110
-
111
- line_parts = (prefix, line, end)
112
- out.append(line_parts)
113
- self._printed.append(line_parts)
114
-
115
- return out
116
-
117
- def generate_process_output_status(
118
- self,
119
- output: ProcessOutput,
120
- include_progress: bool = True,
121
- include_timer: bool | None = None,
122
- ) -> str:
123
- include_timer = include_timer if include_timer is not None else self._timer
124
-
125
- passed = None
126
- icon = ""
127
- poll = output.process.poll()
128
- if include_progress:
129
- icon = constants.ICONS[self._icon]
130
- if poll is not None:
131
- passed = poll == 0
132
-
133
- if passed is True:
134
- colour = self._colours.green_bold
135
- msg = "done"
136
- icon = constants.TICK
137
- elif passed is False:
138
- colour = self._colours.red_bold
139
- msg = "failed"
140
- icon = constants.X
141
- else:
142
- colour = self._colours.white_bold
143
- msg = "running"
144
-
145
- if not icon:
146
- msg += "..."
147
-
148
- timer = ""
149
- if include_timer:
150
- end = output.process.end
151
- if not output.process.end:
152
- end = time.perf_counter()
153
- elapsed = end - output.process.start
154
- timer = f"({format_time_taken(elapsed)})"
155
-
156
- command = output.process.command
157
- if get_num_lines(output.process.command) > 1:
158
- columns = constants.COLUMNS() - (len(msg) + len(timer) + 9)
159
- command = truncate_line(command, columns)
160
-
161
- 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}"
162
-
163
- if timer:
164
- out += f" {self._colours.dim_on}{timer}{self._colours.dim_off}"
165
-
166
- return out
167
-
168
- def generate_process_group_output(
169
- self,
170
- output: ProcessGroupOutput,
171
- interrupt_count: int = 0,
172
- tail_output: bool = True,
173
- ) -> list[tuple[bool, str, str]]:
174
- set_process_lines(output, interrupt_count)
175
-
176
- for out in output.processes:
177
- self.generate_process_output(out, tail_output, append_newlines=True)
178
-
179
- if interrupt_count == 1:
180
- self._printed.append((False, "", "\n"))
181
- self._printed.append(
182
- (
183
- False,
184
- f"{self._colours.yellow_bold}Interrupt!{self._colours.reset_colour}",
185
- "\n",
186
- )
187
- )
188
- elif interrupt_count == 2:
189
- self._printed.append((False, "", "\n"))
190
- self._printed.append(
191
- (
192
- False,
193
- f"{self._colours.red_bold}Abort!{self._colours.reset_colour}",
194
- "\n",
195
- )
196
- )
197
-
198
- self._icon += 1
199
- if self._icon == len(constants.ICONS):
200
- self._icon = 0
201
-
202
- return self._printed
203
-
204
- def print_process_output(
205
- self,
206
- output: ProcessOutput,
207
- tail_output: bool = False,
208
- include_cmd: bool = True,
209
- include_output: bool = True,
210
- include_progress: bool = True,
211
- include_timer: bool | None = None,
212
- ) -> None:
213
- for include_prefix, line, end in self.generate_process_output(
214
- output,
215
- tail_output,
216
- include_cmd,
217
- include_output,
218
- include_progress,
219
- include_timer,
220
- ):
221
- self.write(line, include_prefix, end)
222
-
223
- # Force a flush otherwise lines that don't end in a newline character will not get printed as they are read
224
- self.write("", include_prefix=False, end="", flush=True)
225
-
226
- def print_progress_group_output(
227
- self,
228
- output: ProcessGroupOutput,
229
- interrupt_count: int = 0,
230
- tail_output: bool = True,
231
- ) -> None:
232
- for include_prefix, line, end in self.generate_process_group_output(
233
- output, interrupt_count, tail_output
234
- ):
235
- self.write(line, include_prefix, end, truncate=tail_output)
236
-
237
- def clear_printed_lines(self) -> None:
238
- # Clear all the lines that were just printed
239
- for _, _, end in self._printed:
240
- if end == "\n":
241
- self.write(
242
- f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}",
243
- end="",
244
- )
245
-
246
- self.reset()
247
-
248
- def reset(self) -> None:
249
- self._printed.clear()
250
-
251
-
252
- def set_process_lines(
253
- output: ProcessGroupOutput,
254
- interrupt_count: int = 0,
255
- lines: int = 0,
256
- ) -> None:
257
- lines = lines or constants.LINES() - 1
258
- if interrupt_count:
259
- lines -= 2
260
-
261
- # Allocate lines to processes that have a fixed percentage of lines set
262
- allocated_process_lines = lines // len(output.processes)
263
- processes_with_dynamic_lines: list[ProcessOutput] = []
264
- used_lines = 0
265
- for process_output in output.processes:
266
- # This process output doesn't have percentage_lines set, so skip it
267
- if not process_output.process.percentage_lines:
268
- processes_with_dynamic_lines.append(process_output)
269
- continue
270
-
271
- process_output.process.lines = int(
272
- lines * process_output.process.percentage_lines
273
- )
274
- used_lines += process_output.process.lines
275
-
276
- # Remove the used lines from the total available lines
277
- lines -= used_lines
278
-
279
- while lines:
280
- # Calculate how many lines each process should have based on how many processes and lines are left
281
- num_processes = len(processes_with_dynamic_lines) or 1
282
- allocated_process_lines = lines // num_processes
283
- processes_with_excess_output: list[ProcessOutput] = []
284
- recalculate_lines = False
285
- for process_output in processes_with_dynamic_lines:
286
- # If the number of lines in this process output is less than how many terminal lines we would allocate it,
287
- # Set it's allocated terminal lines to the exact number of lines in its output and remove this number from
288
- # the total available terminal lines
289
- if process_output.lines < allocated_process_lines:
290
- process_output.process.lines = process_output.lines
291
- lines -= process_output.process.lines
292
- recalculate_lines = True
293
- continue
294
-
295
- processes_with_excess_output.append(process_output)
296
-
297
- # We need to re-calcuate how many terminal lines we can allocate to each process if the output of at least one process
298
- # contains less lines than what we would normally allocate it. This is done so we can allocate these extra lines to the
299
- # other processes that contain more lines of output.
300
- if recalculate_lines:
301
- processes_with_dynamic_lines = processes_with_excess_output
302
- else:
303
- # All remaining processes exceed the number of terminal lines we will allocate them, so allocate them
304
- # their terminal lines as normal and break out of the while loop
305
- for process_output in processes_with_excess_output:
306
- process_output.process.lines = allocated_process_lines
307
- lines -= allocated_process_lines
308
-
309
- # If there is any lines left, allocate them to the process that currently contains the most lines in its output, or
310
- # allocate them to the first process if no process contains enough lines
311
- if lines:
312
- process_with_most_lines: ProcessOutput | None = None
313
- most_lines = 0
314
- for process_output in output.processes:
315
- if process_output.process.lines > most_lines:
316
- process_with_most_lines = process_output
317
- most_lines = process_output.process.lines
318
-
319
- if not process_with_most_lines:
320
- output.processes[0].process.lines += lines
321
- else:
322
- process_with_most_lines.process.lines += lines
323
-
324
- break
325
-
326
-
327
- def get_num_lines(line: str, columns: int | None = None) -> int:
328
- lines = 0
329
- columns = columns or constants.COLUMNS()
330
- line = constants.ANSI_ESCAPE.sub("", line)
331
- length = len(line)
332
- line_lines = 1
333
- if length > columns:
334
- line_lines = length // columns
335
- remainder = length % columns
336
- if remainder:
337
- line_lines += 1
338
- lines += 1 * line_lines
339
- return lines
340
-
341
-
342
- def truncate_line(line: str, columns: int | None = None) -> str:
343
- columns = columns or constants.COLUMNS()
344
- escaped_line = constants.ANSI_ESCAPE.sub("", line)
345
- return "".join(escaped_line[:columns]) + "..."
346
-
347
-
348
- def format_time_taken(time_taken: float) -> str:
349
- time_taken = round(time_taken, 1)
350
- seconds = time_taken % (24 * 3600)
351
-
352
- return f"{seconds}s"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes