multiSSH3 5.96__py3-none-any.whl → 5.98__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
@@ -84,10 +84,10 @@ except Exception:
84
84
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
85
85
  def cache_decorator(func):
86
86
  return func
87
- version = '5.96'
87
+ version = '5.98'
88
88
  VERSION = version
89
89
  __version__ = version
90
- COMMIT_DATE = '2025-10-21'
90
+ COMMIT_DATE = '2025-10-24'
91
91
 
92
92
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
93
  '~/multiSSH3.config.json',
@@ -377,6 +377,24 @@ ERROR_MESSAGES_TO_IGNORE = [
377
377
  'Killed by signal',
378
378
  'Connection reset by peer',
379
379
  ]
380
+ __DEFAULT_COLOR_PALETTE = {
381
+ 'cyan': (86, 173, 188),
382
+ 'green': (114, 180, 43),
383
+ 'magenta': (140, 107, 200),
384
+ 'red': (196, 38, 94),
385
+ 'white': (227, 227, 221),
386
+ 'yellow': (179, 180, 43),
387
+ 'blue': (106, 126, 200),
388
+ 'bright_black': (102, 102, 102),
389
+ 'bright_blue': (129, 154, 255),
390
+ 'bright_cyan': (102, 217, 239),
391
+ 'bright_green': (126, 226, 46),
392
+ 'bright_magenta': (174, 129, 255),
393
+ 'bright_red': (249, 38, 114),
394
+ 'bright_white': (248, 248, 242),
395
+ 'bright_yellow': (226, 226, 46),
396
+ }
397
+ COLOR_PALETTE = __DEFAULT_COLOR_PALETTE.copy()
380
398
  _DEFAULT_CALLED = True
381
399
  _DEFAULT_RETURN_UNFINISHED = False
382
400
  _DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
@@ -420,6 +438,11 @@ __thread_start_delay = 0
420
438
  _encoding = DEFAULT_ENCODING
421
439
  __returnZero = DEFAULT_RETURN_ZERO
422
440
  __running_threads = set()
441
+ __control_master_string = '''Host *
442
+ ControlMaster auto
443
+ ControlPath /run/user/%i/ssh_sockets_%C
444
+ ControlPersist 3600
445
+ '''
423
446
  if __resource_lib_available:
424
447
  # Get the current limits
425
448
  _, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
@@ -931,6 +954,8 @@ def get_terminal_color_capability():
931
954
  return '24bit'
932
955
  elif "256" in term:
933
956
  return '256'
957
+ elif "16" in term:
958
+ return '16'
934
959
  try:
935
960
  curses.setupterm()
936
961
  colors = curses.tigetnum("colors")
@@ -949,96 +974,120 @@ def get_terminal_color_capability():
949
974
  return 'None'
950
975
 
951
976
  @cache_decorator
952
- def get_xterm256_palette():
953
- palette = []
954
- # 0–15: system colors (we'll just fill with dummy values;
955
- # you could fill in real RGB if you need to)
956
- system_colors = [
957
- (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
958
- (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
959
- (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
960
- (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
961
- ]
962
- palette.extend(system_colors)
963
- # 16–231: 6x6x6 color cube
964
- levels = [0, 95, 135, 175, 215, 255]
965
- for r in levels:
966
- for g in levels:
967
- for b in levels:
968
- palette.append((r, g, b))
969
- # 232–255: grayscale ramp, 24 steps from 8 to 238
970
- for i in range(24):
971
- level = 8 + i * 10
972
- palette.append((level, level, level))
973
- return palette
977
+ def rgb_to_ansi_color_string(r, g, b):
978
+ """
979
+ Return an ANSI escape sequence setting the foreground to (r,g,b)
980
+ approximated to the terminal's capability, or '' if none.
981
+ """
982
+ cap = get_terminal_color_capability()
983
+ if cap == 'None':
984
+ return ''
985
+ if cap == '24bit':
986
+ return f'\x1b[38;2;{r};{g};{b}m'
987
+ if cap == '256':
988
+ idx = _rgb_to_256_color(r, g, b)
989
+ return f'\x1b[38;5;{idx}m'
990
+ if cap == '16':
991
+ idx = _rgb_to_16_color(r, g, b)
992
+ # 0–7 = 30–37, 8–15 = 90–97
993
+ if idx < 8:
994
+ return f'\x1b[{30 + idx}m'
995
+ else:
996
+ return f'\x1b[{90 + (idx - 8)}m'
997
+ if cap == '8':
998
+ idx = _rgb_to_8_color(r, g, b)
999
+ return f'\x1b[{30 + idx}m'
1000
+ return ''
974
1001
 
975
- @cache_decorator
976
- def rgb_to_xterm_index(r, g, b):
1002
+ def _rgb_to_256_color(r, g, b):
1003
+ """
1004
+ Map (r,g,b) to the 256-color cube or grayscale ramp.
977
1005
  """
978
- Map 24-bit RGB to nearest xterm-256 color index.
979
- r, g, b should be in 0-255.
980
- Returns an int in 0-255.
1006
+ # if it’s already gray, use the 232–255 grayscale ramp
1007
+ if r == g == b:
1008
+ # 24 shades from 232 to 255
1009
+ return 232 + int(round(r / 255 * 23))
1010
+ # else map each channel to 0–5
1011
+ def to6(v):
1012
+ return int(round(v / 255 * 5))
1013
+ r6, g6, b6 = to6(r), to6(g), to6(b)
1014
+ return 16 + 36 * r6 + 6 * g6 + b6
1015
+
1016
+ def _rgb_to_16_color(r, g, b):
981
1017
  """
982
- best_index = 0
1018
+ Pick the nearest of the 16 ANSI standard colors.
1019
+ Returns an index 0-15.
1020
+ """
1021
+ palette = [
1022
+ (0, 0, 0), # 0 black
1023
+ (128, 0, 0), # 1 red
1024
+ (0, 128, 0), # 2 green
1025
+ (128, 128, 0), # 3 yellow
1026
+ (0, 0, 128), # 4 blue
1027
+ (128, 0, 128), # 5 magenta
1028
+ (0, 128, 128), # 6 cyan
1029
+ (192, 192, 192), # 7 white (light gray)
1030
+ (128, 128, 128), # 8 bright black (dark gray)
1031
+ (255, 0, 0), # 9 bright red
1032
+ (0, 255, 0), # 10 bright green
1033
+ (255, 255, 0), # 11 bright yellow
1034
+ (0, 0, 255), # 12 bright blue
1035
+ (255, 0, 255), # 13 bright magenta
1036
+ (0, 255, 255), # 14 bright cyan
1037
+ (255, 255, 255), # 15 bright white
1038
+ ]
1039
+ best_idx = 0
983
1040
  best_dist = float('inf')
984
- for i, (pr, pg, pb) in enumerate(get_xterm256_palette()):
985
- dr = pr - r
986
- dg = pg - g
987
- db = pb - b
988
- dist = dr*dr + dg*dg + db*db
1041
+ for i, (pr, pg, pb) in enumerate(palette):
1042
+ dist = (r - pr)**2 + (g - pg)**2 + (b - pb)**2
989
1043
  if dist < best_dist:
990
1044
  best_dist = dist
991
- best_index = i
992
- return best_index
1045
+ best_idx = i
1046
+ return best_idx
993
1047
 
994
- @cache_decorator
995
- def hashable_to_color(n, brightness_threshold=500):
996
- hash_value = hash(str(n))
1048
+ def _rgb_to_8_color(r, g, b):
1049
+ """
1050
+ Reduce to 8 colors by mapping to the 16-color index then clamping 0-7.
1051
+ """
1052
+ return _rgb_to_16_color(r//2, g//2, b//2)
1053
+
1054
+
1055
+ def int_to_color(hash_value, min_brightness=100,max_brightness=220):
997
1056
  r = (hash_value >> 16) & 0xFF
998
1057
  g = (hash_value >> 8) & 0xFF
999
1058
  b = hash_value & 0xFF
1000
- if (r + g + b) < brightness_threshold:
1001
- return hashable_to_color(hash_value, brightness_threshold)
1059
+ brightness = math.sqrt(0.299 * r**2 + 0.587 * g**2 + 0.114 * b**2)
1060
+ if brightness < min_brightness:
1061
+ return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
1062
+ if brightness > max_brightness:
1063
+ return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
1002
1064
  return (r, g, b)
1003
1065
 
1004
- __previous_ansi_color_index = -1
1066
+ __previous_color_rgb = ()
1005
1067
  @cache_decorator
1006
- def string_to_unique_ansi_color(string):
1068
+ def int_to_unique_ansi_color(number):
1007
1069
  '''
1008
- Convert a string to a unique ANSI color code
1070
+ Convert a number to a unique ANSI color code
1009
1071
 
1010
1072
  Args:
1011
- string (str): The string to convert
1012
-
1073
+ number (int): The number to convert
1013
1074
  Returns:
1014
1075
  int: The ANSI color code
1015
1076
  '''
1016
- global __previous_ansi_color_index
1077
+ global __previous_color_rgb
1017
1078
  # Use a hash function to generate a consistent integer from the string
1018
1079
  color_capability = get_terminal_color_capability()
1019
- index = None
1020
1080
  if color_capability == 'None':
1021
1081
  return ''
1022
- elif color_capability == '16':
1023
- # Map to one of the 14 colors (31-37, 90-96), avoiding black and white
1024
- index = (hash(string) % 14) + 31
1025
- if index > 37:
1026
- index += 52 # Bright colors (90-97)
1027
- elif color_capability == '8':
1028
- index = (hash(string) % 6) + 31
1029
- r,g,b = hashable_to_color(string)
1030
- if color_capability == '256':
1031
- index = rgb_to_xterm_index(r,g,b)
1032
- if index:
1033
- if index == __previous_ansi_color_index:
1034
- return string_to_unique_ansi_color(hash(string))
1035
- __previous_ansi_color_index = index
1036
- if color_capability == '256':
1037
- return f'\033[38;5;{index}m'
1038
- else:
1039
- return f'\033[{index}m'
1082
+ if color_capability == '24bit':
1083
+ r, g, b = int_to_color(number)
1040
1084
  else:
1041
- return f'\033[38;2;{r};{g};{b}m'
1085
+ # for 256 colors and below, reduce brightness threshold as we do not have many color to work with
1086
+ r, g, b = int_to_color(number, min_brightness=70, max_brightness=190)
1087
+ if sum(abs(a - b) for a, b in zip((r, g, b), __previous_color_rgb)) <= 256:
1088
+ r, g, b = int_to_color(hash(str(number)))
1089
+ __previous_color_rgb = (r, g, b)
1090
+ return rgb_to_ansi_color_string(r, g, b)
1042
1091
 
1043
1092
  #%% ------------ Compacting Hostnames ----------------
1044
1093
  def __tokenize_hostname(hostname):
@@ -1388,14 +1437,14 @@ def compact_hostnames(Hostnames,verify = True):
1388
1437
  # hostSet = frozenset(Hostnames)
1389
1438
  # else:
1390
1439
  # hostSet = Hostnames
1391
- hostSet = frozenset(
1440
+ hostSet = frozenset(expand_hostnames(
1392
1441
  hostname.strip()
1393
1442
  for hostnames_str in Hostnames
1394
1443
  for hostname in hostnames_str.split(',')
1395
- )
1444
+ ))
1396
1445
  compact_hosts = __compact_hostnames(hostSet)
1397
1446
  if verify:
1398
- if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
1447
+ if frozenset(expand_hostnames(compact_hosts)) != hostSet:
1399
1448
  if not __global_suppress_printout:
1400
1449
  eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
1401
1450
  compact_hosts = hostSet
@@ -2790,7 +2839,7 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2790
2839
  indexes = Counter({hostname: 0 for hostname in merging_hostnames})
2791
2840
  working_index_keys = set(merging_hostnames)
2792
2841
  previousBuddies = set()
2793
- hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2842
+ hostnameWrapper = textwrap.TextWrapper(width=line_length -1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
2794
2843
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2795
2844
  diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
2796
2845
  def get_multiset_index_for_hostname(hostname):
@@ -2862,12 +2911,14 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2862
2911
  if buddy != previousBuddies:
2863
2912
  hostnameStr = ','.join(compact_hostnames(buddy))
2864
2913
  hostnameLines = hostnameWrapper.wrap(hostnameStr)
2865
- hostnameLines = [line.ljust(line_length - 1) + '│' for line in hostnameLines]
2866
- color = string_to_unique_ansi_color(hostnameStr) if len(buddy) < len(merging_hostnames) else ''
2867
- hostnameLines[0] = f"\033[0m{color}{hostnameLines[0]}"
2914
+ hostnameLines = [line.ljust(line_length) for line in hostnameLines]
2915
+ color = int_to_unique_ansi_color(hash(hostnameStr)) if len(buddy) < len(merging_hostnames) else ''
2916
+ if color:
2917
+ color = f"\033[0m{color}"
2918
+ hostnameLines[0] = f"{color}{hostnameLines[0]}"
2868
2919
  output.extend(hostnameLines)
2869
2920
  previousBuddies = buddy
2870
- output.append(lineToAdd.ljust(line_length - 1) + '│')
2921
+ output.append(lineToAdd.ljust(line_length))
2871
2922
  currentLines[lineToAdd].difference_update(buddy)
2872
2923
  if not currentLines[lineToAdd]:
2873
2924
  del currentLines[lineToAdd]
@@ -2891,17 +2942,24 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2891
2942
 
2892
2943
  def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2893
2944
  output = []
2894
- output.append(('┌'+'─'*(line_length-2) + '┐'))
2895
- hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2945
+ color_cap = get_terminal_color_capability()
2946
+ if color_cap == 'None':
2947
+ color_line = ''
2948
+ color_reset = ''
2949
+ else:
2950
+ color_line = rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
2951
+ color_reset = '\033[0m'
2952
+ output.append(color_line+'─'*(line_length)+color_reset)
2953
+ hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='─ ', subsequent_indent='- ')
2896
2954
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2897
2955
  for merging_hostnames in merge_groups:
2898
2956
  mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2899
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2957
+ output.append(color_line+'─'*(line_length)+color_reset)
2900
2958
  for hostname in remaining_hostnames:
2901
2959
  hostnameLines = hostnameWrapper.wrap(','.join(compact_hostnames([hostname])))
2902
- output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
2903
- output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
2904
- output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2960
+ output.extend(line.ljust(line_length ) for line in hostnameLines)
2961
+ output.extend(line.ljust(line_length ) for line in outputs_by_hostname[hostname])
2962
+ output.append(color_line+'─'*(line_length)+color_reset)
2905
2963
  if output:
2906
2964
  output.pop()
2907
2965
  # if output and output[0] and output[0].startswith('├'):
@@ -2918,7 +2976,7 @@ def pre_merge_hosts(hosts):
2918
2976
  # Create merged hosts
2919
2977
  merged_hosts = []
2920
2978
  for group in output_groups.values():
2921
- group[0].name = ','.join(host.name for host in group)
2979
+ group[0].name = ','.join(compact_hostnames(host.name for host in group))
2922
2980
  merged_hosts.append(group[0])
2923
2981
  return merged_hosts
2924
2982
 
@@ -2926,25 +2984,40 @@ def get_host_raw_output(hosts, terminal_width):
2926
2984
  outputs_by_hostname = {}
2927
2985
  line_bag_by_hostname = {}
2928
2986
  hostnames_by_line_bag_len = {}
2929
- text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2930
- initial_indent=' ', subsequent_indent='│-')
2987
+ text_wrapper = textwrap.TextWrapper(width=terminal_width - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2988
+ initial_indent=' ', subsequent_indent='-')
2931
2989
  max_length = 20
2990
+ color_cap = get_terminal_color_capability()
2991
+ if color_cap == 'None':
2992
+ color_reset_str = ''
2993
+ blue_str = ''
2994
+ cyan_str = ''
2995
+ green_str = ''
2996
+ red_str = ''
2997
+ else:
2998
+ color_reset_str = '\033[0m'
2999
+ blue_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('blue', __DEFAULT_COLOR_PALETTE['blue']))
3000
+ cyan_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_cyan', __DEFAULT_COLOR_PALETTE['bright_cyan']))
3001
+ green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_green', __DEFAULT_COLOR_PALETTE['bright_green']))
3002
+ red_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_red', __DEFAULT_COLOR_PALETTE['bright_red']))
2932
3003
  hosts = pre_merge_hosts(hosts)
2933
3004
  for host in hosts:
2934
- hostPrintOut = ["│█ EXECUTED COMMAND:"]
3005
+ max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
3006
+ hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
2935
3007
  for line in host.command.splitlines():
2936
3008
  hostPrintOut.extend(text_wrapper.wrap(line))
2937
3009
  # hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
2938
3010
  lineBag = {(0,host.command)}
2939
3011
  prevLine = host.command
2940
3012
  if host.stdout:
2941
- hostPrintOut.append('│▓ STDOUT:')
3013
+ hostPrintOut.append(f'{blue_str}▓{color_reset_str} STDOUT:')
2942
3014
  # for line in host.stdout:
2943
3015
  # if len(line) < terminal_width - 2:
2944
- # hostPrintOut.append(f" {line}")
3016
+ # hostPrintOut.append(f" {line}")
2945
3017
  # else:
2946
3018
  # hostPrintOut.extend(text_wrapper.wrap(line))
2947
- hostPrintOut.extend(f" {line}" for line in host.stdout)
3019
+ hostPrintOut.extend(f" {line}" for line in host.stdout)
3020
+ max_length = max(max_length, max(map(len, host.stdout)))
2948
3021
  # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2949
3022
  lineBag.add((prevLine,1))
2950
3023
  lineBag.add((1,host.stdout[0]))
@@ -2960,22 +3033,26 @@ def get_host_raw_output(hosts, terminal_width):
2960
3033
  elif host.stderr[-1].strip().endswith('No route to host'):
2961
3034
  host.stderr[-1] = 'Cannot find host!'
2962
3035
  if host.stderr:
2963
- hostPrintOut.append('│▒ STDERR:')
3036
+ hostPrintOut.append(f'{red_str}▒{color_reset_str} STDERR:')
2964
3037
  # for line in host.stderr:
2965
3038
  # if len(line) < terminal_width - 2:
2966
- # hostPrintOut.append(f" {line}")
3039
+ # hostPrintOut.append(f" {line}")
2967
3040
  # else:
2968
3041
  # hostPrintOut.extend(text_wrapper.wrap(line))
2969
- hostPrintOut.extend(f" {line}" for line in host.stderr)
3042
+ hostPrintOut.extend(f" {line}" for line in host.stderr)
3043
+ max_length = max(max_length, max(map(len, host.stderr)))
2970
3044
  lineBag.add((prevLine,2))
2971
3045
  lineBag.add((2,host.stderr[0]))
2972
3046
  lineBag.update(host.stderr)
2973
3047
  if len(host.stderr) > 1:
2974
3048
  lineBag.update(zip(host.stderr, host.stderr[1:]))
2975
3049
  prevLine = host.stderr[-1]
2976
- hostPrintOut.append(f"│░ RETURN CODE: {host.returncode}")
3050
+ if host.returncode != 0:
3051
+ codeColor = red_str
3052
+ else:
3053
+ codeColor = green_str
3054
+ hostPrintOut.append(f"{codeColor}░{color_reset_str} RETURN CODE: {host.returncode}")
2977
3055
  lineBag.add((prevLine,f"{host.returncode}"))
2978
- max_length = max(max_length, max(map(len, hostPrintOut)))
2979
3056
  outputs_by_hostname[host.name] = hostPrintOut
2980
3057
  line_bag_by_hostname[host.name] = lineBag
2981
3058
  hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
@@ -3016,6 +3093,7 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
3016
3093
  return merge_groups, remaining_hostnames
3017
3094
 
3018
3095
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
3096
+ color_cap = get_terminal_color_capability()
3019
3097
  if quiet:
3020
3098
  # remove hosts with returncode 0
3021
3099
  hosts = [host for host in hosts if host.returncode != 0]
@@ -3023,7 +3101,10 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
3023
3101
  if usejson:
3024
3102
  return '{"Success": true}'
3025
3103
  else:
3026
- return 'Success'
3104
+ if color_cap == 'None':
3105
+ return 'Success'
3106
+ else:
3107
+ return '\033[32mSuccess\033[0m'
3027
3108
  if usejson:
3028
3109
  # [print(dict(host)) for host in hosts]
3029
3110
  #print(json.dumps([dict(host) for host in hosts],indent=4))
@@ -3058,23 +3139,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
3058
3139
  except Exception:
3059
3140
  eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
3060
3141
  diff_display_threshold = 0.9
3142
+
3143
+ color_reset_str = '' if color_cap == 'None' else '\033[0m'
3144
+ white_str = '' if color_cap == 'None' else rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
3061
3145
  terminal_length = get_terminal_size()[0]
3062
3146
  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)
3063
3147
  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)
3064
3148
  outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
3065
3149
  if keyPressesIn[-1]:
3066
3150
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
3067
- outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
3151
+ outputs.append(color_reset_str + " User Inputs:".ljust(line_length,'─'))
3068
3152
  cmdOut = []
3069
3153
  for line in CMDsOut:
3070
3154
  cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
3071
- initial_indent=' ', subsequent_indent='│-'))
3072
- outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
3155
+ initial_indent=' ', subsequent_indent='-'))
3156
+ outputs.extend(cmd.ljust(line_length) for cmd in cmdOut)
3073
3157
  keyPressesIn[-1].clear()
