multiSSH3 5.92__py3-none-any.whl → 6.1__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.
multiSSH3.py CHANGED
@@ -30,7 +30,7 @@ import threading
30
30
  import time
31
31
  import typing
32
32
  import uuid
33
- from collections import Counter, deque, defaultdict, UserDict
33
+ from collections import Counter, deque, defaultdict
34
34
  from itertools import count, product
35
35
 
36
36
  __curses_available = False
@@ -47,7 +47,6 @@ try:
47
47
  except ImportError:
48
48
  pass
49
49
 
50
-
51
50
  try:
52
51
  # Check if functiools.cache is available
53
52
  # cache_decorator = functools.cache
@@ -85,10 +84,10 @@ except Exception:
85
84
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
86
85
  def cache_decorator(func):
87
86
  return func
88
- version = '5.92'
87
+ version = '6.01'
89
88
  VERSION = version
90
89
  __version__ = version
91
- COMMIT_DATE = '2025-10-20'
90
+ COMMIT_DATE = '2025-11-06'
92
91
 
93
92
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
94
93
  '~/multiSSH3.config.json',
@@ -322,7 +321,7 @@ DEFAULT_HOSTS = 'all'
322
321
  DEFAULT_USERNAME = None
323
322
  DEFAULT_PASSWORD = ''
324
323
  DEFAULT_IDENTITY_FILE = None
325
- DEDAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
324
+ DEFAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
326
325
  DEFAULT_USE_KEY = False
327
326
  DEFAULT_EXTRA_ARGS = None
328
327
  DEFAULT_ONE_ON_ONE = False
@@ -346,7 +345,16 @@ DEFAULT_ERROR_ONLY = False
346
345
  DEFAULT_NO_OUTPUT = False
347
346
  DEFAULT_RETURN_ZERO = False
348
347
  DEFAULT_NO_ENV = False
349
- 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
+ ]
350
358
  DEFAULT_NO_HISTORY = False
351
359
  DEFAULT_HISTORY_FILE = '~/.mssh_history'
352
360
  DEFAULT_MAX_CONNECTIONS = 4 * os.cpu_count()
@@ -356,7 +364,7 @@ DEFAULT_GREPPABLE_MODE = False
356
364
  DEFAULT_SKIP_UNREACHABLE = True
357
365
  DEFAULT_SKIP_HOSTS = ''
358
366
  DEFAULT_ENCODING = 'utf-8'
359
- DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
367
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.6
360
368
  SSH_STRICT_HOST_KEY_CHECKING = False
361
369
  FORCE_TRUECOLOR = False
362
370
  ERROR_MESSAGES_TO_IGNORE = [
@@ -368,6 +376,24 @@ ERROR_MESSAGES_TO_IGNORE = [
368
376
  'Killed by signal',
369
377
  'Connection reset by peer',
370
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()
371
397
  _DEFAULT_CALLED = True
372
398
  _DEFAULT_RETURN_UNFINISHED = False
373
399
  _DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
@@ -387,6 +413,9 @@ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
387
413
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__ERROR_MESSAGES_TO_IGNORE_REGEX)
388
414
  else:
389
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)
390
419
 
391
420
  #%% Load mssh Functional Global Variables
392
421
  __global_suppress_printout = False
@@ -394,7 +423,7 @@ __mainReturnCode = 0
394
423
  __failedHosts = set()
395
424
  __wildCharacters = ['*','?','x']
396
425
  _no_env = DEFAULT_NO_ENV
397
- _env_file = DEFAULT_ENV_FILE
426
+ _env_files = DEFAULT_ENV_FILES
398
427
  __globalUnavailableHosts = dict()
399
428
  __ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
400
429
  __keyPressesIn = [[]]
@@ -408,6 +437,11 @@ __thread_start_delay = 0
408
437
  _encoding = DEFAULT_ENCODING
409
438
  __returnZero = DEFAULT_RETURN_ZERO
410
439
  __running_threads = set()
440
+ __control_master_string = '''Host *
441
+ ControlMaster auto
442
+ ControlPath /run/user/%i/ssh_sockets_%C
443
+ ControlPersist 3600
444
+ '''
411
445
  if __resource_lib_available:
412
446
  # Get the current limits
413
447
  _, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
@@ -455,12 +489,12 @@ def check_path(program_name):
455
489
 
456
490
  [check_path(program) for program in _binCalled]
457
491
 
458
- def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
492
+ def find_ssh_key_file(searchPath = DEFAULT_SSH_KEY_SEARCH_PATH):
459
493
  '''
460
494
  Find the ssh public key file
461
495
 
462
496
  Args:
463
- 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.
464
498
 
465
499
  Returns:
466
500
  str: The path to the ssh key file
@@ -476,35 +510,65 @@ def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
476
510
  return None
477
511
 
478
512
  @cache_decorator
479
- def readEnvFromFile(environemnt_file = ''):
513
+ def readEnvFromFile():
480
514
  '''
481
515
  Read the environment variables from env_file
482
516
  Returns:
