multiSSH3 5.91__tar.gz → 5.92__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multiSSH3 might be problematic. Click here for more details.
- {multissh3-5.91 → multissh3-5.92}/PKG-INFO +1 -1
- {multissh3-5.91 → multissh3-5.92}/README.md +0 -0
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.egg-info/PKG-INFO +1 -1
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.py +288 -205
- {multissh3-5.91 → multissh3-5.92}/setup.py +0 -0
- {multissh3-5.91 → multissh3-5.92}/test/test.py +0 -0
- {multissh3-5.91 → multissh3-5.92}/test/testCurses.py +0 -0
- {multissh3-5.91 → multissh3-5.92}/test/testCursesOld.py +0 -0
- {multissh3-5.91 → multissh3-5.92}/test/testPerfCompact.py +0 -0
- {multissh3-5.91 → multissh3-5.92}/test/testPerfExpand.py +0 -0
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.egg-info/SOURCES.txt +0 -0
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.egg-info/dependency_links.txt +0 -0
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.egg-info/entry_points.txt +0 -0
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.egg-info/requires.txt +0 -0
- {multissh3-5.91 → multissh3-5.92}/multiSSH3.egg-info/top_level.txt +0 -0
- {multissh3-5.91 → multissh3-5.92}/setup.cfg +0 -0
|
File without changes
|
|
@@ -12,6 +12,7 @@ import getpass
|
|
|
12
12
|
import glob
|
|
13
13
|
import io
|
|
14
14
|
import ipaddress
|
|
15
|
+
import itertools
|
|
15
16
|
import json
|
|
16
17
|
import math
|
|
17
18
|
import os
|
|
@@ -29,7 +30,7 @@ import threading
|
|
|
29
30
|
import time
|
|
30
31
|
import typing
|
|
31
32
|
import uuid
|
|
32
|
-
from collections import Counter, deque
|
|
33
|
+
from collections import Counter, deque, defaultdict, UserDict
|
|
33
34
|
from itertools import count, product
|
|
34
35
|
|
|
35
36
|
__curses_available = False
|
|
@@ -84,10 +85,10 @@ except Exception:
|
|
|
84
85
|
print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
|
|
85
86
|
def cache_decorator(func):
|
|
86
87
|
return func
|
|
87
|
-
version = '5.
|
|
88
|
+
version = '5.92'
|
|
88
89
|
VERSION = version
|
|
89
90
|
__version__ = version
|
|
90
|
-
COMMIT_DATE = '2025-10-
|
|
91
|
+
COMMIT_DATE = '2025-10-20'
|
|
91
92
|
|
|
92
93
|
CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
|
|
93
94
|
'~/multiSSH3.config.json',
|
|
@@ -153,33 +154,6 @@ def signal_handler(sig, frame):
|
|
|
153
154
|
os.system(f'pkill -ef {os.path.basename(__file__)}')
|
|
154
155
|
_exit_with_code(1, 'Exiting immediately due to Ctrl C')
|
|
155
156
|
|
|
156
|
-
# def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
|
|
157
|
-
# """
|
|
158
|
-
# Read an input from the user with a timeout and a countdown.
|
|
159
|
-
|
|
160
|
-
# Parameters:
|
|
161
|
-
# timeout (int): The timeout value in seconds.
|
|
162
|
-
# prompt (str): The prompt message to display to the user. Default is 'Please enter your selection'.
|
|
163
|
-
|
|
164
|
-
# Returns:
|
|
165
|
-
# str or None: The user input if received within the timeout, or None if no input is received.
|
|
166
|
-
# """
|
|
167
|
-
# import select
|
|
168
|
-
# # Print the initial prompt with the countdown
|
|
169
|
-
# eprint(f"{prompt} [{timeout}s]: ", end='', flush=True)
|
|
170
|
-
# # Loop until the timeout
|
|
171
|
-
# for remaining in range(timeout, 0, -1):
|
|
172
|
-
# # If there is an input, return it
|
|
173
|
-
# # this only works on linux
|
|
174
|
-
# if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
|
|
175
|
-
# return input().strip()
|
|
176
|
-
# # Print the remaining time
|
|
177
|
-
# eprint(f"\r{prompt} [{remaining}s]: ", end='', flush=True)
|
|
178
|
-
# # Wait a second
|
|
179
|
-
# time.sleep(1)
|
|
180
|
-
# # If there is no input, return None
|
|
181
|
-
# return None
|
|
182
|
-
|
|
183
157
|
def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
|
|
184
158
|
"""
|
|
185
159
|
Read input from the user with a timeout (cross-platform).
|
|
@@ -312,6 +286,13 @@ extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={
|
|
|
312
286
|
identity_file={self.identity_file}, ip={self.ip}, current_color_pair={self.current_color_pair}"
|
|
313
287
|
def __str__(self):
|
|
314
288
|
return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
|
|
289
|
+
def get_output_hash(self):
|
|
290
|
+
return hash((
|
|
291
|
+
self.command,
|
|
292
|
+
tuple(self.stdout),
|
|
293
|
+
tuple(self.stderr),
|
|
294
|
+
self.returncode
|
|
295
|
+
))
|
|
315
296
|
|
|
316
297
|
#%% ------------ Load Defaults ( Config ) File ----------------
|
|
317
298
|
def load_config_file(config_file):
|
|
@@ -650,8 +631,6 @@ def format_commands(commands):
|
|
|
650
631
|
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. Error: {e}")
|
|
651
632
|
return commands
|
|
652
633
|
|
|
653
|
-
|
|
654
|
-
|
|
655
634
|
class OrderedMultiSet(deque):
|
|
656
635
|
"""
|
|
657
636
|
A deque extension with O(1) average lookup time.
|
|
@@ -668,29 +647,55 @@ class OrderedMultiSet(deque):
|
|
|
668
647
|
self._counter[item] -= 1
|
|
669
648
|
if self._counter[item] == 0:
|
|
670
649
|
del self._counter[item]
|
|
671
|
-
|
|
672
|
-
def append(self, item,left=False):
|
|
650
|
+
def append(self, item):
|
|
673
651
|
"""Add item to the right end. O(1)."""
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
self.__decrease_count(removed)
|
|
678
|
-
super().appendleft(item) if left else super().append(item)
|
|
652
|
+
if len(self) == self.maxlen:
|
|
653
|
+
self.__decrease_count(self[0])
|
|
654
|
+
super().append(item)
|
|
679
655
|
self._counter[item] += 1
|
|
680
|
-
return removed
|
|
681
656
|
def appendleft(self, item):
|
|
682
657
|
"""Add item to the left end. O(1)."""
|
|
683
|
-
|
|
684
|
-
|
|
658
|
+
if len(self) == self.maxlen:
|
|
659
|
+
self.__decrease_count(self[-1])
|
|
660
|
+
super().appendleft(item)
|
|
661
|
+
self._counter[item] += 1
|
|
662
|
+
def pop(self):
|
|
685
663
|
"""Remove and return item from right end. O(1)."""
|
|
686
|
-
|
|
664
|
+
try:
|
|
665
|
+
item = super().pop()
|
|
666
|
+
self.__decrease_count(item)
|
|
667
|
+
return item
|
|
668
|
+
except IndexError:
|
|
687
669
|
return None
|
|
688
|
-
item = super().popleft() if left else super().pop()
|
|
689
|
-
self.__decrease_count(item)
|
|
690
|
-
return item
|
|
691
670
|
def popleft(self):
|
|
692
671
|
"""Remove and return item from left end. O(1)."""
|
|
693
|
-
|
|
672
|
+
try:
|
|
673
|
+
item = super().popleft()
|
|
674
|
+
self.__decrease_count(item)
|
|
675
|
+
return item
|
|
676
|
+
except IndexError:
|
|
677
|
+
return None
|
|
678
|
+
def put(self, item):
|
|
679
|
+
"""Alias for append, but return removed item - add to right end (FIFO put)."""
|
|
680
|
+
removed = None
|
|
681
|
+
if len(self) == self.maxlen:
|
|
682
|
+
removed = self[0] # Item that will be removed
|
|
683
|
+
self.__decrease_count(removed)
|
|
684
|
+
super().append(item)
|
|
685
|
+
self._counter[item] += 1
|
|
686
|
+
return removed
|
|
687
|
+
def put_left(self, item):
|
|
688
|
+
"""Alias for appendleft, but return removed item - add to left end (LIFO put)."""
|
|
689
|
+
removed = None
|
|
690
|
+
if len(self) == self.maxlen:
|
|
691
|
+
removed = self[-1] # Item that will be removed
|
|
692
|
+
self.__decrease_count(removed)
|
|
693
|
+
super().appendleft(item)
|
|
694
|
+
self._counter[item] += 1
|
|
695
|
+
return removed
|
|
696
|
+
def get(self):
|
|
697
|
+
"""Alias for popleft - remove from left end (FIFO get)."""
|
|
698
|
+
return self.popleft()
|
|
694
699
|
def remove(self, value):
|
|
695
700
|
"""Remove first occurrence of value. O(n)."""
|
|
696
701
|
if value not in self._counter:
|
|
@@ -703,16 +708,34 @@ class OrderedMultiSet(deque):
|
|
|
703
708
|
self._counter.clear()
|
|
704
709
|
def extend(self, iterable):
|
|
705
710
|
"""Extend deque by appending elements from iterable. O(k)."""
|
|
706
|
-
|
|
707
|
-
|
|
711
|
+
# if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extend
|
|
712
|
+
try:
|
|
713
|
+
if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
|
|
714
|
+
super().extend(iterable)
|
|
715
|
+
self._counter.update(iterable)
|
|
716
|
+
elif len(iterable) >= self.maxlen:
|
|
717
|
+
self.clear()
|
|
718
|
+
if isinstance(iterable, (list, tuple)):
|
|
719
|
+
iterable = iterable[-self.maxlen:]
|
|
720
|
+
else:
|
|
721
|
+
iterable = itertools.islice(iterable, len(iterable) - self.maxlen, None)
|
|
722
|
+
super().extend(iterable)
|
|
723
|
+
self._counter.update(iterable)
|
|
724
|
+
else:
|
|
725
|
+
# Need to remove oldest items to make space
|
|
726
|
+
num_to_remove = len(self) + len(iterable) - self.maxlen
|
|
727
|
+
for _ in range(num_to_remove):
|
|
728
|
+
self.__decrease_count(super().popleft())
|
|
729
|
+
super().extend(iterable)
|
|
730
|
+
self._counter.update(iterable)
|
|
731
|
+
except TypeError:
|
|
732
|
+
return self.extend(list(iterable))
|
|
708
733
|
def extendleft(self, iterable):
|
|
709
734
|
"""Extend left side by appending elements from iterable. O(k)."""
|
|
710
735
|
for item in iterable:
|
|
711
736
|
self.appendleft(item)
|
|
712
737
|
def rotate(self, n=1):
|
|
713
738
|
"""Rotate deque n steps to the right. O(k) where k = min(n, len)."""
|
|
714
|
-
if not self:
|
|
715
|
-
return
|
|
716
739
|
super().rotate(n)
|
|
717
740
|
def __contains__(self, item):
|
|
718
741
|
"""Check if item exists in deque. O(1) average."""
|
|
@@ -753,22 +776,18 @@ class OrderedMultiSet(deque):
|
|
|
753
776
|
if self.maxlen is not None:
|
|
754
777
|
return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
|
|
755
778
|
return f"OrderedMultiSet({list(self)})"
|
|
756
|
-
def put(self, item,left=False):
|
|
757
|
-
"""Alias for append - add to right end (FIFO put)."""
|
|
758
|
-
return self.append(item,left=left)
|
|
759
|
-
def get(self,left=True):
|
|
760
|
-
"""Alias for popleft - remove from left end (FIFO get)."""
|
|
761
|
-
return self.pop(left=left)
|
|
762
779
|
def peek(self):
|
|
763
780
|
"""Return leftmost item without removing it."""
|
|
764
|
-
|
|
781
|
+
try:
|
|
782
|
+
return self[0]
|
|
783
|
+
except IndexError:
|
|
765
784
|
return None
|
|
766
|
-
return self[0]
|
|
767
785
|
def peek_right(self):
|
|
768
786
|
"""Return rightmost item without removing it."""
|
|
769
|
-
|
|
787
|
+
try:
|
|
788
|
+
return self[-1]
|
|
789
|
+
except IndexError:
|
|
770
790
|
return None
|
|
771
|
-
return self[-1]
|
|
772
791
|
|
|
773
792
|
def get_terminal_size():
|
|
774
793
|
'''
|
|
@@ -1267,10 +1286,15 @@ def compact_hostnames(Hostnames,verify = True):
|
|
|
1267
1286
|
['sub-s[1-2]']
|
|
1268
1287
|
"""
|
|
1269
1288
|
global __global_suppress_printout
|
|
1270
|
-
if not isinstance(Hostnames, frozenset):
|
|
1271
|
-
|
|
1272
|
-
else:
|
|
1273
|
-
|
|
1289
|
+
# if not isinstance(Hostnames, frozenset):
|
|
1290
|
+
# hostSet = frozenset(Hostnames)
|
|
1291
|
+
# else:
|
|
1292
|
+
# hostSet = Hostnames
|
|
1293
|
+
hostSet = frozenset(
|
|
1294
|
+
hostname.strip()
|
|
1295
|
+
for hostnames_str in Hostnames
|
|
1296
|
+
for hostname in hostnames_str.split(',')
|
|
1297
|
+
)
|
|
1274
1298
|
compact_hosts = __compact_hostnames(hostSet)
|
|
1275
1299
|
if verify:
|
|
1276
1300
|
if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
|
|
@@ -1502,51 +1526,53 @@ def __handle_reading_stream(stream,target, host,buffer:io.BytesIO):
|
|
|
1502
1526
|
buffer.truncate(0)
|
|
1503
1527
|
host.output_buffer.seek(0)
|
|
1504
1528
|
host.output_buffer.truncate(0)
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
continue
|
|
1511
|
-
elif char == b'\r':
|
|
1512
|
-
buffer.seek(0)
|
|
1513
|
-
host.output_buffer.seek(0)
|
|
1514
|
-
elif char == b'\x08':
|
|
1515
|
-
# backspace
|
|
1516
|
-
if buffer.tell() > 0:
|
|
1517
|
-
buffer.seek(buffer.tell() - 1)
|
|
1518
|
-
buffer.truncate()
|
|
1519
|
-
if host.output_buffer.tell() > 0:
|
|
1520
|
-
host.output_buffer.seek(host.output_buffer.tell() - 1)
|
|
1521
|
-
host.output_buffer.truncate()
|
|
1522
|
-
else:
|
|
1523
|
-
# normal character
|
|
1524
|
-
buffer.write(char)
|
|
1525
|
-
host.output_buffer.write(char)
|
|
1526
|
-
# if the length of the buffer is greater than 100, we try to decode the buffer to find if there are any unicode line change chars
|
|
1527
|
-
if buffer.tell() % 100 == 0 and buffer.tell() > 0:
|
|
1528
|
-
try:
|
|
1529
|
-
# try to decode the buffer to find if there are any unicode line change chars
|
|
1530
|
-
decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
|
|
1531
|
-
lines = decodedLine.splitlines()
|
|
1532
|
-
if len(lines) > 1:
|
|
1533
|
-
# if there are multiple lines, we add them to the target
|
|
1534
|
-
for line in lines[:-1]:
|
|
1535
|
-
# for all lines except the last one, we add them to the target
|
|
1536
|
-
target.append(line)
|
|
1537
|
-
host.output.append(line)
|
|
1538
|
-
host.lineNumToPrintSet.add(len(host.output)-1)
|
|
1539
|
-
# we keep the last line in the buffer
|
|
1540
|
-
buffer.seek(0)
|
|
1541
|
-
buffer.truncate(0)
|
|
1542
|
-
buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1543
|
-
host.output_buffer.seek(0)
|
|
1544
|
-
host.output_buffer.truncate(0)
|
|
1545
|
-
host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1546
|
-
|
|
1547
|
-
except UnicodeDecodeError:
|
|
1548
|
-
# if there is a unicode decode error, we just skip this character
|
|
1529
|
+
try:
|
|
1530
|
+
for char in iter(lambda:stream.read(1), b''):
|
|
1531
|
+
host.lastUpdateTime = time.monotonic()
|
|
1532
|
+
if char == b'\n':
|
|
1533
|
+
add_line(buffer,target, host)
|
|
1549
1534
|
continue
|
|
1535
|
+
elif char == b'\r':
|
|
1536
|
+
buffer.seek(0)
|
|
1537
|
+
host.output_buffer.seek(0)
|
|
1538
|
+
elif char == b'\x08':
|
|
1539
|
+
# backspace
|
|
1540
|
+
if buffer.tell() > 0:
|
|
1541
|
+
buffer.seek(buffer.tell() - 1)
|
|
1542
|
+
buffer.truncate()
|
|
1543
|
+
if host.output_buffer.tell() > 0:
|
|
1544
|
+
host.output_buffer.seek(host.output_buffer.tell() - 1)
|
|
1545
|
+
host.output_buffer.truncate()
|
|
1546
|
+
else:
|
|
1547
|
+
# normal character
|
|
1548
|
+
buffer.write(char)
|
|
1549
|
+
host.output_buffer.write(char)
|
|
1550
|
+
# if the length of the buffer is greater than 100, we try to decode the buffer to find if there are any unicode line change chars
|
|
1551
|
+
if buffer.tell() % 100 == 0 and buffer.tell() > 0:
|
|
1552
|
+
try:
|
|
1553
|
+
# try to decode the buffer to find if there are any unicode line change chars
|
|
1554
|
+
decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
|
|
1555
|
+
lines = decodedLine.splitlines()
|
|
1556
|
+
if len(lines) > 1:
|
|
1557
|
+
# if there are multiple lines, we add them to the target
|
|
1558
|
+
for line in lines[:-1]:
|
|
1559
|
+
# for all lines except the last one, we add them to the target
|
|
1560
|
+
target.append(line)
|
|
1561
|
+
host.output.append(line)
|
|
1562
|
+
host.lineNumToPrintSet.add(len(host.output)-1)
|
|
1563
|
+
# we keep the last line in the buffer
|
|
1564
|
+
buffer.seek(0)
|
|
1565
|
+
buffer.truncate(0)
|
|
1566
|
+
buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1567
|
+
host.output_buffer.seek(0)
|
|
1568
|
+
host.output_buffer.truncate(0)
|
|
1569
|
+
host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1570
|
+
|
|
1571
|
+
except UnicodeDecodeError:
|
|
1572
|
+
# if there is a unicode decode error, we just skip this character
|
|
1573
|
+
continue
|
|
1574
|
+
except ValueError:
|
|
1575
|
+
pass
|
|
1550
1576
|
if buffer.tell() > 0:
|
|
1551
1577
|
# if there is still some data in the buffer, we add it to the target
|
|
1552
1578
|
add_line(buffer,target, host)
|
|
@@ -1590,7 +1616,7 @@ def __handle_writing_stream(stream,stop_event,host):
|
|
|
1590
1616
|
# host.output.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
|
|
1591
1617
|
# host.stdout.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
|
|
1592
1618
|
return sentInputPos
|
|
1593
|
-
|
|
1619
|
+
|
|
1594
1620
|
def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
1595
1621
|
'''
|
|
1596
1622
|
Run the command on the host. Will format the commands accordingly. Main execution function.
|
|
@@ -2655,46 +2681,69 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
|
|
|
2655
2681
|
|
|
2656
2682
|
#%% ------------ Generate Output Block ----------------
|
|
2657
2683
|
def can_merge(line_bag1, line_bag2, threshold):
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
except StopIteration:
|
|
2664
|
-
break
|
|
2665
|
-
if item in line_bag2:
|
|
2666
|
-
found = True
|
|
2667
|
-
break
|
|
2668
|
-
if not found:
|
|
2669
|
-
return False
|
|
2670
|
-
return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
|
|
2684
|
+
if threshold > 0.5:
|
|
2685
|
+
samples = itertools.islice(line_bag1, max(int(len(line_bag1) * (1 - threshold)),1))
|
|
2686
|
+
if not line_bag2.intersection(samples):
|
|
2687
|
+
return False
|
|
2688
|
+
return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
|
|
2671
2689
|
|
|
2672
2690
|
def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
|
|
2673
2691
|
indexes = {hostname: 0 for hostname in merging_hostnames}
|
|
2674
|
-
|
|
2692
|
+
working_index_keys = set(indexes.keys())
|
|
2675
2693
|
previousBuddies = set()
|
|
2676
2694
|
hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
|
|
2677
2695
|
hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
|
|
2696
|
+
diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
|
|
2697
|
+
def get_multiset_index_for_hostname(hostname):
|
|
2698
|
+
index = indexes[hostname]
|
|
2699
|
+
tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
|
|
2700
|
+
return [OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index]
|
|
2701
|
+
# futuresChainMap = ChainMap()
|
|
2702
|
+
class futureDict(UserDict):
|
|
2703
|
+
def __missing__(self, key):
|
|
2704
|
+
value = get_multiset_index_for_hostname(key)
|
|
2705
|
+
self[key] = value
|
|
2706
|
+
# futuresChainMap.maps.append(value[0]._counter)
|
|
2707
|
+
return value
|
|
2708
|
+
# def initializeHostnames(self, hostnames):
|
|
2709
|
+
# entries = {hostname: get_multiset_index_for_hostname(hostname) for hostname in hostnames}
|
|
2710
|
+
# self.update(entries)
|
|
2711
|
+
# futuresChainMap.maps.extend(entry[0]._counter for entry in entries.values())
|
|
2712
|
+
futures = futureDict()
|
|
2713
|
+
currentLines = defaultdict(set)
|
|
2714
|
+
for hostname in merging_hostnames:
|
|
2715
|
+
currentLines[outputs_by_hostname[hostname][0]].add(hostname)
|
|
2678
2716
|
while indexes:
|
|
2679
|
-
futures = {}
|
|
2680
2717
|
defer = False
|
|
2681
|
-
|
|
2682
|
-
golden_hostname,
|
|
2683
|
-
|
|
2718
|
+
# sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
|
|
2719
|
+
golden_hostname = min(working_index_keys, key=lambda hn: indexes[hn])
|
|
2720
|
+
golden_index = indexes[golden_hostname]
|
|
2684
2721
|
lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
|
|
2685
|
-
for hostname, index in sorted_working_indexes[1:]:
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2722
|
+
# for hostname, index in sorted_working_indexes[1:]:
|
|
2723
|
+
# if lineToAdd == outputs_by_hostname[hostname][index]:
|
|
2724
|
+
# buddy.add(hostname)
|
|
2725
|
+
# else:
|
|
2726
|
+
# futureLines,tracking_index = futures[hostname]
|
|
2727
|
+
# if lineToAdd in futureLines:
|
|
2728
|
+
# for hn in buddy:
|
|
2729
|
+
# working_indexes.pop(hn,None)
|
|
2730
|
+
# defer = True
|
|
2731
|
+
# break
|
|
2732
|
+
buddy = currentLines[lineToAdd].copy()
|
|
2733
|
+
if len(buddy) < len(working_index_keys):
|
|
2734
|
+
# we need to check the futures then
|
|
2735
|
+
# thisCounter = None
|
|
2736
|
+
# if golden_hostname in futures:
|
|
2737
|
+
# thisCounter = futures[golden_hostname][0]._counter
|
|
2738
|
+
# futuresChainMap.maps.remove(thisCounter)
|
|
2739
|
+
for hostname in working_index_keys - buddy - set(futures.keys()):
|
|
2740
|
+
futures[hostname] # ensure it's initialized
|
|
2741
|
+
# futures.initializeHostnames(working_index_keys - buddy - futures.keys())
|
|
2742
|
+
if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
|
|
2743
|
+
defer = True
|
|
2744
|
+
working_index_keys -= buddy
|
|
2745
|
+
# if thisCounter is not None:
|
|
2746
|
+
# futuresChainMap.maps.append(thisCounter)
|
|
2698
2747
|
if not defer:
|
|
2699
2748
|
if buddy != previousBuddies:
|
|
2700
2749
|
hostnameStr = ','.join(compact_hostnames(buddy))
|
|
@@ -2705,23 +2754,33 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2705
2754
|
output.extend(hostnameLines)
|
|
2706
2755
|
previousBuddies = buddy
|
|
2707
2756
|
output.append(lineToAdd.ljust(line_length - 1) + '│')
|
|
2757
|
+
currentLines[lineToAdd].difference_update(buddy)
|
|
2758
|
+
if not currentLines[lineToAdd]:
|
|
2759
|
+
del currentLines[lineToAdd]
|
|
2708
2760
|
for hostname in buddy:
|
|
2761
|
+
# currentLines[lineToAdd].remove(hostname)
|
|
2762
|
+
# if not currentLines[lineToAdd]:
|
|
2763
|
+
# del currentLines[lineToAdd]
|
|
2709
2764
|
indexes[hostname] += 1
|
|
2710
|
-
|
|
2765
|
+
try:
|
|
2766
|
+
currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
|
|
2767
|
+
except IndexError:
|
|
2711
2768
|
indexes.pop(hostname, None)
|
|
2712
2769
|
futures.pop(hostname, None)
|
|
2770
|
+
# if future:
|
|
2771
|
+
# futuresChainMap.maps.remove(future[0]._counter)
|
|
2713
2772
|
continue
|
|
2714
2773
|
#advance futures
|
|
2715
2774
|
if hostname in futures:
|
|
2775
|
+
futures[hostname][1] += 1
|
|
2716
2776
|
tracking_multiset, tracking_index = futures[hostname]
|
|
2717
|
-
tracking_index += 1
|
|
2718
2777
|
if tracking_index < len(outputs_by_hostname[hostname]):
|
|
2719
2778
|
line = outputs_by_hostname[hostname][tracking_index]
|
|
2720
2779
|
tracking_multiset.append(line)
|
|
2721
2780
|
else:
|
|
2722
|
-
tracking_multiset.
|
|
2723
|
-
futures[hostname] = (tracking_multiset, tracking_index)
|
|
2724
|
-
|
|
2781
|
+
tracking_multiset.popleft()
|
|
2782
|
+
#futures[hostname] = (tracking_multiset, tracking_index)
|
|
2783
|
+
working_index_keys = set(indexes.keys())
|
|
2725
2784
|
|
|
2726
2785
|
def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
|
|
2727
2786
|
output = []
|
|
@@ -2741,6 +2800,20 @@ def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_di
|
|
|
2741
2800
|
# output[0] = '┌' + output[0][1:]
|
|
2742
2801
|
return output
|
|
2743
2802
|
|
|
2803
|
+
def pre_merge_hosts(hosts):
|
|
2804
|
+
'''Merge hosts with identical outputs.'''
|
|
2805
|
+
output_groups = defaultdict(list)
|
|
2806
|
+
# Group hosts by their output identity
|
|
2807
|
+
for host in hosts:
|
|
2808
|
+
identity = host.get_output_hash()
|
|
2809
|
+
output_groups[identity].append(host)
|
|
2810
|
+
# Create merged hosts
|
|
2811
|
+
merged_hosts = []
|
|
2812
|
+
for group in output_groups.values():
|
|
2813
|
+
group[0].name = ','.join(host.name for host in group)
|
|
2814
|
+
merged_hosts.append(group[0])
|
|
2815
|
+
return merged_hosts
|
|
2816
|
+
|
|
2744
2817
|
def get_host_raw_output(hosts, terminal_width):
|
|
2745
2818
|
outputs_by_hostname = {}
|
|
2746
2819
|
line_bag_by_hostname = {}
|
|
@@ -2748,67 +2821,78 @@ def get_host_raw_output(hosts, terminal_width):
|
|
|
2748
2821
|
text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
2749
2822
|
initial_indent='│ ', subsequent_indent='│-')
|
|
2750
2823
|
max_length = 20
|
|
2824
|
+
hosts = pre_merge_hosts(hosts)
|
|
2751
2825
|
for host in hosts:
|
|
2752
2826
|
hostPrintOut = ["│█ EXECUTED COMMAND:"]
|
|
2753
|
-
for line in host
|
|
2827
|
+
for line in host.command.splitlines():
|
|
2754
2828
|
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2829
|
+
# hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
|
|
2830
|
+
lineBag = {(0,host.command)}
|
|
2831
|
+
prevLine = host.command
|
|
2832
|
+
if host.stdout:
|
|
2758
2833
|
hostPrintOut.append('│▓ STDOUT:')
|
|
2759
|
-
for line in host
|
|
2760
|
-
|
|
2834
|
+
for line in host.stdout:
|
|
2835
|
+
if len(line) < terminal_width - 2:
|
|
2836
|
+
hostPrintOut.append(f"│ {line}")
|
|
2837
|
+
else:
|
|
2838
|
+
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2839
|
+
# hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
|
|
2761
2840
|
lineBag.add((prevLine,1))
|
|
2762
|
-
lineBag.add((1,host
|
|
2763
|
-
if len(host
|
|
2764
|
-
lineBag.update(zip(host
|
|
2765
|
-
lineBag.update(host
|
|
2766
|
-
prevLine = host
|
|
2767
|
-
if host
|
|
2768
|
-
if host
|
|
2769
|
-
host
|
|
2770
|
-
elif host
|
|
2771
|
-
host
|
|
2772
|
-
elif host
|
|
2773
|
-
host
|
|
2774
|
-
if host
|
|
2841
|
+
lineBag.add((1,host.stdout[0]))
|
|
2842
|
+
if len(host.stdout) > 1:
|
|
2843
|
+
lineBag.update(zip(host.stdout, host.stdout[1:]))
|
|
2844
|
+
lineBag.update(host.stdout)
|
|
2845
|
+
prevLine = host.stdout[-1]
|
|
2846
|
+
if host.stderr:
|
|
2847
|
+
if host.stderr[0].strip().startswith('ssh: connect to host ') and host.stderr[0].strip().endswith('Connection refused'):
|
|
2848
|
+
host.stderr[0] = 'SSH not reachable!'
|
|
2849
|
+
elif host.stderr[-1].strip().endswith('Connection timed out'):
|
|
2850
|
+
host.stderr[-1] = 'SSH connection timed out!'
|
|
2851
|
+
elif host.stderr[-1].strip().endswith('No route to host'):
|
|
2852
|
+
host.stderr[-1] = 'Cannot find host!'
|
|
2853
|
+
if host.stderr:
|
|
2775
2854
|
hostPrintOut.append('│▒ STDERR:')
|
|
2776
|
-
for line in host
|
|
2777
|
-
|
|
2855
|
+
for line in host.stderr:
|
|
2856
|
+
if len(line) < terminal_width - 2:
|
|
2857
|
+
hostPrintOut.append(f"│ {line}")
|
|
2858
|
+
else:
|
|
2859
|
+
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2778
2860
|
lineBag.add((prevLine,2))
|
|
2779
|
-
lineBag.add((2,host
|
|
2780
|
-
lineBag.update(host
|
|
2781
|
-
if len(host
|
|
2782
|
-
lineBag.update(zip(host
|
|
2783
|
-
prevLine = host
|
|
2784
|
-
hostPrintOut.append(f"│░ RETURN CODE: {host
|
|
2785
|
-
lineBag.add((prevLine,f"{host
|
|
2861
|
+
lineBag.add((2,host.stderr[0]))
|
|
2862
|
+
lineBag.update(host.stderr)
|
|
2863
|
+
if len(host.stderr) > 1:
|
|
2864
|
+
lineBag.update(zip(host.stderr, host.stderr[1:]))
|
|
2865
|
+
prevLine = host.stderr[-1]
|
|
2866
|
+
hostPrintOut.append(f"│░ RETURN CODE: {host.returncode}")
|
|
2867
|
+
lineBag.add((prevLine,f"{host.returncode}"))
|
|
2786
2868
|
max_length = max(max_length, max(map(len, hostPrintOut)))
|
|
2787
|
-
outputs_by_hostname[host
|
|
2788
|
-
line_bag_by_hostname[host
|
|
2789
|
-
hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host
|
|
2869
|
+
outputs_by_hostname[host.name] = hostPrintOut
|
|
2870
|
+
line_bag_by_hostname[host.name] = lineBag
|
|
2871
|
+
hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
|
|
2790
2872
|
return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len), min(max_length+2,terminal_width)
|
|
2791
2873
|
|
|
2792
2874
|
def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
|
|
2793
2875
|
merge_groups = []
|
|
2794
|
-
|
|
2876
|
+
remaining_hostnames = set()
|
|
2877
|
+
for lbl_i, line_bag_len in enumerate(sorted_hostnames_by_line_bag_len_keys):
|
|
2795
2878
|
for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
|
|
2796
|
-
if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
|
|
2879
|
+
# if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
|
|
2880
|
+
# continue
|
|
2881
|
+
try:
|
|
2882
|
+
this_line_bag = line_bag_by_hostname.pop(this_hostname)
|
|
2883
|
+
hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
|
|
2884
|
+
except KeyError:
|
|
2797
2885
|
continue
|
|
2798
|
-
this_line_bag = line_bag_by_hostname[this_hostname]
|
|
2799
2886
|
target_threshold = line_bag_len * (2 - diff_display_threshold)
|
|
2800
2887
|
merge_group = []
|
|
2801
|
-
for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
|
|
2888
|
+
for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys[lbl_i:]:
|
|
2802
2889
|
if other_line_bag_len > target_threshold:
|
|
2803
2890
|
break
|
|
2804
|
-
if other_line_bag_len < line_bag_len:
|
|
2805
|
-
|
|
2891
|
+
# if other_line_bag_len < line_bag_len:
|
|
2892
|
+
# continue
|
|
2806
2893
|
for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
|
|
2807
|
-
if this_hostname == other_hostname:
|
|
2808
|
-
continue
|
|
2809
2894
|
if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
|
|
2810
2895
|
merge_group.append(other_hostname)
|
|
2811
|
-
hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
|
|
2812
2896
|
hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
|
|
2813
2897
|
if not hostnames_by_line_bag_len[other_line_bag_len]:
|
|
2814
2898
|
del hostnames_by_line_bag_len[other_line_bag_len]
|
|
@@ -2816,23 +2900,24 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
|
|
|
2816
2900
|
if merge_group:
|
|
2817
2901
|
merge_group.append(this_hostname)
|
|
2818
2902
|
merge_groups.append(merge_group)
|
|
2819
|
-
|
|
2903
|
+
# del line_bag_by_hostname[this_hostname]
|
|
2904
|
+
else:
|
|
2905
|
+
remaining_hostnames.add(this_hostname)
|
|
2906
|
+
return merge_groups, remaining_hostnames
|
|
2820
2907
|
|
|
2821
2908
|
def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
|
|
2822
2909
|
if quiet:
|
|
2823
2910
|
# remove hosts with returncode 0
|
|
2824
|
-
hosts = [
|
|
2911
|
+
hosts = [host for host in hosts if host.returncode != 0]
|
|
2825
2912
|
if not hosts:
|
|
2826
2913
|
if usejson:
|
|
2827
2914
|
return '{"Success": true}'
|
|
2828
2915
|
else:
|
|
2829
2916
|
return 'Success'
|
|
2830
|
-
else:
|
|
2831
|
-
hosts = [dict(host) for host in hosts]
|
|
2832
2917
|
if usejson:
|
|
2833
2918
|
# [print(dict(host)) for host in hosts]
|
|
2834
2919
|
#print(json.dumps([dict(host) for host in hosts],indent=4))
|
|
2835
|
-
rtnStr = json.dumps(hosts,indent=4)
|
|
2920
|
+
rtnStr = json.dumps([dict(host) for host in hosts],indent=4)
|
|
2836
2921
|
elif greppable:
|
|
2837
2922
|
# transform hosts to a 2d list
|
|
2838
2923
|
rtnStr = '*'*80+'\n'
|
|
@@ -2840,14 +2925,14 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2840
2925
|
for host in hosts:
|
|
2841
2926
|
#header = f"{host['name']} | rc: {host['returncode']} | "
|
|
2842
2927
|
hostAdded = False
|
|
2843
|
-
for line in host
|
|
2844
|
-
rtnList.append([host
|
|
2928
|
+
for line in host.stdout:
|
|
2929
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'stdout',line])
|
|
2845
2930
|
hostAdded = True
|
|
2846
|
-
for line in host
|
|
2847
|
-
rtnList.append([host
|
|
2931
|
+
for line in host.stderr:
|
|
2932
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'stderr',line])
|
|
2848
2933
|
hostAdded = True
|
|
2849
2934
|
if not hostAdded:
|
|
2850
|
-
rtnList.append([host
|
|
2935
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'N/A','<EMPTY>'])
|
|
2851
2936
|
rtnList.append(['','','',''])
|
|
2852
2937
|
rtnStr += pretty_format_table(rtnList)
|
|
2853
2938
|
rtnStr += '*'*80+'\n'
|
|
@@ -2865,11 +2950,7 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2865
2950
|
diff_display_threshold = 0.9
|
|
2866
2951
|
terminal_length = get_terminal_size()[0]
|
|
2867
2952
|
outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_length = get_host_raw_output(hosts,terminal_length)
|
|
2868
|
-
merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
|
|
2869
|
-
# get the remaining hostnames in the hostnames_by_line_bag_len
|
|
2870
|
-
remaining_hostnames = set()
|
|
2871
|
-
for hostnames in hostnames_by_line_bag_len.values():
|
|
2872
|
-
remaining_hostnames.update(hostnames)
|
|
2953
|
+
merge_groups ,remaining_hostnames = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
|
|
2873
2954
|
outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
|
|
2874
2955
|
if keyPressesIn[-1]:
|
|
2875
2956
|
CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
|
|
@@ -2880,8 +2961,8 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2880
2961
|
initial_indent='│ ', subsequent_indent='│-'))
|
|
2881
2962
|
outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
|
|
2882
2963
|
keyPressesIn[-1].clear()
|
|
2883
|
-
if
|
|
2884
|
-
rtnStr = 'Success'
|
|
2964
|
+
if not outputs:
|
|
2965
|
+
rtnStr = 'Success' if quiet else ''
|
|
2885
2966
|
else:
|
|
2886
2967
|
rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
|
|
2887
2968
|
return rtnStr
|
|
@@ -2901,6 +2982,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
|
2901
2982
|
global __global_suppress_printout
|
|
2902
2983
|
global _encoding
|
|
2903
2984
|
global __keyPressesIn
|
|
2985
|
+
for host in hosts:
|
|
2986
|
+
host.output.clear()
|
|
2904
2987
|
rtnStr = generate_output(hosts,usejson,greppable,quiet=__global_suppress_printout,encoding=_encoding,keyPressesIn=__keyPressesIn)
|
|
2905
2988
|
if not quiet:
|
|
2906
2989
|
print(rtnStr)
|
|
@@ -3554,8 +3637,8 @@ def write_default_config(args,CONFIG_FILE = None):
|
|
|
3554
3637
|
#%% ------------ Argument Processing -----------------
|
|
3555
3638
|
def get_parser():
|
|
3556
3639
|
global _binPaths
|
|
3557
|
-
parser = argparse.ArgumentParser(description=
|
|
3558
|
-
epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}',)
|
|
3640
|
+
parser = argparse.ArgumentParser(description='Run a command on multiple hosts, Use #HOST# or #HOSTNAME# to replace the host name in the command.',
|
|
3641
|
+
epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}\nConfig file chain: {CONFIG_FILE_CHAIN!r}',)
|
|
3559
3642
|
parser.add_argument('hosts', metavar='hosts', type=str, nargs='?', help=f'Hosts to run the command on, use "," to seperate hosts. (default: {DEFAULT_HOSTS})',default=DEFAULT_HOSTS)
|
|
3560
3643
|
parser.add_argument('commands', metavar='commands', type=str, nargs='*',default=None,help='the command to run on the hosts / the destination of the files #HOST# or #HOSTNAME# will be replaced with the host name.')
|
|
3561
3644
|
parser.add_argument('-u','--username', type=str,help=f'The general username to use to connect to the hosts. Will get overwrote by individual username@host if specified. (default: {DEFAULT_USERNAME})',default=DEFAULT_USERNAME)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|