multiSSH3 5.93__py3-none-any.whl → 5.95__py3-none-any.whl

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.
multiSSH3.py CHANGED
@@ -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,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.93'
87
+ version = '5.95'
88
88
  VERSION = version
89
89
  __version__ = version
90
- COMMIT_DATE = '2025-10-20'
90
+ COMMIT_DATE = '2025-10-21'
91
91
 
92
92
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
93
  '~/multiSSH3.config.json',
@@ -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):
@@ -345,7 +346,16 @@ DEFAULT_ERROR_ONLY = False
345
346
  DEFAULT_NO_OUTPUT = False
346
347
  DEFAULT_RETURN_ZERO = False
347
348
  DEFAULT_NO_ENV = False
348
- DEFAULT_ENV_FILE = '/etc/profile.d/hosts.sh'
349
+ DEFAULT_ENV_FILE = ''
350
+ DEFAULT_ENV_FILES = ['/etc/profile.d/hosts.sh',
351
+ '~/.bashrc',
352
+ '~/.zshrc',
353
+ '~/host.env',
354
+ '~/hosts.env',
355
+ '.env',
356
+ 'host.env',
357
+ 'hosts.env',
358
+ ]
349
359
  DEFAULT_NO_HISTORY = False
350
360
  DEFAULT_HISTORY_FILE = '~/.mssh_history'
351
361
  DEFAULT_MAX_CONNECTIONS = 4 * os.cpu_count()
@@ -355,7 +365,7 @@ DEFAULT_GREPPABLE_MODE = False
355
365
  DEFAULT_SKIP_UNREACHABLE = True
356
366
  DEFAULT_SKIP_HOSTS = ''
357
367
  DEFAULT_ENCODING = 'utf-8'
358
- DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
368
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.6
359
369
  SSH_STRICT_HOST_KEY_CHECKING = False
360
370
  FORCE_TRUECOLOR = False
361
371
  ERROR_MESSAGES_TO_IGNORE = [
@@ -386,6 +396,9 @@ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
386
396
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__ERROR_MESSAGES_TO_IGNORE_REGEX)
387
397
  else:
388
398
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile('|'.join(ERROR_MESSAGES_TO_IGNORE))
399
+ if DEFAULT_ENV_FILE:
400
+ if DEFAULT_ENV_FILE not in DEFAULT_ENV_FILES:
401
+ DEFAULT_ENV_FILES.append(DEFAULT_ENV_FILE)
389
402
 
390
403
  #%% Load mssh Functional Global Variables
391
404
  __global_suppress_printout = False
@@ -393,7 +406,7 @@ __mainReturnCode = 0
393
406
  __failedHosts = set()
394
407
  __wildCharacters = ['*','?','x']
395
408
  _no_env = DEFAULT_NO_ENV
396
- _env_file = DEFAULT_ENV_FILE
409
+ _env_files = DEFAULT_ENV_FILES
397
410
  __globalUnavailableHosts = dict()
398
411
  __ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
399
412
  __keyPressesIn = [[]]
@@ -475,35 +488,65 @@ def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
475
488
  return None
476
489
 
477
490
  @cache_decorator
478
- def readEnvFromFile(environemnt_file = ''):
491
+ def readEnvFromFile():
479
492
  '''
480
493
  Read the environment variables from env_file
481
494
  Returns:
482
495
  dict: A dictionary of environment variables
483
496
  '''
484
- global env
485
- try:
486
- if env:
487
- return env
488
- except Exception:
489
- env = {}
490
- global _env_file
491
- if environemnt_file:
492
- envf = environemnt_file
493
- else:
494
- envf = _env_file if _env_file else DEFAULT_ENV_FILE
495
- if os.path.exists(envf):
496
- with open(envf,'r') as f:
497
- for line in f:
498
- if line.startswith('#') or not line.strip():
497
+ global _env_files
498
+ global _no_env
499
+ envfs = _env_files if _env_files else DEFAULT_ENV_FILES
500
+ translator = str.maketrans('&|"', ';;\'')
501
+ replacement_re = re.compile(r'\$(?:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})')
502
+ environemnt = {}
503
+ scrubCounter = 0
504
+ for envf in envfs:
505
+ envf = os.path.expanduser(os.path.expandvars(envf))
506
+ if os.path.exists(envf):
507
+ with open(envf,'r') as f:
508
+ lines = f.readlines()
509
+ for line in lines:
510
+ line = line.strip()
511
+ if not line or line.startswith('#') or '=' not in line:
499
512
  continue