483
517
  dict: A dictionary of environment variables
484
518
  '''
485
- global env
486
- try:
487
- if env:
488
- return env
489
- except Exception:
490
- env = {}
491
- global _env_file
492
- if environemnt_file:
493
- envf = environemnt_file
494
- else:
495
- envf = _env_file if _env_file else DEFAULT_ENV_FILE
496
- if os.path.exists(envf):
497
- with open(envf,'r') as f:
498
- for line in f:
499
- 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:
500
534
  continue
501
- key, value = line.replace('export ', '', 1).strip().split('=', 1)
502
- key = key.strip().strip('"').strip("'")
503
- value = value.strip().strip('"').strip("'")
504
- # avoid infinite recursion
505
- if key != value:
506
- env[key] = value.strip('"').strip("'")
507
- 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
508
572
 
509
573
  def replace_magic_strings(string,keys,value,case_sensitive=False):
510
574
  '''
@@ -642,28 +706,25 @@ class OrderedMultiSet(deque):
642
706
  self._counter = Counter()
643
707
  if iterable is not None:
644
708
  self.extend(iterable)
645
- def __decrease_count(self, item):
646
- """Decrease count of item in counter."""
647
- self._counter[item] -= 1
648
- if self._counter[item] == 0:
649
- del self._counter[item]
650
709
  def append(self, item):
651
710
  """Add item to the right end. O(1)."""
652
711
  if len(self) == self.maxlen:
653
- self.__decrease_count(self[0])
712
+ self._counter -= Counter([self[0]])
713
+ # self._counter[self[0]] -= 1
714
+ # self._counter += Counter()
654
715
  super().append(item)
655
716
  self._counter[item] += 1
656
717
  def appendleft(self, item):
657
718
  """Add item to the left end. O(1)."""
658
719
  if len(self) == self.maxlen:
659
- self.__decrease_count(self[-1])
720
+ self._counter -= Counter([self[-1]])
660
721
  super().appendleft(item)
661
722
  self._counter[item] += 1
662
723
  def pop(self):
663
724
  """Remove and return item from right end. O(1)."""
664
725
  try:
665
726
  item = super().pop()
666
- self.__decrease_count(item)
727
+ self._counter -= Counter([item])
667
728
  return item
668
729
  except IndexError:
669
730
  return None
@@ -671,7 +732,7 @@ class OrderedMultiSet(deque):
671
732
  """Remove and return item from left end. O(1)."""
672
733
  try:
673
734
  item = super().popleft()
674
- self.__decrease_count(item)
735
+ self._counter -= Counter([item])
675
736
  return item
676
737
  except IndexError:
677
738
  return None
@@ -680,7 +741,7 @@ class OrderedMultiSet(deque):
680
741
  removed = None
681
742
  if len(self) == self.maxlen:
682
743
  removed = self[0] # Item that will be removed
683
- self.__decrease_count(removed)
744
+ self._counter -= Counter([removed])
684
745
  super().append(item)
685
746
  self._counter[item] += 1
686
747
  return removed
@@ -689,7 +750,7 @@ class OrderedMultiSet(deque):
689
750
  removed = None
690
751
  if len(self) == self.maxlen:
691
752
  removed = self[-1] # Item that will be removed
692
- self.__decrease_count(removed)
753
+ self._counter -= Counter([removed])
693
754
  super().appendleft(item)
694
755
  self._counter[item] += 1
695
756
  return removed
@@ -701,7 +762,7 @@ class OrderedMultiSet(deque):
701
762
  if value not in self._counter:
702
763
  return None
703
764
  super().remove(value)
704
- self.__decrease_count(value)
765
+ self._counter -= Counter([value])
705
766
  def clear(self):
706
767
  """Remove all items. O(1)."""
707
768
  super().clear()
@@ -722,24 +783,61 @@ class OrderedMultiSet(deque):
722
783
  super().extend(iterable)
723
784
  self._counter.update(iterable)
724
785
  else:
725
- # Need to remove oldest items to make space
726
- num_to_remove = len(self) + len(iterable) - self.maxlen
727
- for _ in range(num_to_remove):
728
- self.__decrease_count(super().popleft())
786
+ num_to_keep = self.maxlen - len(iterable)
787
+ self.truncateright(num_to_keep)
729
788
  super().extend(iterable)
730
789
  self._counter.update(iterable)
731
790
  except TypeError:
732
791
  return self.extend(list(iterable))
733
792
  def extendleft(self, iterable):
734
793
  """Extend left side by appending elements from iterable. O(k)."""
735
- for item in iterable:
736
- 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
737
834
  def rotate(self, n=1):
