multiSSH3 5.74__py3-none-any.whl → 5.76__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
@@ -55,10 +55,10 @@ except AttributeError:
55
55
  # If neither is available, use a dummy decorator
56
56
  def cache_decorator(func):
57
57
  return func
58
- version = '5.74'
58
+ version = '5.76'
59
59
  VERSION = version
60
60
  __version__ = version
61
- COMMIT_DATE = '2025-06-03'
61
+ COMMIT_DATE = '2025-06-25'
62
62
 
63
63
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
64
64
  '~/multiSSH3.config.json',
@@ -78,6 +78,24 @@ def eprint(*args, **kwargs):
78
78
  print(f"Error: Cannot print to stderr: {e}")
79
79
  print(*args, **kwargs)
80
80
 
81
+ def _exit_with_code(code, message=None):
82
+ '''
83
+ Exit the program with a specific code and print a message
84
+
85
+ Args:
86
+ code (int): The exit code
87
+ message (str, optional): The message to print. Defaults to None.
88
+
89
+ Returns:
90
+ None
91
+ '''
92
+ global __returnZero
93
+ if message:
94
+ eprint('Exiting: '+ message)
95
+ if __returnZero:
96
+ code = 0
97
+ sys.exit(code)
98
+
81
99
  def signal_handler(sig, frame):
82
100
  '''
83
101
  Handle the Ctrl C signal
@@ -98,7 +116,7 @@ def signal_handler(sig, frame):
98
116
  # wait for 0.1 seconds to allow the threads to exit
99
117
  time.sleep(0.1)
100
118
  os.system(f'pkill -ef {os.path.basename(__file__)}')
101
- sys.exit(0)
119
+ _exit_with_code(1, 'Exiting immediately due to Ctrl C')
102
120
 
103
121
  # def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
104
122
  # """
@@ -303,6 +321,7 @@ DEFAULT_CURSES_MINIMUM_LINE_LEN = 1
303
321
  DEFAULT_SINGLE_WINDOW = False
304
322
  DEFAULT_ERROR_ONLY = False
305
323
  DEFAULT_NO_OUTPUT = False
324
+ DEFAULT_RETURN_ZERO = False
306
325
  DEFAULT_NO_ENV = False
307
326
  DEFAULT_ENV_FILE = '/etc/profile.d/hosts.sh'
308
327
  DEFAULT_NO_HISTORY = False
@@ -313,6 +332,7 @@ DEFAULT_PRINT_SUCCESS_HOSTS = False
313
332
  DEFAULT_GREPPABLE_MODE = False
314
333
  DEFAULT_SKIP_UNREACHABLE = True
315
334
  DEFAULT_SKIP_HOSTS = ''
335
+ DEFAULT_ENCODING = 'utf-8'
316
336
  SSH_STRICT_HOST_KEY_CHECKING = False
317
337
  ERROR_MESSAGES_TO_IGNORE = [
318
338
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
@@ -360,6 +380,8 @@ __curses_color_table = {}
360
380
  __curses_current_color_index = 10
361
381
  __max_connections_nofile_limit_supported = 0
362
382
  __thread_start_delay = 0
383
+ _encoding = DEFAULT_ENCODING
384
+ __returnZero = DEFAULT_RETURN_ZERO
363
385
  if __resource_lib_available:
364
386
  # Get the current limits
365
387
  _, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
@@ -1123,11 +1145,12 @@ def __handle_reading_stream(stream,target, host):
1123
1145
  Returns:
1124
1146
  None
1125
1147
  '''
1148
+ global _encoding
1126
1149
  def add_line(current_line,target, host, keepLastLine=True):
1127
1150
  if not keepLastLine:
1128
1151
  target.pop()
1129
1152
  host.output.pop()
1130
- current_line_str = current_line.decode('utf-8',errors='backslashreplace')
1153
+ current_line_str = current_line.decode(_encoding,errors='backslashreplace')
1131
1154
  target.append(current_line_str)
1132
1155
  host.output.append(current_line_str)
1133
1156
  host.lineNumToPrintSet.add(len(host.output)-1)
@@ -1158,7 +1181,7 @@ def __handle_reading_stream(stream,target, host):
1158
1181
  current_line.append(char[0])
1159
1182
  else:
1160
1183
  # curser is bigger than the length of the line
1161
- current_line += b' '*(curser_position - len(current_line)) + char
1184
+ current_line += b' '*(curser_position - len(current_line)) + char[0]
1162
1185
  curser_position += 1
1163
1186
  if time.monotonic() - previousUpdateTime > 0.1:
1164
1187
  # if the time since the last update is more than 10ms, we update the output
@@ -1181,15 +1204,16 @@ def __handle_writing_stream(stream,stop_event,host):
1181
1204
  None
1182
1205
  '''
1183
1206
  global __keyPressesIn
1207
+ global _encoding
1184
1208
  # __keyPressesIn is a list of lists.
1185
1209
  # Each list is a list of characters to be sent to the stdin of the process at once.
1186
1210
  # We do not send the last line as it may be incomplete.
1187
1211
  sentInput = 0
1188
1212
  while not stop_event.is_set():
1189
1213
  if sentInput < len(__keyPressesIn) - 1 :
1190
- stream.write(''.join(__keyPressesIn[sentInput]).encode())
1214
+ stream.write(''.join(__keyPressesIn[sentInput]).encode(encoding=_encoding,errors='backslashreplace'))
1191
1215
  stream.flush()
1192
- line = '> ' + ''.join(__keyPressesIn[sentInput]).encode().decode().replace('\n', '↵')
1216
+ line = '> ' + ''.join(__keyPressesIn[sentInput]).encode(encoding=_encoding,errors='backslashreplace').decode(encoding=_encoding,errors='backslashreplace').replace('\n', '↵')
1193
1217
  host.output.append(line)
1194
1218
  host.stdout.append(line)
1195
1219
  host.lineNumToPrintSet.add(len(host.output)-1)
@@ -1866,6 +1890,7 @@ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None, indexO
1866
1890
  return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}, rearrangedHosts
1867
1891
 
1868
1892
  def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window=DEFAULT_SINGLE_WINDOW, config_reason = 'New Configuration'):
1893
+ global _encoding
1869
1894
  _ = config_reason
1870
1895
  try:
1871
1896
  box_ansi_color = None
@@ -2101,7 +2126,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2101
2126
  stats = f"Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} ww: {min_char_len} wh:{min_line_len} i:{indexOffset} "
2102
2127
  else:
2103
2128
  # we use the stat bar to display the key presses
2104
- encodedLine = ''.join(__keyPressesIn[lineToDisplay]).encode().decode().strip('\n') + ' '
2129
+ encodedLine = ''.join(__keyPressesIn[lineToDisplay]).encode(encoding=_encoding,errors='backslashreplace').decode(encoding=_encoding,errors='backslashreplace').strip('\n') + ' '
2105
2130
  #stats = '┍'+ f"Send CMD: {encodedLine}"[:max_x - 2].center(max_x - 2, "━")
2106
2131
  # format the stats line with chracter at curser position inverted using ansi escape sequence
2107
2132
  # displayCurserPosition is needed as the curserPosition can be larger than the length of the encodedLine. This is wanted to keep scrolling through the history less painful
@@ -2253,6 +2278,7 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2253
2278
  def generate_output(hosts, usejson = False, greppable = False):
2254
2279
  global __keyPressesIn
2255
2280
  global __global_suppress_printout
2281
+ global __encoding
2256
2282
  if __global_suppress_printout:
2257
2283
  # remove hosts with returncode 0
2258
2284
  hosts = [dict(host) for host in hosts if host.returncode != 0]
@@ -2286,7 +2312,7 @@ def generate_output(hosts, usejson = False, greppable = False):
2286
2312
  rtnStr += pretty_format_table(rtnList)
2287
2313
  rtnStr += '*'*80+'\n'
2288
2314
  if __keyPressesIn[-1]:
2289
- CMDsOut = [''.join(cmd).encode('unicode_escape').decode().replace('\\n', '↵') for cmd in __keyPressesIn if cmd]
2315
+ CMDsOut = [''.join(cmd).encode(encoding=_encoding,errors='backslashreplace').decode(encoding=_encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in __keyPressesIn if cmd]
2290
2316
  rtnStr += 'User Inputs: '+ '\nUser Inputs: '.join(CMDsOut)
2291
2317
  #rtnStr += '\n'
2292
2318
  else:
@@ -2317,7 +2343,7 @@ def generate_output(hosts, usejson = False, greppable = False):
2317
2343
  if not __global_suppress_printout or outputs:
2318
2344
  rtnStr += '*'*80+'\n'
2319
2345
  if __keyPressesIn[-1]:
2320
- CMDsOut = [''.join(cmd).encode('unicode_escape').decode().replace('\\n', '↵') for cmd in __keyPressesIn if cmd]
2346
+ CMDsOut = [''.join(cmd).encode(encoding=_encoding,errors='backslashreplace').decode(encoding=_encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in __keyPressesIn if cmd]
2321
2347
  #rtnStr += f"Key presses: {''.join(__keyPressesIn).encode('unicode_escape').decode()}\n"
2322
2348
  #rtnStr += f"Key presses: {__keyPressesIn}\n"
2323
2349
  rtnStr += "User Inputs: \n "
@@ -2755,7 +2781,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2755
2781
  else:
2756
2782
  eprint(f"Warning: ssh-copy-id not found in {_binPaths} , skipping copy id to the hosts")
2757
2783
  if not commands:
2758
- sys.exit(0)
2784
+ _exit_with_code(0, "Copy id finished, no commands to run")
2759
2785
  if files and not commands:
2760
2786
  # if files are specified but not target dir, we default to file sync mode
2761
2787
  file_sync = True
@@ -2772,8 +2798,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2772
2798
  except:
2773
2799
  pathSet.update(glob.glob(file,recursive=True))
2774
2800
  if not pathSet:
2775
- eprint(f'Warning: No source files at {files!r} are found after resolving globs!')
2776
- sys.exit(66)
2801
+ _exit_with_code(66, f'No source files at {files!r} are found after resolving globs!')
2777
2802
  else:
2778
2803
  pathSet = set(files)
2779
2804
  if file_sync:
@@ -2790,7 +2815,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2790
2815
  eprint("Error: the number of commands must be the same as the number of hosts")
2791
2816
  eprint(f"Number of commands: {len(commands)}")
2792
2817
  eprint(f"Number of hosts: {len(set(targetHostDic) - set(skipHostSet))}")
2793
- sys.exit(255)
2818
+ _exit_with_code(255, "Number of commands and hosts do not match")
2794
2819
  if not __global_suppress_printout:
2795
2820
  eprint('-'*80)
2796
2821
  eprint("Running in one on one mode")
@@ -2905,6 +2930,7 @@ def generate_default_config(args):
2905
2930
  'DEFAULT_SINGLE_WINDOW': args.single_window,
2906
2931
  'DEFAULT_ERROR_ONLY': args.error_only,
2907
2932
  'DEFAULT_NO_OUTPUT': args.no_output,
2933
+ 'DEFAULT_RETURN_ZERO': args.return_zero,
2908
2934
  'DEFAULT_NO_ENV': args.no_env,
2909
2935
  'DEFAULT_ENV_FILE': args.env_file,
2910
2936
  'DEFAULT_NO_HISTORY': args.no_history,
@@ -2915,6 +2941,7 @@ def generate_default_config(args):
2915
2941
  'DEFAULT_GREPPABLE_MODE': args.greppable,
2916
2942
  'DEFAULT_SKIP_UNREACHABLE': args.skip_unreachable,
2917
2943
  'DEFAULT_SKIP_HOSTS': args.skip_hosts,
2944
+ 'DEFAULT_ENCODING': args.encoding,
2918
2945
  'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
2919
2946
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
2920
2947
  }
@@ -2938,8 +2965,7 @@ def write_default_config(args,CONFIG_FILE = None):
2938
2965
  elif inStr.lower().strip().startswith('o'):
2939
2966
  backup = False
2940
2967
  else:
2941
- eprint("Aborted")
2942
- sys.exit(1)
2968
+ _exit_with_code(0, "Aborted by user, no config file written")
2943
2969
  try:
2944
2970
  if backup and os.path.exists(CONFIG_FILE):
2945
2971
  os.rename(CONFIG_FILE,CONFIG_FILE+'.bak')
@@ -2948,8 +2974,7 @@ def write_default_config(args,CONFIG_FILE = None):
2948
2974
  eprint(f"Do you want to continue writing the new config file to {CONFIG_FILE!r}? (y/n)")
2949
2975
  inStr = input_with_timeout_and_countdown(10)
2950
2976
  if not inStr or not inStr.lower().strip().startswith('y'):
2951
- eprint("Aborted")
2952
- sys.exit(1)
2977
+ _exit_with_code(0, "Aborted by user, no config file written")
2953
2978
  try:
2954
2979
  with open(CONFIG_FILE,'w') as f:
2955
2980
  json.dump(__configs_from_file,f,indent=4)
@@ -2970,6 +2995,8 @@ def main():
2970
2995
  global _env_file
2971
2996
  global __DEBUG_MODE
2972
2997
  global __configs_from_file
2998
+ global _encoding
2999
+ global __returnZero
2973
3000
  _emo = False
2974
3001
  # We handle the signal
2975
3002
  signal.signal(signal.SIGINT, signal_handler)
@@ -3002,6 +3029,7 @@ def main():
3002
3029
  parser.add_argument('-B','-sw','--single_window', action='store_true', help=f'Use a single window for all hosts. (default: {DEFAULT_SINGLE_WINDOW})', default=DEFAULT_SINGLE_WINDOW)
3003
3030
  parser.add_argument('-R','-eo','--error_only', action='store_true', help=f'Only print the error output. (default: {DEFAULT_ERROR_ONLY})', default=DEFAULT_ERROR_ONLY)
3004
3031
  parser.add_argument('-Q',"-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
3032
+ 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)
3005
3033
  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)
3006
3034
  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)
3007
3035
  parser.add_argument("-m","--max_connections", type=int, help=f"Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
@@ -3021,6 +3049,7 @@ def main():
3021
3049
  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)
3022
3050
  parser.add_argument('-hf','--history_file', type=str, help=f'The file to store the history. (default: {DEFAULT_HISTORY_FILE})', default=DEFAULT_HISTORY_FILE)
3023
3051
  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')
3052
+ parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3024
3053
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3025
3054
 
3026
3055
  # parser.add_argument('-u', '--user', metavar='user', type=str, nargs=1,
@@ -3046,7 +3075,10 @@ def main():
3046
3075
  args.no_history = True
3047
3076
  args.greppable = True
3048
3077
  args.error_only = True
3049
-
3078
+
3079
+ if args.return_zero:
3080
+ __returnZero = True
3081
+
3050
3082
  if args.generate_config_file or args.store_config_file:
3051
3083
  if args.store_config_file:
3052
3084
  configFileToWriteTo = args.store_config_file
@@ -3062,7 +3094,7 @@ def main():
3062
3094
  if configFileToWriteTo:
3063
3095
  with open(configFileToWriteTo,'r') as f:
3064
3096
  eprint(f"Config file content: \n{f.read()}")
3065
- sys.exit(0)
3097
+ _exit_with_code(0)
3066
3098
  if args.config_file:
3067
3099
  if os.path.exists(args.config_file):
3068
3100
  __configs_from_file.update(load_config_file(os.path.expanduser(args.config_file)))
@@ -3085,7 +3117,7 @@ def main():
3085
3117
  elif inStr.lower().strip().startswith('m'):
3086
3118
  eprint(f"\nRunning multiple commands: {', '.join(args.commands)!r} on all hosts")
3087
3119
  else:
3088
- sys.exit(0)
3120
+ _exit_with_code(0, "Aborted by user, no commands to run")
3089
3121
 
3090
3122
  if args.key or args.use_key:
3091
3123
  if not args.key:
@@ -3109,6 +3141,8 @@ def main():
3109
3141
  # set timeout to the default script timeout if timeout is not set
3110
3142
  if args.timeout == DEFAULT_CLI_TIMEOUT:
3111
3143
  args.timeout = DEFAULT_TIMEOUT
3144
+
3145
+ _encoding = args.encoding
3112
3146
 
3113
3147
  if not __global_suppress_printout:
3114
3148
  cmdStr = getStrCommand(args.hosts,args.commands,
@@ -3168,7 +3202,7 @@ def main():
3168
3202
  # os.system(f'pkill -ef {os.path.basename(__file__)}')
3169
3203
  # os._exit(mainReturnCode)
3170
3204
 
3171
- sys.exit(__mainReturnCode)
3205
+ _exit_with_code(__mainReturnCode)
3172
3206
 
3173
3207
  if __name__ == "__main__":
3174
3208
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.74
3
+ Version: 5.76
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=ioUj2vZW1RiOyXRm_yJizPp751_ggMZeVtDrM6Lvvjk,150851
2
+ multissh3-5.76.dist-info/METADATA,sha256=VYwjn_-6fQHyubKyY6M0cuh6DNJaI4hIkDh4RV0qE5k,18093
3
+ multissh3-5.76.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ multissh3-5.76.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
+ multissh3-5.76.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
+ multissh3-5.76.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- multiSSH3.py,sha256=h8FnoCjcZGOjyPypzJ7H4XLOhMIKU2I-kN-m6PGdjJY,149182
2
- multissh3-5.74.dist-info/METADATA,sha256=ErFNVhzY6qUCJAiI2-paOpyfNiLJKJayjXCYiQRHJPg,18093
3
- multissh3-5.74.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- multissh3-5.74.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
- multissh3-5.74.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
- multissh3-5.74.dist-info/RECORD,,