pyallel 1.1.0__tar.gz → 1.2.1__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.1.0
3
+ Version: 1.2.1
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
@@ -48,10 +48,10 @@ usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
48
48
  Run and handle the output of multiple executables in pyallel (as in parallel)
49
49
 
50
50
  positional arguments:
51
- commands list of quoted commands to run e.g "mypy ." "black ."
51
+ commands list of quoted commands to run in parallel e.g "mypy ." "black ."
52
52
 
53
53
  each command is executed inside a shell, so shell syntax is supported as
54
- if you were running the command directly in a shell, some examples are below:
54
+ if you were running the command directly in a shell, some examples are below
55
55
 
56
56
  "MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
57
57
  "mypy | tee -a mypy.log" <- use pipes to redirect output
@@ -60,6 +60,14 @@ positional arguments:
60
60
  "echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
61
61
  "pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
62
62
 
63
+ commands can be grouped using the group separator symbol (:::)
64
+
65
+ "echo boil kettle" "sleep 1" ::: "echo make coffee"
66
+
67
+ the above will print "boil kettle" and sleep for 1 second first before printing "make coffee"
68
+
69
+ command groups are ran in the sequence you provide them, and if a command group fails
70
+ (if a command fails inside the command group) the rest of the command groups in the sequence are not run
63
71
 
64
72
  options:
65
73
  -h, --help show this help message and exit