738
835
  """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
739
836
  super().rotate(n)
740
837
  def __contains__(self, item):
741
838
  """Check if item exists in deque. O(1) average."""
742
- return item in self._counter
839
+ # return item in self._counter
840
+ return super().__contains__(item)
743
841
  def count(self, item):
744
842
  """Return number of occurrences of item. O(1)."""
745
843
  return self._counter[item]
@@ -747,14 +845,14 @@ class OrderedMultiSet(deque):
747
845
  """Set item at index. O(1) for access, O(1) for counter update."""
748
846
  old_value = self[index]
749
847
  super().__setitem__(index, value)
750
- self.__decrease_count(old_value)
848
+ self._counter -= Counter([old_value])
751
849
  self._counter[value] += 1
752
850
  return old_value
753
851
  def __delitem__(self, index):
754
852
  """Delete item at index. O(n) for deletion, O(1) for counter update."""
755
853
  value = self[index]
756
854
  super().__delitem__(index)
757
- self.__decrease_count(value)
855
+ self._counter -= Counter([value])
758
856
  return value
759
857
  def insert(self, index, value):
760
858
  """Insert value at index. O(n) for insertion, O(1) for counter update."""
@@ -788,6 +886,28 @@ class OrderedMultiSet(deque):
788
886
  return self[-1]
789
887
  except IndexError:
790
888
  return None
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)
791
911
 
792
912
  def get_terminal_size():
793
913
  '''
@@ -833,6 +953,8 @@ def get_terminal_color_capability():
833
953
  return '24bit'
834
954
  elif "256" in term:
835
955
  return '256'
956
+ elif "16" in term:
957
+ return '16'
836
958
  try:
837
959
  curses.setupterm()
838
960
  colors = curses.tigetnum("colors")
@@ -851,96 +973,120 @@ def get_terminal_color_capability():
851
973
  return 'None'
852
974
 
853
975
  @cache_decorator
854
- def get_xterm256_palette():
855
- palette = []
856
- # 0–15: system colors (we'll just fill with dummy values;
857
- # you could fill in real RGB if you need to)
858
- system_colors = [
859
- (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
860
- (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
861
- (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
862
- (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
863
- ]
864
- palette.extend(system_colors)
865
- # 16–231: 6x6x6 color cube
866
- levels = [0, 95, 135, 175, 215, 255]
867
- for r in levels:
868
- for g in levels:
869
- for b in levels:
870
- palette.append((r, g, b))
871
- # 232–255: grayscale ramp, 24 steps from 8 to 238
872
- for i in range(24):
873
- level = 8 + i * 10
874
- palette.append((level, level, level))
875
- 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 ''
876
1000
 
877
- @cache_decorator
878
- def rgb_to_xterm_index(r, g, b):
1001
+ def _rgb_to_256_color(r, g, b):
1002
+ """
1003
+ Map (r,g,b) to the 256-color cube or grayscale ramp.
879
1004
  """
880
- Map 24-bit RGB to nearest xterm-256 color index.
881
- r, g, b should be in 0-255.
882
- Returns an int in 0-255.
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):
883
1016
  """
884
- best_index = 0
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
885
1039
  best_dist = float('inf')
886
- for i, (pr, pg, pb) in enumerate(get_xterm256_palette()):
887
- dr = pr - r
888
- dg = pg - g
889
- db = pb - b
890
- 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
891
1042
  if dist < best_dist:
892
1043
  best_dist = dist
893
- best_index = i
894
- return best_index
1044
+ best_idx = i
1045
+ return best_idx
895
1046
 
896
- @cache_decorator
897
- def hashable_to_color(n, brightness_threshold=500):
898
- 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):
899
1055
  r = (hash_value >> 16) & 0xFF
900
1056
  g = (hash_value >> 8) & 0xFF
901
1057
  b = hash_value & 0xFF
902
- if (r + g + b) < brightness_threshold:
903
- 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)
904
1063
  return (r, g, b)
905
1064
 
906
- __previous_ansi_color_index = -1
1065
+ __previous_color_rgb = ()
907
1066
  @cache_decorator
908
- def string_to_unique_ansi_color(string):
1067
+ def int_to_unique_ansi_color(number):
909
1068
  '''
910
- Convert a string to a unique ANSI color code
1069
+ Convert a number to a unique ANSI color code
911
1070
 
912
1071
  Args:
913
- string (str): The string to convert
914
-
1072
+ number (int): The number to convert
915
1073
  Returns:
916
1074
  int: The ANSI color code
917
1075
  '''
918
- global __previous_ansi_color_index
1076
+ global __previous_color_rgb
919
1077
  # Use a hash function to generate a consistent integer from the string
920
1078
  color_capability = get_terminal_color_capability()
921
- index = None
922
1079
  if color_capability == 'None':
923
1080
  return ''
924
- elif color_capability == '16':
925
- # Map to one of the 14 colors (31-37, 90-96), avoiding black and white
926
- index = (hash(string) % 14) + 31
927
- if index > 37:
928
- index += 52 # Bright colors (90-97)
929
- elif color_capability == '8':
930
- index = (hash(string) % 6) + 31
931
- r,g,b = hashable_to_color(string)
932
- if color_capability == '256':
933
- index = rgb_to_xterm_index(r,g,b)
934
- if index:
935
- if index == __previous_ansi_color_index:
936
- return string_to_unique_ansi_color(hash(string))
937
- __previous_ansi_color_index = index
938
- if color_capability == '256':
939
- return f'\033[38;5;{index}m'
940
- else:
941
- return f'\033[{index}m'
1081
+ if color_capability == '24bit':
1082
+ r, g, b = int_to_color(number)
942
1083
  else:
