multiSSH3 5.91__py3-none-any.whl → 6.2__py3-none-any.whl

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.

multiSSH3.py CHANGED
@@ -12,6 +12,7 @@ import getpass
12
12
  import glob
13
13
  import io
14
14
  import ipaddress
15
+ import itertools
15
16
  import json
16
17
  import math
17
18
  import os
@@ -29,7 +30,7 @@ import threading
29
30
  import time
30
31
  import typing
31
32
  import uuid
32
- from collections import Counter, deque
33
+ from collections import Counter, deque, defaultdict
33
34
  from itertools import count, product
34
35
 
35
36
  __curses_available = False
@@ -46,7 +47,6 @@ try:
46
47
  except ImportError:
47
48
  pass
48
49
 
49
-
50
50
  try:
51
51
  # Check if functiools.cache is available
52
52
  # cache_decorator = functools.cache
@@ -84,10 +84,10 @@ except Exception:
84
84
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
85
85
  def cache_decorator(func):
86
86
  return func
87
- version = '5.91'
87
+ version = '6.02'
88
88
  VERSION = version
89
89
  __version__ = version
90
- COMMIT_DATE = '2025-10-17'
90
+ COMMIT_DATE = '2025-11-10'
91
91
 
92
92
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
93
  '~/multiSSH3.config.json',
@@ -153,33 +153,6 @@ def signal_handler(sig, frame):
153
153
  os.system(f'pkill -ef {os.path.basename(__file__)}')
154
154
  _exit_with_code(1, 'Exiting immediately due to Ctrl C')
155
155
 
156
- # def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
157
- # """
158
- # Read an input from the user with a timeout and a countdown.
159
-
160
- # Parameters:
161
- # timeout (int): The timeout value in seconds.
162
- # prompt (str): The prompt message to display to the user. Default is 'Please enter your selection'.
163
-
164
- # Returns:
165
- # str or None: The user input if received within the timeout, or None if no input is received.
166
- # """
167
- # import select
168
- # # Print the initial prompt with the countdown
169
- # eprint(f"{prompt} [{timeout}s]: ", end='', flush=True)
170
- # # Loop until the timeout
171
- # for remaining in range(timeout, 0, -1):
172
- # # If there is an input, return it
173
- # # this only works on linux
174
- # if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
175
- # return input().strip()
176
- # # Print the remaining time
177
- # eprint(f"\r{prompt} [{remaining}s]: ", end='', flush=True)
178
- # # Wait a second
179
- # time.sleep(1)
180
- # # If there is no input, return None
181
- # return None
182
-
183
156
  def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
184
157
  """
185
158
  Read input from the user with a timeout (cross-platform).
@@ -312,6 +285,13 @@ extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={
312
285
  identity_file={self.identity_file}, ip={self.ip}, current_color_pair={self.current_color_pair}"
313
286
  def __str__(self):
314
287
  return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
288
+ def get_output_hash(self):
289
+ return hash((
290
+ self.command,
291
+ tuple(self.stdout),
292
+ tuple(self.stderr),
293
+ self.returncode
294
+ ))
315
295
 
316
296
  #%% ------------ Load Defaults ( Config ) File ----------------
317
297
  def load_config_file(config_file):
@@ -341,7 +321,7 @@ DEFAULT_HOSTS = 'all'
341
321
  DEFAULT_USERNAME = None
342
322
  DEFAULT_PASSWORD = ''
343
323
  DEFAULT_IDENTITY_FILE = None
344
- DEDAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
324
+ DEFAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
345
325
  DEFAULT_USE_KEY = False
346
326
  DEFAULT_EXTRA_ARGS = None
347
327
  DEFAULT_ONE_ON_ONE = False
@@ -358,14 +338,23 @@ DEFAULT_INTERFACE_IP_PREFIX = None
358
338
  DEFAULT_IPMI_USERNAME = 'ADMIN'
359
339
  DEFAULT_IPMI_PASSWORD = ''
360
340
  DEFAULT_NO_WATCH = False
361
- DEFAULT_CURSES_MINIMUM_CHAR_LEN = 40
362
- DEFAULT_CURSES_MINIMUM_LINE_LEN = 1
341
+ DEFAULT_WINDOW_WIDTH = 40
342
+ DEFAULT_WINDOW_HEIGHT = 1
363
343
  DEFAULT_SINGLE_WINDOW = False
364
344
  DEFAULT_ERROR_ONLY = False
365
345
  DEFAULT_NO_OUTPUT = False
366
346
  DEFAULT_RETURN_ZERO = False
367
347
  DEFAULT_NO_ENV = False
368
- DEFAULT_ENV_FILE = '/etc/profile.d/hosts.sh'
348
+ DEFAULT_ENV_FILE = ''
349
+ DEFAULT_ENV_FILES = ['/etc/profile.d/hosts.sh',
350
+ '~/.bashrc',
351
+ '~/.zshrc',
352
+ '~/host.env',
353
+ '~/hosts.env',
354
+ '.env',
355
+ 'host.env',
356
+ 'hosts.env',
357
+ ]
369
358
  DEFAULT_NO_HISTORY = False
370
359
  DEFAULT_HISTORY_FILE = '~/.mssh_history'
371
360
  DEFAULT_MAX_CONNECTIONS = 4 * os.cpu_count()
@@ -375,7 +364,7 @@ DEFAULT_GREPPABLE_MODE = False
375
364
  DEFAULT_SKIP_UNREACHABLE = True
376
365
  DEFAULT_SKIP_HOSTS = ''
377
366
  DEFAULT_ENCODING = 'utf-8'
378
- DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
367
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.6
379
368
  SSH_STRICT_HOST_KEY_CHECKING = False
380
369
  FORCE_TRUECOLOR = False
381
370
  ERROR_MESSAGES_TO_IGNORE = [
@@ -387,6 +376,24 @@ ERROR_MESSAGES_TO_IGNORE = [
387
376
  'Killed by signal',
388
377
  'Connection reset by peer',
389
378
  ]
379
+ __DEFAULT_COLOR_PALETTE = {
380
+ 'cyan': (86, 173, 188),
381
+ 'green': (114, 180, 43),
382
+ 'magenta': (140, 107, 200),
383
+ 'red': (196, 38, 94),
384
+ 'white': (227, 227, 221),
385
+ 'yellow': (179, 180, 43),
386
+ 'blue': (106, 126, 200),
387
+ 'bright_black': (102, 102, 102),
388
+ 'bright_blue': (129, 154, 255),
389
+ 'bright_cyan': (102, 217, 239),
390
+ 'bright_green': (126, 226, 46),
391
+ 'bright_magenta': (174, 129, 255),
392
+ 'bright_red': (249, 38, 114),
393
+ 'bright_white': (248, 248, 242),
394
+ 'bright_yellow': (226, 226, 46),
395
+ }
396
+ COLOR_PALETTE = __DEFAULT_COLOR_PALETTE.copy()
390
397
  _DEFAULT_CALLED = True
391
398
  _DEFAULT_RETURN_UNFINISHED = False
392
399
  _DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
@@ -406,6 +413,9 @@ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
406
413
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__ERROR_MESSAGES_TO_IGNORE_REGEX)
407
414
  else:
408
415
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile('|'.join(ERROR_MESSAGES_TO_IGNORE))
416
+ if DEFAULT_ENV_FILE:
417
+ if DEFAULT_ENV_FILE not in DEFAULT_ENV_FILES:
418
+ DEFAULT_ENV_FILES.append(DEFAULT_ENV_FILE)
409
419
 
410
420
  #%% Load mssh Functional Global Variables
411
421
  __global_suppress_printout = False
@@ -413,7 +423,7 @@ __mainReturnCode = 0
413
423
  __failedHosts = set()
414
424
  __wildCharacters = ['*','?','x']
415
425
  _no_env = DEFAULT_NO_ENV
416
- _env_file = DEFAULT_ENV_FILE
426
+ _env_files = DEFAULT_ENV_FILES
417
427
  __globalUnavailableHosts = dict()
418
428
  __ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
419
429
  __keyPressesIn = [[]]
@@ -427,6 +437,11 @@ __thread_start_delay = 0
427
437
  _encoding = DEFAULT_ENCODING
428
438
  __returnZero = DEFAULT_RETURN_ZERO
429
439
  __running_threads = set()
440
+ __control_master_string = '''Host *
441
+ ControlMaster auto
442
+ ControlPath /run/user/%i/ssh_sockets_%C
443
+ ControlPersist 3600
444
+ '''
430
445
  if __resource_lib_available:
431
446
  # Get the current limits
432
447
  _, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
@@ -474,12 +489,12 @@ def check_path(program_name):
474
489
 
475
490
  [check_path(program) for program in _binCalled]
476
491
 
477
- def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
492
+ def find_ssh_key_file(searchPath = DEFAULT_SSH_KEY_SEARCH_PATH):
478
493
  '''
479
494
  Find the ssh public key file
480
495
 
481
496
  Args:
482
- searchPath (str, optional): The path to search. Defaults to DEDAULT_SSH_KEY_SEARCH_PATH.
497
+ searchPath (str, optional): The path to search. Defaults to DEFAULT_SSH_KEY_SEARCH_PATH.
483
498
 
484
499
  Returns:
485
500
  str: The path to the ssh key file
@@ -495,35 +510,65 @@ def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
495
510
  return None
496
511
 
497
512
  @cache_decorator
498
- def readEnvFromFile(environemnt_file = ''):
513
+ def readEnvFromFile():
499
514
  '''
500
515
  Read the environment variables from env_file
501
516
  Returns:
502
517
  dict: A dictionary of environment variables
503
518
  '''
504
- global env
505
- try:
506
- if env:
507
- return env
508
- except Exception:
509
- env = {}
510
- global _env_file
511
- if environemnt_file:
512
- envf = environemnt_file
513
- else:
514
- envf = _env_file if _env_file else DEFAULT_ENV_FILE
515
- if os.path.exists(envf):
516
- with open(envf,'r') as f:
517
- for line in f:
518
- if line.startswith('#') or not line.strip():
519
+ global _env_files
520
+ global _no_env
521
+ envfs = _env_files if _env_files else DEFAULT_ENV_FILES
522
+ translator = str.maketrans('&|"', ';;\'')
523
+ replacement_re = re.compile(r'\$(?:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})')
524
+ environemnt = {}
525
+ scrubCounter = 0
526
+ for envf in envfs:
527
+ envf = os.path.expanduser(os.path.expandvars(envf))
528
+ if os.path.exists(envf):
529
+ with open(envf,'r') as f:
530
+ lines = f.readlines()
531
+ for line in lines:
532
+ line = line.strip()
533
+ if not line or line.startswith('#') or '=' not in line:
519
534
  continue
520
- key, value = line.replace('export ', '', 1).strip().split('=', 1)
521
- key = key.strip().strip('"').strip("'")
522
- value = value.strip().strip('"').strip("'")
523
- # avoid infinite recursion
524
- if key != value:
525
- env[key] = value.strip('"').strip("'")
526
- return env
535
+ line = line.translate(translator)
536
+ commands = re.split(r";(?=(?:[^']*'[^']*')*[^']*$)", line)
537
+ for command in commands:
538
+ if not command or command.startswith('#') or '=' not in command or command.startswith('alias '):
539
+ continue
540
+ fields = re.split(r" (?=(?:[^']*'[^']*')*[^']*$)", command)
541
+ for field in fields:
542
+ try:
543
+ if field.startswith('export '):
544
+ field = field.replace('export ', '', 1).strip()
545
+ if not field or field.startswith('#') or '=' not in field:
546
+ continue
547
+ key, _, values = field.partition('=')
548
+ key = key.strip().strip("'")
549
+ values = values.strip().strip("'")
550
+ if '$' in values:
551
+ scrubCounter += 16
552
+ if key and values and key != values:
553
+ environemnt[key] = values
554
+ except Exception:
555
+ continue
556
+ while scrubCounter:
557
+ scrubCounter -= 1
558
+ found = False
559
+ for key, value in environemnt.items():
560
+ if '$' in value:
561
+ for match in replacement_re.findall(value):
562
+ ref_key = match.strip('${}')
563
+ ref_value = environemnt.get(ref_key) if ref_key != key else None
564
+ if not ref_value and not _no_env:
565
+ ref_value = os.environ.get(ref_key)
566
+ if ref_value:
567
+ environemnt[key] = value.replace(match, ref_value)
568
+ found = True
569
+ if not found:
570
+ break
571
+ return environemnt
527
572
 
528
573
  def replace_magic_strings(string,keys,value,case_sensitive=False):
529
574
  '''
@@ -650,8 +695,6 @@ def format_commands(commands):
650
695
  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}")
651
696
  return commands
652
697
 
653
-
654
-
655
698
  class OrderedMultiSet(deque):
656
699
  """
