multiSSH3 5.91__tar.gz → 5.93__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.

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