943
- 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)
944
1090
 
945
1091
  #%% ------------ Compacting Hostnames ----------------
946
1092
  def __tokenize_hostname(hostname):
@@ -1290,14 +1436,14 @@ def compact_hostnames(Hostnames,verify = True):
1290
1436
  # hostSet = frozenset(Hostnames)
1291
1437
  # else:
1292
1438
  # hostSet = Hostnames
1293
- hostSet = frozenset(
1439
+ hostSet = frozenset(expand_hostnames(
1294
1440
  hostname.strip()
1295
1441
  for hostnames_str in Hostnames
1296
1442
  for hostname in hostnames_str.split(',')
1297
- )
1443
+ ))
1298
1444
  compact_hosts = __compact_hostnames(hostSet)
1299
1445
  if verify:
1300
- if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
1446
+ if frozenset(expand_hostnames(compact_hosts)) != hostSet:
1301
1447
  if not __global_suppress_printout:
1302
1448
  eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
1303
1449
  compact_hosts = hostSet
@@ -1667,6 +1813,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1667
1813
  host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
1668
1814
  host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
1669
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)
1670
1817
  formatedCMD = []
1671
1818
  if host.extraargs and isinstance(host.extraargs, str):
1672
1819
  extraargs = host.extraargs.split()
@@ -1865,8 +2012,6 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1865
2012
  stdout_thread.join(timeout=1)
1866
2013
  stderr_thread.join(timeout=1)
1867
2014
  stdin_thread.join(timeout=1)
1868
- # here we handle the rest of the stdout after the subprocess returns
1869
- host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
1870
2015
  if not _emo:
1871
2016
  stdout = None
1872
2017
  stderr = None
@@ -1875,8 +2020,10 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1875
2020
  except subprocess.TimeoutExpired:
1876
2021
  pass
1877
2022
  if stdout:
2023
+ host.output.append('Trying to read the rest of the stdout...')
1878
2024
  __handle_reading_stream(io.BytesIO(stdout),host.stdout, host,host.stdout_buffer)
1879
2025
  if stderr:
2026
+ host.output.append('Trying to read the rest of the stderr...')
1880
2027
  __handle_reading_stream(io.BytesIO(stderr),host.stderr, host,host.stderr_buffer)
1881
2028
  # if the last line in host.stderr is Connection to * closed., we will remove it
1882
2029
  host.returncode = proc.poll()
@@ -2472,7 +2619,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2472
2619
  elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
2473
2620
  # if the key is exit, we will exit the program
2474
2621
  return
2475
- 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
2476
2623
  # if the key is help, we will display the help message
2477
2624
  if not help_shown:
2478
2625
  help_panel.show()
@@ -2688,35 +2835,58 @@ def can_merge(line_bag1, line_bag2, threshold):
2688
2835
  return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
2689
2836
 
2690
2837
  def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2691
- indexes = {hostname: 0 for hostname in merging_hostnames}
2692
- working_index_keys = set(indexes.keys())
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)
2693
2841
  previousBuddies = set()
2694
- 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='- ')
2695
2843
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2696
2844
  diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
2697
2845
  def get_multiset_index_for_hostname(hostname):
2698
2846
  index = indexes[hostname]
2699
2847
  tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2700
- return [OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index]
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]
2701
2850
  # futuresChainMap = ChainMap()
2702
- class futureDict(UserDict):
2703
- def __missing__(self, key):
2704
- value = get_multiset_index_for_hostname(key)
2705
- self[key] = value
2706
- # futuresChainMap.maps.append(value[0]._counter)
2707
- return value
2708
- # def initializeHostnames(self, hostnames):
2709
- # entries = {hostname: get_multiset_index_for_hostname(hostname) for hostname in hostnames}
2710
- # self.update(entries)
2711
- # futuresChainMap.maps.extend(entry[0]._counter for entry in entries.values())
2712
- futures = futureDict()
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}
2713
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'
2714
2884
  for hostname in merging_hostnames:
2715
2885
  currentLines[outputs_by_hostname[hostname][0]].add(hostname)
2716
2886
  while indexes:
2717
2887
  defer = False
2718
2888
  # sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
2719
- golden_hostname = min(working_index_keys, key=lambda hn: indexes[hn])
2889
+ golden_hostname = min(working_index_keys, key=indexes.get)
2720
2890
  golden_index = indexes[golden_hostname]
2721
2891
  lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2722
2892
  # for hostname, index in sorted_working_indexes[1:]:
@@ -2736,8 +2906,8 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2736
2906
  # if golden_hostname in futures:
2737
2907
  # thisCounter = futures[golden_hostname][0]._counter
2738
2908
  # futuresChainMap.maps.remove(thisCounter)
