multiSSH3 5.90__py3-none-any.whl → 5.92__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
@@ -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
@@ -24,11 +25,12 @@ import string
24
25
  import subprocess
25
26
  import sys
26
27
  import tempfile
28
+ import textwrap
27
29
  import threading
28
30
  import time
29
31
  import typing
30
32
  import uuid
31
- from collections import Counter, deque
33
+ from collections import Counter, deque, defaultdict, UserDict
32
34
  from itertools import count, product
33
35
 
34
36
  __curses_available = False
@@ -83,10 +85,10 @@ except Exception:
83
85
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
84
86
  def cache_decorator(func):
85
87
  return func
86
- version = '5.90'
88
+ version = '5.92'
87
89
  VERSION = version
88
90
  __version__ = version
89
- COMMIT_DATE = '2025-10-17'
91
+ COMMIT_DATE = '2025-10-20'
90
92
 
91
93
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
92
94
  '~/multiSSH3.config.json',
@@ -152,33 +154,6 @@ def signal_handler(sig, frame):
152
154
  os.system(f'pkill -ef {os.path.basename(__file__)}')
153
155
  _exit_with_code(1, 'Exiting immediately due to Ctrl C')
154
156
 
155
- # def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
156
- # """
157
- # Read an input from the user with a timeout and a countdown.
158
-
159
- # Parameters:
160
- # timeout (int): The timeout value in seconds.
161
- # prompt (str): The prompt message to display to the user. Default is 'Please enter your selection'.
162
-
163
- # Returns:
164
- # str or None: The user input if received within the timeout, or None if no input is received.
165
- # """
166
- # import select
167
- # # Print the initial prompt with the countdown
168
- # eprint(f"{prompt} [{timeout}s]: ", end='', flush=True)
169
- # # Loop until the timeout
170
- # for remaining in range(timeout, 0, -1):
171
- # # If there is an input, return it
172
- # # this only works on linux
173
- # if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
174
- # return input().strip()
175
- # # Print the remaining time
176
- # eprint(f"\r{prompt} [{remaining}s]: ", end='', flush=True)
177
- # # Wait a second
178
- # time.sleep(1)
179
- # # If there is no input, return None
180
- # return None
181
-
182
157
  def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
183
158
  """
184
159
  Read input from the user with a timeout (cross-platform).
@@ -311,6 +286,13 @@ extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={
311
286
  identity_file={self.identity_file}, ip={self.ip}, current_color_pair={self.current_color_pair}"
312
287
  def __str__(self):
313
288
  return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
289
+ def get_output_hash(self):
290
+ return hash((
291
+ self.command,
292
+ tuple(self.stdout),
293
+ tuple(self.stderr),
294
+ self.returncode
295
+ ))
314
296
 
315
297
  #%% ------------ Load Defaults ( Config ) File ----------------
316
298
  def load_config_file(config_file):
@@ -376,6 +358,7 @@ DEFAULT_SKIP_HOSTS = ''
376
358
  DEFAULT_ENCODING = 'utf-8'
377
359
  DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
378
360
  SSH_STRICT_HOST_KEY_CHECKING = False
361
+ FORCE_TRUECOLOR = False
379
362
  ERROR_MESSAGES_TO_IGNORE = [
380
363
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
381
364
  'Connection to .* closed',
@@ -648,8 +631,6 @@ def format_commands(commands):
648
631
  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}")
649
632
  return commands
650
633
 
651
-
652
-
653
634
  class OrderedMultiSet(deque):
654
635
  """
655
636
  A deque extension with O(1) average lookup time.
@@ -666,29 +647,55 @@ class OrderedMultiSet(deque):
666
647
  self._counter[item] -= 1
667
648
  if self._counter[item] == 0:
668
649
  del self._counter[item]
669
- return self._counter.get(item, 0)
670
- def append(self, item,left=False):
650
+ def append(self, item):
671
651
  """Add item to the right end. O(1)."""
672
- removed = None
673
- if self.maxlen is not None and len(self) == self.maxlen:
674
- removed = self[-1] if left else self[0] # Item that will be removed
675
- self.__decrease_count(removed)
676
- super().appendleft(item) if left else super().append(item)
652
+ if len(self) == self.maxlen:
653
+ self.__decrease_count(self[0])
654
+ super().append(item)
677
655
  self._counter[item] += 1
678
- return removed
679
656
  def appendleft(self, item):
680
657
  """Add item to the left end. O(1)."""
681
- return self.append(item,left=True)
682
- def pop(self,left=False):
658
+ if len(self) == self.maxlen:
659
+ self.__decrease_count(self[-1])
660
+ super().appendleft(item)
661
+ self._counter[item] += 1
662
+ def pop(self):
683
663
  """Remove and return item from right end. O(1)."""
