multiSSH3 5.6__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.6
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.06'
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,6 +1123,8 @@ 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}']
@@ -755,14 +1143,30 @@ def ssh_command(host, sem, timeout=60,passwds=None):
755
1143
  host.stderr.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
756
1144
  host.ipmi = False
757
1145
  host.interface_ip_prefix = None
758
- host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
759
- 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)
760
1151
  return
761
1152
  else:
762
1153
  host.output.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
763
1154
  host.stderr.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
764
1155
  host.returncode = 1
765
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)
766
1170
  else:
767
1171
  if host.files:
768
1172
  if host.scp:
@@ -918,7 +1322,7 @@ def ssh_command(host, sem, timeout=60,passwds=None):
918
1322
  host.ipmi = False
919
1323
  host.interface_ip_prefix = None
920
1324
  host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
921
- ssh_command(host,sem,timeout,passwds)
1325
+ run_command(host,sem,timeout,passwds)
922
1326
  # If transfering files, we will try again using scp if rsync connection is not successful
923
1327
  if host.files and not host.scp and not useScp and host.returncode != 0 and host.stderr:
924
1328
  host.stderr = []
@@ -927,11 +1331,11 @@ def ssh_command(host, sem, timeout=60,passwds=None):
927
1331
  if __DEBUG_MODE:
928
1332
  host.stderr.append('Rsync connection failed! Trying SCP connection...')
929
1333
  host.scp = True
930
- ssh_command(host,sem,timeout,passwds)
1334
+ run_command(host,sem,timeout,passwds)
931
1335
 
932
1336
  def start_run_on_hosts(hosts, timeout=60,password=None,max_connections=4 * os.cpu_count()):
933
1337
  '''
934
- 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
935
1339
 
936
1340
  Args:
937
1341
  hosts (list): A list of Host objects
@@ -945,7 +1349,7 @@ def start_run_on_hosts(hosts, timeout=60,password=None,max_connections=4 * os.cp
945
1349
  if len(hosts) == 0:
946
1350
  return []
947
1351
  sem = threading.Semaphore(max_connections) # Limit concurrent SSH sessions
948
- 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]
949
1353
  for thread in threads:
950
1354
  thread.start()
951
1355
  return threads
@@ -982,13 +1386,6 @@ def get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None):
982
1386
  host.printedLines = 0
983
1387
  return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}
984
1388
 
985
- # 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...
986
- # Traceback (most recent call last):
987
- # File "/usr/local/lib/python3.11/site-packages/multiSSH3.py", line 1030, in generate_display
988
- # host_window_height = max_y // num_hosts_y
989
- # ~~~~~~^^~~~~~~~~~~~~
990
- # ZeroDivisionError: integer division or modulo by zero
991
-
992
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'):
993
1390
  try:
994
1391
  org_dim = stdscr.getmaxyx()
@@ -1323,15 +1720,21 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1323
1720
  if host['stderr']:
1324
1721
  if host['stderr'][0].strip().startswith('ssh: connect to host '):
1325
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!'
1326
1727
  hostPrintOut += " | stderr: "+'↵ '.join(host['stderr'])
1327
1728
  hostPrintOut += f" | return_code: {host['returncode']}"
1328
- if hostPrintOut not in outputs:
1329
- outputs[hostPrintOut] = [host['name']]
1330
- else:
1331
- outputs[hostPrintOut].append(host['name'])
1729
+ outputs.setdefault(hostPrintOut, set()).add(host['name'])
1332
1730
  rtnStr = '*'*80+'\n'
1333
- for output, hosts in outputs.items():
1334
- 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"
1335
1738
  rtnStr += '*'*80+'\n'
1336
1739
  if __keyPressesIn[-1]:
1337
1740
  CMDsOut = [''.join(cmd).encode('unicode_escape').decode().replace('\\n', '↵') for cmd in __keyPressesIn if cmd]
@@ -1348,20 +1751,25 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1348
1751
  if host['stderr']:
1349
1752
  if host['stderr'][0].strip().startswith('ssh: connect to host '):
1350
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!'
1351
1758
  hostPrintOut += "\n stderr:\n "+'\n '.join(host['stderr'])
1352
1759
  hostPrintOut += f"\n return_code: {host['returncode']}"
1353
- if hostPrintOut not in outputs:
1354
- outputs[hostPrintOut] = [host['name']]
1355
- else:
1356
- outputs[hostPrintOut].append(host['name'])
1760
+ outputs.setdefault(hostPrintOut, set()).add(host['name'])
1357
1761
  rtnStr = ''
1358
- 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
1359
1767
  if __global_suppress_printout:
1360
- rtnStr += f'Abnormal returncode produced by {hosts}:\n'
1768
+ rtnStr += f'Abnormal returncode produced by {",".join(compact_hosts)}:\n'
1361
1769
  rtnStr += output+'\n'
1362
1770
  else:
1363
1771
  rtnStr += '*'*80+'\n'
1364
- rtnStr += f"These hosts: {hosts} have a response of:\n"
1772
+ rtnStr += f'These hosts: {",".join(compact_hosts)} have a response of:\n'
1365
1773
  rtnStr += output+'\n'
1366
1774
  if not __global_suppress_printout or outputs:
1367
1775
  rtnStr += '*'*80+'\n'
@@ -1379,35 +1787,6 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1379
1787
  print(rtnStr)
1380
1788
  return rtnStr
1381
1789
 
1382
- # sshConfigged = False
1383
- # def verify_ssh_config():
1384
- # '''
1385
- # Verify that ~/.ssh/config exists and contains the line "StrictHostKeyChecking no"
1386
-
1387
- # Args:
1388
- # None
1389
-
1390
- # Returns:
1391
- # None
1392
- # '''
1393
- # global sshConfigged
1394
- # if not sshConfigged:
1395
- # # first we make sure ~/.ssh/config exists
1396
- # config = ''
1397
- # if not os.path.exists(os.path.expanduser('~/.ssh')):
1398
- # os.makedirs(os.path.expanduser('~/.ssh'))
1399
- # if os.path.exists(os.path.expanduser('~/.ssh/config')):
1400
- # with open(os.path.expanduser('~/.ssh/config'),'r') as f:
1401
- # config = f.read()
1402
- # if config:
1403
- # if 'StrictHostKeyChecking no' not in config:
1404
- # with open(os.path.expanduser('~/.ssh/config'),'a') as f:
1405
- # f.write('\nHost *\n\tStrictHostKeyChecking no\n')
1406
- # else:
1407
- # with open(os.path.expanduser('~/.ssh/config'),'w') as f:
1408
- # f.write('Host *\n\tStrictHostKeyChecking no\n')
1409
- # sshConfigged = True
1410
-
1411
1790
  def signal_handler(sig, frame):
1412
1791
  '''
1413
1792
  Handle the Ctrl C signal
@@ -1434,7 +1813,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1434
1813
  global __globalUnavailableHosts
1435
1814
  global _no_env
1436
1815
  threads = start_run_on_hosts(hosts, timeout=timeout,password=password,max_connections=max_connections)
1437
- 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:
1438
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)
1439
1818
  if not returnUnfinished:
1440
1819
  # wait until all hosts have a return code
@@ -1445,7 +1824,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1445
1824
  # update the unavailable hosts and global unavailable hosts
1446
1825
  if willUpdateUnreachableHosts:
1447
1826
  unavailableHosts = set(unavailableHosts)
1448
- 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))])
1449
1828
  # reachable hosts = all hosts - unreachable hosts
1450
1829
  reachableHosts = set([host.name for host in hosts]) - unavailableHosts
1451
1830
  if __DEBUG_MODE:
@@ -1482,7 +1861,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1482
1861
  os.replace(os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv.new'),os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv'))
1483
1862
 
1484
1863
  except Exception as e:
1485
- eprint(f'Error writing to temporary file: {e}')
1864
+ eprint(f'Error writing to temporary file: {e!r}')
1486
1865
 
1487
1866
  # print the output, if the output of multiple hosts are the same, we aggragate them
1488
1867
  if not called:
@@ -1519,12 +1898,14 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
1519
1898
  scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1520
1899
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
1521
1900
  file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
1901
+ copy_id = False,
1522
1902
  shortend = False) -> str:
1523
1903
  argsList = []
1524
1904
  if oneonone: argsList.append('--oneonone' if not shortend else '-11')
1525
1905
  if timeout and timeout != DEFAULT_TIMEOUT: argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
1526
1906
  if password and password != DEFAULT_PASSWORD: argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
1527
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')
1528
1909
  if nowatch: argsList.append('--nowatch' if not shortend else '-q')
1529
1910
  if json: argsList.append('--json' if not shortend else '-j')
1530
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}')
@@ -1550,6 +1931,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1550
1931
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1551
1932
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1552
1933
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
1934
+ copy_id = False,
1553
1935
  shortend = False):
1554
1936
  hosts = hosts if type(hosts) == str else frozenset(hosts)
1555
1937
  hostStr = formHostStr(hosts)
@@ -1559,6 +1941,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1559
1941
  files = files,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,scp=scp,gather_mode = gather_mode,
1560
1942
  username=username,extraargs=extraargs,skipUnreachable=skipUnreachable,no_env=no_env,
1561
1943
  greppable=greppable,skip_hosts = skip_hosts, file_sync = file_sync,error_only = error_only, identity_file = identity_file,
1944
+ copy_id = copy_id,
1562
1945
  shortend = shortend)
1563
1946
  commandStr = '"' + '" "'.join(commands) + '"' if commands else ''
1564
1947
  return f'multissh {argsStr} {hostStr} {commandStr}'
@@ -1569,7 +1952,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1569
1952
  scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1570
1953
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1571
1954
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1572
- 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):
1573
1957
  f'''
1574
1958
  Run the command on the hosts, aka multissh. main function
1575
1959
 
@@ -1604,6 +1988,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1604
1988
  error_only (bool, optional): Whether to only print the error output. Defaults to {DEFAULT_ERROR_ONLY}.
1605
1989
  quiet (bool, optional): Whether to suppress all verbose printout, added for compatibility, avoid using. Defaults to False.
1606
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.
1607
1992
 
1608
1993
  Returns:
1609
1994
  list: A list of Host objects
@@ -1625,17 +2010,20 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1625
2010
  elif checkTime > 3600:
1626
2011
  checkTime = 3600
1627
2012
  try:
2013
+ readed = False
1628
2014
  if 0 < time.time() - os.path.getmtime(os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')) < checkTime:
1629
- if not __global_suppress_printout:
1630
- eprint(f"Reading unavailable hosts from the file {os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv')}")
2015
+
1631
2016
  with open(os.path.join(tempfile.gettempdir(),'__multiSSH3_UNAVAILABLE_HOSTS.csv'),'r') as f:
1632
2017
  for line in f:
1633
2018
  line = line.strip()
1634
2019
  if line and ',' in line and len(line.split(',')) >= 2 and line.split(',')[0] and line.split(',')[1].isdigit():
1635
2020
  if int(line.split(',')[1]) > time.time() - checkTime:
1636
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')}")
1637
2025
  except Exception as e:
1638
- 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}")
1639
2027
  eprint(str(e))
1640
2028
  elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
1641
2029
  __globalUnavailableHosts.update(readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(','))
@@ -1654,13 +2042,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1654
2042
  commands = [' '.join(command) if not type(command) == str else command for command in commands]
1655
2043
  except:
1656
2044
  pass
1657
- 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.")
1658
2046
  #verify_ssh_config()
1659
2047
  # load global unavailable hosts only if the function is called (so using --repeat will not load the unavailable hosts again)
1660
2048
  if called:
1661
2049
  # if called,
1662
2050
  # if skipUnreachable is not set, we default to skip unreachable hosts within one command call
1663
- __global_suppress_printout = True
1664
2051
  if skipUnreachable is None:
1665
2052
  skipUnreachable = True
1666
2053
  if skipUnreachable:
@@ -1696,10 +2083,34 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1696
2083
  skipHostStr = ','.join(skipHostStr)
1697
2084
  targetHostsList = expand_hostnames(frozenset(hostStr.split(',')))
1698
2085
  if __DEBUG_MODE:
1699
- eprint(f"Target hosts: {targetHostsList}")
2086
+ eprint(f"Target hosts: {targetHostsList!r}")
1700
2087
  skipHostsList = expand_hostnames(frozenset(skipHostStr.split(',')))
1701
2088
  if skipHostsList:
1702
- 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)
1703
2114
  if files and not commands:
1704
2115
  # if files are specified but not target dir, we default to file sync mode
1705
2116
  file_sync = True
@@ -1716,7 +2127,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1716
2127
  except:
1717
2128
  pathSet.update(glob.glob(file,recursive=True))
1718
2129
  if not pathSet:
1719
- 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!')
1720
2131
  sys.exit(66)
1721
2132
  else:
1722
2133
  pathSet = set(files)
@@ -1727,13 +2138,13 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1727
2138
  else:
1728
2139
  files = list(pathSet)
1729
2140
  if __DEBUG_MODE:
1730
- eprint(f"Files: {files}")
2141
+ eprint(f"Files: {files!r}")
1731
2142
  if oneonone:
1732
2143
  hosts = []
1733
- if len(commands) != len(targetHostsList) - len(skipHostsList):
2144
+ if len(commands) != len(set(targetHostsList) - set(skipHostsList)):
1734
2145
  eprint("Error: the number of commands must be the same as the number of hosts")
1735
2146
  eprint(f"Number of commands: {len(commands)}")
1736
- eprint(f"Number of hosts: {len(targetHostsList - skipHostsList)}")
2147
+ eprint(f"Number of hosts: {len(set(targetHostsList) - set(skipHostsList))}")
1737
2148
  sys.exit(255)
1738
2149
  if not __global_suppress_printout:
1739
2150
  eprint('-'*80)
@@ -1748,8 +2159,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1748
2159
  else:
1749
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))
1750
2161
  if not __global_suppress_printout:
1751
- eprint(f"Running command: {command} on host: {host}")
1752
- 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)
1753
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)
1754
2165
  return hosts
1755
2166
  else:
@@ -1762,13 +2173,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1762
2173
  if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
1763
2174
  continue
1764
2175
  if host.strip() in skipHostsList: continue
2176
+ # TODO: use ip to determine if we skip the host or not, also for unavailable hosts
1765
2177
  if file_sync:
1766
2178
  eprint(f"Error: file sync mode need to be specified with at least one path to sync.")
1767
2179
  return []
1768
2180
  elif files:
1769
2181
  eprint(f"Error: files need to be specified with at least one path to sync")
1770
- elif ipmi:
1771
- eprint(f"Error: ipmi mode is not supported in interactive mode")
1772
2182
  else:
1773
2183
  hosts.append(Host(host.strip(), '', files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file))
1774
2184
  if not __global_suppress_printout:
@@ -1922,11 +2332,14 @@ def main():
1922
2332
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
1923
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)
1924
2334
  parser.add_argument("-g","--greppable", action='store_true', help=f"Output in greppable format. (default: {DEFAULT_GREPPABLE_MODE})", default=DEFAULT_GREPPABLE_MODE)
1925
- 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
+
1926
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)
1927
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}')
1928
2341
  parser.add_argument('--debug', action='store_true', help='Print debug information')
1929
- 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')
1930
2343
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
1931
2344
 
1932
2345
  # parser.add_argument('-u', '--user', metavar='user', type=str, nargs=1,
@@ -1936,12 +2349,13 @@ def main():
1936
2349
  # if python version is 3.7 or higher, use parse_intermixed_args
1937
2350
  try:
1938
2351
  args = parser.parse_intermixed_args()
1939
- except:
2352
+ except Exception as e:
2353
+ eprint(f"Error while parsing arguments: {e!r}")
1940
2354
  # try to parse the arguments using parse_known_args
1941
2355
  args, unknown = parser.parse_known_args()
1942
2356
  # if there are unknown arguments, we will try to parse them again using parse_args
1943
2357
  if unknown:
1944
- eprint(f"Warning: Unknown arguments, treating all as commands: {unknown}")
2358
+ eprint(f"Warning: Unknown arguments, treating all as commands: {unknown!r}")
1945
2359
  args.commands += unknown
1946
2360
 
1947
2361
 
@@ -1949,22 +2363,22 @@ def main():
1949
2363
  if args.store_config_file:
1950
2364
  try:
1951
2365
  if os.path.exists(CONFIG_FILE):
1952
- 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)")
1953
2367
  eprint(f"o: Overwrite the file")
1954
- 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)")
1955
2369
  eprint(f"n: Do nothing")
1956
2370
  inStr = input_with_timeout_and_countdown(10)
1957
2371
  if (not inStr) or inStr.lower().strip().startswith('b'):
1958
2372
  write_default_config(args,CONFIG_FILE,backup = True)
1959
- eprint(f"Config file written to {CONFIG_FILE}")
2373
+ eprint(f"Config file written to {CONFIG_FILE!r}")
1960
2374
  elif inStr.lower().strip().startswith('o'):
1961
2375
  write_default_config(args,CONFIG_FILE,backup = False)
1962
- eprint(f"Config file written to {CONFIG_FILE}")
2376
+ eprint(f"Config file written to {CONFIG_FILE!r}")
1963
2377
  else:
1964
2378
  write_default_config(args,CONFIG_FILE,backup = True)
1965
- eprint(f"Config file written to {CONFIG_FILE}")
2379
+ eprint(f"Config file written to {CONFIG_FILE!r}")
1966
2380
  except Exception as e:
1967
- eprint(f"Error while writing config file: {e}")
2381
+ eprint(f"Error while writing config file: {e!r}")
1968
2382
  import traceback
1969
2383
  eprint(traceback.format_exc())
1970
2384
  if not args.commands:
@@ -1984,9 +2398,9 @@ def main():
1984
2398
  inStr = input_with_timeout_and_countdown(3)
1985
2399
  if (not inStr) or inStr.lower().strip().startswith('1'):
1986
2400
  args.commands = [" ".join(args.commands)]
1987
- eprint(f"\nRunning 1 command: {args.commands[0]} on all hosts")
2401
+ eprint(f"\nRunning 1 command: {args.commands[0]!r} on all hosts")
1988
2402
  elif inStr.lower().strip().startswith('m'):
1989
- eprint(f"\nRunning multiple commands: {', '.join(args.commands)} on all hosts")
2403
+ eprint(f"\nRunning multiple commands: {', '.join(args.commands)!r} on all hosts")
1990
2404
  else:
1991
2405
  sys.exit(0)
1992
2406
 
@@ -1997,26 +2411,7 @@ def main():
1997
2411
  if os.path.isdir(os.path.expanduser(args.key)):
1998
2412
  args.key = find_ssh_key_file(args.key)
1999
2413
  elif not os.path.exists(args.key):
2000
- eprint(f"Warning: Identity file {args.key} not found. Passing to ssh anyway. Proceed with caution.")
2001
-
2002
- if args.copy_id:
2003
- if 'ssh-copy-id' in _binPaths:
2004
- # we will copy the id to the hosts
2005
- for host in formHostStr(args.hosts).split(','):
2006
- command = f"{_binPaths['ssh-copy-id']} "
2007
- if args.key:
2008
- command = f"{command}-i {args.key} "
2009
- if args.username:
2010
- command = f"{command} {args.username}@"
2011
- command = f"{command}{host}"
2012
- if args.password and 'sshpass' in _binPaths:
2013
- command = f"{_binPaths['sshpass']} -p {args.password} {command}"
2014
- eprint(f"> {command}")
2015
- os.system(command)
2016
- else:
2017
- eprint(f"Warning: ssh-copy-id not found in {_binPaths} , skipping copy id to the hosts")
2018
- if not args.commands:
2019
- sys.exit(0)
2414
+ eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
2020
2415
 
2021
2416
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
2022
2417
 
@@ -2028,7 +2423,8 @@ def main():
2028
2423
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
2029
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,
2030
2425
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
2031
- 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)
2032
2428
  eprint('> ' + cmdStr)
2033
2429
  if args.error_only:
2034
2430
  __global_suppress_printout = True
@@ -2044,7 +2440,8 @@ def main():
2044
2440
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
2045
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,
2046
2442
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
2047
- 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)
2048
2445
  #print('*'*80)
2049
2446
 
2050
2447
  if not __global_suppress_printout: eprint('-'*80)
@@ -2080,3 +2477,6 @@ def main():
2080
2477
 
2081
2478
  if __name__ == "__main__":
2082
2479
  main()
2480
+
2481
+ # %%
2482
+
@@ -1,7 +0,0 @@
1
- multiSSH3.py,sha256=HqHel8s-dKmlsgHu1Vhcdo5MAp7GiyF6hYczr5dt2n4,100997
2
- multiSSH3-5.6.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- multiSSH3-5.6.dist-info/METADATA,sha256=U0G_An0gJZJE5S5tAk2yRxUgA2_lf9h9YioOvN7i05U,17516
4
- multiSSH3-5.6.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
5
- multiSSH3-5.6.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
6
- multiSSH3-5.6.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
7
- multiSSH3-5.6.dist-info/RECORD,,