multiCMD 1.49__tar.gz → 1.50__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.50}/PKG-INFO +7 -1
- {multicmd-1.49 → multicmd-1.50}/README.md +6 -0
- {multicmd-1.49 → multicmd-1.50}/multiCMD.egg-info/PKG-INFO +7 -1
- {multicmd-1.49 → multicmd-1.50}/multiCMD.py +229 -61
- multicmd-1.50/tests/test_async_executor.py +165 -0
- multicmd-1.49/tests/test_async_executor.py +0 -52
- {multicmd-1.49 → multicmd-1.50}/multiCMD.egg-info/SOURCES.txt +0 -0
- {multicmd-1.49 → multicmd-1.50}/multiCMD.egg-info/dependency_links.txt +0 -0
- {multicmd-1.49 → multicmd-1.50}/multiCMD.egg-info/entry_points.txt +0 -0
- {multicmd-1.49 → multicmd-1.50}/multiCMD.egg-info/requires.txt +0 -0
- {multicmd-1.49 → multicmd-1.50}/multiCMD.egg-info/top_level.txt +0 -0
- {multicmd-1.49 → multicmd-1.50}/setup.cfg +0 -0
- {multicmd-1.49 → multicmd-1.50}/setup.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_cli.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_expand_ranges.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_helpers.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_ping.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_regressions.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_run_command.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_run_commands.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/tests/test_sudo.py +0 -0
- {multicmd-1.49 → multicmd-1.50}/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.50
|
|
4
4
|
Summary: Run commands simultaneously
|
|
5
5
|
Home-page: https://github.com/yufei-pan/multiCMD
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -206,6 +206,12 @@ 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 queued command's `task.thread` is `None` until it starts,
|
|
213
|
+
and `stop()` skips commands that have not started yet.
|
|
214
|
+
|
|
209
215
|
### Range expansion from Python
|
|
210
216
|
|
|
211
217
|
```python
|
|
@@ -180,6 +180,12 @@ 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 queued command's `task.thread` is `None` until it starts,
|
|
187
|
+
and `stop()` skips commands that have not started yet.
|
|
188
|
+
|
|
183
189
|
### Range expansion from Python
|
|
184
190
|
|
|
185
191
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiCMD
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.50
|
|
4
4
|
Summary: Run commands simultaneously
|
|
5
5
|
Home-page: https://github.com/yufei-pan/multiCMD
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -206,6 +206,12 @@ 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 queued command's `task.thread` is `None` until it starts,
|
|
213
|
+
and `stop()` skips commands that have not started yet.
|
|
214
|
+
|
|
209
215
|
### Range expansion from Python
|
|
210
216
|
|
|
211
217
|
```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.50'
|
|
26
27
|
__version__ = version
|
|
27
|
-
COMMIT_DATE = '2026-
|
|
28
|
+
COMMIT_DATE = '2026-10-09'
|
|
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,30 @@ 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 queued command's task.thread stays None
|
|
100
|
+
until it starts.
|
|
76
101
|
@params:
|
|
77
102
|
max_threads: The maximum number of threads to use ( int ) ( Note: if passing semaphore, this likely will be ignored )
|
|
78
103
|
semaphore: The semaphore to use for threading ( threading.Semaphore )
|
|
@@ -85,13 +110,19 @@ class AsyncExecutor:
|
|
|
85
110
|
if semaphore is ...:
|
|
86
111
|
semaphore = threading.Semaphore(max_threads)
|
|
87
112
|
self.semaphore = semaphore
|
|
88
|
-
self.runningThreads = []
|
|
89
113
|
self.tasks = []
|
|
90
114
|
self.timeout = timeout
|
|
91
115
|
self.quiet = quiet
|
|
92
116
|
self.dry_run = dry_run
|
|
93
117
|
self.parse = parse
|
|
94
|
-
self.
|
|
118
|
+
self._lock = threading.Lock()
|
|
119
|
+
# (task, sem, timeout, quiet, dry_run) waiting for a permit
|
|
120
|
+
self._queue = collections.deque()
|
|
121
|
+
self._dispatching = False
|
|
122
|
+
# started threads not yet joined, in start order ( dict used as an ordered set )
|
|
123
|
+
self._threads = {}
|
|
124
|
+
# threads append themselves here when done, so _reap() joins them without scanning
|
|
125
|
+
self._finished = collections.deque()
|
|
95
126
|
|
|
96
127
|
def __iter__(self):
|
|
97
128
|
return iter(self.tasks)
|
|
@@ -107,10 +138,82 @@ class AsyncExecutor:
|
|
|
107
138
|
|
|
108
139
|
def __bool__(self):
|
|
109
140
|
return bool(self.tasks)
|
|
110
|
-
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def runningThreads(self):
|
|
144
|
+
'''
|
|
145
|
+
The threads that have not finished yet: the dispatcher while commands are queued, and the started commands
|
|
146
|
+
@returns:
|
|
147
|
+
list[threading.Thread]: The unfinished threads
|
|
148
|
+
'''
|
|
149
|
+
self._reap()
|
|
150
|
+
with self._lock:
|
|
151
|
+
return list(self._threads)
|
|
152
|
+
|
|
153
|
+
def _reap(self):
|
|
154
|
+
'''
|
|
155
|
+
Join the threads that finished and forget them ( internal )
|
|
156
|
+
Joining releases the thread's stack: a finished thread that is still referenced but never
|
|
157
|
+
joined keeps its stack mapped.
|
|
158
|
+
'''
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
thread = self._finished.popleft()
|
|
162
|
+
except IndexError:
|
|
163
|
+
return
|
|
164
|
+
thread.join()
|
|
165
|
+
with self._lock:
|
|
166
|
+
self._threads.pop(thread, None)
|
|
167
|
+
|
|
168
|
+
def _start_thread(self, target, args):
|
|
169
|
+
thread = threading.Thread(target=target, args=args, daemon=True)
|
|
170
|
+
# registered before it starts, so it is tracked before it can report itself finished
|
|
171
|
+
with self._lock:
|
|
172
|
+
self._threads[thread] = None
|
|
173
|
+
try:
|
|
174
|
+
thread.start()
|
|
175
|
+
except BaseException:
|
|
176
|
+
# never started: it would never report itself finished
|
|
177
|
+
del self._threads[thread]
|
|
178
|
+
raise
|
|
179
|
+
return thread
|
|
180
|
+
|
|
181
|
+
def _dispatch(self):
|
|
182
|
+
'''
|
|
183
|
+
Start queued commands one permit at a time ( internal )
|
|
184
|
+
'''
|
|
185
|
+
try:
|
|
186
|
+
while True:
|
|
187
|
+
with self._lock:
|
|
188
|
+
if not self._queue:
|
|
189
|
+
self._dispatching = False
|
|
190
|
+
break
|
|
191
|
+
task, sem, timeout, quiet, dry_run = self._queue.popleft()
|
|
192
|
+
# wait for a permit, but give up on a command that gets stopped meanwhile
|
|
193
|
+
acquired = False
|
|
194
|
+
while not task.stop and not acquired:
|
|
195
|
+
acquired = sem.acquire(timeout=0.1)
|
|
196
|
+
if task.stop:
|
|
197
|
+
if acquired:
|
|
198
|
+
sem.release()
|
|
199
|
+
continue
|
|
200
|
+
self._reap()
|
|
201
|
+
try:
|
|
202
|
+
task.thread = self._start_thread(_run_executor_task, (task, _HeldPermit(sem), timeout, quiet, dry_run, self._finished))
|
|
203
|
+
except BaseException:
|
|
204
|
+
sem.release()
|
|
205
|
+
raise
|
|
206
|
+
except BaseException:
|
|
207
|
+
# let the next run_commands() start a new dispatcher for what is still queued
|
|
208
|
+
with self._lock:
|
|
209
|
+
self._dispatching = False
|
|
210
|
+
raise
|
|
211
|
+
finally:
|
|
212
|
+
self._finished.append(threading.current_thread())
|
|
213
|
+
|
|
111
214
|
def run_commands(self, commands, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
|
|
112
215
|
'''
|
|
113
|
-
Run multiple commands in parallel asynchronously
|
|
216
|
+
Run multiple commands in parallel asynchronously. Returns right away, the commands are queued
|
|
114
217
|
@params:
|
|
115
218
|
commands: A list of commands to run ( list[str] | list[list[str]] )
|
|
116
219
|
timeout: The timeout for each command to override the object default
|
|
@@ -134,19 +237,26 @@ class AsyncExecutor:
|
|
|
134
237
|
parse = self.parse
|
|
135
238
|
if sem is ...:
|
|
136
239
|
sem = self.semaphore
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if len(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
self.
|
|
145
|
-
self.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
240
|
+
taskObjects, use_sudo = _build_tasks(commands, parse=parse)
|
|
241
|
+
if not sem:
|
|
242
|
+
sem = threading.Semaphore(max_threads if max_threads >= 1 else max(len(taskObjects), 1))
|
|
243
|
+
if use_sudo:
|
|
244
|
+
_validate_sudo(sem)
|
|
245
|
+
self._reap()
|
|
246
|
+
with self._lock:
|
|
247
|
+
self.tasks.extend(taskObjects)
|
|
248
|
+
self._queue.extend((task, sem, timeout, quiet, dry_run) for task in taskObjects)
|
|
249
|
+
start_dispatcher = not self._dispatching and bool(self._queue)
|
|
250
|
+
if start_dispatcher:
|
|
251
|
+
# only the dispatcher clears this, under the lock, once it sees the queue empty
|
|
252
|
+
self._dispatching = True
|
|
253
|
+
if start_dispatcher:
|
|
254
|
+
try:
|
|
255
|
+
self._start_thread(self._dispatch, ())
|
|
256
|
+
except BaseException:
|
|
257
|
+
with self._lock:
|
|
258
|
+
self._dispatching = False
|
|
259
|
+
raise
|
|
150
260
|
return taskObjects
|
|
151
261
|
|
|
152
262
|
def run_command(self, command, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
|
|
@@ -169,26 +279,30 @@ class AsyncExecutor:
|
|
|
169
279
|
'''
|
|
170
280
|
Wait for the threads to finish
|
|
171
281
|
@params:
|
|
172
|
-
timeout: The timeout for each
|
|
173
|
-
threads: The threads to join, default to all running threads managed by this object
|
|
282
|
+
timeout: The timeout for each thread to override the object default ( negative waits forever )
|
|
283
|
+
threads: The threads to join, default to all running threads managed by this object,
|
|
284
|
+
including the ones the dispatcher starts while waiting
|
|
174
285
|
@returns:
|
|
175
286
|
list[threading.Thread]: The list of running threads that are still running
|
|
176
287
|
'''
|
|
177
|
-
if threads is ...:
|
|
178
|
-
threads = self.runningThreads
|
|
179
288
|
if timeout is ...:
|
|
180
289
|
timeout = self.timeout
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
290
|
+
while True:
|
|
291
|
+
pending = self.runningThreads if threads is ... else list(threads)
|
|
292
|
+
for thread in pending:
|
|
293
|
+
if timeout >= 0:
|
|
294
|
+
thread.join(timeout=timeout)
|
|
295
|
+
else:
|
|
296
|
+
thread.join()
|
|
297
|
+
# once a pass finishes every thread, look again for commands the dispatcher started meanwhile
|
|
298
|
+
if threads is not ... or not pending or any(thread.is_alive() for thread in pending):
|
|
299
|
+
break
|
|
187
300
|
return self.runningThreads
|
|
188
301
|
|
|
189
302
|
def stop(self,timeout=...):
|
|
190
303
|
'''
|
|
191
|
-
Stop all running threads. This signals all threads to stop and joins them
|
|
304
|
+
Stop all running threads. This signals all threads to stop and joins them.
|
|
305
|
+
Commands still queued are skipped: they keep task.thread None and returncode None
|
|
192
306
|
@params:
|
|
193
307
|
None
|
|
194
308
|
@returns:
|
|
@@ -208,8 +322,10 @@ class AsyncExecutor:
|
|
|
208
322
|
list[Task]: The list of tasks that are managed by this object
|
|
209
323
|
'''
|
|
210
324
|
self.stop(timeout)
|
|
211
|
-
self.
|
|
212
|
-
|
|
325
|
+
with self._lock:
|
|
326
|
+
self.tasks = []
|
|
327
|
+
self._queue.clear()
|
|
328
|
+
self._threads.clear()
|
|
213
329
|
return self.tasks
|
|
214
330
|
|
|
215
331
|
def join(self, timeout=..., threads = ..., print_error=True):
|
|
@@ -578,6 +694,69 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
578
694
|
else:
|
|
579
695
|
return task.stdout
|
|
580
696
|
|
|
697
|
+
def _run_executor_task(task, permit, timeout, quiet, dry_run, finished):
|
|
698
|
+
'''
|
|
699
|
+
Run an AsyncExecutor command, then report this thread finished so the executor joins it ( internal )
|
|
700
|
+
'''
|
|
701
|
+
try:
|
|
702
|
+
__run_command(task, permit, timeout, quiet, dry_run, identity=...)
|
|
703
|
+
finally:
|
|
704
|
+
finished.append(threading.current_thread())
|
|
705
|
+
|
|
706
|
+
def __run_tracked_command(task, sem, timeout, quiet, dry_run, identity):
|
|
707
|
+
'''
|
|
708
|
+
Run a fire-and-forget command, then report this thread finished so it leaves __running_threads ( internal )
|
|
709
|
+
'''
|
|
710
|
+
try:
|
|
711
|
+
__run_command(task, sem, timeout, quiet, dry_run, identity=identity)
|
|
712
|
+
finally:
|
|
713
|
+
__finished_threads.append(threading.current_thread())
|
|
714
|
+
|
|
715
|
+
def __reap_finished_threads():
|
|
716
|
+
'''
|
|
717
|
+
Join finished fire-and-forget threads and drop them from __running_threads ( internal )
|
|
718
|
+
Joining releases the thread's stack: a finished thread that is still referenced but never
|
|
719
|
+
joined keeps its stack mapped.
|
|
720
|
+
'''
|
|
721
|
+
while True:
|
|
722
|
+
try:
|
|
723
|
+
thread = __finished_threads.popleft()
|
|
724
|
+
except IndexError:
|
|
725
|
+
return
|
|
726
|
+
thread.join()
|
|
727
|
+
with __running_threads_lock:
|
|
728
|
+
__running_threads.discard(thread)
|
|
729
|
+
|
|
730
|
+
def _build_tasks(commands, parse=False, use_sudo=...):
|
|
731
|
+
'''
|
|
732
|
+
Format commands into Tasks, prefixing sudo when requested and available ( internal )
|
|
733
|
+
|
|
734
|
+
@returns:
|
|
735
|
+
tuple[list[Task], bool]: The tasks and whether sudo is used
|
|
736
|
+
'''
|
|
737
|
+
global USE_SUDO
|
|
738
|
+
global SUDO_PATH
|
|
739
|
+
if use_sudo is ...:
|
|
740
|
+
use_sudo = USE_SUDO
|
|
741
|
+
# Guard against requesting sudo when it is not available. Without this,
|
|
742
|
+
# prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
|
|
743
|
+
if use_sudo and not SUDO_PATH:
|
|
744
|
+
print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
|
|
745
|
+
use_sudo = False
|
|
746
|
+
# split the commands in commands if it is a string
|
|
747
|
+
formatedCommands = []
|
|
748
|
+
for command in commands:
|
|
749
|
+
formatedCommands.extend(__format_command(command,expand=parse))
|
|
750
|
+
if use_sudo:
|
|
751
|
+
formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
|
|
752
|
+
return [Task(command) for command in formatedCommands], bool(use_sudo)
|
|
753
|
+
|
|
754
|
+
def _validate_sudo(sem):
|
|
755
|
+
'''
|
|
756
|
+
Validate sudo access before running sudo commands ( internal )
|
|
757
|
+
'''
|
|
758
|
+
__run_command(Task([SUDO_PATH, '-v']),sem,timeout=60,quiet=True,dry_run=False,with_stdErr=False,identity=None)
|
|
759
|
+
|
|
581
760
|
def __format_command(command,expand = False):
|
|
582
761
|
'''
|
|
583
762
|
Format a command
|
|
@@ -714,41 +893,31 @@ def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,wit
|
|
|
714
893
|
@returns:
|
|
715
894
|
list: The output of the commands ( list[None] | list[int] | list[list[str]] | list[Task] | None )
|
|
716
895
|
'''
|
|
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
896
|
# initialize the tasks
|
|
731
|
-
|
|
732
|
-
formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
|
|
733
|
-
tasks = [Task(command) for command in formatedCommands]
|
|
897
|
+
tasks, use_sudo = _build_tasks(commands, parse=parse, use_sudo=use_sudo)
|
|
734
898
|
# run the tasks with max_threads. if max_threads is 0, use the number of commands
|
|
735
899
|
if max_threads < 1:
|
|
736
|
-
max_threads = len(
|
|
900
|
+
max_threads = len(tasks)
|
|
737
901
|
if not sem:
|
|
738
902
|
sem = threading.Semaphore(max_threads) # Limit concurrent sessions
|
|
739
903
|
if use_sudo:
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
904
|
+
_validate_sudo(sem)
|
|
905
|
+
if not wait_for_return:
|
|
906
|
+
__reap_finished_threads()
|
|
907
|
+
threads = [threading.Thread(target=__run_tracked_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
|
|
908
|
+
# tracked before they start, so a thread is always registered before it reports itself finished
|
|
909
|
+
with __running_threads_lock:
|
|
910
|
+
__running_threads.update(threads)
|
|
911
|
+
for thread,task in zip(threads,tasks):
|
|
912
|
+
task.thread = thread
|
|
913
|
+
thread.start()
|
|
914
|
+
elif max_threads > 1:
|
|
743
915
|
threads = [threading.Thread(target=__run_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
|
|
744
916
|
for thread,task in zip(threads,tasks):
|
|
745
917
|
task.thread = thread
|
|
746
918
|
thread.start()
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
thread.join()
|
|
750
|
-
else:
|
|
751
|
-
__running_threads.update(threads)
|
|
919
|
+
for thread in threads:
|
|
920
|
+
thread.join()
|
|
752
921
|
else:
|
|
753
922
|
for task in tasks:
|
|
754
923
|
__run_command(task,sem,timeout,quiet,dry_run,identity=None)
|
|
@@ -777,13 +946,12 @@ def join_threads(threads=...,timeout=None):
|
|
|
777
946
|
@returns:
|
|
778
947
|
None
|
|
779
948
|
'''
|
|
780
|
-
global __running_threads
|
|
781
949
|
if threads is ...:
|
|
782
|
-
|
|
950
|
+
with __running_threads_lock:
|
|
951
|
+
threads = list(__running_threads)
|
|
783
952
|
for thread in threads:
|
|
784
953
|
thread.join(timeout=timeout)
|
|
785
|
-
|
|
786
|
-
__running_threads = {t for t in threads if t.is_alive()}
|
|
954
|
+
__reap_finished_threads()
|
|
787
955
|
|
|
788
956
|
def main():
|
|
789
957
|
parser = argparse.ArgumentParser(description='Run multiple commands in parallel')
|
|
@@ -0,0 +1,165 @@
|
|
|
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():
|
|
73
|
+
gate = threading.Semaphore(0)
|
|
74
|
+
baseline = threading.active_count()
|
|
75
|
+
ex = AsyncExecutor(semaphore=gate, quiet=True, dry_run=True)
|
|
76
|
+
start = time.monotonic()
|
|
77
|
+
tasks = [ex.run_command(['true']) for _ in range(5000)]
|
|
78
|
+
assert time.monotonic() - start < 5
|
|
79
|
+
# no permit is free, so only the dispatcher exists and it waits for one
|
|
80
|
+
assert threading.active_count() <= baseline + 1
|
|
81
|
+
assert all(task.thread is None for task in tasks)
|
|
82
|
+
for _ in range(len(tasks)):
|
|
83
|
+
gate.release()
|
|
84
|
+
ex.wait(timeout=-1)
|
|
85
|
+
assert ex.runningThreads == []
|
|
86
|
+
assert all(task.thread is not None and not task.is_alive() for task in tasks)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.slow
|
|
90
|
+
def test_executor_thread_count_stays_bounded():
|
|
91
|
+
baseline = threading.active_count()
|
|
92
|
+
ex = AsyncExecutor(semaphore=threading.Semaphore(3), quiet=True)
|
|
93
|
+
ex.run_commands([['sleep', '0.05']] * 60)
|
|
94
|
+
peak = 0
|
|
95
|
+
while ex.runningThreads:
|
|
96
|
+
peak = max(peak, threading.active_count())
|
|
97
|
+
time.sleep(0.005)
|
|
98
|
+
# the dispatcher, plus a runner and two stream readers per running command
|
|
99
|
+
assert peak - baseline <= 1 + 3 * 3
|
|
100
|
+
assert ex.get_return_codes() == [0] * 60
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.slow
|
|
104
|
+
@pytest.mark.skipif(not os.path.exists('/proc/self/maps'), reason='needs /proc/self/maps')
|
|
105
|
+
def test_executor_releases_finished_thread_stacks():
|
|
106
|
+
# a stack size nothing else uses, so these threads' stacks can be counted in /proc/self/maps
|
|
107
|
+
stack = 8 * 1024 * 1024 + 3 * 4096
|
|
108
|
+
old_stack = threading.stack_size(stack)
|
|
109
|
+
try:
|
|
110
|
+
ex = AsyncExecutor(semaphore=threading.Semaphore(6), quiet=True)
|
|
111
|
+
for _ in range(1200):
|
|
112
|
+
ex.run_command(['sleep', '0.01'])
|
|
113
|
+
ex.wait(timeout=-1)
|
|
114
|
+
# the executor and its tasks are still referenced: every finished thread must already be joined
|
|
115
|
+
assert ex.get_return_codes() == [0] * 1200
|
|
116
|
+
# glibc keeps a few freed stacks cached for reuse
|
|
117
|
+
assert _stack_mappings(stack) <= 8
|
|
118
|
+
finally:
|
|
119
|
+
threading.stack_size(old_stack)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_executor_threads_stay_out_of_global_set():
|
|
123
|
+
ex = AsyncExecutor(max_threads=2, quiet=True)
|
|
124
|
+
tasks = ex.run_commands([['true'], ['true']])
|
|
125
|
+
ex.wait(timeout=-1)
|
|
126
|
+
assert not {task.thread for task in tasks} & _global_threads()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_finished_fire_and_forget_threads_leave_global_set():
|
|
130
|
+
tasks = run_commands([['true']] * 20, wait_for_return=False, return_object=True, quiet=True)
|
|
131
|
+
for task in tasks:
|
|
132
|
+
task.thread.join()
|
|
133
|
+
run_commands([['true']], wait_for_return=False, quiet=True)
|
|
134
|
+
assert not {task.thread for task in tasks} & _global_threads()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_stop_skips_queued_commands():
|
|
138
|
+
ex = AsyncExecutor(semaphore=threading.Semaphore(0), quiet=True)
|
|
139
|
+
tasks = ex.run_commands([['sleep', '5']] * 3)
|
|
140
|
+
start = time.monotonic()
|
|
141
|
+
ex.stop(timeout=2)
|
|
142
|
+
assert time.monotonic() - start < 2
|
|
143
|
+
assert ex.runningThreads == []
|
|
144
|
+
assert all(task.thread is None and task.returncode is None for task in tasks)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
|
|
148
|
+
def test_failed_thread_start_keeps_executor_usable(monkeypatch):
|
|
149
|
+
ex = AsyncExecutor(max_threads=2, quiet=True)
|
|
150
|
+
real_start = threading.Thread.start
|
|
151
|
+
|
|
152
|
+
def fail_start(self):
|
|
153
|
+
if getattr(self, '_target', None) is multiCMD._run_executor_task:
|
|
154
|
+
raise RuntimeError("can't start new thread")
|
|
155
|
+
real_start(self)
|
|
156
|
+
monkeypatch.setattr(threading.Thread, 'start', fail_start)
|
|
157
|
+
failed = ex.run_command(['true'])
|
|
158
|
+
ex.wait(timeout=-1)
|
|
159
|
+
assert ex.runningThreads == []
|
|
160
|
+
assert failed.thread is None
|
|
161
|
+
monkeypatch.setattr(threading.Thread, 'start', real_start)
|
|
162
|
+
# the permit taken for the failed start was given back, and a new dispatcher picks up new work
|
|
163
|
+
tasks = ex.run_commands([['true'], ['true']])
|
|
164
|
+
ex.wait(timeout=-1)
|
|
165
|
+
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
|