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.
Files changed (59) hide show
  1. {multissh3-6.30 → multissh3-6.31}/PKG-INFO +1 -1
  2. {multissh3-6.30 → multissh3-6.31}/mssh_complete.py +45 -5
  3. {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/PKG-INFO +1 -1
  4. {multissh3-6.30 → multissh3-6.31}/multiSSH3.py +2 -2
  5. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_diagnose.py +26 -1
  6. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_keys.py +32 -5
  7. {multissh3-6.30 → multissh3-6.31}/README.md +0 -0
  8. {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/SOURCES.txt +0 -0
  9. {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/dependency_links.txt +0 -0
  10. {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/entry_points.txt +0 -0
  11. {multissh3-6.30 → multissh3-6.31}/multiSSH3.egg-info/top_level.txt +0 -0
  12. {multissh3-6.30 → multissh3-6.31}/setup.cfg +0 -0
  13. {multissh3-6.30 → multissh3-6.31}/setup.py +0 -0
  14. {multissh3-6.30 → multissh3-6.31}/tests/test_cli_branches.py +0 -0
  15. {multissh3-6.30 → multissh3-6.31}/tests/test_cli_processing.py +0 -0
  16. {multissh3-6.30 → multissh3-6.31}/tests/test_color_helpers.py +0 -0
  17. {multissh3-6.30 → multissh3-6.31}/tests/test_compact_hostnames.py +0 -0
  18. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_blesh.py +0 -0
  19. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_context.py +0 -0
  20. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_editor.py +0 -0
  21. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_engine.py +0 -0
  22. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_eval.py +0 -0
  23. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_hosts.py +0 -0
  24. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_hoststate.py +0 -0
  25. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_index.py +0 -0
  26. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_lex.py +0 -0
  27. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_mssh_helpers.py +0 -0
  28. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_picker.py +0 -0
  29. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_probe.py +0 -0
  30. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_shells.py +0 -0
  31. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_store.py +0 -0
  32. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_suggest.py +0 -0
  33. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_terminal.py +0 -0
  34. {multissh3-6.30 → multissh3-6.31}/tests/test_complete_untrusted.py +0 -0
  35. {multissh3-6.30 → multissh3-6.31}/tests/test_config_cli.py +0 -0
  36. {multissh3-6.30 → multissh3-6.31}/tests/test_config_validate.py +0 -0
  37. {multissh3-6.30 → multissh3-6.31}/tests/test_curses_branches.py +0 -0
  38. {multissh3-6.30 → multissh3-6.31}/tests/test_curses_event_loop.py +0 -0
  39. {multissh3-6.30 → multissh3-6.31}/tests/test_curses_tui.py +0 -0
  40. {multissh3-6.30 → multissh3-6.31}/tests/test_expand_hostnames.py +0 -0
  41. {multissh3-6.30 → multissh3-6.31}/tests/test_file_trust.py +0 -0
  42. {multissh3-6.30 → multissh3-6.31}/tests/test_harness_smoke.py +0 -0
  43. {multissh3-6.30 → multissh3-6.31}/tests/test_helpers_extra.py +0 -0
  44. {multissh3-6.30 → multissh3-6.31}/tests/test_host.py +0 -0
  45. {multissh3-6.30 → multissh3-6.31}/tests/test_host_validity.py +0 -0
  46. {multissh3-6.30 → multissh3-6.31}/tests/test_ipmi_lockout_wait.py +0 -0
  47. {multissh3-6.30 → multissh3-6.31}/tests/test_main_lifecycle.py +0 -0
  48. {multissh3-6.30 → multissh3-6.31}/tests/test_option_no_effect.py +0 -0
  49. {multissh3-6.30 → multissh3-6.31}/tests/test_output_merge.py +0 -0
  50. {multissh3-6.30 → multissh3-6.31}/tests/test_run_command.py +0 -0
  51. {multissh3-6.30 → multissh3-6.31}/tests/test_run_command_branches.py +0 -0
  52. {multissh3-6.30 → multissh3-6.31}/tests/test_run_command_characterization.py +0 -0
  53. {multissh3-6.30 → multissh3-6.31}/tests/test_run_on_hosts.py +0 -0
  54. {multissh3-6.30 → multissh3-6.31}/tests/test_run_on_hosts_modes.py +0 -0
  55. {multissh3-6.30 → multissh3-6.31}/tests/test_runtime_branch_matrix.py +0 -0
  56. {multissh3-6.30 → multissh3-6.31}/tests/test_runtime_dir.py +0 -0
  57. {multissh3-6.30 → multissh3-6.31}/tests/test_smoke_optional.py +0 -0
  58. {multissh3-6.30 → multissh3-6.31}/tests/test_stream_reader.py +0 -0
  59. {multissh3-6.30 → multissh3-6.31}/tests/test_unavailable_hosts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 6.30
3
+ Version: 6.31
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
@@ -16,7 +16,7 @@ import sys
16
16
  import time
17
17
  from bisect import bisect_left
18
18
 
19
- version = '0.3'
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 or _ipv4_range(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
- self.erase(term)
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
- abandon) READLINE_LINE= READLINE_POINT=0; bind -m emacs '"\C-x\C-y": accept-line' ;;
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 6.30
3
+ Version: 6.31
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
@@ -35,10 +35,10 @@ from itertools import count, product
35
35
  from pprint import pformat
36
36
 
37
37
 
38
- version = '6.30'
38
+ version = '6.31'
39
39
  VERSION = version
40
40
  __version__ = version
41
- COMMIT_DATE = '2026-10-12'
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') == ([], False)
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'hosts', 10)
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', 15) + session.read_until(b']@@', 5)
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[', 10) + session.read_until(b']@@', 10)
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[', 10) + session.read_until(b']@@', 10)
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 plain(session.read_until(b'aa1', 10))
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