multiCMD 1.48__tar.gz → 1.49__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.49
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -268,9 +268,12 @@ Run the test suite with [pytest](https://pytest.org):
268
268
 
269
269
  ```bash
270
270
  pip install pytest
271
- pytest
271
+ pytest # full suite under tests/
272
+ pytest -m "not slow" # skip intentional sleep-heavy cases
272
273
  ```
273
274
 
275
+ `test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
276
+
274
277
  ## License
275
278
 
276
279
  GPLv3+ — see the package metadata. Authored by Yufei Pan (<pan@zopyr.us>).
@@ -242,9 +242,12 @@ Run the test suite with [pytest](https://pytest.org):
242
242
 
243
243
  ```bash
244
244
  pip install pytest
245
- pytest
245
+ pytest # full suite under tests/
246
+ pytest -m "not slow" # skip intentional sleep-heavy cases
246
247
  ```
247
248
 
249
+ `test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
250
+
248
251
  ## License
249
252
 
250
253
  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.49
4
4
  Summary: Run commands simultaneously
5
5
  Home-page: https://github.com/yufei-pan/multiCMD
6
6
  Author: Yufei Pan
@@ -268,9 +268,12 @@ Run the test suite with [pytest](https://pytest.org):
268
268
 
269
269
  ```bash
270
270
  pip install pytest
271
- pytest
271
+ pytest # full suite under tests/
272
+ pytest -m "not slow" # skip intentional sleep-heavy cases
272
273
  ```
273
274
 
275
+ `test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
276
+
274
277
  ## License
275
278
 
276
279
  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
@@ -22,9 +22,9 @@ import time
22
22
  from pprint import pformat
23
23
 
24
24
  #%% global vars
25
- version = '1.48'
25
+ version = '1.49'
26
26
  __version__ = version
27
- COMMIT_DATE = '2026-06-04'
27
+ COMMIT_DATE = '2026-08-10'
28
28
  __running_threads = set()
29
29
  __variables = {}
30
30
 
@@ -503,6 +503,7 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
503
503
  outLength = len(task.stdout) + len(task.stderr)
504
504
  time.sleep(0)
505
505
  sleep_time = 1.0e-7 # 100 nanoseconds
506
+ inactivity_timeout = False
506
507
  while proc.poll() is None: # while the process is still running
507
508
  if task.stop:
508
509
  proc.send_signal(signal.SIGINT)
@@ -515,6 +516,7 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
515
516
  outLength = len(task.stdout) + len(task.stderr)
516
517
  elif time.time() - start_time > timeout:
517
518
  task.stderr.append('Timeout!')
519
+ inactivity_timeout = True
518
520
  proc.send_signal(signal.SIGINT)
519
521
  time.sleep(0.01)
520
522
  proc.terminate()
@@ -548,11 +550,11 @@ def __run_command(task,sem, timeout=60, quiet=False,dry_run=False,with_stdErr=Fa
548
550
  stream.close()
549
551
  except Exception:
550
552
  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!'):
553
+ if inactivity_timeout:
554
+ task.returncode = 124
555
+ elif task.returncode is None:
556
+ # process been killed via sigkill or similar without a normal exit code
557
+ if task.stderr and task.stderr[-1].strip().startswith('Ctrl C detected, Emergency Stop!'):
556
558
  task.returncode = 137
557
559
  else:
558
560
  task.returncode = -1
@@ -0,0 +1,52 @@
1
+ import time
2
+
3
+ import pytest
4
+
5
+ import multiCMD
6
+ from multiCMD import AsyncExecutor, run_commands
7
+
8
+
9
+ @pytest.mark.slow
10
+ def test_executor_run_wait_results_codes():
11
+ ex = AsyncExecutor(max_threads=2, timeout=0, quiet=True)
12
+ ex.run_command(["echo", "a"])
13
+ ex.run_commands([["echo", "b"], ["false"]])
14
+ assert len(ex) == 3
15
+ assert bool(ex)
16
+ ex.wait(timeout=2)
17
+ assert ex.get_return_codes() == [0, 0, 1]
18
+ assert ex.get_results() == [["a"], ["b"], []]
19
+
20
+
21
+ @pytest.mark.slow
22
+ def test_executor_join_prints_errors(capsys):
23
+ ex = AsyncExecutor(max_threads=1, quiet=True)
24
+ ex.run_command(["false"])
25
+ ex.join(timeout=2, print_error=True)
26
+ captured = capsys.readouterr()
27
+ assert "failed with return code" in captured.out
28
+
29
+
30
+ @pytest.mark.slow
31
+ def test_executor_stop_and_cleanup():
32
+ ex = AsyncExecutor(max_threads=1, quiet=True)
33
+ ex.run_command(["sleep", "5"])
34
+ time.sleep(0.1)
35
+ ex.stop(timeout=3)
36
+ # stop signals task.stop; process should end
37
+ assert all(not t.is_alive() for t in ex.tasks)
38
+ ex.cleanup(timeout=1)
39
+ assert list(ex) == []
40
+
41
+
42
+ @pytest.mark.slow
43
+ def test_join_threads_module_helper():
44
+ tasks = run_commands(
45
+ [["sleep", "0.3"]],
46
+ wait_for_return=False,
47
+ return_object=True,
48
+ quiet=True,
49
+ )
50
+ assert tasks[0].returncode is None
51
+ multiCMD.join_threads(timeout=2)
52
+ assert tasks[0].returncode == 0
@@ -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