multiSSH3 6.30__tar.gz → 6.31__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.
- {multissh3-6.30 → multissh3-6.31}/PKG-INFO +1 -1
- {multissh3-6.30 → multissh3-6.31}/mssh_complete.py +45 -5
- {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/PKG-INFO +1 -1
- {multissh3-6.30 → multissh3-6.31}/multiSSH3.py +2 -2
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_diagnose.py +26 -1
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_keys.py +32 -5
- {multissh3-6.30 → multissh3-6.31}/README.md +0 -0
- {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/SOURCES.txt +0 -0
- {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/dependency_links.txt +0 -0
- {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/entry_points.txt +0 -0
- {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/top_level.txt +0 -0
- {multissh3-6.30 → multissh3-6.31}/setup.cfg +0 -0
- {multissh3-6.30 → multissh3-6.31}/setup.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_cli_branches.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_cli_processing.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_color_helpers.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_compact_hostnames.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_blesh.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_context.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_editor.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_engine.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_eval.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_hosts.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_hoststate.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_index.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_lex.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_mssh_helpers.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_picker.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_probe.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_shells.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_store.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_suggest.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_terminal.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_complete_untrusted.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_config_cli.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_config_validate.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_curses_branches.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_curses_event_loop.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_curses_tui.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_expand_hostnames.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_file_trust.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_harness_smoke.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_helpers_extra.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_host.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_host_validity.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_ipmi_lockout_wait.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_main_lifecycle.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_option_no_effect.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_output_merge.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_run_command.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_run_command_branches.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_run_command_characterization.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_run_on_hosts.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_run_on_hosts_modes.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_runtime_branch_matrix.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_runtime_dir.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_smoke_optional.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_stream_reader.py +0 -0
- {multissh3-6.30 → multissh3-6.31}/tests/test_unavailable_hosts.py +0 -0
|
@@ -16,7 +16,7 @@ import sys
|
|
|
16
16
|
import time
|
|
17
17
|
from bisect import bisect_left
|
|
18
18
|
|
|
19
|
-
version = '0.
|
|
19
|
+
version = '0.4'
|
|
20
20
|
__version__ = version
|
|
21
21
|
PROTOCOL = 'MSSHC1'
|
|
22
22
|
MSSH_NAMES = ('mssh', 'mssh3', 'multissh', 'multissh3', 'multiSSH3')
|
|
@@ -2037,6 +2037,32 @@ def _ipv4_range(name):
|
|
|
2037
2037
|
plain = plain and not sep
|
|
2038
2038
|
return not plain
|
|
2039
2039
|
|
|
2040
|
+
def _ipv4_members(name, limit=None):
|
|
2041
|
+
'''
|
|
2042
|
+
(the first limit members, how many in all) of an IPv4 range (_ipv4_range), expanded as mssh
|
|
2043
|
+
expands it: [ ] dropped, an a-b range per octet, x * ? for a whole octet or a range end (0 or
|
|
2044
|
+
255), and no .0 or .255 in the last octet. limit: RANGE_MEMBERS_LIMIT when None.
|
|
2045
|
+
'''
|
|
2046
|
+
import itertools
|
|
2047
|
+
wild = ('x', '*', '?')
|
|
2048
|
+
octets = []
|
|
2049
|
+
for part in name.replace('[', '').replace(']', '').strip().split('.'):
|
|
2050
|
+
if '-' in part:
|
|
2051
|
+
low, high = part.split('-', 1)
|
|
2052
|
+
low = 0 if not low or low in wild else int(low)
|
|
2053
|
+
high = 255 if not high or high in wild else int(high)
|
|
2054
|
+
octets.append([str(value) for value in range(low, high + 1)])
|
|
2055
|
+
elif part in wild:
|
|
2056
|
+
octets.append([str(value) for value in range(256)])
|
|
2057
|
+
else:
|
|
2058
|
+
octets.append([part])
|
|
2059
|
+
octets[-1] = [value for value in octets[-1] if value not in ('0', '255')]
|
|
2060
|
+
total = 1
|
|
2061
|
+
for values in octets:
|
|
2062
|
+
total *= len(values)
|
|
2063
|
+
limit = RANGE_MEMBERS_LIMIT if limit is None else limit
|
|
2064
|
+
return ['.'.join(address) for address in itertools.islice(itertools.product(*octets), limit)], total
|
|
2065
|
+
|
|
2040
2066
|
def _range_count(text):
|
|
2041
2067
|
'''How many names expand_ranges makes of text, without a limit; None when it cannot expand it.'''
|
|
2042
2068
|
start = text.find('[')
|
|
@@ -2407,8 +2433,7 @@ class Engine(object):
|
|
|
2407
2433
|
return 'unsure' if answer == 'invalid' and self._mssh_may_know(host, reach) else answer
|
|
2408
2434
|
found = []
|
|
2409
2435
|
for name, (start, end) in _host_parts(token):
|
|
2410
|
-
if not name
|
|
2411
|
-
# IP addresses only: nothing to say about them
|
|
2436
|
+
if not name:
|
|
2412
2437
|
continue
|
|
2413
2438
|
members = self._host_members(name)
|
|
2414
2439
|
if members is None:
|
|
@@ -2436,7 +2461,10 @@ class Engine(object):
|
|
|
2436
2461
|
'''
|
|
2437
2462
|
(members, how many) for an alias or a range, the members up to RANGE_MEMBERS_LIMIT; None for
|
|
2438
2463
|
one host; () for a range that cannot be expanded here (letters, hex): nothing is said about it.
|
|
2464
|
+
The members of an IPv4 range are addresses: they are only pinged, never unknown.
|
|
2439
2465
|
'''
|
|
2466
|
+
if _ipv4_range(name):
|
|
2467
|
+
return _ipv4_members(name)
|
|
2440
2468
|
number = self.hosts.alias_index(name)
|
|
2441
2469
|
if number is not None:
|
|
2442
2470
|
members = self.hosts.alias_members(number)
|
|
@@ -4051,6 +4079,14 @@ class LineEditor(object):
|
|
|
4051
4079
|
term.write(('\x1b[{}A'.format(self._row) if getattr(self, '_row', 0) else '') + '\r\x1b[J')
|
|
4052
4080
|
self._row = 0
|
|
4053
4081
|
|
|
4082
|
+
def interrupted(self, term, prompt):
|
|
4083
|
+
'''
|
|
4084
|
+
Leave the screen as bash leaves it after Ctrl-C: the prompt and the text as typed (no grey
|
|
4085
|
+
suggestion, no warning), ^C, and the cursor at the start of the next row.
|
|
4086
|
+
'''
|
|
4087
|
+
term.write(('\x1b[{}A'.format(self._row) if getattr(self, '_row', 0) else '') + '\r' + prompt + self.text + '^C\x1b[J\r\n')
|
|
4088
|
+
self._row = 0
|
|
4089
|
+
|
|
4054
4090
|
def run(self, term, prompt, style=None):
|
|
4055
4091
|
'''
|
|
4056
4092
|
Edit until the line is handed back.
|
|
@@ -4075,7 +4111,10 @@ class LineEditor(object):
|
|
|
4075
4111
|
self.busy = term.has_input()
|
|
4076
4112
|
outcome = self.handle(name, raw)
|
|
4077
4113
|
if outcome is not None:
|
|
4078
|
-
|
|
4114
|
+
if outcome[0] == 'abandon':
|
|
4115
|
+
self.interrupted(term, prompt)
|
|
4116
|
+
else:
|
|
4117
|
+
self.erase(term)
|
|
4079
4118
|
return outcome
|
|
4080
4119
|
if not self.busy:
|
|
4081
4120
|
self.draw(term, prompt, style)
|
|
@@ -4383,7 +4422,8 @@ _mssh_complete_space() {
|
|
|
4383
4422
|
MSSH_COMPLETE_PROMPT=$_mssh_complete_ps1 _mssh_complete_engine edit ${expand[@]+"${expand[@]}"} -- "$READLINE_LINE " 2>/dev/null)
|
|
4384
4423
|
if _mssh_complete_apply "$resp"; then
|
|
4385
4424
|
case $_mssh_complete_action in
|
|
4386
|
-
|
|
4425
|
+
# the editor left the line and ^C on screen: readline draws a fresh prompt below
|
|
4426
|
+
abandon) READLINE_LINE= READLINE_POINT=0 ;;
|
|
4387
4427
|
pass) bind -m emacs "\"\\C-x\\C-y\": \"$_mssh_complete_keys\"" ;;
|
|
4388
4428
|
esac
|
|
4389
4429
|
return 0
|
|
@@ -35,10 +35,10 @@ from itertools import count, product
|
|
|
35
35
|
from pprint import pformat
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
version = '6.
|
|
38
|
+
version = '6.31'
|
|
39
39
|
VERSION = version
|
|
40
40
|
__version__ = version
|
|
41
|
-
COMMIT_DATE = '2026-10-
|
|
41
|
+
COMMIT_DATE = '2026-10-13'
|
|
42
42
|
# TODO: Add terminal TUI
|
|
43
43
|
|
|
44
44
|
# no current-directory entry: a cloned repo or unpacked archive could set any global (e.g. _sshPath),
|
|
@@ -162,7 +162,22 @@ def test_a_huge_range_is_checked_up_to_the_member_limit(engine, monkeypatch):
|
|
|
162
162
|
|
|
163
163
|
def test_ipv4_ranges_hold_only_addresses(engine):
|
|
164
164
|
# mssh's IPv4 range syntax: ranges, x, * or ? in an octet (found replaying real history)
|
|
165
|
-
assert warnings(engine, 'mssh 127.0.0.1-20,10.0.[1-2].x,10.1.1.* uptime') ==
|
|
165
|
+
assert warnings(engine, 'mssh 127.0.0.1-20,10.0.[1-2].x,10.1.1.* uptime')[0] == []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_members_of_an_ipv4_range_are_pinged(engine):
|
|
169
|
+
# addresses need no DNS: they are queued for the prober's ping, as members of other ranges are
|
|
170
|
+
assert warnings(engine, 'mssh 10.0.0.1-3 uptime') == ([], True)
|
|
171
|
+
assert sorted(mc.take_probe_queue(engine.directory)) == ['10.0.0.1', '10.0.0.2', '10.0.0.3']
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.parametrize('pattern', ['192.168.1.1-254', '10.0.[1-2].x', '10.1.1.*', '127.0.0.1-20', '10.0.x-1.?', '[10-11].0.0.5'])
|
|
175
|
+
def test_ipv4_ranges_expand_as_mssh_expands_them(pattern):
|
|
176
|
+
members, total = mc._ipv4_members(pattern, limit=100000)
|
|
177
|
+
expanded = getattr(multiSSH3, '__expandIPv4Address')(frozenset([pattern]))
|
|
178
|
+
assert sorted(members) == sorted(expanded) and total == len(expanded)
|
|
179
|
+
# past the limit the total is still counted
|
|
180
|
+
assert mc._ipv4_members(pattern, limit=5) == (members[:5], total)
|
|
166
181
|
|
|
167
182
|
|
|
168
183
|
def test_lines_that_do_not_run_mssh_get_nothing(engine):
|
|
@@ -268,6 +283,16 @@ def test_unreachable_members_of_a_range(skipping):
|
|
|
268
283
|
assert warnings(skipping, 'mssh node[01-08] uptime')[0] == [('node[01-08]', 'unreachable', 'node[01-08]: 2 unreachable (node03, node07)')]
|
|
269
284
|
|
|
270
285
|
|
|
286
|
+
@pytest.mark.parametrize('pattern, down', [
|
|
287
|
+
('192.168.1.1-254', ['192.168.1.5', '192.168.1.9']),
|
|
288
|
+
('10.0.[1-2].x', ['10.0.2.7']),
|
|
289
|
+
])
|
|
290
|
+
def test_unreachable_members_of_an_ipv4_range(skipping, pattern, down):
|
|
291
|
+
unavailable(skipping, *down)
|
|
292
|
+
assert warnings(skipping, 'mssh {} uptime'.format(pattern))[0] == [
|
|
293
|
+
(pattern, 'unreachable', '{}: {} unreachable ({})'.format(pattern, len(down), ', '.join(down)))]
|
|
294
|
+
|
|
295
|
+
|
|
271
296
|
def test_an_unknown_host_is_not_also_unreachable(skipping):
|
|
272
297
|
seed(skipping, gpu017='invalid')
|
|
273
298
|
unavailable(skipping, 'gpu017')
|
|
@@ -54,6 +54,17 @@ def plain(text):
|
|
|
54
54
|
return re.sub(r'\x1b(\[[0-9;?<=>]*[ -/]*[@-~]|[()][A-Z0-9]|[=>78])', '', text).replace('\r', '')
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def read_until_shown(session, text, timeout=10.0):
|
|
58
|
+
'''Read until text is on screen, whatever the shell drew between its characters; return what was read.'''
|
|
59
|
+
start = len(session.output)
|
|
60
|
+
end = time.monotonic() + timeout
|
|
61
|
+
while True:
|
|
62
|
+
shown = plain(session.output[start:].decode('utf-8', 'replace'))
|
|
63
|
+
if text in shown or time.monotonic() >= end:
|
|
64
|
+
return shown
|
|
65
|
+
session.read_until(b'@@never@@', 0.1)
|
|
66
|
+
|
|
67
|
+
|
|
57
68
|
def words(line):
|
|
58
69
|
'''The words a line means, whatever quoting was chosen.'''
|
|
59
70
|
return None if line is None else [token.text for token in mc.lex(line)]
|
|
@@ -149,7 +160,7 @@ def test_bash_ctrl_r_keeps_the_last_argument(home):
|
|
|
149
160
|
session.send('true lastarg\r')
|
|
150
161
|
time.sleep(0.3)
|
|
151
162
|
session.send('mssh x\x12')
|
|
152
|
-
session.read_until(b'
|
|
163
|
+
session.read_until(b'type to filter', 10)
|
|
153
164
|
session.send('\x1b')
|
|
154
165
|
time.sleep(0.3)
|
|
155
166
|
session.send('\x15echo "[$_]"\r')
|
|
@@ -214,7 +225,7 @@ def test_editor_runs_pasted_lines_in_order(home):
|
|
|
214
225
|
session = editor_session(env, home)
|
|
215
226
|
try:
|
|
216
227
|
session.send('mssh host1 uptime\rmssh host2 uptime\r')
|
|
217
|
-
output = session.read_until(b'@@RAN[host2
|
|
228
|
+
output = session.read_until(b'@@RAN[host2 uptime]@@', 15)
|
|
218
229
|
assert '@@RAN[host1 uptime]@@' in output and '@@RAN[host2 uptime]@@' in output
|
|
219
230
|
assert output.index('@@RAN[host1 uptime]@@') < output.index('@@RAN[host2 uptime]@@')
|
|
220
231
|
assert 'not found' not in output
|
|
@@ -264,7 +275,7 @@ def test_editor_enter_runs_what_was_typed(home):
|
|
|
264
275
|
session.send('mssh gp')
|
|
265
276
|
session.read_until(b'nvidia-smi', 10)
|
|
266
277
|
session.send('\r')
|
|
267
|
-
assert '@@RAN[gp]@@' in session.read_until(b'@@RAN[
|
|
278
|
+
assert '@@RAN[gp]@@' in session.read_until(b'@@RAN[gp]@@', 10)
|
|
268
279
|
finally:
|
|
269
280
|
session.close()
|
|
270
281
|
|
|
@@ -313,6 +324,22 @@ def test_editor_ctrl_c_drops_the_line(home):
|
|
|
313
324
|
session.close()
|
|
314
325
|
|
|
315
326
|
|
|
327
|
+
def test_editor_ctrl_c_leaves_the_line_on_screen(home):
|
|
328
|
+
# as bash does: what was typed stays, ^C after it, and one fresh prompt below (no warning left over)
|
|
329
|
+
env, home = home
|
|
330
|
+
known(env, gpu017='invalid')
|
|
331
|
+
session = editor_session(env, home)
|
|
332
|
+
try:
|
|
333
|
+
session.send('mssh gpu017 upt')
|
|
334
|
+
session.read_until('unknown host'.encode(), 10)
|
|
335
|
+
time.sleep(0.3)
|
|
336
|
+
session.send('\x03')
|
|
337
|
+
rows = settled(session)
|
|
338
|
+
assert rows[-2:] == ['@@READY@@ mssh gpu017 upt^C', '@@READY@@'], rows
|
|
339
|
+
finally:
|
|
340
|
+
session.close()
|
|
341
|
+
|
|
342
|
+
|
|
316
343
|
def test_editor_ctrl_r_opens_the_picker(home):
|
|
317
344
|
env, home = home
|
|
318
345
|
session = editor_session(env, home)
|
|
@@ -335,7 +362,7 @@ def test_editor_without_colours_still_edits(home):
|
|
|
335
362
|
session.send('mssh gp')
|
|
336
363
|
time.sleep(0.5)
|
|
337
364
|
session.send('u\r')
|
|
338
|
-
output = session.read_until(b'@@RAN[
|
|
365
|
+
output = session.read_until(b'@@RAN[gpu]@@', 10)
|
|
339
366
|
assert '@@RAN[gpu]@@' in output
|
|
340
367
|
assert '\x1b[38;5;244m' not in output and '\x1b[2m' not in output
|
|
341
368
|
finally:
|
|
@@ -406,7 +433,7 @@ def test_blesh_ctrl_r_opens_the_picker_and_keeps_isearch_elsewhere(home):
|
|
|
406
433
|
session.send('\x15\x12aa')
|
|
407
434
|
time.sleep(0.5)
|
|
408
435
|
session.send('\x12')
|
|
409
|
-
assert 'echo aa1' in
|
|
436
|
+
assert 'echo aa1' in read_until_shown(session, 'echo aa1')
|
|
410
437
|
session.send('\x07')
|
|
411
438
|
finally:
|
|
412
439
|
session.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|