pyallel 1.0.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyallel
3
- Version: 1.0.1
3
+ Version: 1.2.0
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
@@ -43,24 +43,38 @@ pip install pyallel
43
43
  Once installed, you can run `pyallel` to see usage information, like so:
44
44
 
45
45
  ```
46
- usage: pyallel [-h] [-t] [-n] [-V] [-v] [--colour {yes,no,auto}] [commands ...]
46
+ usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
47
47
 
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
- can provide environment variables to each command like so:
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
55
 
55
- "MYPY_FORCE_COLOR=1 mypy ."
56
+ "MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
57
+ "mypy | tee -a mypy.log" <- use pipes to redirect output
58
+ "cat > test.log < other.log" <- use input and output redirection
59
+ "mypy .; pytest ." <- run commands one at a time in sequence
60
+ "echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
61
+ "pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
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
56
71
 
57
72
  options:
58
73
  -h, --help show this help message and exit
59
74
  -t, --no-timer don't time how long each command is taking
60
75
  -n, --non-interactive
61
76
  run in non-interactive mode
62
- -V, --verbose run in verbose mode
63
- -v, --version print version and exit
77
+ -V, --version print version and exit
64
78
  --colour {yes,no,auto}
65
79
  colour terminal output, defaults to "auto"
66
80
  ```
@@ -104,8 +118,11 @@ python -m venv .venv && source .venv/bin/activate && pip install . -r requiremen
104
118
 
105
119
  ## TODOs
106
120
 
107
- - [ ] Maybe add support for allowing commands to contain shell idiom's (such as piping
108
- e.g. `echo hi | tee test.log`)
121
+ - [x] Add support to have commands depend on other commands (some commands must complete
122
+ before a given command can start)
123
+ - [ ] Add support to state how many lines a command can use for it's output in interactive mode
124
+ - [ ] Maybe add support to allow the user to provide stdin for commands that request it
125
+ (such as a REPL)
109
126
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
110
127
  `errorformat`)
111
128
  - [ ] Allow list of files to be provided to supply as input arguments to each command
@@ -21,24 +21,38 @@ pip install pyallel
21
21
  Once installed, you can run `pyallel` to see usage information, like so:
22
22
 
23
23
  ```
24
- usage: pyallel [-h] [-t] [-n] [-V] [-v] [--colour {yes,no,auto}] [commands ...]
24
+ usage: pyallel [-h] [-t] [-n] [-V] [--colour {yes,no,auto}] [commands ...]
25
25
 
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
- can provide environment variables to each command like so:
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
33
 
33
- "MYPY_FORCE_COLOR=1 mypy ."
34
+ "MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
35
+ "mypy | tee -a mypy.log" <- use pipes to redirect output
36
+ "cat > test.log < other.log" <- use input and output redirection
37
+ "mypy .; pytest ." <- run commands one at a time in sequence
38
+ "echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
39
+ "pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
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
34
49
 
35
50
  options:
36
51
  -h, --help show this help message and exit
37
52
  -t, --no-timer don't time how long each command is taking
38
53
  -n, --non-interactive
39
54
  run in non-interactive mode
40
- -V, --verbose run in verbose mode
41
- -v, --version print version and exit
55
+ -V, --version print version and exit
42
56
  --colour {yes,no,auto}
43
57
  colour terminal output, defaults to "auto"
44
58
  ```
@@ -82,8 +96,11 @@ python -m venv .venv && source .venv/bin/activate && pip install . -r requiremen
82
96
 
83
97
  ## TODOs
84
98
 
85
- - [ ] Maybe add support for allowing commands to contain shell idiom's (such as piping
86
- e.g. `echo hi | tee test.log`)
99
+ - [x] Add support to have commands depend on other commands (some commands must complete
100
+ before a given command can start)
101
+ - [ ] Add support to state how many lines a command can use for it's output in interactive mode
102
+ - [ ] Maybe add support to allow the user to provide stdin for commands that request it
103
+ (such as a REPL)
87
104
  - [ ] Add custom parsing of command output to support filtering for errors (like vim's
88
105
  `errorformat`)
