multiSSH3 5.85__tar.gz → 5.90__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.

Potentially problematic release.


This version of multiSSH3 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.85
3
+ Version: 5.90
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.85
3
+ Version: 5.90
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -6,6 +6,31 @@
6
6
  # "ipaddress",
7
7
  # ]
8
8
  # ///
9
+ import argparse
10
+ import functools
11
+ import getpass
12
+ import glob
13
+ import io
14
+ import ipaddress
15
+ import json
16
+ import math
17
+ import os
18
+ import queue
19
+ import re
20
+ import shutil
21
+ import signal
22
+ import socket
23
+ import string
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import threading
28
+ import time
29
+ import typing
30
+ import uuid
31
+ from collections import Counter, deque
32
+ from itertools import count, product
33
+
9
34
  __curses_available = False
10
35
  __resource_lib_available = False
11
36
  try:
@@ -20,30 +45,7 @@ try:
20
45
  except ImportError:
21
46
  pass
22
47
 
23
- import subprocess
24
- import threading
25
- import time
26
- import os
27
- import argparse
28
- from itertools import product
29
- import re
30
- import string
31
- import ipaddress
32
- import sys
33
- import json
34
- import socket
35
- import io
36
- import signal
37
- import functools
38
- import glob
39
- import shutil
40
- import getpass
41
- import uuid
42
- import tempfile
43
- import math
44
- from itertools import count
45
- import queue
46
- import typing
48
+
47
49
  try:
48
50
  # Check if functiools.cache is available
49
51
  # cache_decorator = functools.cache
@@ -76,15 +78,15 @@ try:
76
78
  wrapper.cache_clear = cached_func.cache_clear
77
79
  return wrapper
78
80
  return decorating_function(user_function)
79
- except :
81
+ except Exception:
80
82
  # If lrucache is not available, use a dummy decorator
81
83
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
82
84
  def cache_decorator(func):
83
85
  return func
84
- version = '5.85'
86
+ version = '5.90'
85
87
  VERSION = version
86
88
  __version__ = version
87
- COMMIT_DATE = '2025-08-13'
89
+ COMMIT_DATE = '2025-10-17'
88
90
 
89
91
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
90
92
  '~/multiSSH3.config.json',
@@ -93,16 +95,22 @@ CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
95
  '/etc/multiSSH3.d/multiSSH3.config.json',
94
96
  '/etc/multiSSH3.config.json'] # The first one has the highest priority
95
97
 
98
+ ERRORS = []
96
99
 
97
100
  # TODO: Add terminal TUI
98
101
 
99
102
  #%% ------------ Pre Helper Functions ----------------
100
103
  def eprint(*args, **kwargs):
104
+ global ERRORS
101
105
  try:
102
- print(*args, file=sys.stderr, **kwargs)
106
+ if 'file' in kwargs:
107
+ print(*args, **kwargs)
108
+ else:
109
+ print(*args, file=sys.stderr, **kwargs)
103
110
  except Exception as e:
104
111
  print(f"Error: Cannot print to stderr: {e}")
105
112
  print(*args, **kwargs)
113
+ ERRORS.append(' '.join(map(str,args)))
106
114
 
107
115
  def _exit_with_code(code, message=None):