3074
3158
  if not outputs:
3075
- rtnStr = 'Success' if quiet else ''
3159
+ if quiet:
3160
+ if color_cap == 'None':
3161
+ return 'Success'
3162
+ else:
3163
+ return '\033[32mSuccess\033[0m'
3164
+ else:
3165
+ rtnStr = ''
3076
3166
  else:
3077
- rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
3167
+ rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
3078
3168
  return rtnStr
3079
3169
 
3080
3170
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -3120,7 +3210,15 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
3120
3210
  if total_sleeped > 0.1:
3121
3211
  break
3122
3212
  if any([host.returncode is None for host in hosts]):
3123
- curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3213
+ try:
3214
+ curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3215
+ except Exception:
3216
+ try:
3217
+ curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
3218
+ except Exception as e:
3219
+ eprint(f"Curses print error: {e}")
3220
+ import traceback
3221
+ print(traceback.format_exc())
3124
3222
  if not returnUnfinished:
3125
3223
  # wait until all hosts have a return code
3126
3224
  while any([host.returncode is None for host in hosts]):
@@ -3800,6 +3898,7 @@ def get_parser():
3800
3898
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3801
3899
  parser.add_argument('-dt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. {{0-1}} Set to 0 to always display the diff. Set to 1 to disable diff. (Only merge same) (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
3802
3900
  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)