657
700
  A deque extension with O(1) average lookup time.
@@ -663,60 +706,138 @@ class OrderedMultiSet(deque):
663
706
  self._counter = Counter()
664
707
  if iterable is not None:
665
708
  self.extend(iterable)
666
- def __decrease_count(self, item):
667
- """Decrease count of item in counter."""
668
- self._counter[item] -= 1
669
- if self._counter[item] == 0:
670
- del self._counter[item]
671
- return self._counter.get(item, 0)
672
- def append(self, item,left=False):
709
+ def append(self, item):
673
710
  """Add item to the right end. O(1)."""
674
- removed = None
675
- if self.maxlen is not None and len(self) == self.maxlen:
676
- removed = self[-1] if left else self[0] # Item that will be removed
677
- self.__decrease_count(removed)
678
- super().appendleft(item) if left else super().append(item)
711
+ if len(self) == self.maxlen:
712
+ self._counter -= Counter([self[0]])
713
+ # self._counter[self[0]] -= 1
714
+ # self._counter += Counter()
715
+ super().append(item)
679
716
  self._counter[item] += 1
680
- return removed
681
717
  def appendleft(self, item):
682
718
  """Add item to the left end. O(1)."""
683
- return self.append(item,left=True)
684
- def pop(self,left=False):
719
+ if len(self) == self.maxlen:
720
+ self._counter -= Counter([self[-1]])
721
+ super().appendleft(item)
722
+ self._counter[item] += 1
723
+ def pop(self):
685
724
  """Remove and return item from right end. O(1)."""
686
- if not self:
725
+ try:
726
+ item = super().pop()
727
+ self._counter -= Counter([item])
728
+ return item
729
+ except IndexError:
687
730
  return None
688
- item = super().popleft() if left else super().pop()
689
- self.__decrease_count(item)
690
- return item
691
731
  def popleft(self):
692
732
  """Remove and return item from left end. O(1)."""
693
- return self.pop(left=True)
733
+ try:
734
+ item = super().popleft()
735
+ self._counter -= Counter([item])
736
+ return item
737
+ except IndexError:
738
+ return None
739
+ def put(self, item):
740
+ """Alias for append, but return removed item - add to right end (FIFO put)."""
741
+ removed = None
742
+ if len(self) == self.maxlen:
743
+ removed = self[0] # Item that will be removed
744
+ self._counter -= Counter([removed])
745
+ super().append(item)
746
+ self._counter[item] += 1
747
+ return removed
748
+ def put_left(self, item):
749
+ """Alias for appendleft, but return removed item - add to left end (LIFO put)."""
750
+ removed = None
751
+ if len(self) == self.maxlen:
752
+ removed = self[-1] # Item that will be removed
753
+ self._counter -= Counter([removed])
754
+ super().appendleft(item)
755
+ self._counter[item] += 1
756
+ return removed
757
+ def get(self):
758
+ """Alias for popleft - remove from left end (FIFO get)."""
759
+ return self.popleft()
694
760
  def remove(self, value):
695
761
  """Remove first occurrence of value. O(n)."""
696
762
  if value not in self._counter:
697
763
  return None
698
764
  super().remove(value)
699
- self.__decrease_count(value)
765
+ self._counter -= Counter([value])
700
766
  def clear(self):
701
767
  """Remove all items. O(1)."""
702
768
  super().clear()
703
769
  self._counter.clear()
704
770
  def extend(self, iterable):
705
771
  """Extend deque by appending elements from iterable. O(k)."""
706
- for item in iterable:
707
- self.append(item)
772
+ # if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extend
773
+ try:
774
+ if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
775
+ super().extend(iterable)
776
+ self._counter.update(iterable)
777
+ elif len(iterable) >= self.maxlen:
778
+ self.clear()
779
+ if isinstance(iterable, (list, tuple)):
780
+ iterable = iterable[-self.maxlen:]
781
+ else:
782
+ iterable = itertools.islice(iterable, len(iterable) - self.maxlen, None)
783
+ super().extend(iterable)
784
+ self._counter.update(iterable)
785
+ else:
786
+ num_to_keep = self.maxlen - len(iterable)
787
+ self.truncateright(num_to_keep)
788
+ super().extend(iterable)
789
+ self._counter.update(iterable)
790
+ except TypeError:
791
+ return self.extend(list(iterable))
708
792
  def extendleft(self, iterable):
709
793
  """Extend left side by appending elements from iterable. O(k)."""
710
- for item in iterable:
711
- self.appendleft(item)
794
+ # if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extendleft
795
+ try:
796
+ if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
797
+ super().extendleft(iterable)
798
+ self._counter.update(iterable)
799
+ elif len(iterable) >= self.maxlen:
800
+ self.clear()
801
+ if isinstance(iterable, (list, tuple)):
802
+ iterable = iterable[:self.maxlen]
803
+ else:
804
+ iterable = itertools.islice(iterable, 0, self.maxlen)
805
+ super().extendleft(iterable)
806
+ self._counter.update(iterable)
807
+ else:
808
+ num_to_keep = self.maxlen - len(iterable)
809
+ self.truncate(num_to_keep)
810
+ super().extendleft(iterable)
811
+ self._counter.update(iterable)
812
+ except TypeError:
813
+ return self.extendleft(list(iterable))
814
+ def update(self, iterable):
815
+ """Extend deque by appending elements from iterable. Alias for extend. O(k)."""
816
+ return self.extend(iterable)
817
+ def updateleft(self, iterable):
818
+ """Extend left side by appending elements from iterable. Alias for extendleft. O(k)."""
819
+ return self.extendleft(iterable)
820
+ def truncate(self, n):
821
+ """Truncate to keep left n items. O(n)."""
822
+ kept = list(itertools.islice(self, n))
823
+ dropped = Counter(itertools.islice(self, n, None))
824
+ super().clear()
825
+ super().extend(kept)
826
+ self._counter -= dropped
827
+ def truncateright(self, n):
828
+ """Truncate to keep right n items. O(n)."""
829
+ kept = list(itertools.islice(self, len(self) - n, None))
830
+ dropped = Counter(itertools.islice(self, 0, len(self) - n))
831
+ super().clear()
832
+ super().extend(kept)
833
+ self._counter -= dropped
712
834
  def rotate(self, n=1):
713
835
  """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
714
- if not self:
715
- return
716
836
  super().rotate(n)
717
837
  def __contains__(self, item):
718
838
  """Check if item exists in deque. O(1) average."""
719
- return item in self._counter
839
+ # return item in self._counter
840
+ return super().__contains__(item)
720
841
  def count(self, item):
721
842
  """Return number of occurrences of item. O(1)."""
722
843
  return self._counter[item]
@@ -724,14 +845,14 @@ class OrderedMultiSet(deque):
724
845
  """Set item at index. O(1) for access, O(1) for counter update."""
725
846
  old_value = self[index]
726
847
  super().__setitem__(index, value)
727
- self.__decrease_count(old_value)
848
+ self._counter -= Counter([old_value])
728
849
  self._counter[value] += 1
729
850
  return old_value
730
851
  def __delitem__(self, index):
731
852
  """Delete item at index. O(n) for deletion, O(1) for counter update."""
732
853
  value = self[index]
733
854
  super().__delitem__(index)
734
- self.__decrease_count(value)
855
+ self._counter -= Counter([value])
735
856
  return value
736
857
  def insert(self, index, value):
737
858
  """Insert value at index. O(n) for insertion, O(1) for counter update."""
@@ -753,22 +874,40 @@ class OrderedMultiSet(deque):
753
874
  if self.maxlen is not None:
754
875
  return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
755
876
  return f"OrderedMultiSet({list(self)})"
756
- def put(self, item,left=False):
757
- """Alias for append - add to right end (FIFO put)."""
758
- return self.append(item,left=left)
759
- def get(self,left=True):
760
- """Alias for popleft - remove from left end (FIFO get)."""
761
- return self.pop(left=left)
762
877
  def peek(self):
763
878
  """Return leftmost item without removing it."""
764
- if not self:
879
+ try:
880
+ return self[0]
881
+ except IndexError:
765
882
  return None
766
- return self[0]
767
883
  def peek_right(self):
768
884
  """Return rightmost item without removing it."""
769
- if not self:
885
+ try:
886
+ return self[-1]
887
+ except IndexError:
770
888
  return None
771
- return self[-1]
889
+ def __iadd__(self, value):
890
+ return self.extend(value)
891
+ def __add__(self, value):
892
+ new_deque = self.copy()
893
+ new_deque.extend(value)
894
+ return new_deque
895
+ def __mul__(self, value):
896
+ new_deque = OrderedMultiSet(maxlen=self.maxlen)
897
+ for _ in range(value):
898
+ new_deque.extend(self)
899
+ return new_deque
900
+ def __imul__(self, value):
901
+ if value <= 0:
902
+ self.clear()
903
+ return self
904
+ for _ in range(value - 1):
905
+ self.extend(self)
906
+ return self
907
+ def __eq__(self, value):
908
+ if isinstance(value, OrderedMultiSet):
909
+ return self._counter == value._counter
910
+ return super().__eq__(value)
772
911
 
773
912
  def get_terminal_size():
774
913
  '''
@@ -814,6 +953,8 @@ def get_terminal_color_capability():
814
953
  return '24bit'
815
954
  elif "256" in term:
816
955
  return '256'
956
+ elif "16" in term:
957
+ return '16'
817
958
  try:
818
959
  curses.setupterm()
819
960
  colors = curses.tigetnum("colors")
@@ -832,96 +973,120 @@ def get_terminal_color_capability():
832
973
  return 'None'
833
974
 
834
975
  @cache_decorator
835
- def get_xterm256_palette():
836
- palette = []
837
- # 0–15: system colors (we'll just fill with dummy values;
838
- # you could fill in real RGB if you need to)
839
- system_colors = [
840
- (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
841
- (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
842
- (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
843
- (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
844
- ]
845
- palette.extend(system_colors)
846
- # 16–231: 6x6x6 color cube
847
- levels = [0, 95, 135, 175, 215, 255]
848
- for r in levels:
849
- for g in levels:
850
- for b in levels:
851
- palette.append((r, g, b))
852
- # 232–255: grayscale ramp, 24 steps from 8 to 238
853
- for i in range(24):
854
- level = 8 + i * 10
855
- palette.append((level, level, level))
856
- return palette
976
+ def rgb_to_ansi_color_string(r, g, b):
977
+ """
978
+ Return an ANSI escape sequence setting the foreground to (r,g,b)
979
+ approximated to the terminal's capability, or '' if none.
980
+ """
981
+ cap = get_terminal_color_capability()
982
+ if cap == 'None':
983
+ return ''
984
+ if cap == '24bit':
985
+ return f'\x1b[38;2;{r};{g};{b}m'
986
+ if cap == '256':
987
+ idx = _rgb_to_256_color(r, g, b)
988
+ return f'\x1b[38;5;{idx}m'
989
+ if cap == '16':
990
+ idx = _rgb_to_16_color(r, g, b)
991
+ # 0–7 = 30–37, 8–15 = 90–97
992
+ if idx < 8:
993
+ return f'\x1b[{30 + idx}m'
994
+ else:
995
+ return f'\x1b[{90 + (idx - 8)}m'
996
+ if cap == '8':
997
+ idx = _rgb_to_8_color(r, g, b)
998
+ return f'\x1b[{30 + idx}m'
999
+ return ''
857
1000
 
858
- @cache_decorator
859
- def rgb_to_xterm_index(r, g, b):
1001
+ def _rgb_to_256_color(r, g, b):
860
1002
  """