89
106
  - [ ] Allow list of files to be provided to supply as input arguments to each command
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyallel"
3
- version = "1.0.1"
3
+ version = "1.2.0"
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"
@@ -8,25 +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
- verbose: bool = False,
20
19
  ) -> int:
21
- process_group = ProcessGroup.from_commands(
22
- *commands,
20
+ process_group_manager = ProcessGroupManager.from_args(
21
+ *args,
23
22
  colours=colours,
24
23
  interactive=interactive,
25
24
  timer=timer,
26
- verbose=verbose,
27
25
  )
28
26
 
29
- return process_group.stream()
27
+ return process_group_manager.stream()
30
28
 
31
29
 
32
30
  def run(*args: str) -> int:
@@ -57,7 +55,6 @@ def run(*args: str) -> int:
57
55
  colours=colours,
58
56
  interactive=interactive,
59
57
  timer=parsed_args.timer,
60
- verbose=parsed_args.verbose,
61
58
  )
62
59
  except InvalidExecutableErrors as e:
63
60
  exit_code = 1
@@ -9,7 +9,6 @@ class Arguments:
9
9
  commands: list[str]
10
10
  interactive: bool
11
11
  timer: bool
12
- verbose: bool
13
12
  version: bool
14
13
 
15
14
  def __repr__(self) -> str:
@@ -20,11 +19,26 @@ class Arguments:
20
19
  return msg
21
20
 
22
21
 
23
- COMMANDS_HELP = """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 ."
24
23
 
25
- can provide environment variables to each command like so:
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
26
26
 
27
- "MYPY_FORCE_COLOR=1 mypy ."
27
+ "MYPY_FORCE_COLOR=1 mypy ." <- provide environment variables
28
+ "mypy | tee -a mypy.log" <- use pipes to redirect output
29
+ "cat > test.log < other.log" <- use input and output redirection
30
+ "mypy .; pytest ." <- run commands one at a time in sequence
31
+ "echo \$SHELL" or "\$(echo mypy .)" <- expand variables and commands to evaluate (must be escaped)
32
+ "pytest . && mypy . || echo failed!" <- use AND (&&) and OR (||) to run commands conditionally
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
28
42
  """
29
43
 
30
44
 
@@ -57,13 +71,6 @@ def create_parser() -> ArgumentParser:
57
71
  )
58
72
  parser.add_argument(
59
73
  "-V",
60
- "--verbose",
61
- help="run in verbose mode",
62
- action="store_true",
63
- default=False,
64
- )
65
- parser.add_argument(
66
- "-v",
67
74
  "--version",
68
75
  help="print version and exit",
69
76
  action="store_true",
@@ -0,0 +1,76 @@
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
73
+
74
+ @classmethod
75
+ def from_command(cls, id: int, command: str) -> Process:
76
+ return cls(id=id, command=command)
@@ -1,20 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- import os
4
- import shlex
5
- import shutil
6
- import signal
7
- import subprocess
8
- import tempfile
9
3
  import time
10
4
  from collections import defaultdict
11
5
  from dataclasses import dataclass, field
12
- from typing import Any, BinaryIO
13
- from uuid import UUID, uuid4
14
6
 
15
7
  from pyallel import constants
16
8
  from pyallel.colours import Colours
17
9
  from pyallel.errors import InvalidExecutableError, InvalidExecutableErrors
10
+ from pyallel.process import Process
18
11
 
19
12
 
20
13
  def get_num_lines(output: str, columns: int | None = None) -> int:
@@ -39,10 +32,9 @@ class ProcessGroup:
39
32
  processes: list[Process]
40
33
  interactive: bool = False
41
34
  timer: bool = False
42
- verbose: bool = False
43
- output: dict[UUID, list[str]] = field(default_factory=lambda: defaultdict(list))
35
+ output: dict[int, list[str]] = field(default_factory=lambda: defaultdict(list))
44
36
  process_lines: list[int] = field(default_factory=list)
45
- completed_processes: set[UUID] = field(default_factory=set)
37
+ completed_processes: set[int] = field(default_factory=set)
46
38
  exit_code: int = 0
47
39
  interrupt_count: int = 0
48
40
  passed: bool = True
@@ -87,11 +79,6 @@ class ProcessGroup:
87
79
  running_process = None
88
80
  interrupted = False
89
81
 
90
- print(
91
- 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} ",
92
- flush=True,
93
- )
94
-
95
82
  while True:
96
83
  output = ""
97
84
  for process in self.processes:
@@ -99,7 +86,7 @@ class ProcessGroup:
99
86
  running_process is None
100
87
  and process.id not in self.completed_processes
101
88
  ):
102
- output += self._get_command_status(process, verbose=self.verbose)
89
+ output += self._get_command_status(process)
103
90
  output += "\n"
104
91
  running_process = process
105
92
  elif running_process is not process:
@@ -138,7 +125,6 @@ class ProcessGroup:
138
125
  output += self._get_command_status(
139
126
  process,
140
127
  passed=process.return_code() == 0,
141
- verbose=self.verbose,
142
128
  timer=self.timer,
143
129
  )
144
130
  output += f"\n{self.colours.dim_on}=>{self.colours.dim_off} \n"
@@ -189,7 +175,6 @@ class ProcessGroup:
189
175
  process: Process,
190
176
  icon: str | None = None,
191
177
  passed: bool | None = None,
192
- verbose: bool = False,
193
178
  timer: bool = False,
194
179
  ) -> str:
195
180
  if passed is True:
@@ -207,12 +192,7 @@ class ProcessGroup:
207
192
  if not icon:
208
193
  msg += "..."
209
194
 
210
- output = f"{self.colours.dim_on}=>{self.colours.dim_off} {self.colours.white_bold}[{self.colours.reset_colour}{self.colours.blue_bold}{process.name}"
211
-
212
- if verbose:
213
- output += f" {' '.join(process.args)}"
214
-
215
- output += f"{self.colours.reset_colour}{self.colours.white_bold}]{self.colours.reset_colour}{colour} {msg} {icon}{self.colours.reset_colour}"
195
+ 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}"
216
196
 
217
197
  if timer:
218
198
  end = process.end
@@ -223,7 +203,7 @@ class ProcessGroup:
223
203
 
224
204
  return output
225
205
 
226
- def _handle_signal(self, signum: int, _frame: Any) -> None:
206
+ def handle_signal(self, signum: int) -> None:
227
207
  for process in self.processes:
228
208
  if self.interrupt_count == 0:
229
209
  process.interrupt()
@@ -237,17 +217,17 @@ class ProcessGroup:
237
217
  def from_commands(
238
218
  cls,
239
219
  *commands: str,
240
- colours: Colours,
220
+ colours: Colours | None = None,
241
221
  interactive: bool = False,
242
222
  timer: bool = False,
243
- verbose: bool = False,
244
223
  ) -> ProcessGroup:
224
+ colours = colours or Colours()
245
225
  processes: list[Process] = []
246
226
  errors: list[InvalidExecutableError] = []
247
227
 
248
- for command in commands:
228
+ for i, command in enumerate(commands):
249
229
  try:
250
- processes.append(Process.from_command(command))
230
+ processes.append(Process.from_command(i + 1, command))
251
231
  except InvalidExecutableError as e:
252
232
  errors.append(e)
253
233
 
@@ -258,13 +238,9 @@ class ProcessGroup:
258
238
  processes=processes,
259
239
  interactive=interactive,
260
240
  timer=timer,
261
- verbose=verbose,
262
241
  colours=colours,