2739
- for hostname in working_index_keys - buddy - set(futures.keys()):
2740
- futures[hostname] # ensure it's initialized
2909
+ # for hostname in working_index_keys - buddy - set(futures.keys()):
2910
+ # futures[hostname] # ensure it's initialized
2741
2911
  # futures.initializeHostnames(working_index_keys - buddy - futures.keys())
2742
2912
  if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
2743
2913
  defer = True
@@ -2748,20 +2918,27 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2748
2918
  if buddy != previousBuddies:
2749
2919
  hostnameStr = ','.join(compact_hostnames(buddy))
2750
2920
  hostnameLines = hostnameWrapper.wrap(hostnameStr)
2751
- hostnameLines = [line.ljust(line_length - 1) + '│' for line in hostnameLines]
2752
- color = string_to_unique_ansi_color(hostnameStr) if len(buddy) < len(merging_hostnames) else ''
2753
- 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]}"
2754
2930
  output.extend(hostnameLines)
2755
2931
  previousBuddies = buddy
2756
- output.append(lineToAdd.ljust(line_length - 1) + '│')
2932
+ output.append(lineToAdd)
2757
2933
  currentLines[lineToAdd].difference_update(buddy)
2758
2934
  if not currentLines[lineToAdd]:
2759
2935
  del currentLines[lineToAdd]
2936
+ indexes.update(buddy)
2760
2937
  for hostname in buddy:
2761
2938
  # currentLines[lineToAdd].remove(hostname)
2762
2939
  # if not currentLines[lineToAdd]:
2763
2940
  # del currentLines[lineToAdd]
2764
- indexes[hostname] += 1
2941
+ # indexes[hostname] += 1
2765
2942
  try:
2766
2943
  currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
2767
2944
  except IndexError:
@@ -2771,29 +2948,32 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2771
2948
  # futuresChainMap.maps.remove(future[0]._counter)
2772
2949
  continue
2773
2950
  #advance futures
2774
- if hostname in futures:
2775
- futures[hostname][1] += 1
2776
- tracking_multiset, tracking_index = futures[hostname]
2777
- if tracking_index < len(outputs_by_hostname[hostname]):
2778
- line = outputs_by_hostname[hostname][tracking_index]
2779
- tracking_multiset.append(line)
2780
- else:
2781
- tracking_multiset.popleft()
2782
- #futures[hostname] = (tracking_multiset, tracking_index)
2951
+ advance(futures, hostname)
2783
2952
  working_index_keys = set(indexes.keys())
2784
2953
 
2785
2954
  def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2786
2955
  output = []
2787
- 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'([,]+)')
2788
2968
  for merging_hostnames in merge_groups:
2789
2969
  mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2790
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2970
+ output.append(color_line+'─'*(line_length)+color_reset)
2791
2971
  for hostname in remaining_hostnames:
2792
- hostnameLines = textwrap.wrap(hostname, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2793
- initial_indent='├─ ', subsequent_indent='│- ')
2794
- output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
2795
- output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
2796
- 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)
2797
2977
  if output:
2798
2978
  output.pop()
2799
2979
  # if output and output[0] and output[0].startswith('├'):
@@ -2810,7 +2990,7 @@ def pre_merge_hosts(hosts):
2810
2990
  # Create merged hosts
2811
2991
  merged_hosts = []
2812
2992
  for group in output_groups.values():
2813
- group[0].name = ','.join(host.name for host in group)
2993
+ group[0].name = ','.join(compact_hostnames(host.name for host in group))
2814
2994
  merged_hosts.append(group[0])
2815
2995
  return merged_hosts
2816
2996
 
@@ -2818,24 +2998,40 @@ def get_host_raw_output(hosts, terminal_width):
2818
2998
  outputs_by_hostname = {}
2819
2999
  line_bag_by_hostname = {}
2820
3000
  hostnames_by_line_bag_len = {}
2821
- text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2822
- 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='-')
2823
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']))
2824
3017
  hosts = pre_merge_hosts(hosts)
2825
3018
  for host in hosts:
2826
- hostPrintOut = ["│█ EXECUTED COMMAND:"]
3019
+ max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
3020
+ hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
2827
3021
  for line in host.command.splitlines():
2828
3022
  hostPrintOut.extend(text_wrapper.wrap(line))
2829
3023
  # hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
2830
3024
  lineBag = {(0,host.command)}
2831
3025
  prevLine = host.command
2832
3026
  if host.stdout:
2833
- hostPrintOut.append('│▓ STDOUT:')
2834
- for line in host.stdout:
2835
- if len(line) < terminal_width - 2:
2836
- hostPrintOut.append(f" {line}")
2837
- else:
2838
- hostPrintOut.extend(text_wrapper.wrap(line))
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)))
2839
3035
  # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2840
3036
  lineBag.add((prevLine,1))
2841
3037
  lineBag.add((1,host.stdout[0]))
@@ -2851,21 +3047,26 @@ def get_host_raw_output(hosts, terminal_width):
2851
3047
  elif host.stderr[-1].strip().endswith('No route to host'):