500
- key, value = line.replace('export ', '', 1).strip().split('=', 1)
501
- key = key.strip().strip('"').strip("'")
502
- value = value.strip().strip('"').strip("'")
503
- # avoid infinite recursion
504
- if key != value:
505
- env[key] = value.strip('"').strip("'")
506
- return env
513
+ line = line.translate(translator)
514
+ commands = re.split(r";(?=(?:[^']*'[^']*')*[^']*$)", line)
515
+ for command in commands:
516
+ if not command or command.startswith('#') or '=' not in command or command.startswith('alias '):
517
+ continue
518
+ fields = re.split(r" (?=(?:[^']*'[^']*')*[^']*$)", command)
519
+ for field in fields:
520
+ try:
521
+ if field.startswith('export '):
522
+ field = field.replace('export ', '', 1).strip()
523
+ if not field or field.startswith('#') or '=' not in field:
524
+ continue
525
+ key, _, values = field.partition('=')
526
+ key = key.strip().strip("'")
527
+ values = values.strip().strip("'")
528
+ if '$' in values:
529
+ scrubCounter += 16
530
+ if key and values and key != values:
531
+ environemnt[key] = values
532
+ except Exception:
533
+ continue
534
+ while scrubCounter:
535
+ scrubCounter -= 1
536
+ found = False
537
+ for key, value in environemnt.items():
538
+ if '$' in value:
539
+ for match in replacement_re.findall(value):
540
+ ref_key = match.strip('${}')
541
+ ref_value = environemnt.get(ref_key) if ref_key != key else None
542
+ if not ref_value and not _no_env:
543
+ ref_value = os.environ.get(ref_key)
544
+ if ref_value:
545
+ environemnt[key] = value.replace(match, ref_value)
546
+ found = True
547
+ if not found:
548
+ break
549
+ return environemnt
507
550
 
508
551
  def replace_magic_strings(string,keys,value,case_sensitive=False):
509
552
  '''
@@ -641,28 +684,25 @@ class OrderedMultiSet(deque):
641
684
  self._counter = Counter()
642
685
  if iterable is not None:
643
686
  self.extend(iterable)
644
- def __decrease_count(self, item):
645
- """Decrease count of item in counter."""
646
- self._counter[item] -= 1
647
- if self._counter[item] == 0:
648
- del self._counter[item]
649
687
  def append(self, item):
650
688
  """Add item to the right end. O(1)."""
651
689
  if len(self) == self.maxlen:
652
- self.__decrease_count(self[0])
690
+ self._counter -= Counter([self[0]])
691
+ # self._counter[self[0]] -= 1
692
+ # self._counter += Counter()
653
693
  super().append(item)
654
694
  self._counter[item] += 1
655
695
  def appendleft(self, item):
656
696
  """Add item to the left end. O(1)."""
657
697
  if len(self) == self.maxlen:
658
- self.__decrease_count(self[-1])
698
+ self._counter -= Counter([self[-1]])
659
699
  super().appendleft(item)
660
700
  self._counter[item] += 1
661
701
  def pop(self):
662
702
  """Remove and return item from right end. O(1)."""
663
703
  try:
664
704
  item = super().pop()
665
- self.__decrease_count(item)
705
+ self._counter -= Counter([item])
666
706
  return item
667
707
  except IndexError:
668
708
  return None
@@ -670,7 +710,7 @@ class OrderedMultiSet(deque):
670
710
  """Remove and return item from left end. O(1)."""
671
711
  try:
672
712
  item = super().popleft()
673
- self.__decrease_count(item)
713
+ self._counter -= Counter([item])
674
714
  return item
675
715
  except IndexError:
676
716
  return None
@@ -679,7 +719,7 @@ class OrderedMultiSet(deque):
679
719
  removed = None
680
720
  if len(self) == self.maxlen:
681
721
  removed = self[0] # Item that will be removed
682
- self.__decrease_count(removed)
722
+ self._counter -= Counter([removed])
683
723
  super().append(item)
684
724
  self._counter[item] += 1
685
725
  return removed
@@ -688,7 +728,7 @@ class OrderedMultiSet(deque):
688
728
  removed = None
689
729
  if len(self) == self.maxlen:
690
730
  removed = self[-1] # Item that will be removed
691
- self.__decrease_count(removed)
731
+ self._counter -= Counter([removed])
692
732
  super().appendleft(item)
693
733
  self._counter[item] += 1
694
734
  return removed
@@ -700,7 +740,7 @@ class OrderedMultiSet(deque):
700
740
  if value not in self._counter:
701
741
  return None
702
742
  super().remove(value)
703
- self.__decrease_count(value)
743
+ self._counter -= Counter([value])
704
744
  def clear(self):
705
745
  """Remove all items. O(1)."""
706
746
  super().clear()
@@ -721,24 +761,61 @@ class OrderedMultiSet(deque):
721
761
  super().extend(iterable)
722
762
  self._counter.update(iterable)
723
763
  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())
764
+ num_to_keep = self.maxlen - len(iterable)
765
+ self.truncateright(num_to_keep)
728
766
  super().extend(iterable)
729
767
  self._counter.update(iterable)
730
768
  except TypeError:
731
769
  return self.extend(list(iterable))
732
770
  def extendleft(self, iterable):
733
771
  """Extend left side by appending elements from iterable. O(k)."""
734
- for item in iterable:
735
- 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
736
812
  def rotate(self, n=1):
737
813
  """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
738
814
  super().rotate(n)
739
815
  def __contains__(self, item):
740
816
  """Check if item exists in deque. O(1) average."""
741
- return item in self._counter
817
+ # return item in self._counter
818
+ return super().__contains__(item)
742
819
  def count(self, item):
743
820
  """Return number of occurrences of item. O(1)."""
744
821
  return self._counter[item]
@@ -746,14 +823,14 @@ class OrderedMultiSet(deque):
746
823
  """Set item at index. O(1) for access, O(1) for counter update."""
747
824
  old_value = self[index]
748
825
  super().__setitem__(index, value)
749
- self.__decrease_count(old_value)
826
+ self._counter -= Counter([old_value])
750
827
  self._counter[value] += 1
751
828
  return old_value
752
829
  def __delitem__(self, index):
753
830
  """Delete item at index. O(n) for deletion, O(1) for counter update."""
754
831
  value = self[index]
755
832
  super().__delitem__(index)
756
- self.__decrease_count(value)
833
+ self._counter -= Counter([value])
757
834
  return value
758
835
  def insert(self, index, value):
759
836
  """Insert value at index. O(n) for insertion, O(1) for counter update."""
@@ -787,6 +864,28 @@ class OrderedMultiSet(deque):
787
864
  return self[-1]
788
865
  except IndexError:
789
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)
790
889
 
791
890
  def get_terminal_size():
792
891
  '''
@@ -2687,8 +2786,9 @@ def can_merge(line_bag1, line_bag2, threshold):
2687
2786
  return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
2688
2787
 
2689
2788
  def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
2690
- indexes = {hostname: 0 for hostname in merging_hostnames}
2691
- 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)
2692
2792
  previousBuddies = set()
2693
2793
  hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent='├─ ', subsequent_indent='│- ')
2694
2794
  hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
@@ -2696,26 +2796,41 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2696
2796
  def get_multiset_index_for_hostname(hostname):
2697
2797
  index = indexes[hostname]
2698
2798
  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]
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]
2700
2801
  # 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()
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}
2712
2827
  currentLines = defaultdict(set)
2713
2828
  for hostname in merging_hostnames:
2714
2829
  currentLines[outputs_by_hostname[hostname][0]].add(hostname)
2715
2830
  while indexes:
2716
2831
  defer = False
2717
2832
  # sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
2718
- golden_hostname = min(working_index_keys, key=lambda hn: indexes[hn])
2833
+ golden_hostname = min(working_index_keys, key=indexes.get)
2719
2834
  golden_index = indexes[golden_hostname]
2720
2835
  lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2721
2836
  # for hostname, index in sorted_working_indexes[1:]:
@@ -2735,8 +2850,8 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2735
2850
  # if golden_hostname in futures:
2736
2851
  # thisCounter = futures[golden_hostname][0]._counter
2737
2852
  # futuresChainMap.maps.remove(thisCounter)
2738
- for hostname in working_index_keys - buddy - set(futures.keys()):
2739
- futures[hostname] # ensure it's initialized
2853
+ # for hostname in working_index_keys - buddy - set(futures.keys()):
2854
+ # futures[hostname] # ensure it's initialized
2740
2855
  # futures.initializeHostnames(working_index_keys - buddy - futures.keys())