108
116
  '''
@@ -247,7 +255,7 @@ def getIP(hostname: str,local=False):
247
255
  # Then we check the DNS
248
256
  try:
249
257
  return socket.gethostbyname(hostname)
250
- except:
258
+ except Exception:
251
259
  return None
252
260
 
253
261
 
@@ -320,8 +328,8 @@ def load_config_file(config_file):
320
328
  try:
321
329
  with open(config_file,'r') as f:
322
330
  config = json.load(f)
323
- except:
324
- eprint(f"Error: Cannot load config file {config_file!r}")
331
+ except Exception as e:
332
+ eprint(f"Error: Cannot load config file {config_file!r}: {e}")
325
333
  return {}
326
334
  return config
327
335
 
@@ -346,6 +354,8 @@ DEFAULT_INTERVAL = 0
346
354
  DEFAULT_IPMI = False
347
355
  DEFAULT_IPMI_INTERFACE_IP_PREFIX = ''
348
356
  DEFAULT_INTERFACE_IP_PREFIX = None
357
+ DEFAULT_IPMI_USERNAME = 'ADMIN'
358
+ DEFAULT_IPMI_PASSWORD = ''
349
359
  DEFAULT_NO_WATCH = False
350
360
  DEFAULT_CURSES_MINIMUM_CHAR_LEN = 40
351
361
  DEFAULT_CURSES_MINIMUM_LINE_LEN = 1
@@ -364,6 +374,7 @@ DEFAULT_GREPPABLE_MODE = False
364
374
  DEFAULT_SKIP_UNREACHABLE = True
365
375
  DEFAULT_SKIP_HOSTS = ''
366
376
  DEFAULT_ENCODING = 'utf-8'
377
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
367
378
  SSH_STRICT_HOST_KEY_CHECKING = False
368
379
  ERROR_MESSAGES_TO_IGNORE = [
369
380
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
@@ -492,7 +503,7 @@ def readEnvFromFile(environemnt_file = ''):
492
503
  try:
493
504
  if env:
494
505
  return env
495
- except:
506
+ except Exception:
496
507
  env = {}
497
508
  global _env_file
498
509
  if environemnt_file:
@@ -633,10 +644,154 @@ def format_commands(commands):
633
644
  # reformat commands into a list of strings, join the iterables if they are not strings
634
645
  try:
635
646
  commands = [' '.join(command) if not isinstance(command,str) else command for command in commands]
636
- except:
637
- eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands!r} to a list of strings. Continuing anyway but expect failures.")
647
+ except Exception as e:
648
+ eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands!r} to a list of strings. Continuing anyway but expect failures. Error: {e}")
638
649
  return commands
639
650
 
651
+
652
+
653
+ class OrderedMultiSet(deque):
654
+ """
655
+ A deque extension with O(1) average lookup time.
656
+ Maintains all deque functionality while tracking item counts.
657
+ """
658
+ def __init__(self, iterable=None, maxlen=None):
659
+ """Initialize with optional iterable and maxlen."""
660
+ super().__init__(maxlen=maxlen)
661
+ self._counter = Counter()
662
+ if iterable is not None:
663
+ self.extend(iterable)
664
+ def __decrease_count(self, item):
665
+ """Decrease count of item in counter."""
666
+ self._counter[item] -= 1
667
+ if self._counter[item] == 0:
668
+ del self._counter[item]
669
+ return self._counter.get(item, 0)
670
+ def append(self, item,left=False):
671
+ """Add item to the right end. O(1)."""
672
+ removed = None
673
+ if self.maxlen is not None and len(self) == self.maxlen:
674
+ removed = self[-1] if left else self[0] # Item that will be removed
675
+ self.__decrease_count(removed)
676
+ super().appendleft(item) if left else super().append(item)
677
+ self._counter[item] += 1
678
+ return removed
679
+ def appendleft(self, item):
680
+ """Add item to the left end. O(1)."""
681
+ return self.append(item,left=True)
682
+ def pop(self,left=False):
683
+ """Remove and return item from right end. O(1)."""
684
+ if not self:
685
+ return None
686
+ item = super().popleft() if left else super().pop()
687
+ self.__decrease_count(item)
688
+ return item
689
+ def popleft(self):
690
+ """Remove and return item from left end. O(1)."""
691
+ return self.pop(left=True)
692
+ def remove(self, value):
693
+ """Remove first occurrence of value. O(n)."""
694
+ if value not in self._counter:
695
+ return None
696
+ super().remove(value)
697
+ self.__decrease_count(value)
698
+ def clear(self):
699
+ """Remove all items. O(1)."""
700
+ super().clear()
701
+ self._counter.clear()
702
+ def extend(self, iterable):
703
+ """Extend deque by appending elements from iterable. O(k)."""
704
+ for item in iterable:
705
+ self.append(item)
706
+ def extendleft(self, iterable):
707
+ """Extend left side by appending elements from iterable. O(k)."""
708
+ for item in iterable:
709
+ self.appendleft(item)
710
+ def rotate(self, n=1):
711
+ """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
712
+ if not self:
713
+ return
714
+ super().rotate(n)
715
+ def __contains__(self, item):
716
+ """Check if item exists in deque. O(1) average."""
717
+ return item in self._counter
718
+ def count(self, item):
719
+ """Return number of occurrences of item. O(1)."""
720
+ return self._counter[item]
721
+ def __setitem__(self, index, value):
722
+ """Set item at index. O(1) for access, O(1) for counter update."""
723
+ old_value = self[index]
724
+ super().__setitem__(index, value)
725
+ self.__decrease_count(old_value)
726
+ self._counter[value] += 1
727
+ return old_value
728
+ def __delitem__(self, index):
729
+ """Delete item at index. O(n) for deletion, O(1) for counter update."""
730
+ value = self[index]
731
+ super().__delitem__(index)
732
+ self.__decrease_count(value)
733
+ return value
734
+ def insert(self, index, value):
735
+ """Insert value at index. O(n) for insertion, O(1) for counter update."""
736
+ super().insert(index, value)
737
+ self._counter[value] += 1
738
+ def reverse(self):
739
+ """Reverse deque in place. O(n)."""
740
+ super().reverse()
741
+ def copy(self):
742
+ """Create a shallow copy. O(n)."""
743
+ new_deque = OrderedMultiSet(maxlen=self.maxlen)
744
+ new_deque.extend(self)
745
+ return new_deque
746
+ def __copy__(self):
747
+ """Support for copy.copy()."""
748
+ return self.copy()
749
+ def __repr__(self):
750
+ """String representation."""
751
+ if self.maxlen is not None:
752
+ return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
753
+ return f"OrderedMultiSet({list(self)})"
754
+ def put(self, item,left=False):
755
+ """Alias for append - add to right end (FIFO put)."""
756
+ return self.append(item,left=left)
757
+ def get(self,left=True):
758
+ """Alias for popleft - remove from left end (FIFO get)."""
759
+ return self.pop(left=left)
760
+ def peek(self):
761
+ """Return leftmost item without removing it."""
762
+ if not self:
763
+ return None
764
+ return self[0]
765
+ def peek_right(self):
766
+ """Return rightmost item without removing it."""
767
+ if not self:
768
+ return None
769
+ return self[-1]
770
+
771
+ def get_terminal_size():
772
+ '''
773
+ Get the terminal size
774
+
775
+ @params:
776
+ None
777
+
778
+ @returns:
779
+ (int,int): the number of columns and rows of the terminal
780
+ '''
781
+ try:
782
+ import os
783
+ _tsize = os.get_terminal_size()
784
+ except Exception:
785
+ try:
786
+ import fcntl
787
+ import struct
788
+ import termios
789
+ packed = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
790
+ _tsize = struct.unpack('HHHH', packed)[:2]
791
+ except Exception:
792
+ import shutil
793
+ _tsize = shutil.get_terminal_size(fallback=(120, 30))
794
+ return _tsize
640
795
  #%% ------------ Compacting Hostnames ----------------
641
796
  def __tokenize_hostname(hostname):
642
797
  """
@@ -1323,6 +1478,11 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1323
1478
  global __ipmiiInterfaceIPPrefix
1324
1479
  global _binPaths
1325
1480
  global __DEBUG_MODE
1481
+ global DEFAULT_IPMI_USERNAME
1482
+ global DEFAULT_IPMI_PASSWORD
1483
+ global DEFAULT_USERNAME
1484
+ global DEFAULT_PASSWORD
1485
+ global SSH_STRICT_HOST_KEY_CHECKING
1326
1486
  if retry_limit < 0:
1327
1487
  host.output.append('Error: Retry limit reached!')
1328
1488
  host.stderr.append('Error: Retry limit reached!')
@@ -1366,7 +1526,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1366
1526
  host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
1367
1527
  host.resolvedName = host.username + '@' if host.username else ''
1368
1528
  host.resolvedName += host.address
1369
- except:
1529
+ except Exception:
1370
1530
  host.resolvedName = host.name
1371
1531
  else:
1372
1532
  host.resolvedName = host.name
@@ -1378,22 +1538,27 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1378
1538
  host.command = host.command.replace('ipmitool ','')
1379
1539
  elif host.command.startswith(_binPaths['ipmitool']):
1380
1540
  host.command = host.command.replace(_binPaths['ipmitool'],'')
1381
- if not host.username:
1382
- host.username = 'admin'
1541
+ if not host.username or host.username == DEFAULT_USERNAME:
1542
+ if DEFAULT_IPMI_USERNAME:
1543
+ host.username = DEFAULT_IPMI_USERNAME
1544
+ elif DEFAULT_USERNAME:
1545
+ host.username = DEFAULT_USERNAME
1546
+ else:
1547
+ host.username = 'ADMIN'
1548
+ if not passwds or passwds == DEFAULT_PASSWORD:
1549
+ if DEFAULT_IPMI_PASSWORD:
1550
+ passwds = DEFAULT_IPMI_PASSWORD
1551
+ elif DEFAULT_PASSWORD:
1552
+ passwds = DEFAULT_PASSWORD
1553
+ else:
1554
+ host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1555
+ passwds = 'admin'
1383
1556
  if not host.command:
1384
1557
  host.command = 'power status'
1385
1558
  if 'sh' in _binPaths:
1386
- if passwds:
1387
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
1388
- else:
1389
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1390
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P admin {" ".join(extraargs)} {host.command}']
1559
+ formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
1391
1560
  else:
1392
- if passwds:
1393
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
1394
- else:
1395
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1396
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P admin'] + extraargs + [host.command]
1561
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
1397
1562
  elif 'ssh' in _binPaths:
1398
1563
  host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1399
1564
  if __DEBUG_MODE:
@@ -1544,7 +1709,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1544
1709
  stderr_thread.join(timeout=1)
1545
1710
  stdin_thread.join(timeout=1)
1546
1711
  # here we handle the rest of the stdout after the subprocess returns
1547
- host.output.append(f'Pipe Closed. Trying to read the rest of the stdout...')
1712
+ host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
1548
1713
  if not _emo:
1549
1714
  stdout = None
1550
1715
  stderr = None
@@ -1964,7 +2129,7 @@ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None, indexO
1964
2129
  rearrangedHosts.add(host)
1965
2130
  return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}, rearrangedHosts
1966
2131
 
1967
- def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window=DEFAULT_SINGLE_WINDOW, config_reason = 'New Configuration'):
2132
+ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window=DEFAULT_SINGLE_WINDOW,help_shown = False, config_reason = 'New Configuration'):
1968
2133
  global _encoding
1969
2134
  _ = config_reason
1970
2135
  try:
@@ -1983,9 +2148,9 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1983
2148
  min_line_len_local = max_y-1
1984
2149
  # return True if the terminal is too small
1985
2150
  if max_x < 2 or max_y < 2:
1986
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal too small')
2151
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal too small')
1987
2152
  if min_char_len_local < 1 or min_line_len_local < 1:
1988
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Minimum character or line length too small')
2153
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Minimum character or line length too small')
1989
2154
  # We need to figure out how many hosts we can fit in the terminal
1990
2155
  # We will need at least 2 lines per host, one for its name, one for its output
1991
2156
  # Each line will be at least 61 characters long (60 for the output, 1 for the borders)
@@ -1993,10 +2158,10 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
1993
2158
  max_num_hosts_y = max_y // (min_line_len_local + 1)
1994
2159
  max_num_hosts = max_num_hosts_x * max_num_hosts_y
1995
2160
  if max_num_hosts < 1:
1996
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal too small to display any hosts')
2161
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal too small to display any hosts')
1997
2162
  hosts_to_display , host_stats, rearrangedHosts = _get_hosts_to_display(hosts, max_num_hosts)
1998
2163
  if len(hosts_to_display) == 0:
1999
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'No hosts to display')
2164
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'No hosts to display')
2000
2165
  # Now we calculate the actual number of hosts we will display for x and y
2001
2166
  optimal_len_x = max(min_char_len_local, 80)
2002
2167
  num_hosts_x = min(max(min(max_num_hosts_x, max_x // optimal_len_x),1),len(hosts_to_display))
@@ -2017,7 +2182,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2017
2182
  host_window_height = max_y // num_hosts_y
2018
2183
  host_window_width = max_x // num_hosts_x
2019
2184
  if host_window_height < 1 or host_window_width < 1:
2020
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Host window too small')
2185
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Host window too small')
2021
2186
 
2022
2187
  old_stat = ''
2023
2188
  old_bottom_stat = ''
@@ -2078,7 +2243,6 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2078
2243
  _curses_add_string_to_window(window=help_window,y=12,line='Esc : Clear line', color_pair_list=[-1,-1,1], lead_str='│', box_ansi_color=box_ansi_color)
2079
2244
  help_panel = curses.panel.new_panel(help_window)
2080
2245
  help_panel.hide()
2081
- help_shown = False
2082
2246
  curses.panel.update_panels()
2083
2247
  indexOffset = 0
2084
2248
  while host_stats['running'] > 0 or host_stats['waiting'] > 0:
@@ -2091,7 +2255,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2091
2255
  # with open('keylog.txt','a') as f:
2092
2256
  # f.write(str(key)+'\n')
2093
2257
  if key == 410 or key == curses.KEY_RESIZE: # 410 is the key code for resize
2094
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize requested')
2258
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal resize requested')
2095
2259
  # if the user pressed ctrl + d and the last line is empty, we will exit by adding 'exit\n' to the last line
2096
2260
  elif key == 4 and not __keyPressesIn[-1]:
2097
2261
  __keyPressesIn[-1].extend('exit\n')
@@ -2099,20 +2263,20 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2099
2263
  elif key == 95 and not __keyPressesIn[-1]: # 95 is the key code for _
2100
2264
  # if last line is empty, we will reconfigure the wh to be smaller
2101
2265
  if min_line_len != 1:
2102
- return (lineToDisplay,curserPosition , min_char_len , max(min_line_len -1,1), single_window, 'Decrease line length')
2266
+ return (lineToDisplay,curserPosition , min_char_len , max(min_line_len -1,1), single_window,help_shown, 'Decrease line length')
2103
2267
  elif key == 43 and not __keyPressesIn[-1]: # 43 is the key code for +
2104
2268
  # if last line is empty, we will reconfigure the wh to be larger
2105
- return (lineToDisplay,curserPosition , min_char_len , min_line_len +1, single_window, 'Increase line length')
2269
+ return (lineToDisplay,curserPosition , min_char_len , min_line_len +1, single_window,help_shown, 'Increase line length')
2106
2270
  elif key == 123 and not __keyPressesIn[-1]: # 123 is the key code for {
2107
2271
  # if last line is empty, we will reconfigure the ww to be smaller
2108
2272
  if min_char_len != 1:
2109
- return (lineToDisplay,curserPosition , max(min_char_len -1,1), min_line_len, single_window, 'Decrease character length')
2273
+ return (lineToDisplay,curserPosition , max(min_char_len -1,1), min_line_len, single_window,help_shown, 'Decrease character length')
2110
2274
  elif key == 124 and not __keyPressesIn[-1]: # 124 is the key code for |
2111
2275
  # if last line is empty, we will toggle the single window mode
2112
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, not single_window, 'Toggle single window mode')
2276
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, not single_window,help_shown, 'Toggle single window mode')
2113
2277
  elif key == 125 and not __keyPressesIn[-1]: # 125 is the key code for }
2114
2278
  # if last line is empty, we will reconfigure the ww to be larger
2115
- return (lineToDisplay,curserPosition , min_char_len +1, min_line_len, single_window, 'Increase character length')
2279
+ return (lineToDisplay,curserPosition , min_char_len +1, min_line_len, single_window,help_shown, 'Increase character length')
2116
2280
  elif key == 60 and not __keyPressesIn[-1]: # 60 is the key code for <
2117
2281
  indexOffset = (indexOffset - 1 ) % len(hosts)
2118
2282
  elif key == 62 and not __keyPressesIn[-1]: # 62 is the key code for >
@@ -2147,11 +2311,11 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2147
2311
  curserPosition = len(__keyPressesIn[lineToDisplay])
2148
2312
  elif key == curses.KEY_REFRESH or key == curses.KEY_F5 or key == 18: # 18 is the key code for ctrl + R
2149
2313
  # if the key is refresh, we will refresh the screen
2150
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Refresh requested')
2314
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Refresh requested')
2151
2315
  elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
2152
2316
  # if the key is exit, we will exit the program
2153
2317
  return
2154
- elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1: # 63 is the key code for ?
2318
+ elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?
2155
2319
  # if the key is help, we will display the help message
2156
2320
  if not help_shown:
2157
2321
  help_panel.show()
@@ -2194,7 +2358,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2194
2358
  curserPosition += 1
2195
2359
  # reconfigure when the terminal size changes
2196
2360
  if org_dim != stdscr.getmaxyx():
2197
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize detected')
2361
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal resize detected')
2198
2362
  # We generate the aggregated stats if user did not input anything
2199
2363
  if not __keyPressesIn[lineToDisplay]:
2200
2364
  #stats = '┍'+ f" Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} ww: {min_char_len} wh:{min_line_len} "[:max_x - 2].center(max_x - 2, "━")
@@ -2263,12 +2427,12 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2263
2427
  # if the line is visible, we will reprint it
2264
2428
  if visibleLowerBound <= lineNumToReprint <= len(host.output):
2265
2429
  _curses_add_string_to_window(window=host_window, y=lineNumToReprint + 1, line=host.output[lineNumToReprint], color_pair_list=host.current_color_pair,lead_str='│',keep_top_n_lines=1,box_ansi_color=box_ansi_color,fill_char='')
2266
- except Exception as e:
2430
+ except Exception:
2267
2431
  # import traceback
2268
2432
  # print(str(e).strip())
2269
2433
  # print(traceback.format_exc().strip())
2270
2434
  if org_dim != stdscr.getmaxyx():
2271
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize detected')
2435
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal resize detected')
2272
2436
  if host.lastPrintedUpdateTime != host.lastUpdateTime and host.output_buffer.tell() > 0:
2273
2437
  # this means there is still output in the buffer, we will print it
2274
2438
  # we will print the output in the window
@@ -2276,11 +2440,14 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2276
2440
  host_window.noutrefresh()
2277
2441
  host.lastPrintedUpdateTime = host.lastUpdateTime
2278
2442
  hosts_to_display, host_stats,rearrangedHosts = _get_hosts_to_display(hosts, max_num_hosts,hosts_to_display, indexOffset)
2443
+ if help_shown:
2444
+ help_window.touchwin()
2445
+ help_window.noutrefresh()
2279
2446
  curses.doupdate()
2280
2447
  last_refresh_time = time.perf_counter()
2281
2448
  except Exception as e:
2282
2449
  import traceback
2283
- return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, f'Error: {str(e)}',traceback.format_exc())
2450
+ return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, f'Error: {str(e)}',traceback.format_exc())
2284
2451
  return None
2285
2452
 
2286
2453
  def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window = DEFAULT_SINGLE_WINDOW):
@@ -2328,9 +2495,9 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2328
2495
  # print if can change color
2329
2496
  _curses_add_string_to_window(window=stdscr, y=8, line=f"Real color capability: {curses.can_change_color()}", number_of_char_to_write=stdscr.getmaxyx()[1] - 1)
2330
2497
  stdscr.refresh()
2331
- except:
2498
+ except Exception:
2332
2499
  pass
2333
- params = (-1,0 , min_char_len, min_line_len, single_window,'new config')
2500
+ params = (-1,0 , min_char_len, min_line_len, single_window,False,'new config')
2334
2501
  while params:
2335
2502
  params = __generate_display(stdscr, hosts, *params)
2336
2503
  if not params:
@@ -2341,21 +2508,166 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2341
2508
  # print the current configuration
2342
2509
  stdscr.clear()
2343
2510
  try:
2344
- stdscr.addstr(0, 0, f"{params[5]}, Reloading Configuration: min_char_len={params[2]}, min_line_len={params[3]}, single_window={params[4]} with window size {stdscr.getmaxyx()} and {len(hosts)} hosts...")
2345
- if len(params) > 6:
2511
+ stdscr.addstr(0, 0, f"{params[6]}, Reloading Configuration: min_char_len={params[2]}, min_line_len={params[3]}, single_window={params[4]} with window size {stdscr.getmaxyx()} and {len(hosts)} hosts...")
2512
+ if len(params) > 7:
2346
2513
  # traceback is available, print it
2347
2514
  i = 1
2348
- for line in params[6].split('\n'):
2515
+ for line in params[7].split('\n'):
2349
2516
  stdscr.addstr(i, 0, line)
2350
2517
  i += 1
2351
2518
  stdscr.refresh()
2352
- except:
2519
+ except Exception:
2353
2520
  pass
2354
- params = params[:5] + ('new config',)
2521
+ params = params[:6] + ('new config',)
2355
2522
  time.sleep(0.01)
2356
2523
  #time.sleep(0.25)
2357
2524
 
2358
2525
  #%% ------------ Generate Output Block ----------------
2526
+ def can_merge(line_bag1, line_bag2, threshold):
2527
+ bag1_iter = iter(line_bag1)
2528
+ found = False
2529
+ for _ in range(max(int(len(line_bag1) * (1-threshold)),1)):
2530
+ try:
2531
+ item = next(bag1_iter)
2532
+ except StopIteration:
2533
+ break
2534
+ if item in line_bag2:
2535
+ found = True
2536
+ break
2537
+ if not found:
2538
+ return False
2539
+ return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
2540
+
2541
+ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold):
2542
+ terminal_length = get_terminal_size()[0]
2543
+ output.append(('├'+'─'*(terminal_length-1)))
2544
+ indexes = {hostname: 0 for hostname in merging_hostnames}
2545
+ working_indexes = indexes.copy()
2546
+ previousBuddies = set()
2547
+ while indexes:
2548
+ futures = {}
2549
+ defer = False
2550
+ sorted_working_indexes = sorted(working_indexes.items(), key=lambda x: x[1])
2551
+ golden_hostname, golden_index = sorted_working_indexes[0]
2552
+ buddy = {golden_hostname}
2553
+ lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2554
+ for hostname, index in sorted_working_indexes[1:]:
2555
+ if lineToAdd == outputs_by_hostname[hostname][index]:
2556
+ buddy.add(hostname)
2557
+ else:
2558
+ if hostname not in futures:
2559
+ diff_display_item_count = max(int(len(outputs_by_hostname[hostname]) * (1 - diff_display_threshold)),1)
2560
+ tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2561
+ futures[hostname] = (OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index)
2562
+ if lineToAdd in futures[hostname]:
2563
+ for hn in buddy:
2564
+ del working_indexes[hn]
2565
+ defer = True
2566
+ break
2567
+ if not defer:
2568
+ if buddy != previousBuddies:
2569
+ output.append(f"├─ {','.join(compact_hostnames(buddy))}")
2570
+ previousBuddies = buddy
2571
+ output.append(lineToAdd)
2572
+ for hostname in buddy:
2573
+ indexes[hostname] += 1
2574
+ if indexes[hostname] >= len(outputs_by_hostname[hostname]):
2575
+ indexes.pop(hostname, None)
2576
+ futures.pop(hostname, None)
2577
+ continue
2578
+ #advance futures
2579
+ if hostname in futures:
2580
+ tracking_multiset, tracking_index = futures[hostname]
2581
+ tracking_index += 1
2582
+ if tracking_index < len(outputs_by_hostname[hostname]):
2583
+ line = outputs_by_hostname[hostname][tracking_index]
2584
+ tracking_multiset.append(line)
2585
+ else:
2586
+ tracking_multiset.pop_left()
2587
+ futures[hostname] = (tracking_multiset, tracking_index)
2588
+ working_indexes = indexes.copy()
2589
+
2590
+ def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold):
2591
+ terminal_length = get_terminal_size()[0]
2592
+ output = []
2593
+ for merging_hostnames in merge_groups:
2594
+ mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold)
2595
+ for hostname in remaining_hostnames:
2596
+ output.append('├'+'─'*(terminal_length-1))
2597
+ output.append(f"├─ {hostname}")
2598
+ output.extend(outputs_by_hostname[hostname])
2599
+ return output
2600
+
2601
+ def get_host_raw_output(hosts):
2602
+ outputs_by_hostname = {}
2603
+ line_bag_by_hostname = {}
2604
+ hostnames_by_line_bag_len = {}
2605
+ for host in hosts:
2606
+ hostPrintOut = ["│█ EXECUTED COMMAND"]
2607
+ hostPrintOut.extend(['│ ' + line for line in host['command'].splitlines()])
2608
+ lineBag = {(0,host['command'])}
2609
+ prevLine = host['command']
2610
+ if host['stdout']:
2611
+ hostPrintOut.append('│▓ STDOUT:')
2612
+ hostPrintOut.extend(['│ ' + line for line in host['stdout']])
2613
+ lineBag.add((prevLine,1))
2614
+ lineBag.add((1,host['stdout'][0]))
2615
+ if len(host['stdout']) > 1:
2616
+ lineBag.update(zip(host['stdout'], host['stdout'][1:]))
2617
+ lineBag.update(host['stdout'])
2618
+ prevLine = host['stdout'][-1]
2619
+ if host['stderr']:
2620
+ if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2621
+ host['stderr'][0] = 'SSH not reachable!'
2622
+ elif host['stderr'][-1].strip().endswith('Connection timed out'):
2623
+ host['stderr'][-1] = 'SSH connection timed out!'
2624
+ elif host['stderr'][-1].strip().endswith('No route to host'):
2625
+ host['stderr'][-1] = 'Cannot find host!'
2626
+ if host['stderr']:
2627
+ hostPrintOut.append('│▒ STDERR:')
2628
+ hostPrintOut.extend(['│ ' + line for line in host['stderr']])
2629
+ lineBag.add((prevLine,2))
2630
+ lineBag.add((2,host['stderr'][0]))
2631
+ lineBag.update(host['stderr'])
2632
+ if len(host['stderr']) > 1:
2633
+ lineBag.update(zip(host['stderr'], host['stderr'][1:]))
2634
+ prevLine = host['stderr'][-1]
2635
+ hostPrintOut.append(f"│░ RETURN CODE: {host['returncode']}")
2636
+ lineBag.add((prevLine,f"{host['returncode']}"))
2637
+ outputs_by_hostname[host['name']] = hostPrintOut
2638
+ line_bag_by_hostname[host['name']] = lineBag
2639
+ hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
2640
+ return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len)
2641
+
2642
+ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
2643
+ merge_groups = []
2644
+ for line_bag_len in hostnames_by_line_bag_len.copy():
2645
+ for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
2646
+ if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
2647
+ continue
2648
+ this_line_bag = line_bag_by_hostname[this_hostname]
2649
+ target_threshold = line_bag_len * (2 - diff_display_threshold)
2650
+ merge_group = []
2651
+ for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
2652
+ if other_line_bag_len > target_threshold:
2653
+ break
2654
+ if other_line_bag_len < line_bag_len:
2655
+ continue
2656
+ for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
2657
+ if this_hostname == other_hostname:
2658
+ continue
2659
+ if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
2660
+ merge_group.append(other_hostname)
2661
+ hostnames_by_line_bag_len[line_bag_len].discard(this_hostname)
2662
+ hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
2663
+ if not hostnames_by_line_bag_len[other_line_bag_len]:
2664
+ del hostnames_by_line_bag_len[other_line_bag_len]
2665
+ del line_bag_by_hostname[other_hostname]
2666
+ if merge_group:
2667
+ merge_group.append(this_hostname)
2668
+ merge_groups.append(merge_group)
2669
+ return merge_groups
2670
+
2359
2671
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
2360
2672
  if quiet:
2361
2673
  # remove hosts with returncode 0
@@ -2394,40 +2706,30 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2394
2706
  rtnStr += 'User Inputs: '+ '\nUser Inputs: '.join(CMDsOut)
2395
2707
  #rtnStr += '\n'
2396
2708
  else:
2397
- outputs = {}
2398
- for host in hosts:
2399
- hostPrintOut = f" Command:\n {host['command']}\n"
2400
- hostPrintOut += " stdout:\n "+'\n '.join(host['stdout'])
2401
- if host['stderr']:
2402
- if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2403
- host['stderr'][0] = 'SSH not reachable!'
2404
- elif host['stderr'][-1].strip().endswith('Connection timed out'):
2405
- host['stderr'][-1] = 'SSH connection timed out!'
2406
- elif host['stderr'][-1].strip().endswith('No route to host'):
2407
- host['stderr'][-1] = 'Cannot find host!'
2408
- hostPrintOut += "\n stderr:\n "+'\n '.join(host['stderr'])
2409
- hostPrintOut += f"\n return_code: {host['returncode']}"
2410
- outputs.setdefault(hostPrintOut, set()).add(host['name'])
2411
- rtnStr = ''
2412
- for output, hostSet in outputs.items():
2413
- compact_hosts = compact_hostnames(hostSet)
2414
- rtnStr += '*'*80+'\n'
2415
- if quiet:
2416
- rtnStr += f'Abnormal returncode produced by {",".join(compact_hosts)}:\n'
2417
- rtnStr += output+'\n'
2418
- else:
2419
- rtnStr += f'These hosts: "{",".join(compact_hosts)}" have a response of:\n'
2420
- rtnStr += output+'\n'
2421
- if not quiet or outputs:
2422
- rtnStr += '*'*80+'\n'
2709
+ try:
2710
+ diff_display_threshold = float(DEFAULT_DIFF_DISPLAY_THRESHOLD)
2711
+ if diff_display_threshold < 0 or diff_display_threshold > 1:
2712
+ raise ValueError
2713
+ except Exception:
2714
+ eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2715
+ diff_display_threshold = 0.9
2716
+ terminal_length = get_terminal_size()[0]
2717
+ outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys = get_host_raw_output(hosts)
2718
+ merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
2719
+ # get the remaining hostnames in the hostnames_by_line_bag_len
2720
+ remaining_hostnames = set()
2721
+ for hostnames in hostnames_by_line_bag_len.values():
2722
+ remaining_hostnames.update(hostnames)
2723
+ outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold)
2423
2724
  if keyPressesIn[-1]:
2424
2725
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2425
- rtnStr += "User Inputs: \n "
2426
- rtnStr += '\n '.join(CMDsOut)
2427
- rtnStr += '\n'
2726
+ outputs.append("├─ User Inputs:".ljust(terminal_length-1,'─'))
2727
+ outputs.extend(CMDsOut)
2428
2728
  keyPressesIn[-1].clear()
2429
2729
  if quiet and not outputs:
2430
- rtnStr += 'Success'
2730
+ rtnStr = 'Success'
2731
+ else:
2732
+ rtnStr = '\n'.join(outputs + [('╘'+'─'*(terminal_length-1))])
2431
2733
  return rtnStr
2432
2734
 
2433
2735
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -2485,8 +2787,8 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2485
2787
  availableHosts = set()
2486
2788
  for host in hosts:
2487
2789
  if host.stderr and ('No route to host' in host.stderr[0].strip() or 'Connection timed out' in host.stderr[0].strip() or (host.stderr[-1].strip().startswith('Timeout!') and host.returncode == 124)):
2488
- unavailableHosts[host.name] = int(time.monotonic())
2489
- __globalUnavailableHosts[host.name] = int(time.monotonic())
2790
+ unavailableHosts[host.name] = int(time.monotonic() + unavailable_host_expiry)
2791
+ __globalUnavailableHosts[host.name] = int(time.monotonic() + unavailable_host_expiry)
2490
2792
  else:
2491
2793
  availableHosts.add(host.name)
2492
2794
  if host.name in unavailableHosts:
@@ -2511,7 +2813,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2511
2813
  expireTime = int(line.split(',')[1])
2512
2814
  if expireTime < time.monotonic() and hostname not in availableHosts:
2513
2815
  oldDic[hostname] = expireTime
2514
- except:
2816
+ except Exception:
2515
2817
  pass
2516
2818
  # add new entries
2517
2819
  oldDic.update(unavailableHosts)
@@ -2565,33 +2867,60 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
2565
2867
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
2566
2868
  shortend = False) -> str:
2567
2869
  argsList = []
2568
- if oneonone: argsList.append('--oneonone' if not shortend else '-11')
2569
- if timeout and timeout != DEFAULT_TIMEOUT: argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
2570
- if repeat and repeat != DEFAULT_REPEAT: argsList.append(f'--repeat={repeat}' if not shortend else f'-r={repeat}')
2571
- if interval and interval != DEFAULT_INTERVAL: argsList.append(f'--interval={interval}' if not shortend else f'-i={interval}')
2572
- if password and password != DEFAULT_PASSWORD: argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
2573
- if identity_file and identity_file != DEFAULT_IDENTITY_FILE: argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
2574
- if copy_id: argsList.append('--copy_id' if not shortend else '-ci')
2575
- if no_watch: argsList.append('--no_watch' if not shortend else '-q')
2576
- if json: argsList.append('--json' if not shortend else '-j')
2577
- if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS: argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
2578
- if files: argsList.extend([f'--file="{file}"' for file in files] if not shortend else [f'-f="{file}"' for file in files])
2579
- if ipmi: argsList.append('--ipmi')
2580
- if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX: argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
2581
- if scp: argsList.append('--scp')
2582
- if gather_mode: argsList.append('--gather_mode' if not shortend else '-gm')
2583
- if username and username != DEFAULT_USERNAME: argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
2584
- if extraargs and extraargs != DEFAULT_EXTRA_ARGS: argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
2585
- if skipUnreachable: argsList.append('--skip_unreachable' if not shortend else '-su')
2586
- if unavailable_host_expiry and unavailable_host_expiry != DEFAULT_UNAVAILABLE_HOST_EXPIRY: argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
2587
- if no_env: argsList.append('--no_env')
2588
- if env_file and env_file != DEFAULT_ENV_FILE: argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
2589
- if no_history: argsList.append('--no_history' if not shortend else '-nh')
2590
- if history_file and history_file != DEFAULT_HISTORY_FILE: argsList.append(f'--history_file="{history_file}"' if not shortend else f'-hf="{history_file}"')
2591
- if greppable: argsList.append('--greppable' if not shortend else '-g')
2592
- if error_only: argsList.append('--error_only' if not shortend else '-eo')
2593
- if skip_hosts and skip_hosts != DEFAULT_SKIP_HOSTS: argsList.append(f'--skip_hosts="{skip_hosts}"' if not shortend else f'-sh="{skip_hosts}"')
2594
- if file_sync: argsList.append('--file_sync' if not shortend else '-fs')
2870
+ if oneonone:
2871
+ argsList.append('--oneonone' if not shortend else '-11')
2872
+ if timeout and timeout != DEFAULT_TIMEOUT:
2873
+ argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
2874
+ if repeat and repeat != DEFAULT_REPEAT:
2875
+ argsList.append(f'--repeat={repeat}' if not shortend else f'-r={repeat}')
2876
+ if interval and interval != DEFAULT_INTERVAL:
2877
+ argsList.append(f'--interval={interval}' if not shortend else f'-i={interval}')
2878
+ if password and password != DEFAULT_PASSWORD:
2879
+ argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
2880
+ if identity_file and identity_file != DEFAULT_IDENTITY_FILE:
2881
+ argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
2882
+ if copy_id:
2883
+ argsList.append('--copy_id' if not shortend else '-ci')
2884
+ if no_watch:
2885
+ argsList.append('--no_watch' if not shortend else '-q')
2886
+ if json:
2887
+ argsList.append('--json' if not shortend else '-j')
2888
+ if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS:
2889
+ argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
2890
+ if files:
2891
+ argsList.extend([f'--file="{file}"' for file in files] if not shortend else [f'-f="{file}"' for file in files])
2892
+ if ipmi:
2893
+ argsList.append('--ipmi')
2894
+ if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX:
2895
+ argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
2896
+ if scp:
2897
+ argsList.append('--scp')
2898
+ if gather_mode:
2899
+ argsList.append('--gather_mode' if not shortend else '-gm')
2900
+ if username and username != DEFAULT_USERNAME:
2901
+ argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
2902
+ if extraargs and extraargs != DEFAULT_EXTRA_ARGS:
2903
+ argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
2904
+ if skipUnreachable:
2905
+ argsList.append('--skip_unreachable' if not shortend else '-su')
2906
+ if unavailable_host_expiry and unavailable_host_expiry != DEFAULT_UNAVAILABLE_HOST_EXPIRY:
2907
+ argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
2908
+ if no_env:
2909
+ argsList.append('--no_env')
2910
+ if env_file and env_file != DEFAULT_ENV_FILE:
2911
+ argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
2912
+ if no_history:
2913
+ argsList.append('--no_history' if not shortend else '-nh')
2914
+ if history_file and history_file != DEFAULT_HISTORY_FILE:
2915
+ argsList.append(f'--history_file="{history_file}"' if not shortend else f'-hf="{history_file}"')
2916
+ if greppable:
2917
+ argsList.append('--greppable' if not shortend else '-g')
2918
+ if error_only:
2919
+ argsList.append('--error_only' if not shortend else '-eo')
2920
+ if skip_hosts and skip_hosts != DEFAULT_SKIP_HOSTS:
2921
+ argsList.append(f'--skip_hosts="{skip_hosts}"' if not shortend else f'-sh="{skip_hosts}"')
2922
+ if file_sync:
2923
+ argsList.append('--file_sync' if not shortend else '-fs')
2595
2924
  return ' '.join(argsList)
2596
2925
 
2597
2926
  def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
@@ -2751,7 +3080,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2751
3080
  if line and ',' in line and len(line.split(',')) >= 2 and line.split(',')[0] and line.split(',')[1].isdigit():
2752
3081
  hostname = line.split(',')[0]
2753
3082
  expireTime = int(line.split(',')[1])
2754
- if expireTime < time.monotonic() and expireTime + unavailable_host_expiry > time.monotonic():
3083
+ if expireTime > time.monotonic():
2755
3084
  __globalUnavailableHosts[hostname] = expireTime
2756
3085
  readed = True
2757
3086
  if readed and not __global_suppress_printout:
@@ -2760,7 +3089,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2760
3089
  eprint(f"Warning: Unable to read the unavailable hosts from the file {os.path.join(tempfile.gettempdir(),f'__{getpass.getuser()}_multiSSH3_UNAVAILABLE_HOSTS.csv')!r}")
2761
3090
  eprint(str(e))
2762
3091
  elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
2763
- __globalUnavailableHosts.update({host: int(time.monotonic()) for host in readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(',') if host})
3092
+ __globalUnavailableHosts.update({host: int(time.monotonic()+ unavailable_host_expiry) for host in readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(',') if host})
2764
3093
  if not max_connections:
2765
3094
  max_connections = 4 * os.cpu_count()
2766
3095
  elif max_connections == 0:
@@ -2832,7 +3161,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2832
3161
  # we will copy the id to the hosts
2833
3162
  hosts = []
2834
3163
  for host in targetHostDic:
2835
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
3164
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
3165
+ continue
2836
3166
  command = f"{_binPaths['ssh-copy-id']} "
2837
3167
  if identity_file:
2838
3168
  command = f"{command}-i {identity_file} "
@@ -2868,7 +3198,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2868
3198
  for file in files:
2869
3199
  try:
2870
3200
  pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
2871
- except:
3201
+ except Exception:
2872
3202
  pathSet.update(glob.glob(file,recursive=True))
2873
3203
  if not pathSet:
2874
3204
  _exit_with_code(66, f'No source files at {files!r} are found after resolving globs!')
@@ -2893,17 +3223,19 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2893
3223
  eprint('-'*80)
2894
3224
  eprint("Running in one on one mode")
2895
3225
  for host, command in zip(targetHostDic, commands):
2896
- if not ipmi and skipUnreachable and host in unavailableHosts:
3226
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
2897
3227
  eprint(f"Skipping unavailable host: {host}")
2898
3228
  continue
2899
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
3229
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
3230
+ continue
2900
3231
  if file_sync:
2901
3232
  hosts.append(Host(host, os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip = targetHostDic[host]))
2902
3233
  else:
2903
3234
  hosts.append(Host(host, command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
2904
3235
  if not __global_suppress_printout:
2905
3236
  eprint(f"Running command: {command!r} on host: {host!r}")
2906
- if not __global_suppress_printout: eprint('-'*80)
3237
+ if not __global_suppress_printout:
3238
+ eprint('-'*80)
2907
3239
  if not no_start:
2908
3240
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
2909
3241
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
@@ -2917,15 +3249,17 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2917
3249
  # run in interactive mode ssh mode
2918
3250
  hosts = []
2919
3251
  for host in targetHostDic:
2920
- if not ipmi and skipUnreachable and host in unavailableHosts:
2921
- if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
3252
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
3253
+ if not __global_suppress_printout:
3254
+ print(f"Skipping unavailable host: {host}")
3255
+ continue
3256
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
2922
3257
  continue
2923
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
2924
3258
  if file_sync:
2925
- eprint(f"Error: file sync mode need to be specified with at least one path to sync.")
3259
+ eprint("Error: file sync mode need to be specified with at least one path to sync.")
2926
3260
  return []
2927
3261
  elif files:
2928
- eprint(f"Error: files need to be specified with at least one path to sync")
3262
+ eprint("Error: files need to be specified with at least one path to sync")
2929
3263
  else:
2930
3264
  hosts.append(Host(host, '', files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file,ip=targetHostDic[host]))
2931
3265
  if not __global_suppress_printout:
@@ -2933,7 +3267,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2933
3267
  eprint(f"Running in interactive mode on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
2934
3268
  eprint('-'*80)
2935
3269
  if no_start:
2936
- eprint(f"Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
3270
+ eprint("Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
2937
3271
  else:
2938
3272
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
2939
3273
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
@@ -2944,10 +3278,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2944
3278
  for command in commands:
2945
3279
  hosts = []
2946
3280
  for host in targetHostDic:
2947
- if not ipmi and skipUnreachable and host in unavailableHosts:
2948
- if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
3281
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
3282
+ if not __global_suppress_printout:
3283
+ print(f"Skipping unavailable host: {host}")
3284
+ continue
3285
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
2949
3286
  continue
2950
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
2951
3287
  if file_sync:
2952
3288
  hosts.append(Host(host, os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
2953
3289
  else:
@@ -2997,6 +3333,8 @@ def generate_default_config(args):
2997
3333
  'DEFAULT_IPMI': args.ipmi,
2998
3334
  'DEFAULT_IPMI_INTERFACE_IP_PREFIX': args.ipmi_interface_ip_prefix,
2999
3335
  'DEFAULT_INTERFACE_IP_PREFIX': args.interface_ip_prefix,
3336
+ 'DEFAULT_IPMI_USERNAME': args.ipmi_username,
3337
+ 'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
3000
3338
  'DEFAULT_NO_WATCH': args.no_watch,
3001
3339
  'DEFAULT_CURSES_MINIMUM_CHAR_LEN': args.window_width,
3002
3340
  'DEFAULT_CURSES_MINIMUM_LINE_LEN': args.window_height,
@@ -3015,6 +3353,7 @@ def generate_default_config(args):
3015
3353
  'DEFAULT_SKIP_UNREACHABLE': args.skip_unreachable,
3016
3354
  'DEFAULT_SKIP_HOSTS': args.skip_hosts,
3017
3355
  'DEFAULT_ENCODING': args.encoding,
3356
+ 'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
3018
3357
  'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
3019
3358
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
3020
3359
  }
@@ -3029,9 +3368,9 @@ def write_default_config(args,CONFIG_FILE = None):
3029
3368
  backup = True
3030
3369
  if os.path.exists(CONFIG_FILE):
3031
3370
  eprint(f"Warning: {CONFIG_FILE!r} already exists, what to do? (o/b/n)")
3032
- eprint(f"o: Overwrite the file")
3371
+ eprint("o: Overwrite the file")
3033
3372
  eprint(f"b: Rename the current config file at {CONFIG_FILE!r}.bak forcefully and write the new config file (default)")
3034
- eprint(f"n: Do nothing")
3373
+ eprint("n: Do nothing")
3035
3374
  inStr = input_with_timeout_and_countdown(10)
3036
3375
  if (not inStr) or inStr.lower().strip().startswith('b'):
3037
3376
  backup = True
@@ -3054,7 +3393,7 @@ def write_default_config(args,CONFIG_FILE = None):
3054
3393
  eprint(f"Config file written to {CONFIG_FILE!r}")
3055
3394
  except Exception as e:
3056
3395
  eprint(f"Error: Unable to write to the config file: {e!r}")
3057
- eprint(f'Printing the config file to stdout:')
3396
+ eprint('Printing the config file to stdout:')
3058
3397
  print(json.dumps(__configs_from_file, indent=4))
3059
3398
 
3060
3399
  #%% ------------ Argument Processing -----------------
@@ -3073,7 +3412,7 @@ def get_parser():
3073
3412
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3074
3413
  parser.add_argument('-s','-fs','--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
3075
3414
  parser.add_argument('-W','--scp', action='store_true', help=f'Use scp for copying files instead of rsync. Need to use this on windows. (default: {DEFAULT_SCP})', default=DEFAULT_SCP)
3076
- parser.add_argument('-G','-gm','--gather_mode', action='store_true', help=f'Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3415
+ parser.add_argument('-G','-gm','--gather_mode', action='store_true', help='Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3077
3416
  #parser.add_argument("-d",'-c',"--destination", type=str, help="The destination of the files. Same as specify with commands. Added for compatibility. Use #HOST# or #HOSTNAME# to replace the host name in the destination")
3078
3417
  parser.add_argument("-t","--timeout", type=int, help=f"Timeout for each command in seconds. Set default value via DEFAULT_CLI_TIMEOUT in config file. Use 0 for disabling timeout. (default: {DEFAULT_CLI_TIMEOUT})", default=DEFAULT_CLI_TIMEOUT)
3079
3418
  parser.add_argument('-T','--use_script_timeout',action='store_true', help=f'Use shortened timeout suitable to use in a script. Set value via DEFAULT_TIMEOUT field in config file. (current: {DEFAULT_TIMEOUT})', default=False)
@@ -3082,6 +3421,8 @@ def get_parser():
3082
3421
  parser.add_argument('-M',"--ipmi", action='store_true', help=f"Use ipmitool to run the command. (default: {DEFAULT_IPMI})", default=DEFAULT_IPMI)
3083
3422
  parser.add_argument("-mpre","--ipmi_interface_ip_prefix", type=str, help=f"The prefix of the IPMI interfaces (default: {DEFAULT_IPMI_INTERFACE_IP_PREFIX})", default=DEFAULT_IPMI_INTERFACE_IP_PREFIX)
3084
3423
  parser.add_argument("-pre","--interface_ip_prefix", type=str, help=f"The prefix of the for the interfaces (default: {DEFAULT_INTERFACE_IP_PREFIX})", default=DEFAULT_INTERFACE_IP_PREFIX)
3424
+ parser.add_argument('-iu','--ipmi_username', type=str,help=f'The username to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_USERNAME})',default=DEFAULT_IPMI_USERNAME)
3425
+ parser.add_argument('-ip','--ipmi_password', type=str,help=f'The password to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_PASSWORD})',default=DEFAULT_IPMI_PASSWORD)
3085
3426
  parser.add_argument('-S',"-q","-nw","--no_watch","--quiet", action='store_true', help=f"Quiet mode, no curses watch, only print the output. (default: {DEFAULT_NO_WATCH})", default=DEFAULT_NO_WATCH)
3086
3427
  parser.add_argument("-ww",'--window_width', type=int, help=f"The minimum character length of the curses window. (default: {DEFAULT_CURSES_MINIMUM_CHAR_LEN})", default=DEFAULT_CURSES_MINIMUM_CHAR_LEN)
3087
3428
  parser.add_argument("-wh",'--window_height', type=int, help=f"The minimum line height of the curses window. (default: {DEFAULT_CURSES_MINIMUM_LINE_LEN})", default=DEFAULT_CURSES_MINIMUM_LINE_LEN)
@@ -3091,28 +3432,31 @@ def get_parser():
3091
3432
  parser.add_argument('-Z','-rz','--return_zero', action='store_true', help=f"Return 0 even if there are errors. (default: {DEFAULT_RETURN_ZERO})", default=DEFAULT_RETURN_ZERO)
3092
3433
  parser.add_argument('-C','--no_env', action='store_true', help=f'Do not load the command line environment variables. (default: {DEFAULT_NO_ENV})', default=DEFAULT_NO_ENV)
3093
3434
  parser.add_argument("--env_file", type=str, help=f"The file to load the mssh file based environment variables from. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILE})", default=DEFAULT_ENV_FILE)
3094
- parser.add_argument("-m","--max_connections", type=int, help=f"Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3435
+ parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3095
3436
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3096
3437
  parser.add_argument('-w',"--success_hosts", action='store_true', help=f"Output the hosts that succeeded in summary as well. (default: {DEFAULT_PRINT_SUCCESS_HOSTS})", default=DEFAULT_PRINT_SUCCESS_HOSTS)
3097
3438
  parser.add_argument('-P',"-g","--greppable",'--table', action='store_true', help=f"Output in greppable table. (default: {DEFAULT_GREPPABLE_MODE})", default=DEFAULT_GREPPABLE_MODE)
3098
- group = parser.add_mutually_exclusive_group()
3099
- group.add_argument('-x',"-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
3100
- group.add_argument('-a',"-nsu","--no_skip_unreachable",dest = 'skip_unreachable', action='store_false', help=f"Do not skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {not DEFAULT_SKIP_UNREACHABLE})", default=not DEFAULT_SKIP_UNREACHABLE)
3439
+ su_group = parser.add_mutually_exclusive_group()
3440
+ su_group.add_argument('-x',"-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
3441
+ su_group.add_argument('-a',"-nsu","--no_skip_unreachable",dest = 'skip_unreachable', action='store_false', help=f"Do not skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {not DEFAULT_SKIP_UNREACHABLE})", default=not DEFAULT_SKIP_UNREACHABLE)
3101
3442
  parser.add_argument('-uhe','--unavailable_host_expiry', type=int, help=f"Time in seconds to expire the unavailable hosts (default: {DEFAULT_UNAVAILABLE_HOST_EXPIRY})", default=DEFAULT_UNAVAILABLE_HOST_EXPIRY)
3102
3443
  parser.add_argument('-X',"-sh","--skip_hosts", type=str, help=f"Skip the hosts in the list. (default: {DEFAULT_SKIP_HOSTS if DEFAULT_SKIP_HOSTS else 'None'})", default=DEFAULT_SKIP_HOSTS)
3103
- parser.add_argument('--generate_config_file', action='store_true', help=f'Store / generate the default config file from command line argument and current config at --config_file / stdout')
3104
- parser.add_argument('--config_file', type=str,nargs='?', help=f'Additional config file to use, will pioritize over config chains. When using with store_config_file, will store the resulting config file at this location. Use without a path will use multiSSH3.config.json',const='multiSSH3.config.json',default=None)
3105
- parser.add_argument('--store_config_file',type = str,nargs='?',help=f'Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
3444
+ parser.add_argument('--generate_config_file', action='store_true', help='Store / generate the default config file from command line argument and current config at --config_file / stdout')
3445
+ parser.add_argument('--config_file', type=str,nargs='?', help='Additional config file to use, will pioritize over config chains. When using with store_config_file, will store the resulting config file at this location. Use without a path will use multiSSH3.config.json',const='multiSSH3.config.json',default=None)
3446
+ parser.add_argument('--store_config_file',type = str,nargs='?',help='Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
3106
3447
  parser.add_argument('--debug', action='store_true', help='Print debug information')
3107
3448
  parser.add_argument('-ci','--copy_id', action='store_true', help='Copy the ssh id to the hosts')
3108
3449
  parser.add_argument('-I','-nh','--no_history', action='store_true', help=f'Do not record the command to history. Default: {DEFAULT_NO_HISTORY}', default=DEFAULT_NO_HISTORY)
3109
3450
  parser.add_argument('-hf','--history_file', type=str, help=f'The file to store the history. (default: {DEFAULT_HISTORY_FILE})', default=DEFAULT_HISTORY_FILE)
3110
3451
  parser.add_argument('--script', action='store_true', help='Run the command in script mode, short for -SCRIPT or --no_watch --skip_unreachable --no_env --no_history --greppable --error_only')
3111
3452
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3453
+ parser.add_argument('-ddt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. Set to 0 to always display the diff. (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
3112
3454
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3113
3455
  return parser
3114
3456
 
3115
3457
  def process_args(args = None):
3458
+ global DEFAULT_IPMI_USERNAME
3459
+ global DEFAULT_IPMI_PASSWORD
3116
3460
  parser = get_parser()
3117
3461
  # We handle the signal
3118
3462
  signal.signal(signal.SIGINT, signal_handler)
@@ -3172,10 +3516,10 @@ def process_config_file(args):
3172
3516
 
3173
3517
  def process_commands(args):
3174
3518
  if not args.file and len(args.commands) > 1 and all([len(command.split()) == 1 for command in args.commands]):
3175
- eprint(f"Multiple one word command detected, what to do? (1/m/n)")
3519
+ eprint("Multiple one word command detected, what to do? (1/m/n)")
3176
3520
  eprint(f"1: Run 1 command [{' '.join(args.commands)}] on all hosts ( default )")
3177
3521
  eprint(f"m: Run multiple commands [{', '.join(args.commands)}] on all hosts")
3178
- eprint(f"n: Exit")
3522
+ eprint("n: Exit")
3179
3523
  inStr = input_with_timeout_and_countdown(3)
3180
3524
  if (not inStr) or inStr.lower().strip().startswith('1'):
3181
3525
  args.commands = [" ".join(args.commands)]
@@ -3206,6 +3550,9 @@ def set_global_with_args(args):
3206
3550
  global __configs_from_file
3207
3551
  global _encoding
3208
3552
  global __returnZero
3553
+ global DEFAULT_IPMI_USERNAME
3554
+ global DEFAULT_IPMI_PASSWORD
3555
+ global DEFAULT_DIFF_DISPLAY_THRESHOLD
3209
3556
  _emo = False
3210
3557
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3211
3558
  _env_file = args.env_file
@@ -3213,6 +3560,11 @@ def set_global_with_args(args):
3213
3560
  _encoding = args.encoding
3214
3561
  if args.return_zero:
3215
3562
  __returnZero = True
3563
+ if args.ipmi_username:
3564
+ DEFAULT_IPMI_USERNAME = args.ipmi_username
3565
+ if args.ipmi_password:
3566
+ DEFAULT_IPMI_PASSWORD = args.ipmi_password
3567
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
3216
3568
 
3217
3569
  #%% ------------ Wrapper Block ----------------
3218
3570
  def main():
@@ -3252,7 +3604,8 @@ def main():
3252
3604
  eprint(f"Sleeping for {args.interval} seconds")
3253
3605
  time.sleep(args.interval)
3254
3606
 
3255
- if not __global_suppress_printout: eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3607
+ if not __global_suppress_printout:
3608
+ eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3256
3609
  hosts = run_command_on_hosts(args.hosts,args.commands,
3257
3610
  oneonone=args.oneonone,timeout=args.timeout,password=args.password,
3258
3611
  no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
@@ -3278,7 +3631,8 @@ def main():
3278
3631
  eprint(f'Complete. Failed hosts (Return Code not 0) count: {__mainReturnCode}')
3279
3632
  eprint(f'failed_hosts: {",".join(compact_hostnames(__failedHosts))}')
3280
3633
  else:
3281
- if not __global_suppress_printout: eprint('Complete. All hosts returned 0.')
3634
+ if not __global_suppress_printout:
3635
+ eprint('Complete. All hosts returned 0.')
3282
3636
 
3283
3637
  if args.success_hosts and not __global_suppress_printout:
3284
3638
  eprint(f'succeeded_hosts: {",".join(compact_hostnames(succeededHosts))}')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes