multiSSH3 5.91__py3-none-any.whl → 5.93__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.
Potentially problematic release.
This version of multiSSH3 might be problematic. Click here for more details.
- multiSSH3.py +289 -207
- {multissh3-5.91.dist-info → multissh3-5.93.dist-info}/METADATA +1 -1
- multissh3-5.93.dist-info/RECORD +6 -0
- multissh3-5.91.dist-info/RECORD +0 -6
- {multissh3-5.91.dist-info → multissh3-5.93.dist-info}/WHEEL +0 -0
- {multissh3-5.91.dist-info → multissh3-5.93.dist-info}/entry_points.txt +0 -0
- {multissh3-5.91.dist-info → multissh3-5.93.dist-info}/top_level.txt +0 -0
multiSSH3.py
CHANGED
|
@@ -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.
|
|
87
|
+
version = '5.93'
|
|
88
88
|
VERSION = version
|
|
89
89
|
__version__ = version
|
|
90
|
-
COMMIT_DATE = '2025-10-
|
|
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
|
-
|
|
672
|
-
def append(self, item,left=False):
|
|
649
|
+
def append(self, item):
|
|
673
650
|
"""Add item to the right end. O(1)."""
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1272
|
-
else:
|
|
1273
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2682
|
-
golden_hostname,
|
|
2683
|
-
|
|
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
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
-
|
|
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.
|
|
2723
|
-
futures[hostname] = (tracking_multiset, tracking_index)
|
|
2724
|
-
|
|
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
|
|
2826
|
+
for line in host.command.splitlines():
|
|
2754
2827
|
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
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
|
|
2760
|
-
|
|
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
|
|
2763
|
-
if len(host
|
|
2764
|
-
lineBag.update(zip(host
|
|
2765
|
-
lineBag.update(host
|
|
2766
|
-
prevLine = host
|
|
2767
|
-
if host
|
|
2768
|
-
if host
|
|
2769
|
-
host
|
|
2770
|
-
elif host
|
|
2771
|
-
host
|
|
2772
|
-
elif host
|
|
2773
|
-
host
|
|
2774
|
-
if host
|
|
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
|
|
2777
|
-
|
|
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
|
|
2780
|
-
lineBag.update(host
|
|
2781
|
-
if len(host
|
|
2782
|
-
lineBag.update(zip(host
|
|
2783
|
-
prevLine = host
|
|
2784
|
-
hostPrintOut.append(f"│░ RETURN CODE: {host
|
|
2785
|
-
lineBag.add((prevLine,f"{host
|
|
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
|
|
2788
|
-
line_bag_by_hostname[host
|
|
2789
|
-
hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
2844
|
-
rtnList.append([host
|
|
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
|
|
2847
|
-
rtnList.append([host
|
|
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
|
|
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
|
|
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=
|
|
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('-
|
|
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
|
|
@@ -0,0 +1,6 @@
|
|
|
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,,
|
multissh3-5.91.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
multiSSH3.py,sha256=9hTPnoRn6yswbQ8QaQIvkgIOwlVohz1o8jFlq_-S5D0,172386
|
|
2
|
-
multissh3-5.91.dist-info/METADATA,sha256=kyyw0bHMd-ifFluN0LAlKqhY6udom5D3Q2_AgAuSqqU,18093
|
|
3
|
-
multissh3-5.91.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
4
|
-
multissh3-5.91.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
|
|
5
|
-
multissh3-5.91.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
|
|
6
|
-
multissh3-5.91.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|