multiSSH3 5.90__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
@@ -24,6 +24,7 @@ import string
24
24
  import subprocess
25
25
  import sys
26
26
  import tempfile
27
+ import textwrap
27
28
  import threading
28
29
  import time
29
30
  import typing
@@ -83,7 +84,7 @@ except Exception:
83
84
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
84
85
  def cache_decorator(func):
85
86
  return func
86
- version = '5.90'
87
+ version = '5.91'
87
88
  VERSION = version
88
89
  __version__ = version
89
90
  COMMIT_DATE = '2025-10-17'
@@ -376,6 +377,7 @@ DEFAULT_SKIP_HOSTS = ''
376
377
  DEFAULT_ENCODING = 'utf-8'
377
378
  DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
378
379
  SSH_STRICT_HOST_KEY_CHECKING = False
380
+ FORCE_TRUECOLOR = False
379
381
  ERROR_MESSAGES_TO_IGNORE = [
380
382
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
381
383
  'Connection to .* closed',
@@ -792,6 +794,135 @@ def get_terminal_size():
792
794
  import shutil
793
795
  _tsize = shutil.get_terminal_size(fallback=(120, 30))
794
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
+
795
926
  #%% ------------ Compacting Hostnames ----------------
796
927
  def __tokenize_hostname(hostname):
797
928
  """
@@ -2538,12 +2669,12 @@ def can_merge(line_bag1, line_bag2, threshold):
2538
2669
  return False
2539
2670
  return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
2540
2671
 
2541
- def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold):
2542
- terminal_length = get_terminal_size()[0]
2543
- output.append(('├'+'─'*(terminal_length-1)))
2672
+ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2544
2673
  indexes = {hostname: 0 for hostname in merging_hostnames}
2545
2674
  working_indexes = indexes.copy()
2546
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'([,]+)')
2547
2678
  while indexes:
2548
2679
  futures = {}
2549
2680
  defer = False
@@ -2566,9 +2697,14 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2566
2697
  break
2567
2698
  if not defer:
2568
2699
  if buddy != previousBuddies:
2569
- output.append(f"├─ {','.join(compact_hostnames(buddy))}")
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)
2570
2706
  previousBuddies = buddy
2571
- output.append(lineToAdd)
2707
+ output.append(lineToAdd.ljust(line_length - 1) + '│')
2572
2708
  for hostname in buddy:
2573
2709
  indexes[hostname] += 1
2574
2710
  if indexes[hostname] >= len(outputs_by_hostname[hostname]):
@@ -2587,29 +2723,41 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2587
2723
  futures[hostname] = (tracking_multiset, tracking_index)
2588
2724
  working_indexes = indexes.copy()
2589
2725
 
2590
- def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold):
2591
- terminal_length = get_terminal_size()[0]
2726
+ def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2592
2727
  output = []
2728
+ output.append(('┌'+'─'*(line_length-2) + '┐'))
2593
2729
  for merging_hostnames in merge_groups:
2594
- mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold)
2730
+ mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2731
+ output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2595
2732
  for hostname in remaining_hostnames:
2596
- output.append('├'+'─'*(terminal_length-1))
2597
- output.append(f"├─ {hostname}")
2598
- output.extend(outputs_by_hostname[hostname])
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:]
2599
2742
  return output
2600
2743
 
2601
- def get_host_raw_output(hosts):
2744
+ def get_host_raw_output(hosts, terminal_width):
2602
2745
  outputs_by_hostname = {}
2603
2746
  line_bag_by_hostname = {}
2604
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
2605
2751
  for host in hosts:
2606
- hostPrintOut = ["│█ EXECUTED COMMAND"]
2607
- hostPrintOut.extend(['│ ' + line for line in host['command'].splitlines()])
2752
+ hostPrintOut = ["│█ EXECUTED COMMAND:"]
2753
+ for line in host['command'].splitlines():
2754
+ hostPrintOut.extend(text_wrapper.wrap(line))
2608
2755
  lineBag = {(0,host['command'])}
2609
2756
  prevLine = host['command']
2610
2757
  if host['stdout']:
2611
2758
  hostPrintOut.append('│▓ STDOUT:')
2612
- hostPrintOut.extend(['│ ' + line for line in host['stdout']])
2759
+ for line in host['stdout']:
2760
+ hostPrintOut.extend(text_wrapper.wrap(line))
2613
2761
  lineBag.add((prevLine,1))
2614
2762
  lineBag.add((1,host['stdout'][0]))
2615
2763
  if len(host['stdout']) > 1:
@@ -2625,7 +2773,8 @@ def get_host_raw_output(hosts):
2625
2773
  host['stderr'][-1] = 'Cannot find host!'
2626
2774
  if host['stderr']:
2627
2775
  hostPrintOut.append('│▒ STDERR:')
2628
- hostPrintOut.extend(['│ ' + line for line in host['stderr']])
2776
+ for line in host['stderr']:
2777
+ hostPrintOut.extend(text_wrapper.wrap(line))
2629
2778
  lineBag.add((prevLine,2))
2630
2779
  lineBag.add((2,host['stderr'][0]))
2631
2780
  lineBag.update(host['stderr'])
@@ -2634,10 +2783,11 @@ def get_host_raw_output(hosts):
2634
2783
  prevLine = host['stderr'][-1]
2635
2784
  hostPrintOut.append(f"│░ RETURN CODE: {host['returncode']}")
2636
2785
  lineBag.add((prevLine,f"{host['returncode']}"))
2786
+ max_length = max(max_length, max(map(len, hostPrintOut)))
2637
2787
  outputs_by_hostname[host['name']] = hostPrintOut
2638
2788
  line_bag_by_hostname[host['name']] = lineBag
2639
2789
  hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
2640
- return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len)
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)
2641
2791
 
2642
2792
  def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
2643
2793
  merge_groups = []
@@ -2658,7 +2808,7 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
2658
2808
  continue
2659
2809
  if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
2660
2810
  merge_group.append(other_hostname)
2661
- hostnames_by_line_bag_len[line_bag_len].discard(this_hostname)
2811
+ hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
2662
2812
  hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
2663
2813
  if not hostnames_by_line_bag_len[other_line_bag_len]:
2664
2814
  del hostnames_by_line_bag_len[other_line_bag_len]
@@ -2714,22 +2864,26 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2714
2864
  eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2715
2865
  diff_display_threshold = 0.9
2716
2866
  terminal_length = get_terminal_size()[0]
2717
- outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys = get_host_raw_output(hosts)
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)
2718
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)
2719
2869
  # get the remaining hostnames in the hostnames_by_line_bag_len
2720
2870
  remaining_hostnames = set()
2721
2871
  for hostnames in hostnames_by_line_bag_len.values():
2722
2872
  remaining_hostnames.update(hostnames)
2723
- outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold)
2873
+ outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
2724
2874
  if keyPressesIn[-1]:
2725
2875
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2726
- outputs.append("├─ User Inputs:".ljust(terminal_length-1,'─'))
2727
- outputs.extend(CMDsOut)
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)
2728
2882
  keyPressesIn[-1].clear()
2729
2883
  if quiet and not outputs:
2730
2884
  rtnStr = 'Success'
2731
2885
  else:
2732
- rtnStr = '\n'.join(outputs + [(''+'─'*(terminal_length-1))])
2886
+ rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
2733
2887
  return rtnStr
2734
2888
 
2735
2889
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -3356,6 +3510,7 @@ def generate_default_config(args):
3356
3510
  'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
3357
3511
  'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
3358
3512
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
3513
+ 'FORCE_TRUECOLOR': args.force_truecolor,
3359
3514
  }
3360
3515
 
3361
3516
  def write_default_config(args,CONFIG_FILE = None):
@@ -3400,7 +3555,7 @@ def write_default_config(args,CONFIG_FILE = None):
3400
3555
  def get_parser():
3401
3556
  global _binPaths
3402
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}',
3403
- 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()}',)
3404
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)
3405
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.')
3406
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)
@@ -3451,6 +3606,7 @@ def get_parser():
3451
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')
3452
3607
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3453
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)
3454
3610
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3455
3611
  return parser
3456
3612
 
@@ -3553,6 +3709,7 @@ def set_global_with_args(args):
3553
3709
  global DEFAULT_IPMI_USERNAME
3554
3710
  global DEFAULT_IPMI_PASSWORD
3555
3711
  global DEFAULT_DIFF_DISPLAY_THRESHOLD
3712
+ global FORCE_TRUECOLOR
3556
3713
  _emo = False
3557
3714
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3558
3715
  _env_file = args.env_file
@@ -3565,6 +3722,7 @@ def set_global_with_args(args):
3565
3722
  if args.ipmi_password:
3566
3723
  DEFAULT_IPMI_PASSWORD = args.ipmi_password
3567
3724
  DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
3725
+ FORCE_TRUECOLOR = args.force_truecolor
3568
3726
 
3569
3727
  #%% ------------ Wrapper Block ----------------
3570
3728
  def main():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.90
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=xvYajOU_FiCfgYlGcO7AjJofnGgAj01DHarCIUeXiqU,166815
2
- multissh3-5.90.dist-info/METADATA,sha256=0kE8yqDXIrcrWqRnhgqeiZ1X3U8-odE2r6QY8uIDZKk,18093
3
- multissh3-5.90.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- multissh3-5.90.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
- multissh3-5.90.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
- multissh3-5.90.dist-info/RECORD,,