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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

multiSSH3.py CHANGED
@@ -6,6 +6,32 @@
6
6
  # "ipaddress",
7
7
  # ]
8
8
  # ///
9
+ import argparse
10
+ import functools
11
+ import getpass
12
+ import glob
13
+ import io
14
+ import ipaddress
15
+ import json
16
+ import math
17
+ import os
18
+ import queue
19
+ import re
20
+ import shutil
21
+ import signal
22
+ import socket
23
+ import string
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import textwrap
28
+ import threading
29
+ import time
30
+ import typing
31
+ import uuid
32
+ from collections import Counter, deque
33
+ from itertools import count, product
34
+
9
35
  __curses_available = False
10
36
  __resource_lib_available = False
11
37
  try:
@@ -20,30 +46,7 @@ try:
20
46
  except ImportError:
21
47
  pass
22
48
 
23
- import subprocess
24
- import threading
25
- import time
26
- import os
27
- import argparse
28
- from itertools import product
29
- import re
30
- import string
31
- import ipaddress
32
- import sys
33
- import json
34
- import socket
35
- import io
36
- import signal
37
- import functools
38
- import glob
39
- import shutil
40
- import getpass
41
- import uuid
42
- import tempfile
43
- import math
44
- from itertools import count
45
- import queue
46
- import typing
49
+
47
50
  try:
48
51
  # Check if functiools.cache is available
49
52
  # cache_decorator = functools.cache
@@ -76,15 +79,15 @@ try:
76
79
  wrapper.cache_clear = cached_func.cache_clear
77
80
  return wrapper
78
81
  return decorating_function(user_function)
79
- except :
82
+ except Exception:
80
83
  # If lrucache is not available, use a dummy decorator
81
84
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
82
85
  def cache_decorator(func):
83
86
  return func
84
- version = '5.86'
87
+ version = '5.91'
85
88
  VERSION = version
86
89
  __version__ = version
87
- COMMIT_DATE = '2025-10-07'
90
+ COMMIT_DATE = '2025-10-17'
88
91
 
89
92
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
90
93
  '~/multiSSH3.config.json',
@@ -93,16 +96,22 @@ CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
96
  '/etc/multiSSH3.d/multiSSH3.config.json',
94
97
  '/etc/multiSSH3.config.json'] # The first one has the highest priority
95
98
 
99
+ ERRORS = []
96
100
 
97
101
  # TODO: Add terminal TUI
98
102
 
99
103
  #%% ------------ Pre Helper Functions ----------------
100
104
  def eprint(*args, **kwargs):
105
+ global ERRORS
101
106
  try:
102
- print(*args, file=sys.stderr, **kwargs)
107
+ if 'file' in kwargs:
108
+ print(*args, **kwargs)
109
+ else:
110
+ print(*args, file=sys.stderr, **kwargs)
103
111
  except Exception as e:
104
112
  print(f"Error: Cannot print to stderr: {e}")
105
113
  print(*args, **kwargs)
114
+ ERRORS.append(' '.join(map(str,args)))
106
115
 
107
116
  def _exit_with_code(code, message=None):
108
117
  '''
@@ -247,7 +256,7 @@ def getIP(hostname: str,local=False):
247
256
  # Then we check the DNS
248
257
  try:
249
258
  return socket.gethostbyname(hostname)
250
- except:
259
+ except Exception:
251
260
  return None
252
261
 
253
262
 
@@ -320,8 +329,8 @@ def load_config_file(config_file):
320
329
  try:
321
330
  with open(config_file,'r') as f:
322
331
  config = json.load(f)
323
- except:
324
- eprint(f"Error: Cannot load config file {config_file!r}")
332
+ except Exception as e:
333
+ eprint(f"Error: Cannot load config file {config_file!r}: {e}")
325
334
  return {}
326
335
  return config
327
336
 
@@ -346,6 +355,8 @@ DEFAULT_INTERVAL = 0
346
355
  DEFAULT_IPMI = False
347
356
  DEFAULT_IPMI_INTERFACE_IP_PREFIX = ''
348
357
  DEFAULT_INTERFACE_IP_PREFIX = None
358
+ DEFAULT_IPMI_USERNAME = 'ADMIN'
359
+ DEFAULT_IPMI_PASSWORD = ''
349
360
  DEFAULT_NO_WATCH = False
350
361
  DEFAULT_CURSES_MINIMUM_CHAR_LEN = 40
351
362
  DEFAULT_CURSES_MINIMUM_LINE_LEN = 1
@@ -364,7 +375,9 @@ DEFAULT_GREPPABLE_MODE = False
364
375
  DEFAULT_SKIP_UNREACHABLE = True
365
376
  DEFAULT_SKIP_HOSTS = ''
366
377
  DEFAULT_ENCODING = 'utf-8'
378
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
367
379
  SSH_STRICT_HOST_KEY_CHECKING = False
380
+ FORCE_TRUECOLOR = False
368
381
  ERROR_MESSAGES_TO_IGNORE = [
369
382
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
370
383
  'Connection to .* closed',
@@ -492,7 +505,7 @@ def readEnvFromFile(environemnt_file = ''):
492
505
  try:
493
506
  if env:
494
507
  return env
495
- except:
508
+ except Exception:
496
509
  env = {}
497
510
  global _env_file
498
511
  if environemnt_file:
@@ -633,10 +646,283 @@ def format_commands(commands):
633
646
  # reformat commands into a list of strings, join the iterables if they are not strings
634
647
  try:
635
648
  commands = [' '.join(command) if not isinstance(command,str) else command for command in commands]
636
- except:
637
- eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands!r} to a list of strings. Continuing anyway but expect failures.")
649
+ except Exception as e:
650
+ eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands!r} to a list of strings. Continuing anyway but expect failures. Error: {e}")
638
651
  return commands
639
652
 
653
+
654
+
655
+ class OrderedMultiSet(deque):
656
+ """
657
+ A deque extension with O(1) average lookup time.
658
+ Maintains all deque functionality while tracking item counts.
659
+ """
660
+ def __init__(self, iterable=None, maxlen=None):
661
+ """Initialize with optional iterable and maxlen."""
662
+ super().__init__(maxlen=maxlen)
663
+ self._counter = Counter()
664
+ if iterable is not None:
665
+ self.extend(iterable)
666
+ def __decrease_count(self, item):
667
+ """Decrease count of item in counter."""
668
+ self._counter[item] -= 1
669
+ if self._counter[item] == 0:
670
+ del self._counter[item]
671
+ return self._counter.get(item, 0)
672
+ def append(self, item,left=False):
673
+ """Add item to the right end. O(1)."""
674
+ removed = None
675
+ if self.maxlen is not None and len(self) == self.maxlen:
676
+ removed = self[-1] if left else self[0] # Item that will be removed
677
+ self.__decrease_count(removed)
678
+ super().appendleft(item) if left else super().append(item)
679
+ self._counter[item] += 1
680
+ return removed
681
+ def appendleft(self, item):
682
+ """Add item to the left end. O(1)."""
683
+ return self.append(item,left=True)
684
+ def pop(self,left=False):
685
+ """Remove and return item from right end. O(1)."""
686
+ if not self:
687
+ return None
688
+ item = super().popleft() if left else super().pop()
689
+ self.__decrease_count(item)
690
+ return item
691
+ def popleft(self):
692
+ """Remove and return item from left end. O(1)."""
693
+ return self.pop(left=True)
694
+ def remove(self, value):
695
+ """Remove first occurrence of value. O(n)."""
696
+ if value not in self._counter:
697
+ return None
698
+ super().remove(value)
699
+ self.__decrease_count(value)
700
+ def clear(self):
701
+ """Remove all items. O(1)."""
702
+ super().clear()
703
+ self._counter.clear()
704
+ def extend(self, iterable):
705
+ """Extend deque by appending elements from iterable. O(k)."""
706
+ for item in iterable:
707
+ self.append(item)
708
+ def extendleft(self, iterable):
709
+ """Extend left side by appending elements from iterable. O(k)."""
710
+ for item in iterable:
711
+ self.appendleft(item)
712
+ def rotate(self, n=1):
713
+ """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
714
+ if not self:
715
+ return
716
+ super().rotate(n)
717
+ def __contains__(self, item):
718
+ """Check if item exists in deque. O(1) average."""
719
+ return item in self._counter
720
+ def count(self, item):
721
+ """Return number of occurrences of item. O(1)."""
722
+ return self._counter[item]
723
+ def __setitem__(self, index, value):
724
+ """Set item at index. O(1) for access, O(1) for counter update."""
725
+ old_value = self[index]
726
+ super().__setitem__(index, value)
727
+ self.__decrease_count(old_value)
728
+ self._counter[value] += 1
729
+ return old_value
730
+ def __delitem__(self, index):
731
+ """Delete item at index. O(n) for deletion, O(1) for counter update."""
732
+ value = self[index]
733
+ super().__delitem__(index)
734
+ self.__decrease_count(value)
735
+ return value
736
+ def insert(self, index, value):
737
+ """Insert value at index. O(n) for insertion, O(1) for counter update."""
738
+ super().insert(index, value)
739
+ self._counter[value] += 1
740
+ def reverse(self):
741
+ """Reverse deque in place. O(n)."""
742
+ super().reverse()
743
+ def copy(self):
744
+ """Create a shallow copy. O(n)."""
745
+ new_deque = OrderedMultiSet(maxlen=self.maxlen)
746
+ new_deque.extend(self)
747
+ return new_deque
748
+ def __copy__(self):
749
+ """Support for copy.copy()."""
750
+ return self.copy()
751
+ def __repr__(self):
752
+ """String representation."""
753
+ if self.maxlen is not None:
754
+ return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
755
+ return f"OrderedMultiSet({list(self)})"
756
+ def put(self, item,left=False):
757
+ """Alias for append - add to right end (FIFO put)."""
758
+ return self.append(item,left=left)
759
+ def get(self,left=True):
760
+ """Alias for popleft - remove from left end (FIFO get)."""
761
+ return self.pop(left=left)
762
+ def peek(self):
763
+ """Return leftmost item without removing it."""
764
+ if not self:
765
+ return None
766
+ return self[0]
767
+ def peek_right(self):
768
+ """Return rightmost item without removing it."""
769
+ if not self:
770
+ return None
771
+ return self[-1]
772
+
773
+ def get_terminal_size():
774
+ '''
775
+ Get the terminal size
776
+
777
+ @params:
778
+ None
779
+
780
+ @returns:
781
+ (int,int): the number of columns and rows of the terminal
782
+ '''
783
+ try:
784
+ import os
785
+ _tsize = os.get_terminal_size()
786
+ except Exception:
787
+ try:
788
+ import fcntl
789
+ import struct
790
+ import termios
791
+ packed = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
792
+ _tsize = struct.unpack('HHHH', packed)[:2]
793
+ except Exception:
794
+ import shutil
795
+ _tsize = shutil.get_terminal_size(fallback=(120, 30))
796
+ return _tsize
797
+
798
+ @cache_decorator
799
+ def get_terminal_color_capability():
800
+ global FORCE_TRUECOLOR
801
+ if not sys.stdout.isatty():
802
+ return 'None'
803
+ term = os.environ.get("TERM", "")
804
+ if term == "dumb":
805
+ return 'None'
806
+ elif term == "linux":
807
+ return '8'
808
+ elif FORCE_TRUECOLOR:
809
+ return '24bit'
810
+ colorterm = os.environ.get("COLORTERM", "")
811
+ if colorterm in ("truecolor", "24bit", "24-bit"):
812
+ return '24bit'
813
+ if term in ("xterm-truecolor", "xterm-24bit", "xterm-kitty", "alacritty", "wezterm", "foot", "terminology"):
814
+ return '24bit'
815
+ elif "256" in term:
816
+ return '256'
817
+ try:
818
+ curses.setupterm()
819
+ colors = curses.tigetnum("colors")
820
+ # tigetnum returns -1 if the capability isn’t defined
821
+ if colors >= 16777216:
822
+ return '24bit'
823
+ elif colors >= 256:
824
+ return '256'
825
+ elif colors >= 16:
826
+ return '16'
827
+ elif colors > 0:
828
+ return '8'
829
+ else:
830
+ return 'None'
831
+ except Exception:
832
+ return 'None'
833
+
834
+ @cache_decorator
835
+ def get_xterm256_palette():
836
+ palette = []
837
+ # 0–15: system colors (we'll just fill with dummy values;
838
+ # you could fill in real RGB if you need to)
839
+ system_colors = [
840
+ (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
841
+ (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
842
+ (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
843
+ (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
844
+ ]
845
+ palette.extend(system_colors)
846
+ # 16–231: 6x6x6 color cube
847
+ levels = [0, 95, 135, 175, 215, 255]
848
+ for r in levels:
849
+ for g in levels:
850
+ for b in levels:
851
+ palette.append((r, g, b))
852
+ # 232–255: grayscale ramp, 24 steps from 8 to 238
853
+ for i in range(24):
854
+ level = 8 + i * 10
855
+ palette.append((level, level, level))
856
+ return palette
857
+
858
+ @cache_decorator
859
+ def rgb_to_xterm_index(r, g, b):
860
+ """
861
+ Map 24-bit RGB to nearest xterm-256 color index.
862
+ r, g, b should be in 0-255.
863
+ Returns an int in 0-255.
864
+ """
865
+ best_index = 0
866
+ best_dist = float('inf')
867
+ for i, (pr, pg, pb) in enumerate(get_xterm256_palette()):
868
+ dr = pr - r
869
+ dg = pg - g
870
+ db = pb - b
871
+ dist = dr*dr + dg*dg + db*db
872
+ if dist < best_dist:
873
+ best_dist = dist
874
+ best_index = i
875
+ return best_index
876
+
877
+ @cache_decorator
878
+ def hashable_to_color(n, brightness_threshold=500):
879
+ hash_value = hash(str(n))
880
+ r = (hash_value >> 16) & 0xFF
881
+ g = (hash_value >> 8) & 0xFF
882
+ b = hash_value & 0xFF
883
+ if (r + g + b) < brightness_threshold:
884
+ return hashable_to_color(hash_value, brightness_threshold)
885
+ return (r, g, b)
886
+
887
+ __previous_ansi_color_index = -1
888
+ @cache_decorator
889
+ def string_to_unique_ansi_color(string):
890
+ '''
891
+ Convert a string to a unique ANSI color code
892
+
893
+ Args:
894
+ string (str): The string to convert
895
+
896
+ Returns:
897
+ int: The ANSI color code
898
+ '''
899
+ global __previous_ansi_color_index
900
+ # Use a hash function to generate a consistent integer from the string
901
+ color_capability = get_terminal_color_capability()
902
+ index = None
903
+ if color_capability == 'None':
904
+ return ''
905
+ elif color_capability == '16':
906
+ # Map to one of the 14 colors (31-37, 90-96), avoiding black and white
907
+ index = (hash(string) % 14) + 31
908
+ if index > 37:
909
+ index += 52 # Bright colors (90-97)
910
+ elif color_capability == '8':
911
+ index = (hash(string) % 6) + 31
912
+ r,g,b = hashable_to_color(string)
913
+ if color_capability == '256':
914
+ index = rgb_to_xterm_index(r,g,b)
915
+ if index:
916
+ if index == __previous_ansi_color_index:
917
+ return string_to_unique_ansi_color(hash(string))
918
+ __previous_ansi_color_index = index
919
+ if color_capability == '256':
920
+ return f'\033[38;5;{index}m'
921
+ else:
922
+ return f'\033[{index}m'
923
+ else:
924
+ return f'\033[38;2;{r};{g};{b}m'
925
+
640
926
  #%% ------------ Compacting Hostnames ----------------
641
927
  def __tokenize_hostname(hostname):
642
928
  """
@@ -1323,6 +1609,11 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1323
1609
  global __ipmiiInterfaceIPPrefix
1324
1610
  global _binPaths
1325
1611
  global __DEBUG_MODE
1612
+ global DEFAULT_IPMI_USERNAME
1613
+ global DEFAULT_IPMI_PASSWORD
1614
+ global DEFAULT_USERNAME
1615
+ global DEFAULT_PASSWORD
1616
+ global SSH_STRICT_HOST_KEY_CHECKING
1326
1617
  if retry_limit < 0:
1327
1618
  host.output.append('Error: Retry limit reached!')
1328
1619
  host.stderr.append('Error: Retry limit reached!')
@@ -1366,7 +1657,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1366
1657
  host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
1367
1658
  host.resolvedName = host.username + '@' if host.username else ''
1368
1659
  host.resolvedName += host.address
1369
- except:
1660
+ except Exception:
1370
1661
  host.resolvedName = host.name
1371
1662
  else:
1372
1663
  host.resolvedName = host.name
@@ -1378,22 +1669,27 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1378
1669
  host.command = host.command.replace('ipmitool ','')
1379
1670
  elif host.command.startswith(_binPaths['ipmitool']):
1380
1671
  host.command = host.command.replace(_binPaths['ipmitool'],'')
1381
- if not host.username:
1382
- host.username = 'admin'
1672
+ if not host.username or host.username == DEFAULT_USERNAME:
1673
+ if DEFAULT_IPMI_USERNAME:
1674
+ host.username = DEFAULT_IPMI_USERNAME
1675
+ elif DEFAULT_USERNAME:
1676
+ host.username = DEFAULT_USERNAME
1677
+ else:
1678
+ host.username = 'ADMIN'
1679
+ if not passwds or passwds == DEFAULT_PASSWORD:
1680
+ if DEFAULT_IPMI_PASSWORD:
1681
+ passwds = DEFAULT_IPMI_PASSWORD
1682
+ elif DEFAULT_PASSWORD:
1683
+ passwds = DEFAULT_PASSWORD
1684
+ else:
1685
+ host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1686
+ passwds = 'admin'
1383
1687
  if not host.command:
1384
1688
  host.command = 'power status'
1385
1689
  if 'sh' in _binPaths:
1386
- if passwds:
1387
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
1388
- else:
1389
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1390
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P admin {" ".join(extraargs)} {host.command}']
1690
+ formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
1391
1691
  else:
1392
- if passwds:
1393
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
1394
- else:
1395
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1396
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P admin'] + extraargs + [host.command]
1692
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
1397
1693
  elif 'ssh' in _binPaths:
1398
1694
  host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1399
1695
  if __DEBUG_MODE:
@@ -1544,7 +1840,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1544
1840
  stderr_thread.join(timeout=1)
1545
1841
  stdin_thread.join(timeout=1)
1546
1842
  # here we handle the rest of the stdout after the subprocess returns
1547
- host.output.append(f'Pipe Closed. Trying to read the rest of the stdout...')
1843
+ host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
1548
1844
  if not _emo:
1549
1845
  stdout = None
1550
1846
  stderr = None
@@ -2262,7 +2558,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2262
2558
  # if the line is visible, we will reprint it
2263
2559
  if visibleLowerBound <= lineNumToReprint <= len(host.output):
2264
2560
  _curses_add_string_to_window(window=host_window, y=lineNumToReprint + 1, line=host.output[lineNumToReprint], color_pair_list=host.current_color_pair,lead_str='│',keep_top_n_lines=1,box_ansi_color=box_ansi_color,fill_char='')
2265
- except Exception as e:
2561
+ except Exception:
2266
2562
  # import traceback
2267
2563
  # print(str(e).strip())
2268
2564
  # print(traceback.format_exc().strip())
@@ -2330,7 +2626,7 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2330
2626
  # print if can change color
2331
2627
  _curses_add_string_to_window(window=stdscr, y=8, line=f"Real color capability: {curses.can_change_color()}", number_of_char_to_write=stdscr.getmaxyx()[1] - 1)
2332
2628
  stdscr.refresh()
2333
- except:
2629
+ except Exception:
2334
2630
  pass
2335
2631
  params = (-1,0 , min_char_len, min_line_len, single_window,False,'new config')
2336
2632
  while params:
@@ -2351,13 +2647,177 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2351
2647
  stdscr.addstr(i, 0, line)
2352
2648
  i += 1
2353
2649
  stdscr.refresh()
2354
- except:
2650
+ except Exception:
2355
2651
  pass
2356
2652
  params = params[:6] + ('new config',)
2357
2653
  time.sleep(0.01)
2358
2654
  #time.sleep(0.25)
2359
2655
 
2360
2656
  #%% ------------ Generate Output Block ----------------
2657
+ def can_merge(line_bag1, line_bag2, threshold):
2658
+ bag1_iter = iter(line_bag1)
2659
+ found = False
2660
+ for _ in range(max(int(len(line_bag1) * (1-threshold)),1)):
2661
+ try:
2662
+ item = next(bag1_iter)
2663
+ except StopIteration:
2664
+ break
2665
+ if item in line_bag2:
2666
+ found = True
2667
+ break
2668
+ if not found:
2669
+ return False
2670
+ return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
2671
+
2672
+ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2673
+ indexes = {hostname: 0 for hostname in merging_hostnames}
2674
+ working_indexes = indexes.copy()
2675
+ previousBuddies = set()
2676
+ hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2677
+ hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2678
+ while indexes:
2679
+ futures = {}
2680
+ defer = False
2681
+ sorted_working_indexes = sorted(working_indexes.items(), key=lambda x: x[1])
2682
+ golden_hostname, golden_index = sorted_working_indexes[0]
2683
+ buddy = {golden_hostname}
2684
+ lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2685
+ for hostname, index in sorted_working_indexes[1:]:
2686
+ if lineToAdd == outputs_by_hostname[hostname][index]:
2687
+ buddy.add(hostname)
2688
+ else:
2689
+ if hostname not in futures:
2690
+ diff_display_item_count = max(int(len(outputs_by_hostname[hostname]) * (1 - diff_display_threshold)),1)
2691
+ tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2692
+ futures[hostname] = (OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index)
2693
+ if lineToAdd in futures[hostname]:
2694
+ for hn in buddy:
2695
+ del working_indexes[hn]
2696
+ defer = True
2697
+ break
2698
+ if not defer:
2699
+ if buddy != previousBuddies:
2700
+ hostnameStr = ','.join(compact_hostnames(buddy))
2701
+ hostnameLines = hostnameWrapper.wrap(hostnameStr)
2702
+ hostnameLines = [line.ljust(line_length - 1) + '│' for line in hostnameLines]
2703
+ color = string_to_unique_ansi_color(hostnameStr) if len(buddy) < len(merging_hostnames) else ''
2704
+ hostnameLines[0] = f"\033[0m{color}{hostnameLines[0]}"
2705
+ output.extend(hostnameLines)
2706
+ previousBuddies = buddy
2707
+ output.append(lineToAdd.ljust(line_length - 1) + '│')
2708
+ for hostname in buddy:
2709
+ indexes[hostname] += 1
2710
+ if indexes[hostname] >= len(outputs_by_hostname[hostname]):
2711
+ indexes.pop(hostname, None)
2712
+ futures.pop(hostname, None)
2713
+ continue
2714
+ #advance futures
2715
+ if hostname in futures:
2716
+ tracking_multiset, tracking_index = futures[hostname]
2717
+ tracking_index += 1
2718
+ if tracking_index < len(outputs_by_hostname[hostname]):
2719
+ line = outputs_by_hostname[hostname][tracking_index]
2720
+ tracking_multiset.append(line)
2721
+ else:
2722
+ tracking_multiset.pop_left()
2723
+ futures[hostname] = (tracking_multiset, tracking_index)
2724
+ working_indexes = indexes.copy()
2725
+
2726
+ def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2727
+ output = []
2728
+ output.append(('┌'+'─'*(line_length-2) + '┐'))
2729
+ for merging_hostnames in merge_groups:
2730
+ mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2731
+ output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2732
+ for hostname in remaining_hostnames:
2733
+ hostnameLines = textwrap.wrap(hostname, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2734
+ initial_indent='├─ ', subsequent_indent='│- ')
2735
+ output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
2736
+ output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
2737
+ output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2738
+ if output:
2739
+ output.pop()
2740
+ # if output and output[0] and output[0].startswith('├'):
2741
+ # output[0] = '┌' + output[0][1:]
2742
+ return output
2743
+
2744
+ def get_host_raw_output(hosts, terminal_width):
2745
+ outputs_by_hostname = {}
2746
+ line_bag_by_hostname = {}
2747
+ hostnames_by_line_bag_len = {}
2748
+ text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2749
+ initial_indent='│ ', subsequent_indent='│-')
2750
+ max_length = 20
2751
+ for host in hosts:
2752
+ hostPrintOut = ["│█ EXECUTED COMMAND:"]
2753
+ for line in host['command'].splitlines():
2754
+ hostPrintOut.extend(text_wrapper.wrap(line))
2755
+ lineBag = {(0,host['command'])}
2756
+ prevLine = host['command']
2757
+ if host['stdout']:
2758
+ hostPrintOut.append('│▓ STDOUT:')
2759
+ for line in host['stdout']:
2760
+ hostPrintOut.extend(text_wrapper.wrap(line))
2761
+ lineBag.add((prevLine,1))
2762
+ lineBag.add((1,host['stdout'][0]))
2763
+ if len(host['stdout']) > 1:
2764
+ lineBag.update(zip(host['stdout'], host['stdout'][1:]))
2765
+ lineBag.update(host['stdout'])
2766
+ prevLine = host['stdout'][-1]
2767
+ if host['stderr']:
2768
+ if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2769
+ host['stderr'][0] = 'SSH not reachable!'
2770
+ elif host['stderr'][-1].strip().endswith('Connection timed out'):
2771
+ host['stderr'][-1] = 'SSH connection timed out!'
2772
+ elif host['stderr'][-1].strip().endswith('No route to host'):
2773
+ host['stderr'][-1] = 'Cannot find host!'
2774
+ if host['stderr']:
2775
+ hostPrintOut.append('│▒ STDERR:')
2776
+ for line in host['stderr']:
2777
+ hostPrintOut.extend(text_wrapper.wrap(line))
2778
+ lineBag.add((prevLine,2))
2779
+ lineBag.add((2,host['stderr'][0]))
2780
+ lineBag.update(host['stderr'])
2781
+ if len(host['stderr']) > 1:
2782
+ lineBag.update(zip(host['stderr'], host['stderr'][1:]))
2783
+ prevLine = host['stderr'][-1]
2784
+ hostPrintOut.append(f"│░ RETURN CODE: {host['returncode']}")
2785
+ lineBag.add((prevLine,f"{host['returncode']}"))
2786
+ max_length = max(max_length, max(map(len, hostPrintOut)))
2787
+ outputs_by_hostname[host['name']] = hostPrintOut
2788
+ line_bag_by_hostname[host['name']] = lineBag
2789
+ hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
2790
+ return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len), min(max_length+2,terminal_width)
2791
+
2792
+ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
2793
+ merge_groups = []
2794
+ for line_bag_len in hostnames_by_line_bag_len.copy():
2795
+ for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
2796
+ if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
2797
+ continue
2798
+ this_line_bag = line_bag_by_hostname[this_hostname]
2799
+ target_threshold = line_bag_len * (2 - diff_display_threshold)
2800
+ merge_group = []
2801
+ for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
2802
+ if other_line_bag_len > target_threshold:
2803
+ break
2804
+ if other_line_bag_len < line_bag_len:
2805
+ continue
2806
+ for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
2807
+ if this_hostname == other_hostname:
2808
+ continue
2809
+ if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
2810
+ merge_group.append(other_hostname)
2811
+ hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
2812
+ hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
2813
+ if not hostnames_by_line_bag_len[other_line_bag_len]:
2814
+ del hostnames_by_line_bag_len[other_line_bag_len]
2815
+ del line_bag_by_hostname[other_hostname]
2816
+ if merge_group:
2817
+ merge_group.append(this_hostname)
2818
+ merge_groups.append(merge_group)
2819
+ return merge_groups
2820
+
2361
2821
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
2362
2822
  if quiet:
2363
2823
  # remove hosts with returncode 0
@@ -2396,40 +2856,34 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2396
2856
  rtnStr += 'User Inputs: '+ '\nUser Inputs: '.join(CMDsOut)
2397
2857
  #rtnStr += '\n'
2398
2858
  else:
2399
- outputs = {}
2400
- for host in hosts:
2401
- hostPrintOut = f" Command:\n {host['command']}\n"
2402
- hostPrintOut += " stdout:\n "+'\n '.join(host['stdout'])
2403
- if host['stderr']:
2404
- if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2405
- host['stderr'][0] = 'SSH not reachable!'
2406
- elif host['stderr'][-1].strip().endswith('Connection timed out'):
2407
- host['stderr'][-1] = 'SSH connection timed out!'
2408
- elif host['stderr'][-1].strip().endswith('No route to host'):
2409
- host['stderr'][-1] = 'Cannot find host!'
2410
- hostPrintOut += "\n stderr:\n "+'\n '.join(host['stderr'])
2411
- hostPrintOut += f"\n return_code: {host['returncode']}"
2412
- outputs.setdefault(hostPrintOut, set()).add(host['name'])
2413
- rtnStr = ''
2414
- for output, hostSet in outputs.items():
2415
- compact_hosts = compact_hostnames(hostSet)
2416
- rtnStr += '*'*80+'\n'
2417
- if quiet:
2418
- rtnStr += f'Abnormal returncode produced by {",".join(compact_hosts)}:\n'
2419
- rtnStr += output+'\n'
2420
- else:
2421
- rtnStr += f'These hosts: "{",".join(compact_hosts)}" have a response of:\n'
2422
- rtnStr += output+'\n'
2423
- if not quiet or outputs:
2424
- rtnStr += '*'*80+'\n'
2859
+ try:
2860
+ diff_display_threshold = float(DEFAULT_DIFF_DISPLAY_THRESHOLD)
2861
+ if diff_display_threshold < 0 or diff_display_threshold > 1:
2862
+ raise ValueError
2863
+ except Exception:
2864
+ eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2865
+ diff_display_threshold = 0.9
2866
+ terminal_length = get_terminal_size()[0]
2867
+ outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_length = get_host_raw_output(hosts,terminal_length)
2868
+ merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
2869
+ # get the remaining hostnames in the hostnames_by_line_bag_len
2870
+ remaining_hostnames = set()
2871
+ for hostnames in hostnames_by_line_bag_len.values():
2872
+ remaining_hostnames.update(hostnames)
2873
+ outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
2425
2874
  if keyPressesIn[-1]:
2426
2875
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2427
- rtnStr += "User Inputs: \n "
2428
- rtnStr += '\n '.join(CMDsOut)
2429
- rtnStr += '\n'
2876
+ outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
2877
+ cmdOut = []
2878
+ for line in CMDsOut:
2879
+ cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2880
+ initial_indent='│ ', subsequent_indent='│-'))
2881
+ outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
2430
2882
  keyPressesIn[-1].clear()
2431
2883
  if quiet and not outputs:
2432
- rtnStr += 'Success'
2884
+ rtnStr = 'Success'
2885
+ else:
2886
+ rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
2433
2887
  return rtnStr
2434
2888
 
2435
2889
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -2487,8 +2941,8 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2487
2941
  availableHosts = set()
2488
2942
  for host in hosts:
2489
2943
  if host.stderr and ('No route to host' in host.stderr[0].strip() or 'Connection timed out' in host.stderr[0].strip() or (host.stderr[-1].strip().startswith('Timeout!') and host.returncode == 124)):
2490
- unavailableHosts[host.name] = int(time.monotonic())
2491
- __globalUnavailableHosts[host.name] = int(time.monotonic())
2944
+ unavailableHosts[host.name] = int(time.monotonic() + unavailable_host_expiry)
2945
+ __globalUnavailableHosts[host.name] = int(time.monotonic() + unavailable_host_expiry)
2492
2946
  else:
2493
2947
  availableHosts.add(host.name)
2494
2948
  if host.name in unavailableHosts:
@@ -2513,7 +2967,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2513
2967
  expireTime = int(line.split(',')[1])
2514
2968
  if expireTime < time.monotonic() and hostname not in availableHosts:
2515
2969
  oldDic[hostname] = expireTime
2516
- except:
2970
+ except Exception:
2517
2971
  pass
2518
2972
  # add new entries
2519
2973
  oldDic.update(unavailableHosts)
@@ -2567,33 +3021,60 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
2567
3021
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
2568
3022
  shortend = False) -> str:
2569
3023
  argsList = []
2570
- if oneonone: argsList.append('--oneonone' if not shortend else '-11')
2571
- if timeout and timeout != DEFAULT_TIMEOUT: argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
2572
- if repeat and repeat != DEFAULT_REPEAT: argsList.append(f'--repeat={repeat}' if not shortend else f'-r={repeat}')
2573
- if interval and interval != DEFAULT_INTERVAL: argsList.append(f'--interval={interval}' if not shortend else f'-i={interval}')
2574
- if password and password != DEFAULT_PASSWORD: argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
2575
- if identity_file and identity_file != DEFAULT_IDENTITY_FILE: argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
2576
- if copy_id: argsList.append('--copy_id' if not shortend else '-ci')
2577
- if no_watch: argsList.append('--no_watch' if not shortend else '-q')
2578
- if json: argsList.append('--json' if not shortend else '-j')
2579
- if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS: argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
2580
- if files: argsList.extend([f'--file="{file}"' for file in files] if not shortend else [f'-f="{file}"' for file in files])
2581
- if ipmi: argsList.append('--ipmi')
2582
- if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX: argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
2583
- if scp: argsList.append('--scp')
2584
- if gather_mode: argsList.append('--gather_mode' if not shortend else '-gm')
2585
- if username and username != DEFAULT_USERNAME: argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
2586
- if extraargs and extraargs != DEFAULT_EXTRA_ARGS: argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
2587
- if skipUnreachable: argsList.append('--skip_unreachable' if not shortend else '-su')
2588
- if unavailable_host_expiry and unavailable_host_expiry != DEFAULT_UNAVAILABLE_HOST_EXPIRY: argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
2589
- if no_env: argsList.append('--no_env')
2590
- if env_file and env_file != DEFAULT_ENV_FILE: argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
2591
- if no_history: argsList.append('--no_history' if not shortend else '-nh')
2592
- if history_file and history_file != DEFAULT_HISTORY_FILE: argsList.append(f'--history_file="{history_file}"' if not shortend else f'-hf="{history_file}"')
2593
- if greppable: argsList.append('--greppable' if not shortend else '-g')
2594
- if error_only: argsList.append('--error_only' if not shortend else '-eo')
2595
- if skip_hosts and skip_hosts != DEFAULT_SKIP_HOSTS: argsList.append(f'--skip_hosts="{skip_hosts}"' if not shortend else f'-sh="{skip_hosts}"')
2596
- if file_sync: argsList.append('--file_sync' if not shortend else '-fs')
3024
+ if oneonone:
3025
+ argsList.append('--oneonone' if not shortend else '-11')
3026
+ if timeout and timeout != DEFAULT_TIMEOUT:
3027
+ argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
3028
+ if repeat and repeat != DEFAULT_REPEAT:
3029
+ argsList.append(f'--repeat={repeat}' if not shortend else f'-r={repeat}')
3030
+ if interval and interval != DEFAULT_INTERVAL:
3031
+ argsList.append(f'--interval={interval}' if not shortend else f'-i={interval}')
3032
+ if password and password != DEFAULT_PASSWORD:
3033
+ argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
3034
+ if identity_file and identity_file != DEFAULT_IDENTITY_FILE:
3035
+ argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
3036
+ if copy_id:
3037
+ argsList.append('--copy_id' if not shortend else '-ci')
3038
+ if no_watch:
3039
+ argsList.append('--no_watch' if not shortend else '-q')
3040
+ if json:
3041
+ argsList.append('--json' if not shortend else '-j')
3042
+ if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS:
3043
+ argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
3044
+ if files:
3045
+ argsList.extend([f'--file="{file}"' for file in files] if not shortend else [f'-f="{file}"' for file in files])
3046
+ if ipmi:
3047
+ argsList.append('--ipmi')
3048
+ if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX:
3049
+ argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
3050
+ if scp:
3051
+ argsList.append('--scp')
3052
+ if gather_mode:
3053
+ argsList.append('--gather_mode' if not shortend else '-gm')
3054
+ if username and username != DEFAULT_USERNAME:
3055
+ argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
3056
+ if extraargs and extraargs != DEFAULT_EXTRA_ARGS:
3057
+ argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
3058
+ if skipUnreachable:
3059
+ argsList.append('--skip_unreachable' if not shortend else '-su')
3060
+ if unavailable_host_expiry and unavailable_host_expiry != DEFAULT_UNAVAILABLE_HOST_EXPIRY:
3061
+ argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
3062
+ if no_env:
3063
+ argsList.append('--no_env')
3064
+ if env_file and env_file != DEFAULT_ENV_FILE:
3065
+ argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
3066
+ if no_history:
3067
+ argsList.append('--no_history' if not shortend else '-nh')
3068
+ if history_file and history_file != DEFAULT_HISTORY_FILE:
3069
+ argsList.append(f'--history_file="{history_file}"' if not shortend else f'-hf="{history_file}"')
3070
+ if greppable:
3071
+ argsList.append('--greppable' if not shortend else '-g')
3072
+ if error_only:
3073
+ argsList.append('--error_only' if not shortend else '-eo')
3074
+ if skip_hosts and skip_hosts != DEFAULT_SKIP_HOSTS:
3075
+ argsList.append(f'--skip_hosts="{skip_hosts}"' if not shortend else f'-sh="{skip_hosts}"')
3076
+ if file_sync:
3077
+ argsList.append('--file_sync' if not shortend else '-fs')
2597
3078
  return ' '.join(argsList)
2598
3079
 
2599
3080
  def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
@@ -2753,7 +3234,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2753
3234
  if line and ',' in line and len(line.split(',')) >= 2 and line.split(',')[0] and line.split(',')[1].isdigit():
2754
3235
  hostname = line.split(',')[0]
2755
3236
  expireTime = int(line.split(',')[1])
2756
- if expireTime < time.monotonic() and expireTime + unavailable_host_expiry > time.monotonic():
3237
+ if expireTime > time.monotonic():
2757
3238
  __globalUnavailableHosts[hostname] = expireTime
2758
3239
  readed = True
2759
3240
  if readed and not __global_suppress_printout:
@@ -2762,7 +3243,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2762
3243
  eprint(f"Warning: Unable to read the unavailable hosts from the file {os.path.join(tempfile.gettempdir(),f'__{getpass.getuser()}_multiSSH3_UNAVAILABLE_HOSTS.csv')!r}")
2763
3244
  eprint(str(e))
2764
3245
  elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
2765
- __globalUnavailableHosts.update({host: int(time.monotonic()) for host in readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(',') if host})
3246
+ __globalUnavailableHosts.update({host: int(time.monotonic()+ unavailable_host_expiry) for host in readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(',') if host})
2766
3247
  if not max_connections:
2767
3248
  max_connections = 4 * os.cpu_count()
2768
3249
  elif max_connections == 0:
@@ -2834,7 +3315,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2834
3315
  # we will copy the id to the hosts
2835
3316
  hosts = []
2836
3317
  for host in targetHostDic:
2837
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
3318
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
3319
+ continue
2838
3320
  command = f"{_binPaths['ssh-copy-id']} "
2839
3321
  if identity_file:
2840
3322
  command = f"{command}-i {identity_file} "
@@ -2870,7 +3352,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2870
3352
  for file in files:
2871
3353
  try:
2872
3354
  pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
2873
- except:
3355
+ except Exception:
2874
3356
  pathSet.update(glob.glob(file,recursive=True))
2875
3357
  if not pathSet:
2876
3358
  _exit_with_code(66, f'No source files at {files!r} are found after resolving globs!')
@@ -2895,17 +3377,19 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2895
3377
  eprint('-'*80)
2896
3378
  eprint("Running in one on one mode")
2897
3379
  for host, command in zip(targetHostDic, commands):
2898
- if not ipmi and skipUnreachable and host in unavailableHosts:
3380
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
2899
3381
  eprint(f"Skipping unavailable host: {host}")
2900
3382
  continue
2901
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
3383
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
3384
+ continue
2902
3385
  if file_sync:
2903
3386
  hosts.append(Host(host, os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip = targetHostDic[host]))
2904
3387
  else:
2905
3388
  hosts.append(Host(host, command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
2906
3389
  if not __global_suppress_printout:
2907
3390
  eprint(f"Running command: {command!r} on host: {host!r}")
2908
- if not __global_suppress_printout: eprint('-'*80)
3391
+ if not __global_suppress_printout:
3392
+ eprint('-'*80)
2909
3393
  if not no_start:
2910
3394
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
2911
3395
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
@@ -2919,15 +3403,17 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2919
3403
  # run in interactive mode ssh mode
2920
3404
  hosts = []
2921
3405
  for host in targetHostDic:
2922
- if not ipmi and skipUnreachable and host in unavailableHosts:
2923
- if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
3406
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
3407
+ if not __global_suppress_printout:
3408
+ print(f"Skipping unavailable host: {host}")
3409
+ continue
3410
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
2924
3411
  continue
2925
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
2926
3412
  if file_sync:
2927
- eprint(f"Error: file sync mode need to be specified with at least one path to sync.")
3413
+ eprint("Error: file sync mode need to be specified with at least one path to sync.")
2928
3414
  return []
2929
3415
  elif files:
2930
- eprint(f"Error: files need to be specified with at least one path to sync")
3416
+ eprint("Error: files need to be specified with at least one path to sync")
2931
3417
  else:
2932
3418
  hosts.append(Host(host, '', files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file,ip=targetHostDic[host]))
2933
3419
  if not __global_suppress_printout:
@@ -2935,7 +3421,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2935
3421
  eprint(f"Running in interactive mode on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
2936
3422
  eprint('-'*80)
2937
3423
  if no_start:
2938
- eprint(f"Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
3424
+ eprint("Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
2939
3425
  else:
2940
3426
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
2941
3427
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
@@ -2946,10 +3432,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2946
3432
  for command in commands:
2947
3433
  hosts = []
2948
3434
  for host in targetHostDic:
2949
- if not ipmi and skipUnreachable and host in unavailableHosts:
2950
- if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
3435
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
3436
+ if not __global_suppress_printout:
3437
+ print(f"Skipping unavailable host: {host}")
3438
+ continue
3439
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
2951
3440
  continue
2952
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
2953
3441
  if file_sync:
2954
3442
  hosts.append(Host(host, os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
2955
3443
  else:
@@ -2999,6 +3487,8 @@ def generate_default_config(args):
2999
3487
  'DEFAULT_IPMI': args.ipmi,
3000
3488
  'DEFAULT_IPMI_INTERFACE_IP_PREFIX': args.ipmi_interface_ip_prefix,
3001
3489
  'DEFAULT_INTERFACE_IP_PREFIX': args.interface_ip_prefix,
3490
+ 'DEFAULT_IPMI_USERNAME': args.ipmi_username,
3491
+ 'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
3002
3492
  'DEFAULT_NO_WATCH': args.no_watch,
3003
3493
  'DEFAULT_CURSES_MINIMUM_CHAR_LEN': args.window_width,
3004
3494
  'DEFAULT_CURSES_MINIMUM_LINE_LEN': args.window_height,
@@ -3017,8 +3507,10 @@ def generate_default_config(args):
3017
3507
  'DEFAULT_SKIP_UNREACHABLE': args.skip_unreachable,
3018
3508
  'DEFAULT_SKIP_HOSTS': args.skip_hosts,
3019
3509
  'DEFAULT_ENCODING': args.encoding,
3510
+ 'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
3020
3511
  'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
3021
3512
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
3513
+ 'FORCE_TRUECOLOR': args.force_truecolor,
3022
3514
  }
3023
3515
 
3024
3516
  def write_default_config(args,CONFIG_FILE = None):
@@ -3031,9 +3523,9 @@ def write_default_config(args,CONFIG_FILE = None):
3031
3523
  backup = True
3032
3524
  if os.path.exists(CONFIG_FILE):
3033
3525
  eprint(f"Warning: {CONFIG_FILE!r} already exists, what to do? (o/b/n)")
3034
- eprint(f"o: Overwrite the file")
3526
+ eprint("o: Overwrite the file")
3035
3527
  eprint(f"b: Rename the current config file at {CONFIG_FILE!r}.bak forcefully and write the new config file (default)")
3036
- eprint(f"n: Do nothing")
3528
+ eprint("n: Do nothing")
3037
3529
  inStr = input_with_timeout_and_countdown(10)
3038
3530
  if (not inStr) or inStr.lower().strip().startswith('b'):
3039
3531
  backup = True
@@ -3056,14 +3548,14 @@ def write_default_config(args,CONFIG_FILE = None):
3056
3548
  eprint(f"Config file written to {CONFIG_FILE!r}")
3057
3549
  except Exception as e:
3058
3550
  eprint(f"Error: Unable to write to the config file: {e!r}")
3059
- eprint(f'Printing the config file to stdout:')
3551
+ eprint('Printing the config file to stdout:')
3060
3552
  print(json.dumps(__configs_from_file, indent=4))
3061
3553
 
3062
3554
  #%% ------------ Argument Processing -----------------
3063
3555
  def get_parser():
3064
3556
  global _binPaths
3065
3557
  parser = argparse.ArgumentParser(description=f'Run a command on multiple hosts, Use #HOST# or #HOSTNAME# to replace the host name in the command. Config file chain: {CONFIG_FILE_CHAIN!r}',
3066
- epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}')
3558
+ epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}',)
3067
3559
  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)
3068
3560
  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.')
3069
3561
  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)
@@ -3075,7 +3567,7 @@ def get_parser():
3075
3567
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3076
3568
  parser.add_argument('-s','-fs','--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
3077
3569
  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)
3078
- parser.add_argument('-G','-gm','--gather_mode', action='store_true', help=f'Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3570
+ 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)
3079
3571
  #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")
3080
3572
  parser.add_argument("-t","--timeout", type=int, help=f"Timeout for each command in seconds. Set default value via DEFAULT_CLI_TIMEOUT in config file. Use 0 for disabling timeout. (default: {DEFAULT_CLI_TIMEOUT})", default=DEFAULT_CLI_TIMEOUT)
3081
3573
  parser.add_argument('-T','--use_script_timeout',action='store_true', help=f'Use shortened timeout suitable to use in a script. Set value via DEFAULT_TIMEOUT field in config file. (current: {DEFAULT_TIMEOUT})', default=False)
@@ -3084,6 +3576,8 @@ def get_parser():
3084
3576
  parser.add_argument('-M',"--ipmi", action='store_true', help=f"Use ipmitool to run the command. (default: {DEFAULT_IPMI})", default=DEFAULT_IPMI)
3085
3577
  parser.add_argument("-mpre","--ipmi_interface_ip_prefix", type=str, help=f"The prefix of the IPMI interfaces (default: {DEFAULT_IPMI_INTERFACE_IP_PREFIX})", default=DEFAULT_IPMI_INTERFACE_IP_PREFIX)
3086
3578
  parser.add_argument("-pre","--interface_ip_prefix", type=str, help=f"The prefix of the for the interfaces (default: {DEFAULT_INTERFACE_IP_PREFIX})", default=DEFAULT_INTERFACE_IP_PREFIX)
3579
+ parser.add_argument('-iu','--ipmi_username', type=str,help=f'The username to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_USERNAME})',default=DEFAULT_IPMI_USERNAME)
3580
+ parser.add_argument('-ip','--ipmi_password', type=str,help=f'The password to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_PASSWORD})',default=DEFAULT_IPMI_PASSWORD)
3087
3581
  parser.add_argument('-S',"-q","-nw","--no_watch","--quiet", action='store_true', help=f"Quiet mode, no curses watch, only print the output. (default: {DEFAULT_NO_WATCH})", default=DEFAULT_NO_WATCH)
3088
3582
  parser.add_argument("-ww",'--window_width', type=int, help=f"The minimum character length of the curses window. (default: {DEFAULT_CURSES_MINIMUM_CHAR_LEN})", default=DEFAULT_CURSES_MINIMUM_CHAR_LEN)
3089
3583
  parser.add_argument("-wh",'--window_height', type=int, help=f"The minimum line height of the curses window. (default: {DEFAULT_CURSES_MINIMUM_LINE_LEN})", default=DEFAULT_CURSES_MINIMUM_LINE_LEN)
@@ -3093,7 +3587,7 @@ def get_parser():
3093
3587
  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)
3094
3588
  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)
3095
3589
  parser.add_argument("--env_file", type=str, help=f"The file to load the mssh file based environment variables from. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILE})", default=DEFAULT_ENV_FILE)
3096
- parser.add_argument("-m","--max_connections", type=int, help=f"Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3590
+ parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3097
3591
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3098
3592
  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)
3099
3593
  parser.add_argument('-P',"-g","--greppable",'--table', action='store_true', help=f"Output in greppable table. (default: {DEFAULT_GREPPABLE_MODE})", default=DEFAULT_GREPPABLE_MODE)
@@ -3102,19 +3596,23 @@ def get_parser():
3102
3596
  su_group.add_argument('-a',"-nsu","--no_skip_unreachable",dest = 'skip_unreachable', action='store_false', help=f"Do not skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {not DEFAULT_SKIP_UNREACHABLE})", default=not DEFAULT_SKIP_UNREACHABLE)
3103
3597
  parser.add_argument('-uhe','--unavailable_host_expiry', type=int, help=f"Time in seconds to expire the unavailable hosts (default: {DEFAULT_UNAVAILABLE_HOST_EXPIRY})", default=DEFAULT_UNAVAILABLE_HOST_EXPIRY)
3104
3598
  parser.add_argument('-X',"-sh","--skip_hosts", type=str, help=f"Skip the hosts in the list. (default: {DEFAULT_SKIP_HOSTS if DEFAULT_SKIP_HOSTS else 'None'})", default=DEFAULT_SKIP_HOSTS)
3105
- parser.add_argument('--generate_config_file', action='store_true', help=f'Store / generate the default config file from command line argument and current config at --config_file / stdout')
3106
- parser.add_argument('--config_file', type=str,nargs='?', help=f'Additional config file to use, will pioritize over config chains. When using with store_config_file, will store the resulting config file at this location. Use without a path will use multiSSH3.config.json',const='multiSSH3.config.json',default=None)
3107
- parser.add_argument('--store_config_file',type = str,nargs='?',help=f'Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
3599
+ parser.add_argument('--generate_config_file', action='store_true', help='Store / generate the default config file from command line argument and current config at --config_file / stdout')
3600
+ parser.add_argument('--config_file', type=str,nargs='?', help='Additional config file to use, will pioritize over config chains. When using with store_config_file, will store the resulting config file at this location. Use without a path will use multiSSH3.config.json',const='multiSSH3.config.json',default=None)
3601
+ parser.add_argument('--store_config_file',type = str,nargs='?',help='Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
3108
3602
  parser.add_argument('--debug', action='store_true', help='Print debug information')
3109
3603
  parser.add_argument('-ci','--copy_id', action='store_true', help='Copy the ssh id to the hosts')
3110
3604
  parser.add_argument('-I','-nh','--no_history', action='store_true', help=f'Do not record the command to history. Default: {DEFAULT_NO_HISTORY}', default=DEFAULT_NO_HISTORY)
3111
3605
  parser.add_argument('-hf','--history_file', type=str, help=f'The file to store the history. (default: {DEFAULT_HISTORY_FILE})', default=DEFAULT_HISTORY_FILE)
3112
3606
  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')
3113
3607
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3608
+ parser.add_argument('-ddt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. Set to 0 to always display the diff. (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
3609
+ 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)
3114
3610
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3115
3611
  return parser
3116
3612
 
3117
3613
  def process_args(args = None):
3614
+ global DEFAULT_IPMI_USERNAME
3615
+ global DEFAULT_IPMI_PASSWORD
3118
3616
  parser = get_parser()
3119
3617
  # We handle the signal
3120
3618
  signal.signal(signal.SIGINT, signal_handler)
@@ -3174,10 +3672,10 @@ def process_config_file(args):
3174
3672
 
3175
3673
  def process_commands(args):
3176
3674
  if not args.file and len(args.commands) > 1 and all([len(command.split()) == 1 for command in args.commands]):
3177
- eprint(f"Multiple one word command detected, what to do? (1/m/n)")
3675
+ eprint("Multiple one word command detected, what to do? (1/m/n)")
3178
3676
  eprint(f"1: Run 1 command [{' '.join(args.commands)}] on all hosts ( default )")
3179
3677
  eprint(f"m: Run multiple commands [{', '.join(args.commands)}] on all hosts")
3180
- eprint(f"n: Exit")
3678
+ eprint("n: Exit")
3181
3679
  inStr = input_with_timeout_and_countdown(3)
3182
3680
  if (not inStr) or inStr.lower().strip().startswith('1'):
3183
3681
  args.commands = [" ".join(args.commands)]
@@ -3208,6 +3706,10 @@ def set_global_with_args(args):
3208
3706
  global __configs_from_file
3209
3707
  global _encoding
3210
3708
  global __returnZero
3709
+ global DEFAULT_IPMI_USERNAME
3710
+ global DEFAULT_IPMI_PASSWORD
3711
+ global DEFAULT_DIFF_DISPLAY_THRESHOLD
3712
+ global FORCE_TRUECOLOR
3211
3713
  _emo = False
3212
3714
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3213
3715
  _env_file = args.env_file
@@ -3215,6 +3717,12 @@ def set_global_with_args(args):
3215
3717
  _encoding = args.encoding
3216
3718
  if args.return_zero:
3217
3719
  __returnZero = True
3720
+ if args.ipmi_username:
3721
+ DEFAULT_IPMI_USERNAME = args.ipmi_username
3722
+ if args.ipmi_password:
3723
+ DEFAULT_IPMI_PASSWORD = args.ipmi_password
3724
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
3725
+ FORCE_TRUECOLOR = args.force_truecolor
3218
3726
 
3219
3727
  #%% ------------ Wrapper Block ----------------
3220
3728
  def main():
@@ -3254,7 +3762,8 @@ def main():
3254
3762
  eprint(f"Sleeping for {args.interval} seconds")
3255
3763
  time.sleep(args.interval)
3256
3764
 
3257
- if not __global_suppress_printout: eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3765
+ if not __global_suppress_printout:
3766
+ eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3258
3767
  hosts = run_command_on_hosts(args.hosts,args.commands,
3259
3768
  oneonone=args.oneonone,timeout=args.timeout,password=args.password,
3260
3769
  no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
@@ -3280,7 +3789,8 @@ def main():
3280
3789
  eprint(f'Complete. Failed hosts (Return Code not 0) count: {__mainReturnCode}')
3281
3790
  eprint(f'failed_hosts: {",".join(compact_hostnames(__failedHosts))}')
3282
3791
  else:
3283
- if not __global_suppress_printout: eprint('Complete. All hosts returned 0.')
3792
+ if not __global_suppress_printout:
3793
+ eprint('Complete. All hosts returned 0.')
3284
3794
 
3285
3795
  if args.success_hosts and not __global_suppress_printout:
3286
3796
  eprint(f'succeeded_hosts: {",".join(compact_hostnames(succeededHosts))}')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.86
3
+ Version: 5.91
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=9hTPnoRn6yswbQ8QaQIvkgIOwlVohz1o8jFlq_-S5D0,172386
2
+ multissh3-5.91.dist-info/METADATA,sha256=kyyw0bHMd-ifFluN0LAlKqhY6udom5D3Q2_AgAuSqqU,18093
3
+ multissh3-5.91.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ multissh3-5.91.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
+ multissh3-5.91.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
+ multissh3-5.91.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- multiSSH3.py,sha256=Mbr7AsHObwHIkMCuS9bsfmfMFWNQ4NngBlM-oVCXAlg,154547
2
- multissh3-5.86.dist-info/METADATA,sha256=r4ndEU9KGZ5RMn-AtlxzUazkrnbLbT7LFkQWgF9RwqY,18093
3
- multissh3-5.86.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- multissh3-5.86.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
- multissh3-5.86.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
- multissh3-5.86.dist-info/RECORD,,