multiCMD 1.50__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.50 → multicmd-1.51}/PKG-INFO +5 -3
- {multicmd-1.50 → multicmd-1.51}/README.md +4 -2
- {multicmd-1.50 → multicmd-1.51}/multiCMD.egg-info/PKG-INFO +5 -3
- {multicmd-1.50 → multicmd-1.51}/multiCMD.py +19 -10
- {multicmd-1.50 → multicmd-1.51}/tests/test_async_executor.py +23 -4
- {multicmd-1.50 → multicmd-1.51}/multiCMD.egg-info/SOURCES.txt +0 -0
- {multicmd-1.50 → multicmd-1.51}/multiCMD.egg-info/dependency_links.txt +0 -0
- {multicmd-1.50 → multicmd-1.51}/multiCMD.egg-info/entry_points.txt +0 -0
- {multicmd-1.50 → multicmd-1.51}/multiCMD.egg-info/requires.txt +0 -0
- {multicmd-1.50 → multicmd-1.51}/multiCMD.egg-info/top_level.txt +0 -0
- {multicmd-1.50 → multicmd-1.51}/setup.cfg +0 -0
- {multicmd-1.50 → multicmd-1.51}/setup.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_cli.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_expand_ranges.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_helpers.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_ping.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_regressions.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_run_command.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_run_commands.py +0 -0
- {multicmd-1.50 → multicmd-1.51}/tests/test_sudo.py +0 -0
- {multicmd-1.50 → 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
|
|
@@ -209,8 +209,10 @@ print(ex.get_results())
|
|
|
209
209
|
`run_command(s)` returns right away and queues the commands. One dispatcher thread
|
|
210
210
|
starts each queued command only after taking a permit from the semaphore, so the
|
|
211
211
|
executor holds at most one thread per permit plus the dispatcher, however many
|
|
212
|
-
commands are queued. A
|
|
213
|
-
|
|
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.
|
|
214
216
|
|
|
215
217
|
### Range expansion from Python
|
|
216
218
|
|
|
@@ -183,8 +183,10 @@ print(ex.get_results())
|
|
|
183
183
|
`run_command(s)` returns right away and queues the commands. One dispatcher thread
|
|
184
184
|
starts each queued command only after taking a permit from the semaphore, so the
|
|
185
185
|
executor holds at most one thread per permit plus the dispatcher, however many
|
|
186
|
-
commands are queued. A
|
|
187
|
-
|
|
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.
|
|
188
190
|
|
|
189
191
|
### Range expansion from Python
|
|
190
192
|
|
|
@@ -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
|
|
@@ -209,8 +209,10 @@ print(ex.get_results())
|
|
|
209
209
|
`run_command(s)` returns right away and queues the commands. One dispatcher thread
|
|
210
210
|
starts each queued command only after taking a permit from the semaphore, so the
|
|
211
211
|
executor holds at most one thread per permit plus the dispatcher, however many
|
|
212
|
-
commands are queued. A
|
|
213
|
-
|
|
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.
|
|
214
216
|
|
|
215
217
|
### Range expansion from Python
|
|
216
218
|
|
|
@@ -23,9 +23,9 @@ import time
|
|
|
23
23
|
from pprint import pformat
|
|
24
24
|
|
|
25
25
|
#%% global vars
|
|
26
|
-
version = '1.
|
|
26
|
+
version = '1.51'
|
|
27
27
|
__version__ = version
|
|
28
|
-
COMMIT_DATE = '2026-10-
|
|
28
|
+
COMMIT_DATE = '2026-10-10'
|
|
29
29
|
# fire-and-forget threads (run_commands with wait_for_return=False) that join_threads() waits on
|
|
30
30
|
__running_threads = set()
|
|
31
31
|
__running_threads_lock = threading.Lock()
|
|
@@ -96,8 +96,9 @@ class AsyncExecutor:
|
|
|
96
96
|
|
|
97
97
|
Commands are queued and a dispatcher thread starts each one only after taking a permit
|
|
98
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
|
|
100
|
-
|
|
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.
|
|
101
102
|
@params:
|
|
102
103
|
max_threads: The maximum number of threads to use ( int ) ( Note: if passing semaphore, this likely will be ignored )
|
|
103
104
|
semaphore: The semaphore to use for threading ( threading.Semaphore )
|
|
@@ -158,23 +159,31 @@ class AsyncExecutor:
|
|
|
158
159
|
'''
|
|
159
160
|
while True:
|
|
160
161
|
try:
|
|
161
|
-
thread = self._finished.popleft()
|
|
162
|
+
thread, task = self._finished.popleft()
|
|
162
163
|
except IndexError:
|
|
163
164
|
return
|
|
164
165
|
thread.join()
|
|
165
166
|
with self._lock:
|
|
166
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
|
|
167
171
|
|
|
168
|
-
def _start_thread(self, target, args):
|
|
172
|
+
def _start_thread(self, target, args, task=None):
|
|
169
173
|
thread = threading.Thread(target=target, args=args, daemon=True)
|
|
170
174
|
# registered before it starts, so it is tracked before it can report itself finished
|
|
171
175
|
with self._lock:
|
|
172
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
|
|
173
180
|
try:
|
|
174
181
|
thread.start()
|
|
175
182
|
except BaseException:
|
|
176
183
|
# never started: it would never report itself finished
|
|
177
184
|
del self._threads[thread]
|
|
185
|
+
if task is not None:
|
|
186
|
+
task.thread = None
|
|
178
187
|
raise
|
|
179
188
|
return thread
|
|
180
189
|
|
|
@@ -199,7 +208,7 @@ class AsyncExecutor:
|
|
|
199
208
|
continue
|
|
200
209
|
self._reap()
|
|
201
210
|
try:
|
|
202
|
-
|
|
211
|
+
self._start_thread(_run_executor_task, (task, _HeldPermit(sem), timeout, quiet, dry_run, self._finished), task)
|
|
203
212
|
except BaseException:
|
|
204
213
|
sem.release()
|
|
205
214
|
raise
|
|
@@ -209,7 +218,7 @@ class AsyncExecutor:
|
|
|
209
218
|
self._dispatching = False
|
|
210
219
|
raise
|
|
211
220
|
finally:
|
|
212
|
-
self._finished.append(threading.current_thread())
|
|
221
|
+
self._finished.append((threading.current_thread(), None))
|
|
213
222
|
|
|
214
223
|
def run_commands(self, commands, timeout=...,max_threads=...,quiet=...,dry_run=...,parse = ...,sem = ...):
|
|
215
224
|
'''
|
|
@@ -696,12 +705,12 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
|
|
|
696
705
|
|
|
697
706
|
def _run_executor_task(task, permit, timeout, quiet, dry_run, finished):
|
|
698
707
|
'''
|
|
699
|
-
Run an AsyncExecutor command, then report this thread finished so the executor joins it ( internal )
|
|
708
|
+
Run an AsyncExecutor command, then report this thread finished so the executor joins it and drops it from the task ( internal )
|
|
700
709
|
'''
|
|
701
710
|
try:
|
|
702
711
|
__run_command(task, permit, timeout, quiet, dry_run, identity=...)
|
|
703
712
|
finally:
|
|
704
|
-
finished.append(threading.current_thread())
|
|
713
|
+
finished.append((threading.current_thread(), task))
|
|
705
714
|
|
|
706
715
|
def __run_tracked_command(task, sem, timeout, quiet, dry_run, identity):
|
|
707
716
|
'''
|
|
@@ -69,7 +69,10 @@ def _stack_mappings(size):
|
|
|
69
69
|
return count
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def test_executor_queues_commands_instead_of_parking_threads():
|
|
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)))
|
|
73
76
|
gate = threading.Semaphore(0)
|
|
74
77
|
baseline = threading.active_count()
|
|
75
78
|
ex = AsyncExecutor(semaphore=gate, quiet=True, dry_run=True)
|
|
@@ -83,7 +86,8 @@ def test_executor_queues_commands_instead_of_parking_threads():
|
|
|
83
86
|
gate.release()
|
|
84
87
|
ex.wait(timeout=-1)
|
|
85
88
|
assert ex.runningThreads == []
|
|
86
|
-
assert
|
|
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)
|
|
87
91
|
|
|
88
92
|
|
|
89
93
|
@pytest.mark.slow
|
|
@@ -121,9 +125,15 @@ def test_executor_releases_finished_thread_stacks():
|
|
|
121
125
|
|
|
122
126
|
def test_executor_threads_stay_out_of_global_set():
|
|
123
127
|
ex = AsyncExecutor(max_threads=2, quiet=True)
|
|
124
|
-
tasks = ex.run_commands([['
|
|
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()
|
|
125
136
|
ex.wait(timeout=-1)
|
|
126
|
-
assert not {task.thread for task in tasks} & _global_threads()
|
|
127
137
|
|
|
128
138
|
|
|
129
139
|
def test_finished_fire_and_forget_threads_leave_global_set():
|
|
@@ -134,6 +144,15 @@ def test_finished_fire_and_forget_threads_leave_global_set():
|
|
|
134
144
|
assert not {task.thread for task in tasks} & _global_threads()
|
|
135
145
|
|
|
136
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
|
+
|
|
137
156
|
def test_stop_skips_queued_commands():
|
|
138
157
|
ex = AsyncExecutor(semaphore=threading.Semaphore(0), quiet=True)
|
|
139
158
|
tasks = ex.run_commands([['sleep', '5']] * 3)
|
|
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
|