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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.50
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 queued command's `task.thread` is `None` until it starts,
213
- and `stop()` skips commands that have not started yet.
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 queued command's `task.thread` is `None` until it starts,
187
- and `stop()` skips commands that have not started yet.
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.50
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 queued command's `task.thread` is `None` until it starts,
213
- and `stop()` skips commands that have not started yet.
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.50'
26
+ version = '1.51'
27
27
  __version__ = version
28
- COMMIT_DATE = '2026-10-09'
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 queued command's task.thread stays None
100
- until it starts.
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
- task.thread = self._start_thread(_run_executor_task, (task, _HeldPermit(sem), timeout, quiet, dry_run, self._finished))
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 all(task.thread is not None and not task.is_alive() for task in tasks)
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([['true'], ['true']])
128
+ tasks = ex.run_commands([['sleep', '0.3'], ['sleep', '0.3']])
129
+ # finished tasks drop their thread, so take the threads while the commands run
130
+ deadline = time.monotonic() + 5
131
+ while not all(task.thread for task in tasks) and time.monotonic() < deadline:
132
+ time.sleep(0.01)
133
+ threads = {task.thread for task in tasks}
134
+ assert None not in threads
135
+ assert not threads & _global_threads()
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