2852
3048
  host.stderr[-1] = 'Cannot find host!'
2853
3049
  if host.stderr:
2854
- hostPrintOut.append('│▒ STDERR:')
2855
- for line in host.stderr:
2856
- if len(line) < terminal_width - 2:
2857
- hostPrintOut.append(f" {line}")
2858
- else:
2859
- hostPrintOut.extend(text_wrapper.wrap(line))
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)))
2860
3058
  lineBag.add((prevLine,2))
2861
3059
  lineBag.add((2,host.stderr[0]))
2862
3060
  lineBag.update(host.stderr)
2863
3061
  if len(host.stderr) > 1:
2864
3062
  lineBag.update(zip(host.stderr, host.stderr[1:]))
2865
3063
  prevLine = host.stderr[-1]
2866
- hostPrintOut.append(f"│░ RETURN CODE: {host.returncode}")
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}")
2867
3069
  lineBag.add((prevLine,f"{host.returncode}"))
2868
- max_length = max(max_length, max(map(len, hostPrintOut)))
2869
3070
  outputs_by_hostname[host.name] = hostPrintOut
2870
3071
  line_bag_by_hostname[host.name] = lineBag
2871
3072
  hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
@@ -2906,6 +3107,7 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
2906
3107
  return merge_groups, remaining_hostnames
2907
3108
 
2908
3109
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
3110
+ color_cap = get_terminal_color_capability()
2909
3111
  if quiet:
2910
3112
  # remove hosts with returncode 0
2911
3113
  hosts = [host for host in hosts if host.returncode != 0]
@@ -2913,7 +3115,10 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2913
3115
  if usejson:
2914
3116
  return '{"Success": true}'
2915
3117
  else:
2916
- return 'Success'
3118
+ if color_cap == 'None':
3119
+ return 'Success'
3120
+ else:
3121
+ return '\033[32mSuccess\033[0m'
2917
3122
  if usejson:
2918
3123
  # [print(dict(host)) for host in hosts]
2919
3124
  #print(json.dumps([dict(host) for host in hosts],indent=4))
@@ -2948,23 +3153,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2948
3153
  except Exception:
2949
3154
  eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2950
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']))
2951
3159
  terminal_length = get_terminal_size()[0]
2952
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)
2953
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)
2954
3162
  outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
2955
3163
  if keyPressesIn[-1]:
2956
3164
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2957
- outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
3165
+ outputs.append(color_reset_str + " User Inputs:".ljust(line_length,'─'))
2958
3166
  cmdOut = []
2959
3167
  for line in CMDsOut:
2960
3168
  cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2961
- initial_indent=' ', subsequent_indent='│-'))
2962
- outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
3169
+ initial_indent=' ', subsequent_indent='-'))
3170
+ outputs.extend(cmdOut)
2963
3171
  keyPressesIn[-1].clear()
2964
3172
  if not outputs:
2965
- rtnStr = 'Success' if quiet else ''
3173
+ if quiet:
3174
+ if color_cap == 'None':
3175
+ return 'Success'
3176
+ else:
3177
+ return '\033[32mSuccess\033[0m'
3178
+ else:
3179
+ rtnStr = ''
2966
3180
  else:
2967
- rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
3181
+ rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
2968
3182
  return rtnStr
2969
3183
 
2970
3184
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -3010,7 +3224,16 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
3010
3224
  if total_sleeped > 0.1:
3011
3225
  break
3012
3226
  if any([host.returncode is None for host in hosts]):
3013
- 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 = curses_min_char_len, min_line_len = curses_min_line_len, 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 = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3233
+ except Exception as e:
3234
+ eprint(f"Curses print error: {e}")
3235
+ import traceback
3236
+ print(traceback.format_exc())
3014
3237
  if not returnUnfinished:
3015
3238
  # wait until all hosts have a return code
3016
3239
  while any([host.returncode is None for host in hosts]):
@@ -3100,7 +3323,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3100
3323
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
3101
3324
  file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3102
3325
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
3103
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILE,
3326
+ history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
3104
3327
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3105
3328
  shortend = False) -> str:
3106
3329
  argsList = []
@@ -3144,8 +3367,10 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3144
3367
  argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
3145
3368
  if no_env:
3146
3369
  argsList.append('--no_env')
3147
- if env_file and env_file != DEFAULT_ENV_FILE:
3370
+ if env_file:
3148
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])
3149
3374
  if no_history:
3150
3375
  argsList.append('--no_history' if not shortend else '-nh')
3151
3376
  if history_file and history_file != DEFAULT_HISTORY_FILE:
@@ -3168,7 +3393,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3168
3393
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
3169
3394
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3170
3395
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
3171
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILE,
3396
+ history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
3172
3397
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3173
3398
  shortend = False,tabSeperated = False):
3174
3399
  _ = called
@@ -3188,7 +3413,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3188
3413
  no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
3189
3414
  file_sync = file_sync,error_only = error_only, identity_file = identity_file,
3190
3415
  copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
