pyallel 1.2.5__tar.gz → 1.2.7__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.2.5
3
+ Version: 1.2.7
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.2.5"
3
+ version = "1.2.7"
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"
@@ -0,0 +1,137 @@
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 InvalidExecutableErrors
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.clear()
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 InvalidExecutableErrors 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()
@@ -0,0 +1,275 @@
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
+ truncate: bool = False,
25
+ ) -> None:
26
+ prefix = self._prefix if include_prefix else ""
27
+ if truncate:
28
+ columns = constants.COLUMNS() - len(prefix)
29
+ if get_num_lines(line, columns) > 1:
30
+ line = truncate_line(line, columns)
31
+ print(f"{prefix}{line}", end=end, flush=True)
32
+
33
+ def info(self, msg: str) -> None:
34
+ self.write(
35
+ f"{self._colours.white_bold}{msg}{self._colours.reset_colour}",
36
+ include_prefix=False,
37
+ )
38
+
39
+ def ok(self, msg: str) -> None:
40
+ self.write(
41
+ f"{self._colours.green_bold}{msg}{self._colours.reset_colour}",
42
+ include_prefix=False,
43
+ )
44
+
45
+ def warn(self, msg: str) -> None:
46
+ self.write(
47
+ f"{self._colours.yellow_bold}{msg}{self._colours.reset_colour}",
48
+ include_prefix=False,
49
+ )
50
+
51
+ def error(self, msg: str) -> None:
52
+ self.write(
53
+ f"{self._colours.red_bold}{msg}{self._colours.reset_colour}",
54
+ include_prefix=False,
55
+ )
56
+
57
+ def generate_process_output(
58
+ self,
59
+ output: ProcessOutput,
60
+ tail_output: bool = False,
61
+ include_cmd: bool = True,
62
+ include_output: bool = True,
63
+ include_progress: bool = True,
64
+ include_timer: bool | None = None,
65
+ append_newlines: bool = False,
66
+ ) -> list[tuple[bool, str, str]]:
67
+ out: list[tuple[bool, str, str]] = []
68
+ line_parts: tuple[bool, str, str]
69
+
70
+ if include_cmd:
71
+ status = self.generate_process_output_status(
72
+ output, include_progress, include_timer
73
+ )
74
+ line_parts = (False, status, "\n")
75
+ out.append(line_parts)
76
+ self._printed.append(line_parts)
77
+
78
+ if include_output:
79
+ lines = output.data.splitlines(keepends=True)
80
+
81
+ if tail_output:
82
+ status_lines = get_num_lines(status)
83
+ output_lines = output.lines - status_lines
84
+ lines = lines[-output_lines:]
85
+
86
+ for line in lines:
87
+ prefix = True
88
+ end = line[-1]
89
+ if append_newlines and end != "\n":
90
+ end = "\n"
91
+ else:
92
+ line = line[:-1]
93
+
94
+ try:
95
+ prev_line = self._printed[-1]
96
+ except IndexError:
97
+ pass
98
+ else:
99
+ if prev_line[2] != "\n":
100
+ prefix = False
101
+
102
+ line_parts = (prefix, line, end)
103
+ out.append(line_parts)
104
+ self._printed.append(line_parts)
105
+
106
+ return out
107
+
108
+ def generate_process_output_status(
109
+ self,
110
+ output: ProcessOutput,
111
+ include_progress: bool = True,
112
+ include_timer: bool | None = None,
113
+ ) -> str:
114
+ include_timer = include_timer if include_timer is not None else self._timer
115
+
116
+ passed = None
117
+ icon = ""
118
+ poll = output.process.poll()
119
+ if include_progress:
120
+ icon = constants.ICONS[self._icon]
121
+ if poll is not None:
122
+ passed = poll == 0
123
+
124
+ if passed is True:
125
+ colour = self._colours.green_bold
126
+ msg = "done"
127
+ icon = constants.TICK
128
+ elif passed is False:
129
+ colour = self._colours.red_bold
130
+ msg = "failed"
131
+ icon = constants.X
132
+ else:
133
+ colour = self._colours.white_bold
134
+ msg = "running"
135
+
136
+ if not icon:
137
+ msg += "..."
138
+
139
+ out = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{output.process.command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}"
140
+
141
+ if include_timer:
142
+ end = output.process.end
143
+ if not output.process.end:
144
+ end = time.perf_counter()
145
+ elapsed = end - output.process.start
146
+ out += f" {self._colours.dim_on}({format_time_taken(elapsed)}){self._colours.dim_off}"
147
+
148
+ return out
149
+
150
+ def generate_process_group_output(
151
+ self,
152
+ output: ProcessGroupOutput,
153
+ interrupt_count: int = 0,
154
+ tail_output: bool = True,
155
+ ) -> list[tuple[bool, str, str]]:
156
+ set_process_lines(output, interrupt_count)
157
+
158
+ for out in output.processes:
159
+ self.generate_process_output(out, tail_output, append_newlines=True)
160
+
161
+ if interrupt_count == 1:
162
+ self._printed.append((False, "", "\n"))
163
+ self._printed.append(
164
+ (
165
+ False,
166
+ f"{self._colours.yellow_bold}Interrupt!{self._colours.reset_colour}",
167
+ "\n",
168
+ )
169
+ )
170
+ elif interrupt_count == 2:
171
+ self._printed.append((False, "", "\n"))
172
+ self._printed.append(
173
+ (
174
+ False,
175
+ f"{self._colours.red_bold}Abort!{self._colours.reset_colour}",
176
+ "\n",
177
+ )
178
+ )
179
+
180
+ self._icon += 1
181
+ if self._icon == len(constants.ICONS):
182
+ self._icon = 0
183
+
184
+ return self._printed
185
+
186
+ def print_process_output(
187
+ self,
188
+ output: ProcessOutput,
189
+ tail_output: bool = False,
190
+ include_cmd: bool = True,
191
+ include_output: bool = True,
192
+ include_progress: bool = True,
193
+ include_timer: bool | None = None,
194
+ ) -> None:
195
+ for include_prefix, line, end in self.generate_process_output(
196
+ output,
197
+ tail_output,
198
+ include_cmd,
199
+ include_output,
200
+ include_progress,
201
+ include_timer,
202
+ ):
203
+ self.write(line, include_prefix, end)
204
+
205
+ def print_progress_group_output(
206
+ self,
207
+ output: ProcessGroupOutput,
208
+ interrupt_count: int = 0,
209
+ tail_output: bool = True,
210
+ ) -> None:
211
+ for include_prefix, line, end in self.generate_process_group_output(
212
+ output, interrupt_count, tail_output
213
+ ):
214
+ self.write(line, include_prefix, end, truncate=tail_output)
215
+
216
+ def clear_printed_lines(self) -> None:
217
+ # Clear all the lines that were just printed
218
+ for _, _, end in self._printed:
219
+ if end == "\n":
220
+ self.write(
221
+ f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}",
222
+ end="",
223
+ )
224
+
225
+ self.clear()
226
+
227
+ def clear(self) -> None:
228
+ self._printed.clear()
229
+
230
+
231
+ def set_process_lines(
232
+ output: ProcessGroupOutput,
233
+ interrupt_count: int = 0,
234
+ lines: int | None = None,
235
+ ) -> None:
236
+ lines = lines or constants.LINES() - 1
237
+ if interrupt_count:
238
+ lines -= 2
239
+
240
+ num_processes = len(output.processes)
241
+ remainder = lines % num_processes
242
+ tail = lines // num_processes
243
+
244
+ for out in output.processes:
245
+ out.lines = tail
246
+ if remainder:
247
+ output.processes[-1].lines += remainder
248
+
249
+
250
+ def get_num_lines(line: str, columns: int | None = None) -> int:
251
+ lines = 0
252
+ columns = columns or constants.COLUMNS()
253
+ line = constants.ANSI_ESCAPE.sub("", line)
254
+ length = len(line)
255
+ line_lines = 1
256
+ if length > columns:
257
+ line_lines = length // columns
258
+ remainder = length % columns
259
+ if remainder:
260
+ line_lines += 1
261
+ lines += 1 * line_lines
262
+ return lines
263
+
264
+
265
+ def truncate_line(line: str, columns: int | None = None) -> str:
266
+ columns = columns or constants.COLUMNS()
267
+ escaped_line = constants.ANSI_ESCAPE.sub("", line)
268
+ return "".join(escaped_line[:columns]) + "..."
269
+
270
+
271
+ def format_time_taken(time_taken: float) -> str:
272
+ time_taken = round(time_taken, 1)
273
+ seconds = time_taken % (24 * 3600)
274
+
275
+ return f"{seconds}s"
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import signal
4
+ import subprocess
5
+ import tempfile
6
+ import time
7
+ from typing import BinaryIO
8
+
9
+
10
+ class ProcessOutput:
11
+ def __init__(self, id: int, process: Process, data: str = "") -> None:
12
+ self.id = id
13
+ self.data = data
14
+ self.process = process
15
+ self.lines = -1
16
+
17
+ def merge(self, other: ProcessOutput) -> None:
18
+ self.data += other.data
19
+
20
+
21
+ class Process:
22
+ def __init__(self, id: int, command: str) -> None:
23
+ self.id = id
24
+ self.command = command
25
+ self.start = 0.0
26
+ self.end = 0.0
27
+ self._fd: BinaryIO
28
+ self._process: subprocess.Popen[bytes]
29
+
30
+ def run(self) -> None:
31
+ self.start = time.perf_counter()
32
+ fd, fd_name = tempfile.mkstemp()
33
+ self._fd = open(fd_name, "rb")
34
+ self._process = subprocess.Popen(
35
+ self.command,
36
+ stdin=subprocess.DEVNULL,
37
+ stdout=fd,
38
+ stderr=subprocess.STDOUT,
39
+ shell=True,
40
+ )
41
+
42
+ def __del__(self) -> None:
43
+ try:
44
+ self._fd.close()
45
+ except AttributeError:
46
+ pass
47
+
48
+ def poll(self) -> int | None:
49
+ poll = self._process.poll()
50
+ if poll is not None and not self.end:
51
+ self.end = time.perf_counter()
52
+ return poll
53
+
54
+ def read(self) -> bytes:
55
+ return self._fd.read()
56
+
57
+ def readline(self) -> bytes:
58
+ return self._fd.readline()
59
+
60
+ def return_code(self) -> int | None:
61
+ return self._process.returncode
62
+
63
+ def interrupt(self) -> None:
64
+ if hasattr(self, "_process"):
65
+ self._process.send_signal(signal.SIGINT)
66
+
67
+ def kill(self) -> None:
68
+ if hasattr(self, "_process"):
69
+ self._process.send_signal(signal.SIGKILL)
70
+
71
+ def wait(self) -> int:
72
+ return self._process.wait()
73
+
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+ from pyallel.errors import InvalidExecutableError, InvalidExecutableErrors
6
+ from pyallel.process import Process, ProcessOutput
7
+
8
+
9
+ class ProcessGroupOutput:
10
+ def __init__(self, id: int, processes: Sequence[ProcessOutput]) -> None:
11
+ self.id = id
12
+ self.processes = processes
13
+
14
+ def merge(self, other: ProcessGroupOutput) -> None:
15
+ for i, _ in enumerate(self.processes):
16
+ self.processes[i].merge(other.processes[i])
17
+
18
+
19
+ class ProcessGroup:
20
+ def __init__(self, id: int, processes: list[Process]) -> None:
21
+ self.id = id
22
+ self.processes = processes
23
+ self._exit_code: int = 0
24
+ self._interrupt_count: int = 0
25
+
26
+ def run(self) -> None:
27
+ for process in self.processes:
28
+ process.run()
29
+
30
+ def poll(self) -> int | None:
31
+ polls: list[int | None] = [process.poll() for process in self.processes]
32
+
33
+ running = [p for p in polls if p is None]
34
+ failed = [p for p in polls if p is not None and p > 0]
35
+
36
+ if running:
37
+ return None
38
+ elif failed:
39
+ return 1
40
+ else:
41
+ return 0
42
+
43
+ def stream(self) -> ProcessGroupOutput:
44
+ return ProcessGroupOutput(
45
+ id=self.id,
46
+ processes=[
47
+ ProcessOutput(
48
+ id=process.id, process=process, data=process.read().decode()
49
+ )
50
+ for process in self.processes
51
+ ],
52
+ )
53
+
54
+ def handle_signal(self, _signum: int) -> None:
55
+ for process in self.processes:
56
+ if self._interrupt_count == 0:
57
+ process.interrupt()
58
+ else:
59
+ process.kill()
60
+
61
+ self._interrupt_count += 1
62
+
63
+ @classmethod
64
+ def from_commands(cls, id: int, process_id: int, *commands: str) -> ProcessGroup:
65
+ processes: list[Process] = []
66
+ errors: list[InvalidExecutableError] = []
67
+
68
+ for i, command in enumerate(commands):
69
+ try:
70
+ processes.append(Process(i + process_id, command))
71
+ except InvalidExecutableError as e:
72
+ errors.append(e)
73
+
74
+ if errors:
75
+ raise InvalidExecutableErrors(*errors)
76
+
77
+ process_group = cls(id=id, processes=processes)
78
+
79
+ return process_group
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import signal
4
+ from typing import Any
5
+
6
+ from pyallel.process import ProcessOutput
7
+ from pyallel.process_group import ProcessGroupOutput, ProcessGroup
8
+
9
+
10
+ class ProcessGroupManagerOutput:
11
+ def __init__(
12
+ self,
13
+ process_group_outputs: dict[int, ProcessGroupOutput] | None = None,
14
+ cur_process_group_id: int = 1,
15
+ ) -> None:
16
+ self.process_group_outputs = process_group_outputs or {}
17
+ self.cur_process_group_id = cur_process_group_id
18
+
19
+ def merge(self, other: ProcessGroupManagerOutput) -> None:
20
+ self.cur_process_group_id = other.cur_process_group_id
21
+ for key, value in other.process_group_outputs.items():
22
+ if key in self.process_group_outputs:
23
+ self.process_group_outputs[key].merge(value)
24
+ else:
25
+ self.process_group_outputs[key] = value
26
+
27
+
28
+ class ProcessGroupManager:
29
+ def __init__(self, process_groups: list[ProcessGroup]) -> None:
30
+ self._exit_code = 0
31
+ self._interrupt_count = 0
32
+ self._cur_process_group: ProcessGroup | None = None
33
+ self._process_groups = process_groups
34
+ self._output = ProcessGroupManagerOutput(
35
+ process_group_outputs={
36
+ pg.id: ProcessGroupOutput(
37
+ id=pg.id,
38
+ processes=[ProcessOutput(id=p.id, process=p) for p in pg.processes],
39
+ )
40
+ for pg in self._process_groups
41
+ }
42
+ )
43
+
44
+ def run(self) -> None:
45
+ if self._process_groups:
46
+ self._cur_process_group = self._process_groups.pop(0)
47
+ self._cur_process_group.run()
48
+ else:
49
+ self._cur_process_group = None
50
+
51
+ def next(self) -> bool:
52
+ return True if self._cur_process_group or self._process_groups else False
53
+
54
+ def stream(self) -> ProcessGroupManagerOutput:
55
+ if self._cur_process_group is None:
56
+ return ProcessGroupManagerOutput()
57
+
58
+ output = ProcessGroupManagerOutput(
59
+ cur_process_group_id=self._cur_process_group.id,
60
+ process_group_outputs={
61
+ self._cur_process_group.id: self._cur_process_group.stream()
62
+ },
63
+ )
64
+
65
+ self._output.merge(output)
66
+
67
+ return output
68
+
69
+ def get_cur_process_group_output(self) -> ProcessGroupOutput:
70
+ if self._cur_process_group:
71
+ return self._output.process_group_outputs[self._cur_process_group.id]
72
+
73
+ raise KeyError("no current process group output")
74
+
75
+ def get_process(self, id: int) -> ProcessOutput:
76
+ for pg in self._output.process_group_outputs.values():
77
+ for process in pg.processes:
78
+ if process.id == id:
79
+ return process
80
+
81
+ raise KeyError(f"process with id '{id}' not found")
82
+
83
+ def poll(self) -> int | None:
84
+ if self._cur_process_group is None:
85
+ return 0
86
+
87
+ poll = self._cur_process_group.poll()
88
+
89
+ if poll is not None and self._exit_code:
90
+ return self._exit_code
91
+
92
+ if self._interrupt_count > 1:
93
+ return self._exit_code
94
+
95
+ return poll
96
+
97
+ def handle_signal(self, signum: int, _frame: Any) -> None:
98
+ for process_group in self._process_groups:
99
+ process_group.handle_signal(signum)
100
+
101
+ self._exit_code = 128 + signum
102
+ self._interrupt_count += 1
103
+
104
+ @classmethod
105
+ def from_args(cls, *args: str) -> ProcessGroupManager:
106
+ last_separator_index = 0
107
+ commands: list[str] = []
108
+ process_groups: list[ProcessGroup] = []
109
+ progress_group_id = 1
110
+ process_id = 1
111
+
112
+ for i, arg in enumerate(args):
113
+ if arg == ":::":
114
+ if i - 1 == 0:
115
+ pg = ProcessGroup.from_commands(
116
+ progress_group_id, process_id, args[0]
117
+ )
118
+ else:
119
+ pg = ProcessGroup.from_commands(
120
+ progress_group_id, process_id, *commands[last_separator_index:]
121
+ )
122
+
123
+ process_groups.append(pg)
124
+ process_id += len(pg.processes)
125
+ last_separator_index = i
126
+ progress_group_id += 1
127
+ continue
128
+
129
+ commands.append(arg)
130
+
131
+ if len(process_groups) > 1:
132
+ last_separator_index -= 1
133
+
134
+ process_groups.append(
135
+ ProcessGroup.from_commands(
136
+ progress_group_id, process_id, *commands[last_separator_index:]
137
+ )
138
+ )
139
+
140
+ process_group_manager = cls(process_groups=process_groups)
141
+
142
+ signal.signal(signal.SIGINT, process_group_manager.handle_signal)
143
+ signal.signal(signal.SIGTERM, process_group_manager.handle_signal)
144
+
145
+ return process_group_manager
@@ -1,88 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import importlib.metadata
4
- import sys
5
- import traceback
6
-
7
- from pyallel import constants
8
- from pyallel.colours import Colours
9
- from pyallel.errors import InvalidExecutableErrors
10
- from pyallel.parser import Arguments, create_parser
11
- from pyallel.process_group_manager import ProcessGroupManager
12
-
13
-
14
- def main_loop(
15
- *args: str,
16
- colours: Colours,
17
- interactive: bool = False,
18
- timer: bool = False,
19
- ) -> int:
20
- process_group_manager = ProcessGroupManager.from_args(
21
- *args,
22
- colours=colours,
23
- interactive=interactive,
24
- timer=timer,
25
- )
26
-
27
- return process_group_manager.stream()
28
-
29
-
30
- def run(*args: str) -> int:
31
- parser = create_parser()
32
- parsed_args = parser.parse_args(args=args, namespace=Arguments())
33
-
34
- if parsed_args.version:
35
- my_version = importlib.metadata.version("pyallel")
36
- print(my_version)
37
- return 0
38
-
39
- if not parsed_args.commands:
40
- parser.print_help()
41
- return 2
42
-
43
- colours = Colours.from_colour(parsed_args.colour)
44
-
45
- interactive = True
46
- if not parsed_args.interactive:
47
- interactive = False
48
- elif not constants.IN_TTY:
49
- interactive = False
50
-
51
- message = None
52
- try:
53
- exit_code = main_loop(
54
- *parsed_args.commands,
55
- colours=colours,
56
- interactive=interactive,
57
- timer=parsed_args.timer,
58
- )
59
- except InvalidExecutableErrors as e:
60
- exit_code = 1
61
- message = str(e)
62
- except Exception:
63
- exit_code = 1
64
- message = traceback.format_exc()
65
-
66
- if exit_code == 1:
67
- if not message:
68
- print(
69
- f"{colours.dim_on}=>{colours.dim_off} {colours.red_bold}Failed!{colours.reset_colour}"
70
- )
71
- else:
72
- print(
73
- f"{colours.dim_on}=>{colours.dim_off} {colours.red_bold}Error: {message}{colours.reset_colour}"
74
- )
75
- elif exit_code == 0:
76
- print(
77
- f"{colours.dim_on}=>{colours.dim_off} {colours.green_bold}Done!{colours.reset_colour}"
78
- )
79
-
80
- return exit_code
81
-
82
-
83
- def entry_point() -> None:
84
- sys.exit(run(*sys.argv[1:]))
85
-
86
-
87
- if __name__ == "__main__":
88
- entry_point()
@@ -1,72 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import signal
4
- import subprocess
5
- import tempfile
6
- import time
7
- from dataclasses import dataclass, field
8
- from typing import BinaryIO
9
-
10
-
11
- @dataclass
12
- class Process:
13
- id: int
14
- command: str
15
- start: float = 0.0
16
- end: float = 0.0
17
- _fd: BinaryIO | None = field(init=False, repr=False, compare=False, default=None)
18
- _process: subprocess.Popen[bytes] | None = field(
19
- init=False, repr=False, compare=False, default=None
20
- )
21
-
22
- def run(self) -> None:
23
- self.start = time.perf_counter()
24
- fd, fd_name = tempfile.mkstemp()
25
- self._fd = open(fd_name, "rb")
26
- self._process = subprocess.Popen(
27
- self.command,
28
- stdin=subprocess.DEVNULL,
29
- stdout=fd,
30
- stderr=subprocess.STDOUT,
31
- shell=True,
32
- )
33
-
34
- def __del__(self) -> None:
35
- if self._fd:
36
- self._fd.close()
37
-
38
- def poll(self) -> int | None:
39
- if self._process:
40
- poll = self._process.poll()
41
- if poll is not None and not self.end:
42
- self.end = time.perf_counter()
43
- return poll
44
- return None
45
-
46
- def read(self) -> bytes:
47
- if self._fd:
48
- return self._fd.read()
49
- return b""
50
-
51
- def readline(self) -> bytes:
52
- if self._fd:
53
- return self._fd.readline()
54
- return b""
55
-
56
- def return_code(self) -> int | None:
57
- if self._process:
58
- return self._process.returncode
59
- return None
60
-
61
- def interrupt(self) -> None:
62
- if self._process:
63
- self._process.send_signal(signal.SIGINT)
64
-
65
- def kill(self) -> None:
66
- if self._process:
67
- self._process.send_signal(signal.SIGKILL)
68
-
69
- def wait(self) -> int:
70
- if self._process:
71
- return self._process.wait()
72
- return -1
@@ -1,328 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import time
4
- from collections import defaultdict
5
- from dataclasses import dataclass, field
6
-
7
- from pyallel import constants
8
- from pyallel.colours import Colours
9
- from pyallel.errors import InvalidExecutableError, InvalidExecutableErrors
10
- from pyallel.process import Process
11
-
12
-
13
- def get_num_lines(output: str, columns: int | None = None) -> int:
14
- lines = 0
15
- columns = columns or constants.COLUMNS()
16
- for line in output.splitlines():
17
- line = constants.ANSI_ESCAPE.sub("", line)
18
- length = len(line)
19
- line_lines = 1
20
- if length > columns:
21
- line_lines = length // columns
22
- remainder = length % columns
23
- if remainder:
24
- line_lines += 1
25
- lines += 1 * line_lines
26
- return lines
27
-
28
-
29
- def format_time_taken(time_taken: float) -> str:
30
- time_taken = round(time_taken, 1)
31
- seconds = time_taken % (24 * 3600)
32
-
33
- return f"{seconds}s"
34
-
35
-
36
- @dataclass
37
- class ProcessGroup:
38
- processes: list[Process]
39
- interactive: bool = False
40
- timer: bool = False
41
- output: dict[int, list[str]] = field(default_factory=lambda: defaultdict(list))
42
- process_lines: list[int] = field(default_factory=list)
43
- completed_processes: set[int] = field(default_factory=set)
44
- exit_code: int = 0
45
- interrupt_count: int = 0
46
- passed: bool = True
47
- icon: int = 0
48
- colours: Colours = field(default_factory=Colours)
49
-
50
- def __post_init__(self) -> None:
51
- self.process_lines = [0 for _ in self.processes]
52
-
53
- def stream(self) -> int:
54
- for process in self.processes:
55
- process.run()
56
-
57
- if not self.interactive:
58
- return self.stream_non_interactive()
59
-
60
- while True:
61
- output = self.complete_output()
62
- self.icon += 1
63
- if self.icon == len(constants.ICONS):
64
- self.icon = 0
65
-
66
- print(output, end="", flush=True)
67
-
68
- # Clear all the lines that were just printed
69
- for _ in range(get_num_lines(output) - (1 if self.exit_code > 1 else 0)):
70
- print(
71
- f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}",
72
- end="",
73
- )
74
-
75
- if len(self.completed_processes) == len(self.processes):
76
- break
77
-
78
- time.sleep(0.1)
79
-
80
- print(self.complete_output(all=True), flush=True)
81
-
82
- if not self.exit_code and not self.passed:
83
- self.exit_code = 1
84
-
85
- return self.exit_code
86
-
87
- def stream_non_interactive(self) -> int:
88
- running_process = None
89
- interrupted = False
90
-
91
- while True:
92
- output = ""
93
- for process in self.processes:
94
- if (
95
- running_process is None
96
- and process.id not in self.completed_processes
97
- ):
98
- output += self._get_command_status(process)
99
- output += "\n"
100
- running_process = process
101
- elif running_process is not process:
102
- # Need to do this to properly keep track of how long all the other
103
- # commands are taking
104
- process.poll()
105
- continue
106
-
107
- process_output = process.readline().decode()
108
-
109
- if not self.output[process.id] and process_output:
110
- process_output = self._prefix(process_output)
111
- self.output[process.id].append(process_output)
112
- output += process_output
113
- elif process_output:
114
- if self.output[process.id][-1][-1] != "\n":
115
- self.output[process.id][-1] += process_output
116
- else:
117
- process_output = self._prefix(process_output)
118
- self.output[process.id].append(process_output)
119
- output += process_output
120
-
121
- if process.poll() is not None:
122
- if process.return_code() != 0:
123
- self.passed = False
124
- process_output = process.read().decode()
125
- if process_output:
126
- output += self._prefix(process_output)
127
-
128
- if (output and output[-1] != "\n") or (
129
- self.output[process.id]
130
- and self.output[process.id][-1][-1] != "\n"
131
- ):
132
- output += "\n"
133
-
134
- output += self._get_command_status(
135
- process,
136
- passed=process.return_code() == 0,
137
- timer=self.timer,
138
- )
139
- output += f"\n{self.colours.dim_on}=>{self.colours.dim_off} \n"
140
- self.completed_processes.add(process.id)
141
- running_process = None
142
-
143
- if self.interrupt_count == 0:
144
- pass
145
- elif not interrupted and self.interrupt_count == 1:
146
- if (output and output[-1] != "\n") or (
147
- self.output[process.id]
148
- and self.output[process.id][-1][-1] != "\n"
149
- ):
150
- output += "\n"
151
- output += f"{self.colours.dim_on}=>{self.colours.dim_off} \n{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.yellow_bold}Interrupt!{self.colours.reset_colour}\n{self.colours.dim_on}=>{self.colours.dim_off} \n"
152
- interrupted = True
153
-
154
- if output:
155
- print(output, end="", flush=True)
156
-
157
- if len(self.completed_processes) == len(self.processes):
158
- break
159
-
160
- time.sleep(0.01)
161
-
162
- if self.interrupt_count == 2:
163
- print(
164
- f"{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.red_bold}Abort!{self.colours.reset_colour}",
165
- flush=True,
166
- )
167
-
168
- if not self.exit_code and not self.passed:
169
- self.exit_code = 1
170
-
171
- return self.exit_code
172
-
173
- def _prefix(self, output: str, keepend: bool = True) -> str:
174
- prefixed_output = "\n".join(
175
- f"{self.colours.dim_on}=>{self.colours.dim_off} {line}{self.colours.reset_colour}"
176
- for line in output.splitlines()
177
- )
178
- if keepend and output and output[-1] == "\n":
179
- prefixed_output += "\n"
180
- return prefixed_output
181
-
182
- def _get_command_status(
183
- self,
184
- process: Process,
185
- icon: str | None = None,
186
- passed: bool | None = None,
187
- timer: bool = False,
188
- ) -> str:
189
- if passed is True:
190
- colour = self.colours.green_bold
191
- msg = "done"
192
- icon = icon or constants.TICK
193
- elif passed is False:
194
- colour = self.colours.red_bold
195
- msg = "failed"
196
- icon = icon or constants.X
197
- else:
198
- colour = self.colours.white_bold
199
- msg = "running"
200
- icon = icon or ""
201
- if not icon:
202
- msg += "..."
203
-
204
- output = f"{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.white_bold}[{self.colours.reset_colour}{self.colours.blue_bold}{process.command}{self.colours.reset_colour}{self.colours.white_bold}]{self.colours.reset_colour}{colour} {msg} {icon}{self.colours.reset_colour}"
205
-
206
- if timer:
207
- end = process.end
208
- if not process.end:
209
- end = time.perf_counter()
210
- elapsed = end - process.start
211
- output += f" {self.colours.dim_on}({format_time_taken(elapsed)}){self.colours.dim_off}"
212
-
213
- return output
214
-
215
- def handle_signal(self, signum: int) -> None:
216
- for process in self.processes:
217
- if self.interrupt_count == 0:
218
- process.interrupt()
219
- else:
220
- process.kill()
221
-
222
- self.exit_code = 128 + signum
223
- self.interrupt_count += 1
224
-
225
- @classmethod
226
- def from_commands(
227
- cls,
228
- *commands: str,
229
- colours: Colours | None = None,
230
- interactive: bool = False,
231
- timer: bool = False,
232
- ) -> ProcessGroup:
233
- colours = colours or Colours()
234
- processes: list[Process] = []
235
- errors: list[InvalidExecutableError] = []
236
-
237
- for i, command in enumerate(commands):
238
- try:
239
- processes.append(Process(i + 1, command))
240
- except InvalidExecutableError as e:
241
- errors.append(e)
242
-
243
- if errors:
244
- raise InvalidExecutableErrors(*errors)
245
-
246
- process_group = cls(
247
- processes=processes,
248
- interactive=interactive,
249
- timer=timer,
250
- colours=colours,
251
- )
252
-
253
- return process_group
254
-
255
- def complete_output(self, all: bool = False) -> str:
256
- num_processes = len(self.processes)
257
- lines = constants.LINES() - (2 * num_processes)
258
- remainder = lines % num_processes
259
- tail = lines // num_processes
260
- for i in range(num_processes):
261
- self.process_lines[i] = tail
262
- if remainder:
263
- self.process_lines[-1] += remainder - 2
264
- else:
265
- self.process_lines[-1] -= 2
266
-
267
- output = ""
268
- for i, process in enumerate(self.processes, start=1):
269
- process_output = ""
270
- if process.poll() is not None:
271
- self.completed_processes.add(process.id)
272
- if process.return_code() != 0:
273
- self.passed = False
274
- process_output += self._get_command_status(
275
- process,
276
- passed=process.return_code() == 0,
277
- timer=self.timer,
278
- )
279
- process_output += "\n"
280
- else:
281
- process_output += self._get_command_status(
282
- process,
283
- icon=constants.ICONS[self.icon],
284
- timer=self.timer,
285
- )
286
- process_output += "\n"
287
-
288
- command_lines = get_num_lines(process_output)
289
- p_output = process.read().decode()
290
- if not self.output[process.id]:
291
- self.output[process.id].append("")
292
- self.output[process.id][0] += p_output
293
- p_output = self.output[process.id][0]
294
- p_output_lines = 0
295
- if p_output:
296
- if not all:
297
- p_output = ""
298
- for line in p_output.splitlines()[-self.process_lines[i - 1] :]:
299
- if len(line) + 3 > constants.COLUMNS():
300
- p_output += f"{''.join(line[:constants.COLUMNS()-3])}\n"
301
- else:
302
- p_output += line + "\n"
303
- p_output = self._prefix(p_output)
304
- if p_output and p_output[-1] != "\n":
305
- p_output += "\n"
306
- if i != num_processes:
307
- p_output += "\n"
308
- p_output_lines = get_num_lines(p_output)
309
-
310
- if not all and (command_lines + p_output_lines) > self.process_lines[i - 1]:
311
- truncate = (command_lines + p_output_lines) - self.process_lines[i - 1]
312
- p_output = "\n".join(p_output.splitlines()[truncate:])
313
- p_output += "\n"
314
-
315
- process_output += p_output
316
- output += process_output
317
-
318
- if self.interrupt_count == 0:
319
- return output
320
-
321
- if self.interrupt_count == 1:
322
- output += (
323
- f"\n{self.colours.yellow_bold}Interrupt!{self.colours.reset_colour}"
324
- )
325
- elif self.interrupt_count == 2:
326
- output += f"\n{self.colours.red_bold}Abort!{self.colours.reset_colour}"
327
-
328
- return output
@@ -1,95 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import signal
4
- from dataclasses import dataclass, field
5
- from typing import Any
6
-
7
- from pyallel.colours import Colours
8
- from pyallel.process_group import ProcessGroup
9
-
10
-
11
- @dataclass
12
- class ProcessGroupManager:
13
- process_groups: list[ProcessGroup]
14
- interactive: bool = False
15
- colours: Colours = field(default_factory=Colours)
16
-
17
- def stream(self) -> int:
18
- exit_code = 0
19
-
20
- if not self.interactive:
21
- print(
22
- f"{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.white_bold}Running commands...{self.colours.reset_colour}\n{self.colours.dim_on}=>{self.colours.dim_off} ",
23
- flush=True,
24
- )
25
-
26
- for process_group in self.process_groups:
27
- exit_code = process_group.stream()
28
- if exit_code > 0:
29
- break
30
-
31
- return exit_code
32
-
33
- def handle_signal(self, signum: int, _frame: Any) -> None:
34
- for process_group in self.process_groups:
35
- process_group.handle_signal(signum)
36
-
37
- @classmethod
38
- def from_args(
39
- cls,
40
- *args: str,
41
- colours: Colours | None = None,
42
- interactive: bool = False,
43
- timer: bool = False,
44
- ) -> ProcessGroupManager:
45
- colours = colours or Colours()
46
- last_separator_index = 0
47
- commands: list[str] = []
48
- process_groups: list[ProcessGroup] = []
49
-
50
- for i, arg in enumerate(args):
51
- if arg == ":::":
52
- if i - 1 == 0:
53
- process_groups.append(
54
- ProcessGroup.from_commands(
55
- args[0],
56
- colours=colours,
57
- interactive=interactive,
58
- timer=timer,
59
- )
60
- )
61
- else:
62
- process_groups.append(
63
- ProcessGroup.from_commands(
64
- *commands[last_separator_index:],
65
- colours=colours,
66
- interactive=interactive,
67
- timer=timer,
68
- )
69
- )
70
-
71
- last_separator_index = i
72
- continue
73
-
74
- commands.append(arg)
75
-
76
- if len(process_groups) > 1:
77
- last_separator_index -= 1
78
-
79
- process_groups.append(
80
- ProcessGroup.from_commands(
81
- *commands[last_separator_index:],
82
- colours=colours,
83
- interactive=interactive,
84
- timer=timer,
85
- )
86
- )
87
-
88
- process_group_manager = cls(
89
- process_groups=process_groups, interactive=interactive, colours=colours
90
- )
91
-
92
- signal.signal(signal.SIGINT, process_group_manager.handle_signal)
93
- signal.signal(signal.SIGTERM, process_group_manager.handle_signal)
94
-
95
- return process_group_manager
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes