multiSSH3 5.94__tar.gz → 5.96__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.94
3
+ Version: 5.96
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.94
3
+ Version: 5.96
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
@@ -30,7 +30,7 @@ import threading
30
30
  import time
31
31
  import typing
32
32
  import uuid
33
- from collections import Counter, deque, defaultdict, UserDict
33
+ from collections import Counter, deque, defaultdict
34
34
  from itertools import count, product
35
35
 
36
36
  __curses_available = False
@@ -84,7 +84,7 @@ 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.94'
87
+ version = '5.96'
88
88
  VERSION = version
89
89
  __version__ = version
90
90
  COMMIT_DATE = '2025-10-21'
@@ -99,6 +99,7 @@ CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
99
99
  ERRORS = []
100
100
 
101
101
  # TODO: Add terminal TUI
102
+ # TODO: Change -fs behavior
102
103
 
103
104
  #%% ------------ Pre Helper Functions ----------------
104
105
  def eprint(*args, **kwargs):
@@ -364,7 +365,7 @@ DEFAULT_GREPPABLE_MODE = False
364
365
  DEFAULT_SKIP_UNREACHABLE = True
365
366
  DEFAULT_SKIP_HOSTS = ''
366
367
  DEFAULT_ENCODING = 'utf-8'
367
- DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
368
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.6
368
369
  SSH_STRICT_HOST_KEY_CHECKING = False
369
370
  FORCE_TRUECOLOR = False
370
371
  ERROR_MESSAGES_TO_IGNORE = [
@@ -683,28 +684,25 @@ class OrderedMultiSet(deque):
683
684
  self._counter = Counter()
684
685
  if iterable is not None:
685
686
  self.extend(iterable)
686
- def __decrease_count(self, item):
687
- """Decrease count of item in counter."""
688
- self._counter[item] -= 1
689
- if self._counter[item] == 0:
690
- del self._counter[item]
691
687
  def append(self, item):
692
688
  """Add item to the right end. O(1)."""
693
689
  if len(self) == self.maxlen:
694
- self.__decrease_count(self[0])
690
+ self._counter -= Counter([self[0]])
691
+ # self._counter[self[0]] -= 1
692
+ # self._counter += Counter()
695
693
  super().append(item)
696
694
  self._counter[item] += 1
697
695
  def appendleft(self, item):
698
696
  """Add item to the left end. O(1)."""
699
697
  if len(self) == self.maxlen:
700
- self.__decrease_count(self[-1])
698
+ self._counter -= Counter([self[-1]])
701
699
  super().appendleft(item)
702
700
  self._counter[item] += 1
703
701
  def pop(self):
704
702
  """Remove and return item from right end. O(1)."""
705
703
  try:
706
704
  item = super().pop()
707
- self.__decrease_count(item)
705
+ self._counter -= Counter([item])
708
706
  return item
709
707
  except IndexError:
710
708
  return None
@@ -712,7 +710,7 @@ class OrderedMultiSet(deque):
712
710
  """Remove and return item from left end. O(1)."""
713
711
  try:
714
712
  item = super().popleft()
715
- self.__decrease_count(item)
713
+ self._counter -= Counter([item])
716
714
  return item
717
715
  except IndexError:
718
716
  return None
@@ -721,7 +719,7 @@ class OrderedMultiSet(deque):
721
719
  removed = None
722
720
  if len(self) == self.maxlen:
723
721
  removed = self[0] # Item that will be removed
724
- self.__decrease_count(removed)
722
+ self._counter -= Counter([removed])
725
723
  super().append(item)
726
724
  self._counter[item] += 1
727
725
  return removed
@@ -730,7 +728,7 @@ class OrderedMultiSet(deque):
730
728
  removed = None
731
729
  if len(self) == self.maxlen:
732
730
  removed = self[-1] # Item that will be removed
733
- self.__decrease_count(removed)
731
+ self._counter -= Counter([removed])
734
732
  super().appendleft(item)
735
733
  self._counter[item] += 1
736
734
  return removed
@@ -742,7 +740,7 @@ class OrderedMultiSet(deque):
742
740
  if value not in self._counter:
743
741
  return None
744
742
  super().remove(value)
745
- self.__decrease_count(value)
743
+ self._counter -= Counter([value])
746
744
  def clear(self):
747
745
  """Remove all items. O(1)."""
748
746
  super().clear()
@@ -763,24 +761,61 @@ class OrderedMultiSet(deque):
763
761
  super().extend(iterable)
764
762
  self._counter.update(iterable)
765
763
  else:
766
- # Need to remove oldest items to make space
767
- num_to_remove = len(self) + len(iterable) - self.maxlen
768
- for _ in range(num_to_remove):
769
- self.__decrease_count(super().popleft())
764
+ num_to_keep = self.maxlen - len(iterable)
765
+ self.truncateright(num_to_keep)
770
766
  super().extend(iterable)
771
767
  self._counter.update(iterable)
772
768
  except TypeError:
773
769
  return self.extend(list(iterable))
774
770
  def extendleft(self, iterable):
775
771
  """Extend left side by appending elements from iterable. O(k)."""
776
- for item in iterable:
777
- self.appendleft(item)
772
+ # if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extendleft
773
+ try:
774
+ if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
775
+ super().extendleft(iterable)
776
+ self._counter.update(iterable)
777
+ elif len(iterable) >= self.maxlen:
778
+ self.clear()
779
+ if isinstance(iterable, (list, tuple)):
780
+ iterable = iterable[:self.maxlen]
781
+ else:
782
+ iterable = itertools.islice(iterable, 0, self.maxlen)
783
+ super().extendleft(iterable)
784
+ self._counter.update(iterable)
785
+ else:
786
+ num_to_keep = self.maxlen - len(iterable)
787
+ self.truncate(num_to_keep)
788
+ super().extendleft(iterable)
789
+ self._counter.update(iterable)
790
+ except TypeError:
791
+ return self.extendleft(list(iterable))
792
+ def update(self, iterable):
793
+ """Extend deque by appending elements from iterable. Alias for extend. O(k)."""
794
+ return self.extend(iterable)
795
+ def updateleft(self, iterable):
796
+ """Extend left side by appending elements from iterable. Alias for extendleft. O(k)."""
797
+ return self.extendleft(iterable)
798
+ def truncate(self, n):
799
+ """Truncate to keep left n items. O(n)."""
800
+ kept = list(itertools.islice(self, n))
801
+ dropped = Counter(itertools.islice(self, n, None))
802
+ super().clear()
803
+ super().extend(kept)
804
+ self._counter -= dropped
805
+ def truncateright(self, n):
806
+ """Truncate to keep right n items. O(n)."""
807
+ kept = list(itertools.islice(self, len(self) - n, None))
808
+ dropped = Counter(itertools.islice(self, 0, len(self) - n))
809
+ super().clear()
810
+ super().extend(kept)
811
+ self._counter -= dropped
778
812
  def rotate(self, n=1):
779
813
  """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
780
814
  super().rotate(n)
781
815
  def __contains__(self, item):
782
816
  """Check if item exists in deque. O(1) average."""
783
- return item in self._counter
817
+ # return item in self._counter
818
+ return super().__contains__(item)
784
819
  def count(self, item):
785
820
  """Return number of occurrences of item. O(1)."""
786
821
  return self._counter[item]
@@ -788,14 +823,14 @@ class OrderedMultiSet(deque):
788
823
  """Set item at index. O(1) for access, O(1) for counter update."""
789
824
  old_value = self[index]
790
825
  super().__setitem__(index, value)
791
- self.__decrease_count(old_value)
826
+ self._counter -= Counter([old_value])
792
827
  self._counter[value] += 1
793
828
  return old_value
794
829
  def __delitem__(self, index):
795
830
  """Delete item at index. O(n) for deletion, O(1) for counter update."""
796
831
  value = self[index]
797
832
  super().__delitem__(index)
798
- self.__decrease_count(value)
833
+ self._counter -= Counter([value])
799
834
  return value
800
835
  def insert(self, index, value):
801
836
  """Insert value at index. O(n) for insertion, O(1) for counter update."""
@@ -829,6 +864,28 @@ class OrderedMultiSet(deque):
829
864
  return self[-1]
830
865
  except IndexError:
831
866
  return None
867
+ def __iadd__(self, value):
868
+ return self.extend(value)
869
+ def __add__(self, value):
870
+ new_deque = self.copy()
871
+ new_deque.extend(value)
872
+ return new_deque
873
+ def __mul__(self, value):
874
+ new_deque = OrderedMultiSet(maxlen=self.maxlen)
875
+ for _ in range(value):
876
+ new_deque.extend(self)
877
+ return new_deque
878
+ def __imul__(self, value):
879
+ if value <= 0:
880
+ self.clear()
881
+ return self
882
+ for _ in range(value - 1):
883
+ self.extend(self)
884
+ return self
885
+ def __eq__(self, value):
886
+ if isinstance(value, OrderedMultiSet):
887
+ return self._counter == value._counter
888
+ return super().__eq__(value)
832
889
 
833
890
  def get_terminal_size():
834
891
  '''
@@ -2729,8 +2786,9 @@ def can_merge(line_bag1, line_bag2, threshold):
2729
2786
  return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
2730
2787
 
2731
2788
  def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2732
- indexes = {hostname: 0 for hostname in merging_hostnames}
2733
- working_index_keys = set(indexes.keys())
2789
+ #indexes = {hostname: 0 for hostname in merging_hostnames}
2790
+ indexes = Counter({hostname: 0 for hostname in merging_hostnames})
2791
+ working_index_keys = set(merging_hostnames)
2734
2792
  previousBuddies = set()
2735
2793
  hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2736
2794
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
@@ -2738,26 +2796,41 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2738
2796
  def get_multiset_index_for_hostname(hostname):
2739
2797
  index = indexes[hostname]
2740
2798
  tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2741
- return [OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index]
2799
+ tracking_iter = itertools.islice(outputs_by_hostname[hostname], tracking_index)
2800
+ return [deque(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_iter]
2742
2801
  # futuresChainMap = ChainMap()
2743
- class futureDict(UserDict):
2744
- def __missing__(self, key):
2745
- value = get_multiset_index_for_hostname(key)
2746
- self[key] = value
2747
- # futuresChainMap.maps.append(value[0]._counter)
2748
- return value
2749
- # def initializeHostnames(self, hostnames):
2750
- # entries = {hostname: get_multiset_index_for_hostname(hostname) for hostname in hostnames}
2751
- # self.update(entries)
2752
- # futuresChainMap.maps.extend(entry[0]._counter for entry in entries.values())
2753
- futures = futureDict()
2802
+ # class futureDict(UserDict):
2803
+ # def __missing__(self, key):
2804
+ # value = get_multiset_index_for_hostname(key)
2805
+ # self[key] = value
2806
+ # # futuresChainMap.maps.append(value[0]._counter)
2807
+ # return value
2808
+ # # def initializeHostnames(self, hostnames):
2809
+ # # entries = {hostname: get_multiset_index_for_hostname(hostname) for hostname in hostnames}
2810
+ # # self.update(entries)
2811
+ # # futuresChainMap.maps.extend(entry[0]._counter for entry in entries.values())
2812
+ def advance(dict,key):
2813
+ try:
2814
+ value = dict[key]
2815
+ value[0].append(next(value[1]))
2816
+ except StopIteration:
2817
+ try:
2818
+ value[0].popleft()
2819
+ except IndexError:
2820
+ pass
2821
+ except KeyError:
2822
+ pass
2823
+ # futures = futureDict()
2824
+ # for hostname in merging_hostnames:
2825
+ # futures[hostname] # ensure it's initialized
2826
+ futures = {hostname: get_multiset_index_for_hostname(hostname) for hostname in merging_hostnames}
2754
2827
  currentLines = defaultdict(set)
2755
2828
  for hostname in merging_hostnames:
2756
2829
  currentLines[outputs_by_hostname[hostname][0]].add(hostname)
2757
2830
  while indexes:
2758
2831
  defer = False
2759
2832
  # sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
2760
- golden_hostname = min(working_index_keys, key=lambda hn: indexes[hn])
2833
+ golden_hostname = min(working_index_keys, key=indexes.get)
2761
2834
  golden_index = indexes[golden_hostname]
2762
2835
  lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2763
2836
  # for hostname, index in sorted_working_indexes[1:]:
@@ -2777,8 +2850,8 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2777
2850
  # if golden_hostname in futures:
2778
2851
  # thisCounter = futures[golden_hostname][0]._counter
2779
2852
  # futuresChainMap.maps.remove(thisCounter)
2780
- for hostname in working_index_keys - buddy - set(futures.keys()):
2781
- futures[hostname] # ensure it's initialized
2853
+ # for hostname in working_index_keys - buddy - set(futures.keys()):
2854
+ # futures[hostname] # ensure it's initialized
2782
2855
  # futures.initializeHostnames(working_index_keys - buddy - futures.keys())
2783
2856
  if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
2784
2857
  defer = True
@@ -2798,11 +2871,12 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2798
2871
  currentLines[lineToAdd].difference_update(buddy)
2799
2872
  if not currentLines[lineToAdd]:
2800
2873
  del currentLines[lineToAdd]
2874
+ indexes.update(buddy)
2801
2875
  for hostname in buddy:
2802
2876
  # currentLines[lineToAdd].remove(hostname)
2803
2877
  # if not currentLines[lineToAdd]:
2804
2878
  # del currentLines[lineToAdd]
2805
- indexes[hostname] += 1
2879
+ # indexes[hostname] += 1
2806
2880
  try:
2807
2881
  currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
2808
2882
  except IndexError:
@@ -2812,26 +2886,19 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2812
2886
  # futuresChainMap.maps.remove(future[0]._counter)
2813
2887
  continue
2814
2888
  #advance futures
2815
- if hostname in futures:
2816
- futures[hostname][1] += 1
2817
- tracking_multiset, tracking_index = futures[hostname]
2818
- if tracking_index < len(outputs_by_hostname[hostname]):
2819
- line = outputs_by_hostname[hostname][tracking_index]
2820
- tracking_multiset.append(line)
2821
- else:
2822
- tracking_multiset.popleft()
2823
- #futures[hostname] = (tracking_multiset, tracking_index)
2889
+ advance(futures, hostname)
2824
2890
  working_index_keys = set(indexes.keys())
2825
2891
 
2826
2892
  def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
2827
2893
  output = []
2828
2894
  output.append(('┌'+'─'*(line_length-2) + '┐'))
2895
+ hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2896
+ hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
2829
2897
  for merging_hostnames in merge_groups:
2830
2898
  mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
2831
2899
  output.append('\033[0m├'+'─'*(line_length-2) + '┤')
2832
2900
  for hostname in remaining_hostnames:
2833
- hostnameLines = textwrap.wrap(hostname, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
2834
- initial_indent='├─ ', subsequent_indent='│- ')
2901
+ hostnameLines = hostnameWrapper.wrap(','.join(compact_hostnames([hostname])))
2835
2902
  output.extend(line.ljust(line_length - 1) + '│' for line in hostnameLines)
2836
2903
  output.extend(line.ljust(line_length - 1) + '│' for line in outputs_by_hostname[hostname])
2837
2904
  output.append('\033[0m├'+'─'*(line_length-2) + '┤')
@@ -2872,11 +2939,12 @@ def get_host_raw_output(hosts, terminal_width):
2872
2939
  prevLine = host.command
2873
2940
  if host.stdout:
2874
2941
  hostPrintOut.append('│▓ STDOUT:')
2875
- for line in host.stdout:
2876
- if len(line) < terminal_width - 2:
2877
- hostPrintOut.append(f"│ {line}")
2878
- else:
2879
- hostPrintOut.extend(text_wrapper.wrap(line))
2942
+ # for line in host.stdout:
2943
+ # if len(line) < terminal_width - 2:
2944
+ # hostPrintOut.append(f"│ {line}")
2945
+ # else:
2946
+ # hostPrintOut.extend(text_wrapper.wrap(line))
2947
+ hostPrintOut.extend(f"│ {line}" for line in host.stdout)
2880
2948
  # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2881
2949
  lineBag.add((prevLine,1))
2882
2950
  lineBag.add((1,host.stdout[0]))
@@ -2893,11 +2961,12 @@ def get_host_raw_output(hosts, terminal_width):
2893
2961
  host.stderr[-1] = 'Cannot find host!'
2894
2962
  if host.stderr:
2895
2963
  hostPrintOut.append('│▒ STDERR:')
2896
- for line in host.stderr:
2897
- if len(line) < terminal_width - 2:
2898
- hostPrintOut.append(f"│ {line}")
2899
- else:
2900
- hostPrintOut.extend(text_wrapper.wrap(line))
2964
+ # for line in host.stderr:
2965
+ # if len(line) < terminal_width - 2:
2966
+ # hostPrintOut.append(f"│ {line}")
2967
+ # else:
2968
+ # hostPrintOut.extend(text_wrapper.wrap(line))
2969
+ hostPrintOut.extend(f"│ {line}" for line in host.stderr)
2901
2970
  lineBag.add((prevLine,2))
2902
2971
  lineBag.add((2,host.stderr[0]))
2903
2972
  lineBag.update(host.stderr)
@@ -3689,7 +3758,7 @@ def get_parser():
3689
3758
  parser.add_argument('-ea','--extraargs',type=str,help=f'Extra arguments to pass to the ssh / rsync / scp command. Put in one string for multiple arguments.Use "=" ! Ex. -ea="--delete" (default: {DEFAULT_EXTRA_ARGS})',default=DEFAULT_EXTRA_ARGS)
3690
3759
  parser.add_argument("-11",'--oneonone', action='store_true', help=f"Run one corresponding command on each host. (default: {DEFAULT_ONE_ON_ONE})", default=DEFAULT_ONE_ON_ONE)
3691
3760
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3692
- parser.add_argument('-s','-fs','--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
3761
+ parser.add_argument('-s','-fs','--file_sync',nargs='?', action='append', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})',const=True, default=[DEFAULT_FILE_SYNC])
3693
3762
  parser.add_argument('-W','--scp', action='store_true', help=f'Use scp for copying files instead of rsync. Need to use this on windows. (default: {DEFAULT_SCP})', default=DEFAULT_SCP)
3694
3763
  parser.add_argument('-G','-gm','--gather_mode', action='store_true', help='Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3695
3764
  #parser.add_argument("-d",'-c',"--destination", type=str, help="The destination of the files. Same as specify with commands. Added for compatibility. Use #HOST# or #HOSTNAME# to replace the host name in the destination")
@@ -3760,7 +3829,18 @@ def process_args(args = None):
3760
3829
  args.no_history = True
3761
3830
  args.greppable = True
3762
3831
  args.error_only = True
3763
-
3832
+
3833
+ if args.file_sync:
3834
+ for path in args.file_sync:
3835
+ if path and isinstance(path, str):
3836
+ if args.file:
3837
+ if path not in args.file:
3838
+ args.file.append(path)
3839
+ else:
3840
+ args.file = [path]
3841
+ args.file_sync = any(args.file_sync)
3842
+ else:
3843
+ args.file_sync = False
3764
3844
  if args.unavailable_host_expiry <= 0:
3765
3845
  eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
3766
3846
  args.unavailable_host_expiry = 10
File without changes
File without changes
File without changes
File without changes
File without changes