2741
2856
  if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
2742
2857
  defer = True
@@ -2756,11 +2871,12 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2756
2871
  currentLines[lineToAdd].difference_update(buddy)
2757
2872
  if not currentLines[lineToAdd]:
2758
2873
  del currentLines[lineToAdd]
2874
+ indexes.update(buddy)
2759
2875
  for hostname in buddy:
2760
2876
  # currentLines[lineToAdd].remove(hostname)
2761
2877
  # if not currentLines[lineToAdd]:
2762
2878
  # del currentLines[lineToAdd]
2763
- indexes[hostname] += 1
2879
+ # indexes[hostname] += 1
2764
2880
  try:
2765
2881
  currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
2766
2882
  except IndexError:
@@ -2770,15 +2886,7 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
2770
2886
  # futuresChainMap.maps.remove(future[0]._counter)
2771
2887
  continue
2772
2888
  #advance futures
2773
- if hostname in futures:
2774
- futures[hostname][1] += 1
2775
- tracking_multiset, tracking_index = futures[hostname]
2776
- if tracking_index < len(outputs_by_hostname[hostname]):
2777
- line = outputs_by_hostname[hostname][tracking_index]
2778
- tracking_multiset.append(line)
2779
- else:
2780
- tracking_multiset.popleft()
2781
- #futures[hostname] = (tracking_multiset, tracking_index)
2889
+ advance(futures, hostname)
2782
2890
  working_index_keys = set(indexes.keys())
2783
2891
 
2784
2892
  def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
@@ -2830,16 +2938,17 @@ def get_host_raw_output(hosts, terminal_width):
2830
2938
  prevLine = host.command
2831
2939
  if host.stdout:
2832
2940
  hostPrintOut.append('│▓ STDOUT:')
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))
2941
+ # for line in host.stdout:
2942
+ # if len(line) < terminal_width - 2:
2943
+ # hostPrintOut.append(f"│ {line}")
2944
+ # else:
2945
+ # hostPrintOut.extend(text_wrapper.wrap(line))
2946
+ hostPrintOut.extend(f"│ {line}" for line in host.stdout)
2838
2947
  # hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
2839
2948
  lineBag.add((prevLine,1))
2840
2949
  lineBag.add((1,host.stdout[0]))
2841
2950
  if len(host.stdout) > 1:
2842
- lineBag.update(zip(host.stdout, host.stdout[1:]))
2951
+ lineBag.update(itertools.pairwise(host.stdout))
2843
2952
  lineBag.update(host.stdout)
2844
2953
  prevLine = host.stdout[-1]
2845
2954
  if host.stderr:
@@ -2851,16 +2960,17 @@ def get_host_raw_output(hosts, terminal_width):
2851
2960
  host.stderr[-1] = 'Cannot find host!'
2852
2961
  if host.stderr:
2853
2962
  hostPrintOut.append('│▒ STDERR:')
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))
2963
+ # for line in host.stderr:
2964
+ # if len(line) < terminal_width - 2:
2965
+ # hostPrintOut.append(f"│ {line}")
2966
+ # else:
2967
+ # hostPrintOut.extend(text_wrapper.wrap(line))
2968
+ hostPrintOut.extend(f"│ {line}" for line in host.stderr)
2859
2969
  lineBag.add((prevLine,2))
2860
2970
  lineBag.add((2,host.stderr[0]))
2861
2971
  lineBag.update(host.stderr)
2862
2972
  if len(host.stderr) > 1:
2863
- lineBag.update(zip(host.stderr, host.stderr[1:]))
2973
+ lineBag.update(itertools.pairwise(host.stderr))
2864
2974
  prevLine = host.stderr[-1]
2865
2975
  hostPrintOut.append(f"│░ RETURN CODE: {host.returncode}")
2866
2976
  lineBag.add((prevLine,f"{host.returncode}"))
@@ -3099,7 +3209,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3099
3209
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
3100
3210
  file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3101
3211
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
3102
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILE,
3212
+ history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILES,
3103
3213
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3104
3214
  shortend = False) -> str:
3105
3215
  argsList = []
@@ -3143,8 +3253,8 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3143
3253
  argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
3144
3254
  if no_env:
3145
3255
  argsList.append('--no_env')
3146
- if env_file and env_file != DEFAULT_ENV_FILE:
3147
- argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
3256
+ if env_file and env_file != DEFAULT_ENV_FILES:
3257
+ argsList.extend([f'--env_file="{ef}"' for ef in env_file] if not shortend else [f'-ef="{ef}"' for ef in env_file])
3148
3258
  if no_history:
3149
3259
  argsList.append('--no_history' if not shortend else '-nh')
3150
3260
  if history_file and history_file != DEFAULT_HISTORY_FILE:
@@ -3167,7 +3277,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
3167
3277
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
3168
3278
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3169
3279
  copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
3170
- history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILE,
3280
+ history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILES,
3171
3281
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3172
3282
  shortend = False,tabSeperated = False):
3173
3283
  _ = called
@@ -3579,7 +3689,7 @@ def generate_default_config(args):
3579
3689
  'DEFAULT_NO_OUTPUT': args.no_output,
3580
3690
  'DEFAULT_RETURN_ZERO': args.return_zero,
3581
3691
  'DEFAULT_NO_ENV': args.no_env,
3582
- 'DEFAULT_ENV_FILE': args.env_file,
3692
+ 'DEFAULT_ENV_FILES': args.env_file,
3583
3693
  'DEFAULT_NO_HISTORY': args.no_history,
3584
3694
  'DEFAULT_HISTORY_FILE': args.history_file,
3585
3695
  'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
@@ -3668,7 +3778,7 @@ def get_parser():
3668
3778
  parser.add_argument('-Q',"-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
3669
3779
  parser.add_argument('-Z','-rz','--return_zero', action='store_true', help=f"Return 0 even if there are errors. (default: {DEFAULT_RETURN_ZERO})", default=DEFAULT_RETURN_ZERO)
3670
3780
  parser.add_argument('-C','--no_env', action='store_true', help=f'Do not load the command line environment variables. (default: {DEFAULT_NO_ENV})', default=DEFAULT_NO_ENV)
3671
- parser.add_argument("--env_file", type=str, help=f"The file to load the mssh file based environment variables from. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILE})", default=DEFAULT_ENV_FILE)
3781
+ parser.add_argument("--env_file", action='append', help=f"The files to load the mssh file based environment variables from. Can specify multiple. Load first to last. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILES})", default=DEFAULT_ENV_FILES)
3672
3782
  parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3673
3783
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3674
3784
  parser.add_argument('-w',"--success_hosts", action='store_true', help=f"Output the hosts that succeeded in summary as well. (default: {DEFAULT_PRINT_SUCCESS_HOSTS})", default=DEFAULT_PRINT_SUCCESS_HOSTS)
@@ -3783,7 +3893,7 @@ def process_keys(args):
3783
3893
  def set_global_with_args(args):
3784
3894
  global _emo
3785
3895
  global __ipmiiInterfaceIPPrefix
3786
- global _env_file
3896
+ global _env_files
3787
3897
  global __DEBUG_MODE
3788
3898
  global __configs_from_file
3789
3899
  global _encoding
@@ -3794,7 +3904,7 @@ def set_global_with_args(args):
3794
3904
  global FORCE_TRUECOLOR
3795
3905
  _emo = False
3796
3906
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3797
- _env_file = args.env_file
3907
+ _env_files = args.env_file
3798
3908
  __DEBUG_MODE = args.debug
3799
3909
  _encoding = args.encoding
3800
3910
  if args.return_zero:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.93
3
+ Version: 5.95
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
@@ -0,0 +1,6 @@
1
+ multiSSH3.py,sha256=PWEXxU31f8mCRPzHqrWyG7DJcKXn99jmyTCT5mfuZoQ,179383
2
+ multissh3-5.95.dist-info/METADATA,sha256=GfNp-SWNCY8KyBjmuiK7JWbJWURo0oZ5ltBRAYlC4dw,18093
3
+ multissh3-5.95.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ multissh3-5.95.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
+ multissh3-5.95.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
+ multissh3-5.95.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- multiSSH3.py,sha256=m-JRMY79fgRvnHoA9RhlxwApByi6qTq0l3PdtQnYOwI,175506
2
- multissh3-5.93.dist-info/METADATA,sha256=yqQlWtFYYLn9BatS6SBOFkEVY3DrNWp1Z0R0ZrvPWXw,18093
3
- multissh3-5.93.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- multissh3-5.93.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
5
- multissh3-5.93.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
6
- multissh3-5.93.dist-info/RECORD,,