multiCMD 1.54__tar.gz → 1.56__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.54 → multicmd-1.56}/PKG-INFO +127 -23
- {multicmd-1.54 → multicmd-1.56}/README.md +126 -22
- {multicmd-1.54 → multicmd-1.56}/multiCMD.egg-info/PKG-INFO +127 -23
- {multicmd-1.54 → multicmd-1.56}/multiCMD.egg-info/SOURCES.txt +2 -0
- {multicmd-1.54 → multicmd-1.56}/multiCMD.py +1088 -230
- multicmd-1.56/tests/test_async_executor.py +819 -0
- multicmd-1.56/tests/test_env.py +965 -0
- multicmd-1.56/tests/test_interrupts.py +623 -0
- multicmd-1.56/tests/test_pool.py +194 -0
- multicmd-1.56/tests/test_process_groups.py +281 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_pty.py +1 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_run_command.py +45 -0
- multicmd-1.56/tests/test_supervision.py +500 -0
- multicmd-1.54/tests/test_async_executor.py +0 -251
- multicmd-1.54/tests/test_pool.py +0 -70
- multicmd-1.54/tests/test_process_groups.py +0 -155
- multicmd-1.54/tests/test_supervision.py +0 -207
- {multicmd-1.54 → multicmd-1.56}/LICENSE +0 -0
- {multicmd-1.54 → multicmd-1.56}/multiCMD.egg-info/dependency_links.txt +0 -0
- {multicmd-1.54 → multicmd-1.56}/multiCMD.egg-info/entry_points.txt +0 -0
- {multicmd-1.54 → multicmd-1.56}/multiCMD.egg-info/top_level.txt +0 -0
- {multicmd-1.54 → multicmd-1.56}/setup.cfg +0 -0
- {multicmd-1.54 → multicmd-1.56}/setup.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_api.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_cli.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_cli_input.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_cli_summary.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_display.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_expand_ranges.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_helpers.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_parsing.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_ping.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_reader.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_regressions.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_run_commands.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_sudo.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_task.py +0 -0
- {multicmd-1.54 → multicmd-1.56}/tests/test_windows.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiCMD
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.56
|
|
4
4
|
Summary: Run commands simultaneously
|
|
5
5
|
Home-page: https://github.com/yufei-pan/multiCMD
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -71,8 +71,8 @@ You can also just drop `multiCMD.py` into your project and import it directly.
|
|
|
71
71
|
```bash
|
|
72
72
|
$ mcmd -h
|
|
73
73
|
usage: mcmd [-h] [-p] [-t SECONDS] [-m max_threads] [--sudo] [-q] [-n]
|
|
74
|
-
[--shell] [-f FILE] [-a FILE] [--summary |
|
|
75
|
-
[--no-prefix] [--no-color] [--pty | --no-pty] [-V]
|
|
74
|
+
[--shell] [-e NAME=VALUE] [-f FILE] [-a FILE] [--summary |
|
|
75
|
+
--no-summary] [--no-prefix] [--no-color] [--pty | --no-pty] [-V]
|
|
76
76
|
[command ...]
|
|
77
77
|
|
|
78
78
|
Run multiple commands in parallel
|
|
@@ -96,6 +96,8 @@ options:
|
|
|
96
96
|
-n, --dry-run print each command, quoted, instead of running it
|
|
97
97
|
--shell run each command through /bin/sh -c ( pipes, ;, > and
|
|
98
98
|
globs work )
|
|
99
|
+
-e, --env NAME=VALUE set NAME to VALUE in the environment of every command
|
|
100
|
+
( repeatable ); NAME alone removes it
|
|
99
101
|
-f, --file FILE read commands from FILE, one per line ( - for stdin;
|
|
100
102
|
blank lines and # comments are skipped )
|
|
101
103
|
-a, --arg-file FILE run each command template once per line of FILE, with
|
|
@@ -293,10 +295,47 @@ multiCMD.run_commands(cmds, max_threads=8, color=False) # never color
|
|
|
293
295
|
multiCMD.run_command(["python3", "train.py"], pty=True) # live output, stderr merged
|
|
294
296
|
```
|
|
295
297
|
|
|
298
|
+
### Environment per call
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
multiCMD.run_command(["xfs_info", "/data"], extra_env={"LC_ALL": "C"}) # add / override variables
|
|
302
|
+
multiCMD.run_command(["env"], extra_env={"HTTP_PROXY": None}) # None removes one
|
|
303
|
+
multiCMD.run_command(["env"], env={"PATH": "/usr/bin", "HOME": "/tmp"}) # the whole environment, as subprocess's env
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`env` is the whole environment of the command, as `subprocess`'s `env` ( names and values
|
|
307
|
+
are `str`; on POSIX `bytes` and `os.PathLike` work too ). `extra_env` is applied on top of the
|
|
308
|
+
inherited environment, or of `env` when both are given; a `None` value removes the variable,
|
|
309
|
+
and an empty `extra_env` is the same as none. "Inherited" is `os.environ` as the process holds
|
|
310
|
+
it when the command starts: a variable set behind Python's back ( `os.putenv`, `os.unsetenv`, a
|
|
311
|
+
C library's `setenv` ) reaches commands started without `env` / `extra_env`, not the ones
|
|
312
|
+
given them. An inherited variable with an empty name ( a corrupt `environ` entry such as `=x` ) is left out of the environment of a command given `env` / `extra_env`, and out of an `env` copied from `os.environ`; an empty name in `extra_env` is a `ValueError`. Names are case-insensitive on Windows, so `extra_env={"Path": ...}` replaces `PATH`
|
|
313
|
+
there. On POSIX a command is looked up in the `PATH` of the environment it gets, on every Python
|
|
314
|
+
version; Windows searches the calling process's `PATH` whatever the command's environment says.
|
|
315
|
+
Before Python 3.11 the launcher starts in this process's environment and is handed the command's on
|
|
316
|
+
top of it, so the two together must fit the system's argument limit ( `getconf ARG_MAX`; 2 MiB on Linux
|
|
317
|
+
with the default stack, 128 KiB for one string: before Python 3.11 an entry travels as `_MULTICMD_ENV_<i>=NAME=VALUE`, 15 bytes
|
|
318
|
+
plus the digits of `i` more than its `NAME=VALUE` ); one that does not fit fails with return
|
|
319
|
+
code 126, `Argument list too long`, on every Python.
|
|
320
|
+
On Python 3.7-3.10 a command started with
|
|
321
|
+
neither `env` nor `extra_env` still gets the `LC_CTYPE=C.UTF-8` that interpreter's locale coercion
|
|
322
|
+
sets in a C locale ( as before ); a command given one of them gets exactly the environment described.
|
|
323
|
+
Both are per command: they never change `os.environ` and never reach another command, so
|
|
324
|
+
concurrent commands each see their own. `AsyncExecutor` takes them too, as defaults a call
|
|
325
|
+
overrides. `sudo` resets the environment by default, so with `use_sudo` they may not reach the
|
|
326
|
+
command. On the command line: `mcmd -e LC_ALL=C -e UNSET_ME "cmd"`; `-n` shows them as
|
|
327
|
+
`env LC_ALL=C cmd` ( `env -i ...` for a whole environment ).
|
|
328
|
+
|
|
296
329
|
`raise_error=True` raises `multiCMD.CommandError`; its `.tasks` holds the failed `Task`
|
|
297
330
|
objects and its message lists each one with its return code and last error line.
|
|
298
331
|
`Task` also records `start_time`, `end_time` and `duration`. With `dry_run=True` nothing
|
|
299
332
|
runs; each command is printed once, quoted so it can be pasted into a shell.
|
|
333
|
+
A command that is not found (return code 127), cannot be executed (126) or that multiCMD
|
|
334
|
+
itself could not run (-1, `task._error`) says why in its `task.stderr`; multiCMD also
|
|
335
|
+
prints a line saying so on stderr, unless `quiet=True`. Before Python 3.11 the exec of a
|
|
336
|
+
command given by its path, or of a file found in `PATH` that may not be executed, happens in
|
|
337
|
+
the launcher's process: it exits 127 / 126 with the reason as the command's own stderr, which
|
|
338
|
+
then shows with the command's output ( unless quiet ) instead of as a line of multiCMD's own.
|
|
300
339
|
|
|
301
340
|
### Asynchronous / fire-and-forget
|
|
302
341
|
|
|
@@ -309,7 +348,8 @@ tasks = multiCMD.run_commands(
|
|
|
309
348
|
max_threads=2, quiet=True,
|
|
310
349
|
wait_for_return=False, return_object=True,
|
|
311
350
|
)
|
|
312
|
-
# tasks[i].returncode is None until that command completes
|
|
351
|
+
# tasks[i].returncode is None until that command completes; tasks[i].thread.join(timeout)
|
|
352
|
+
# waits for one command ( a command whose thread could not start fails with -1, its thread already done )
|
|
313
353
|
|
|
314
354
|
# Later, block until everything launched this way has finished:
|
|
315
355
|
multiCMD.join_threads()
|
|
@@ -336,7 +376,8 @@ executor holds at most one thread per permit plus the dispatcher, however many
|
|
|
336
376
|
commands are queued. A command's `task.thread` is `None` until it starts, and goes
|
|
337
377
|
back to `None` once it has finished and the executor has joined its thread, so a
|
|
338
378
|
large batch does not keep a `Thread` object per finished command. `stop()` skips
|
|
339
|
-
commands that have not started yet
|
|
379
|
+
commands that have not started yet, and so does a Ctrl-C ( or an error ) that interrupts
|
|
380
|
+
`run_command(s)`, for that call's commands.
|
|
340
381
|
|
|
341
382
|
### Range expansion from Python
|
|
342
383
|
|
|
@@ -370,21 +411,37 @@ default, in the API and on the command line) disables it.
|
|
|
370
411
|
|
|
371
412
|
## Process groups and signals
|
|
372
413
|
|
|
373
|
-
Every command runs in a process group of its own, so a timeout, `stop()` or Ctrl-C
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
arrives), can call `multiCMD.kill_all()` from its
|
|
414
|
+
Every command runs in a process group of its own, so a timeout, `stop()` or Ctrl-C reaches the
|
|
415
|
+
whole process tree, not just the first process. On Python 3.11+ this uses `Popen(process_group=0)`;
|
|
416
|
+
older Pythons start the command through a tiny exec wrapper. A command that reads the terminal (a
|
|
417
|
+
password prompt, for example) fails at once instead of hanging: multiCMD blocks SIGTTIN/SIGTTOU for
|
|
418
|
+
it, and `mcmd` also ignores them, which every command inherits through any shell.
|
|
419
|
+
|
|
420
|
+
Signals sent to your program's group do not reach the commands, so multiCMD stops them itself. One
|
|
421
|
+
Ctrl-C (or a SIGTERM handler's `sys.exit()`) in a call waiting on the main thread, `join_threads()`
|
|
422
|
+
and `AsyncExecutor.wait()` included, stops every command, skips the queued ones and
|
|
423
|
+
re-raises, giving them up to 5 s to exit; a second Ctrl-C ends that wait at once. `mcmd` stops its
|
|
424
|
+
commands on SIGTERM / SIGHUP (unless started with them ignored, as under `nohup`). At interpreter exit
|
|
425
|
+
the commands still running are killed, and a command queued on one of multiCMD's threads fails (-1,
|
|
426
|
+
"not started: the interpreter is exiting") instead of outliving the program; one run from an `atexit`
|
|
427
|
+
handler after that, on the main thread, runs as usual. A program that handles SIGTERM itself, or runs
|
|
428
|
+
multiCMD from its own threads (where Ctrl-C never arrives), can call `multiCMD.kill_all()` from its
|
|
429
|
+
handler. A command not seen to exit a second after SIGKILL is no longer tracked; its pid is named on
|
|
430
|
+
stderr, unless quiet.
|
|
431
|
+
|
|
432
|
+
multiCMD never touches SIGINT's handler (`mcmd` sets its own SIGTERM / SIGHUP handlers, nothing
|
|
433
|
+
else). What your handler raises while a call waits for its commands is handled as a Ctrl-C.
|
|
434
|
+
|
|
435
|
+
A command is registered from the moment `Popen` returns it: a Ctrl-C that Python raises inside
|
|
436
|
+
`Popen` as a serial run starts a command (some 50-70 µs each) can leave it running, unknown to
|
|
437
|
+
`kill_all()`. Two Ctrl-Cs microseconds apart can leave a command running, still registered, until
|
|
438
|
+
`kill_all()` or the exit stops it. On Python 3.6-3.9, which raise a Ctrl-C at nearly any instruction,
|
|
439
|
+
even one at a few points between the steps of a run can leave its command registered but not signalled.
|
|
440
|
+
On Python 3.6-3.9 and 3.14 a Ctrl-C at the wrong instruction of a `with` on a lock can leave it held: one of
|
|
441
|
+
`threading`'s can hang the program as its threads end (CPython's own), and one of multiCMD's (an
|
|
442
|
+
`AsyncExecutor`'s queue, the live output) hangs that executor, or the next call that is not quiet, if the
|
|
443
|
+
program keeps going.
|
|
444
|
+
These and the other known limits are in [docs/limitations.md](docs/limitations.md).
|
|
388
445
|
|
|
389
446
|
## Key functions and objects
|
|
390
447
|
|
|
@@ -392,12 +449,13 @@ arrives), can call `multiCMD.kill_all()` from its handler.
|
|
|
392
449
|
run_command(command, timeout=0, max_threads=1, quiet=False, dry_run=False,
|
|
393
450
|
with_stdErr=False, return_code_only=False, return_object=False,
|
|
394
451
|
wait_for_return=True, sem=None, use_sudo=..., raise_error=False,
|
|
395
|
-
prefix=False, color=None, pty=False, shell=False)
|
|
452
|
+
prefix=False, color=None, pty=False, shell=False, env=None, extra_env=None)
|
|
396
453
|
|
|
397
454
|
run_commands(commands, timeout=0, max_threads=1, quiet=False, dry_run=False,
|
|
398
455
|
with_stdErr=False, return_code_only=False, return_object=False,
|
|
399
456
|
parse=False, wait_for_return=True, sem=None, use_sudo=...,
|
|
400
|
-
raise_error=False, prefix=False, color=None, pty=False, shell=False
|
|
457
|
+
raise_error=False, prefix=False, color=None, pty=False, shell=False,
|
|
458
|
+
env=None, extra_env=None)
|
|
401
459
|
|
|
402
460
|
ping(hosts, timeout=1, max_threads=0, ...) # returns True/False reachability
|
|
403
461
|
join_threads(threads=..., timeout=None) # join fire-and-forget threads
|
|
@@ -406,7 +464,8 @@ set_sudo(use_sudo) # enable/disable sudo globally
|
|
|
406
464
|
|
|
407
465
|
class Task: # command, returncode, stdout, stderr, start_time, end_time, duration
|
|
408
466
|
class CommandError: # raised by raise_error=True; .tasks = the failed Tasks
|
|
409
|
-
class AsyncExecutor: # run_command(s), wait, join, stop, cleanup, get_results, get_return_codes
|
|
467
|
+
class AsyncExecutor: # run_command(s), wait, join, stop, cleanup, get_results, get_return_codes;
|
|
468
|
+
# takes the options above, env / extra_env as defaults a call overrides
|
|
410
469
|
```
|
|
411
470
|
|
|
412
471
|
The module also bundles a few terminal/formatting helpers used internally and
|
|
@@ -432,6 +491,51 @@ pytest -m "not slow" # skip intentional sleep-heavy cases
|
|
|
432
491
|
|
|
433
492
|
`test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
|
|
434
493
|
|
|
494
|
+
## Upgrading to 1.56 from 1.55
|
|
495
|
+
|
|
496
|
+
- Fix: a command that times out while it redraws a `\r` progress line on stderr keeps its `Timeout!`
|
|
497
|
+
line; 1.55 could replace it with the progress line, leaving return code 124 without the marker.
|
|
498
|
+
- Simpler interrupt handling, some 700 lines lighter. One Ctrl-C still stops every command.
|
|
499
|
+
What changes is a few rare corners, each in [docs/limitations.md](docs/limitations.md):
|
|
500
|
+
- two Ctrl-Cs microseconds apart can leave a command running, still registered, until `kill_all()`
|
|
501
|
+
or the exit stops it; 1.55 stopped it at once;
|
|
502
|
+
- a Ctrl-C can lose a serial run's starting command anywhere inside `Popen` (about 51 µs per start on
|
|
503
|
+
Python 3.14), not only inside the fork (about 41 µs);
|
|
504
|
+
- a Ctrl-C that leaves `Popen.poll()`'s lock held makes that command's stop about 2 s slower;
|
|
505
|
+
- an ordinary exception your own signal handler raises as a serial run starts a command is recorded as
|
|
506
|
+
a start failure (-1) and not raised, as in 1.54;
|
|
507
|
+
- `join_threads()` on another thread can return before a batch that is still starting.
|
|
508
|
+
- `__running_threads_lock` and `_live_procs_lock`, unused since 1.55 and kept only for code that imported
|
|
509
|
+
them, are gone.
|
|
510
|
+
|
|
511
|
+
## Upgrading to 1.55 from 1.54
|
|
512
|
+
|
|
513
|
+
- New: a per-call environment. `env=` (the whole environment, as `subprocess`'s) and `extra_env=`
|
|
514
|
+
(added on top; a `None` value removes a variable) on `run_command`, `run_commands` and
|
|
515
|
+
`AsyncExecutor` (constructor defaults, overridden per call), and `mcmd -e NAME=VALUE` / `-e NAME`;
|
|
516
|
+
see [Environment per call](#environment-per-call).
|
|
517
|
+
- `quiet=True` now hides the lines about a command that could not be run: `Command path not found`
|
|
518
|
+
(127), `Command cannot be executed` (126) and the error of a command multiCMD could not run (-1),
|
|
519
|
+
which 1.54 printed even when quiet, partly on stdout. The reasons are still in `task.stderr`. Warnings
|
|
520
|
+
about the run's setup (sudo not found or not usable, no pty) still print.
|
|
521
|
+
- A Ctrl-C in `join_threads()` stops the commands it waits for, skips the ones still queued, waits up
|
|
522
|
+
to 5 s for them and re-raises; 1.54 re-raised and left them running. A Ctrl-C that interrupts
|
|
523
|
+
`AsyncExecutor.run_command(s)` skips that call's commands.
|
|
524
|
+
- A SIGTERM handler's `sys.exit()`, or whatever your own signal handler raises while a call waits for
|
|
525
|
+
its commands, stops them and records them as stopped before it is raised on, as a Ctrl-C does; 1.54
|
|
526
|
+
did that only for `KeyboardInterrupt`.
|
|
527
|
+
- A Ctrl-C while a command starts, times out or is stopped, or while multiCMD reads the output that
|
|
528
|
+
a finished command's background processes still hold open, no longer leaves the command or those
|
|
529
|
+
processes running unknown to `kill_all()` and the exit hook, or known to them but never signalled.
|
|
530
|
+
A second Ctrl-C ends the wait for the commands at once; each is still sent SIGTERM, and `kill_all()`
|
|
531
|
+
and the exit hook finish the kill. The limits below are the exceptions.
|
|
532
|
+
- Once the interpreter exits, a command queued on one of multiCMD's threads that has not started fails
|
|
533
|
+
with -1 ("not started: the interpreter is exiting") instead of starting and outliving the program.
|
|
534
|
+
- A command not seen to exit a second after SIGKILL is no longer tracked, so later `kill_all()` calls and the
|
|
535
|
+
exit do not wait for it again; its pid is named on stderr, unless quiet.
|
|
536
|
+
- Known limits: see [Process groups and signals](#process-groups-and-signals) and
|
|
537
|
+
[docs/limitations.md](docs/limitations.md).
|
|
538
|
+
|
|
435
539
|
## License
|
|
436
540
|
|
|
437
541
|
GPLv3+ — see the package metadata. Authored by Yufei Pan (<pan@zopyr.us>).
|
|
@@ -45,8 +45,8 @@ You can also just drop `multiCMD.py` into your project and import it directly.
|
|
|
45
45
|
```bash
|
|
46
46
|
$ mcmd -h
|
|
47
47
|
usage: mcmd [-h] [-p] [-t SECONDS] [-m max_threads] [--sudo] [-q] [-n]
|
|
48
|
-
[--shell] [-f FILE] [-a FILE] [--summary |
|
|
49
|
-
[--no-prefix] [--no-color] [--pty | --no-pty] [-V]
|
|
48
|
+
[--shell] [-e NAME=VALUE] [-f FILE] [-a FILE] [--summary |
|
|
49
|
+
--no-summary] [--no-prefix] [--no-color] [--pty | --no-pty] [-V]
|
|
50
50
|
[command ...]
|
|
51
51
|
|
|
52
52
|
Run multiple commands in parallel
|
|
@@ -70,6 +70,8 @@ options:
|
|
|
70
70
|
-n, --dry-run print each command, quoted, instead of running it
|
|
71
71
|
--shell run each command through /bin/sh -c ( pipes, ;, > and
|
|
72
72
|
globs work )
|
|
73
|
+
-e, --env NAME=VALUE set NAME to VALUE in the environment of every command
|
|
74
|
+
( repeatable ); NAME alone removes it
|
|
73
75
|
-f, --file FILE read commands from FILE, one per line ( - for stdin;
|
|
74
76
|
blank lines and # comments are skipped )
|
|
75
77
|
-a, --arg-file FILE run each command template once per line of FILE, with
|
|
@@ -267,10 +269,47 @@ multiCMD.run_commands(cmds, max_threads=8, color=False) # never color
|
|
|
267
269
|
multiCMD.run_command(["python3", "train.py"], pty=True) # live output, stderr merged
|
|
268
270
|
```
|
|
269
271
|
|
|
272
|
+
### Environment per call
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
multiCMD.run_command(["xfs_info", "/data"], extra_env={"LC_ALL": "C"}) # add / override variables
|
|
276
|
+
multiCMD.run_command(["env"], extra_env={"HTTP_PROXY": None}) # None removes one
|
|
277
|
+
multiCMD.run_command(["env"], env={"PATH": "/usr/bin", "HOME": "/tmp"}) # the whole environment, as subprocess's env
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`env` is the whole environment of the command, as `subprocess`'s `env` ( names and values
|
|
281
|
+
are `str`; on POSIX `bytes` and `os.PathLike` work too ). `extra_env` is applied on top of the
|
|
282
|
+
inherited environment, or of `env` when both are given; a `None` value removes the variable,
|
|
283
|
+
and an empty `extra_env` is the same as none. "Inherited" is `os.environ` as the process holds
|
|
284
|
+
it when the command starts: a variable set behind Python's back ( `os.putenv`, `os.unsetenv`, a
|
|
285
|
+
C library's `setenv` ) reaches commands started without `env` / `extra_env`, not the ones
|
|
286
|
+
given them. An inherited variable with an empty name ( a corrupt `environ` entry such as `=x` ) is left out of the environment of a command given `env` / `extra_env`, and out of an `env` copied from `os.environ`; an empty name in `extra_env` is a `ValueError`. Names are case-insensitive on Windows, so `extra_env={"Path": ...}` replaces `PATH`
|
|
287
|
+
there. On POSIX a command is looked up in the `PATH` of the environment it gets, on every Python
|
|
288
|
+
version; Windows searches the calling process's `PATH` whatever the command's environment says.
|
|
289
|
+
Before Python 3.11 the launcher starts in this process's environment and is handed the command's on
|
|
290
|
+
top of it, so the two together must fit the system's argument limit ( `getconf ARG_MAX`; 2 MiB on Linux
|
|
291
|
+
with the default stack, 128 KiB for one string: before Python 3.11 an entry travels as `_MULTICMD_ENV_<i>=NAME=VALUE`, 15 bytes
|
|
292
|
+
plus the digits of `i` more than its `NAME=VALUE` ); one that does not fit fails with return
|
|
293
|
+
code 126, `Argument list too long`, on every Python.
|
|
294
|
+
On Python 3.7-3.10 a command started with
|
|
295
|
+
neither `env` nor `extra_env` still gets the `LC_CTYPE=C.UTF-8` that interpreter's locale coercion
|
|
296
|
+
sets in a C locale ( as before ); a command given one of them gets exactly the environment described.
|
|
297
|
+
Both are per command: they never change `os.environ` and never reach another command, so
|
|
298
|
+
concurrent commands each see their own. `AsyncExecutor` takes them too, as defaults a call
|
|
299
|
+
overrides. `sudo` resets the environment by default, so with `use_sudo` they may not reach the
|
|
300
|
+
command. On the command line: `mcmd -e LC_ALL=C -e UNSET_ME "cmd"`; `-n` shows them as
|
|
301
|
+
`env LC_ALL=C cmd` ( `env -i ...` for a whole environment ).
|
|
302
|
+
|
|
270
303
|
`raise_error=True` raises `multiCMD.CommandError`; its `.tasks` holds the failed `Task`
|
|
271
304
|
objects and its message lists each one with its return code and last error line.
|
|
272
305
|
`Task` also records `start_time`, `end_time` and `duration`. With `dry_run=True` nothing
|
|
273
306
|
runs; each command is printed once, quoted so it can be pasted into a shell.
|
|
307
|
+
A command that is not found (return code 127), cannot be executed (126) or that multiCMD
|
|
308
|
+
itself could not run (-1, `task._error`) says why in its `task.stderr`; multiCMD also
|
|
309
|
+
prints a line saying so on stderr, unless `quiet=True`. Before Python 3.11 the exec of a
|
|
310
|
+
command given by its path, or of a file found in `PATH` that may not be executed, happens in
|
|
311
|
+
the launcher's process: it exits 127 / 126 with the reason as the command's own stderr, which
|
|
312
|
+
then shows with the command's output ( unless quiet ) instead of as a line of multiCMD's own.
|
|
274
313
|
|
|
275
314
|
### Asynchronous / fire-and-forget
|
|
276
315
|
|
|
@@ -283,7 +322,8 @@ tasks = multiCMD.run_commands(
|
|
|
283
322
|
max_threads=2, quiet=True,
|
|
284
323
|
wait_for_return=False, return_object=True,
|
|
285
324
|
)
|
|
286
|
-
# tasks[i].returncode is None until that command completes
|
|
325
|
+
# tasks[i].returncode is None until that command completes; tasks[i].thread.join(timeout)
|
|
326
|
+
# waits for one command ( a command whose thread could not start fails with -1, its thread already done )
|
|
287
327
|
|
|
288
328
|
# Later, block until everything launched this way has finished:
|
|
289
329
|
multiCMD.join_threads()
|
|
@@ -310,7 +350,8 @@ executor holds at most one thread per permit plus the dispatcher, however many
|
|
|
310
350
|
commands are queued. A command's `task.thread` is `None` until it starts, and goes
|
|
311
351
|
back to `None` once it has finished and the executor has joined its thread, so a
|
|
312
352
|
large batch does not keep a `Thread` object per finished command. `stop()` skips
|
|
313
|
-
commands that have not started yet
|
|
353
|
+
commands that have not started yet, and so does a Ctrl-C ( or an error ) that interrupts
|
|
354
|
+
`run_command(s)`, for that call's commands.
|
|
314
355
|
|
|
315
356
|
### Range expansion from Python
|
|
316
357
|
|
|
@@ -344,21 +385,37 @@ default, in the API and on the command line) disables it.
|
|
|
344
385
|
|
|
345
386
|
## Process groups and signals
|
|
346
387
|
|
|
347
|
-
Every command runs in a process group of its own, so a timeout, `stop()` or Ctrl-C
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
arrives), can call `multiCMD.kill_all()` from its
|
|
388
|
+
Every command runs in a process group of its own, so a timeout, `stop()` or Ctrl-C reaches the
|
|
389
|
+
whole process tree, not just the first process. On Python 3.11+ this uses `Popen(process_group=0)`;
|
|
390
|
+
older Pythons start the command through a tiny exec wrapper. A command that reads the terminal (a
|
|
391
|
+
password prompt, for example) fails at once instead of hanging: multiCMD blocks SIGTTIN/SIGTTOU for
|
|
392
|
+
it, and `mcmd` also ignores them, which every command inherits through any shell.
|
|
393
|
+
|
|
394
|
+
Signals sent to your program's group do not reach the commands, so multiCMD stops them itself. One
|
|
395
|
+
Ctrl-C (or a SIGTERM handler's `sys.exit()`) in a call waiting on the main thread, `join_threads()`
|
|
396
|
+
and `AsyncExecutor.wait()` included, stops every command, skips the queued ones and
|
|
397
|
+
re-raises, giving them up to 5 s to exit; a second Ctrl-C ends that wait at once. `mcmd` stops its
|
|
398
|
+
commands on SIGTERM / SIGHUP (unless started with them ignored, as under `nohup`). At interpreter exit
|
|
399
|
+
the commands still running are killed, and a command queued on one of multiCMD's threads fails (-1,
|
|
400
|
+
"not started: the interpreter is exiting") instead of outliving the program; one run from an `atexit`
|
|
401
|
+
handler after that, on the main thread, runs as usual. A program that handles SIGTERM itself, or runs
|
|
402
|
+
multiCMD from its own threads (where Ctrl-C never arrives), can call `multiCMD.kill_all()` from its
|
|
403
|
+
handler. A command not seen to exit a second after SIGKILL is no longer tracked; its pid is named on
|
|
404
|
+
stderr, unless quiet.
|
|
405
|
+
|
|
406
|
+
multiCMD never touches SIGINT's handler (`mcmd` sets its own SIGTERM / SIGHUP handlers, nothing
|
|
407
|
+
else). What your handler raises while a call waits for its commands is handled as a Ctrl-C.
|
|
408
|
+
|
|
409
|
+
A command is registered from the moment `Popen` returns it: a Ctrl-C that Python raises inside
|
|
410
|
+
`Popen` as a serial run starts a command (some 50-70 µs each) can leave it running, unknown to
|
|
411
|
+
`kill_all()`. Two Ctrl-Cs microseconds apart can leave a command running, still registered, until
|
|
412
|
+
`kill_all()` or the exit stops it. On Python 3.6-3.9, which raise a Ctrl-C at nearly any instruction,
|
|
413
|
+
even one at a few points between the steps of a run can leave its command registered but not signalled.
|
|
414
|
+
On Python 3.6-3.9 and 3.14 a Ctrl-C at the wrong instruction of a `with` on a lock can leave it held: one of
|
|
415
|
+
`threading`'s can hang the program as its threads end (CPython's own), and one of multiCMD's (an
|
|
416
|
+
`AsyncExecutor`'s queue, the live output) hangs that executor, or the next call that is not quiet, if the
|
|
417
|
+
program keeps going.
|
|
418
|
+
These and the other known limits are in [docs/limitations.md](docs/limitations.md).
|
|
362
419
|
|
|
363
420
|
## Key functions and objects
|
|
364
421
|
|
|
@@ -366,12 +423,13 @@ arrives), can call `multiCMD.kill_all()` from its handler.
|
|
|
366
423
|
run_command(command, timeout=0, max_threads=1, quiet=False, dry_run=False,
|
|
367
424
|
with_stdErr=False, return_code_only=False, return_object=False,
|
|
368
425
|
wait_for_return=True, sem=None, use_sudo=..., raise_error=False,
|
|
369
|
-
prefix=False, color=None, pty=False, shell=False)
|
|
426
|
+
prefix=False, color=None, pty=False, shell=False, env=None, extra_env=None)
|
|
370
427
|
|
|
371
428
|
run_commands(commands, timeout=0, max_threads=1, quiet=False, dry_run=False,
|
|
372
429
|
with_stdErr=False, return_code_only=False, return_object=False,
|
|
373
430
|
parse=False, wait_for_return=True, sem=None, use_sudo=...,
|
|
374
|
-
raise_error=False, prefix=False, color=None, pty=False, shell=False
|
|
431
|
+
raise_error=False, prefix=False, color=None, pty=False, shell=False,
|
|
432
|
+
env=None, extra_env=None)
|
|
375
433
|
|
|
376
434
|
ping(hosts, timeout=1, max_threads=0, ...) # returns True/False reachability
|
|
377
435
|
join_threads(threads=..., timeout=None) # join fire-and-forget threads
|
|
@@ -380,7 +438,8 @@ set_sudo(use_sudo) # enable/disable sudo globally
|
|
|
380
438
|
|
|
381
439
|
class Task: # command, returncode, stdout, stderr, start_time, end_time, duration
|
|
382
440
|
class CommandError: # raised by raise_error=True; .tasks = the failed Tasks
|
|
383
|
-
class AsyncExecutor: # run_command(s), wait, join, stop, cleanup, get_results, get_return_codes
|
|
441
|
+
class AsyncExecutor: # run_command(s), wait, join, stop, cleanup, get_results, get_return_codes;
|
|
442
|
+
# takes the options above, env / extra_env as defaults a call overrides
|
|
384
443
|
```
|
|
385
444
|
|
|
386
445
|
The module also bundles a few terminal/formatting helpers used internally and
|
|
@@ -406,6 +465,51 @@ pytest -m "not slow" # skip intentional sleep-heavy cases
|
|
|
406
465
|
|
|
407
466
|
`test_expand_ranges_perf.py` is a manual performance microbenchmark and is not collected by pytest.
|
|
408
467
|
|
|
468
|
+
## Upgrading to 1.56 from 1.55
|
|
469
|
+
|
|
470
|
+
- Fix: a command that times out while it redraws a `\r` progress line on stderr keeps its `Timeout!`
|
|
471
|
+
line; 1.55 could replace it with the progress line, leaving return code 124 without the marker.
|
|
472
|
+
- Simpler interrupt handling, some 700 lines lighter. One Ctrl-C still stops every command.
|
|
473
|
+
What changes is a few rare corners, each in [docs/limitations.md](docs/limitations.md):
|
|
474
|
+
- two Ctrl-Cs microseconds apart can leave a command running, still registered, until `kill_all()`
|
|
475
|
+
or the exit stops it; 1.55 stopped it at once;
|
|
476
|
+
- a Ctrl-C can lose a serial run's starting command anywhere inside `Popen` (about 51 µs per start on
|
|
477
|
+
Python 3.14), not only inside the fork (about 41 µs);
|
|
478
|
+
- a Ctrl-C that leaves `Popen.poll()`'s lock held makes that command's stop about 2 s slower;
|
|
479
|
+
- an ordinary exception your own signal handler raises as a serial run starts a command is recorded as
|
|
480
|
+
a start failure (-1) and not raised, as in 1.54;
|
|
481
|
+
- `join_threads()` on another thread can return before a batch that is still starting.
|
|
482
|
+
- `__running_threads_lock` and `_live_procs_lock`, unused since 1.55 and kept only for code that imported
|
|
483
|
+
them, are gone.
|
|
484
|
+
|
|
485
|
+
## Upgrading to 1.55 from 1.54
|
|
486
|
+
|
|
487
|
+
- New: a per-call environment. `env=` (the whole environment, as `subprocess`'s) and `extra_env=`
|
|
488
|
+
(added on top; a `None` value removes a variable) on `run_command`, `run_commands` and
|
|
489
|
+
`AsyncExecutor` (constructor defaults, overridden per call), and `mcmd -e NAME=VALUE` / `-e NAME`;
|
|
490
|
+
see [Environment per call](#environment-per-call).
|
|
491
|
+
- `quiet=True` now hides the lines about a command that could not be run: `Command path not found`
|
|
492
|
+
(127), `Command cannot be executed` (126) and the error of a command multiCMD could not run (-1),
|
|
493
|
+
which 1.54 printed even when quiet, partly on stdout. The reasons are still in `task.stderr`. Warnings
|
|
494
|
+
about the run's setup (sudo not found or not usable, no pty) still print.
|
|
495
|
+
- A Ctrl-C in `join_threads()` stops the commands it waits for, skips the ones still queued, waits up
|
|
496
|
+
to 5 s for them and re-raises; 1.54 re-raised and left them running. A Ctrl-C that interrupts
|
|
497
|
+
`AsyncExecutor.run_command(s)` skips that call's commands.
|
|
498
|
+
- A SIGTERM handler's `sys.exit()`, or whatever your own signal handler raises while a call waits for
|
|
499
|
+
its commands, stops them and records them as stopped before it is raised on, as a Ctrl-C does; 1.54
|
|
500
|
+
did that only for `KeyboardInterrupt`.
|
|
501
|
+
- A Ctrl-C while a command starts, times out or is stopped, or while multiCMD reads the output that
|
|
502
|
+
a finished command's background processes still hold open, no longer leaves the command or those
|
|
503
|
+
processes running unknown to `kill_all()` and the exit hook, or known to them but never signalled.
|
|
504
|
+
A second Ctrl-C ends the wait for the commands at once; each is still sent SIGTERM, and `kill_all()`
|
|
505
|
+
and the exit hook finish the kill. The limits below are the exceptions.
|
|
506
|
+
- Once the interpreter exits, a command queued on one of multiCMD's threads that has not started fails
|
|
507
|
+
with -1 ("not started: the interpreter is exiting") instead of starting and outliving the program.
|
|
508
|
+
- A command not seen to exit a second after SIGKILL is no longer tracked, so later `kill_all()` calls and the
|
|
509
|
+
exit do not wait for it again; its pid is named on stderr, unless quiet.
|
|
510
|
+
- Known limits: see [Process groups and signals](#process-groups-and-signals) and
|
|
511
|
+
[docs/limitations.md](docs/limitations.md).
|
|
512
|
+
|
|
409
513
|
## License
|
|
410
514
|
|
|
411
515
|
GPLv3+ — see the package metadata. Authored by Yufei Pan (<pan@zopyr.us>).
|