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.
- {multicmd-1.48 → multicmd-1.49}/PKG-INFO +5 -2
- {multicmd-1.48 → multicmd-1.49}/README.md +4 -1
- {multicmd-1.48 → multicmd-1.49}/multiCMD.egg-info/PKG-INFO +5 -2
- multicmd-1.49/multiCMD.egg-info/SOURCES.txt +19 -0
- {multicmd-1.48 → multicmd-1.49}/multiCMD.py +9 -7
- multicmd-1.49/tests/test_async_executor.py +52 -0
- multicmd-1.49/tests/test_cli.py +45 -0
- multicmd-1.49/tests/test_expand_ranges.py +48 -0
- multicmd-1.49/tests/test_helpers.py +76 -0
- multicmd-1.49/tests/test_ping.py +31 -0
- multicmd-1.49/tests/test_regressions.py +68 -0
- multicmd-1.49/tests/test_run_command.py +97 -0
- multicmd-1.49/tests/test_run_commands.py +119 -0
- multicmd-1.49/tests/test_sudo.py +45 -0
- multicmd-1.49/tests/test_task.py +22 -0
- multicmd-1.48/multiCMD.egg-info/SOURCES.txt +0 -9
- {multicmd-1.48 → multicmd-1.49}/multiCMD.egg-info/dependency_links.txt +0 -0
- {multicmd-1.48 → multicmd-1.49}/multiCMD.egg-info/entry_points.txt +0 -0
- {multicmd-1.48 → multicmd-1.49}/multiCMD.egg-info/requires.txt +0 -0
- {multicmd-1.48 → multicmd-1.49}/multiCMD.egg-info/top_level.txt +0 -0
- {multicmd-1.48 → multicmd-1.49}/setup.cfg +0 -0
- {multicmd-1.48 → multicmd-1.49}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiCMD
|
|
3
|
-
Version: 1.
|
|
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.
|
|
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.
|
|
25
|
+
version = '1.49'
|
|
26
26
|
__version__ = version
|
|
27
|
-
COMMIT_DATE = '2026-
|
|
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
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|