684
- if not self:
664
+ try:
665
+ item = super().pop()
666
+ self.__decrease_count(item)
667
+ return item
668
+ except IndexError:
685
669
  return None
686
- item = super().popleft() if left else super().pop()
687
- self.__decrease_count(item)
688
- return item
689
670
  def popleft(self):
690
671
  """Remove and return item from left end. O(1)."""
691
- return self.pop(left=True)
672
+ try:
673
+ item = super().popleft()
674
+ self.__decrease_count(item)
675
+ return item
676
+ except IndexError:
677
+ return None
678
+ def put(self, item):
679
+ """Alias for append, but return removed item - add to right end (FIFO put)."""
680
+ removed = None
681
+ if len(self) == self.maxlen:
682
+ removed = self[0] # Item that will be removed
683
+ self.__decrease_count(removed)
684
+ super().append(item)
685
+ self._counter[item] += 1
686
+ return removed
687
+ def put_left(self, item):
688
+ """Alias for appendleft, but return removed item - add to left end (LIFO put)."""
689
+ removed = None
690
+ if len(self) == self.maxlen:
691
+ removed = self[-1] # Item that will be removed
692
+ self.__decrease_count(removed)
693
+ super().appendleft(item)
694
+ self._counter[item] += 1
695
+ return removed
696
+ def get(self):
697
+ """Alias for popleft - remove from left end (FIFO get)."""
698
+ return self.popleft()
692
699
  def remove(self, value):
693
700
  """Remove first occurrence of value. O(n)."""
694
701
  if value not in self._counter:
@@ -701,16 +708,34 @@ class OrderedMultiSet(deque):
701
708
  self._counter.clear()
702
709
  def extend(self, iterable):
703
710
  """Extend deque by appending elements from iterable. O(k)."""
704
- for item in iterable:
705
- self.append(item)
711
+ # if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extend
712
+ try:
713
+ if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
714
+ super().extend(iterable)
715
+ self._counter.update(iterable)
716
+ elif len(iterable) >= self.maxlen:
717
+ self.clear()
718
+ if isinstance(iterable, (list, tuple)):
719
+ iterable = iterable[-self.maxlen:]
720
+ else:
721
+ iterable = itertools.islice(iterable, len(iterable) - self.maxlen, None)
722
+ super().extend(iterable)
723
+ self._counter.update(iterable)
724
+ 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())
729
+ super().extend(iterable)
730
+ self._counter.update(iterable)
731
+ except TypeError:
732
+ return self.extend(list(iterable))
706
733
  def extendleft(self, iterable):
707
734
  """Extend left side by appending elements from iterable. O(k)."""
708
735
  for item in iterable:
709
736
  self.appendleft(item)
710
737
  def rotate(self, n=1):
711
738
  """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
712
- if not self:
713
- return
714
739
  super().rotate(n)
715
740
  def __contains__(self, item):
716
741
  """Check if item exists in deque. O(1) average."""
@@ -751,22 +776,18 @@ class OrderedMultiSet(deque):
751
776
  if self.maxlen is not None:
752
777
  return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
753
778
  return f"OrderedMultiSet({list(self)})"
754
- def put(self, item,left=False):
755
- """Alias for append - add to right end (FIFO put)."""
756
- return self.append(item,left=left)
757
- def get(self,left=True):
758
- """Alias for popleft - remove from left end (FIFO get)."""
759
- return self.pop(left=left)
760
779
  def peek(self):
761
780
  """Return leftmost item without removing it."""
762
- if not self:
781
+ try:
782
+ return self[0]
783
+ except IndexError:
763
784
  return None
764
- return self[0]
765
785
  def peek_right(self):
766
786
  """Return rightmost item without removing it."""
767
- if not self:
787
+ try:
788
+ return self[-1]
789
+ except IndexError:
768
790
  return None
769
- return self[-1]
770
791
 
771
792
  def get_terminal_size():
772
793
  '''
@@ -792,6 +813,135 @@ def get_terminal_size():
792
813
  import shutil
793
814
  _tsize = shutil.get_terminal_size(fallback=(120, 30))
794
815
  return _tsize
816
+
817
+ @cache_decorator
818
+ def get_terminal_color_capability():
819
+ global FORCE_TRUECOLOR
820
+ if not sys.stdout.isatty():
821
+ return 'None'
822
+ term = os.environ.get("TERM", "")
823
+ if term == "dumb":
824
+ return 'None'
825
+ elif term == "linux":
826
+ return '8'
827
+ elif FORCE_TRUECOLOR:
828
+ return '24bit'
829
+ colorterm = os.environ.get("COLORTERM", "")
830
+ if colorterm in ("truecolor", "24bit", "24-bit"):
831
+ return '24bit'
832
+ if term in ("xterm-truecolor", "xterm-24bit", "xterm-kitty", "alacritty", "wezterm", "foot", "terminology"):
833
+ return '24bit'
834
+ elif "256" in term:
835
+ return '256'
836
+ try:
837
+ curses.setupterm()
838
+ colors = curses.tigetnum("colors")
839
+ # tigetnum returns -1 if the capability isn’t defined
840
+ if colors >= 16777216:
841
+ return '24bit'
842
+ elif colors >= 256:
843
+ return '256'
844
+ elif colors >= 16:
845
+ return '16'
846
+ elif colors > 0:
847
+ return '8'
848
+ else:
849
+ return 'None'
850
+ except Exception:
851
+ return 'None'
852
+
853
+ @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
876
+
877
+ @cache_decorator
878
+ def rgb_to_xterm_index(r, g, b):
879
+ """
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.
883
+ """
884
+ best_index = 0
885
+ 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
891
+ if dist < best_dist:
892
+ best_dist = dist
893
+ best_index = i
894
+ return best_index
895
+
896
+ @cache_decorator
897
+ def hashable_to_color(n, brightness_threshold=500):
898
+ hash_value = hash(str(n))
899
+ r = (hash_value >> 16) & 0xFF
900
+ g = (hash_value >> 8) & 0xFF
901
+ b = hash_value & 0xFF
902
+ if (r + g + b) < brightness_threshold:
903
+ return hashable_to_color(hash_value, brightness_threshold)
904
+ return (r, g, b)
905
+
906
+ __previous_ansi_color_index = -1
907
+ @cache_decorator
908
+ def string_to_unique_ansi_color(string):
909
+ '''
910
+ Convert a string to a unique ANSI color code
911
+
912
+ Args:
913
+ string (str): The string to convert
914
+
915
+ Returns:
916
+ int: The ANSI color code
917
+ '''
918
+ global __previous_ansi_color_index
919
+ # Use a hash function to generate a consistent integer from the string
920
+ color_capability = get_terminal_color_capability()
921
+ index = None
922
+ if color_capability == 'None':
923
+ 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'
942
+ else:
943
+ return f'\033[38;2;{r};{g};{b}m'
944
+
795
945
  #%% ------------ Compacting Hostnames ----------------
796
946
  def __tokenize_hostname(hostname):
797
947
  """
@@ -1136,10 +1286,15 @@ def compact_hostnames(Hostnames,verify = True):
1136
1286
  ['sub-s[1-2]']
1137
1287
  """
1138
1288
  global __global_suppress_printout
1139
- if not isinstance(Hostnames, frozenset):
1140
- hostSet = frozenset(Hostnames)
1141
- else:
1142
- hostSet = Hostnames
1289
+ # if not isinstance(Hostnames, frozenset):
1290
+ # hostSet = frozenset(Hostnames)
1291
+ # else:
1292
+ # hostSet = Hostnames
1293
+ hostSet = frozenset(
1294
+ hostname.strip()
1295
+ for hostnames_str in Hostnames
1296
+ for hostname in hostnames_str.split(',')
1297
+ )
1143
1298
  compact_hosts = __compact_hostnames(hostSet)
1144
1299
  if verify:
1145
1300
  if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
@@ -1371,51 +1526,53 @@ def __handle_reading_stream(stream,target, host,buffer:io.BytesIO):
1371
1526
  buffer.truncate(0)
1372
1527
  host.output_buffer.seek(0)
1373
1528
  host.output_buffer.truncate(0)
1374
-
1375
- for char in iter(lambda:stream.read(1), b''):
1376
- host.lastUpdateTime = time.monotonic()
1377
- if char == b'\n':
1378
- add_line(buffer,target, host)
1379
- continue
1380
- elif char == b'\r':
1381
- buffer.seek(0)
1382
- host.output_buffer.seek(0)
1383
- elif char == b'\x08':
1384
- # backspace
1385
- if buffer.tell() > 0:
1386
- buffer.seek(buffer.tell() - 1)
1387
- buffer.truncate()
1388
- if host.output_buffer.tell() > 0:
1389
- host.output_buffer.seek(host.output_buffer.tell() - 1)
1390
- host.output_buffer.truncate()
1391
- else:
1392
- # normal character
1393
- buffer.write(char)
1394
- host.output_buffer.write(char)
1395
- # 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
1396
- if buffer.tell() % 100 == 0 and buffer.tell() > 0:
1397
- try:
1398
- # try to decode the buffer to find if there are any unicode line change chars
1399
- decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
1400
- lines = decodedLine.splitlines()
1401
- if len(lines) > 1:
1402
- # if there are multiple lines, we add them to the target
1403
- for line in lines[:-1]:
1404
- # for all lines except the last one, we add them to the target
1405
- target.append(line)
1406
- host.output.append(line)
1407
- host.lineNumToPrintSet.add(len(host.output)-1)
1408
- # we keep the last line in the buffer
1409
- buffer.seek(0)
1410
- buffer.truncate(0)
1411
- buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1412
- host.output_buffer.seek(0)
1413
- host.output_buffer.truncate(0)
1414
- host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1415
-
1416
- except UnicodeDecodeError:
1417
- # if there is a unicode decode error, we just skip this character
1529
+ try:
1530
+ for char in iter(lambda:stream.read(1), b''):
1531
+ host.lastUpdateTime = time.monotonic()
1532
+ if char == b'\n':
1533
+ add_line(buffer,target, host)
1418
1534
  continue
1535
+ elif char == b'\r':
1536
+ buffer.seek(0)
1537
+ host.output_buffer.seek(0)
1538
+ elif char == b'\x08':
1539
+ # backspace
1540
+ if buffer.tell() > 0:
1541
+ buffer.seek(buffer.tell() - 1)
1542
+ buffer.truncate()
1543
+ if host.output_buffer.tell() > 0:
1544
+ host.output_buffer.seek(host.output_buffer.tell() - 1)
1545
+ host.output_buffer.truncate()
1546
+ else:
1547
+ # normal character
1548
+ buffer.write(char)
1549
+ host.output_buffer.write(char)
1550
+ # 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
1551
+ if buffer.tell() % 100 == 0 and buffer.tell() > 0:
1552
+ try:
1553
+ # try to decode the buffer to find if there are any unicode line change chars
1554
+ decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
1555
+ lines = decodedLine.splitlines()
1556
+ if len(lines) > 1:
1557
+ # if there are multiple lines, we add them to the target
1558
+ for line in lines[:-1]:
1559
+ # for all lines except the last one, we add them to the target
1560
+ target.append(line)
1561
+ host.output.append(line)
1562
+ host.lineNumToPrintSet.add(len(host.output)-1)
1563
+ # we keep the last line in the buffer
1564
+ buffer.seek(0)
1565
+ buffer.truncate(0)
1566
+ buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1567
+ host.output_buffer.seek(0)
1568
+ host.output_buffer.truncate(0)
1569
+ host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
1570
+
1571
+ except UnicodeDecodeError:
1572
+ # if there is a unicode decode error, we just skip this character
1573
+ continue
1574
+ except ValueError:
1575
+ pass
1419
1576
  if buffer.tell() > 0:
1420
1577
  # if there is still some data in the buffer, we add it to the target
1421
1578
  add_line(buffer,target, host)
@@ -1459,7 +1616,7 @@ def __handle_writing_stream(stream,stop_event,host):
1459
1616
  # host.output.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
1460
1617
  # host.stdout.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
1461
1618
  return sentInputPos
1462
-
1619
+
1463
1620
  def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1464
1621
  '''
1465
1622
  Run the command on the host. Will format the commands accordingly. Main execution function.
@@ -2524,141 +2681,218 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2524
2681
 
2525
2682
  #%% ------------ Generate Output Block ----------------
2526
2683
  def can_merge(line_bag1, line_bag2, threshold):
2527
- bag1_iter = iter(line_bag1)
2528
- found = False
2529
- for _ in range(max(int(len(line_bag1) * (1-threshold)),1)):
2530
- try:
2531
- item = next(bag1_iter)
2532
- except StopIteration:
2533
- break
2534
- if item in line_bag2:
2535
- found = True
2536
- break
2537
- if not found:
2538
- return False
2539
- return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
2684
+ if threshold > 0.5:
2685
+ samples = itertools.islice(line_bag1, max(int(len(line_bag1) * (1 - threshold)),1))
2686
+ if not line_bag2.intersection(samples):
2687
+ return False
2688
+ return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
2540
2689
 
2541
- def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold):
2542
- terminal_length = get_terminal_size()[0]
2543
- output.append(('├'+'─'*(terminal_length-1)))
2690
+ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2544
2691
  indexes = {hostname: 0 for hostname in merging_hostnames}
2545
- working_indexes = indexes.copy()
2692
+ working_index_keys = set(indexes.keys())
2546
2693
  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='│- ')
2695
+ hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2696
+ diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
2697
+ def get_multiset_index_for_hostname(hostname):
2698
+ index = indexes[hostname]
2699
+ 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]
2701
+ # 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()
2713
+ currentLines = defaultdict(set)
2714
+ for hostname in merging_hostnames:
2715
+ currentLines[outputs_by_hostname[hostname][0]].add(hostname)
2547
2716
  while indexes:
2548
- futures = {}
2549
2717
  defer = False
2550
- sorted_working_indexes = sorted(working_indexes.items(), key=lambda x: x[1])
2551
- golden_hostname, golden_index = sorted_working_indexes[0]
2552
- buddy = {golden_hostname}
2718
+ # sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
2719
+ golden_hostname = min(working_index_keys, key=lambda hn: indexes[hn])
2720
+ golden_index = indexes[golden_hostname]
2553
2721
  lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2554
- for hostname, index in sorted_working_indexes[1:]:
2555
- if lineToAdd == outputs_by_hostname[hostname][index]:
2556
- buddy.add(hostname)
2557
- else:
2558
- if hostname not in futures:
2559
- diff_display_item_count = max(int(len(outputs_by_hostname[hostname]) * (1 - diff_display_threshold)),1)
2560
- tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2561
- futures[hostname] = (OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index)
2562
- if lineToAdd in futures[hostname]:
2563
- for hn in buddy:
2564
- del working_indexes[hn]
2565
- defer = True
2566
- break
2722
+ # for hostname, index in sorted_working_indexes[1:]:
2723
+ # if lineToAdd == outputs_by_hostname[hostname][index]:
2724
+ # buddy.add(hostname)
2725
+ # else:
2726
+ # futureLines,tracking_index = futures[hostname]
2727
+ # if lineToAdd in futureLines:
2728
+ # for hn in buddy:
2729
+ # working_indexes.pop(hn,None)
2730
+ # defer = True
2731
+ # break
2732
+ buddy = currentLines[lineToAdd].copy()
2733
+ if len(buddy) < len(working_index_keys):
2734
+ # we need to check the futures then
2735
+ # thisCounter = None
2736
+ # if golden_hostname in futures:
2737
+ # thisCounter = futures[golden_hostname][0]._counter
2738
+ # futuresChainMap.maps.remove(thisCounter)
2739
+ for hostname in working_index_keys - buddy - set(futures.keys()):
2740
+ futures[hostname] # ensure it's initialized
2741
+ # futures.initializeHostnames(working_index_keys - buddy - futures.keys())
2742
+ if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
2743
+ defer = True
2744
+ working_index_keys -= buddy
2745
+ # if thisCounter is not None:
2746
+ # futuresChainMap.maps.append(thisCounter)
2567
2747
  if not defer:
2568
2748
  if buddy != previousBuddies:
2569
- output.append(f"├─ {','.join(compact_hostnames(buddy))}")
2749
+ hostnameStr = ','.join(compact_hostnames(buddy))
2750
+ 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]}"
2754
+ output.extend(hostnameLines)
2570
2755
  previousBuddies = buddy
2571
- output.append(lineToAdd)
2756
+ output.append(lineToAdd.ljust(line_length - 1) + '│')
2757
+ currentLines[lineToAdd].difference_update(buddy)
2758
+ if not currentLines[lineToAdd]:
2759
+ del currentLines[lineToAdd]
2572
2760
  for hostname in buddy:
2761
+ # currentLines[lineToAdd].remove(hostname)
2762
+ # if not currentLines[lineToAdd]:
2763
+ # del currentLines[lineToAdd]
2573
2764
  indexes[hostname] += 1
2574
- if indexes[hostname] >= len(outputs_by_hostname[hostname]):
2765
+ try:
2766
+ currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
2767
+ except IndexError:
2575
2768
  indexes.pop(hostname, None)
2576
2769
  futures.pop(hostname, None)
2770
+ # if future:
2771
+ # futuresChainMap.maps.remove(future[0]._counter)
2577
2772
  continue
2578
2773
  #advance futures
2579
2774
  if hostname in futures:
2775
+ futures[hostname][1] += 1
2580
2776
  tracking_multiset, tracking_index = futures[hostname]
2581
- tracking_index += 1
2582
2777
  if tracking_index < len(outputs_by_hostname[hostname]):
2583
2778
  line = outputs_by_hostname[hostname][tracking_index]
2584
2779
  tracking_multiset.append(line)
2585
2780
  else:
2586
- tracking_multiset.pop_left()
2587
- futures[hostname] = (tracking_multiset, tracking_index)
2588
- working_indexes = indexes.copy()
2781
+ tracking_multiset.popleft()
2782
+ #futures[hostname] = (tracking_multiset, tracking_index)
2783
+ working_index_keys = set(indexes.keys())
2589
2784
 
2590
- def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold):
2591
- terminal_length = get_terminal_size()[0]
2785
+ def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2592
2786
  output = []
2787
+ output.append(('┌'+'─'*(line_length-2) + '┐'))
2593
2788
  for merging_hostnames in merge_groups:
2594
- mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold)
2789
+ mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2790
+ output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2595
2791
  for hostname in remaining_hostnames:
2596
- output.append('├'+'─'*(terminal_length-1))
2597
- output.append(f"├─ {hostname}")
2598
- output.extend(outputs_by_hostname[hostname])
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) + '┤')
2797
+ if output:
2798
+ output.pop()
2799
+ # if output and output[0] and output[0].startswith('├'):
2800
+ # output[0] = '┌' + output[0][1:]
2599
2801
  return output
2600
2802
 
2601
- def get_host_raw_output(hosts):
2803
+ def pre_merge_hosts(hosts):
2804
+ '''Merge hosts with identical outputs.'''
2805
+ output_groups = defaultdict(list)
2806
+ # Group hosts by their output identity
2807
+ for host in hosts:
2808
+ identity = host.get_output_hash()
2809
+ output_groups[identity].append(host)
2810
+ # Create merged hosts
2811
+ merged_hosts = []
2812
+ for group in output_groups.values():
2813
+ group[0].name = ','.join(host.name for host in group)
2814
+ merged_hosts.append(group[0])
2815
+ return merged_hosts
2816
+
2817
+ def get_host_raw_output(hosts, terminal_width):
2602
2818
  outputs_by_hostname = {}
2603
2819
  line_bag_by_hostname = {}
2604
2820
  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='│-')
2823
+ max_length = 20
2824
+ hosts = pre_merge_hosts(hosts)
2605
2825
  for host in hosts:
2606
- hostPrintOut = ["│█ EXECUTED COMMAND"]
2607
- hostPrintOut.extend(['│ ' + line for line in host['command'].splitlines()])
2608
- lineBag = {(0,host['command'])}
2609
- prevLine = host['command']
2610
- if host['stdout']:
2826
+ hostPrintOut = ["│█ EXECUTED COMMAND:"]
2827
+ for line in host.command.splitlines():
2828
+ hostPrintOut.extend(text_wrapper.wrap(line))
2829
+ # hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
2830
+ lineBag = {(0,host.command)}
2831
+ prevLine = host.command
2832
+ if host.stdout:
2611
2833
  hostPrintOut.append('│▓ STDOUT:')
2612
- hostPrintOut.extend(['│ ' + line for line in host['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))
2839
+ # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2613
2840
  lineBag.add((prevLine,1))
2614
- lineBag.add((1,host['stdout'][0]))
2615
- if len(host['stdout']) > 1:
2616
- lineBag.update(zip(host['stdout'], host['stdout'][1:]))
2617
- lineBag.update(host['stdout'])
2618
- prevLine = host['stdout'][-1]
2619
- if host['stderr']:
2620
- if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2621
- host['stderr'][0] = 'SSH not reachable!'
2622
- elif host['stderr'][-1].strip().endswith('Connection timed out'):
2623
- host['stderr'][-1] = 'SSH connection timed out!'
2624
- elif host['stderr'][-1].strip().endswith('No route to host'):
2625
- host['stderr'][-1] = 'Cannot find host!'
2626
- if host['stderr']:
2841
+ lineBag.add((1,host.stdout[0]))
2842
+ if len(host.stdout) > 1:
2843
+ lineBag.update(zip(host.stdout, host.stdout[1:]))
2844
+ lineBag.update(host.stdout)
2845
+ prevLine = host.stdout[-1]
2846
+ if host.stderr:
2847
+ if host.stderr[0].strip().startswith('ssh: connect to host ') and host.stderr[0].strip().endswith('Connection refused'):
2848
+ host.stderr[0] = 'SSH not reachable!'
2849
+ elif host.stderr[-1].strip().endswith('Connection timed out'):
2850
+ host.stderr[-1] = 'SSH connection timed out!'
2851
+ elif host.stderr[-1].strip().endswith('No route to host'):
2852
+ host.stderr[-1] = 'Cannot find host!'
2853
+ if host.stderr:
2627
2854
  hostPrintOut.append('│▒ STDERR:')
2628
- hostPrintOut.extend(['│ ' + line for line in host['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))
2629
2860
  lineBag.add((prevLine,2))
2630
- lineBag.add((2,host['stderr'][0]))
2631
- lineBag.update(host['stderr'])
2632
- if len(host['stderr']) > 1:
2633
- lineBag.update(zip(host['stderr'], host['stderr'][1:]))
2634
- prevLine = host['stderr'][-1]
2635
- hostPrintOut.append(f"│░ RETURN CODE: {host['returncode']}")
2636
- lineBag.add((prevLine,f"{host['returncode']}"))
2637
- outputs_by_hostname[host['name']] = hostPrintOut
2638
- line_bag_by_hostname[host['name']] = lineBag
2639
- hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
2640
- return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len)
2861
+ lineBag.add((2,host.stderr[0]))
2862
+ lineBag.update(host.stderr)
2863
+ if len(host.stderr) > 1:
2864
+ lineBag.update(zip(host.stderr, host.stderr[1:]))
2865
+ prevLine = host.stderr[-1]
2866
+ hostPrintOut.append(f"│░ RETURN CODE: {host.returncode}")
2867
+ lineBag.add((prevLine,f"{host.returncode}"))
2868
+ max_length = max(max_length, max(map(len, hostPrintOut)))
2869
+ outputs_by_hostname[host.name] = hostPrintOut
2870
+ line_bag_by_hostname[host.name] = lineBag
2871
+ hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
2872
+ 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)
2641
2873
 
2642
2874
  def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
2643
2875
  merge_groups = []
2644
- for line_bag_len in hostnames_by_line_bag_len.copy():
2876
+ remaining_hostnames = set()
2877
+ for lbl_i, line_bag_len in enumerate(sorted_hostnames_by_line_bag_len_keys):
2645
2878
  for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
2646
- if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
2879
+ # if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
2880
+ # continue
2881
+ try:
2882
+ this_line_bag = line_bag_by_hostname.pop(this_hostname)
2883
+ hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
2884
+ except KeyError:
2647
2885
  continue
2648
- this_line_bag = line_bag_by_hostname[this_hostname]
2649
2886
  target_threshold = line_bag_len * (2 - diff_display_threshold)
2650
2887
  merge_group = []
2651
- for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
2888
+ for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys[lbl_i:]:
2652
2889
  if other_line_bag_len > target_threshold:
2653
2890
  break
2654
- if other_line_bag_len < line_bag_len:
2655
- continue
2891
+ # if other_line_bag_len < line_bag_len:
2892
+ # continue
2656
2893
  for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
2657
- if this_hostname == other_hostname:
2658
- continue
2659
2894
  if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
2660
2895
  merge_group.append(other_hostname)
2661
- hostnames_by_line_bag_len[line_bag_len].discard(this_hostname)
2662
2896
  hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
2663
2897
  if not hostnames_by_line_bag_len[other_line_bag_len]:
2664
2898
  del hostnames_by_line_bag_len[other_line_bag_len]
@@ -2666,23 +2900,24 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
2666
2900
  if merge_group:
2667
2901
  merge_group.append(this_hostname)
2668
2902
  merge_groups.append(merge_group)
2669
- return merge_groups
2903
+ # del line_bag_by_hostname[this_hostname]
2904
+ else:
2905
+ remaining_hostnames.add(this_hostname)
2906
+ return merge_groups, remaining_hostnames
2670
2907
 
2671
2908
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
2672
2909
  if quiet:
2673
2910
  # remove hosts with returncode 0
2674
- hosts = [dict(host) for host in hosts if host.returncode != 0]
2911
+ hosts = [host for host in hosts if host.returncode != 0]
2675
2912
  if not hosts:
2676
2913
  if usejson:
2677
2914
  return '{"Success": true}'
2678
2915
  else:
2679
2916
  return 'Success'
2680
- else:
2681
- hosts = [dict(host) for host in hosts]
2682
2917
  if usejson:
2683
2918
  # [print(dict(host)) for host in hosts]
2684
2919
  #print(json.dumps([dict(host) for host in hosts],indent=4))
2685
- rtnStr = json.dumps(hosts,indent=4)
2920
+ rtnStr = json.dumps([dict(host) for host in hosts],indent=4)
2686
2921
  elif greppable:
2687
2922
  # transform hosts to a 2d list
2688
2923
  rtnStr = '*'*80+'\n'
@@ -2690,14 +2925,14 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2690
2925
  for host in hosts:
2691
2926
  #header = f"{host['name']} | rc: {host['returncode']} | "
2692
2927
  hostAdded = False
2693
- for line in host['stdout']:
2694
- rtnList.append([host['name'],f"rc: {host['returncode']}",'stdout',line])
2928
+ for line in host.stdout:
2929
+ rtnList.append([host.name,f"rc: {host.returncode}",'stdout',line])
2695
2930
  hostAdded = True
2696
- for line in host['stderr']:
2697
- rtnList.append([host['name'],f"rc: {host['returncode']}",'stderr',line])
2931
+ for line in host.stderr:
2932
+ rtnList.append([host.name,f"rc: {host.returncode}",'stderr',line])
2698
2933
  hostAdded = True
2699
2934
  if not hostAdded:
2700
- rtnList.append([host['name'],f"rc: {host['returncode']}",'N/A','<EMPTY>'])
2935
+ rtnList.append([host.name,f"rc: {host.returncode}",'N/A','<EMPTY>'])
2701
2936
  rtnList.append(['','','',''])
2702
2937
  rtnStr += pretty_format_table(rtnList)
2703
2938
  rtnStr += '*'*80+'\n'
@@ -2714,22 +2949,22 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2714
2949
  eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2715
2950
  diff_display_threshold = 0.9
2716
2951
  terminal_length = get_terminal_size()[0]
2717
- outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys = get_host_raw_output(hosts)
2718
- merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
2719
- # get the remaining hostnames in the hostnames_by_line_bag_len
2720
- remaining_hostnames = set()
2721
- for hostnames in hostnames_by_line_bag_len.values():
2722
- remaining_hostnames.update(hostnames)
2723
- outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold)
2952
+ 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
+ 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
+ outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
2724
2955
  if keyPressesIn[-1]:
2725
2956
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2726
- outputs.append("├─ User Inputs:".ljust(terminal_length-1,'─'))
2727
- outputs.extend(CMDsOut)
2957
+ outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
2958
+ cmdOut = []
2959
+ for line in CMDsOut:
2960
+ 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)
2728
2963
  keyPressesIn[-1].clear()
2729
- if quiet and not outputs:
2730
- rtnStr = 'Success'
2964
+ if not outputs:
2965
+ rtnStr = 'Success' if quiet else ''
2731
2966
  else:
2732
- rtnStr = '\n'.join(outputs + [(''+'─'*(terminal_length-1))])
2967
+ rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
2733
2968
  return rtnStr
2734
2969
 
2735
2970
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -2747,6 +2982,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
2747
2982
  global __global_suppress_printout
2748
2983
  global _encoding
2749
2984
  global __keyPressesIn
2985
+ for host in hosts:
2986
+ host.output.clear()
2750
2987
  rtnStr = generate_output(hosts,usejson,greppable,quiet=__global_suppress_printout,encoding=_encoding,keyPressesIn=__keyPressesIn)
2751
2988
  if not quiet:
2752
2989
  print(rtnStr)
@@ -3356,6 +3593,7 @@ def generate_default_config(args):
3356
3593
  'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
3357
3594
  'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
3358
3595
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
3596
+ 'FORCE_TRUECOLOR': args.force_truecolor,
3359
3597
  }
3360
3598
 
3361
3599
  def write_default_config(args,CONFIG_FILE = None):
@@ -3399,8 +3637,8 @@ def write_default_config(args,CONFIG_FILE = None):
3399
3637
  #%% ------------ Argument Processing -----------------
3400
3638
  def get_parser():
3401
3639
  global _binPaths
3402
- 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}',
3403
- epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}')
3640
+ parser = argparse.ArgumentParser(description='Run a command on multiple hosts, Use #HOST# or #HOSTNAME# to replace the host name in the command.',
3641
+ 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}',)
3404
3642
  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)
3405
3643
  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.')
3406
3644
  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)
@@ -3451,6 +3689,7 @@ def get_parser():
3451
3689
  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')
3452
3690
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3453
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)
3692
+ 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)
3454
3693
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3455
3694
  return parser
3456
3695
 
@@ -3553,6 +3792,7 @@ def set_global_with_args(args):
3553
3792
  global DEFAULT_IPMI_USERNAME
3554
3793
  global DEFAULT_IPMI_PASSWORD
3555
3794
  global DEFAULT_DIFF_DISPLAY_THRESHOLD
3795
+ global FORCE_TRUECOLOR
3556
3796
  _emo = False
3557
3797
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3558
3798
  _env_file = args.env_file
@@ -3565,6 +3805,7 @@ def set_global_with_args(args):
3565
3805
  if args.ipmi_password:
3566
3806
  DEFAULT_IPMI_PASSWORD = args.ipmi_password
3567
3807
  DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
3808
+ FORCE_TRUECOLOR = args.force_truecolor
3568
3809
 
3569
3810
  #%% ------------ Wrapper Block ----------------
3570
3811
  def main():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.90
3
+ Version: 5.92
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -0,0 +1,6 @@
1
+ multiSSH3.py,sha256=Qd1CLxhgZIpxtyVZGCHvG0WgWMRoWG2MMOYc9C4XgdA,175456
2
+ multissh3-5.92.dist-info/METADATA,sha256=5_7RfWwwIAQF25p_sQf46j0oKqfTVCYwzPcjo1fsFsk,18093
3
+ multissh3-5.92.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ multissh3-5.92.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
+ multissh3-5.92.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
+ multissh3-5.92.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- multiSSH3.py,sha256=xvYajOU_FiCfgYlGcO7AjJofnGgAj01DHarCIUeXiqU,166815
2
- multissh3-5.90.dist-info/METADATA,sha256=0kE8yqDXIrcrWqRnhgqeiZ1X3U8-odE2r6QY8uIDZKk,18093
3
- multissh3-5.90.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- multissh3-5.90.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
- multissh3-5.90.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
- multissh3-5.90.dist-info/RECORD,,