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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiCMD
3
- Version: 1.48
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.48
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.48'
26
+ version = '1.50'
26
27
  __version__ = version
27
- COMMIT_DATE = '2026-06-04'
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.__lastNotJoined = 0
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
- if len(self.runningThreads) > 130000:
138
- self.wait(timeout=0)
139
- if len(self.runningThreads) > 130000:
140
- print('The amount of running threads approching cpython limit of 130704. Waiting until some available.')
141
- while len(self.runningThreads) > 120000:
142
- self.wait(timeout=1)
143
- elif len(self.runningThreads) + self.__lastNotJoined > 1000:
144
- self.wait(timeout=0)
145
- self.__lastNotJoined = len(self.runningThreads)
146
- taskObjects: list[Task] = run_commands(commands,timeout=timeout,max_threads=max_threads,quiet=quiet,dry_run=dry_run,with_stdErr=False,
147
- return_code_only=False,return_object=True, parse = parse, wait_for_return = False, sem = sem)
148
- self.tasks.extend(taskObjects)
149
- self.runningThreads.extend([task.thread for task in taskObjects])
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 command to override the object default
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
- for thread in threads:
182
- if timeout >= 0:
183
- thread.join(timeout=timeout)
184
- else:
185
- thread.join()
186
- self.runningThreads = [thread for thread in self.runningThreads if thread.is_alive()]
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.tasks = []
212
- self.runningThreads = []
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 task.returncode is None:
552
- # process been killed via timeout or sigkill
553
- if task.stderr and task.stderr[-1].strip().startswith('Timeout!'):
554
- task.returncode = 124
555
- elif task.stderr and task.stderr[-1].strip().startswith('Ctrl C detected, Emergency Stop!'):
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
- if use_sudo:
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(formatedCommands)
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
- # validate sudo access
739
- __run_command(Task([SUDO_PATH, '-v']),sem,timeout=60,quiet=True,dry_run=False,with_stdErr=False,identity=None)
740
- if max_threads > 1 or not wait_for_return:
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
- if wait_for_return:
746
- for thread in threads:
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
- threads = __running_threads
950
+ with __running_threads_lock:
951
+ threads = list(__running_threads)
781
952
  for thread in threads:
782
953
  thread.join(timeout=timeout)
783
- if threads is __running_threads:
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
@@ -1,9 +0,0 @@
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
File without changes
File without changes