3901
+ parser.add_argument('--add_control_master_config', action='store_true', help='Add ControlMaster configuration to ~/.ssh/config to speed up multiple connections to the same host.')
3803
3902
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3804
3903
  return parser
3805
3904
 
@@ -3859,7 +3958,7 @@ def process_config_file(args):
3859
3958
  else:
3860
3959
  configFileToWriteTo = args.config_file
3861
3960
  write_default_config(args,configFileToWriteTo)
3862
- if not args.commands:
3961
+ if not args.commands and not args.file:
3863
3962
  if configFileToWriteTo:
3864
3963
  with open(configFileToWriteTo,'r') as f:
3865
3964
  eprint(f"Config file content: \n{f.read()}")
@@ -3901,6 +4000,35 @@ def process_keys(args):
3901
4000
  eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
3902
4001
  return args
3903
4002
 
4003
+ def process_control_master_config(args):
4004
+ global __control_master_string
4005
+ if args.add_control_master_config:
4006
+ try:
4007
+ if not os.path.exists(os.path.expanduser('~/.ssh')):
4008
+ os.makedirs(os.path.expanduser('~/.ssh'),mode=0o700)
4009
+ ssh_config_file = os.path.expanduser('~/.ssh/config')
4010
+ if not os.path.exists(ssh_config_file):
4011
+ with open(ssh_config_file,'w') as f:
4012
+ f.write(__control_master_string)
4013
+ os.chmod(ssh_config_file,0o644)
4014
+ else:
4015
+ with open(ssh_config_file,'r') as f:
4016
+ ssh_config_content = f.readlines()
4017
+ if set(map(str.strip,ssh_config_content)).issuperset(set(map(str.strip,__control_master_string.splitlines()))):
4018
+ eprint("ControlMaster configuration already exists in ~/.ssh/config, skipping adding:")
4019
+ eprint(__control_master_string)
4020
+ else:
4021
+ with open(ssh_config_file,'a') as f:
4022
+ f.write('\n# Added by multiSSH3.py to speed up subsequent connections\n')
4023
+ f.write(__control_master_string)
4024
+ eprint("ControlMaster configuration added to ~/.ssh/config.")
4025
+ except Exception as e:
4026
+ eprint(f"Error adding ControlMaster configuration: {e}")
4027
+ import traceback
4028
+ traceback.print_exc()
4029
+ if not args.commands and not args.file:
4030
+ _exit_with_code(0, "Done configuring ControlMaster.")
4031
+ return args
3904
4032
 