861
- Map 24-bit RGB to nearest xterm-256 color index.
862
- r, g, b should be in 0-255.
863
- Returns an int in 0-255.
1003
+ Map (r,g,b) to the 256-color cube or grayscale ramp.
864
1004
  """
865
- best_index = 0
1005
+ # if it’s already gray, use the 232–255 grayscale ramp
1006
+ if r == g == b:
1007
+ # 24 shades from 232 to 255
1008
+ return 232 + int(round(r / 255 * 23))
1009
+ # else map each channel to 0–5
1010
+ def to6(v):
1011
+ return int(round(v / 255 * 5))
1012
+ r6, g6, b6 = to6(r), to6(g), to6(b)
1013
+ return 16 + 36 * r6 + 6 * g6 + b6
1014
+
1015
+ def _rgb_to_16_color(r, g, b):
1016
+ """
1017
+ Pick the nearest of the 16 ANSI standard colors.
1018
+ Returns an index 0-15.
1019
+ """
1020
+ palette = [
1021
+ (0, 0, 0), # 0 black
1022
+ (128, 0, 0), # 1 red
1023
+ (0, 128, 0), # 2 green
1024
+ (128, 128, 0), # 3 yellow
1025
+ (0, 0, 128), # 4 blue
1026
+ (128, 0, 128), # 5 magenta
1027
+ (0, 128, 128), # 6 cyan
1028
+ (192, 192, 192), # 7 white (light gray)
1029
+ (128, 128, 128), # 8 bright black (dark gray)
1030
+ (255, 0, 0), # 9 bright red
1031
+ (0, 255, 0), # 10 bright green
1032
+ (255, 255, 0), # 11 bright yellow
1033
+ (0, 0, 255), # 12 bright blue
1034
+ (255, 0, 255), # 13 bright magenta
1035
+ (0, 255, 255), # 14 bright cyan
1036
+ (255, 255, 255), # 15 bright white
1037
+ ]
1038
+ best_idx = 0
866
1039
  best_dist = float('inf')
867
- for i, (pr, pg, pb) in enumerate(get_xterm256_palette()):
868
- dr = pr - r
869
- dg = pg - g
870
- db = pb - b
871
- dist = dr*dr + dg*dg + db*db
1040
+ for i, (pr, pg, pb) in enumerate(palette):
1041
+ dist = (r - pr)**2 + (g - pg)**2 + (b - pb)**2
872
1042
  if dist < best_dist:
873
1043
  best_dist = dist
874
- best_index = i
875
- return best_index
1044
+ best_idx = i
1045
+ return best_idx
876
1046
 
877
- @cache_decorator
878
- def hashable_to_color(n, brightness_threshold=500):
879
- hash_value = hash(str(n))
1047
+ def _rgb_to_8_color(r, g, b):
1048
+ """
1049
+ Reduce to 8 colors by mapping to the 16-color index then clamping 0-7.
1050
+ """
1051
+ return _rgb_to_16_color(r//2, g//2, b//2)
1052
+
1053
+
1054
+ def int_to_color(hash_value, min_brightness=100,max_brightness=220):
880
1055
  r = (hash_value >> 16) & 0xFF
881
1056
  g = (hash_value >> 8) & 0xFF
882
1057
  b = hash_value & 0xFF
883
- if (r + g + b) < brightness_threshold:
884
- return hashable_to_color(hash_value, brightness_threshold)
1058
+ brightness = math.sqrt(0.299 * r**2 + 0.587 * g**2 + 0.114 * b**2)
1059
+ if brightness < min_brightness:
1060
+ return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
1061
+ if brightness > max_brightness:
1062
+ return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
885
1063
  return (r, g, b)
886
1064
 
887
- __previous_ansi_color_index = -1
1065
+ __previous_color_rgb = ()
888
1066
  @cache_decorator
889
- def string_to_unique_ansi_color(string):
1067
+ def int_to_unique_ansi_color(number):
890
1068
  '''
891
- Convert a string to a unique ANSI color code
1069
+ Convert a number to a unique ANSI color code
892
1070
 
893
1071
  Args:
894
- string (str): The string to convert
895
-
1072
+ number (int): The number to convert
896
1073
  Returns:
897
1074
  int: The ANSI color code
898
1075
  '''
899
- global __previous_ansi_color_index
1076
+ global __previous_color_rgb
900
1077
  # Use a hash function to generate a consistent integer from the string
901
1078
  color_capability = get_terminal_color_capability()
902
- index = None
903
1079
  if color_capability == 'None':
904
1080
  return ''
905
- elif color_capability == '16':
906
- # Map to one of the 14 colors (31-37, 90-96), avoiding black and white
907
- index = (hash(string) % 14) + 31
908
- if index > 37:
909
- index += 52 # Bright colors (90-97)
910
- elif color_capability == '8':
911
- index = (hash(string) % 6) + 31
912
- r,g,b = hashable_to_color(string)
913
- if color_capability == '256':
914
- index = rgb_to_xterm_index(r,g,b)
915
- if index:
916
- if index == __previous_ansi_color_index:
917
- return string_to_unique_ansi_color(hash(string))
918
- __previous_ansi_color_index = index
919
- if color_capability == '256':
920
- return f'\033[38;5;{index}m'
921
- else:
922
- return f'\033[{index}m'
1081
+ if color_capability == '24bit':
1082
+ r, g, b = int_to_color(number)
923
1083
  else:
924
- return f'\033[38;2;{r};{g};{b}m'
1084
+ # for 256 colors and below, reduce brightness threshold as we do not have many color to work with
1085
+ r, g, b = int_to_color(number, min_brightness=70, max_brightness=190)
1086
+ if sum(abs(a - b) for a, b in zip((r, g, b), __previous_color_rgb)) <= 256:
1087
+ r, g, b = int_to_color(hash(str(number)))
1088
+ __previous_color_rgb = (r, g, b)
1089
+ return rgb_to_ansi_color_string(r, g, b)
925
1090
 
926
1091
  #%% ------------ Compacting Hostnames ----------------
927
1092
  def __tokenize_hostname(hostname):
@@ -1267,13 +1432,18 @@ def compact_hostnames(Hostnames,verify = True):
1267
1432
  ['sub-s[1-2]']
1268
1433
  """
1269
1434
  global __global_suppress_printout
1270
- if not isinstance(Hostnames, frozenset):
1271
- hostSet = frozenset(Hostnames)
1272
- else:
1273
- hostSet = Hostnames
1435
+ # if not isinstance(Hostnames, frozenset):
1436
+ # hostSet = frozenset(Hostnames)
1437
+ # else:
1438
+ # hostSet = Hostnames
1439
+ hostSet = frozenset(expand_hostnames(
1440
+ hostname.strip()
1441
+ for hostnames_str in Hostnames
1442
+ for hostname in hostnames_str.split(',')
1443
+ ))
1274
1444
  compact_hosts = __compact_hostnames(hostSet)
1275
1445
  if verify:
1276
- if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
1446
+ if frozenset(expand_hostnames(compact_hosts)) != hostSet:
1277
1447
  if not __global_suppress_printout:
1278
1448
  eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
1279
1449
  compact_hosts = hostSet
@@ -1502,51 +1672,53 @@ def __handle_reading_stream(stream,target, host,buffer:io.BytesIO):
1502
1672
  buffer.truncate(0)
1503
1673
  host.output_buffer.seek(0)
1504
1674
  host.output_buffer.truncate(0)
1505
-
1506
- for char in iter(lambda:stream.read(1), b''):
1507
- host.lastUpdateTime = time.monotonic()
1508
- if char == b'\n':
1509
- add_line(buffer,target, host)
1510
- continue
1511
- elif char == b'\r':
1512
- buffer.seek(0)
1513
- host.output_buffer.seek(0)
1514
- elif char == b'\x08':
1515
- # backspace
1516
- if buffer.tell() > 0:
1517
- buffer.seek(buffer.tell() - 1)
1518
- buffer.truncate()
1519
- if host.output_buffer.tell() > 0:
1520
- host.output_buffer.seek(host.output_buffer.tell() - 1)
1521
- host.output_buffer.truncate()
1522
- else:
1523
- # normal character
1524
- buffer.write(char)
1525
- host.output_buffer.write(char)
1526
- # if the length of the buffer is greater than 100, we try to decode the buffer to find if there are any unicode line change chars
1527
- if buffer.tell() % 100 == 0 and buffer.tell() > 0:
1528
- try:
1529
- # try to decode the buffer to find if there are any unicode line change chars
1530
- decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
1531
- lines = decodedLine.splitlines()
1532
- if len(lines) > 1:
1533
- # if there are multiple lines, we add them to the target
1534
- for line in lines[:-1]:
1535
- # for all lines except the last one, we add them to the target
1536
- target.append(line)
1537
- host.output.append(line)
1538
- host.lineNumToPrintSet.add(len(host.output)-1)
1539
- # we keep the last line in the buffer
1540
- buffer.seek(0)
1541
- buffer.truncate(0)
1542
- buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1543
- host.output_buffer.seek(0)
1544
- host.output_buffer.truncate(0)
1545
- host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1546
-
1547
- except UnicodeDecodeError:
1548
- # if there is a unicode decode error, we just skip this character
1675
+ try:
1676
+ for char in iter(lambda:stream.read(1), b''):
1677
+ host.lastUpdateTime = time.monotonic()
1678
+ if char == b'\n':
1679
+ add_line(buffer,target, host)
1549
1680
  continue
1681
+ elif char == b'\r':
1682
+ buffer.seek(0)
1683
+ host.output_buffer.seek(0)
1684
+ elif char == b'\x08':
1685
+ # backspace
1686
+ if buffer.tell() > 0:
1687
+ buffer.seek(buffer.tell() - 1)
1688
+ buffer.truncate()
1689
+ if host.output_buffer.tell() > 0:
1690
+ host.output_buffer.seek(host.output_buffer.tell() - 1)
1691
+ host.output_buffer.truncate()
1692
+ else:
1693
+ # normal character
1694
+ buffer.write(char)
1695
+ host.output_buffer.write(char)
1696
+ # if the length of the buffer is greater than 100, we try to decode the buffer to find if there are any unicode line change chars
1697
+ if buffer.tell() % 100 == 0 and buffer.tell() > 0:
1698
+ try:
1699
+ # try to decode the buffer to find if there are any unicode line change chars
1700
+ decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
1701
+ lines = decodedLine.splitlines()
1702
+ if len(lines) > 1:
1703
+ # if there are multiple lines, we add them to the target
1704
+ for line in lines[:-1]:
1705
+ # for all lines except the last one, we add them to the target
1706
+ target.append(line)
1707
+ host.output.append(line)
1708
+ host.lineNumToPrintSet.add(len(host.output)-1)
1709
+ # we keep the last line in the buffer
1710
+ buffer.seek(0)
1711
+ buffer.truncate(0)
1712
+ buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1713
+ host.output_buffer.seek(0)
1714
+ host.output_buffer.truncate(0)
1715
+ host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1716
+
1717
+ except UnicodeDecodeError:
1718
+ # if there is a unicode decode error, we just skip this character
1719
+ continue
1720
+ except ValueError:
1721
+ pass
1550
1722
  if buffer.tell() > 0:
1551
1723
  # if there is still some data in the buffer, we add it to the target
1552
1724
  add_line(buffer,target, host)