@@ -90,26 +98,53 @@ You can also build an executable with the following (executables will be written
90
98
  > The `arch=x86_64` values in the following code blocks can be replaced with `arch=aarch64` and
91
99
  > any other architecture that is supported by docker to build an executable for that given architecture
92
100
 
101
+ > [!NOTE]
102
+ > To build aarch64 binaries on an x86_64 host machine, you will need to run the following
103
+ > commands to setup qemu to allow this to work
104
+
105
+ ```bash
106
+ sudo apt-get install qemu binfmt-support qemu-user-static && \
107
+ docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
108
+ ```
109
+
93
110
  #### Build for generic linux
94
111
 
95
112
  ```bash
96
- docker build --tag pyallel --build-arg 'arch=x86_64' . && docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel
113
+ docker build --tag pyallel --build-arg 'arch=x86_64' --build-arg "uid=$(id -u)" . && \
114
+ docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel
97
115
  ```
98
116
 
99
117
  #### Build for alpine linux
100
118
 
101
119
  ```bash
102
- docker build --tag pyallel-alpine --build-arg 'arch=x86_64' --file Dockerfile.alpine . && docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel-alpine
120
+ docker build --tag pyallel-alpine --build-arg 'arch=x86_64' --build-arg "uid=$(id -u)" --file Dockerfile.alpine . && \
121
+ docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel-alpine
103
122
  ```
104
123
 
105
124
  #### Build locally
106
125
 
107
126
  ```bash
108
- python -m venv .venv && source .venv/bin/activate && pip install . -r requirements_build.txt && ./build.sh
127
+ python -m venv .venv && \
128
+ source .venv/bin/activate && \
129
+ pip install . -r requirements_build.txt && \
130
+ ./build.sh
131
+ ```
132
+
133
+ #### Build all
134
+
135
+ ```bash
136
+ ./build_all.sh
109
137
  ```
110
138
 
111
139
  ## TODOs
112
140
 
141
+ - [x] Add support to have commands depend on other commands (some commands must complete
142
+ before a given command can start)
143
+ - [ ] Add a debug mode that logs debug information to a log file
144
+ - [ ] Fix wrapping of long commands in the command status line
145
+ - [ ] Fix wrapping of very long lines in command output that pushes other commands off the
146
+ screen
147
+ - [ ] Add support to state how many lines a command can use for it's output in interactive mode
113
148
  - [ ] Maybe add support to allow the user to provide stdin for commands that request it
114
149
  (such as a REPL)
115
150
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
@@ -26,10 +26,10 @@ usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
26
26
  Run and handle the output of multiple executables in pyallel (as in parallel)
27
27
 
28
28
  positional arguments:
29
- commands list of quoted commands to run e.g "mypy ." "black ."
29
+ commands list of quoted commands to run in parallel e.g "mypy ." "black ."
30
30
 
31
31
  each command is executed inside a shell, so shell syntax is supported as
32
- if you were running the command directly in a shell, some examples are below:
32
+ if you were running the command directly in a shell, some examples are below
33
33
 
34
34
  "MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
35
35
  "mypy | tee -a mypy.log" <- use pipes to redirect output
@@ -38,6 +38,14 @@ positional arguments:
38
38
  "echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
39
39
  "pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
40
40
 
41
+ commands can be grouped using the group separator symbol (:::)
42
+
43
+ "echo boil kettle" "sleep 1" ::: "echo make coffee"
44
+
45
+ the above will print "boil kettle" and sleep for 1 second first before printing "make coffee"
46
+
47
+ command groups are ran in the sequence you provide them, and if a command group fails
48
+ (if a command fails inside the command group) the rest of the command groups in the sequence are not run
41
49
 
42
50
  options:
43
51
  -h, --help show this help message and exit
@@ -68,26 +76,53 @@ You can also build an executable with the following (executables will be written
68
76
  > The `arch=x86_64` values in the following code blocks can be replaced with `arch=aarch64` and
69
77
  > any other architecture that is supported by docker to build an executable for that given architecture
70
78
 
79
+ > [!NOTE]
80
+ > To build aarch64 binaries on an x86_64 host machine, you will need to run the following
81
+ > commands to setup qemu to allow this to work
82
+
83
+ ```bash
84
+ sudo apt-get install qemu binfmt-support qemu-user-static && \
85
+ docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
86
+ ```
87
+
71
88
  #### Build for generic linux
72
89
 
73
90
  ```bash
74
- docker build --tag pyallel --build-arg 'arch=x86_64' . && docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel
91
+ docker build --tag pyallel --build-arg 'arch=x86_64' --build-arg "uid=$(id -u)" . && \
92
+ docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel
75
93
  ```
76
94
 
77
95
  #### Build for alpine linux
78
96
 
79
97
  ```bash
80
- docker build --tag pyallel-alpine --build-arg 'arch=x86_64' --file Dockerfile.alpine . && docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel-alpine
98
+ docker build --tag pyallel-alpine --build-arg 'arch=x86_64' --build-arg "uid=$(id -u)" --file Dockerfile.alpine . && \
99
+ docker run -e 'arch=x86_64' --rm --volume "$(pwd):/src" pyallel-alpine
81
100
  ```
82
101
 
83
102
  #### Build locally
84
103
 
85
104
  ```bash
86
- python -m venv .venv && source .venv/bin/activate && pip install . -r requirements_build.txt && ./build.sh
105
+ python -m venv .venv && \
106
+ source .venv/bin/activate && \
107
+ pip install . -r requirements_build.txt && \
108
+ ./build.sh
109
+ ```
110
+
111
+ #### Build all
112
+
113
+ ```bash
114
+ ./build_all.sh
87
115
  ```
88
116
 
89
117
  ## TODOs
90
118
 
119
+ - [x] Add support to have commands depend on other commands (some commands must complete
120
+ before a given command can start)
121
+ - [ ] Add a debug mode that logs debug information to a log file
122
+ - [ ] Fix wrapping of long commands in the command status line
123
+ - [ ] Fix wrapping of very long lines in command output that pushes other commands off the
124
+ screen
125
+ - [ ] Add support to state how many lines a command can use for it's output in interactive mode
91
126
  - [ ] Maybe add support to allow the user to provide stdin for commands that request it
92
127
  (such as a REPL)
93
128
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyallel"
3
- version = "1.1.0"
3
+ version = "1.2.1"
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"
@@ -5,7 +5,7 @@ import sys
5
5
  IN_TTY = sys.stdout.isatty()
6
6
  CLEAR_LINE = "\033[2K"
7
7
  UP_LINE = "\033[1F"
8
- ANSI_ESCAPE = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")
8
+ ANSI_ESCAPE = re.compile(r"(\x9B|\x1B\[|\x1B\()[0-?]*[ -\/]*[@-~]")
9
9
 
10
10
  if IN_TTY:
11
11
 
@@ -8,23 +8,23 @@ from pyallel import constants
8
8
  from pyallel.colours import Colours
9
9
  from pyallel.errors import InvalidExecutableErrors
10
10
  from pyallel.parser import Arguments, create_parser
11
- from pyallel.process import ProcessGroup
11
+ from pyallel.process_group_manager import ProcessGroupManager
12
12
 
13
13
 
14
14
  def main_loop(
15
- *commands: str,
15
+ *args: str,
16
16
  colours: Colours,
17
17
  interactive: bool = False,
18
18
  timer: bool = False,
19
19
  ) -> int:
20
- process_group = ProcessGroup.from_commands(
21
- *commands,
20
+ process_group_manager = ProcessGroupManager.from_args(
21
+ *args,
22
22
  colours=colours,
23
23
  interactive=interactive,
24
24
  timer=timer,
25
25
  )
26
26
 
27
- return process_group.stream()
27
+ return process_group_manager.stream()
28
28
 
29
29
 
30
30
  def run(*args: str) -> int:
@@ -19,10 +19,10 @@ class Arguments:
19
19
  return msg
20
20
 
21
21
 
22
- COMMANDS_HELP = r"""list of quoted commands to run e.g "mypy ." "black ."
22
+ COMMANDS_HELP = r"""list of quoted commands to run in parallel e.g "mypy ." "black ."
23
23
 
24
24
  each command is executed inside a shell, so shell syntax is supported as
25
- if you were running the command directly in a shell, some examples are below:
25
+ if you were running the command directly in a shell, some examples are below
26
26
 
27
27
  "MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
28
28
  "mypy | tee -a mypy.log" <- use pipes to redirect output
@@ -31,6 +31,14 @@ if you were running the command directly in a shell, some examples are below:
31
31
  "echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
32
32
  "pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
33
33
 
34
+ commands can be grouped using the group separator symbol (:::)
35
+
36
+ "echo boil kettle" "sleep 1" ::: "echo make coffee"
37
+
38
+ the above will print "boil kettle" and sleep for 1 second first before printing "make coffee"
39
+
40
+ command groups are ran in the sequence you provide them, and if a command group fails
41
+ (if a command fails inside the command group) the rest of the command groups in the sequence are not run
34
42
  """
35
43
 
36
44
 
@@ -0,0 +1,72 @@
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,17 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import signal
4
- import subprocess
5
- import tempfile
6
3
  import time
7
4
  from collections import defaultdict
8
5
  from dataclasses import dataclass, field
9
- from typing import Any, BinaryIO
10
- from uuid import UUID, uuid4
11
6
 
12
7
  from pyallel import constants
13
8
  from pyallel.colours import Colours
14
9
  from pyallel.errors import InvalidExecutableError, InvalidExecutableErrors
10
+ from pyallel.process import Process
15
11
 
16
12
 
17
13
  def get_num_lines(output: str, columns: int | None = None) -> int:
@@ -36,15 +32,18 @@ class ProcessGroup:
36
32
  processes: list[Process]
37
33
  interactive: bool = False
38
34
  timer: bool = False
39
- output: dict[UUID, list[str]] = field(default_factory=lambda: defaultdict(list))
35
+ output: dict[int, list[str]] = field(default_factory=lambda: defaultdict(list))
40
36
  process_lines: list[int] = field(default_factory=list)
41
- completed_processes: set[UUID] = field(default_factory=set)
37
+ completed_processes: set[int] = field(default_factory=set)
42
38
  exit_code: int = 0
43
39
  interrupt_count: int = 0
44
40
  passed: bool = True
45
41
  icon: int = 0
46
42
  colours: Colours = field(default_factory=Colours)
47
43
 
44
+ def __post_init__(self) -> None:
45
+ self.process_lines = [0 for _ in self.processes]
46
+
48
47
  def stream(self) -> int:
49
48
  for process in self.processes:
50
49
  process.run()
@@ -83,11 +82,6 @@ class ProcessGroup:
83
82
  running_process = None
84
83
  interrupted = False
85
84
 
86
- print(
87
- 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} ",
88
- flush=True,
89
- )
90
-
91
85
  while True:
92
86
  output = ""
93
87
  for process in self.processes:
@@ -212,7 +206,7 @@ class ProcessGroup:
212
206
 
213
207
  return output
214
208
 
215
- def _handle_signal(self, signum: int, _frame: Any) -> None:
209
+ def handle_signal(self, signum: int) -> None:
216
210
  for process in self.processes:
217
211
  if self.interrupt_count == 0:
218
212
  process.interrupt()
@@ -226,16 +220,17 @@ class ProcessGroup:
226
220
  def from_commands(
227
221
  cls,
228
222
  *commands: str,
229
- colours: Colours,
223
+ colours: Colours | None = None,
230
224
  interactive: bool = False,
231
225
  timer: bool = False,
232
226
  ) -> ProcessGroup:
227
+ colours = colours or Colours()
233
228
  processes: list[Process] = []
234
229
  errors: list[InvalidExecutableError] = []
235
230
 
236
- for command in commands:
231
+ for i, command in enumerate(commands):
237
232
  try:
238
- processes.append(Process.from_command(command))
233
+ processes.append(Process(i + 1, command))
239
234
  except InvalidExecutableError as e:
240
235
  errors.append(e)
241
236
 
@@ -249,23 +244,15 @@ class ProcessGroup:
249
244
  colours=colours,
250
245
  )
251
246
 
252
- signal.signal(signal.SIGINT, process_group._handle_signal)
253
- signal.signal(signal.SIGTERM, process_group._handle_signal)
254
-
255
247
  return process_group
256
248
 
257
- def complete_output(self, tail: int = 20, all: bool = False) -> str:
249
+ def complete_output(self, all: bool = False) -> str:
258
250
  num_processes = len(self.processes)
259
251
  lines = constants.LINES() - (2 * num_processes)
260
252
  remainder = lines % num_processes
261
253
  tail = lines // num_processes
262
-
263
- if self.process_lines:
264
- self.process_lines = []
265
-
266
- for process in self.processes:
267
- self.process_lines.append(tail)
268
-
254
+ for i in range(num_processes):
255
+ self.process_lines[i] = tail
269
256
  if remainder:
270
257
  self.process_lines[-1] += remainder - 2
271
258
  else:
@@ -319,71 +306,3 @@ class ProcessGroup:
319
306
  output += f"\n{self.colours.red_bold}Abort!{self.colours.reset_colour}"
320
307
 
321
308
  return output
322
-
323
-
324
- @dataclass
325
- class Process:
326
- id: UUID = field(repr=False, compare=False)
327
- command: str
328
- start: float = 0.0
329
- end: float = 0.0
330
- _fd: BinaryIO | None = field(init=False, repr=False, compare=False, default=None)
331
- _process: subprocess.Popen[bytes] | None = field(
332
- init=False, repr=False, compare=False, default=None
333
- )
334
-
335
- def run(self) -> None:
336
- self.start = time.perf_counter()
337
- fd, fd_name = tempfile.mkstemp()
338
- self._fd = open(fd_name, "rb")
339
- self._process = subprocess.Popen(
340
- self.command,
341
- stdin=subprocess.DEVNULL,
342
- stdout=fd,
343
- stderr=subprocess.STDOUT,
344
- shell=True,
345
- )
346
-
347
- def __del__(self) -> None:
348
- if self._fd:
349
- self._fd.close()
350
-
351
- def poll(self) -> int | None:
352
- if self._process:
353
- poll = self._process.poll()
354
- if poll is not None and not self.end:
355
- self.end = time.perf_counter()
356
- return poll
357
- return None
358
-
359
- def read(self) -> bytes:
360
- if self._fd:
361
- return self._fd.read()
362
- return b""
363
-
364
- def readline(self) -> bytes:
365
- if self._fd:
366
- return self._fd.readline()
367
- return b""
368
-
369
- def return_code(self) -> int | None:
370
- if self._process:
371
- return self._process.returncode
372
- return None
373
-
374
- def interrupt(self) -> None:
375
- if self._process:
376
- self._process.send_signal(signal.SIGINT)
377
-
378
- def kill(self) -> None:
379
- if self._process:
380
- self._process.send_signal(signal.SIGKILL)
381
-
382
- def wait(self) -> int:
383
- if self._process:
384
- return self._process.wait()
385
- return -1
386
-
387
- @classmethod
388
- def from_command(cls, command: str) -> Process:
389
- return cls(id=uuid4(), command=command)
@@ -0,0 +1,95 @@
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