multiCMD 1.48__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.48 → multicmd-1.50}/PKG-INFO +11 -2
- {multicmd-1.48 → multicmd-1.50}/README.md +10 -1
- {multicmd-1.48 → multicmd-1.50}/multiCMD.egg-info/PKG-INFO +11 -2
- multicmd-1.50/multiCMD.egg-info/SOURCES.txt +19 -0
- {multicmd-1.48 → multicmd-1.50}/multiCMD.py +236 -66
- multicmd-1.50/tests/test_async_executor.py +165 -0
- multicmd-1.50/tests/test_cli.py +45 -0
- multicmd-1.50/tests/test_expand_ranges.py +48 -0
- multicmd-1.50/tests/test_helpers.py +76 -0
- multicmd-1.50/tests/test_ping.py +31 -0
- multicmd-1.50/tests/test_regressions.py +68 -0
- multicmd-1.50/tests/test_run_command.py +97 -0
- multicmd-1.50/tests/test_run_commands.py +119 -0
- multicmd-1.50/tests/test_sudo.py +45 -0
- multicmd-1.50/tests/test_task.py +22 -0
- multicmd-1.48/multiCMD.egg-info/SOURCES.txt +0 -9
- {multicmd-1.48 → multicmd-1.50}/multiCMD.egg-info/dependency_links.txt +0 -0
- {multicmd-1.48 → multicmd-1.50}/multiCMD.egg-info/entry_points.txt +0 -0
- {multicmd-1.48 → multicmd-1.50}/multiCMD.egg-info/requires.txt +0 -0
- {multicmd-1.48 → multicmd-1.50}/multiCMD.egg-info/top_level.txt +0 -0
- {multicmd-1.48 → multicmd-1.50}/setup.cfg +0 -0
- {multicmd-1.48 → multicmd-1.50}/setup.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
|
|
@@ -268,9 +274,12 @@ Run the test suite with [pytest](https://pytest.org):
|
|
|
268
274
|
|
|
269
275
|
```bash
|
|
270
276
|
pip install pytest
|
|
271
|
-
pytest
|
|
277
|
+
pytest # full suite under tests/
|
|
278
|
+
pytest -m "not slow" # skip intentional sleep-heavy cases
|
|
272
279
|
```
|
|
273
280
|
|
|
281
|
+
`test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
|
|
282
|
+
|
|
274
283
|
## License
|
|
275
284
|
|
|
276
285
|
GPLv3+ — see the package metadata. Authored by Yufei Pan (<pan@zopyr.us>).
|
|
@@ -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
|
|
@@ -242,9 +248,12 @@ Run the test suite with [pytest](https://pytest.org):
|
|
|
242
248
|
|
|
243
249
|
```bash
|
|
244
250
|
pip install pytest
|
|
245
|
-
pytest
|
|
251
|
+
pytest # full suite under tests/
|
|
252
|
+
pytest -m "not slow" # skip intentional sleep-heavy cases
|
|
246
253
|
```
|
|
247
254
|
|
|
255
|
+
`test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
|
|
256
|
+
|
|
248
257
|
## License
|
|
249
258
|
|
|
250
259
|
GPLv3+ — see the package metadata. Authored by Yufei Pan (<pan@zopyr.us>).
|
|
@@ -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
|
|
@@ -268,9 +274,12 @@ Run the test suite with [pytest](https://pytest.org):
|
|
|
268
274
|
|
|
269
275
|
```bash
|
|
270
276
|
pip install pytest
|
|
271
|
-
pytest
|
|
277
|
+
pytest # full suite under tests/
|
|
278
|
+
pytest -m "not slow" # skip intentional sleep-heavy cases
|
|
272
279
|
```
|
|
273
280
|
|
|
281
|
+
`test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
|
|
282
|
+
|
|
274
283
|
## License
|
|
275
284
|
|
|
276
285
|
GPLv3+ — see the package metadata. Authored by Yufei Pan (<pan@zopyr.us>).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
multiCMD.py
|
|
3
|
+
setup.py
|
|
4
|
+
multiCMD.egg-info/PKG-INFO
|
|
5
|
+
multiCMD.egg-info/SOURCES.txt
|
|
6
|
+
multiCMD.egg-info/dependency_links.txt
|
|
7
|
+
multiCMD.egg-info/entry_points.txt
|
|
8
|
+
multiCMD.egg-info/requires.txt
|
|
9
|
+
multiCMD.egg-info/top_level.txt
|
|
10
|
+
tests/test_async_executor.py
|
|
11
|
+
tests/test_cli.py
|
|
12
|
+
tests/test_expand_ranges.py
|
|
13
|
+
tests/test_helpers.py
|
|
14
|
+
tests/test_ping.py
|
|
15
|
+
tests/test_regressions.py
|
|
16
|
+
tests/test_run_command.py
|
|
17
|
+
tests/test_run_commands.py
|
|
18
|
+
tests/test_sudo.py
|
|
19
|
+
tests/test_task.py
|
|
@@ -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):
|
|
@@ -503,6 +619,7 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
503
619
|
outLength = len(task.stdout) + len(task.stderr)
|
|
504
620
|
time.sleep(0)
|
|
505
621
|
sleep_time = 1.0e-7 # 100 nanoseconds
|
|
622
|
+
inactivity_timeout = False
|
|
506
623
|
while proc.poll() is None: # while the process is still running
|
|
507
624
|
if task.stop:
|
|
508
625
|
proc.send_signal(signal.SIGINT)
|
|
@@ -515,6 +632,7 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
515
632
|
outLength = len(task.stdout) + len(task.stderr)
|
|
516
633
|
elif time.time() - start_time > timeout:
|
|
517
634
|
task.stderr.append('Timeout!')
|
|
635
|
+
inactivity_timeout = True
|
|
518
636
|
proc.send_signal(signal.SIGINT)
|
|
519
637
|
time.sleep(0.01)
|
|
520
638
|
proc.terminate()
|
|
@@ -548,11 +666,11 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
548
666
|
stream.close()
|
|
549
667
|
except Exception:
|
|
550
668
|
pass
|
|
551
|
-
if
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
669
|
+
if inactivity_timeout:
|
|
670
|
+
task.returncode = 124
|
|
671
|
+
elif task.returncode is None:
|
|
672
|
+
# process been killed via sigkill or similar without a normal exit code
|
|
673
|
+
if task.stderr and task.stderr[-1].strip().startswith('Ctrl C detected, Emergency Stop!'):
|
|
556
674
|
task.returncode = 137
|
|
557
675
|
else:
|
|
558
676
|
task.returncode = -1
|
|
@@ -576,6 +694,69 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
576
694
|
else:
|
|
577
695
|
return task.stdout
|
|
578
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
|
+
|
|
579
760
|
def __format_command(command,expand = False):
|
|
580
761
|
'''
|
|
581
762
|
Format a command
|
|
@@ -712,41 +893,31 @@ def run_commands(commands, timeout=0,max_threads=1,quiet=False,dry_run=False,wit
|
|
|
712
893
|
@returns:
|
|
713
894
|
list: The output of the commands ( list[None] | list[int] | list[list[str]] | list[Task] | None )
|
|
714
895
|
'''
|
|
715
|
-
global USE_SUDO
|
|
716
|
-
global SUDO_PATH
|
|
717
|
-
if use_sudo is ...:
|
|
718
|
-
use_sudo = USE_SUDO
|
|
719
|
-
# Guard against requesting sudo when it is not available. Without this,
|
|
720
|
-
# prepending a None SUDO_PATH would crash subprocess.Popen with a TypeError.
|
|
721
|
-
if use_sudo and not SUDO_PATH:
|
|
722
|
-
print("sudo not found in PATH, cannot use sudo. ignoring it...", file=sys.stderr)
|
|
723
|
-
use_sudo = False
|
|
724
|
-
# split the commands in commands if it is a string
|
|
725
|
-
formatedCommands = []
|
|
726
|
-
for command in commands:
|
|
727
|
-
formatedCommands.extend(__format_command(command,expand=parse))
|
|
728
896
|
# initialize the tasks
|
|
729
|
-
|
|
730
|
-
formatedCommands = [[SUDO_PATH] + command for command in formatedCommands]
|
|
731
|
-
tasks = [Task(command) for command in formatedCommands]
|
|
897
|
+
tasks, use_sudo = _build_tasks(commands, parse=parse, use_sudo=use_sudo)
|
|
732
898
|
# run the tasks with max_threads. if max_threads is 0, use the number of commands
|
|
733
899
|
if max_threads < 1:
|
|
734
|
-
max_threads = len(
|
|
900
|
+
max_threads = len(tasks)
|
|
735
901
|
if not sem:
|
|
736
902
|
sem = threading.Semaphore(max_threads) # Limit concurrent sessions
|
|
737
903
|
if use_sudo:
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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:
|
|
741
915
|
threads = [threading.Thread(target=__run_command, args=(task,sem,timeout,quiet,dry_run,...),daemon=True) for task in tasks]
|
|
742
916
|
for thread,task in zip(threads,tasks):
|
|
743
917
|
task.thread = thread
|
|
744
918
|
thread.start()
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
thread.join()
|
|
748
|
-
else:
|
|
749
|
-
__running_threads.update(threads)
|
|
919
|
+
for thread in threads:
|
|
920
|
+
thread.join()
|
|
750
921
|
else:
|
|
751
922
|
for task in tasks:
|
|
752
923
|
__run_command(task,sem,timeout,quiet,dry_run,identity=None)
|
|
@@ -775,13 +946,12 @@ def join_threads(threads=...,timeout=None):
|
|
|
775
946
|
@returns:
|
|
776
947
|
None
|
|
777
948
|
'''
|
|
778
|
-
global __running_threads
|
|
779
949
|
if threads is ...:
|
|
780
|
-
|
|
950
|
+
with __running_threads_lock:
|
|
951
|
+
threads = list(__running_threads)
|
|
781
952
|
for thread in threads:
|
|
782
953
|
thread.join(timeout=timeout)
|
|
783
|
-
|
|
784
|
-
__running_threads = {t for t in threads if t.is_alive()}
|
|
954
|
+
__reap_finished_threads()
|
|
785
955
|
|
|
786
956
|
def main():
|
|
787
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]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from conftest import run_cli
|
|
2
|
+
import multiCMD
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_version_flag():
|
|
6
|
+
proc = run_cli(["-V"])
|
|
7
|
+
assert proc.returncode == 0
|
|
8
|
+
assert multiCMD.version in (proc.stdout + proc.stderr)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_help_flag():
|
|
12
|
+
proc = run_cli(["-h"])
|
|
13
|
+
assert proc.returncode == 0
|
|
14
|
+
assert "max_threads" in proc.stdout
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_two_echoes():
|
|
18
|
+
proc = run_cli(["echo hello", "echo world"])
|
|
19
|
+
assert proc.returncode == 0
|
|
20
|
+
combined = proc.stdout + proc.stderr
|
|
21
|
+
assert "hello" in combined
|
|
22
|
+
assert "world" in combined
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_parse_flag_expands():
|
|
26
|
+
proc = run_cli(["-p", "echo [1-2]"])
|
|
27
|
+
assert proc.returncode == 0
|
|
28
|
+
combined = proc.stdout + proc.stderr
|
|
29
|
+
assert "[1-2]" not in combined
|
|
30
|
+
assert "Running command: echo 1" in combined
|
|
31
|
+
assert "Running command: echo 2" in combined
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_max_threads_and_timeout_smoke():
|
|
35
|
+
proc = run_cli(["-q", "-m", "2", "-t", "5", "echo a", "echo b"], timeout=20)
|
|
36
|
+
assert proc.returncode == 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_sudo_flag_without_sudo_binary_does_not_crash(monkeypatch):
|
|
40
|
+
# Force child env: PATH without sudo by invoking python -c wrapper is heavy;
|
|
41
|
+
# instead assert current host behavior: --sudo with missing sudo should not
|
|
42
|
+
# abort with traceback. If sudo exists, command still succeeds for echo.
|
|
43
|
+
proc = run_cli(["-q", "--sudo", "echo ok"], timeout=20)
|
|
44
|
+
assert proc.returncode == 0
|
|
45
|
+
assert "Traceback" not in (proc.stdout + proc.stderr)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import multiCMD
|
|
2
|
+
from multiCMD import _expand_ranges, _expand_ranges_fast
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_alias_is_fast_implementation():
|
|
6
|
+
assert _expand_ranges is _expand_ranges_fast
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_numeric_alpha_and_comma():
|
|
10
|
+
assert _expand_ranges("file[1-3].txt") == ["file1.txt", "file2.txt", "file3.txt"]
|
|
11
|
+
assert _expand_ranges("file[a-c].txt") == ["filea.txt", "fileb.txt", "filec.txt"]
|
|
12
|
+
assert _expand_ranges("file[1-2,a-b].txt") == [
|
|
13
|
+
"file1.txt", "file2.txt", "filea.txt", "fileb.txt"
|
|
14
|
+
]
|
|
15
|
+
assert _expand_ranges("file[1-3,5].txt") == [
|
|
16
|
+
"file1.txt", "file2.txt", "file3.txt", "file5.txt"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_zero_padding_shorter_endpoint_rule():
|
|
21
|
+
assert _expand_ranges("file[01-03].txt") == [
|
|
22
|
+
"file01.txt", "file02.txt", "file03.txt"
|
|
23
|
+
]
|
|
24
|
+
assert _expand_ranges("[0-10]") == [str(i) for i in range(11)]
|
|
25
|
+
assert _expand_ranges("[01-10]") == [f"{i:02d}" for i in range(1, 11)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_hex_style_alpha_range():
|
|
29
|
+
assert _expand_ranges("v[a-f]") == [f"v{c}" for c in "abcdef"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_multi_bracket_cartesian():
|
|
33
|
+
assert _expand_ranges("[1-2]-[a-b]") == ["1-a", "1-b", "2-a", "2-b"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_variable_assignment_and_reference():
|
|
37
|
+
multiCMD.__variables.clear()
|
|
38
|
+
assert _expand_ranges("[n:3]host[1-n]") == ["host1", "host2", "host3"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_brace_expression_eval():
|
|
42
|
+
multiCMD.__variables.clear()
|
|
43
|
+
assert _expand_ranges("host[{2+3}]") == ["host5"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_passthrough_without_brackets():
|
|
47
|
+
assert _expand_ranges("plainhost") == ["plainhost"]
|
|
48
|
+
assert _expand_ranges("") == [""]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import select
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from multiCMD import (
|
|
6
|
+
_genrate_progress_bar,
|
|
7
|
+
format_bytes,
|
|
8
|
+
get_terminal_size,
|
|
9
|
+
input_with_timeout_and_countdown,
|
|
10
|
+
int_to_color,
|
|
11
|
+
parseTable,
|
|
12
|
+
pretty_format_table,
|
|
13
|
+
print_progress_bar,
|
|
14
|
+
slugify,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_pretty_format_table_basic_and_remove_empty():
|
|
19
|
+
data = [["a", ""], ["b", ""]]
|
|
20
|
+
header = ["h1", "h2"]
|
|
21
|
+
out = pretty_format_table(data, header=header, remove_empty_columns=True)
|
|
22
|
+
assert out == "h1\n--\na \nb \n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_parse_table():
|
|
26
|
+
text = "col1 col2\nfoo bar\n"
|
|
27
|
+
table = parseTable(text)
|
|
28
|
+
assert table == [["col1", "col2"], ["foo", "bar"]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_slugify_ascii_and_unicode():
|
|
32
|
+
assert slugify("Hello World!") == "hello-world"
|
|
33
|
+
assert slugify("café", allow_unicode=True) == "café" # runtime: preserves unicode
|
|
34
|
+
assert slugify("café", allow_unicode=False) == "cafe"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_format_bytes_roundtrip():
|
|
38
|
+
assert format_bytes(1024, use_1024_bytes=True, to_str=True) == "1.00 Ki"
|
|
39
|
+
assert format_bytes("1 KiB", to_int=True) == 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_int_to_color_deterministic_and_in_range():
|
|
43
|
+
assert int_to_color(0x123456) == (36, 103, 171)
|
|
44
|
+
assert int_to_color(0) == (160, 160, 160)
|
|
45
|
+
assert int_to_color(0xFFFFFF) == (220, 220, 220)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_progress_bar_helpers(capsys, monkeypatch):
|
|
49
|
+
expected_bar = (
|
|
50
|
+
"P██████████████████████████████████▁__________________________________|50.0% S"
|
|
51
|
+
)
|
|
52
|
+
assert _genrate_progress_bar(5, 10, prefix="P", suffix="S", columns=80) == expected_bar
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr(
|
|
55
|
+
"multiCMD.get_terminal_size", lambda: (80, 24)
|
|
56
|
+
)
|
|
57
|
+
print_progress_bar(5, 10, prefix="Progress:", suffix="Complete")
|
|
58
|
+
captured = capsys.readouterr()
|
|
59
|
+
assert captured.out == (
|
|
60
|
+
"\rProgress: |████████████████████████▄_________________________|50.0% | Complete"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_get_terminal_size_tuple():
|
|
65
|
+
cols, rows = get_terminal_size()
|
|
66
|
+
assert cols > 0 and rows > 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_input_with_timeout_and_countdown(monkeypatch):
|
|
70
|
+
monkeypatch.setattr(builtins, "input", lambda prompt="": "user input")
|
|
71
|
+
monkeypatch.setattr(
|
|
72
|
+
select, "select", lambda r, w, x, timeout=None: ([sys.stdin], [], [])
|
|
73
|
+
)
|
|
74
|
+
assert input_with_timeout_and_countdown(5) == "user input"
|
|
75
|
+
monkeypatch.setattr(select, "select", lambda r, w, x, timeout=None: ([], [], []))
|
|
76
|
+
assert input_with_timeout_and_countdown(1) is None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from conftest import loopback_ping_works
|
|
6
|
+
from multiCMD import ping
|
|
7
|
+
|
|
8
|
+
pytestmark = [
|
|
9
|
+
pytest.mark.ping,
|
|
10
|
+
pytest.mark.skipif(shutil.which("ping") is None, reason="ping not installed"),
|
|
11
|
+
pytest.mark.skipif(not loopback_ping_works(), reason="loopback ping failed"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_ping_single_true_false():
|
|
16
|
+
assert ping("127.0.0.1", timeout=2, quiet=True) is True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_ping_list_true_false():
|
|
20
|
+
assert ping(["127.0.0.1", "127.0.0.1"], timeout=2, quiet=True) == [True, True]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_ping_raw_return_codes():
|
|
24
|
+
rcs = ping(
|
|
25
|
+
["127.0.0.1"],
|
|
26
|
+
timeout=2,
|
|
27
|
+
quiet=True,
|
|
28
|
+
return_true_false=False,
|
|
29
|
+
return_code_only=True,
|
|
30
|
+
)
|
|
31
|
+
assert rcs == [0]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import multiCMD
|
|
6
|
+
from multiCMD import int_to_color, run_command
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_regression_sudo_none_path_no_typeerror():
|
|
10
|
+
"""use_sudo with SUDO_PATH=None must not prepend None into Popen argv."""
|
|
11
|
+
multiCMD.SUDO_PATH = None
|
|
12
|
+
out = run_command(["echo", "ok"], quiet=True, use_sudo=True)
|
|
13
|
+
assert out == ["ok"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.slow
|
|
17
|
+
def test_regression_stdin_eof_no_hang():
|
|
18
|
+
"""Commands that read stdin must see EOF (DEVNULL), not hang until timeout."""
|
|
19
|
+
t0 = time.time()
|
|
20
|
+
task = run_command(["cat"], quiet=True, return_object=True, timeout=2)
|
|
21
|
+
elapsed = time.time() - t0
|
|
22
|
+
assert elapsed < 1.5
|
|
23
|
+
assert task.returncode == 0
|
|
24
|
+
assert task.stdout == []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_regression_int_to_color_no_recursion():
|
|
28
|
+
# Previously could RecursionError / non-deterministic str hashing
|
|
29
|
+
colors = [int_to_color(i) for i in range(0, 5000, 97)]
|
|
30
|
+
assert int_to_color(42) == int_to_color(42)
|
|
31
|
+
for rgb in colors:
|
|
32
|
+
assert all(0 <= c <= 255 for c in rgb)
|
|
33
|
+
# Distinct inputs should not all collapse onto the same color.
|
|
34
|
+
assert len(set(colors)) > 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.slow
|
|
38
|
+
def test_regression_concurrent_stdout_stderr_no_race():
|
|
39
|
+
code = (
|
|
40
|
+
"import sys\n"
|
|
41
|
+
"for i in range(200):\n"
|
|
42
|
+
" print('O', i, flush=True)\n"
|
|
43
|
+
" print('E', i, file=sys.stderr, flush=True)\n"
|
|
44
|
+
)
|
|
45
|
+
task = run_command(["python3", "-c", code], quiet=True, return_object=True)
|
|
46
|
+
assert task.returncode == 0
|
|
47
|
+
assert len(task.stdout) == 200
|
|
48
|
+
assert len(task.stderr) == 200
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.slow
|
|
52
|
+
def test_regression_inactivity_timeout_not_total_runtime():
|
|
53
|
+
"""A chatty process lasting longer than timeout must not be killed for wall time."""
|
|
54
|
+
code = (
|
|
55
|
+
"import time\n"
|
|
56
|
+
"for _ in range(6):\n"
|
|
57
|
+
" print('tick', flush=True)\n"
|
|
58
|
+
" time.sleep(0.25)\n"
|
|
59
|
+
)
|
|
60
|
+
task = run_command(
|
|
61
|
+
["python3", "-c", code],
|
|
62
|
+
timeout=0.5,
|
|
63
|
+
quiet=True,
|
|
64
|
+
return_object=True,
|
|
65
|
+
)
|
|
66
|
+
assert task.returncode == 0
|
|
67
|
+
assert not any(line.strip().startswith("Timeout!") for line in task.stderr)
|
|
68
|
+
assert len(task.stdout) == 6
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from multiCMD import run_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_echo_stdout_list():
|
|
9
|
+
assert run_command(["echo", "hello"], quiet=True) == ["hello"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_string_command_form():
|
|
13
|
+
assert run_command("echo hello", quiet=True) == ["hello"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_dry_run_empty():
|
|
17
|
+
assert run_command(["echo", "hello"], dry_run=True, quiet=True) == []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_return_code_only_success_and_failure():
|
|
21
|
+
assert run_command(["echo", "hello"], return_code_only=True, quiet=True) == 0
|
|
22
|
+
assert run_command(["false"], return_code_only=True, quiet=True) == 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_return_object():
|
|
26
|
+
task = run_command(["echo", "hello"], return_object=True, quiet=True)
|
|
27
|
+
assert task.returncode == 0
|
|
28
|
+
assert task.stdout == ["hello"]
|
|
29
|
+
assert task.stderr == []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_with_stderr_merged():
|
|
33
|
+
# python writes to stderr
|
|
34
|
+
out = run_command(
|
|
35
|
+
["python3", "-c", "import sys; sys.stderr.write('e\\n'); print('o')"],
|
|
36
|
+
quiet=True,
|
|
37
|
+
with_stdErr=True,
|
|
38
|
+
)
|
|
39
|
+
assert "o" in out
|
|
40
|
+
assert "e" in out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.slow
|
|
44
|
+
def test_timeout_silent_command():
|
|
45
|
+
result = run_command(["sleep", "2"], timeout=0.3, quiet=True, with_stdErr=False)
|
|
46
|
+
assert "Timeout!" not in result
|
|
47
|
+
result = run_command(["sleep", "2"], timeout=0.3, quiet=True, with_stdErr=True)
|
|
48
|
+
assert "Timeout!" in "\n".join(result)
|
|
49
|
+
task = run_command(
|
|
50
|
+
["sleep", "2"], timeout=0.3, quiet=True, return_object=True
|
|
51
|
+
)
|
|
52
|
+
assert task.returncode == 124
|
|
53
|
+
assert task.stderr and task.stderr[-1].strip().startswith("Timeout!")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.slow
|
|
57
|
+
def test_timeout_returncode_preserved_after_sigint_handler_stderr():
|
|
58
|
+
task = run_command(
|
|
59
|
+
[
|
|
60
|
+
"python3",
|
|
61
|
+
"-c",
|
|
62
|
+
"import signal, sys, time\n"
|
|
63
|
+
"def handler(sig, frame):\n"
|
|
64
|
+
" sys.stderr.write('handled\\n')\n"
|
|
65
|
+
" sys.stderr.flush()\n"
|
|
66
|
+
" sys.exit(0)\n"
|
|
67
|
+
"signal.signal(signal.SIGINT, handler)\n"
|
|
68
|
+
"time.sleep(10)\n",
|
|
69
|
+
],
|
|
70
|
+
timeout=0.3,
|
|
71
|
+
quiet=True,
|
|
72
|
+
return_object=True,
|
|
73
|
+
)
|
|
74
|
+
assert task.returncode == 124
|
|
75
|
+
assert "Timeout!" in task.stderr
|
|
76
|
+
assert "handled" in task.stderr
|
|
77
|
+
assert task.stderr[-1].strip() == "handled"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.slow
|
|
81
|
+
def test_wait_for_return_false():
|
|
82
|
+
task = run_command(
|
|
83
|
+
["sleep", "0.4"],
|
|
84
|
+
wait_for_return=False,
|
|
85
|
+
quiet=True,
|
|
86
|
+
return_object=True,
|
|
87
|
+
)
|
|
88
|
+
assert task.returncode is None
|
|
89
|
+
deadline = time.time() + 2
|
|
90
|
+
while task.returncode is None and time.time() < deadline:
|
|
91
|
+
time.sleep(0.05)
|
|
92
|
+
assert task.returncode == 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_raise_error_on_failure():
|
|
96
|
+
with pytest.raises(Exception, match="Commands failed"):
|
|
97
|
+
run_command(["false"], quiet=True, raise_error=True)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from multiCMD import run_commands
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_batch_stdout():
|
|
10
|
+
assert run_commands(
|
|
11
|
+
[["echo", "hello"], ["echo", "world"]], quiet=True
|
|
12
|
+
) == [["hello"], ["world"]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_dry_run_and_return_codes():
|
|
16
|
+
cmds = [["echo", "hello"], ["echo", "world"]]
|
|
17
|
+
assert run_commands(cmds, dry_run=True, quiet=True) == [[], []]
|
|
18
|
+
assert run_commands(cmds, return_code_only=True, quiet=True) == [0, 0]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_return_objects_preserve_order():
|
|
22
|
+
tasks = run_commands(
|
|
23
|
+
[["echo", "hello"], ["false"], ["echo", "world"]],
|
|
24
|
+
return_object=True,
|
|
25
|
+
quiet=True,
|
|
26
|
+
)
|
|
27
|
+
assert [t.returncode for t in tasks] == [0, 1, 0]
|
|
28
|
+
assert tasks[0].stdout == ["hello"]
|
|
29
|
+
assert tasks[2].stdout == ["world"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_parse_expands_commands():
|
|
33
|
+
result = run_commands([["echo", "[0-10]"]], quiet=True, parse=True)
|
|
34
|
+
assert result == [[str(i)] for i in range(11)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.slow
|
|
38
|
+
def test_timeout_mixed_batch():
|
|
39
|
+
result = run_commands(
|
|
40
|
+
[["sleep", "2"], ["echo", "done"]],
|
|
41
|
+
timeout=0.3,
|
|
42
|
+
quiet=True,
|
|
43
|
+
with_stdErr=True,
|
|
44
|
+
max_threads=2,
|
|
45
|
+
)
|
|
46
|
+
assert "Timeout!" in "\n".join(result[0])
|
|
47
|
+
assert result[1] == ["done"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.slow
|
|
51
|
+
def test_max_threads_scheduling():
|
|
52
|
+
# Port of legacy test_run_commands_max_threads with slightly tighter sleeps.
|
|
53
|
+
# With max_threads=2, only 2 of the 3 sleeps can run concurrently, so the
|
|
54
|
+
# 3rd is queued behind the first 2 and won't start (let alone finish)
|
|
55
|
+
# until one of them completes around t=0.6s.
|
|
56
|
+
cmds = [["sleep", "0.6"], ["sleep", "0.6"], ["sleep", "0.6"]]
|
|
57
|
+
tasks = run_commands(
|
|
58
|
+
cmds,
|
|
59
|
+
wait_for_return=False,
|
|
60
|
+
timeout=0,
|
|
61
|
+
quiet=True,
|
|
62
|
+
return_object=True,
|
|
63
|
+
max_threads=2,
|
|
64
|
+
)
|
|
65
|
+
time.sleep(0.2)
|
|
66
|
+
alive = sum(1 for t in tasks if t.returncode is None)
|
|
67
|
+
assert alive == 3
|
|
68
|
+
|
|
69
|
+
# By ~0.8s, exactly 2 of the 3 sleeps have had time to finish (the ones
|
|
70
|
+
# that started immediately); the 3rd was blocked until ~0.6s and is
|
|
71
|
+
# still running. If max_threads were not enforced (e.g. unbounded),
|
|
72
|
+
# all 3 would already be finished by this point.
|
|
73
|
+
time.sleep(0.6)
|
|
74
|
+
finished = sum(1 for t in tasks if t.returncode is not None)
|
|
75
|
+
assert finished == 2
|
|
76
|
+
|
|
77
|
+
deadline = time.time() + 3
|
|
78
|
+
while any(t.returncode is None for t in tasks) and time.time() < deadline:
|
|
79
|
+
time.sleep(0.05)
|
|
80
|
+
assert [t.returncode for t in tasks] == [0, 0, 0]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.slow
|
|
84
|
+
def test_max_threads_scheduling_wall_time():
|
|
85
|
+
# Direct wall-time invariant: with max_threads=2, three 0.6s sleeps must
|
|
86
|
+
# take noticeably longer than a single 0.6s batch (2 batches of work),
|
|
87
|
+
# whereas with an unbounded/8-thread pool they'd all run concurrently
|
|
88
|
+
# and finish in ~0.6s.
|
|
89
|
+
cmds = [["sleep", "0.6"], ["sleep", "0.6"], ["sleep", "0.6"]]
|
|
90
|
+
start = time.time()
|
|
91
|
+
run_commands(
|
|
92
|
+
cmds,
|
|
93
|
+
wait_for_return=True,
|
|
94
|
+
timeout=0,
|
|
95
|
+
quiet=True,
|
|
96
|
+
max_threads=2,
|
|
97
|
+
)
|
|
98
|
+
elapsed = time.time() - start
|
|
99
|
+
assert elapsed >= 1.1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.slow
|
|
103
|
+
def test_shared_semaphore_bounds_concurrency():
|
|
104
|
+
sem = threading.Semaphore(1)
|
|
105
|
+
# With sem=1, two 0.3s sleeps cannot overlap even if max_threads=2
|
|
106
|
+
t0 = time.time()
|
|
107
|
+
run_commands(
|
|
108
|
+
[["sleep", "0.3"], ["sleep", "0.3"]],
|
|
109
|
+
quiet=True,
|
|
110
|
+
max_threads=2,
|
|
111
|
+
sem=sem,
|
|
112
|
+
)
|
|
113
|
+
elapsed = time.time() - t0
|
|
114
|
+
assert elapsed >= 0.55
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_raise_error_batch():
|
|
118
|
+
with pytest.raises(Exception, match="Commands failed"):
|
|
119
|
+
run_commands([["true"], ["false"]], quiet=True, raise_error=True)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import multiCMD
|
|
6
|
+
from conftest import has_sudo_binary
|
|
7
|
+
from multiCMD import run_command, set_sudo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_set_sudo_false():
|
|
11
|
+
multiCMD.USE_SUDO = True
|
|
12
|
+
set_sudo(False)
|
|
13
|
+
assert multiCMD.USE_SUDO is False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_set_sudo_true_when_root():
|
|
17
|
+
if os.geteuid() != 0:
|
|
18
|
+
pytest.skip("requires root to exercise root-disables-sudo path")
|
|
19
|
+
set_sudo(True)
|
|
20
|
+
assert multiCMD.USE_SUDO is False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_set_sudo_true_without_sudo_binary(capsys):
|
|
24
|
+
multiCMD.SUDO_PATH = None
|
|
25
|
+
if os.geteuid() == 0:
|
|
26
|
+
pytest.skip("root path short-circuits before SUDO_PATH check")
|
|
27
|
+
set_sudo(True)
|
|
28
|
+
assert multiCMD.USE_SUDO is False
|
|
29
|
+
err = capsys.readouterr().err
|
|
30
|
+
assert "sudo not found" in err
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.sudo
|
|
34
|
+
def test_set_sudo_true_non_root_with_binary():
|
|
35
|
+
if os.geteuid() == 0 or not has_sudo_binary():
|
|
36
|
+
pytest.skip("needs non-root and sudo in PATH")
|
|
37
|
+
set_sudo(True)
|
|
38
|
+
assert multiCMD.USE_SUDO is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_use_sudo_none_path_does_not_crash():
|
|
42
|
+
multiCMD.SUDO_PATH = None
|
|
43
|
+
# Must not raise TypeError from Popen([None, ...])
|
|
44
|
+
result = run_command(["echo", "ok"], quiet=True, use_sudo=True)
|
|
45
|
+
assert result == ["ok"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from multiCMD import Task, run_command
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_task_defaults_and_iter():
|
|
5
|
+
t = Task(["echo", "x"])
|
|
6
|
+
assert t.returncode is None
|
|
7
|
+
assert t.stdout == []
|
|
8
|
+
assert t.stderr == []
|
|
9
|
+
assert t.stop is False
|
|
10
|
+
assert dict(t) == {
|
|
11
|
+
"command": ["echo", "x"],
|
|
12
|
+
"returncode": None,
|
|
13
|
+
"stdout": [],
|
|
14
|
+
"stderr": [],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_task_repr_str_and_alive():
|
|
19
|
+
t = run_command(["echo", "hi"], return_object=True, quiet=True)
|
|
20
|
+
assert "hi" in str(t)
|
|
21
|
+
assert "Task(" in repr(t)
|
|
22
|
+
assert t.is_alive() is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|