@@ -1590,7 +1762,7 @@ def __handle_writing_stream(stream,stop_event,host):
1590
1762
  # host.output.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
1591
1763
  # host.stdout.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
1592
1764
  return sentInputPos
1593
-
1765
+
1594
1766
  def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1595
1767
  '''
1596
1768
  Run the command on the host. Will format the commands accordingly. Main execution function.
@@ -1641,6 +1813,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1641
1813
  host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
1642
1814
  host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
1643
1815
  host.command = replace_magic_strings(host.command,['#UUID#'],str(host.uuid),case_sensitive=False)
1816
+ host.command = replace_magic_strings(host.command,['#IP#'],str(host.ip),case_sensitive=False)
1644
1817
  formatedCMD = []
1645
1818
  if host.extraargs and isinstance(host.extraargs, str):
1646
1819
  extraargs = host.extraargs.split()
@@ -1839,8 +2012,6 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1839
2012
  stdout_thread.join(timeout=1)
1840
2013
  stderr_thread.join(timeout=1)
1841
2014
  stdin_thread.join(timeout=1)
1842
- # here we handle the rest of the stdout after the subprocess returns
1843
- host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
1844
2015
  if not _emo:
1845
2016
  stdout = None
1846
2017
  stderr = None
@@ -1849,8 +2020,10 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1849
2020
  except subprocess.TimeoutExpired:
1850
2021
  pass
1851
2022
  if stdout:
2023
+ host.output.append('Trying to read the rest of the stdout...')
1852
2024
  __handle_reading_stream(io.BytesIO(stdout),host.stdout, host,host.stdout_buffer)
1853
2025
  if stderr:
2026
+ host.output.append('Trying to read the rest of the stderr...')
1854
2027
  __handle_reading_stream(io.BytesIO(stderr),host.stderr, host,host.stderr_buffer)
1855
2028
  # if the last line in host.stderr is Connection to * closed., we will remove it
1856
2029
  host.returncode = proc.poll()
@@ -2260,7 +2433,7 @@ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None, indexO
2260
2433
  rearrangedHosts.add(host)
2261
2434
  return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}, rearrangedHosts
2262
2435
 
2263
- 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'):
2436
+ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_WINDOW_WIDTH, min_line_len = DEFAULT_WINDOW_HEIGHT,single_window=DEFAULT_SINGLE_WINDOW,help_shown = False, config_reason = 'New Configuration'):
2264
2437
  global _encoding
2265
2438
  _ = config_reason
2266
2439
  try:
@@ -2446,7 +2619,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2446
2619
  elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
2447
2620
  # if the key is exit, we will exit the program
2448
2621
  return
2449
- elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?
2622
+ elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?, 8 is the key code for backspace
2450
2623
  # if the key is help, we will display the help message
2451
2624
  if not help_shown:
2452
2625
  help_panel.show()
@@ -2581,7 +2754,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2581
2754
  return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, f'Error: {str(e)}',traceback.format_exc())
2582
2755
  return None
2583
2756
 
2584
- 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):
2757
+ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_WINDOW_WIDTH, min_line_len = DEFAULT_WINDOW_HEIGHT,single_window = DEFAULT_SINGLE_WINDOW):
2585
2758
  '''
2586
2759
  Print the output of the hosts on the screen
2587
2760
 
@@ -2655,160 +2828,272 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2655
2828
 
2656
2829
  #%% ------------ Generate Output Block ----------------
2657
2830
  def can_merge(line_bag1, line_bag2, threshold):
2658
- bag1_iter = iter(line_bag1)
2659
- found = False
2660
- for _ in range(max(int(len(line_bag1) * (1-threshold)),1)):
2661
- try:
2662
- item = next(bag1_iter)
2663
- except StopIteration:
2664
- break
2665
- if item in line_bag2:
2666
- found = True
2667
- break
2668
- if not found:
2669
- return False
2670
- return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
2831
+ if threshold > 0.5:
2832
+ samples = itertools.islice(line_bag1, max(int(len(line_bag1) * (1 - threshold)),1))
2833
+ if not line_bag2.intersection(samples):
2834
+ return False
2835
+ return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
2671
2836
 
2672
2837
  def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2673
- indexes = {hostname: 0 for hostname in merging_hostnames}
2674
- working_indexes = indexes.copy()
2838
+ #indexes = {hostname: 0 for hostname in merging_hostnames}
2839
+ indexes = Counter({hostname: 0 for hostname in merging_hostnames})
2840
+ working_index_keys = set(merging_hostnames)
2675
2841
  previousBuddies = set()
2676
- hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2842
+ hostnameWrapper = textwrap.TextWrapper(width=line_length -1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
2677
2843
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2844
+ diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
2845
+ def get_multiset_index_for_hostname(hostname):
2846
+ index = indexes[hostname]
2847
+ tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2848
+ tracking_iter = itertools.islice(outputs_by_hostname[hostname], tracking_index)
2849
+ return [deque(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_iter]
2850
+ # futuresChainMap = ChainMap()
2851
+ # class futureDict(UserDict):
2852
+ # def __missing__(self, key):
2853
+ # value = get_multiset_index_for_hostname(key)
2854
+ # self[key] = value
2855
+ # # futuresChainMap.maps.append(value[0]._counter)
2856
+ # return value
2857
+ # # def initializeHostnames(self, hostnames):
2858
+ # # entries = {hostname: get_multiset_index_for_hostname(hostname) for hostname in hostnames}
2859
+ # # self.update(entries)
2860
+ # # futuresChainMap.maps.extend(entry[0]._counter for entry in entries.values())
2861
+ def advance(dict,key):
2862
+ try:
2863
+ value = dict[key]
2864
+ value[0].append(next(value[1]))
2865
+ except StopIteration:
2866
+ try:
2867
+ value[0].popleft()
2868
+ except IndexError:
2869
+ pass
2870
+ except KeyError:
2871
+ pass
2872
+ # futures = futureDict()
2873
+ # for hostname in merging_hostnames:
2874
+ # futures[hostname] # ensure it's initialized
2875
+ futures = {hostname: get_multiset_index_for_hostname(hostname) for hostname in merging_hostnames}
2876
+ currentLines = defaultdict(set)
2877
+ color_cap = get_terminal_color_capability()
2878
+ if color_cap == 'None':
2879
+ green_str = ''
2880
+ reset_str = ''
2881
+ else:
2882
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('green', __DEFAULT_COLOR_PALETTE['green']))
2883
+ reset_str = '\033[0m'
2884
+ for hostname in merging_hostnames:
2885
+ currentLines[outputs_by_hostname[hostname][0]].add(hostname)
2678
2886
  while indexes:
2679
- futures = {}
2680
2887
  defer = False
2681
- sorted_working_indexes = sorted(working_indexes.items(), key=lambda x: x[1])
2682
- golden_hostname, golden_index = sorted_working_indexes[0]
2683
- buddy = {golden_hostname}
2888
+ # sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
2889
+ golden_hostname = min(working_index_keys, key=indexes.get)
2890
+ golden_index = indexes[golden_hostname]
2684
2891
  lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2685
- for hostname, index in sorted_working_indexes[1:]:
2686
- if lineToAdd == outputs_by_hostname[hostname][index]:
2687
- buddy.add(hostname)
2688
- else:
2689
- if hostname not in futures:
2690
- diff_display_item_count = max(int(len(outputs_by_hostname[hostname]) * (1 - diff_display_threshold)),1)
2691
- tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2692
- futures[hostname] = (OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index)
2693
- if lineToAdd in futures[hostname]:
2694
- for hn in buddy:
2695
- del working_indexes[hn]
2696
- defer = True
2697
- break
2892
+ # for hostname, index in sorted_working_indexes[1:]:
2893
+ # if lineToAdd == outputs_by_hostname[hostname][index]:
2894
+ # buddy.add(hostname)
2895
+ # else:
2896
+ # futureLines,tracking_index = futures[hostname]
2897
+ # if lineToAdd in futureLines:
2898
+ # for hn in buddy:
2899
+ # working_indexes.pop(hn,None)
2900
+ # defer = True
2901
+ # break
2902
+ buddy = currentLines[lineToAdd].copy()
2903
+ if len(buddy) < len(working_index_keys):
2904
+ # we need to check the futures then
2905
+ # thisCounter = None
2906
+ # if golden_hostname in futures:
2907
+ # thisCounter = futures[golden_hostname][0]._counter
2908
+ # futuresChainMap.maps.remove(thisCounter)
2909
+ # for hostname in working_index_keys - buddy - set(futures.keys()):
2910
+ # futures[hostname] # ensure it's initialized
2911
+ # futures.initializeHostnames(working_index_keys - buddy - futures.keys())
2912
+ if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
2913
+ defer = True
2914
+ working_index_keys -= buddy
2915
+ # if thisCounter is not None:
2916
+ # futuresChainMap.maps.append(thisCounter)
2698
2917
  if not defer:
2699
2918
  if buddy != previousBuddies:
2700
2919
  hostnameStr = ','.join(compact_hostnames(buddy))
2701
2920
  hostnameLines = hostnameWrapper.wrap(hostnameStr)
2702
- hostnameLines = [line.ljust(line_length - 1) + '│' for line in hostnameLines]
2703
- color = string_to_unique_ansi_color(hostnameStr) if len(buddy) < len(merging_hostnames) else ''
2704
- hostnameLines[0] = f"\033[0m{color}{hostnameLines[0]}"
2921
+ # hostnameLines = [line.ljust(line_length) for line in hostnameLines]
2922
+ if color_cap == 'None':
2923
+ hostnameLines[0] = f"{hostnameLines[0]}"
2924
+ elif len(buddy) < len(merging_hostnames):
2925
+ color = int_to_unique_ansi_color(hash(hostnameStr))
2926
+ hostnameLines[0] = f"{color}■{hostnameLines[0]}"
2927
+ hostnameLines[-1] += reset_str
2928
+ else:
2929
+ hostnameLines[0] = f"{green_str}■{reset_str}{hostnameLines[0]}"
2705
2930
  output.extend(hostnameLines)
2706
2931
  previousBuddies = buddy
2707
- output.append(lineToAdd.ljust(line_length - 1) + '│')
2932
+ output.append(lineToAdd)
2933
+ currentLines[lineToAdd].difference_update(buddy)
2934
+ if not currentLines[lineToAdd]:
2935
+ del currentLines[lineToAdd]
2936
+ indexes.update(buddy)
2708
2937
  for hostname in buddy:
2709
- indexes[hostname] += 1
2710
- if indexes[hostname] >= len(outputs_by_hostname[hostname]):
2938
+ # currentLines[lineToAdd].remove(hostname)
2939
+ # if not currentLines[lineToAdd]:
2940
+ # del currentLines[lineToAdd]
2941
+ # indexes[hostname] += 1
2942
+ try:
2943
+ currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
2944
+ except IndexError:
2711
2945
  indexes.pop(hostname, None)
2712
2946
  futures.pop(hostname, None)
2947
+ # if future:
2948
+ # futuresChainMap.maps.remove(future[0]._counter)
2713
2949
  continue
2714
2950
  #advance futures
2715
- if hostname in futures:
2716
- tracking_multiset, tracking_index = futures[hostname]
2717
- tracking_index += 1
2718
- if tracking_index < len(outputs_by_hostname[hostname]):
2719
- line = outputs_by_hostname[hostname][tracking_index]
2720
- tracking_multiset.append(line)
2721
- else:
2722
- tracking_multiset.pop_left()
2723
- futures[hostname] = (tracking_multiset, tracking_index)
2724
- working_indexes = indexes.copy()
2951
+ advance(futures, hostname)
2952
+ working_index_keys = set(indexes.keys())
2725
2953
 
2726
2954
  def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2727
2955
  output = []
2728
- output.append(('┌'+'─'*(line_length-2) + '┐'))
2956
+ color_cap = get_terminal_color_capability()
2957
+ if color_cap == 'None':
2958
+ color_line = ''
2959
+ color_reset = ''
2960
+ green_str = ''
2961
+ else:
2962
+ color_line = rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
2963
+ color_reset = '\033[0m'
2964
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('green', __DEFAULT_COLOR_PALETTE['green']))
2965
+ output.append(color_line+'─'*(line_length)+color_reset)
2966
+ hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
2967
+ hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2729
2968
  for merging_hostnames in merge_groups:
2730
2969
  mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2731
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2970
+ output.append(color_line+'─'*(line_length)+color_reset)
2732
2971
  for hostname in remaining_hostnames:
2733
- hostnameLines = textwrap.wrap(hostname, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2734
- initial_indent='├─ ', subsequent_indent='│- ')
2735
- output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
2736
- output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
2737
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2972
+ hostnameLines = hostnameWrapper.wrap(','.join(compact_hostnames([hostname])))
2973
+ hostnameLines[0] = f"{green_str}■{color_reset}{hostnameLines[0]}"
2974
+ output.extend(hostnameLines)
2975
+ output.extend(outputs_by_hostname[hostname])
2976
+ output.append(color_line+'─'*(line_length)+color_reset)
2738
2977
  if output:
2739
2978
  output.pop()
2740
2979
  # if output and output[0] and output[0].startswith('├'):
2741
2980
  # output[0] = '┌' + output[0][1:]
2742
2981
  return output
2743
2982
 
2983
+ def pre_merge_hosts(hosts):
2984
+ '''Merge hosts with identical outputs.'''
2985
+ output_groups = defaultdict(list)
2986
+ # Group hosts by their output identity
2987
+ for host in hosts:
2988
+ identity = host.get_output_hash()
2989
+ output_groups[identity].append(host)
2990
+ # Create merged hosts
2991
+ merged_hosts = []
2992
+ for group in output_groups.values():
2993
+ group[0].name = ','.join(compact_hostnames(host.name for host in group))
2994
+ merged_hosts.append(group[0])
2995
+ return merged_hosts
2996
+
2744
2997
  def get_host_raw_output(hosts, terminal_width):
2745
2998
  outputs_by_hostname = {}
2746
2999
  line_bag_by_hostname = {}
2747
3000
  hostnames_by_line_bag_len = {}
2748
- text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2749
- initial_indent=' ', subsequent_indent='│-')
3001
+ text_wrapper = textwrap.TextWrapper(width=terminal_width - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
3002
+ initial_indent=' ', subsequent_indent='-')
2750
3003
  max_length = 20
3004
+ color_cap = get_terminal_color_capability()
3005
+ if color_cap == 'None':
3006
+ color_reset_str = ''
3007
+ blue_str = ''
3008
+ cyan_str = ''
3009
+ green_str = ''
3010
+ red_str = ''
3011
+ else:
3012
+ color_reset_str = '\033[0m'
3013
+ blue_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('blue', __DEFAULT_COLOR_PALETTE['blue']))
3014
+ cyan_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_cyan', __DEFAULT_COLOR_PALETTE['bright_cyan']))
3015
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_green', __DEFAULT_COLOR_PALETTE['bright_green']))
3016
+ red_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_red', __DEFAULT_COLOR_PALETTE['bright_red']))
3017
+ hosts = pre_merge_hosts(hosts)
2751
3018
  for host in hosts:
2752
- hostPrintOut = ["│█ EXECUTED COMMAND:"]
2753
- for line in host['command'].splitlines():
3019
+ max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
3020
+ hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
3021
+ for line in host.command.splitlines():
2754
3022
  hostPrintOut.extend(text_wrapper.wrap(line))
2755
- lineBag = {(0,host['command'])}
2756
- prevLine = host['command']
2757
- if host['stdout']:
2758
- hostPrintOut.append('│▓ STDOUT:')
2759
- for line in host['stdout']:
2760
- hostPrintOut.extend(text_wrapper.wrap(line))
3023
+ # hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
3024
+ lineBag = {(0,host.command)}
3025
+ prevLine = host.command
3026
+ if host.stdout:
3027
+ hostPrintOut.append(f'{blue_str}▓{color_reset_str} STDOUT:')
3028
+ # for line in host.stdout:
3029
+ # if len(line) < terminal_width - 2:
3030
+ # hostPrintOut.append(f" {line}")
3031
+ # else:
3032
+ # hostPrintOut.extend(text_wrapper.wrap(line))
3033
+ hostPrintOut.extend(f" {line}" for line in host.stdout)
3034
+ max_length = max(max_length, max(map(len, host.stdout)))
3035
+ # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2761
3036
  lineBag.add((prevLine,1))
2762
- lineBag.add((1,host['stdout'][0]))
2763
- if len(host['stdout']) > 1:
2764
- lineBag.update(zip(host['stdout'], host['stdout'][1:]))
2765
- lineBag.update(host['stdout'])
2766
- prevLine = host['stdout'][-1]
2767
- if host['stderr']:
2768
- if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2769
- host['stderr'][0] = 'SSH not reachable!'
2770
- elif host['stderr'][-1].strip().endswith('Connection timed out'):
2771
- host['stderr'][-1] = 'SSH connection timed out!'
2772
- elif host['stderr'][-1].strip().endswith('No route to host'):
2773
- host['stderr'][-1] = 'Cannot find host!'
2774
- if host['stderr']:
2775
- hostPrintOut.append('│▒ STDERR:')
2776
- for line in host['stderr']:
2777
- hostPrintOut.extend(text_wrapper.wrap(line))
3037
+ lineBag.add((1,host.stdout[0]))
3038
+ if len(host.stdout) > 1:
3039
+ lineBag.update(zip(host.stdout, host.stdout[1:]))
3040
+ lineBag.update(host.stdout)
3041
+ prevLine = host.stdout[-1]
3042
+ if host.stderr:
3043
+ if host.stderr[0].strip().startswith('ssh: connect to host ') and host.stderr[0].strip().endswith('Connection refused'):
3044
+ host.stderr[0] = 'SSH not reachable!'
3045
+ elif host.stderr[-1].strip().endswith('Connection timed out'):
3046
+ host.stderr[-1] = 'SSH connection timed out!'
3047
+ elif host.stderr[-1].strip().endswith('No route to host'):
3048
+ host.stderr[-1] = 'Cannot find host!'
3049
+ if host.stderr:
3050
+ hostPrintOut.append(f'{red_str}▒{color_reset_str} STDERR:')
3051
+ # for line in host.stderr:
3052
+ # if len(line) < terminal_width - 2:
3053
+ # hostPrintOut.append(f" {line}")
3054
+ # else:
3055
+ # hostPrintOut.extend(text_wrapper.wrap(line))
3056
+ hostPrintOut.extend(f" {line}" for line in host.stderr)
3057
+ max_length = max(max_length, max(map(len, host.stderr)))
2778
3058
  lineBag.add((prevLine,2))
2779
- lineBag.add((2,host['stderr'][0]))
2780
- lineBag.update(host['stderr'])
2781
- if len(host['stderr']) > 1:
2782
- lineBag.update(zip(host['stderr'], host['stderr'][1:]))
2783
- prevLine = host['stderr'][-1]
2784
- hostPrintOut.append(f"│░ RETURN CODE: {host['returncode']}")
2785
- lineBag.add((prevLine,f"{host['returncode']}"))
2786
- max_length = max(max_length, max(map(len, hostPrintOut)))
2787
- outputs_by_hostname[host['name']] = hostPrintOut
2788
- line_bag_by_hostname[host['name']] = lineBag
2789
- hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
3059
+ lineBag.add((2,host.stderr[0]))
3060
+ lineBag.update(host.stderr)
3061
+ if len(host.stderr) > 1:
3062
+ lineBag.update(zip(host.stderr, host.stderr[1:]))
3063
+ prevLine = host.stderr[-1]
3064
+ if host.returncode != 0:
3065
+ codeColor = red_str
3066
+ else:
3067
+ codeColor = green_str
3068
+ hostPrintOut.append(f"{codeColor}░{color_reset_str} RETURN CODE: {host.returncode}")
3069
+ lineBag.add((prevLine,f"{host.returncode}"))
3070
+ outputs_by_hostname[host.name] = hostPrintOut
3071
+ line_bag_by_hostname[host.name] = lineBag
3072
+ hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
2790
3073
  return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len), min(max_length+2,terminal_width)
2791
3074
 
2792
3075
  def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
2793
3076
  merge_groups = []
2794
- for line_bag_len in hostnames_by_line_bag_len.copy():
3077
+ remaining_hostnames = set()
3078
+ for lbl_i, line_bag_len in enumerate(sorted_hostnames_by_line_bag_len_keys):
2795
3079
  for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
2796
- if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
3080
+ # if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
3081
+ # continue
3082
+ try:
3083
+ this_line_bag = line_bag_by_hostname.pop(this_hostname)
3084
+ hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
3085
+ except KeyError:
2797
3086
  continue
2798
- this_line_bag = line_bag_by_hostname[this_hostname]
2799
3087
  target_threshold = line_bag_len * (2 - diff_display_threshold)
2800
3088
  merge_group = []
2801
- for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
3089
+ for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys[lbl_i:]:
2802
3090
  if other_line_bag_len > target_threshold:
2803
3091
  break
2804
- if other_line_bag_len < line_bag_len:
2805
- continue
3092
+ # if other_line_bag_len < line_bag_len:
3093
+ # continue
2806
3094
  for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
2807
- if this_hostname == other_hostname:
2808
- continue
2809
3095
  if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
2810
3096
  merge_group.append(other_hostname)
2811
- hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
2812
3097
  hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
2813
3098
  if not hostnames_by_line_bag_len[other_line_bag_len]:
2814
3099
  del hostnames_by_line_bag_len[other_line_bag_len]
@@ -2816,23 +3101,28 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
2816
3101
  if merge_group:
2817
3102
  merge_group.append(this_hostname)
2818
3103
  merge_groups.append(merge_group)
2819
- return merge_groups
3104
+ # del line_bag_by_hostname[this_hostname]
3105
+ else:
3106
+ remaining_hostnames.add(this_hostname)
3107
+ return merge_groups, remaining_hostnames
2820
3108
 
2821
3109
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
3110
+ color_cap = get_terminal_color_capability()
2822
3111
  if quiet:
2823
3112
  # remove hosts with returncode 0
2824
- hosts = [dict(host) for host in hosts if host.returncode != 0]
3113
+ hosts = [host for host in hosts if host.returncode != 0]
2825
3114
  if not hosts:
2826
3115
  if usejson:
2827
3116
  return '{"Success": true}'
2828
3117
  else:
2829
- return 'Success'
2830
- else:
2831
- hosts = [dict(host) for host in hosts]
3118
+ if color_cap == 'None':
3119
+ return 'Success'
3120
+ else:
3121
+ return '\033[32mSuccess\033[0m'
2832
3122
  if usejson:
2833
3123
  # [print(dict(host)) for host in hosts]
2834
3124
  #print(json.dumps([dict(host) for host in hosts],indent=4))
2835
- rtnStr = json.dumps(hosts,indent=4)
3125
+ rtnStr = json.dumps([dict(host) for host in hosts],indent=4)
2836
3126
  elif greppable:
2837
3127
  # transform hosts to a 2d list
2838
3128
  rtnStr = '*'*80+'\n'
@@ -2840,14 +3130,14 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2840
3130
  for host in hosts:
2841
3131
  #header = f"{host['name']} | rc: {host['returncode']} | "
2842
3132
  hostAdded = False
2843
- for line in host['stdout']:
2844
- rtnList.append([host['name'],f"rc: {host['returncode']}",'stdout',line])
3133
+ for line in host.stdout:
3134
+ rtnList.append([host.name,f"rc: {host.returncode}",'stdout',line])
2845
3135
  hostAdded = True
2846
- for line in host['stderr']:
2847
- rtnList.append([host['name'],f"rc: {host['returncode']}",'stderr',line])
3136
+ for line in host.stderr:
3137
+ rtnList.append([host.name,f"rc: {host.returncode}",'stderr',line])
2848
3138
  hostAdded = True