3191
- history_file = history_file, env_file = env_file,
3416
+ history_file = history_file, env_file = env_file, env_files = env_files,
3192
3417
  repeat = repeat,interval = interval,
3193
3418
  shortend = shortend)
3194
3419
  commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
@@ -3247,48 +3472,48 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
3247
3472
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = True,
3248
3473
  history_file = DEFAULT_HISTORY_FILE,
3249
3474
  ):
3250
- f'''
3251
- Run the command on the hosts, aka multissh. main function
3252
-
3253
- Args:
3254
- hosts (str/iterable): A string of hosts seperated by space or comma / iterable of hosts. Default to {DEFAULT_HOSTS}.
3255
- commands (list): A list of commands to run on the hosts. When using files, defines the destination of the files. Defaults to None.
3256
- oneonone (bool, optional): Whether to run the commands one on one. Defaults to {DEFAULT_ONE_ON_ONE}.
3257
- timeout (int, optional): The timeout for the command. Defaults to {DEFAULT_TIMEOUT}.
3258
- password (str, optional): The password for the hosts. Defaults to {DEFAULT_PASSWORD}.
3259
- no_watch (bool, optional): Whether to print the output. Defaults to {DEFAULT_NO_WATCH}.
3260
- json (bool, optional): Whether to print the output in JSON format. Defaults to {DEFAULT_JSON_MODE}.
3261
- called (bool, optional): Whether the function is called by another function. Defaults to {_DEFAULT_CALLED}.
3262
- max_connections (int, optional): The maximum number of concurrent SSH sessions. Defaults to 4 * os.cpu_count().
3263
- files (list, optional): A list of files to be copied to the hosts. Defaults to None.
3264
- ipmi (bool, optional): Whether to use IPMI to connect to the hosts. Defaults to {DEFAULT_IPMI}.
3265
- interface_ip_prefix (str, optional): The prefix of the IPMI interface. Defaults to {DEFAULT_INTERFACE_IP_PREFIX}.
3266
- returnUnfinished (bool, optional): Whether to return the unfinished hosts. Defaults to {_DEFAULT_RETURN_UNFINISHED}.
3267
- scp (bool, optional): Whether to use scp instead of rsync. Defaults to {DEFAULT_SCP}.
3268
- gather_mode (bool, optional): Whether to use gather mode. Defaults to False.
3269
- username (str, optional): The username to use to connect to the hosts. Defaults to {DEFAULT_USERNAME}.
3270
- extraargs (str, optional): Extra arguments to pass to the ssh / rsync / scp command. Defaults to {DEFAULT_EXTRA_ARGS}.
3271
- skipUnreachable (bool, optional): Whether to skip unreachable hosts. Defaults to {DEFAULT_SKIP_UNREACHABLE}.
3272
- no_env (bool, optional): Whether to not read the current sat system environment variables. (Will still read from files) Defaults to {DEFAULT_NO_ENV}.
3273
- greppable (bool, optional): Whether to print the output in greppable format. Defaults to {DEFAULT_GREPPABLE_MODE}.
3274
- willUpdateUnreachableHosts (bool, optional): Whether to update the global unavailable hosts. Defaults to {_DEFAULT_UPDATE_UNREACHABLE_HOSTS}.
3275
- no_start (bool, optional): Whether to return the hosts without starting the command. Defaults to {_DEFAULT_NO_START}.
3276
- skip_hosts (str, optional): The hosts to skip. Defaults to {DEFAULT_SKIP_HOSTS}.
3277
- min_char_len (int, optional): The minimum character per line of the curses output. Defaults to {DEFAULT_CURSES_MINIMUM_CHAR_LEN}.
3278
- min_line_len (int, optional): The minimum line number for each window of the curses output. Defaults to {DEFAULT_CURSES_MINIMUM_LINE_LEN}.
3279
- single_window (bool, optional): Whether to use a single window for the curses output. Defaults to {DEFAULT_SINGLE_WINDOW}.
3280
- file_sync (bool, optional): Whether to use file sync mode to sync directories. Defaults to {DEFAULT_FILE_SYNC}.
3281
- error_only (bool, optional): Whether to only print the error output. Defaults to {DEFAULT_ERROR_ONLY}.
3282
- quiet (bool, optional): Whether to suppress all verbose printout, added for compatibility, avoid using. Defaults to False.
3283
- identity_file (str, optional): The identity file to use for the ssh connection. Defaults to {DEFAULT_IDENTITY_FILE}.
3284
- copy_id (bool, optional): Whether to copy the id to the hosts. Defaults to False.
3285
- 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}.
3286
- no_history (bool, optional): Whether to not save the history of the command. Defaults to True.
3287
- 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
+ files (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
+ curses_min_char_len (int): Minimum width per curses window. Default: DEFAULT_CURSES_MINIMUM_CHAR_LEN.
3503
+ curses_min_line_len (int): Minimum height per curses window. Default: DEFAULT_CURSES_MINIMUM_LINE_LEN.
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.
3288
3513
 
3289
3514
  Returns:
3290
- list: A list of Host objects
3291
- '''
3515
+ list: List of Host objects representing each host/command run.
3516
+ """
3292
3517
  global __globalUnavailableHosts
3293
3518
  global __global_suppress_printout
3294
3519
  global _no_env
@@ -3556,7 +3781,7 @@ def generate_default_config(args):
3556
3781
  'DEFAULT_USERNAME': args.username,
3557
3782
  'DEFAULT_PASSWORD': args.password,
3558
3783
  'DEFAULT_IDENTITY_FILE': args.key if args.key and not os.path.isdir(args.key) else DEFAULT_IDENTITY_FILE,
3559
- 'DEDAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEDAULT_SSH_KEY_SEARCH_PATH,
3784
+ 'DEFAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEFAULT_SSH_KEY_SEARCH_PATH,
3560
3785
  'DEFAULT_USE_KEY': args.use_key,
3561
3786
  'DEFAULT_EXTRA_ARGS': args.extraargs,
3562
3787
  'DEFAULT_ONE_ON_ONE': args.oneonone,
@@ -3580,7 +3805,7 @@ def generate_default_config(args):
3580
3805
  'DEFAULT_NO_OUTPUT': args.no_output,
3581
3806
  'DEFAULT_RETURN_ZERO': args.return_zero,
3582
3807
  'DEFAULT_NO_ENV': args.no_env,
3583
- 'DEFAULT_ENV_FILE': args.env_file,
3808
+ 'DEFAULT_ENV_FILES': args.env_files,
3584
3809
  'DEFAULT_NO_HISTORY': args.no_history,
3585
3810
  'DEFAULT_HISTORY_FILE': args.history_file,
3586
3811
  'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
@@ -3643,12 +3868,12 @@ def get_parser():
3643
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.')
3644
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)
3645
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)
3646
- 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','--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)
3647
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)
3648
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)
3649
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)
3650
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")
3651
- 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])
3652
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)
3653
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)
3654
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")
@@ -3669,7 +3894,8 @@ def get_parser():
3669
3894
  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)
3670
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)
3671
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)
3672
- 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})")
3673
3899
  parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3674
3900
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3675
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)
@@ -3688,8 +3914,9 @@ def get_parser():
3688
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)
3689
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')
3690
3916
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3691
- 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)
3692
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.')
3693
3920
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3694
3921
  return parser
3695
3922
 
@@ -3719,7 +3946,18 @@ def process_args(args = None):
3719
3946
  args.no_history = True
3720
3947
  args.greppable = True
3721
3948
  args.error_only = True
3722
-
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
3723
3961
  if args.unavailable_host_expiry <= 0:
3724
3962
  eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
3725
3963
  args.unavailable_host_expiry = 10
@@ -3738,7 +3976,7 @@ def process_config_file(args):
3738
3976
  else:
3739
3977
  configFileToWriteTo = args.config_file
3740
3978
  write_default_config(args,configFileToWriteTo)
3741
- if not args.commands:
3979
+ if not args.commands and not args.file:
3742
3980
  if configFileToWriteTo:
3743
3981
  with open(configFileToWriteTo,'r') as f:
3744
3982
  eprint(f"Config file content: \n{f.read()}")
@@ -3780,11 +4018,40 @@ def process_keys(args):
3780
4018
  eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
3781
4019
  return args
3782
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
3783
4050
 
3784
4051
  def set_global_with_args(args):
3785
4052
  global _emo
3786
4053
  global __ipmiiInterfaceIPPrefix
3787
- global _env_file
4054
+ global _env_files
3788
4055
  global __DEBUG_MODE
3789
4056
  global __configs_from_file
3790
4057
  global _encoding
@@ -3795,7 +4062,10 @@ def set_global_with_args(args):
3795
4062
  global FORCE_TRUECOLOR
3796
4063
  _emo = False
3797
4064
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3798
- _env_file = args.env_file
4065
+ if args.env_file:
4066
+ _env_files = [args.env_file]
4067
+ else:
4068
+ _env_files = DEFAULT_ENV_FILES.extend(args.env_files) if args.env_files else DEFAULT_ENV_FILES
3799
4069
  __DEBUG_MODE = args.debug
3800
4070
  _encoding = args.encoding
3801
4071
  if args.return_zero:
@@ -3816,6 +4086,7 @@ def main():
3816
4086
  args = process_config_file(args)
3817
4087
  args = process_commands(args)
3818
4088
  args = process_keys(args)
4089
+ args = process_control_master_config(args)
3819
4090
  set_global_with_args(args)
3820
4091
 
3821
4092
  if args.use_script_timeout:
@@ -3834,7 +4105,7 @@ def main():
3834
4105
  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,
3835
4106
  copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
3836
4107
  history_file = args.history_file,
3837
- env_file = args.env_file,
4108
+ env_file = args.env_file,env_files = args.env_files,
3838
4109
  repeat = args.repeat,interval = args.interval)
3839
4110
  eprint('> ' + cmdStr)
3840
4111
  if args.error_only: