multiSSH3 5.90__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.90 → multissh3-5.92}/PKG-INFO +1 -1
- {multissh3-5.90 → multissh3-5.92}/README.md +0 -0
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.egg-info/PKG-INFO +1 -1
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.py +463 -222
- {multissh3-5.90 → multissh3-5.92}/setup.py +0 -0
- {multissh3-5.90 → multissh3-5.92}/test/test.py +0 -0
- {multissh3-5.90 → multissh3-5.92}/test/testCurses.py +0 -0
- {multissh3-5.90 → multissh3-5.92}/test/testCursesOld.py +0 -0
- {multissh3-5.90 → multissh3-5.92}/test/testPerfCompact.py +0 -0
- {multissh3-5.90 → multissh3-5.92}/test/testPerfExpand.py +0 -0
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.egg-info/SOURCES.txt +0 -0
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.egg-info/dependency_links.txt +0 -0
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.egg-info/entry_points.txt +0 -0
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.egg-info/requires.txt +0 -0
- {multissh3-5.90 → multissh3-5.92}/multiSSH3.egg-info/top_level.txt +0 -0
- {multissh3-5.90 → 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
|
|
@@ -24,11 +25,12 @@ import string
|
|
|
24
25
|
import subprocess
|
|
25
26
|
import sys
|
|
26
27
|
import tempfile
|
|
28
|
+
import textwrap
|
|
27
29
|
import threading
|
|
28
30
|
import time
|
|
29
31
|
import typing
|
|
30
32
|
import uuid
|
|
31
|
-
from collections import Counter, deque
|
|
33
|
+
from collections import Counter, deque, defaultdict, UserDict
|
|
32
34
|
from itertools import count, product
|
|
33
35
|
|
|
34
36
|
__curses_available = False
|
|
@@ -83,10 +85,10 @@ except Exception:
|
|
|
83
85
|
print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
|
|
84
86
|
def cache_decorator(func):
|
|
85
87
|
return func
|
|
86
|
-
version = '5.
|
|
88
|
+
version = '5.92'
|
|
87
89
|
VERSION = version
|
|
88
90
|
__version__ = version
|
|
89
|
-
COMMIT_DATE = '2025-10-
|
|
91
|
+
COMMIT_DATE = '2025-10-20'
|
|
90
92
|
|
|
91
93
|
CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
|
|
92
94
|
'~/multiSSH3.config.json',
|
|
@@ -152,33 +154,6 @@ def signal_handler(sig, frame):
|
|
|
152
154
|
os.system(f'pkill -ef {os.path.basename(__file__)}')
|
|
153
155
|
_exit_with_code(1, 'Exiting immediately due to Ctrl C')
|
|
154
156
|
|
|
155
|
-
# def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
|
|
156
|
-
# """
|
|
157
|
-
# Read an input from the user with a timeout and a countdown.
|
|
158
|
-
|
|
159
|
-
# Parameters:
|
|
160
|
-
# timeout (int): The timeout value in seconds.
|
|
161
|
-
# prompt (str): The prompt message to display to the user. Default is 'Please enter your selection'.
|
|
162
|
-
|
|
163
|
-
# Returns:
|
|
164
|
-
# str or None: The user input if received within the timeout, or None if no input is received.
|
|
165
|
-
# """
|
|
166
|
-
# import select
|
|
167
|
-
# # Print the initial prompt with the countdown
|
|
168
|
-
# eprint(f"{prompt} [{timeout}s]: ", end='', flush=True)
|
|
169
|
-
# # Loop until the timeout
|
|
170
|
-
# for remaining in range(timeout, 0, -1):
|
|
171
|
-
# # If there is an input, return it
|
|
172
|
-
# # this only works on linux
|
|
173
|
-
# if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
|
|
174
|
-
# return input().strip()
|
|
175
|
-
# # Print the remaining time
|
|
176
|
-
# eprint(f"\r{prompt} [{remaining}s]: ", end='', flush=True)
|
|
177
|
-
# # Wait a second
|
|
178
|
-
# time.sleep(1)
|
|
179
|
-
# # If there is no input, return None
|
|
180
|
-
# return None
|
|
181
|
-
|
|
182
157
|
def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
|
|
183
158
|
"""
|
|
184
159
|
Read input from the user with a timeout (cross-platform).
|
|
@@ -311,6 +286,13 @@ extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={
|
|
|
311
286
|
identity_file={self.identity_file}, ip={self.ip}, current_color_pair={self.current_color_pair}"
|
|
312
287
|
def __str__(self):
|
|
313
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
|
+
))
|
|
314
296
|
|
|
315
297
|
#%% ------------ Load Defaults ( Config ) File ----------------
|
|
316
298
|
def load_config_file(config_file):
|
|
@@ -376,6 +358,7 @@ DEFAULT_SKIP_HOSTS = ''
|
|
|
376
358
|
DEFAULT_ENCODING = 'utf-8'
|
|
377
359
|
DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
|
|
378
360
|
SSH_STRICT_HOST_KEY_CHECKING = False
|
|
361
|
+
FORCE_TRUECOLOR = False
|
|
379
362
|
ERROR_MESSAGES_TO_IGNORE = [
|
|
380
363
|
'Pseudo-terminal will not be allocated because stdin is not a terminal',
|
|
381
364
|
'Connection to .* closed',
|
|
@@ -648,8 +631,6 @@ def format_commands(commands):
|
|
|
648
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}")
|
|
649
632
|
return commands
|
|
650
633
|
|
|
651
|
-
|
|
652
|
-
|
|
653
634
|
class OrderedMultiSet(deque):
|
|
654
635
|
"""
|
|
655
636
|
A deque extension with O(1) average lookup time.
|
|
@@ -666,29 +647,55 @@ class OrderedMultiSet(deque):
|
|
|
666
647
|
self._counter[item] -= 1
|
|
667
648
|
if self._counter[item] == 0:
|
|
668
649
|
del self._counter[item]
|
|
669
|
-
|
|
670
|
-
def append(self, item,left=False):
|
|
650
|
+
def append(self, item):
|
|
671
651
|
"""Add item to the right end. O(1)."""
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
self.__decrease_count(removed)
|
|
676
|
-
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)
|
|
677
655
|
self._counter[item] += 1
|
|
678
|
-
return removed
|
|
679
656
|
def appendleft(self, item):
|
|
680
657
|
"""Add item to the left end. O(1)."""
|
|
681
|
-
|
|
682
|
-
|
|
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):
|
|
683
663
|
"""Remove and return item from right end. O(1)."""
|
|
684
|
-
|
|
664
|
+
try:
|
|
665
|
+
item = super().pop()
|
|
666
|
+
self.__decrease_count(item)
|
|
667
|
+
return item
|
|
668
|
+
except IndexError:
|
|
685
669
|
return None
|
|
686
|
-
item = super().popleft() if left else super().pop()
|
|
687
|
-
self.__decrease_count(item)
|
|
688
|
-
return item
|
|
689
670
|
def popleft(self):
|
|
690
671
|
"""Remove and return item from left end. O(1)."""
|
|
691
|
-
|
|
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()
|
|
692
699
|
def remove(self, value):
|
|
693
700
|
"""Remove first occurrence of value. O(n)."""
|
|
694
701
|
if value not in self._counter:
|
|
@@ -701,16 +708,34 @@ class OrderedMultiSet(deque):
|
|
|
701
708
|
self._counter.clear()
|
|
702
709
|
def extend(self, iterable):
|
|
703
710
|
"""Extend deque by appending elements from iterable. O(k)."""
|
|
704
|
-
|
|
705
|
-
|
|
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))
|
|
706
733
|
def extendleft(self, iterable):
|
|
707
734
|
"""Extend left side by appending elements from iterable. O(k)."""
|
|
708
735
|
for item in iterable:
|
|
709
736
|
self.appendleft(item)
|
|
710
737
|
def rotate(self, n=1):
|
|
711
738
|
"""Rotate deque n steps to the right. O(k) where k = min(n, len)."""
|
|
712
|
-
if not self:
|
|
713
|
-
return
|
|
714
739
|
super().rotate(n)
|
|
715
740
|
def __contains__(self, item):
|
|
716
741
|
"""Check if item exists in deque. O(1) average."""
|
|
@@ -751,22 +776,18 @@ class OrderedMultiSet(deque):
|
|
|
751
776
|
if self.maxlen is not None:
|
|
752
777
|
return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
|
|
753
778
|
return f"OrderedMultiSet({list(self)})"
|
|
754
|
-
def put(self, item,left=False):
|
|
755
|
-
"""Alias for append - add to right end (FIFO put)."""
|
|
756
|
-
return self.append(item,left=left)
|
|
757
|
-
def get(self,left=True):
|
|
758
|
-
"""Alias for popleft - remove from left end (FIFO get)."""
|
|
759
|
-
return self.pop(left=left)
|
|
760
779
|
def peek(self):
|
|
761
780
|
"""Return leftmost item without removing it."""
|
|
762
|
-
|
|
781
|
+
try:
|
|
782
|
+
return self[0]
|
|
783
|
+
except IndexError:
|
|
763
784
|
return None
|
|
764
|
-
return self[0]
|
|
765
785
|
def peek_right(self):
|
|
766
786
|
"""Return rightmost item without removing it."""
|
|
767
|
-
|
|
787
|
+
try:
|
|
788
|
+
return self[-1]
|
|
789
|
+
except IndexError:
|
|
768
790
|
return None
|
|
769
|
-
return self[-1]
|
|
770
791
|
|
|
771
792
|
def get_terminal_size():
|
|
772
793
|
'''
|
|
@@ -792,6 +813,135 @@ def get_terminal_size():
|
|
|
792
813
|
import shutil
|
|
793
814
|
_tsize = shutil.get_terminal_size(fallback=(120, 30))
|
|
794
815
|
return _tsize
|
|
816
|
+
|
|
817
|
+
@cache_decorator
|
|
818
|
+
def get_terminal_color_capability():
|
|
819
|
+
global FORCE_TRUECOLOR
|
|
820
|
+
if not sys.stdout.isatty():
|
|
821
|
+
return 'None'
|
|
822
|
+
term = os.environ.get("TERM", "")
|
|
823
|
+
if term == "dumb":
|
|
824
|
+
return 'None'
|
|
825
|
+
elif term == "linux":
|
|
826
|
+
return '8'
|
|
827
|
+
elif FORCE_TRUECOLOR:
|
|
828
|
+
return '24bit'
|
|
829
|
+
colorterm = os.environ.get("COLORTERM", "")
|
|
830
|
+
if colorterm in ("truecolor", "24bit", "24-bit"):
|
|
831
|
+
return '24bit'
|
|
832
|
+
if term in ("xterm-truecolor", "xterm-24bit", "xterm-kitty", "alacritty", "wezterm", "foot", "terminology"):
|
|
833
|
+
return '24bit'
|
|
834
|
+
elif "256" in term:
|
|
835
|
+
return '256'
|
|
836
|
+
try:
|
|
837
|
+
curses.setupterm()
|
|
838
|
+
colors = curses.tigetnum("colors")
|
|
839
|
+
# tigetnum returns -1 if the capability isn’t defined
|
|
840
|
+
if colors >= 16777216:
|
|
841
|
+
return '24bit'
|
|
842
|
+
elif colors >= 256:
|
|
843
|
+
return '256'
|
|
844
|
+
elif colors >= 16:
|
|
845
|
+
return '16'
|
|
846
|
+
elif colors > 0:
|
|
847
|
+
return '8'
|
|
848
|
+
else:
|
|
849
|
+
return 'None'
|
|
850
|
+
except Exception:
|
|
851
|
+
return 'None'
|
|
852
|
+
|
|
853
|
+
@cache_decorator
|
|
854
|
+
def get_xterm256_palette():
|
|
855
|
+
palette = []
|
|
856
|
+
# 0–15: system colors (we'll just fill with dummy values;
|
|
857
|
+
# you could fill in real RGB if you need to)
|
|
858
|
+
system_colors = [
|
|
859
|
+
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
|
|
860
|
+
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
|
|
861
|
+
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
|
|
862
|
+
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
|
|
863
|
+
]
|
|
864
|
+
palette.extend(system_colors)
|
|
865
|
+
# 16–231: 6x6x6 color cube
|
|
866
|
+
levels = [0, 95, 135, 175, 215, 255]
|
|
867
|
+
for r in levels:
|
|
868
|
+
for g in levels:
|
|
869
|
+
for b in levels:
|
|
870
|
+
palette.append((r, g, b))
|
|
871
|
+
# 232–255: grayscale ramp, 24 steps from 8 to 238
|
|
872
|
+
for i in range(24):
|
|
873
|
+
level = 8 + i * 10
|
|
874
|
+
palette.append((level, level, level))
|
|
875
|
+
return palette
|
|
876
|
+
|
|
877
|
+
@cache_decorator
|
|
878
|
+
def rgb_to_xterm_index(r, g, b):
|
|
879
|
+
"""
|
|
880
|
+
Map 24-bit RGB to nearest xterm-256 color index.
|
|
881
|
+
r, g, b should be in 0-255.
|
|
882
|
+
Returns an int in 0-255.
|
|
883
|
+
"""
|
|
884
|
+
best_index = 0
|
|
885
|
+
best_dist = float('inf')
|
|
886
|
+
for i, (pr, pg, pb) in enumerate(get_xterm256_palette()):
|
|
887
|
+
dr = pr - r
|
|
888
|
+
dg = pg - g
|
|
889
|
+
db = pb - b
|
|
890
|
+
dist = dr*dr + dg*dg + db*db
|
|
891
|
+
if dist < best_dist:
|
|
892
|
+
best_dist = dist
|
|
893
|
+
best_index = i
|
|
894
|
+
return best_index
|
|
895
|
+
|
|
896
|
+
@cache_decorator
|
|
897
|
+
def hashable_to_color(n, brightness_threshold=500):
|
|
898
|
+
hash_value = hash(str(n))
|
|
899
|
+
r = (hash_value >> 16) & 0xFF
|
|
900
|
+
g = (hash_value >> 8) & 0xFF
|
|
901
|
+
b = hash_value & 0xFF
|
|
902
|
+
if (r + g + b) < brightness_threshold:
|
|
903
|
+
return hashable_to_color(hash_value, brightness_threshold)
|
|
904
|
+
return (r, g, b)
|
|
905
|
+
|
|
906
|
+
__previous_ansi_color_index = -1
|
|
907
|
+
@cache_decorator
|
|
908
|
+
def string_to_unique_ansi_color(string):
|
|
909
|
+
'''
|
|
910
|
+
Convert a string to a unique ANSI color code
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
string (str): The string to convert
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
int: The ANSI color code
|
|
917
|
+
'''
|
|
918
|
+
global __previous_ansi_color_index
|
|
919
|
+
# Use a hash function to generate a consistent integer from the string
|
|
920
|
+
color_capability = get_terminal_color_capability()
|
|
921
|
+
index = None
|
|
922
|
+
if color_capability == 'None':
|
|
923
|
+
return ''
|
|
924
|
+
elif color_capability == '16':
|
|
925
|
+
# Map to one of the 14 colors (31-37, 90-96), avoiding black and white
|
|
926
|
+
index = (hash(string) % 14) + 31
|
|
927
|
+
if index > 37:
|
|
928
|
+
index += 52 # Bright colors (90-97)
|
|
929
|
+
elif color_capability == '8':
|
|
930
|
+
index = (hash(string) % 6) + 31
|
|
931
|
+
r,g,b = hashable_to_color(string)
|
|
932
|
+
if color_capability == '256':
|
|
933
|
+
index = rgb_to_xterm_index(r,g,b)
|
|
934
|
+
if index:
|
|
935
|
+
if index == __previous_ansi_color_index:
|
|
936
|
+
return string_to_unique_ansi_color(hash(string))
|
|
937
|
+
__previous_ansi_color_index = index
|
|
938
|
+
if color_capability == '256':
|
|
939
|
+
return f'\033[38;5;{index}m'
|
|
940
|
+
else:
|
|
941
|
+
return f'\033[{index}m'
|
|
942
|
+
else:
|
|
943
|
+
return f'\033[38;2;{r};{g};{b}m'
|
|
944
|
+
|
|
795
945
|
#%% ------------ Compacting Hostnames ----------------
|
|
796
946
|
def __tokenize_hostname(hostname):
|
|
797
947
|
"""
|
|
@@ -1136,10 +1286,15 @@ def compact_hostnames(Hostnames,verify = True):
|
|
|
1136
1286
|
['sub-s[1-2]']
|
|
1137
1287
|
"""
|
|
1138
1288
|
global __global_suppress_printout
|
|
1139
|
-
if not isinstance(Hostnames, frozenset):
|
|
1140
|
-
|
|
1141
|
-
else:
|
|
1142
|
-
|
|
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
|
+
)
|
|
1143
1298
|
compact_hosts = __compact_hostnames(hostSet)
|
|
1144
1299
|
if verify:
|
|
1145
1300
|
if set(expand_hostnames(compact_hosts)) != set(expand_hostnames(hostSet)):
|
|
@@ -1371,51 +1526,53 @@ def __handle_reading_stream(stream,target, host,buffer:io.BytesIO):
|
|
|
1371
1526
|
buffer.truncate(0)
|
|
1372
1527
|
host.output_buffer.seek(0)
|
|
1373
1528
|
host.output_buffer.truncate(0)
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
continue
|
|
1380
|
-
elif char == b'\r':
|
|
1381
|
-
buffer.seek(0)
|
|
1382
|
-
host.output_buffer.seek(0)
|
|
1383
|
-
elif char == b'\x08':
|
|
1384
|
-
# backspace
|
|
1385
|
-
if buffer.tell() > 0:
|
|
1386
|
-
buffer.seek(buffer.tell() - 1)
|
|
1387
|
-
buffer.truncate()
|
|
1388
|
-
if host.output_buffer.tell() > 0:
|
|
1389
|
-
host.output_buffer.seek(host.output_buffer.tell() - 1)
|
|
1390
|
-
host.output_buffer.truncate()
|
|
1391
|
-
else:
|
|
1392
|
-
# normal character
|
|
1393
|
-
buffer.write(char)
|
|
1394
|
-
host.output_buffer.write(char)
|
|
1395
|
-
# 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
|
|
1396
|
-
if buffer.tell() % 100 == 0 and buffer.tell() > 0:
|
|
1397
|
-
try:
|
|
1398
|
-
# try to decode the buffer to find if there are any unicode line change chars
|
|
1399
|
-
decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
|
|
1400
|
-
lines = decodedLine.splitlines()
|
|
1401
|
-
if len(lines) > 1:
|
|
1402
|
-
# if there are multiple lines, we add them to the target
|
|
1403
|
-
for line in lines[:-1]:
|
|
1404
|
-
# for all lines except the last one, we add them to the target
|
|
1405
|
-
target.append(line)
|
|
1406
|
-
host.output.append(line)
|
|
1407
|
-
host.lineNumToPrintSet.add(len(host.output)-1)
|
|
1408
|
-
# we keep the last line in the buffer
|
|
1409
|
-
buffer.seek(0)
|
|
1410
|
-
buffer.truncate(0)
|
|
1411
|
-
buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1412
|
-
host.output_buffer.seek(0)
|
|
1413
|
-
host.output_buffer.truncate(0)
|
|
1414
|
-
host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1415
|
-
|
|
1416
|
-
except UnicodeDecodeError:
|
|
1417
|
-
# 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)
|
|
1418
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
|
|
1419
1576
|
if buffer.tell() > 0:
|
|
1420
1577
|
# if there is still some data in the buffer, we add it to the target
|
|
1421
1578
|
add_line(buffer,target, host)
|
|
@@ -1459,7 +1616,7 @@ def __handle_writing_stream(stream,stop_event,host):
|
|
|
1459
1616
|
# host.output.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
|
|
1460
1617
|
# host.stdout.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
|
|
1461
1618
|
return sentInputPos
|
|
1462
|
-
|
|
1619
|
+
|
|
1463
1620
|
def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
1464
1621
|
'''
|
|
1465
1622
|
Run the command on the host. Will format the commands accordingly. Main execution function.
|
|
@@ -2524,141 +2681,218 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
|
|
|
2524
2681
|
|
|
2525
2682
|
#%% ------------ Generate Output Block ----------------
|
|
2526
2683
|
def can_merge(line_bag1, line_bag2, threshold):
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
except StopIteration:
|
|
2533
|
-
break
|
|
2534
|
-
if item in line_bag2:
|
|
2535
|
-
found = True
|
|
2536
|
-
break
|
|
2537
|
-
if not found:
|
|
2538
|
-
return False
|
|
2539
|
-
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
|
|
2540
2689
|
|
|
2541
|
-
def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold):
|
|
2542
|
-
terminal_length = get_terminal_size()[0]
|
|
2543
|
-
output.append(('├'+'─'*(terminal_length-1)))
|
|
2690
|
+
def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
|
|
2544
2691
|
indexes = {hostname: 0 for hostname in merging_hostnames}
|
|
2545
|
-
|
|
2692
|
+
working_index_keys = set(indexes.keys())
|
|
2546
2693
|
previousBuddies = set()
|
|
2694
|
+
hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
|
|
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)
|
|
2547
2716
|
while indexes:
|
|
2548
|
-
futures = {}
|
|
2549
2717
|
defer = False
|
|
2550
|
-
|
|
2551
|
-
golden_hostname,
|
|
2552
|
-
|
|
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]
|
|
2553
2721
|
lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
|
|
2554
|
-
for hostname, index in sorted_working_indexes[1:]:
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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)
|
|
2567
2747
|
if not defer:
|
|
2568
2748
|
if buddy != previousBuddies:
|
|
2569
|
-
|
|
2749
|
+
hostnameStr = ','.join(compact_hostnames(buddy))
|
|
2750
|
+
hostnameLines = hostnameWrapper.wrap(hostnameStr)
|
|
2751
|
+
hostnameLines = [line.ljust(line_length - 1) + '│' for line in hostnameLines]
|
|
2752
|
+
color = string_to_unique_ansi_color(hostnameStr) if len(buddy) < len(merging_hostnames) else ''
|
|
2753
|
+
hostnameLines[0] = f"\033[0m{color}{hostnameLines[0]}"
|
|
2754
|
+
output.extend(hostnameLines)
|
|
2570
2755
|
previousBuddies = buddy
|
|
2571
|
-
output.append(lineToAdd)
|
|
2756
|
+
output.append(lineToAdd.ljust(line_length - 1) + '│')
|
|
2757
|
+
currentLines[lineToAdd].difference_update(buddy)
|
|
2758
|
+
if not currentLines[lineToAdd]:
|
|
2759
|
+
del currentLines[lineToAdd]
|
|
2572
2760
|
for hostname in buddy:
|
|
2761
|
+
# currentLines[lineToAdd].remove(hostname)
|
|
2762
|
+
# if not currentLines[lineToAdd]:
|
|
2763
|
+
# del currentLines[lineToAdd]
|
|
2573
2764
|
indexes[hostname] += 1
|
|
2574
|
-
|
|
2765
|
+
try:
|
|
2766
|
+
currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
|
|
2767
|
+
except IndexError:
|
|
2575
2768
|
indexes.pop(hostname, None)
|
|
2576
2769
|
futures.pop(hostname, None)
|
|
2770
|
+
# if future:
|
|
2771
|
+
# futuresChainMap.maps.remove(future[0]._counter)
|
|
2577
2772
|
continue
|
|
2578
2773
|
#advance futures
|
|
2579
2774
|
if hostname in futures:
|
|
2775
|
+
futures[hostname][1] += 1
|
|
2580
2776
|
tracking_multiset, tracking_index = futures[hostname]
|
|
2581
|
-
tracking_index += 1
|
|
2582
2777
|
if tracking_index < len(outputs_by_hostname[hostname]):
|
|
2583
2778
|
line = outputs_by_hostname[hostname][tracking_index]
|
|
2584
2779
|
tracking_multiset.append(line)
|
|
2585
2780
|
else:
|
|
2586
|
-
tracking_multiset.
|
|
2587
|
-
futures[hostname] = (tracking_multiset, tracking_index)
|
|
2588
|
-
|
|
2781
|
+
tracking_multiset.popleft()
|
|
2782
|
+
#futures[hostname] = (tracking_multiset, tracking_index)
|
|
2783
|
+
working_index_keys = set(indexes.keys())
|
|
2589
2784
|
|
|
2590
|
-
def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold):
|
|
2591
|
-
terminal_length = get_terminal_size()[0]
|
|
2785
|
+
def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
|
|
2592
2786
|
output = []
|
|
2787
|
+
output.append(('┌'+'─'*(line_length-2) + '┐'))
|
|
2593
2788
|
for merging_hostnames in merge_groups:
|
|
2594
|
-
mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold)
|
|
2789
|
+
mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
|
|
2790
|
+
output.append('\033[0m├'+'─'*(line_length-2) + '┤')
|
|
2595
2791
|
for hostname in remaining_hostnames:
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
output.extend(
|
|
2792
|
+
hostnameLines = textwrap.wrap(hostname, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
2793
|
+
initial_indent='├─ ', subsequent_indent='│- ')
|
|
2794
|
+
output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
|
|
2795
|
+
output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
|
|
2796
|
+
output.append('\033[0m├'+'─'*(line_length-2) + '┤')
|
|
2797
|
+
if output:
|
|
2798
|
+
output.pop()
|
|
2799
|
+
# if output and output[0] and output[0].startswith('├'):
|
|
2800
|
+
# output[0] = '┌' + output[0][1:]
|
|
2599
2801
|
return output
|
|
2600
2802
|
|
|
2601
|
-
def
|
|
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
|
+
|
|
2817
|
+
def get_host_raw_output(hosts, terminal_width):
|
|
2602
2818
|
outputs_by_hostname = {}
|
|
2603
2819
|
line_bag_by_hostname = {}
|
|
2604
2820
|
hostnames_by_line_bag_len = {}
|
|
2821
|
+
text_wrapper = textwrap.TextWrapper(width=terminal_width - 2, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
2822
|
+
initial_indent='│ ', subsequent_indent='│-')
|
|
2823
|
+
max_length = 20
|
|
2824
|
+
hosts = pre_merge_hosts(hosts)
|
|
2605
2825
|
for host in hosts:
|
|
2606
|
-
hostPrintOut = ["│█ EXECUTED COMMAND"]
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2826
|
+
hostPrintOut = ["│█ EXECUTED COMMAND:"]
|
|
2827
|
+
for line in host.command.splitlines():
|
|
2828
|
+
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
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:
|
|
2611
2833
|
hostPrintOut.append('│▓ STDOUT:')
|
|
2612
|
-
|
|
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)
|
|
2613
2840
|
lineBag.add((prevLine,1))
|
|
2614
|
-
lineBag.add((1,host
|
|
2615
|
-
if len(host
|
|
2616
|
-
lineBag.update(zip(host
|
|
2617
|
-
lineBag.update(host
|
|
2618
|
-
prevLine = host
|
|
2619
|
-
if host
|
|
2620
|
-
if host
|
|
2621
|
-
host
|
|
2622
|
-
elif host
|
|
2623
|
-
host
|
|
2624
|
-
elif host
|
|
2625
|
-
host
|
|
2626
|
-
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:
|
|
2627
2854
|
hostPrintOut.append('│▒ STDERR:')
|
|
2628
|
-
|
|
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))
|
|
2629
2860
|
lineBag.add((prevLine,2))
|
|
2630
|
-
lineBag.add((2,host
|
|
2631
|
-
lineBag.update(host
|
|
2632
|
-
if len(host
|
|
2633
|
-
lineBag.update(zip(host
|
|
2634
|
-
prevLine = host
|
|
2635
|
-
hostPrintOut.append(f"│░ RETURN CODE: {host
|
|
2636
|
-
lineBag.add((prevLine,f"{host
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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}"))
|
|
2868
|
+
max_length = max(max_length, max(map(len, hostPrintOut)))
|
|
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)
|
|
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)
|
|
2641
2873
|
|
|
2642
2874
|
def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
|
|
2643
2875
|
merge_groups = []
|
|
2644
|
-
|
|
2876
|
+
remaining_hostnames = set()
|
|
2877
|
+
for lbl_i, line_bag_len in enumerate(sorted_hostnames_by_line_bag_len_keys):
|
|
2645
2878
|
for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
|
|
2646
|
-
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:
|
|
2647
2885
|
continue
|
|
2648
|
-
this_line_bag = line_bag_by_hostname[this_hostname]
|
|
2649
2886
|
target_threshold = line_bag_len * (2 - diff_display_threshold)
|
|
2650
2887
|
merge_group = []
|
|
2651
|
-
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:]:
|
|
2652
2889
|
if other_line_bag_len > target_threshold:
|
|
2653
2890
|
break
|
|
2654
|
-
if other_line_bag_len < line_bag_len:
|
|
2655
|
-
|
|
2891
|
+
# if other_line_bag_len < line_bag_len:
|
|
2892
|
+
# continue
|
|
2656
2893
|
for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
|
|
2657
|
-
if this_hostname == other_hostname:
|
|
2658
|
-
continue
|
|
2659
2894
|
if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
|
|
2660
2895
|
merge_group.append(other_hostname)
|
|
2661
|
-
hostnames_by_line_bag_len[line_bag_len].discard(this_hostname)
|
|
2662
2896
|
hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
|
|
2663
2897
|
if not hostnames_by_line_bag_len[other_line_bag_len]:
|
|
2664
2898
|
del hostnames_by_line_bag_len[other_line_bag_len]
|
|
@@ -2666,23 +2900,24 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
|
|
|
2666
2900
|
if merge_group:
|
|
2667
2901
|
merge_group.append(this_hostname)
|
|
2668
2902
|
merge_groups.append(merge_group)
|
|
2669
|
-
|
|
2903
|
+
# del line_bag_by_hostname[this_hostname]
|
|
2904
|
+
else:
|
|
2905
|
+
remaining_hostnames.add(this_hostname)
|
|
2906
|
+
return merge_groups, remaining_hostnames
|
|
2670
2907
|
|
|
2671
2908
|
def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
|
|
2672
2909
|
if quiet:
|
|
2673
2910
|
# remove hosts with returncode 0
|
|
2674
|
-
hosts = [
|
|
2911
|
+
hosts = [host for host in hosts if host.returncode != 0]
|
|
2675
2912
|
if not hosts:
|
|
2676
2913
|
if usejson:
|
|
2677
2914
|
return '{"Success": true}'
|
|
2678
2915
|
else:
|
|
2679
2916
|
return 'Success'
|
|
2680
|
-
else:
|
|
2681
|
-
hosts = [dict(host) for host in hosts]
|
|
2682
2917
|
if usejson:
|
|
2683
2918
|
# [print(dict(host)) for host in hosts]
|
|
2684
2919
|
#print(json.dumps([dict(host) for host in hosts],indent=4))
|
|
2685
|
-
rtnStr = json.dumps(hosts,indent=4)
|
|
2920
|
+
rtnStr = json.dumps([dict(host) for host in hosts],indent=4)
|
|
2686
2921
|
elif greppable:
|
|
2687
2922
|
# transform hosts to a 2d list
|
|
2688
2923
|
rtnStr = '*'*80+'\n'
|
|
@@ -2690,14 +2925,14 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2690
2925
|
for host in hosts:
|
|
2691
2926
|
#header = f"{host['name']} | rc: {host['returncode']} | "
|
|
2692
2927
|
hostAdded = False
|
|
2693
|
-
for line in host
|
|
2694
|
-
rtnList.append([host
|
|
2928
|
+
for line in host.stdout:
|
|
2929
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'stdout',line])
|
|
2695
2930
|
hostAdded = True
|
|
2696
|
-
for line in host
|
|
2697
|
-
rtnList.append([host
|
|
2931
|
+
for line in host.stderr:
|
|
2932
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'stderr',line])
|
|
2698
2933
|
hostAdded = True
|
|
2699
2934
|
if not hostAdded:
|
|
2700
|
-
rtnList.append([host
|
|
2935
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'N/A','<EMPTY>'])
|
|
2701
2936
|
rtnList.append(['','','',''])
|
|
2702
2937
|
rtnStr += pretty_format_table(rtnList)
|
|
2703
2938
|
rtnStr += '*'*80+'\n'
|
|
@@ -2714,22 +2949,22 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2714
2949
|
eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
|
|
2715
2950
|
diff_display_threshold = 0.9
|
|
2716
2951
|
terminal_length = get_terminal_size()[0]
|
|
2717
|
-
outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys = get_host_raw_output(hosts)
|
|
2718
|
-
merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
|
|
2719
|
-
|
|
2720
|
-
remaining_hostnames = set()
|
|
2721
|
-
for hostnames in hostnames_by_line_bag_len.values():
|
|
2722
|
-
remaining_hostnames.update(hostnames)
|
|
2723
|
-
outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold)
|
|
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)
|
|
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)
|
|
2954
|
+
outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
|
|
2724
2955
|
if keyPressesIn[-1]:
|
|
2725
2956
|
CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
|
|
2726
|
-
outputs.append("├─ User Inputs:".ljust(
|
|
2727
|
-
|
|
2957
|
+
outputs.append("├─ User Inputs:".ljust(line_length -1,'─')+'┤')
|
|
2958
|
+
cmdOut = []
|
|
2959
|
+
for line in CMDsOut:
|
|
2960
|
+
cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
2961
|
+
initial_indent='│ ', subsequent_indent='│-'))
|
|
2962
|
+
outputs.extend(cmd.ljust(line_length -1)+'│' for cmd in cmdOut)
|
|
2728
2963
|
keyPressesIn[-1].clear()
|
|
2729
|
-
if
|
|
2730
|
-
rtnStr = 'Success'
|
|
2964
|
+
if not outputs:
|
|
2965
|
+
rtnStr = 'Success' if quiet else ''
|
|
2731
2966
|
else:
|
|
2732
|
-
rtnStr = '\n'.join(outputs + [('
|
|
2967
|
+
rtnStr = '\n'.join(outputs + [('\033[0m└'+'─'*(line_length-2)+'┘')])
|
|
2733
2968
|
return rtnStr
|
|
2734
2969
|
|
|
2735
2970
|
def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
@@ -2747,6 +2982,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
|
2747
2982
|
global __global_suppress_printout
|
|
2748
2983
|
global _encoding
|
|
2749
2984
|
global __keyPressesIn
|
|
2985
|
+
for host in hosts:
|
|
2986
|
+
host.output.clear()
|
|
2750
2987
|
rtnStr = generate_output(hosts,usejson,greppable,quiet=__global_suppress_printout,encoding=_encoding,keyPressesIn=__keyPressesIn)
|
|
2751
2988
|
if not quiet:
|
|
2752
2989
|
print(rtnStr)
|
|
@@ -3356,6 +3593,7 @@ def generate_default_config(args):
|
|
|
3356
3593
|
'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
|
|
3357
3594
|
'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
|
|
3358
3595
|
'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
|
|
3596
|
+
'FORCE_TRUECOLOR': args.force_truecolor,
|
|
3359
3597
|
}
|
|
3360
3598
|
|
|
3361
3599
|
def write_default_config(args,CONFIG_FILE = None):
|
|
@@ -3399,8 +3637,8 @@ def write_default_config(args,CONFIG_FILE = None):
|
|
|
3399
3637
|
#%% ------------ Argument Processing -----------------
|
|
3400
3638
|
def get_parser():
|
|
3401
3639
|
global _binPaths
|
|
3402
|
-
parser = argparse.ArgumentParser(description=
|
|
3403
|
-
epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}')
|
|
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}',)
|
|
3404
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)
|
|
3405
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.')
|
|
3406
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)
|
|
@@ -3451,6 +3689,7 @@ def get_parser():
|
|
|
3451
3689
|
parser.add_argument('--script', action='store_true', help='Run the command in script mode, short for -SCRIPT or --no_watch --skip_unreachable --no_env --no_history --greppable --error_only')
|
|
3452
3690
|
parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
|
|
3453
3691
|
parser.add_argument('-ddt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. Set to 0 to always display the diff. (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
|
|
3692
|
+
parser.add_argument('--force_truecolor', action='store_true', help=f'Force truecolor output even when not in a truecolor terminal. (default: {FORCE_TRUECOLOR})', default=FORCE_TRUECOLOR)
|
|
3454
3693
|
parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
|
|
3455
3694
|
return parser
|
|
3456
3695
|
|
|
@@ -3553,6 +3792,7 @@ def set_global_with_args(args):
|
|
|
3553
3792
|
global DEFAULT_IPMI_USERNAME
|
|
3554
3793
|
global DEFAULT_IPMI_PASSWORD
|
|
3555
3794
|
global DEFAULT_DIFF_DISPLAY_THRESHOLD
|
|
3795
|
+
global FORCE_TRUECOLOR
|
|
3556
3796
|
_emo = False
|
|
3557
3797
|
__ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
|
|
3558
3798
|
_env_file = args.env_file
|
|
@@ -3565,6 +3805,7 @@ def set_global_with_args(args):
|
|
|
3565
3805
|
if args.ipmi_password:
|
|
3566
3806
|
DEFAULT_IPMI_PASSWORD = args.ipmi_password
|
|
3567
3807
|
DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
|
|
3808
|
+
FORCE_TRUECOLOR = args.force_truecolor
|
|
3568
3809
|
|
|
3569
3810
|
#%% ------------ Wrapper Block ----------------
|
|
3570
3811
|
def main():
|
|
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
|