multiCMD 1.49__tar.gz → 1.51__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.
- {multicmd-1.49 → multicmd-1.51}/PKG-INFO +9 -1
- {multicmd-1.49 → multicmd-1.51}/README.md +8 -0
- {multicmd-1.49 → multicmd-1.51}/multiCMD.egg-info/PKG-INFO +9 -1
- {multicmd-1.49 → multicmd-1.51}/multiCMD.py +238 -61
- multicmd-1.51/tests/test_async_executor.py +184 -0
- multicmd-1.49/tests/test_async_executor.py +0 -52
- {multicmd-1.49 → multicmd-1.51}/multiCMD.egg-info/SOURCES.txt +0 -0
- {multicmd-1.49 → multicmd-1.51}/multiCMD.egg-info/dependency_links.txt +0 -0
- {multicmd-1.49 → multicmd-1.51}/multiCMD.egg-info/entry_points.txt +0 -0
- {multicmd-1.49 → multicmd-1.51}/multiCMD.egg-info/requires.txt +0 -0
- {multicmd-1.49 → multicmd-1.51}/multiCMD.egg-info/top_level.txt +0 -0
- {multicmd-1.49 → multicmd-1.51}/setup.cfg +0 -0
- {multicmd-1.49 → multicmd-1.51}/setup.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_cli.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_expand_ranges.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_helpers.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_ping.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_regressions.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_run_command.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_run_commands.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_sudo.py +0 -0
- {multicmd-1.49 → multicmd-1.51}/tests/test_task.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiCMD
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.51
|
|
4
4
|
Summary: Run commands simultaneously
|
|
5
5
|
Home-page: https://github.com/yufei-pan/multiCMD
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -206,6 +206,14 @@ print(ex.get_return_codes())
|
|
|
206
206
|
print(ex.get_results())
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
`run_command(s)` returns right away and queues the commands. One dispatcher thread
|
|
210
|
+
starts each queued command only after taking a permit from the semaphore, so the
|
|
211
|
+
executor holds at most one thread per permit plus the dispatcher, however many
|
|
212
|
+
commands are queued. A command's `task.thread` is `None` until it starts, and goes
|
|
213
|
+
back to `None` once it has finished and the executor has joined its thread, so a
|
|
214
|
+
large batch does not keep a `Thread` object per finished command. `stop()` skips
|
|
215
|
+
commands that have not started yet.
|
|
216
|
+
|
|
209
217
|
### Range expansion from Python
|
|
210
218
|
|
|
211
219
|
```python
|
|
@@ -180,6 +180,14 @@ print(ex.get_return_codes())
|
|
|
180
180
|
print(ex.get_results())
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
+
`run_command(s)` returns right away and queues the commands. One dispatcher thread
|
|
184
|
+
starts each queued command only after taking a permit from the semaphore, so the
|
|
185
|
+
executor holds at most one thread per permit plus the dispatcher, however many
|
|
186
|
+
commands are queued. A command's `task.thread` is `None` until it starts, and goes
|
|
187
|
+
back to `None` once it has finished and the executor has joined its thread, so a
|
|
188
|
+
large batch does not keep a `Thread` object per finished command. `stop()` skips
|
|
189
|
+
commands that have not started yet.
|
|
190
|
+
|
|
183
191
|
### Range expansion from Python
|
|
184
192
|
|
|
185
193
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiCMD
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.51
|
|
4
4
|
Summary: Run commands simultaneously
|
|
5
5
|
Home-page: https://github.com/yufei-pan/multiCMD
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -206,6 +206,14 @@ print(ex.get_return_codes())
|
|
|
206
206
|
print(ex.get_results())
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
`run_command(s)` returns right away and queues the commands. One dispatcher thread
|
|
210
|
+
starts each queued command only after taking a permit from the semaphore, so the
|
|
211
|
+
executor holds at most one thread per permit plus the dispatcher, however many
|
|
212
|
+
commands are queued. A command's `task.thread` is `None` until it starts, and goes
|
|
213
|
+
back to `None` once it has finished and the executor has joined its thread, so a
|
|
214
|
+
large batch does not keep a `Thread` object per finished command. `stop()` skips
|
|
215
|
+
commands that have not started yet.
|
|
216
|
+
|
|
209
217
|
### Range expansion from Python
|
|
210
218
|
|
|
211
219
|
```python
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# ///
|
|
8
8
|
#%% imports
|
|
9
9
|
import argparse
|
|
10
|
+
import collections
|
|
10
11
|
import itertools
|
|
11
12
|
import math
|
|
12
13
|
import os
|
|
@@ -22,10 +23,14 @@ import time
|
|
|
22
23
|
from pprint import pformat
|
|
23
24
|
|
|
24
25
|
#%% global vars
|
|
25
|
-
version = '1.
|
|
26
|
+
version = '1.51'
|
|
26
27
|
__version__ = version
|
|
27
|
-
COMMIT_DATE = '2026-
|
|
28
|
+
COMMIT_DATE = '2026-10-10'
|
|
29
|
+
# fire-and-forget threads (run_commands with wait_for_return=False) that join_threads() waits on
|
|
28
30
|
__running_threads = set()
|
|
31
|
+
__running_threads_lock = threading.Lock()
|
|
32
|
+
# fire-and-forget threads append themselves here when done, so they get joined and dropped from __running_threads
|
|
33
|
+
__finished_threads = collections.deque()
|
|
29
34
|
__variables = {}
|
|
30
35
|
|
|
31
36
|
# immutable helpers compiled once at import time
|
|
@@ -69,10 +74,31 @@ stop={self.stop!r})"""
|
|
|
69
74
|
def is_alive(self):
|
|
70
75
|
return self.thread is not None and self.thread.is_alive()
|
|
71
76
|
|
|
77
|
+
class _HeldPermit:
|
|
78
|
+
'''
|
|
79
|
+
Semaphore stand-in for a command whose permit the dispatcher already holds ( internal )
|
|
80
|
+
Entering is free, exiting releases the permit.
|
|
81
|
+
'''
|
|
82
|
+
def __init__(self, semaphore):
|
|
83
|
+
self.semaphore = semaphore
|
|
84
|
+
|
|
85
|
+
def __enter__(self):
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *exc_info):
|
|
89
|
+
self.semaphore.release()
|
|
90
|
+
return False
|
|
91
|
+
|
|
72
92
|
class AsyncExecutor:
|
|
73
93
|
def __init__(self, max_threads=1,semaphore=...,timeout=0,quiet=True,dry_run=False,parse=False):
|
|
74
94
|
'''
|
|
75
95
|
AsyncExecutor class to run commands in parallel asynchronously
|
|
96
|
+
|
|
97
|
+
Commands are queued and a dispatcher thread starts each one only after taking a permit
|
|
98
|
+
from the semaphore, so the executor holds at most one thread per permit it uses plus the
|
|
99
|
+
dispatcher, however many commands are queued. A command's task.thread is None until it
|
|
100
|
+
starts, and goes back to None once it has finished and the executor has joined its thread,
|
|
101
|
+
so finished tasks do not keep their Thread objects.
|
|
76
102
|
@params:
|
|
77
103
|
max_threads: The maximum number of threads to use ( int ) ( Note: if passing semaphore, this likely will be ignored )
|
|
78
104
|
semaphore: The semaphore to use for threading ( threading.Semaphore )
|
|
@@ -85,13 +111,19 @@ class AsyncExecutor:
|
|
|
85
111
|
if semaphore is ...:
|
|
86
112
|
semaphore = threading.Semaphore(max_threads)
|
|
87
113
|
self.semaphore = semaphore
|
|
88
|
-
self.runningThreads = []
|
|
89
114
|
self.tasks = []
|
|
90
115
|
self.timeout = timeout
|
|
91
116
|
self.quiet = quiet
|
|
92
117
|
self.dry_run = dry_run
|
|
93
118
|
self.parse = parse
|
|
94
|
-
self.
|
|
119
|
+
self._lock = threading.Lock()
|
|
120
|
+
# (task, sem, timeout, quiet, dry_run) waiting for a permit
|
|
121
|
+
self._queue = collections.deque()
|
|
122
|
+
self._dispatching = False
|
|
123
|
+
# started threads not yet joined, in start order ( dict used as an ordered set )
|
|
124
|
+
self._threads = {}
|
|
125
|
+
# threads append themselves here when done, so _reap() joins them without scanning
|
|
126
|
+
self._finished = collections.deque()
|
|
95
127
|
|
|
96
128
|
def __iter__(self):
|
|
97
129
|
return iter(self.tasks)
|
|
@@ -107,10 +139,90 @@ class AsyncExecutor:
|
|
|
107
139
|
|
|
108
140
|
def __bool__(self):
|
|
109
141
|
return bool(self.tasks)
|
|
110
|
-
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def runningThreads(self):
|
|
145
|
+
'''
|
|
146
|
+
The threads that have not finished yet: the dispatcher while commands are queued, and the started commands
|
|
147
|
+
@returns:
|
|
148
|
+
list[threading.Thread]: The unfinished threads
|
|
149
|
+
'''
|
|
150
|
+
self._reap()
|
|
151
|
+
with self._lock:
|
|
152
|
+
return list(self._threads)
|
|
153
|
+
|
|
154
|
+
def _reap(self):
|
|
155
|
+
'''
|
|
156
|
+
Join the threads that finished and forget them ( internal )
|
|
157
|
+
Joining releases the thread's stack: a finished thread that is still referenced but never
|
|
158
|
+
joined keeps its stack mapped.
|
|
159
|
+
'''
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
thread, task = self._finished.popleft()
|
|
163
|
+
except IndexError:
|
|
164
|
+
return
|
|
165
|
+
thread.join()
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._threads.pop(thread, None)
|
|
168
|
+
# a finished Thread object costs ~2 KB, and the task outlives it in self.tasks
|
|
169
|
+
if task is not None and task.thread is thread:
|
|
170
|
+
task.thread = None
|
|
171
|
+
|
|
172
|
+
def _start_thread(self, target, args, task=None):
|
|
173
|
+
thread = threading.Thread(target=target, args=args, daemon=True)
|
|
174
|
+
# registered before it starts, so it is tracked before it can report itself finished
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._threads[thread] = None
|
|
177
|
+
if task is not None:
|
|
178
|
+
# set before it starts, so _reap() never sees the task without its thread
|
|
179
|
+
task.thread = thread
|
|
180
|
+
try:
|
|
181
|
+
thread.start()
|
|
182
|
+
except BaseException:
|
|
183
|
+
# never started: it would never report itself finished
|
|
184
|
+
del self._threads[thread]
|
|
185
|
+
if task is not None:
|
|
186
|
+
task.thread = None
|
|
187
|
+
raise
|
|
188
|
+
return thread
|
|
189
|
+
|
|
190
|
+
def _dispatch(self):
|
|
191
|
+
'''
|
|
192
|
+
Start queued commands one permit at a time ( internal )
|
|
193
|
+
'''
|
|
194
|
+
try:
|
|
195
|
+
while True:
|
|
196
|
+
with self._lock:
|
|
197
|
+
if not self._queue:
|
|
198
|
+
self._dispatching = False
|
|
199
|
+
break
|
|
200
|
+
task, sem, timeout, quiet, dry_run = self._queue.popleft()
|
|
201
|
+
# wait for a permit, but give up on a command that gets stopped meanwhile
|
|
202
|
+
acquired = False
|
|
203
|
+
while not task.stop and not acquired:
|
|
204
|
+
acquired = sem.acquire(timeout=0.1)
|
|
205
|
+
if task.stop:
|
|
206
|
+
if acquired:
|
|
207
|
+
sem.release()
|
|
208
|
+
continue
|
|
209
|
+
self._reap()
|
|
210
|
+
try:
|
|
211
|
+
self._start_thread(_run_executor_task, (task, _HeldPermit(sem), timeout, quiet, dry_run, self._finished), task)
|
|
212
|
+
except BaseException:
|
|
213
|
+
sem.release()
|
|
214
|
+
raise
|
|
215
|
+
except BaseException:
|
|
216
|
+
# let the next run_commands() start a new dispatcher for what is still queued
|
|
217
|
+
with self._lock:
|
|
218
|
+
self._dispatching = False
|
|
219
|
+
raise
|
|
220
|
+
finally:
|
|
221
|
+
self._finished.append((threading.current_thread(), None))
|
|
222
|
+
|
|
111
223
|
def run_commands(self, commands, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
|
|
112
224
|
'''
|
|
113
|
-
Run multiple commands in parallel asynchronously
|
|
225
|
+
Run multiple commands in parallel asynchronously. Returns right away, the commands are queued
|
|
114
226
|
@params:
|
|
115
227
|
commands: A list of commands to run ( list[str] | list[list[str]] )
|
|
116
228
|
timeout: The timeout for each command to override the object default
|
|
@@ -134,19 +246,26 @@ class AsyncExecutor:
|
|
|
134
246
|
parse = self.parse
|
|
135
247
|
if sem is ...:
|
|
136
248
|
sem = self.semaphore
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if len(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
self.
|
|
145
|
-
self.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
249
|
+
taskObjects, use_sudo = _build_tasks(commands, parse=parse)
|
|
250
|
+
if not sem:
|
|
251
|
+
sem = threading.Semaphore(max_threads if max_threads >= 1 else max(len(taskObjects), 1))
|
|
252
|
+
if use_sudo:
|
|
253
|
+
_validate_sudo(sem)
|
|
254
|
+
self._reap()
|
|
255
|
+
with self._lock:
|
|
256
|
+
self.tasks.extend(taskObjects)
|
|
257
|
+
self._queue.extend((task, sem, timeout, quiet, dry_run) for task in taskObjects)
|
|
258
|
+
start_dispatcher = not self._dispatching and bool(self._queue)
|
|
259
|
+
if start_dispatcher:
|
|
260
|
+
# only the dispatcher clears this, under the lock, once it sees the queue empty
|
|
261
|
+
self._dispatching = True
|
|
262
|
+
if start_dispatcher:
|
|
263
|
+
try:
|
|
264
|
+
self._start_thread(self._dispatch, ())
|
|
265
|
+
except BaseException:
|
|
266
|
+
with self._lock:
|
|
267
|
+
self._dispatching = False
|
|
268
|
+
raise
|
|
150
269
|
return taskObjects
|
|
151
270
|
|
|
152
271
|
def run_command(self, command, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
|
|
@@ -169,26 +288,30 @@ class AsyncExecutor:
|
|
|
169
288
|
'''
|
|
170
289
|
Wait for the threads to finish
|
|
171
290
|
@params:
|
|
172
|
-
timeout: The timeout for each
|
|
173
|
-
threads: The threads to join, default to all running threads managed by this object
|
|
291
|
+
timeout: The timeout for each thread to override the object default ( negative waits forever )
|
|
292
|
+
threads: The threads to join, default to all running threads managed by this object,
|
|
293
|
+
including the ones the dispatcher starts while waiting
|
|
174
294
|
@returns:
|
|
175
295
|
list[threading.Thread]: The list of running threads that are still running
|
|
176
296
|
'''
|
|
177
|
-
if threads is ...:
|
|
178
|
-
threads = self.runningThreads
|
|
179
297
|
if timeout is ...:
|
|
180
298
|
timeout = self.timeout
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
299
|
+
while True:
|
|
300
|
+
pending = self.runningThreads if threads is ... else list(threads)
|
|
301
|
+
for thread in pending:
|
|
302
|
+
if timeout >= 0:
|
|
303
|
+
thread.join(timeout=timeout)
|
|
304
|
+
else:
|
|
305
|
+
thread.join()
|
|
306
|
+
# once a pass finishes every thread, look again for commands the dispatcher started meanwhile
|
|
307
|
+
if threads is not ... or not pending or any(thread.is_alive() for thread in pending):
|
|
308
|
+
break
|
|
187
309
|
return self.runningThreads
|
|
188
310
|
|
|
189
311
|
def stop(self,timeout=...):
|
|
190
312
|
'''
|
|
191
|
-
Stop all running threads. This signals all threads to stop and joins them
|
|
313
|
+
Stop all running threads. This signals all threads to stop and joins them.
|
|
314
|
+
Commands still queued are skipped: they keep task.thread None and returncode None
|
|
192
315
|
@params:
|
|
193
316
|
None
|
|
194
317
|
@returns:
|
|
@@ -208,8 +331,10 @@ class AsyncExecutor:
|
|
|
208
331
|
list[Task]: The list of tasks that are managed by this object
|
|
209
332
|
'''
|
|
210
333
|
self.stop(timeout)
|
|
211
|
-
self.
|
|
212
|
-
|
|
334
|
+
with self._lock:
|
|
335
|
+
self.tasks = []
|
|
336
|
+
self._queue.clear()
|
|
337
|
+
self._threads.clear()
|
|
213
338
|
return self.tasks
|
|
214
339
|
|
|
215
340
|
def join(self, timeout=..., threads = ..., print_error=True):
|
|
@@ -578,6 +703,69 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
578
703
|
else:
|
|
579
704
|
return task.stdout
|
|
580
705
|
|
|
706
|
+
def _run_executor_task(task, permit, timeout, quiet, dry_run, finished):
|
|
707
|
+
'''
|
|
708
|
+
Run an AsyncExecutor command, then report this thread finished so the executor joins it and drops it from the task ( internal )
|
|
709
|
+
'''
|
|
710
|
+
try:
|
|
711
|
+
__run_command(task, permit, timeout, quiet, dry_run, identity=...)
|
|
712
|
+
finally:
|
|
713
|
+
finished.append((threading.current_thread(), task))
|
|
714
|
+
|
|
715
|
+
def __run_tracked_command(task, sem, timeout, quiet, dry_run, identity):
|
|
716
|
+
'''
|
|
717
|
+
Run a fire-and-forget command, then report this thread finished so it leaves __running_threads ( internal )
|
|
718
|
+
'''
|
|
719
|
+
try:
|
|
720
|
+
__run_command(task, sem, timeout, quiet, dry_run, identity=identity)
|
|
721
|
+
finally:
|
|
722
|
+
__finished_threads.append(threading.current_thread())
|
|
723
|
+
|
|
724
|
+
def __reap_finished_threads():
|
|
725
|
+
'''
|
|
726
|
+
Join finished fire-and-forget threads and drop them from __running_threads ( internal )
|
|
727
|
+
Joining releases the thread's stack: a finished thread that is still referenced but never
|
|
728
|
+
joined keeps its stack mapped.
|
|
729
|
+
'''
|
|
730
|
+
while True:
|
|
731
|
+
try:
|
|
732
|
+
thread = __finished_threads.popleft()
|
|
733
|
+
except IndexError:
|
|
734
|
+
return
|
|
735
|
+
thread.join()
|
|
736
|
+
with __running_threads_lock:
|
|
737
|
+
__running_threads.discard(thread)
|
|
738
|
+
|
|
739
|
+
def _build_tasks(commands, parse=False, use_sudo=...):
|
|
740
|
+
'''
|
|
741
|
+
Format commands into Tasks, prefixing sudo when requested and available ( internal )
|
|
742
|
+
|
|
743
|
+
@returns:
|
|
744
|
+
tuple[list[Task], bool]: The tasks and whether sudo is used
|
|
745
|
+
'''
|
|
746
|
+
global USE_SUDO
|
|
747
|
+
global SUDO_PATH
|
|
748
|
+
if use_sudo is ...:
|
|
749
|
+
use_sudo = USE_SUDO
|
|
750
|
+
# Guard against requesting sudo when it is not available. Without this,
|
|
751
|
+
# prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
|
|
752
|
+
if use_sudo and not SUDO_PATH:
|
|
753
|
+
print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
|
|
754
|
+
use_sudo = False
|
|
755
|
+
# split the commands in commands if it is a string
|
|
756
|
+
formatedCommands = []
|
|
757
|
+
for command in commands:
|
|
758
|
+
formatedCommands.extend(__format_command(command,expand=parse))
|
|
759
|
+
if use_sudo:
|
|
760
|
+
formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
|
|
761
|
+
return [Task(command) for command in formatedCommands], bool(use_sudo)
|
|
762
|
+
|
|
763
|
+
def _validate_sudo(sem):
|
|
764
|
+
'''
|
|
765
|
+
Validate sudo access before running sudo commands ( internal )
|
|
766
|
+
'''
|
|
767
|
+
__run_command(Task([SUDO_PATH, '-v']),sem,timeout=60,quiet=True,dry_run=False,with_stdErr=False,identity=None)
|
|
768
|
+
|
|
581
769
|
def __format_command(command,expand = False):
|
|
582
770
|
'''
|
|
583
771
|
Format a command
|
|
@@ -714,41 +902,31 @@ def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,wit
|
|
|
714
902
|
@returns:
|
|
715
903
|
list: The output of the commands ( list[None] | list[int] | list[list[str]] | list[Task] | None )
|
|
716
904
|
'''
|
|
717
|
-
global USE_SUDO
|
|
718
|
-
global SUDO_PATH
|
|
719
|
-
if use_sudo is ...:
|
|
720
|
-
use_sudo = USE_SUDO
|
|
721
|
-
# Guard against requesting sudo when it is not available. Without this,
|
|
722
|
-
# prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
|
|
723
|
-
if use_sudo and not SUDO_PATH:
|
|
724
|
-
print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
|
|
725
|
-
use_sudo = False
|
|
726
|
-
# split the commands in commands if it is a string
|
|
727
|
-
formatedCommands = []
|
|
728
|
-
for command in commands:
|
|
729
|
-
formatedCommands.extend(__format_command(command,expand=parse))
|
|
730
905
|
# initialize the tasks
|
|
731
|
-
|
|
732
|
-
formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
|
|
733
|
-
tasks = [Task(command) for command in formatedCommands]
|
|
906
|
+
tasks, use_sudo = _build_tasks(commands, parse=parse, use_sudo=use_sudo)
|
|
734
907
|
# run the tasks with max_threads. if max_threads is 0, use the number of commands
|
|
735
908
|
if max_threads < 1:
|
|
736
|
-
max_threads = len(
|
|
909
|
+
max_threads = len(tasks)
|
|
737
910
|
if not sem:
|
|
738
911
|
sem = threading.Semaphore(max_threads) # Limit concurrent sessions
|
|
739
912
|
if use_sudo:
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
913
|
+
_validate_sudo(sem)
|
|
914
|
+
if not wait_for_return:
|
|
915
|
+
__reap_finished_threads()
|
|
916
|
+
threads = [threading.Thread(target=__run_tracked_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
|
|
917
|
+
# tracked before they start, so a thread is always registered before it reports itself finished
|
|
918
|
+
with __running_threads_lock:
|
|
919
|
+
__running_threads.update(threads)
|
|
920
|
+
for thread,task in zip(threads,tasks):
|
|
921
|
+
task.thread = thread
|
|
922
|
+
thread.start()
|
|
923
|
+
elif max_threads > 1:
|
|
743
924
|
threads = [threading.Thread(target=__run_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
|
|
744
925
|
for thread,task in zip(threads,tasks):
|
|
745
926
|
task.thread = thread
|
|
746
927
|
thread.start()
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
thread.join()
|
|
750
|
-
else:
|
|
751
|
-
__running_threads.update(threads)
|
|
928
|
+
for thread in threads:
|
|
929
|
+
thread.join()
|
|
752
930
|
else:
|
|
753
931
|
for task in tasks:
|
|
754
932
|
__run_command(task,sem,timeout,quiet,dry_run,identity=None)
|
|
@@ -777,13 +955,12 @@ def join_threads(threads=...,timeout=None):
|
|
|
777
955
|
@returns:
|
|
778
956
|
None
|
|
779
957
|
'''
|
|
780
|
-
global __running_threads
|
|
781
958
|
if threads is ...:
|
|
782
|
-
|
|
959
|
+
with __running_threads_lock:
|
|
960
|
+
threads = list(__running_threads)
|
|
783
961
|
for thread in threads:
|
|
784
962
|
thread.join(timeout=timeout)
|
|
785
|
-
|
|
786
|
-
__running_threads = {t for t in threads if t.is_alive()}
|
|
963
|
+
__reap_finished_threads()
|
|
787
964
|
|
|
788
965
|
def main():
|
|
789
966
|
parser = argparse.ArgumentParser(description='Run multiple commands in parallel')
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import multiCMD
|
|
8
|
+
from multiCMD import AsyncExecutor, run_commands
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.slow
|
|
12
|
+
def test_executor_run_wait_results_codes():
|
|
13
|
+
ex = AsyncExecutor(max_threads=2, timeout=0, quiet=True)
|
|
14
|
+
ex.run_command(["echo", "a"])
|
|
15
|
+
ex.run_commands([["echo", "b"], ["false"]])
|
|
16
|
+
assert len(ex) == 3
|
|
17
|
+
assert bool(ex)
|
|
18
|
+
ex.wait(timeout=2)
|
|
19
|
+
assert ex.get_return_codes() == [0, 0, 1]
|
|
20
|
+
assert ex.get_results() == [["a"], ["b"], []]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.slow
|
|
24
|
+
def test_executor_join_prints_errors(capsys):
|
|
25
|
+
ex = AsyncExecutor(max_threads=1, quiet=True)
|
|
26
|
+
ex.run_command(["false"])
|
|
27
|
+
ex.join(timeout=2, print_error=True)
|
|
28
|
+
captured = capsys.readouterr()
|
|
29
|
+
assert "failed with return code" in captured.out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.slow
|
|
33
|
+
def test_executor_stop_and_cleanup():
|
|
34
|
+
ex = AsyncExecutor(max_threads=1, quiet=True)
|
|
35
|
+
ex.run_command(["sleep", "5"])
|
|
36
|
+
time.sleep(0.1)
|
|
37
|
+
ex.stop(timeout=3)
|
|
38
|
+
# stop signals task.stop; process should end
|
|
39
|
+
assert all(not t.is_alive() for t in ex.tasks)
|
|
40
|
+
ex.cleanup(timeout=1)
|
|
41
|
+
assert list(ex) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.slow
|
|
45
|
+
def test_join_threads_module_helper():
|
|
46
|
+
tasks = run_commands(
|
|
47
|
+
[["sleep", "0.3"]],
|
|
48
|
+
wait_for_return=False,
|
|
49
|
+
return_object=True,
|
|
50
|
+
quiet=True,
|
|
51
|
+
)
|
|
52
|
+
assert tasks[0].returncode is None
|
|
53
|
+
multiCMD.join_threads(timeout=2)
|
|
54
|
+
assert tasks[0].returncode == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _global_threads():
|
|
58
|
+
return getattr(multiCMD, '__running_threads')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stack_mappings(size):
|
|
62
|
+
count = 0
|
|
63
|
+
with open('/proc/self/maps') as maps:
|
|
64
|
+
for line in maps:
|
|
65
|
+
fields = line.split()
|
|
66
|
+
if len(fields) == 5 and fields[1] == 'rw-p':
|
|
67
|
+
low, high = (int(x, 16) for x in fields[0].split('-'))
|
|
68
|
+
count += high - low == size
|
|
69
|
+
return count
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_executor_queues_commands_instead_of_parking_threads(monkeypatch):
|
|
73
|
+
ran = []
|
|
74
|
+
run_task = multiCMD._run_executor_task
|
|
75
|
+
monkeypatch.setattr(multiCMD, '_run_executor_task', lambda task, *args: (ran.append(task), run_task(task, *args)))
|
|
76
|
+
gate = threading.Semaphore(0)
|
|
77
|
+
baseline = threading.active_count()
|
|
78
|
+
ex = AsyncExecutor(semaphore=gate, quiet=True, dry_run=True)
|
|
79
|
+
start = time.monotonic()
|
|
80
|
+
tasks = [ex.run_command(['true']) for _ in range(5000)]
|
|
81
|
+
assert time.monotonic() - start < 5
|
|
82
|
+
# no permit is free, so only the dispatcher exists and it waits for one
|
|
83
|
+
assert threading.active_count() <= baseline + 1
|
|
84
|
+
assert all(task.thread is None for task in tasks)
|
|
85
|
+
for _ in range(len(tasks)):
|
|
86
|
+
gate.release()
|
|
87
|
+
ex.wait(timeout=-1)
|
|
88
|
+
assert ex.runningThreads == []
|
|
89
|
+
assert sorted(map(id, ran)) == sorted(map(id, tasks))
|
|
90
|
+
assert all(task.thread is None and not task.is_alive() for task in tasks)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.slow
|
|
94
|
+
def test_executor_thread_count_stays_bounded():
|
|
95
|
+
baseline = threading.active_count()
|
|
96
|
+
ex = AsyncExecutor(semaphore=threading.Semaphore(3), quiet=True)
|
|
97
|
+
ex.run_commands([['sleep', '0.05']] * 60)
|
|
98
|
+
peak = 0
|
|
99
|
+
while ex.runningThreads:
|
|
100
|
+
peak = max(peak, threading.active_count())
|
|
101
|
+
time.sleep(0.005)
|
|
102
|
+
# the dispatcher, plus a runner and two stream readers per running command
|
|
103
|
+
assert peak - baseline <= 1 + 3 * 3
|
|
104
|
+
assert ex.get_return_codes() == [0] * 60
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.slow
|
|
108
|
+
@pytest.mark.skipif(not os.path.exists('/proc/self/maps'), reason='needs /proc/self/maps')
|
|
109
|
+
def test_executor_releases_finished_thread_stacks():
|
|
110
|
+
# a stack size nothing else uses, so these threads' stacks can be counted in /proc/self/maps
|
|
111
|
+
stack = 8 * 1024 * 1024 + 3 * 4096
|
|
112
|
+
old_stack = threading.stack_size(stack)
|
|
113
|
+
try:
|
|
114
|
+
ex = AsyncExecutor(semaphore=threading.Semaphore(6), quiet=True)
|
|
115
|
+
for _ in range(1200):
|
|
116
|
+
ex.run_command(['sleep', '0.01'])
|
|
117
|
+
ex.wait(timeout=-1)
|
|
118
|
+
# the executor and its tasks are still referenced: every finished thread must already be joined
|
|
119
|
+
assert ex.get_return_codes() == [0] * 1200
|
|
120
|
+
# glibc keeps a few freed stacks cached for reuse
|
|
121
|
+
assert _stack_mappings(stack) <= 8
|
|
122
|
+
finally:
|
|
123
|
+
threading.stack_size(old_stack)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_executor_threads_stay_out_of_global_set():
|
|
127
|
+
ex = AsyncExecutor(max_threads=2, quiet=True)
|
|
128
|
+
tasks = ex.run_commands([['sleep', '0.3'], ['sleep', '0.3']])
|
|
129
|
+
# finished tasks drop their thread, so take the threads while the commands run
|
|
130
|
+
deadline = time.monotonic() + 5
|
|
131
|
+
while not all(task.thread for task in tasks) and time.monotonic() < deadline:
|
|
132
|
+
time.sleep(0.01)
|
|
133
|
+
threads = {task.thread for task in tasks}
|
|
134
|
+
assert None not in threads
|
|
135
|
+
assert not threads & _global_threads()
|
|
136
|
+
ex.wait(timeout=-1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_finished_fire_and_forget_threads_leave_global_set():
|
|
140
|
+
tasks = run_commands([['true']] * 20, wait_for_return=False, return_object=True, quiet=True)
|
|
141
|
+
for task in tasks:
|
|
142
|
+
task.thread.join()
|
|
143
|
+
run_commands([['true']], wait_for_return=False, quiet=True)
|
|
144
|
+
assert not {task.thread for task in tasks} & _global_threads()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_finished_executor_tasks_release_their_threads():
|
|
148
|
+
ex = AsyncExecutor(max_threads=4, quiet=True)
|
|
149
|
+
tasks = ex.run_commands([['true']] * 20)
|
|
150
|
+
ex.wait(timeout=-1)
|
|
151
|
+
assert [task.returncode for task in tasks] == [0] * 20
|
|
152
|
+
# a finished Thread object costs ~2 KB, kept for as long as the executor keeps the task
|
|
153
|
+
assert all(task.thread is None and not task.is_alive() for task in tasks)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_stop_skips_queued_commands():
|
|
157
|
+
ex = AsyncExecutor(semaphore=threading.Semaphore(0), quiet=True)
|
|
158
|
+
tasks = ex.run_commands([['sleep', '5']] * 3)
|
|
159
|
+
start = time.monotonic()
|
|
160
|
+
ex.stop(timeout=2)
|
|
161
|
+
assert time.monotonic() - start < 2
|
|
162
|
+
assert ex.runningThreads == []
|
|
163
|
+
assert all(task.thread is None and task.returncode is None for task in tasks)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
|
|
167
|
+
def test_failed_thread_start_keeps_executor_usable(monkeypatch):
|
|
168
|
+
ex = AsyncExecutor(max_threads=2, quiet=True)
|
|
169
|
+
real_start = threading.Thread.start
|
|
170
|
+
|
|
171
|
+
def fail_start(self):
|
|
172
|
+
if getattr(self, '_target', None) is multiCMD._run_executor_task:
|
|
173
|
+
raise RuntimeError("can't start new thread")
|
|
174
|
+
real_start(self)
|
|
175
|
+
monkeypatch.setattr(threading.Thread, 'start', fail_start)
|
|
176
|
+
failed = ex.run_command(['true'])
|
|
177
|
+
ex.wait(timeout=-1)
|
|
178
|
+
assert ex.runningThreads == []
|
|
179
|
+
assert failed.thread is None
|
|
180
|
+
monkeypatch.setattr(threading.Thread, 'start', real_start)
|
|
181
|
+
# the permit taken for the failed start was given back, and a new dispatcher picks up new work
|
|
182
|
+
tasks = ex.run_commands([['true'], ['true']])
|
|
183
|
+
ex.wait(timeout=-1)
|
|
184
|
+
assert [task.returncode for task in tasks] == [0, 0]
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import time
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
import multiCMD
|
|
6
|
-
from multiCMD import AsyncExecutor, run_commands
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@pytest.mark.slow
|
|
10
|
-
def test_executor_run_wait_results_codes():
|
|
11
|
-
ex = AsyncExecutor(max_threads=2, timeout=0, quiet=True)
|
|
12
|
-
ex.run_command(["echo", "a"])
|
|
13
|
-
ex.run_commands([["echo", "b"], ["false"]])
|
|
14
|
-
assert len(ex) == 3
|
|
15
|
-
assert bool(ex)
|
|
16
|
-
ex.wait(timeout=2)
|
|
17
|
-
assert ex.get_return_codes() == [0, 0, 1]
|
|
18
|
-
assert ex.get_results() == [["a"], ["b"], []]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@pytest.mark.slow
|
|
22
|
-
def test_executor_join_prints_errors(capsys):
|
|
23
|
-
ex = AsyncExecutor(max_threads=1, quiet=True)
|
|
24
|
-
ex.run_command(["false"])
|
|
25
|
-
ex.join(timeout=2, print_error=True)
|
|
26
|
-
captured = capsys.readouterr()
|
|
27
|
-
assert "failed with return code" in captured.out
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@pytest.mark.slow
|
|
31
|
-
def test_executor_stop_and_cleanup():
|
|
32
|
-
ex = AsyncExecutor(max_threads=1, quiet=True)
|
|
33
|
-
ex.run_command(["sleep", "5"])
|
|
34
|
-
time.sleep(0.1)
|
|
35
|
-
ex.stop(timeout=3)
|
|
36
|
-
# stop signals task.stop; process should end
|
|
37
|
-
assert all(not t.is_alive() for t in ex.tasks)
|
|
38
|
-
ex.cleanup(timeout=1)
|
|
39
|
-
assert list(ex) == []
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@pytest.mark.slow
|
|
43
|
-
def test_join_threads_module_helper():
|
|
44
|
-
tasks = run_commands(
|
|
45
|
-
[["sleep", "0.3"]],
|
|
46
|
-
wait_for_return=False,
|
|
47
|
-
return_object=True,
|
|
48
|
-
quiet=True,
|
|
49
|
-
)
|
|
50
|
-
assert tasks[0].returncode is None
|
|
51
|
-
multiCMD.join_threads(timeout=2)
|
|
52
|
-
assert tasks[0].returncode == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|