263
242
  )
264
243
 
265
- signal.signal(signal.SIGINT, process_group._handle_signal)
266
- signal.signal(signal.SIGTERM, process_group._handle_signal)
267
-
268
244
  return process_group
269
245
 
270
246
  def complete_output(self, tail: int = 20, all: bool = False) -> str:
@@ -293,7 +269,6 @@ class ProcessGroup:
293
269
  output += self._get_command_status(
294
270
  process,
295
271
  passed=process.return_code() == 0,
296
- verbose=self.verbose,
297
272
  timer=self.timer,
298
273
  )
299
274
  output += "\n"
@@ -301,7 +276,6 @@ class ProcessGroup:
301
276
  output += self._get_command_status(
302
277
  process,
303
278
  icon=constants.ICONS[self.icon],
304
- verbose=self.verbose,
305
279
  timer=self.timer,
306
280
  )
307
281
  output += "\n"
@@ -334,93 +308,3 @@ class ProcessGroup:
334
308
  output += f"\n{self.colours.red_bold}Abort!{self.colours.reset_colour}"
335
309
 
336
310
  return output
337
-
338
-
339
- @dataclass
340
- class Process:
341
- id: UUID = field(repr=False, compare=False)
342
- name: str
343
- args: list[str] = field(default_factory=list)
344
- env: dict[str, str] = field(default_factory=dict)
345
- start: float = 0.0
346
- end: float = 0.0
347
- _fd: BinaryIO | None = field(init=False, repr=False, compare=False, default=None)
348
- _process: subprocess.Popen[bytes] | None = field(
349
- init=False, repr=False, compare=False, default=None
350
- )
351
-
352
- def run(self) -> None:
353
- self.start = time.perf_counter()
354
- fd, fd_name = tempfile.mkstemp()
355
- self._fd = open(fd_name, "rb")
356
- self._process = subprocess.Popen(
357
- [self.name, *self.args],
358
- stdin=subprocess.DEVNULL,
359
- stdout=fd,
360
- stderr=subprocess.STDOUT,
361
- env=self.env,
362
- )
363
-
364
- def __del__(self) -> None:
365
- if self._fd:
366
- self._fd.close()
367
-
368
- def poll(self) -> int | None:
369
- if self._process:
370
- poll = self._process.poll()
371
- if poll is not None and not self.end:
372
- self.end = time.perf_counter()
373
- return poll
374
- return None
375
-
376
- def read(self) -> bytes:
377
- if self._fd:
378
- return self._fd.read()
379
- return b""
380
-
381
- def readline(self) -> bytes:
382
- if self._fd:
383
- return self._fd.readline()
384
- return b""
385
-
386
- def return_code(self) -> int | None:
387
- if self._process:
388
- return self._process.returncode
389
- return None
390
-
391
- def interrupt(self) -> None:
392
- if self._process:
393
- self._process.send_signal(signal.SIGINT)
394
-
395
- def kill(self) -> None:
396
- if self._process:
397
- self._process.send_signal(signal.SIGKILL)
398
-
399
- def wait(self) -> int:
400
- if self._process:
401
- return self._process.wait()
402
- return -1
403
-
404
- @classmethod
405
- def from_command(cls, command: str) -> Process:
406
- env = os.environ.copy()
407
- args = command.split()
408
-
409
- parsed_args: list[str] = []
410
- for arg in args:
411
- if "=" in arg:
412
- name, env_value = arg.split("=")
413
- env[name] = env_value
414
- else:
415
- parsed_args.append(arg)
416
-
417
- if not shutil.which(parsed_args[0]):
418
- raise InvalidExecutableError(parsed_args[0])
419
-
420
- str_args = shlex.split(" ".join(parsed_args[1:]))
421
- return cls(
422
- id=uuid4(),
423
- name=parsed_args[0],
424
- args=str_args,
425
- env=env,
426
- )
@@ -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