multiSSH3 5.4__py3-none-any.whl → 5.10__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: multiSSH3
3
- Version: 5.4
3
+ Version: 5.10
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,7 @@
1
+ multiSSH3.py,sha256=KJ4gAQ8XOPLIFIeXYEr0-Whvzyn4QT27pDRHTRBvK0I,118599
2
+ multiSSH3-5.10.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ multiSSH3-5.10.dist-info/METADATA,sha256=6E5KuJPHjB2YEaRimfcn9VhIoZ0y_O8BtsYlogJcXMs,17517
4
+ multiSSH3-5.10.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
5
+ multiSSH3-5.10.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
6
+ multiSSH3-5.10.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
7
+ multiSSH3-5.10.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.2.0)
2
+ Generator: setuptools (75.3.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
multiSSH3.py CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- import curses
2
+ __curses_available = False
3
+ try:
4
+ import curses
5
+ __curses_available = True
6
+ except ImportError:
7
+ pass
3
8
  import subprocess
4
9
  import threading
5
10
  import time,os
@@ -31,7 +36,7 @@ except AttributeError:
31
36
  # If neither is available, use a dummy decorator
32
37
  def cache_decorator(func):
33
38
  return func
34
- version = '5.04'
39
+ version = '5.10'
35
40
  VERSION = version
36
41
 
37
42
  CONFIG_FILE = '/etc/multiSSH3.config.json'
@@ -39,7 +44,11 @@ CONFIG_FILE = '/etc/multiSSH3.config.json'
39
44
  import sys
40
45
 
41
46
  def eprint(*args, **kwargs):
42
- print(*args, file=sys.stderr, **kwargs)
47
+ try:
48
+ print(*args, file=sys.stderr, **kwargs)
49
+ except Exception as e:
50
+ print(f"Error: Cannot print to stderr: {e}")
51
+ print(*args, **kwargs)
43
52
 
44
53
  def load_config_file(config_file):
45
54
  '''
@@ -57,7 +66,7 @@ def load_config_file(config_file):
57
66
  with open(config_file,'r') as f:
58
67
  config = json.load(f)
59
68
  except:
60
- eprint(f"Error: Cannot load config file {config_file}")
69
+ eprint(f"Error: Cannot load config file {config_file!r}")
61
70
  return {}
62
71
  return config
63
72
 
@@ -200,7 +209,7 @@ def get_i():
200
209
  return __host_i_counter
201
210
 
202
211
  class Host:
203
- def __init__(self, name, command, files = None,ipmi = False,interface_ip_prefix = None,scp=False,extraargs=None,gatherMode=False,identity_file=None):
212
+ def __init__(self, name, command, files = None,ipmi = False,interface_ip_prefix = None,scp=False,extraargs=None,gatherMode=False,identity_file=None,bash=False,i = get_i(),uuid=uuid.uuid4()):
204
213
  self.name = name # the name of the host (hostname or IP address)
205
214
  self.command = command # the command to run on the host
206
215
  self.returncode = None # the return code of the command
@@ -211,14 +220,15 @@ class Host:
211
220
  self.lastUpdateTime = time.time() # the last time the output was updated
212
221
  self.files = files # the files to be copied to the host
213
222
  self.ipmi = ipmi # whether to use ipmi to connect to the host
223
+ self.bash = bash # whether to use bash to run the command
214
224
  self.interface_ip_prefix = interface_ip_prefix # the prefix of the ip address of the interface to be used to connect to the host
215
225
  self.scp = scp # whether to use scp to copy files to the host
216
226
  self.gatherMode = gatherMode # whether the host is in gather mode
217
227
  self.extraargs = extraargs # extra arguments to be passed to ssh
218
228
  self.resolvedName = None # the resolved IP address of the host
219
229
  # also store a globally unique integer i from 0
220
- self.i = get_i()
221
- self.uuid = uuid.uuid4()
230
+ self.i = i
231
+ self.uuid = uuid
222
232
  self.identity_file = identity_file
223
233
 
224
234
  def __iter__(self):
@@ -265,8 +275,323 @@ def check_path(program_name):
265
275
 
266
276
  [check_path(program) for program in ['sshpass', 'ssh', 'scp', 'ipmitool','rsync','bash','ssh-copy-id']]
267
277
 
278
+ #%%
279
+ def tokenize_hostname(hostname):
280
+ """
281
+ Tokenize the hostname into a list of tokens.
282
+ Tokens will be separated by symbols or numbers.
283
+
284
+ Args:
285
+ hostname (str): The hostname to tokenize.
286
+
287
+ Returns:
288
+ list: A list of tokens.
289
+
290
+ Example:
291
+ >>> tokenize_hostname('www.example.com')
292
+ ('www', '.', 'example', '.', 'com')
293
+ >>> tokenize_hostname('localhost')
294
+ ('localhost',)
295
+ >>> tokenize_hostname('Sub-S1')
296
+ ('Sub', '-', 'S', '1')
297
+ >>> tokenize_hostname('Sub-S10')
298
+ ('Sub', '-', 'S', '10')
299
+ >>> tokenize_hostname('Process-Client10-1')
300
+ ('Process', '-', 'Client', '10', '-', '1')
301
+ >>> tokenize_hostname('Process-C5-15')
302
+ ('Process', '-', 'C', '5', '-', '15')
303
+ >>> tokenize_hostname('192.168.1.1')
304
+ ('192', '.', '168', '.', '1', '.', '1')
305
+ """
306
+ # Regular expression to match sequences of letters, digits, or symbols
307
+ tokens = re.findall(r'[A-Za-z]+|\d+|[^A-Za-z0-9]', hostname)
308
+ return tuple(tokens)
309
+
310
+ @cache_decorator
311
+ def hashTokens(tokens):
312
+ """
313
+ Translate a list of tokens in string to a list of integers with positional information.
314
+
315
+ Args:
316
+ tokens (tuple): A tuple of tokens.
317
+
318
+ Returns:
319
+ list: A list of integers.
320
+
321
+ Example:
322
+ >>> tuple(hashTokens(('1')))
323
+ (1,)
324
+ >>> tuple(hashTokens(('1', '2')))
325
+ (1, 2)
326
+ >>> tuple(hashTokens(('1', '.', '2')))
327
+ (1, -5047856122680242044, 2)
328
+ >>> tuple(hashTokens(('Process', '-', 'C', '5', '-', '15')))
329
+ (117396829274297939, 7549860403020794775, 8629208860073383633, 5, 7549860403020794775, 15)
330
+ >>> tuple(hashTokens(('192', '.', '168', '.', '1', '.', '1')))
331
+ (192, -5047856122680242044, 168, -5047856122680242044, 1, -5047856122680242044, 1)
332
+ """
333
+ return tuple(int(token) if token.isdigit() else hash(token) for token in tokens)
334
+
335
+ def findDiffIndex(token1, token2):
336
+ """
337
+ Find the index of the first difference between two lists of tokens.
338
+ If there is more than one difference, return -1.
339
+
340
+ Args:
341
+ token1 (tuple): A list of tokens.
342
+ token2 (tuple): A list of tokens.
343
+
344
+ Returns:
345
+ int: The index of the first difference between the two lists of tokens.
346
+
347
+ Example:
348
+ >>> findDiffIndex(('1',), ('1',))
349
+ -1
350
+ >>> findDiffIndex(('1','2'), ('1', '1'))
351
+ 1
352
+ >>> findDiffIndex(('1','1'), ('1', '1', '1'))
353
+ Traceback (most recent call last):
354
+ ...
355
+ ValueError: The two lists must have the same length.
356
+ >>> findDiffIndex(('192', '.', '168', '.', '2', '.', '1'), ('192', '.', '168', '.', '1', '.', '1'))
357
+ 4
358
+ >>> findDiffIndex(('192', '.', '168', '.', '2', '.', '1'), ('192', '.', '168', '.', '1', '.', '2'))
359
+ -1
360
+ >>> findDiffIndex(('Process', '-', 'C', '5', '-', '15'), ('Process', '-', 'C', '5', '-', '15'))
361
+ -1
362
+ >>> findDiffIndex(('Process', '-', 'C', '5', '-', '15'), ('Process', '-', 'C', '5', '-', '16'))
363
+ 5
364
+ >>> findDiffIndex(tokenize_hostname('nebulahost3'), tokenize_hostname('nebulaleaf3'))
365
+ -1
366
+ >>> findDiffIndex(tokenize_hostname('nebulaleaf3'), tokenize_hostname('nebulaleaf4'))
367
+ 1
368
+ """
369
+ if len(token1) != len(token2):
370
+ raise ValueError('The two lists must have the same length.')
371
+ rtn = -1
372
+ for i, (subToken1, subToken2) in enumerate(zip(token1, token2)):
373
+ if subToken1 != subToken2:
374
+ if rtn == -1 and subToken1.isdigit() and subToken2.isdigit():
375
+ rtn = i
376
+ else:
377
+ return -1
378
+ return rtn
379
+
380
+ def generateSumDic(Hostnames):
381
+ """
382
+ Generate a dictionary of sums of tokens for a list of hostnames.
383
+
384
+ Args:
385
+ Hostnames (list): A list of hostnames.
386
+
387
+ Example:
388
+ >>> generateSumDic(['localhost'])
389
+ {6564370170492138900: {('localhost',): {}}}
390
+ >>> generateSumDic(['1', '2'])
391
+ {1: {('1',): {}}, 2: {('2',): {}}}
392
+ >>> generateSumDic(['1.1','1.2'])
393
+ {3435203479547611399: {('1', '.', '1'): {}}, 3435203479547611400: {('1', '.', '2'): {}}}
394
+ >>> generateSumDic(['1.2','2.1'])
395
+ {3435203479547611400: {('1', '.', '2'): {}, ('2', '.', '1'): {}}}
396
+ """
397
+ sumDic = {}
398
+ for hostname in reversed(sorted(Hostnames)):
399
+ tokens = tokenize_hostname(hostname)
400
+ sumHash = sum(hashTokens(tokens))
401
+ sumDic.setdefault(sumHash, {})[tokens] = {}
402
+ return sumDic
403
+
404
+ def filterSumDic(sumDic):
405
+ """
406
+ Filter the sumDic to do one order of grouping.
407
+
408
+ Args:
409
+ sumDic (dict): A dictionary of sums of tokens.
410
+
411
+ Returns:
412
+ dict: A filtered dictionary of sums of tokens.
413
+
414
+ Example:
415
+ >>> filterSumDic(generateSumDic(['server15', 'server16', 'server17']))
416
+ {-6728831096159691241: {('server', '17'): {(1, 0): [15, 17]}}}
417
+ >>> filterSumDic(generateSumDic(['server15', 'server16', 'server17', 'server18']))
418
+ {-6728831096159691240: {('server', '18'): {(1, 0): [15, 18]}}}
419
+ >>> filterSumDic(generateSumDic(['server-1', 'server-2', 'server-3']))
420
+ {1441623239094376437: {('server', '-', '3'): {(2, 0): [1, 3]}}}
421
+ >>> filterSumDic(generateSumDic(['server-1-2', 'server-1-1', 'server-2-1', 'server-2-2']))
422
+ {9612077574348444129: {('server', '-', '1', '-', '2'): {(4, 0): [1, 2]}}, 9612077574348444130: {('server', '-', '2', '-', '2'): {(4, 0): [1, 2]}}}
423
+ >>> filterSumDic(generateSumDic(['server-1-2', 'server-1-1', 'server-2-2']))
424
+ {9612077574348444129: {('server', '-', '1', '-', '2'): {(4, 0): [1, 2]}}, 9612077574348444130: {('server', '-', '2', '-', '2'): {}}}
425
+ >>> filterSumDic(generateSumDic(['test1-a', 'test2-a']))
426
+ {12310874833182455839: {('test', '2', '-', 'a'): {(1, 0): [1, 2]}}}
427
+ >>> filterSumDic(generateSumDic(['sub-s1', 'sub-s2']))
428
+ {15455586825715425366: {('sub', '-', 's', '2'): {(3, 0): [1, 2]}}}
429
+ >>> filterSumDic(generateSumDic(['s9', 's10', 's11']))
430
+ {1169697225593811728: {('s', '11'): {(1, 0): [9, 11]}}}
431
+ >>> filterSumDic(generateSumDic(['s99', 's98', 's100','s101']))
432
+ {1169697225593811818: {('s', '101'): {(1, 0): [98, 101]}}}
433
+ >>> filterSumDic(generateSumDic(['s08', 's09', 's10', 's11']))
434
+ {1169697225593811728: {('s', '11'): {(1, 2): [8, 11]}}}
435
+ >>> filterSumDic(generateSumDic(['s099', 's098', 's100','s101']))
436
+ {1169697225593811818: {('s', '101'): {(1, 3): [98, 101]}}}
437
+ >>> filterSumDic(generateSumDic(['server1', 'server2', 'server3','server04']))
438
+ {-6728831096159691255: {('server', '3'): {(1, 0): [1, 3]}}, -6728831096159691254: {('server', '04'): {}}}
439
+ >>> filterSumDic(generateSumDic(['server9', 'server09', 'server10','server10']))
440
+ {-6728831096159691249: {('server', '09'): {}}, -6728831096159691248: {('server', '10'): {(1, 0): [9, 10]}}}
441
+ >>> filterSumDic(generateSumDic(['server09', 'server9', 'server10']))
442
+ {-6728831096159691249: {('server', '9'): {}}, -6728831096159691248: {('server', '10'): {(1, 2): [9, 10]}}}
443
+ """
444
+ lastSumHash = None
445
+ newSumDic = {}
446
+ for key, value in sumDic.items():
447
+ newSumDic[key] = value.copy()
448
+ sumDic = newSumDic
449
+ newSumDic = {}
450
+ for sumHash in sorted(sumDic):
451
+ if lastSumHash is None:
452
+ lastSumHash = sumHash
453
+ newSumDic[sumHash] = sumDic[sumHash].copy()
454
+ continue
455
+ if sumHash - lastSumHash == 1:
456
+ # this means the distence between these two group of hostnames is 1, thus we try to group them together
457
+ for hostnameTokens in sumDic[sumHash]:
458
+ added = False
459
+ if lastSumHash in newSumDic and sumDic[lastSumHash]:
460
+ for lastHostnameTokens in sumDic[lastSumHash].copy():
461
+ # if the two hostnames are able to group, we group them together
462
+ # the two hostnames are able to group if:
463
+ # 1. the two hostnames have the same amount of tokens
464
+ # 2. the last hostname is not already been grouped
465
+ # 3. the two hostnames have the same tokens except for one token
466
+ # 4. the two hostnames have the same token groups
467
+ if len(hostnameTokens) == len(lastHostnameTokens) and \
468
+ lastSumHash in newSumDic and lastHostnameTokens in newSumDic[lastSumHash] and \
469
+ (diffIndex:=findDiffIndex(hostnameTokens, lastHostnameTokens)) != -1 and \
470
+ sumDic[sumHash][hostnameTokens] == sumDic[lastSumHash][lastHostnameTokens]:
471
+ # the sumDic[sumHash][hostnameTokens] will ba a dic of 2 element value lists with 2 element key representing:
472
+ # (token position that got grouped, the amount of zero padding (length) ):
473
+ # [ the start int token, the end int token]
474
+ # if we entered here, this means we are able to group the two hostnames together
475
+
476
+ if not diffIndex:
477
+ # should never happen, but just in case, we skip grouping
478
+ continue
479
+ tokenToGroup = hostnameTokens[diffIndex]
480
+ try:
481
+ tokenLength = len(tokenToGroup)
482
+ tokenToGroup = int(tokenToGroup)
483
+ except ValueError:
484
+ # if the token is not an int, we skip grouping
485
+ continue
486
+ # group(09 , 10) -> (x, 2): [9, 10]
487
+ # group(9 , 10) -> (x, 0): [9, 10]
488
+ # group(9 , 010) -> not able to group
489
+ # group(009 , 10) -> not able to group
490
+ # group(08, 09) -> (x, 2): [8, 9]
491
+ # group(08, 9) -> not able to group
492
+ # group(8, 09) -> not able to group
493
+ # group(0099, 0100) -> (x, 4): [99, 100]
494
+ # group(0099, 100) -> not able to groups
495
+ # group(099, 100) -> (x, 3): [99, 100]
496
+ # group(99, 100) -> (x, 0): [99, 100]
497
+ lastTokenToGroup = lastHostnameTokens[diffIndex]
498
+ try:
499
+ minimumTokenLength = 0
500
+ lastTokenLength = len(lastTokenToGroup)
501
+ if lastTokenLength > tokenLength:
502
+ raise ValueError('The last token is longer than the current token.')
503
+ elif lastTokenLength < tokenLength:
504
+ if tokenLength - lastTokenLength != 1:
505
+ raise ValueError('The last token is not one less than the current token.')
506
+ # if the last token is not made out of all 9s, we cannot group
507
+ if any(c != '9' for c in lastTokenToGroup):
508
+ raise ValueError('The last token is not made out of all 9s.')
509
+ elif lastTokenToGroup[0] == '0' and lastTokenLength > 1:
510
+ # we have encoutered a padded last token, will set this as the minimum token length
511
+ minimumTokenLength = lastTokenLength
512
+ lastTokenToGroup = int(lastTokenToGroup)
513
+ except ValueError:
514
+ # if the token is not an int, we skip grouping
515
+ continue
516
+ assert lastTokenToGroup + 1 == tokenToGroup, 'Error! The two tokens are not one apart.'
517
+ # we take the last hostname tokens grouped dic out from the newSumDic
518
+ hostnameGroupDic = newSumDic[lastSumHash][lastHostnameTokens].copy()
519
+ if (diffIndex, minimumTokenLength) in hostnameGroupDic and hostnameGroupDic[(diffIndex, minimumTokenLength)][1] + 1 == tokenToGroup:
520
+ # if the token is already grouped, we just update the end token
521
+ hostnameGroupDic[(diffIndex, minimumTokenLength)][1] = tokenToGroup
522
+ elif (diffIndex, tokenLength) in hostnameGroupDic and hostnameGroupDic[(diffIndex, tokenLength)][1] + 1 == tokenToGroup:
523
+ # alternatively, there is already an exact length padded token grouped
524
+ hostnameGroupDic[(diffIndex, tokenLength)][1] = tokenToGroup
525
+ elif sumDic[lastSumHash][lastHostnameTokens] == newSumDic[lastSumHash][lastHostnameTokens]:
526
+ # only when there are no new groups added to this token group this iter, we can add the new group
527
+ hostnameGroupDic[(diffIndex, minimumTokenLength)] = [lastTokenToGroup, tokenToGroup]
528
+ else:
529
+ # skip grouping if there are new groups added to this token group this iter
530
+ continue
531
+ # move the grouped dic under the new hostname / sum hash
532
+ del newSumDic[lastSumHash][lastHostnameTokens]
533
+ del sumDic[lastSumHash][lastHostnameTokens]
534
+ if not newSumDic[lastSumHash]:
535
+ del newSumDic[lastSumHash]
536
+ newSumDic.setdefault(sumHash, {})[hostnameTokens] = hostnameGroupDic
537
+ # we add the new group to the newSumDic
538
+ added = True
539
+ break
540
+ if not added:
541
+ # if the two hostnames are not able to group, we just add the last group to the newSumDic
542
+ newSumDic.setdefault(sumHash, {})[hostnameTokens] = sumDic[sumHash][hostnameTokens].copy()
543
+ else:
544
+ # this means the distence between these two group of hostnames is not 1, thus we just add the last group to the newSumDic
545
+ newSumDic[sumHash] = sumDic[sumHash].copy()
546
+ lastSumHash = sumHash
547
+ return newSumDic
548
+
549
+ def compact_hostnames(Hostnames):
550
+ """
551
+ Compact a list of hostnames.
552
+ Compact numeric numbers into ranges.
268
553
 
554
+ Args:
555
+ Hostnames (list): A list of hostnames.
269
556
 
557
+ Returns:
558
+ list: A list of comapcted hostname list.
559
+
560
+ Example:
561
+ >>> compact_hostnames(['server15', 'server16', 'server17'])
562
+ ['server[15-17]']
563
+ >>> compact_hostnames(['server-1', 'server-2', 'server-3'])
564
+ ['server-[1-3]']
565
+ >>> compact_hostnames(['server-1-2', 'server-1-1', 'server-2-1', 'server-2-2'])
566
+ ['server-[1-2]-[1-2]']
567
+ >>> compact_hostnames(['server-1-2', 'server-1-1', 'server-2-2'])
568
+ ['server-1-[1-2]', 'server-2-2']
569
+ >>> compact_hostnames(['test1-a', 'test2-a'])
570
+ ['test[1-2]-a']
571
+ >>> compact_hostnames(['sub-s1', 'sub-s2'])
572
+ ['sub-s[1-2]']
573
+ """
574
+ sumDic = generateSumDic(Hostnames)
575
+ filteredSumDic = filterSumDic(sumDic)
576
+ lastFilteredSumDicLen = len(filteredSumDic) + 1
577
+ while lastFilteredSumDicLen > len(filteredSumDic):
578
+ lastFilteredSumDicLen = len(filteredSumDic)
579
+ filteredSumDic = filterSumDic(filteredSumDic)
580
+ rtnSet = set()
581
+ for sumHash in filteredSumDic:
582
+ for hostnameTokens in filteredSumDic[sumHash]:
583
+ hostnameGroupDic = filteredSumDic[sumHash][hostnameTokens]
584
+ hostnameList = list(hostnameTokens)
585
+ for tokenIndex, tokenLength in hostnameGroupDic:
586
+ startToken, endToken = hostnameGroupDic[(tokenIndex, tokenLength)]
587
+ if tokenLength:
588
+ hostnameList[tokenIndex] = f'[{startToken:0{tokenLength}d}-{endToken:0{tokenLength}d}]'
589
+ else:
590
+ hostnameList[tokenIndex] = f'[{startToken}-{endToken}]'
591
+ rtnSet.add(''.join(hostnameList))
592
+ return rtnSet
593
+
594
+ #%%
270
595
  @cache_decorator
271
596
  def expandIPv4Address(hosts):
272
597
  '''
@@ -324,6 +649,8 @@ def getIP(hostname,local=False):
324
649
  str: The IP address of the hostname
325
650
  '''
326
651
  global _etc_hosts
652
+ if '@' in hostname:
653
+ _, hostname = hostname.rsplit('@',1)
327
654
  # First we check if the hostname is an IP address
328
655
  try:
329
656
  ipaddress.ip_address(hostname)
@@ -385,7 +712,7 @@ def readEnvFromFile(environemnt_file = ''):
385
712
  return env
386
713
 
387
714
  @cache_decorator
388
- def expand_hostname(text,validate=True):
715
+ def old_expand_hostname(text,validate=True):
389
716
  '''
390
717
  Expand the hostname range in the text.
391
718
  Will search the string for a range ( [] encloused and non enclosed number ranges).
@@ -464,6 +791,64 @@ def expand_hostname(text,validate=True):
464
791
  expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
465
792
  return expandedhosts
466
793
 
794
+ @cache_decorator
795
+ def expand_hostname(text, validate=True):
796
+ '''
797
+ Expand the hostname range in the text.
798
+ Will search the string for a range ( [] enclosed and non-enclosed number ranges).
799
+ Will expand the range, validate them using validate_expand_hostname and return a list of expanded hostnames
800
+
801
+ Args:
802
+ text (str): The text to be expanded
803
+ validate (bool, optional): Whether to validate the hostname. Defaults to True.
804
+
805
+ Returns:
806
+ set: A set of expanded hostnames
807
+ '''
808
+ expandinghosts = [text]
809
+ expandedhosts = set()
810
+ # all valid alphanumeric characters
811
+ alphanumeric = string.digits + string.ascii_letters
812
+ while len(expandinghosts) > 0:
813
+ hostname = expandinghosts.pop()
814
+ match = re.search(r'\[(.*?)]', hostname)
815
+ if not match:
816
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
817
+ continue
818
+ group = match.group(1)
819
+ parts = group.split(',')
820
+ for part in parts:
821
+ part = part.strip()
822
+ if '-' in part:
823
+ try:
824
+ range_start,_, range_end = part.partition('-')
825
+ except ValueError:
826
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
827
+ continue
828
+ range_start = range_start.strip()
829
+ range_end = range_end.strip()
830
+ if range_start.isdigit() and range_end.isdigit():
831
+ padding_length = min(len(range_start), len(range_end))
832
+ format_str = "{:0" + str(padding_length) + "d}"
833
+ for i in range(int(range_start), int(range_end) + 1):
834
+ formatted_i = format_str.format(i)
835
+ expandinghosts.append(hostname.replace(match.group(0), formatted_i, 1))
836
+ elif all(c in string.hexdigits for c in range_start + range_end):
837
+ for i in range(int(range_start, 16), int(range_end, 16) + 1):
838
+ expandinghosts.append(hostname.replace(match.group(0), format(i, 'x'), 1))
839
+ else:
840
+ try:
841
+ start_index = alphanumeric.index(range_start)
842
+ end_index = alphanumeric.index(range_end)
843
+ for i in range(start_index, end_index + 1):
844
+ expandinghosts.append(hostname.replace(match.group(0), alphanumeric[i], 1))
845
+ except ValueError:
846
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
847
+ else:
848
+ expandinghosts.append(hostname.replace(match.group(0), part, 1))
849
+ return expandedhosts
850
+
851
+
467
852
  @cache_decorator
468
853
  def expand_hostnames(hosts):
469
854
  '''
@@ -529,7 +914,7 @@ def validate_expand_hostname(hostname):
529
914
  elif getIP(hostname,local=False):
530
915
  return [hostname]
531
916
  else:
532
- eprint(f"Error: {hostname} is not a valid hostname or IP address!")
917
+ eprint(f"Error: {hostname!r} is not a valid hostname or IP address!")
533
918
  global __mainReturnCode
534
919
  __mainReturnCode += 1
535
920
  global __failedHosts
@@ -623,8 +1008,9 @@ def handle_writing_stream(stream,stop_event,host):
623
1008
  if sentInput < len(__keyPressesIn) - 1 :
624
1009
  stream.write(''.join(__keyPressesIn[sentInput]).encode())
625
1010
  stream.flush()
626
- host.output.append(' $ ' + ''.join(__keyPressesIn[sentInput]).encode().decode().replace('\n', '↵'))
627
- host.stdout.append(' $ ' + ''.join(__keyPressesIn[sentInput]).encode().decode().replace('\n', '↵'))
1011
+ line = '> ' + ''.join(__keyPressesIn[sentInput]).encode().decode().replace('\n', '↵')
1012
+ host.output.append(line)
1013
+ host.stdout.append(line)
628
1014
  sentInput += 1
629
1015
  host.lastUpdateTime = time.time()
630
1016
  else:
@@ -667,7 +1053,7 @@ def replace_magic_strings(string,keys,value,case_sensitive=False):
667
1053
  string = re.sub(re.escape(key),value,string,flags=re.IGNORECASE)
668
1054
  return string
669
1055
 
670
- def ssh_command(host, sem, timeout=60,passwds=None):
1056
+ def run_command(host, sem, timeout=60,passwds=None):
671
1057
  '''
672
1058
  Run the command on the host. Will format the commands accordingly. Main execution function.
673
1059
 
@@ -706,8 +1092,6 @@ def ssh_command(host, sem, timeout=60,passwds=None):
706
1092
  host.command = replace_magic_strings(host.command,['#ID#'],str(id(host)),case_sensitive=False)
707
1093
  host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
708
1094
  host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
709
- if host.resolvedName:
710
- host.command = replace_magic_strings(host.command,['#RESOLVEDNAME#','#RESOLVED#'],host.resolvedName,case_sensitive=False)
711
1095
  host.command = replace_magic_strings(host.command,['#UUID#'],str(host.uuid),case_sensitive=False)
712
1096
  formatedCMD = []
713
1097
  if host.extraargs and type(host.extraargs) == str:
@@ -729,6 +1113,8 @@ def ssh_command(host, sem, timeout=60,passwds=None):
729
1113
  host.resolvedName = host.name
730
1114
  else:
731
1115
  host.resolvedName = host.name
1116
+ if host.resolvedName:
1117
+ host.command = replace_magic_strings(host.command,['#RESOLVEDNAME#','#RESOLVED#'],host.resolvedName,case_sensitive=False)
732
1118
  if host.ipmi:
733
1119
  if 'ipmitool' in _binPaths:
734
1120
  if host.command.startswith('ipmitool '):
@@ -737,30 +1123,50 @@ def ssh_command(host, sem, timeout=60,passwds=None):
737
1123
  host.command = host.command.replace(_binPaths['ipmitool'],'')
738
1124
  if not host.username:
739
1125
  host.username = 'admin'
1126
+ if not host.command:
1127
+ host.command = 'power status'
740
1128
  if 'bash' in _binPaths:
741
1129
  if passwds:
742
1130
  formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
743
1131
  else:
744
- formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} {" ".join(extraargs)} {host.command}']
1132
+ host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1133
+ formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} -P admin {" ".join(extraargs)} {host.command}']
745
1134
  else:
746
1135
  if passwds:
747
1136
  formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
748
1137
  else:
749
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}'] + extraargs + [host.command]
1138
+ host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1139
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P admin'] + extraargs + [host.command]
750
1140
  elif 'ssh' in _binPaths:
751
1141
  host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
752
1142
  if __DEBUG_MODE:
753
1143
  host.stderr.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
754
1144
  host.ipmi = False
755
1145
  host.interface_ip_prefix = None
756
- host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
757
- ssh_command(host,sem,timeout,passwds)
1146
+ if not host.command:
1147
+ host.command = 'ipmitool power status'
1148
+ else:
1149
+ host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
1150
+ run_command(host,sem,timeout,passwds)
758
1151
  return
759
1152
  else:
760
1153
  host.output.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
761
1154
  host.stderr.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
762
1155
  host.returncode = 1
763
1156
  return
1157
+ elif host.bash:
1158
+ if 'bash' in _binPaths:
1159
+ host.output.append('Running command in bash mode, ignoring the hosts...')
1160
+ if __DEBUG_MODE:
1161
+ host.stderr.append('Running command in bash mode, ignoring the hosts...')
1162
+ formatedCMD = [_binPaths['bash'],'-c',host.command]
1163
+ else:
1164
+ host.output.append('Bash not found on the local machine! Using ssh localhost instead...')
1165
+ if __DEBUG_MODE:
1166
+ host.stderr.append('Bash not found on the local machine! Using ssh localhost instead...')
1167
+ host.bash = False
1168
+ host.name = 'localhost'
1169
+ run_command(host,sem,timeout,passwds)
764
1170
  else:
765
1171
  if host.files:
766
1172
  if host.scp:
@@ -916,7 +1322,7 @@ def ssh_command(host, sem, timeout=60,passwds=None):
916
1322
  host.ipmi = False
917
1323
  host.interface_ip_prefix = None
918
1324
  host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
919
- ssh_command(host,sem,timeout,passwds)
1325
+ run_command(host,sem,timeout,passwds)
920
1326
  # If transfering files, we will try again using scp if rsync connection is not successful
921
1327
  if host.files and not host.scp and not useScp and host.returncode != 0 and host.stderr:
922
1328
  host.stderr = []
@@ -925,11 +1331,11 @@ def ssh_command(host, sem, timeout=60,passwds=None):
925
1331
  if __DEBUG_MODE:
926
1332
  host.stderr.append('Rsync connection failed! Trying SCP connection...')
927
1333
  host.scp = True
928
- ssh_command(host,sem,timeout,passwds)
1334
+ run_command(host,sem,timeout,passwds)
929
1335
 
930
1336
  def start_run_on_hosts(hosts, timeout=60,password=None,max_connections=4 * os.cpu_count()):
931
1337
  '''
932
- Start running the command on the hosts. Wrapper function for ssh_command
1338
+ Start running the command on the hosts. Wrapper function for run_command
933
1339
 
934
1340
  Args:
935
1341
  hosts (list): A list of Host objects
@@ -943,7 +1349,7 @@ def start_run_on_hosts(hosts, timeout=60,password=None,max_connections=4 * os.cp
943
1349
  if len(hosts) == 0:
944
1350
  return []
945
1351
  sem = threading.Semaphore(max_connections) # Limit concurrent SSH sessions
946
- threads = [threading.Thread(target=ssh_command, args=(host, sem,timeout,password), daemon=True) for host in hosts]
1352
+ threads = [threading.Thread(target=run_command, args=(host, sem,timeout,password), daemon=True) for host in hosts]
947
1353
  for thread in threads:
948
1354
  thread.start()
949
1355
  return threads
@@ -980,13 +1386,6 @@ def get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None):
980
1386
  host.printedLines = 0
981
1387
  return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}
982
1388
 
983
- # Error: integer division or modulo by zero, Reloading Configuration: min_char_len=40, min_line_len=1, single_window=False with window size (61, 186) and 1 hosts...
984
- # Traceback (most recent call last):
985
- # File "/usr/local/lib/python3.11/site-packages/multiSSH3.py", line 1030, in generate_display
986
- # host_window_height = max_y // num_hosts_y
987
- # ~~~~~~^^~~~~~~~~~~~~
988
- # ZeroDivisionError: integer division or modulo by zero
989
-
990
1389
  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'):
991
1390
  try:
992
1391
  org_dim = stdscr.getmaxyx()
@@ -1321,15 +1720,21 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1321
1720
  if host['stderr']:
1322
1721
  if host['stderr'][0].strip().startswith('ssh: connect to host '):
1323
1722
  host['stderr'][0] = 'SSH not reachable!'
1723
+ elif host['stderr'][-1].strip().endswith('Connection timed out'):
1724
+ host['stderr'][-1] = 'SSH connection timed out!'
1725
+ elif host['stderr'][-1].strip().endswith('No route to host'):
1726
+ host['stderr'][-1] = 'Cannot find host!'
1324
1727
  hostPrintOut += " | stderr: "+'↵ '.join(host['stderr'])
1325
1728
  hostPrintOut += f" | return_code: {host['returncode']}"
1326
- if hostPrintOut not in outputs:
1327
- outputs[hostPrintOut] = [host['name']]
1328
- else:
1329
- outputs[hostPrintOut].append(host['name'])
1729
+ outputs.setdefault(hostPrintOut, set()).add(host['name'])
1330
1730
  rtnStr = '*'*80+'\n'
1331
- for output, hosts in outputs.items():
1332
- rtnStr += f"{','.join(hosts)}{output}\n"
1731
+ for output, hostSet in outputs.items():
1732
+ compact_hosts = compact_hostnames(hostSet)
1733
+ if expand_hostnames(frozenset(compact_hosts)) != expand_hostnames(frozenset(hostSet)):
1734
+ eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
1735
+ rtnStr += f"{','.join(hostSet)}{output}\n"
1736
+ else:
1737
+ rtnStr += f"{','.join(compact_hosts)}{output}\n"
1333
1738
  rtnStr += '*'*80+'\n'
1334
1739
  if __keyPressesIn[-1]:
1335
1740
  CMDsOut = [''.join(cmd).encode('unicode_escape').decode().replace('\\n', '↵') for cmd in __keyPressesIn if cmd]
@@ -1346,20 +1751,25 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1346
1751
  if host['stderr']:
1347
1752
  if host['stderr'][0].strip().startswith('ssh: connect to host '):
1348
1753
  host['stderr'][0] = 'SSH not reachable!'
1754
+ elif host['stderr'][-1].strip().endswith('Connection timed out'):
1755
+ host['stderr'][-1] = 'SSH connection timed out!'
1756
+ elif host['stderr'][-1].strip().endswith('No route to host'):
1757
+ host['stderr'][-1] = 'Cannot find host!'
1349
1758
  hostPrintOut += "\n stderr:\n "+'\n '.join(host['stderr'])
1350
1759
  hostPrintOut += f"\n return_code: {host['returncode']}"
1351
- if hostPrintOut not in outputs:
1352
- outputs[hostPrintOut] = [host['name']]
1353
- else:
1354
- outputs[hostPrintOut].append(host['name'])
1760
+ outputs.setdefault(hostPrintOut, set()).add(host['name'])
1355
1761
  rtnStr = ''
1356
- for output, hosts in outputs.items():
1762
+ for output, hostSet in outputs.items():
1763
+ compact_hosts = compact_hostnames(hostSet)
1764
+ if expand_hostnames(frozenset(compact_hosts)) != expand_hostnames(frozenset(hostSet)):
1765
+ eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
1766
+ compact_hosts = hostSet
1357
1767
  if __global_suppress_printout:
1358
- rtnStr += f'Abnormal returncode produced by {hosts}:\n'
1768
+ rtnStr += f'Abnormal returncode produced by {",".join(compact_hosts)}:\n'
1359
1769
  rtnStr += output+'\n'
1360
1770
  else:
1361
1771
  rtnStr += '*'*80+'\n'
1362
- rtnStr += f"These hosts: {hosts} have a response of:\n"
1772
+ rtnStr += f'These hosts: {",".join(compact_hosts)} have a response of:\n'
1363
1773
  rtnStr += output+'\n'
1364
1774
  if not __global_suppress_printout or outputs:
1365
1775
  rtnStr += '*'*80+'\n'
@@ -1377,35 +1787,6 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1377
1787
  print(rtnStr)
1378
1788
  return rtnStr
1379
1789
 
1380
- # sshConfigged = False
1381
- # def verify_ssh_config():
1382
- # '''
1383
- # Verify that ~/.ssh/config exists and contains the line "StrictHostKeyChecking no"
1384
-
1385
- # Args:
1386
- # None
1387
-
1388
- # Returns:
1389
- # None
1390
- # '''
1391
- # global sshConfigged
1392
- # if not sshConfigged:
1393
- # # first we make sure ~/.ssh/config exists
1394
- # config = ''
1395
- # if not os.path.exists(os.path.expanduser('~/.ssh')):
1396
- # os.makedirs(os.path.expanduser('~/.ssh'))
1397
- # if os.path.exists(os.path.expanduser('~/.ssh/config')):
1398
- # with open(os.path.expanduser('~/.ssh/config'),'r') as f:
1399
- # config = f.read()
1400
- # if config:
1401
- # if 'StrictHostKeyChecking no' not in config:
1402
- # with open(os.path.expanduser('~/.ssh/config'),'a') as f:
1403
- # f.write('\nHost *\n\tStrictHostKeyChecking no\n')
1404
- # else:
1405
- # with open(os.path.expanduser('~/.ssh/config'),'w') as f:
1406
- # f.write('Host *\n\tStrictHostKeyChecking no\n')
1407
- # sshConfigged = True
1408
-
1409
1790
  def signal_handler(sig, frame):
1410
1791
  '''
1411
1792
  Handle the Ctrl C signal
@@ -1432,7 +1813,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1432
1813
  global __globalUnavailableHosts
1433
1814
  global _no_env
1434
1815
  threads = start_run_on_hosts(hosts, timeout=timeout,password=password,max_connections=max_connections)
1435
- if not nowatch and threads and not returnUnfinished and any([thread.is_alive() for thread in threads]) and sys.stdout.isatty() and os.get_terminal_size() and os.get_terminal_size().columns > 10:
1816
+ if __curses_available and not nowatch and threads and not returnUnfinished and any([thread.is_alive() for thread in threads]) and sys.stdout.isatty() and os.get_terminal_size() and os.get_terminal_size().columns > 10:
1436
1817
  curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
1437
1818
  if not returnUnfinished:
1438
1819
  # wait until all hosts have a return code
@@ -1443,7 +1824,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1443
1824
  # update the unavailable hosts and global unavailable hosts
1444
1825
  if willUpdateUnreachableHosts:
1445
1826
  unavailableHosts = set(unavailableHosts)
1446
- unavailableHosts.update([host.name for host in hosts if host.stderr and ('No route to host' in host.stderr[0].strip() or host.stderr[0].strip().startswith('Timeout!'))])
1827
+ unavailableHosts.update([host.name for host in hosts if host.stderr and ('No route to host' in host.stderr[0].strip() or (host.stderr[-1].strip().startswith('Timeout!') and host.returncode == 124))])
1447
1828
  # reachable hosts = all hosts - unreachable hosts
1448
1829
  reachableHosts = set([host.name for host in hosts]) - unavailableHosts
1449
1830
  if __DEBUG_MODE:
@@ -1480,7 +1861,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1480
1861
  os.replace(os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv.new'),os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv'))
1481
1862
 
1482
1863
  except Exception as e:
1483
- eprint(f'Error writing to temporary file: {e}')
1864
+ eprint(f'Error writing to temporary file: {e!r}')
1484
1865
 
1485
1866
  # print the output, if the output of multiple hosts are the same, we aggragate them
1486
1867
  if not called:
@@ -1517,12 +1898,14 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
1517
1898
  scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1518
1899
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
1519
1900
  file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
1901
+ copy_id = False,
1520
1902
  shortend = False) -> str:
1521
1903
  argsList = []
1522
1904
  if oneonone: argsList.append('--oneonone' if not shortend else '-11')
1523
1905
  if timeout and timeout != DEFAULT_TIMEOUT: argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
1524
1906
  if password and password != DEFAULT_PASSWORD: argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
1525
1907
  if identity_file and identity_file != DEFAULT_IDENTITY_FILE: argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
1908
+ if copy_id: argsList.append('--copy_id' if not shortend else '-ci')
1526
1909
  if nowatch: argsList.append('--nowatch' if not shortend else '-q')
1527
1910
  if json: argsList.append('--json' if not shortend else '-j')
1528
1911
  if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS: argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
@@ -1548,6 +1931,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1548
1931
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1549
1932
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1550
1933
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
1934
+ copy_id = False,
1551
1935
  shortend = False):
1552
1936
  hosts = hosts if type(hosts) == str else frozenset(hosts)
1553
1937
  hostStr = formHostStr(hosts)
@@ -1557,6 +1941,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1557
1941
  files = files,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,scp=scp,gather_mode = gather_mode,
1558
1942
  username=username,extraargs=extraargs,skipUnreachable=skipUnreachable,no_env=no_env,
1559
1943
  greppable=greppable,skip_hosts = skip_hosts, file_sync = file_sync,error_only = error_only, identity_file = identity_file,
1944
+ copy_id = copy_id,
1560
1945
  shortend = shortend)
1561
1946
  commandStr = '"' + '" "'.join(commands) + '"' if commands else ''
1562
1947
  return f'multissh {argsStr} {hostStr} {commandStr}'
@@ -1567,7 +1952,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1567
1952
  scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1568
1953
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1569
1954
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1570
- single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY,quiet = False,identity_file = DEFAULT_IDENTITY_FILE):
1955
+ single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY,quiet = False,identity_file = DEFAULT_IDENTITY_FILE,
1956
+ copy_id = False):
1571
1957
  f'''
1572
1958
  Run the command on the hosts, aka multissh. main function
1573
1959
 
@@ -1602,6 +1988,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1602
1988
  error_only (bool, optional): Whether to only print the error output. Defaults to {DEFAULT_ERROR_ONLY}.
1603
1989
  quiet (bool, optional): Whether to suppress all verbose printout, added for compatibility, avoid using. Defaults to False.
1604
1990
  identity_file (str, optional): The identity file to use for the ssh connection. Defaults to {DEFAULT_IDENTITY_FILE}.
1991
+ copy_id (bool, optional): Whether to copy the id to the hosts. Defaults to False.
1605
1992
 
1606
1993
  Returns:
1607
1994
  list: A list of Host objects
@@ -1623,17 +2010,20 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1623
2010
  elif checkTime > 3600:
1624
2011
  checkTime = 3600
1625
2012
  try:
2013
+ readed = False
1626
2014
  if 0 < time.time() - os.path.getmtime(os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')) < checkTime:
1627
- if not __global_suppress_printout:
1628
- eprint(f"Reading unavailable hosts from the file {os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')}")
2015
+
1629
2016
  with open(os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv'),'r') as f:
1630
2017
  for line in f:
1631
2018
  line = line.strip()
1632
2019
  if line and ',' in line and len(line.split(',')) >= 2 and line.split(',')[0] and line.split(',')[1].isdigit():
1633
2020
  if int(line.split(',')[1]) > time.time() - checkTime:
1634
2021
  __globalUnavailableHosts.add(line.split(',')[0])
2022
+ readed = True
2023
+ if readed and not __global_suppress_printout:
2024
+ eprint(f"Read unavailable hosts from the file {os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')}")
1635
2025
  except Exception as e:
1636
- eprint(f"Warning: Unable to read the unavailable hosts from the file {os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')}")
2026
+ eprint(f"Warning: Unable to read the unavailable hosts from the file {os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')!r}")
1637
2027
  eprint(str(e))
1638
2028
  elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
1639
2029
  __globalUnavailableHosts.update(readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(','))
@@ -1652,13 +2042,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1652
2042
  commands = [' '.join(command) if not type(command) == str else command for command in commands]
1653
2043
  except:
1654
2044
  pass
1655
- eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands} to a list of strings. Continuing anyway but expect failures.")
2045
+ 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.")
1656
2046
  #verify_ssh_config()
1657
2047
  # load global unavailable hosts only if the function is called (so using --repeat will not load the unavailable hosts again)
1658
2048
  if called:
1659
2049
  # if called,
1660
2050
  # if skipUnreachable is not set, we default to skip unreachable hosts within one command call
1661
- __global_suppress_printout = True
1662
2051
  if skipUnreachable is None:
1663
2052
  skipUnreachable = True
1664
2053
  if skipUnreachable:
@@ -1694,10 +2083,34 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1694
2083
  skipHostStr = ','.join(skipHostStr)
1695
2084
  targetHostsList = expand_hostnames(frozenset(hostStr.split(',')))
1696
2085
  if __DEBUG_MODE:
1697
- eprint(f"Target hosts: {targetHostsList}")
2086
+ eprint(f"Target hosts: {targetHostsList!r}")
1698
2087
  skipHostsList = expand_hostnames(frozenset(skipHostStr.split(',')))
1699
2088
  if skipHostsList:
1700
- eprint(f"Skipping hosts: {skipHostsList}")
2089
+ eprint(f"Skipping hosts: {skipHostsList!r}")
2090
+ if copy_id:
2091
+ if 'ssh-copy-id' in _binPaths:
2092
+ # we will copy the id to the hosts
2093
+ hosts = []
2094
+ for host in targetHostsList:
2095
+ if host.strip() in skipHostsList: continue
2096
+ command = f"{_binPaths['ssh-copy-id']} "
2097
+ if identity_file:
2098
+ command = f"{command}-i {identity_file} "
2099
+ if username:
2100
+ command = f"{command} {username}@"
2101
+ command = f"{command}{host}"
2102
+ if password and 'sshpass' in _binPaths:
2103
+ command = f"{_binPaths['sshpass']} -p {password} {command}"
2104
+ hosts.append(Host(host, command,identity_file=identity_file,bash=True))
2105
+ else:
2106
+ eprint(f"> {command}")
2107
+ os.system(command)
2108
+ if hosts:
2109
+ processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinished, nowatch, json, called, greppable,unavailableHosts,willUpdateUnreachableHosts,curses_min_char_len = curses_min_char_len, curses_min_line_len = curses_min_line_len,single_window=single_window)
2110
+ else:
2111
+ eprint(f"Warning: ssh-copy-id not found in {_binPaths} , skipping copy id to the hosts")
2112
+ if not commands:
2113
+ sys.exit(0)
1701
2114
  if files and not commands:
1702
2115
  # if files are specified but not target dir, we default to file sync mode
1703
2116
  file_sync = True
@@ -1714,7 +2127,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1714
2127
  except:
1715
2128
  pathSet.update(glob.glob(file,recursive=True))
1716
2129
  if not pathSet:
1717
- eprint(f'Warning: No source files at {files} are found after resolving globs!')
2130
+ eprint(f'Warning: No source files at {files!r} are found after resolving globs!')
1718
2131
  sys.exit(66)
1719
2132
  else:
1720
2133
  pathSet = set(files)
@@ -1725,13 +2138,13 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1725
2138
  else:
1726
2139
  files = list(pathSet)
1727
2140
  if __DEBUG_MODE:
1728
- eprint(f"Files: {files}")
2141
+ eprint(f"Files: {files!r}")
1729
2142
  if oneonone:
1730
2143
  hosts = []
1731
- if len(commands) != len(targetHostsList) - len(skipHostsList):
2144
+ if len(commands) != len(set(targetHostsList) - set(skipHostsList)):
1732
2145
  eprint("Error: the number of commands must be the same as the number of hosts")
1733
2146
  eprint(f"Number of commands: {len(commands)}")
1734
- eprint(f"Number of hosts: {len(targetHostsList - skipHostsList)}")
2147
+ eprint(f"Number of hosts: {len(set(targetHostsList) - set(skipHostsList))}")
1735
2148
  sys.exit(255)
1736
2149
  if not __global_suppress_printout:
1737
2150
  eprint('-'*80)
@@ -1746,8 +2159,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1746
2159
  else:
1747
2160
  hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file))
1748
2161
  if not __global_suppress_printout:
1749
- eprint(f"Running command: {command} on host: {host}")
1750
- if not __global_suppress_printout: print('-'*80)
2162
+ eprint(f"Running command: {command!r} on host: {host!r}")
2163
+ if not __global_suppress_printout: eprint('-'*80)
1751
2164
  if not no_start: processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinished, nowatch, json, called, greppable,unavailableHosts,willUpdateUnreachableHosts,curses_min_char_len = curses_min_char_len, curses_min_line_len = curses_min_line_len,single_window=single_window)
1752
2165
  return hosts
1753
2166
  else:
@@ -1760,13 +2173,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1760
2173
  if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
1761
2174
  continue
1762
2175
  if host.strip() in skipHostsList: continue
2176
+ # TODO: use ip to determine if we skip the host or not, also for unavailable hosts
1763
2177
  if file_sync:
1764
2178
  eprint(f"Error: file sync mode need to be specified with at least one path to sync.")
1765
2179
  return []
1766
2180
  elif files:
1767
2181
  eprint(f"Error: files need to be specified with at least one path to sync")
1768
- elif ipmi:
1769
- eprint(f"Error: ipmi mode is not supported in interactive mode")
1770
2182
  else:
1771
2183
  hosts.append(Host(host.strip(), '', files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file))
1772
2184
  if not __global_suppress_printout:
@@ -1920,11 +2332,14 @@ def main():
1920
2332
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
1921
2333
  parser.add_argument("--success_hosts", action='store_true', help=f"Output the hosts that succeeded in summary as wells. (default: {DEFAULT_PRINT_SUCCESS_HOSTS})", default=DEFAULT_PRINT_SUCCESS_HOSTS)
1922
2334
  parser.add_argument("-g","--greppable", action='store_true', help=f"Output in greppable format. (default: {DEFAULT_GREPPABLE_MODE})", default=DEFAULT_GREPPABLE_MODE)
1923
- parser.add_argument("-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
2335
+ group = parser.add_mutually_exclusive_group()
2336
+ group.add_argument("-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
2337
+ group.add_argument("-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)
2338
+
1924
2339
  parser.add_argument("-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)
1925
2340
  parser.add_argument('--store_config_file', action='store_true', help=f'Store / generate the default config file from command line argument and current config at {CONFIG_FILE}')
1926
2341
  parser.add_argument('--debug', action='store_true', help='Print debug information')
1927
- parser.add_argument('--copy-id', action='store_true', help='Copy the ssh id to the hosts')
2342
+ parser.add_argument('-ci','--copy_id', action='store_true', help='Copy the ssh id to the hosts')
1928
2343
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
1929
2344
 
1930
2345
  # parser.add_argument('-u', '--user', metavar='user', type=str, nargs=1,
@@ -1932,14 +2347,15 @@ def main():
1932
2347
  #args = parser.parse_args()
1933
2348
 
1934
2349
  # if python version is 3.7 or higher, use parse_intermixed_args
1935
- if sys.version_info >= (3,7):
2350
+ try:
1936
2351
  args = parser.parse_intermixed_args()
1937
- else:
2352
+ except Exception as e:
2353
+ eprint(f"Error while parsing arguments: {e!r}")
1938
2354
  # try to parse the arguments using parse_known_args
1939
2355
  args, unknown = parser.parse_known_args()
1940
2356
  # if there are unknown arguments, we will try to parse them again using parse_args
1941
2357
  if unknown:
1942
- eprint(f"Warning: Unknown arguments, treating all as commands: {unknown}")
2358
+ eprint(f"Warning: Unknown arguments, treating all as commands: {unknown!r}")
1943
2359
  args.commands += unknown
1944
2360
 
1945
2361
 
@@ -1947,22 +2363,22 @@ def main():
1947
2363
  if args.store_config_file:
1948
2364
  try:
1949
2365
  if os.path.exists(CONFIG_FILE):
1950
- eprint(f"Warning: {CONFIG_FILE} already exists, what to do? (o/b/n)")
2366
+ eprint(f"Warning: {CONFIG_FILE!r} already exists, what to do? (o/b/n)")
1951
2367
  eprint(f"o: Overwrite the file")
1952
- eprint(f"b: Rename the current config file at {CONFIG_FILE}.bak forcefully and write the new config file (default)")
2368
+ eprint(f"b: Rename the current config file at {CONFIG_FILE!r}.bak forcefully and write the new config file (default)")
1953
2369
  eprint(f"n: Do nothing")
1954
2370
  inStr = input_with_timeout_and_countdown(10)
1955
2371
  if (not inStr) or inStr.lower().strip().startswith('b'):
1956
2372
  write_default_config(args,CONFIG_FILE,backup = True)
1957
- eprint(f"Config file written to {CONFIG_FILE}")
2373
+ eprint(f"Config file written to {CONFIG_FILE!r}")
1958
2374
  elif inStr.lower().strip().startswith('o'):
1959
2375
  write_default_config(args,CONFIG_FILE,backup = False)
1960
- eprint(f"Config file written to {CONFIG_FILE}")
2376
+ eprint(f"Config file written to {CONFIG_FILE!r}")
1961
2377
  else:
1962
2378
  write_default_config(args,CONFIG_FILE,backup = True)
1963
- eprint(f"Config file written to {CONFIG_FILE}")
2379
+ eprint(f"Config file written to {CONFIG_FILE!r}")
1964
2380
  except Exception as e:
1965
- eprint(f"Error while writing config file: {e}")
2381
+ eprint(f"Error while writing config file: {e!r}")
1966
2382
  import traceback
1967
2383
  eprint(traceback.format_exc())
1968
2384
  if not args.commands:
@@ -1982,9 +2398,9 @@ def main():
1982
2398
  inStr = input_with_timeout_and_countdown(3)
1983
2399
  if (not inStr) or inStr.lower().strip().startswith('1'):
1984
2400
  args.commands = [" ".join(args.commands)]
1985
- eprint(f"\nRunning 1 command: {args.commands[0]} on all hosts")
2401
+ eprint(f"\nRunning 1 command: {args.commands[0]!r} on all hosts")
1986
2402
  elif inStr.lower().strip().startswith('m'):
1987
- eprint(f"\nRunning multiple commands: {', '.join(args.commands)} on all hosts")
2403
+ eprint(f"\nRunning multiple commands: {', '.join(args.commands)!r} on all hosts")
1988
2404
  else:
1989
2405
  sys.exit(0)
1990
2406
 
@@ -1995,26 +2411,7 @@ def main():
1995
2411
  if os.path.isdir(os.path.expanduser(args.key)):
1996
2412
  args.key = find_ssh_key_file(args.key)
1997
2413
  elif not os.path.exists(args.key):
1998
- eprint(f"Warning: Identity file {args.key} not found. Passing to ssh anyway. Proceed with caution.")
1999
-
2000
- if args.copy_id:
2001
- if 'ssh-copy-id' in _binPaths:
2002
- # we will copy the id to the hosts
2003
- for host in formHostStr(args.hosts).split(','):
2004
- command = f"{_binPaths['ssh-copy-id']} "
2005
- if args.key:
2006
- command = f"{command}-i {args.key} "
2007
- if args.username:
2008
- command = f"{command} {args.username}@"
2009
- command = f"{command}{host}"
2010
- if args.password and 'sshpass' in _binPaths:
2011
- command = f"{_binPaths['sshpass']} -p {args.password} {command}"
2012
- eprint(f"> {command}")
2013
- os.system(command)
2014
- else:
2015
- eprint(f"Warning: ssh-copy-id not found in {_binPaths} , skipping copy id to the hosts")
2016
- if not args.commands:
2017
- sys.exit(0)
2414
+ eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
2018
2415
 
2019
2416
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
2020
2417
 
@@ -2026,7 +2423,8 @@ def main():
2026
2423
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
2027
2424
  files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
2028
2425
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
2029
- curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key)
2426
+ curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
2427
+ copy_id=args.copy_id)
2030
2428
  eprint('> ' + cmdStr)
2031
2429
  if args.error_only:
2032
2430
  __global_suppress_printout = True
@@ -2042,7 +2440,8 @@ def main():
2042
2440
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
2043
2441
  files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
2044
2442
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
2045
- curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key)
2443
+ curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
2444
+ copy_id=args.copy_id)
2046
2445
  #print('*'*80)
2047
2446
 
2048
2447
  if not __global_suppress_printout: eprint('-'*80)
@@ -2078,3 +2477,6 @@ def main():
2078
2477
 
2079
2478
  if __name__ == "__main__":
2080
2479
  main()
2480
+
2481
+ # %%
2482
+
@@ -1,7 +0,0 @@
1
- multiSSH3.py,sha256=U5BjFNI9GfDr8Nbj6KOkSo6f0FQ7kGtgFSxDPA2gKNA,100793
2
- multiSSH3-5.4.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- multiSSH3-5.4.dist-info/METADATA,sha256=hvYNJ9SyZ_E3Bk9cM61WJkdEqknsNZQ6FLetMx8brtA,17516
4
- multiSSH3-5.4.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
5
- multiSSH3-5.4.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
6
- multiSSH3-5.4.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
7
- multiSSH3-5.4.dist-info/RECORD,,