3905
4033
  def set_global_with_args(args):
3906
4034
  global _emo
@@ -3937,6 +4065,7 @@ def main():
3937
4065
  args = process_config_file(args)
3938
4066
  args = process_commands(args)
3939
4067
  args = process_keys(args)
4068
+ args = process_control_master_config(args)
3940
4069
  set_global_with_args(args)
3941
4070
 
3942
4071
  if args.use_script_timeout:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.96
3
+ Version: 5.98
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=oIb2TT_bbUmMRKA-7GT75w7xdW5QpsibmUb-nHrjUtM,184779
2
+ multissh3-5.98.dist-info/METADATA,sha256=Oqw0T08ZrfV_eIBGvKqgK7sR5clxE1uOZamYOgVX4ws,18093
3
+ multissh3-5.98.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ multissh3-5.98.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
+ multissh3-5.98.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
+ multissh3-5.98.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- multiSSH3.py,sha256=xmpPdker87cPs5mcocUo3MdI4SjMsCh4u3MnwJbcGo8,179830
2
- multissh3-5.96.dist-info/METADATA,sha256=QuuF4JIB_Fv_WcFiYrChIFVt8eCioYYbFJLHri8CTVA,18093
3
- multissh3-5.96.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- multissh3-5.96.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
- multissh3-5.96.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
- multissh3-5.96.dist-info/RECORD,,