2849
3139
  if not hostAdded:
2850
- rtnList.append([host['name'],f"rc: {host['returncode']}",'N/A','<EMPTY>'])
3140
+ rtnList.append([host.name,f"rc: {host.returncode}",'N/A','<EMPTY>'])
2851
3141
  rtnList.append(['','','',''])
2852
3142
  rtnStr += pretty_format_table(rtnList)
2853
3143
  rtnStr += '*'*80+'\n'
@@ -2863,27 +3153,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2863
3153
  except Exception:
2864
3154
  eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2865
3155
  diff_display_threshold = 0.9
3156
+
3157
+ color_reset_str = '' if color_cap == 'None' else '\033[0m'
3158
+ white_str = '' if color_cap == 'None' else rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
2866
3159
  terminal_length = get_terminal_size()[0]
2867
3160
  outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_length = get_host_raw_output(hosts,terminal_length)
2868
- merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
2869
- # get the remaining hostnames in the hostnames_by_line_bag_len
2870
- remaining_hostnames = set()
2871
- for hostnames in hostnames_by_line_bag_len.values():
2872
- remaining_hostnames.update(hostnames)
3161
+ merge_groups ,remaining_hostnames = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
2873
3162
  outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
2874
3163
  if keyPressesIn[-1]:
2875
3164
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2876
- outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
3165
+ outputs.append(color_reset_str + " User Inputs:".ljust(line_length,'─'))
2877
3166
  cmdOut = []
2878
3167
  for line in CMDsOut:
2879
3168
  cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2880
- initial_indent=' ', subsequent_indent='│-'))
2881
- outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
3169
+ initial_indent=' ', subsequent_indent='-'))
3170
+ outputs.extend(cmdOut)
2882
3171
  keyPressesIn[-1].clear()
2883
- if quiet and not outputs:
2884
- rtnStr = 'Success'
3172
+ if not outputs:
3173
+ if quiet:
3174
+ if color_cap == 'None':
3175
+ return 'Success'
3176
+ else:
3177
+ return '\033[32mSuccess\033[0m'
3178
+ else:
3179
+ rtnStr = ''
2885
3180
  else:
2886
- rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
3181
+ rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
2887
3182
  return rtnStr
2888
3183
 
2889
3184
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -2901,6 +3196,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
2901
3196
  global __global_suppress_printout
2902
3197
  global _encoding
2903
3198
  global __keyPressesIn
3199
+ for host in hosts:
3200
+ host.output.clear()
2904
3201
  rtnStr = generate_output(hosts,usejson,greppable,quiet=__global_suppress_printout,encoding=_encoding,keyPressesIn=__keyPressesIn)
2905
3202
  if not quiet:
2906
3203
  print(rtnStr)
@@ -2908,8 +3205,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
2908
3205
 
2909
3206
  #%% ------------ Run / Process Hosts Block ----------------
2910
3207
  def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinished, no_watch, json, called, greppable,
2911
- unavailableHosts:dict,willUpdateUnreachableHosts,curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN,
2912
- curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window = DEFAULT_SINGLE_WINDOW,
3208
+ unavailableHosts:dict,willUpdateUnreachableHosts,window_width = DEFAULT_WINDOW_WIDTH,
3209
+ window_height = DEFAULT_WINDOW_HEIGHT,single_window = DEFAULT_SINGLE_WINDOW,
2913
3210
  unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY):
2914
3211
  global __globalUnavailableHosts
2915
3212
  global _no_env
@@ -2927,7 +3224,16 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2927
3224
  if total_sleeped > 0.1:
2928
3225
  break
2929
3226
  if any([host.returncode is None for host in hosts]):
2930
- curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3227
+ try:
3228
+ curses.wrapper(curses_print, hosts, threads, min_char_len = window_width, min_line_len = window_height, single_window = single_window)
3229
+ except Exception:
3230
+ try:
3231
+ os.environ['TERM'] = 'xterm-256color'
3232
+ curses.wrapper(curses_print, hosts, threads, min_char_len = window_width, min_line_len = window_height, single_window = single_window)
3233
+ except Exception as e:
3234
+ eprint(f"Curses print error: {e}")
3235
+ import traceback
3236
+ print(traceback.format_exc())
2931
3237
  if not returnUnfinished:
2932
3238
  # wait until all hosts have a return code
2933
3239
  while any([host.returncode is None for host in hosts]):
@@ -3017,7 +3323,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3017
3323
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
3018
3324
  file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3019
3325
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
3020
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILE,
3326
+ history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
3021
3327
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3022
3328
  shortend = False) -> str:
3023
3329
  argsList = []
@@ -3061,8 +3367,10 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3061
3367
  argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
3062
3368
  if no_env:
3063
3369
  argsList.append('--no_env')
3064
- if env_file and env_file != DEFAULT_ENV_FILE:
3370
+ if env_file:
3065
3371
  argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
3372
+ if env_files:
3373
+ argsList.extend([f'--env_files="{ef}"' for ef in env_files] if not shortend else [f'-efs="{ef}"' for ef in env_files])
3066
3374
  if no_history:
3067
3375
  argsList.append('--no_history' if not shortend else '-nh')
3068
3376
  if history_file and history_file != DEFAULT_HISTORY_FILE:
@@ -3082,18 +3390,18 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3082
3390
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
3083
3391
  scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
3084
3392
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
3085
- skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
3393
+ skip_hosts = DEFAULT_SKIP_HOSTS, window_width = DEFAULT_WINDOW_WIDTH, window_height = DEFAULT_WINDOW_HEIGHT,
3086
3394
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3087
3395
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
3088
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILE,
3396
+ history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = [],
3089
3397
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3090
- shortend = False,tabSeperated = False):
3398
+ shortend = False,tabSeperated = False,**kwargs) -> str:
3091
3399
  _ = called
3092
3400
  _ = returnUnfinished
3093
3401
  _ = willUpdateUnreachableHosts
3094
3402
  _ = no_start
3095
- _ = curses_min_char_len
3096
- _ = curses_min_line_len
3403
+ _ = window_width
3404
+ _ = window_height
3097
3405
  _ = single_window
3098
3406
  hosts = hosts if isinstance(hosts,str) else frozenset(hosts)
3099
3407
  hostStr = formHostStr(hosts)
@@ -3105,7 +3413,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3105
3413
  no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
3106
3414
  file_sync = file_sync,error_only = error_only, identity_file = identity_file,
3107
3415
  copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
3108
- history_file = history_file, env_file = env_file,
3416
+ history_file = history_file, env_file = env_file, env_files = env_files,
3109
3417
  repeat = repeat,interval = interval,
3110
3418
  shortend = shortend)
3111
3419
  commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
@@ -3156,56 +3464,56 @@ def record_command_history(kwargs):
3156
3464
  #%% ------------ Main Block ----------------
3157
3465
  def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
3158
3466
  no_watch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
3159
- files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
3467
+ file = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
3160
3468
  scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
3161
3469
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
3162
- skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
3470
+ skip_hosts = DEFAULT_SKIP_HOSTS, window_width = DEFAULT_WINDOW_WIDTH, window_height = DEFAULT_WINDOW_HEIGHT,
3163
3471
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY,quiet = False,identity_file = DEFAULT_IDENTITY_FILE,
3164
3472
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = True,
3165
- history_file = DEFAULT_HISTORY_FILE,
3473
+ history_file = DEFAULT_HISTORY_FILE,**kwargs
3166
3474
  ):
3167
- f'''
3168
- Run the command on the hosts, aka multissh. main function
3169
-
3170
- Args:
3171
- hosts (str/iterable): A string of hosts seperated by space or comma / iterable of hosts. Default to {DEFAULT_HOSTS}.
3172
- commands (list): A list of commands to run on the hosts. When using files, defines the destination of the files. Defaults to None.
3173
- oneonone (bool, optional): Whether to run the commands one on one. Defaults to {DEFAULT_ONE_ON_ONE}.
3174
- timeout (int, optional): The timeout for the command. Defaults to {DEFAULT_TIMEOUT}.
3175
- password (str, optional): The password for the hosts. Defaults to {DEFAULT_PASSWORD}.
3176
- no_watch (bool, optional): Whether to print the output. Defaults to {DEFAULT_NO_WATCH}.
3177
- json (bool, optional): Whether to print the output in JSON format. Defaults to {DEFAULT_JSON_MODE}.
3178
- called (bool, optional): Whether the function is called by another function. Defaults to {_DEFAULT_CALLED}.
3179
- max_connections (int, optional): The maximum number of concurrent SSH sessions. Defaults to 4 * os.cpu_count().
3180
- files (list, optional): A list of files to be copied to the hosts. Defaults to None.
3181
- ipmi (bool, optional): Whether to use IPMI to connect to the hosts. Defaults to {DEFAULT_IPMI}.
3182
- interface_ip_prefix (str, optional): The prefix of the IPMI interface. Defaults to {DEFAULT_INTERFACE_IP_PREFIX}.
3183
- returnUnfinished (bool, optional): Whether to return the unfinished hosts. Defaults to {_DEFAULT_RETURN_UNFINISHED}.
3184
- scp (bool, optional): Whether to use scp instead of rsync. Defaults to {DEFAULT_SCP}.
3185
- gather_mode (bool, optional): Whether to use gather mode. Defaults to False.
3186
- username (str, optional): The username to use to connect to the hosts. Defaults to {DEFAULT_USERNAME}.
3187
- extraargs (str, optional): Extra arguments to pass to the ssh / rsync / scp command. Defaults to {DEFAULT_EXTRA_ARGS}.
3188
- skipUnreachable (bool, optional): Whether to skip unreachable hosts. Defaults to {DEFAULT_SKIP_UNREACHABLE}.
3189
- no_env (bool, optional): Whether to not read the current sat system environment variables. (Will still read from files) Defaults to {DEFAULT_NO_ENV}.
3190
- greppable (bool, optional): Whether to print the output in greppable format. Defaults to {DEFAULT_GREPPABLE_MODE}.
3191
- willUpdateUnreachableHosts (bool, optional): Whether to update the global unavailable hosts. Defaults to {_DEFAULT_UPDATE_UNREACHABLE_HOSTS}.
3192
- no_start (bool, optional): Whether to return the hosts without starting the command. Defaults to {_DEFAULT_NO_START}.
3193
- skip_hosts (str, optional): The hosts to skip. Defaults to {DEFAULT_SKIP_HOSTS}.
3194
- min_char_len (int, optional): The minimum character per line of the curses output. Defaults to {DEFAULT_CURSES_MINIMUM_CHAR_LEN}.
3195
- min_line_len (int, optional): The minimum line number for each window of the curses output. Defaults to {DEFAULT_CURSES_MINIMUM_LINE_LEN}.
3196
- single_window (bool, optional): Whether to use a single window for the curses output. Defaults to {DEFAULT_SINGLE_WINDOW}.
3197
- file_sync (bool, optional): Whether to use file sync mode to sync directories. Defaults to {DEFAULT_FILE_SYNC}.
3198
- error_only (bool, optional): Whether to only print the error output. Defaults to {DEFAULT_ERROR_ONLY}.
3199
- quiet (bool, optional): Whether to suppress all verbose printout, added for compatibility, avoid using. Defaults to False.
3200
- identity_file (str, optional): The identity file to use for the ssh connection. Defaults to {DEFAULT_IDENTITY_FILE}.
3201
- copy_id (bool, optional): Whether to copy the id to the hosts. Defaults to False.
3202
- unavailable_host_expiry (int, optional): The time in seconds to keep the unavailable hosts in the global unavailable hosts. Defaults to {DEFAULT_UNAVAILABLE_HOST_EXPIRY}.
3203
- no_history (bool, optional): Whether to not save the history of the command. Defaults to True.
3204
- history_file (str, optional): The file to save the history of the command. Defaults to {DEFAULT_HISTORY_FILE}.
3475
+ """
3476
+ Run commands on multiple hosts via SSH or IPMI.
3477
+
3478
+ Parameters:
3479
+ hosts (str or iterable): Hosts to run the command on. Can be a string (comma/space-separated) or iterable. Default: DEFAULT_HOSTS.
3480
+ commands (list or None): List of commands to run on the hosts. If files are used, defines the destination. Default: None.
3481
+ oneonone (bool): If True, run each command on the corresponding host (1:1 mapping). Default: DEFAULT_ONE_ON_ONE.
3482
+ timeout (int): Timeout for each command in seconds. Default: DEFAULT_TIMEOUT.
3483
+ password (str): Password for SSH/IPMI authentication. Default: DEFAULT_PASSWORD.
3484
+ no_watch (bool): If True, do not use curses TUI; just print output. Default: DEFAULT_NO_WATCH.
3485
+ json (bool): If True, output results in JSON format. Default: DEFAULT_JSON_MODE.
3486
+ called (bool): If True, function is called programmatically (not CLI). Default: _DEFAULT_CALLED.
3487
+ max_connections (int): Maximum concurrent SSH sessions. Default: 4 * os.cpu_count().
3488
+ file (list or None): Files to copy to hosts. Default: None.
3489
+ ipmi (bool): Use IPMI instead of SSH. Default: DEFAULT_IPMI.
3490
+ interface_ip_prefix (str or None): Override IP prefix for host connection. Default: DEFAULT_INTERFACE_IP_PREFIX.
3491
+ returnUnfinished (bool): If True, return hosts even if not finished. Default: _DEFAULT_RETURN_UNFINISHED.
3492
+ scp (bool): Use scp for file transfer (instead of rsync). Default: DEFAULT_SCP.
3493
+ gather_mode (bool): Gather files from hosts (pull mode). Default: False.
3494
+ username (str or None): Username for SSH/IPMI. Default: DEFAULT_USERNAME.
3495
+ extraargs (str or list or None): Extra args for SSH/SCP/rsync. Default: DEFAULT_EXTRA_ARGS.
3496
+ skipUnreachable (bool): Skip hosts marked as unreachable. Default: DEFAULT_SKIP_UNREACHABLE.
3497
+ no_env (bool): Do not load environment variables from shell. Default: DEFAULT_NO_ENV.
3498
+ greppable (bool): Output in greppable table format. Default: DEFAULT_GREPPABLE_MODE.
3499
+ willUpdateUnreachableHosts (bool): Update global unreachable hosts file. Default: _DEFAULT_UPDATE_UNREACHABLE_HOSTS.
3500
+ no_start (bool): If True, return Host objects without running commands. Default: _DEFAULT_NO_START.
3501
+ skip_hosts (str or None): Hosts to skip. Default: DEFAULT_SKIP_HOSTS.
3502
+ window_width (int): Minimum width per curses window. Default: DEFAULT_WINDOW_WIDTH.
3503
+ window_height (int): Minimum height per curses window. Default: DEFAULT_WINDOW_HEIGHT.
3504
+ single_window (bool): Use a single curses window for all hosts. Default: DEFAULT_SINGLE_WINDOW.
3505
+ file_sync (bool): Enable file sync mode (sync directories). Default: DEFAULT_FILE_SYNC.
3506
+ error_only (bool): Only print error output. Default: DEFAULT_ERROR_ONLY.
3507
+ quiet (bool): Suppress all output (overrides other output options). Default: False.
3508
+ identity_file (str or None): SSH identity file. Default: DEFAULT_IDENTITY_FILE.
3509
+ copy_id (bool): Use ssh-copy-id to copy public key to hosts. Default: False.
3510
+ unavailable_host_expiry (int): Seconds to keep hosts marked as unavailable. Default: DEFAULT_UNAVAILABLE_HOST_EXPIRY.
3511
+ no_history (bool): Do not record command history. Default: True.
3512
+ history_file (str): File to store command history. Default: DEFAULT_HISTORY_FILE.
3205
3513
 
3206
3514
  Returns:
3207
- list: A list of Host objects
3208
- '''
3515
+ list: List of Host objects representing each host/command run.
3516
+ """
3209
3517
  global __globalUnavailableHosts
3210
3518
  global __global_suppress_printout
3211
3519
  global _no_env
@@ -3333,39 +3641,39 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3333
3641
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
3334
3642
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
3335
3643
  unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
3336
- curses_min_char_len = curses_min_char_len, curses_min_line_len = curses_min_line_len,
3644
+ window_width = window_width, window_height = window_height,
3337
3645
  single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
3338
3646
  else:
3339
3647
  eprint(f"Warning: ssh-copy-id not found in {_binPaths} , skipping copy id to the hosts")
3340
3648
  if not commands:
3341
3649
  _exit_with_code(0, "Copy id finished, no commands to run")
3342
- if files and not commands:
3650
+ if file and not commands:
3343
3651
  # if files are specified but not target dir, we default to file sync mode
3344
3652
  file_sync = True
3345
3653
  if file_sync:
3346
3654
  # set the files to the union of files and commands
3347
- files = set(files+commands) if files else set(commands)
3348
- if files:
3655
+ file = set(file+commands) if file else set(commands)
3656
+ if file:
3349
3657
  # try to resolve files first (like * etc)
3350
3658
  if not gather_mode:
3351
3659
  pathSet = set()
3352
- for file in files:
3660
+ for file in file:
3353
3661
  try:
3354
3662
  pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
3355
3663
  except Exception:
3356
3664
  pathSet.update(glob.glob(file,recursive=True))
3357
3665
  if not pathSet:
3358
- _exit_with_code(66, f'No source files at {files!r} are found after resolving globs!')
3666
+ _exit_with_code(66, f'No source files at {file!r} are found after resolving globs!')
3359
3667
  else:
3360
- pathSet = set(files)
3668
+ pathSet = set(file)
3361
3669
  if file_sync:
3362
3670
  # use abosolute path for file sync
3363
3671
  commands = [os.path.abspath(file) for file in pathSet]
3364
- files = []
3672
+ file = []
3365
3673
  else:
3366
- files = list(pathSet)
3674
+ file = list(pathSet)
3367
3675
  if __DEBUG_MODE:
3368
- eprint(f"Files: {files!r}")
3676
+ eprint(f"Files: {file!r}")
3369
3677
  if oneonone:
3370
3678
  hosts = []
3371
3679
  if len(commands) != len(set(targetHostDic) - set(skipHostSet)):
@@ -3385,7 +3693,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3385
3693
  if file_sync:
3386
3694
  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]))
3387
3695
  else:
3388
- 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]))
3696
+ hosts.append(Host(host, command, files = file,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
3389
3697
  if not __global_suppress_printout:
3390
3698
  eprint(f"Running command: {command!r} on host: {host!r}")
3391
3699
  if not __global_suppress_printout:
@@ -3394,7 +3702,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3394
3702
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
3395
3703
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
3396
3704
  unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
3397
- curses_min_char_len = curses_min_char_len, curses_min_line_len = curses_min_line_len,
3705
+ window_width = window_width, window_height = window_height,
3398
3706
  single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
3399
3707
  return hosts
3400
3708
  else:
@@ -3412,10 +3720,10 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3412
3720
  if file_sync:
3413
3721
  eprint("Error: file sync mode need to be specified with at least one path to sync.")
3414
3722
  return []
3415
- elif files:
3723
+ elif file:
3416
3724
  eprint("Error: files need to be specified with at least one path to sync")
3417
3725
  else:
3418
- 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]))
3726
+ hosts.append(Host(host, '', files = file,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file,ip=targetHostDic[host]))
3419
3727
  if not __global_suppress_printout:
3420
3728
  eprint('-'*80)
3421
3729
  eprint(f"Running in interactive mode on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
@@ -3426,7 +3734,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3426
3734
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
3427
3735
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
3428
3736
  unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
3429
- curses_min_char_len = curses_min_char_len, curses_min_line_len = curses_min_line_len,
3737
+ window_width = window_width, window_height = window_height,
3430
3738
  single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
3431
3739
  return hosts
3432
3740
  for command in commands:
@@ -3441,7 +3749,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3441
3749
  if file_sync:
3442
3750
  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]))
3443
3751
  else:
3444
- 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]))
3752
+ hosts.append(Host(host, command, files = file,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
3445
3753
  if not __global_suppress_printout and len(commands) > 1:
3446
3754
  eprint('-'*80)
3447
3755
  eprint(f"Running command: {command} on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
@@ -3450,7 +3758,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3450
3758
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
3451
3759
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
3452
3760
  unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
3453
- curses_min_char_len = curses_min_char_len, curses_min_line_len = curses_min_line_len,
3761
+ window_width = window_width, window_height = window_height,
3454
3762
  single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
3455
3763
  allHosts += hosts
3456
3764
  return allHosts
@@ -3472,8 +3780,8 @@ def generate_default_config(args):
3472
3780
  'DEFAULT_HOSTS': args.hosts,
3473
3781
  'DEFAULT_USERNAME': args.username,
3474
3782
  'DEFAULT_PASSWORD': args.password,
3475
- 'DEFAULT_IDENTITY_FILE': args.key if args.key and not os.path.isdir(args.key) else DEFAULT_IDENTITY_FILE,
3476
- 'DEDAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEDAULT_SSH_KEY_SEARCH_PATH,
3783
+ 'DEFAULT_IDENTITY_FILE': args.identity_file if args.identity_file and not os.path.isdir(args.identity_file) else DEFAULT_IDENTITY_FILE,
3784
+ 'DEFAULT_SSH_KEY_SEARCH_PATH': args.identity_file if args.identity_file and os.path.isdir(args.identity_file) else DEFAULT_SSH_KEY_SEARCH_PATH,
3477
3785
  'DEFAULT_USE_KEY': args.use_key,
3478
3786
  'DEFAULT_EXTRA_ARGS': args.extraargs,
3479
3787
  'DEFAULT_ONE_ON_ONE': args.oneonone,
@@ -3490,14 +3798,14 @@ def generate_default_config(args):
3490
3798
  'DEFAULT_IPMI_USERNAME': args.ipmi_username,
3491
3799
  'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
3492
3800
  'DEFAULT_NO_WATCH': args.no_watch,
3493
- 'DEFAULT_CURSES_MINIMUM_CHAR_LEN': args.window_width,
3494
- 'DEFAULT_CURSES_MINIMUM_LINE_LEN': args.window_height,
3801
+ 'DEFAULT_WINDOW_WIDTH': args.window_width,
3802
+ 'DEFAULT_WINDOW_HEIGHT': args.window_height,
3495
3803
  'DEFAULT_SINGLE_WINDOW': args.single_window,
3496
3804
  'DEFAULT_ERROR_ONLY': args.error_only,
3497
3805
  'DEFAULT_NO_OUTPUT': args.no_output,
3498
3806
  'DEFAULT_RETURN_ZERO': args.return_zero,
3499
3807
  'DEFAULT_NO_ENV': args.no_env,
3500
- 'DEFAULT_ENV_FILE': args.env_file,
3808
+ 'DEFAULT_ENV_FILES': args.env_files,
3501
3809
  'DEFAULT_NO_HISTORY': args.no_history,
3502
3810
  'DEFAULT_HISTORY_FILE': args.history_file,
3503
3811
  'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
@@ -3554,18 +3862,18 @@ def write_default_config(args,CONFIG_FILE = None):
3554
3862
  #%% ------------ Argument Processing -----------------
3555
3863
  def get_parser():
3556
3864
  global _binPaths
3557
- parser = argparse.ArgumentParser(description=f'Run a command on multiple hosts, Use #HOST# or #HOSTNAME# to replace the host name in the command. Config file chain: {CONFIG_FILE_CHAIN!r}',
3558
- epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}',)
3865
+ parser = argparse.ArgumentParser(description='Run a command on multiple hosts, Use #HOST# or #HOSTNAME# to replace the host name in the command.',
3866
+ epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}\nConfig file chain: {CONFIG_FILE_CHAIN!r}',)
3559
3867
  parser.add_argument('hosts', metavar='hosts', type=str, nargs='?', help=f'Hosts to run the command on, use "," to seperate hosts. (default: {DEFAULT_HOSTS})',default=DEFAULT_HOSTS)
3560
3868
  parser.add_argument('commands', metavar='commands', type=str, nargs='*',default=None,help='the command to run on the hosts / the destination of the files #HOST# or #HOSTNAME# will be replaced with the host name.')
3561
3869
  parser.add_argument('-u','--username', type=str,help=f'The general username to use to connect to the hosts. Will get overwrote by individual username@host if specified. (default: {DEFAULT_USERNAME})',default=DEFAULT_USERNAME)
3562
3870
  parser.add_argument('-p', '--password', type=str,help=f'The password to use to connect to the hosts, (default: {DEFAULT_PASSWORD})',default=DEFAULT_PASSWORD)
3563
- parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {DEDAULT_SSH_KEY_SEARCH_PATH} (default: {DEFAULT_IDENTITY_FILE})',const=DEDAULT_SSH_KEY_SEARCH_PATH,default=DEFAULT_IDENTITY_FILE)
3871
+ parser.add_argument('-k','--identity_file','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {DEFAULT_SSH_KEY_SEARCH_PATH} (default: {DEFAULT_IDENTITY_FILE})',const=DEFAULT_SSH_KEY_SEARCH_PATH,default=DEFAULT_IDENTITY_FILE)
3564
3872
  parser.add_argument('-uk','--use_key', action='store_true', help=f'Attempt to use public key file to connect to the hosts. (default: {DEFAULT_USE_KEY})', default=DEFAULT_USE_KEY)
3565
3873
  parser.add_argument('-ea','--extraargs',type=str,help=f'Extra arguments to pass to the ssh / rsync / scp command. Put in one string for multiple arguments.Use "=" ! Ex. -ea="--delete" (default: {DEFAULT_EXTRA_ARGS})',default=DEFAULT_EXTRA_ARGS)
3566
3874
  parser.add_argument("-11",'--oneonone', action='store_true', help=f"Run one corresponding command on each host. (default: {DEFAULT_ONE_ON_ONE})", default=DEFAULT_ONE_ON_ONE)
3567
3875
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3568
- 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)
3876
+ parser.add_argument('-s','-fs','--file_sync',nargs='?', action='append', 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})',const=True, default=[DEFAULT_FILE_SYNC])
3569
3877
  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)
3570
3878
  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)
3571
3879
  #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")
@@ -3578,15 +3886,16 @@ def get_parser():
3578
3886
  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)
3579
3887
  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)
3580
3888
  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)
3581
- 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)
3582
- 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)
3583
- 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)
3889
+ parser.add_argument('-S',"-q","-nw","--no_watch", action='store_true', help=f"Quiet mode, no curses watch, only print the output. (default: {DEFAULT_NO_WATCH})", default=DEFAULT_NO_WATCH)
3890
+ parser.add_argument("-ww",'--window_width', type=int, help=f"The minimum character length of the curses window. (default: {DEFAULT_WINDOW_WIDTH})", default=DEFAULT_WINDOW_WIDTH)
3891
+ parser.add_argument("-wh",'--window_height', type=int, help=f"The minimum line height of the curses window. (default: {DEFAULT_WINDOW_HEIGHT})", default=DEFAULT_WINDOW_HEIGHT)
3584
3892
  parser.add_argument('-B','-sw','--single_window', action='store_true', help=f'Use a single window for all hosts. (default: {DEFAULT_SINGLE_WINDOW})', default=DEFAULT_SINGLE_WINDOW)
3585
3893
  parser.add_argument('-R','-eo','--error_only', action='store_true', help=f'Only print the error output. (default: {DEFAULT_ERROR_ONLY})', default=DEFAULT_ERROR_ONLY)
3586
- parser.add_argument('-Q',"-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
3894
+ parser.add_argument('-Q',"-no","--no_output","--quiet", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
3587
3895
  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)
3588
3896
  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)
3589
- 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)
3897
+ parser.add_argument('-ef',"--env_file", type=str, help="Replace the env file look up chain with this env_file. ( Still work with --no_env ) (default: None)", default='')
3898
+ parser.add_argument('-efs',"--env_files", action='append', help=f"The files to load the mssh file based environment variables from. Can specify multiple. Load first to last. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILES})")
3590
3899
  parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3591
3900
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3592
3901
  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)
@@ -3605,8 +3914,9 @@ def get_parser():
3605
3914
  parser.add_argument('-hf','--history_file', type=str, help=f'The file to store the history. (default: {DEFAULT_HISTORY_FILE})', default=DEFAULT_HISTORY_FILE)
3606
3915
  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')
3607
3916
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3608
- 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)
3917
+ parser.add_argument('-dt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. {{0-1}} Set to 0 to always display the diff. Set to 1 to disable diff. (Only merge same) (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
3609
3918
  parser.add_argument('--force_truecolor', action='store_true', help=f'Force truecolor output even when not in a truecolor terminal. (default: {FORCE_TRUECOLOR})', default=FORCE_TRUECOLOR)
3919
+ parser.add_argument('--add_control_master_config', action='store_true', help='Add ControlMaster configuration to ~/.ssh/config to speed up multiple connections to the same host.')
3610
3920
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3611
3921
  return parser
3612
3922
 
@@ -3636,7 +3946,18 @@ def process_args(args = None):
3636
3946
  args.no_history = True
3637
3947
  args.greppable = True
3638
3948
  args.error_only = True
3639
-
3949
+
3950
+ if args.file_sync:
3951
+ for path in args.file_sync:
3952
+ if path and isinstance(path, str):
3953
+ if args.file:
3954
+ if path not in args.file:
3955
+ args.file.append(path)
3956
+ else:
3957
+ args.file = [path]
3958
+ args.file_sync = any(args.file_sync)
3959
+ else:
3960
+ args.file_sync = False
3640
3961
  if args.unavailable_host_expiry <= 0:
3641
3962
  eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
3642
3963
  args.unavailable_host_expiry = 10
@@ -3655,7 +3976,7 @@ def process_config_file(args):
3655
3976
  else:
3656
3977
  configFileToWriteTo = args.config_file
3657
3978
  write_default_config(args,configFileToWriteTo)
3658
- if not args.commands:
3979
+ if not args.commands and not args.file:
3659
3980
  if configFileToWriteTo:
3660
3981
  with open(configFileToWriteTo,'r') as f:
3661
3982
  eprint(f"Config file content: \n{f.read()}")
@@ -3687,21 +4008,50 @@ def process_commands(args):
3687
4008
  return args
3688
4009
 
3689
4010
  def process_keys(args):
3690
- if args.key or args.use_key:
3691
- if not args.key:
3692
- args.key = find_ssh_key_file()
4011
+ if args.identity_file or args.use_key:
4012
+ if not args.identity_file:
4013
+ args.identity_file = find_ssh_key_file()
3693
4014
  else:
3694
- if os.path.isdir(os.path.expanduser(args.key)):
3695
- args.key = find_ssh_key_file(args.key)
3696
- elif not os.path.exists(args.key):
3697
- eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
4015
+ if os.path.isdir(os.path.expanduser(args.identity_file)):
4016
+ args.identity_file = find_ssh_key_file(args.identity_file)
4017
+ elif not os.path.exists(args.identity_file):
4018
+ eprint(f"Warning: Identity file {args.identity_file!r} not found. Passing to ssh anyway. Proceed with caution.")
3698
4019
  return args
3699
4020
 
4021
+ def process_control_master_config(args):
4022
+ global __control_master_string
4023
+ if args.add_control_master_config:
4024
+ try:
4025
+ if not os.path.exists(os.path.expanduser('~/.ssh')):
4026
+ os.makedirs(os.path.expanduser('~/.ssh'),mode=0o700)
4027
+ ssh_config_file = os.path.expanduser('~/.ssh/config')
4028
+ if not os.path.exists(ssh_config_file):
4029
+ with open(ssh_config_file,'w') as f:
4030
+ f.write(__control_master_string)
4031
+ os.chmod(ssh_config_file,0o644)
4032
+ else:
4033
+ with open(ssh_config_file,'r') as f:
4034
+ ssh_config_content = f.readlines()
4035
+ if set(map(str.strip,ssh_config_content)).issuperset(set(map(str.strip,__control_master_string.splitlines()))):
4036
+ eprint("ControlMaster configuration already exists in ~/.ssh/config, skipping adding:")
4037
+ eprint(__control_master_string)
4038
+ else:
4039
+ with open(ssh_config_file,'a') as f:
4040
+ f.write('\n# Added by multiSSH3.py to speed up subsequent connections\n')
4041
+ f.write(__control_master_string)
4042
+ eprint("ControlMaster configuration added to ~/.ssh/config.")
4043
+ except Exception as e:
4044
+ eprint(f"Error adding ControlMaster configuration: {e}")
4045
+ import traceback
4046
+ traceback.print_exc()
4047
+ if not args.commands and not args.file:
4048
+ _exit_with_code(0, "Done configuring ControlMaster.")
4049
+ return args
3700
4050
 
3701
4051
  def set_global_with_args(args):
3702
4052
  global _emo
3703
4053
  global __ipmiiInterfaceIPPrefix
3704
- global _env_file
4054
+ global _env_files
3705
4055
  global __DEBUG_MODE
3706
4056
  global __configs_from_file
3707
4057
  global _encoding
@@ -3712,7 +4062,10 @@ def set_global_with_args(args):
3712
4062
  global FORCE_TRUECOLOR
3713
4063
  _emo = False
3714
4064
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3715
- _env_file = args.env_file
4065
+ if args.env_file:
4066
+ _env_files = [args.env_file]
4067
+ else:
4068
+ _env_files = DEFAULT_ENV_FILES + args.env_files if args.env_files else DEFAULT_ENV_FILES
3716
4069
  __DEBUG_MODE = args.debug
3717
4070
  _encoding = args.encoding
3718
4071
  if args.return_zero:
@@ -3733,6 +4086,7 @@ def main():
3733
4086
  args = process_config_file(args)
3734
4087
  args = process_commands(args)
3735
4088
  args = process_keys(args)
4089
+ args = process_control_master_config(args)
3736
4090
  set_global_with_args(args)
3737
4091
 
3738
4092
  if args.use_script_timeout:
@@ -3743,16 +4097,7 @@ def main():
3743
4097
  if args.no_output:
3744
4098
  __global_suppress_printout = True
3745
4099
  if not __global_suppress_printout:
3746
- cmdStr = getStrCommand(args.hosts,args.commands,
3747
- oneonone=args.oneonone,timeout=args.timeout,password=args.password,
3748
- no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
3749
- files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
3750
- extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
3751
- curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
3752
- copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
3753
- history_file = args.history_file,
3754
- env_file = args.env_file,
3755
- repeat = args.repeat,interval = args.interval)
4100
+ cmdStr = getStrCommand(**vars(args))
3756
4101
  eprint('> ' + cmdStr)
3757
4102
  if args.error_only:
3758
4103
  __global_suppress_printout = True
@@ -3764,15 +4109,7 @@ def main():
3764
4109
 
3765
4110
  if not __global_suppress_printout:
3766
4111
  eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3767
- hosts = run_command_on_hosts(args.hosts,args.commands,
3768
- oneonone=args.oneonone,timeout=args.timeout,password=args.password,
3769
- no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
3770
- files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
3771
- extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
3772
- curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
3773
- copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
3774
- history_file = args.history_file,
3775
- )
4112
+ hosts = run_command_on_hosts(**vars(args),called=False)
3776
4113
  #print('*'*80)
3777
4114
  #if not __global_suppress_printout: eprint('-'*80)
3778
4115
  succeededHosts = set()