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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.91
3
+ Version: 5.92
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
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.91
3
+ Version: 5.92
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
@@ -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.91'
88
+ version = '5.92'
88
89
  VERSION = version
89
90
  __version__ = version
90
- COMMIT_DATE = '2025-10-17'
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
- return self._counter.get(item, 0)
672
- def append(self, item,left=False):
650
+ def append(self, item):
673
651
  """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)
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
- return self.append(item,left=True)
684
- def pop(self,left=False):
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
- if not self:
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
- return self.pop(left=True)
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
- for item in iterable:
707
- self.append(item)
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
- if not self:
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
- if not self:
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
- hostSet = frozenset(Hostnames)
1272
- else:
1273
- hostSet = Hostnames
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
- 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
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
- 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)
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
- working_indexes = indexes.copy()
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
- 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}
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
- 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
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
- if indexes[hostname] >= len(outputs_by_hostname[hostname]):
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.pop_left()
2723
- futures[hostname] = (tracking_multiset, tracking_index)
2724
- working_indexes = indexes.copy()
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['command'].splitlines():
2827
+ for line in host.command.splitlines():
2754
2828
  hostPrintOut.extend(text_wrapper.wrap(line))
2755
- lineBag = {(0,host['command'])}
2756
- prevLine = host['command']
2757
- if host['stdout']:
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['stdout']:
2760
- hostPrintOut.extend(text_wrapper.wrap(line))
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['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']:
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['stderr']:
2777
- hostPrintOut.extend(text_wrapper.wrap(line))
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['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']}"))
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['name']] = hostPrintOut
2788
- line_bag_by_hostname[host['name']] = lineBag
2789
- hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
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
- for line_bag_len in hostnames_by_line_bag_len.copy():
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
- continue
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
- return merge_groups
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 = [dict(host) for host in hosts if host.returncode != 0]
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['stdout']:
2844
- rtnList.append([host['name'],f"rc: {host['returncode']}",'stdout',line])
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['stderr']:
2847
- rtnList.append([host['name'],f"rc: {host['returncode']}",'stderr',line])
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['name'],f"rc: {host['returncode']}",'N/A','<EMPTY>'])
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 quiet and not outputs:
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=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()}',)
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