sshscript 5.0.0__py3-none-any.whl → 6.0.0__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.
sshscript/__init__.py CHANGED
@@ -59,6 +59,7 @@ run_file = sshscript.run_file
59
59
  check_file = sshscript.check_file
60
60
  run_script = sshscript.run_script
61
61
  Session = session.Session
62
+ ScriptResult = session.ScriptResult
62
63
  CommandResult = session.CommandResult
63
64
  CommandJob = session.CommandJob
64
65
  CommandTimeoutError = session.CommandTimeoutError
@@ -89,6 +90,7 @@ __all__ = [
89
90
  'run_script',
90
91
  'Session',
91
92
  'CommandResult',
93
+ 'ScriptResult',
92
94
  'CommandJob',
93
95
  'CommandTimeoutError',
94
96
  'JobResult',
sshscript/__init__.pyi CHANGED
@@ -1,6 +1,7 @@
1
1
  from typing import Any, ContextManager
2
2
  from os import PathLike
3
3
  from .session import Session as Session
4
+ from .scriptcontrol import ScriptResult as ScriptResult
4
5
  from .commandresult import CommandResult as CommandResult
5
6
  from .commandjob import CommandJob as CommandJob, JobResult as JobResult, CommandTimeoutError as CommandTimeoutError
6
7
  from .errorutils import SSHScriptException as SSHScriptException
@@ -9,7 +10,7 @@ __version__: str
9
10
 
10
11
  def check_file(script_path: str | PathLike[str]) -> int: ...
11
12
  def run_file(script_path: str | PathLike[str], vars: dict[str, Any] | None = ..., showScript: bool = ...) -> int: ...
12
- def run_script(script: str, varGlobals: dict[str, Any] | None = ..., showScript: bool = ...) -> dict[str, Any]: ...
13
+ def run_script(script: str, varGlobals: dict[str, Any] | None = ..., showScript: bool = ...) -> ScriptResult: ...
13
14
  def spy_imports() -> ContextManager[None]: ...
14
15
  def get_logger() -> Any: ...
15
16
  def set_logger(userlogger: Any = ..., logname: str | None = ...) -> Any: ...
sshscript/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Single source of the SSHScript package version."""
2
2
 
3
- __version__ = "5.0.0"
3
+ __version__ = "6.0.0"
@@ -1492,6 +1492,11 @@ class GenericChannel(object):
1492
1492
  Existing newlines are retained. Use send() to write exact text instead.
1493
1493
  A readiness/silence status does not establish command success.
1494
1494
  """
1495
+ return self._input_text(text, timeout=timeout, append_newline=True)
1496
+
1497
+ def _input_text(self, text, timeout=60, *, append_newline=True):
1498
+ """Shared interactive protocol for lines and raw terminal control bytes."""
1499
+ payload = text + ('\n' if append_newline else '') if isinstance(text, str) else text
1495
1500
  if not isinstance(text, str):
1496
1501
  raise TypeError('input text must be str')
1497
1502
 
@@ -1504,7 +1509,7 @@ class GenericChannel(object):
1504
1509
  )
1505
1510
 
1506
1511
  if not self.hijacked:
1507
- return self.send(text + '\n', timeout=timeout)
1512
+ return self.send(payload, timeout=timeout)
1508
1513
 
1509
1514
  deadline = time.monotonic() + timeout
1510
1515
  executing_lock = self.executing_lock
@@ -1555,7 +1560,7 @@ class GenericChannel(object):
1555
1560
  stderr.set_callback(prompt_found, current_prompt)
1556
1561
 
1557
1562
  self.send(
1558
- text + '\n',
1563
+ payload,
1559
1564
  timeout=self._remaining(deadline),
1560
1565
  )
1561
1566
 
sshscript/dollarparser.py CHANGED
@@ -138,7 +138,7 @@ def _token_syntax_error(error, script_path, spyscript):
138
138
  return _source_syntax_error(synthetic, script_path, spyscript)
139
139
 
140
140
 
141
- def parse(script_path, spyscript):
141
+ def parse(script_path, spyscript, *, preprocess=None):
142
142
  """Return a transformed AST whose locations still refer to *spyscript*."""
143
143
  _cache_source(script_path, spyscript)
144
144
  try:
@@ -156,6 +156,8 @@ def parse(script_path, spyscript):
156
156
  raise _source_syntax_error(error, script_path, spyscript) from None
157
157
 
158
158
  try:
159
+ if preprocess is not None:
160
+ tree = preprocess.visit(tree)
159
161
  py_tree = DollarChanger().visit(tree)
160
162
  return ast.fix_missing_locations(py_tree)
161
163
  except Exception as error:
sshscript/errorutils.py CHANGED
@@ -32,13 +32,6 @@ class SSHScriptException(Exception):
32
32
  def __str__(self):
33
33
  return f'{self.message}(#{self.errno})'
34
34
 
35
- class SSHScriptBreak(SSHScriptException):
36
- """Control-flow signal for $.break(status), handled by the script runner."""
37
- pass
38
-
39
- class SSHScriptExit(SSHScriptException):
40
- """Control-flow signal for $.exit(status), propagated to the command-line runner."""
41
- pass
42
35
 
43
36
  def dumpScript(source,tb_lineno=None,linerange=10):
44
37
  """Print source with line numbers and optional error-line context."""
@@ -0,0 +1,606 @@
1
+ """Persistent text console for humans and agents using a retained terminal session."""
2
+ import argparse
3
+ import ast
4
+ import builtins
5
+ import code
6
+ from io import StringIO
7
+ import tokenize
8
+ from contextlib import ExitStack, redirect_stdout, redirect_stderr
9
+ from dataclasses import dataclass
10
+ from getpass import getpass
11
+ import sys
12
+ import time
13
+ import subprocess
14
+
15
+ if __package__:
16
+ from . import dollarparser, tokenparser, patching
17
+ from .session import Session
18
+ else:
19
+ import dollarparser, tokenparser, patching
20
+ from session import Session
21
+
22
+
23
+ class _LeaveLayer(BaseException):
24
+ pass
25
+
26
+
27
+ class _QuitConsole(BaseException):
28
+ pass
29
+
30
+
31
+ class _ConsoleSyntax(ast.NodeTransformer):
32
+ """Route prompt-level dollar syntax through the environment manager.
33
+
34
+ Lexical with blocks retain the normal .spy compiler's context ownership.
35
+ Generated names are changed as AST nodes, never by replacing source strings.
36
+ """
37
+ def __init__(self):
38
+ self.lexical_depth = 0
39
+
40
+ def visit_With(self, node):
41
+ self.lexical_depth += 1
42
+ try:
43
+ return self.generic_visit(node)
44
+ finally:
45
+ self.lexical_depth -= 1
46
+
47
+ visit_AsyncWith = visit_With
48
+
49
+ def visit_Name(self, node):
50
+ if not self.lexical_depth and node.id in ('_sshscript_in_context_', '_c'):
51
+ return ast.copy_location(ast.Name(id='_console_control_', ctx=node.ctx), node)
52
+ return node
53
+
54
+ def visit_Call(self, node):
55
+ if (not self.lexical_depth and isinstance(node.func, ast.Name)
56
+ and node.func.id in ('exec_command', 'onedollar', 'twodollars')):
57
+ node.func = ast.copy_location(ast.Attribute(
58
+ value=ast.Name(id='_console_control_', ctx=ast.Load()),
59
+ attr='exec_command', ctx=ast.Load()), node.func)
60
+ return self.generic_visit(node)
61
+
62
+ def visit_Expr(self, node):
63
+ raw_command = (isinstance(node.value, ast.Call)
64
+ and isinstance(node.value.func, ast.Name)
65
+ and node.value.func.id in ('exec_command', 'onedollar', 'twodollars'))
66
+ node = self.generic_visit(node)
67
+ if raw_command:
68
+ # A raw shell line displays its output, not a JobResult repr.
69
+ return ast.copy_location(ast.Assign(
70
+ targets=[ast.Name(id='result', ctx=ast.Store())], value=node.value), node)
71
+ return node
72
+
73
+
74
+ class _PythonConsole(code.InteractiveConsole):
75
+ def __init__(self, namespace):
76
+ super().__init__(namespace)
77
+ self.cell = 1
78
+
79
+ def runsource(self, source, filename='<input>', symbol='single'):
80
+ filename = f'<sshscript-console-{self.cell}>'
81
+ try:
82
+ try:
83
+ compiled = self.compile(source, filename, symbol)
84
+ except SyntaxError:
85
+ # Use the existing tokenizer/compiler for dollar syntax, including
86
+ # properties inside Python expressions and f-strings.
87
+ try:
88
+ translated = tokenparser.convert(source)
89
+ except tokenize.TokenError as error:
90
+ if error.args[0] in ('EOF in multi-line statement',
91
+ 'EOF in multi-line string',
92
+ 'unexpected EOF in multi-line statement',
93
+ 'unexpected EOF in multi-line string'):
94
+ # Older tokenizers also report EOF after an unmatched
95
+ # closing delimiter. That is invalid, not unfinished.
96
+ delimiters = []
97
+ pairs = {')': '(', ']': '[', '}': '{'}
98
+ try:
99
+ for token in tokenize.generate_tokens(StringIO(source).readline):
100
+ if token.type != tokenize.OP:
101
+ continue
102
+ if token.string in ('(', '[', '{'):
103
+ delimiters.append(token.string)
104
+ elif token.string in pairs:
105
+ if not delimiters or delimiters.pop() != pairs[token.string]:
106
+ diagnostic = SyntaxError(
107
+ f'unmatched {token.string!r}',
108
+ (filename, token.start[0], token.start[1] + 1, None))
109
+ raise dollarparser._source_syntax_error(
110
+ diagnostic, filename, source) from None
111
+ except tokenize.TokenError:
112
+ pass
113
+ return True
114
+ raise dollarparser._token_syntax_error(error, filename, source) from None
115
+ try:
116
+ compiled = self.compile(translated, filename, symbol)
117
+ except SyntaxError as error:
118
+ raise dollarparser._source_syntax_error(error, filename, source) from None
119
+ if compiled is not None:
120
+ tree = dollarparser.parse(filename, source, preprocess=_ConsoleSyntax())
121
+ # The script compiler adds stack setup; keep future imports
122
+ # before that setup when compiling an interactive cell.
123
+ futures = [n for n in tree.body if isinstance(n, ast.ImportFrom)
124
+ and n.module == '__future__']
125
+ body = futures + [n for n in tree.body if n not in futures]
126
+ tree = ast.Interactive(body=body) if symbol == 'single' else ast.Module(body=body, type_ignores=[])
127
+ compiled = compile(ast.fix_missing_locations(tree), filename, symbol,
128
+ flags=self.compile.compiler.flags)
129
+ if compiled is None:
130
+ return True
131
+ except (SyntaxError, OverflowError, ValueError):
132
+ self.cell += 1
133
+ self.showsyntaxerror(filename)
134
+ return False
135
+ self.cell += 1
136
+ self.runcode(compiled)
137
+ return False
138
+
139
+ def runcode(self, code_object):
140
+ try:
141
+ exec(code_object, self.locals)
142
+ except (_LeaveLayer, _QuitConsole, SystemExit, KeyboardInterrupt):
143
+ raise
144
+ except BaseException as error:
145
+ self.locals['error'] = error
146
+ if isinstance(error, NameError):
147
+ self.write(f'{type(error).__name__}: {error}\n')
148
+ else:
149
+ # Do not echo credential-bearing source or arbitrary server text.
150
+ self.write(f'{type(error).__name__}: operation failed; inspect error for details.\n')
151
+
152
+
153
+ @dataclass
154
+ class _Layer:
155
+ resources: ExitStack
156
+ session: Session
157
+ console: object
158
+ kind: str
159
+ host: str
160
+ user: str
161
+ result: object = None
162
+ program_buffers: tuple = ()
163
+ program_positions: tuple = (0, 0)
164
+
165
+
166
+ class InteractiveConsole:
167
+ """Own a stack of connection/console contexts and a persistent Python namespace.
168
+
169
+ All context entry and exit occurs on the calling thread. No input history is
170
+ persisted. Connections under sudo/su are rejected rather than implying that
171
+ the SSH Session has adopted the console's effective identity or key files.
172
+ """
173
+ def __init__(self, *, stdout=None, stderr=None, input_func=None,
174
+ password_func=None, session_factory=Session):
175
+ self._stdout = stdout or sys.stdout
176
+ self._stderr = stderr or sys.stderr
177
+ self.input_func = input_func or input
178
+ self.password_func = password_func or getpass
179
+ self.session_factory = session_factory
180
+ self.layers = []
181
+ self.namespace = {'__name__': '__console__', 'getpass': self.password_func,
182
+ 'control': self, '_console_control_': self,
183
+ 'patching': patching, 'result': None, 'error': None}
184
+ self._helpers = {'help': self.help, 'exit': self._exit_layer, 'quit': self.quit}
185
+ self.namespace.update(self._helpers)
186
+ self.python = _PythonConsole(self.namespace)
187
+ self.more = False
188
+ self.done = False
189
+
190
+ @property
191
+ def current(self):
192
+ if not self.layers:
193
+ raise RuntimeError('console is not open')
194
+ return self.layers[-1]
195
+
196
+ def _refresh(self):
197
+ self.namespace.update(session=self.current.session,
198
+ console=self.current.console,
199
+ _sshscript_session_=self.current.session)
200
+
201
+ def _push(self, resources, session, console, kind, host, *, program=False):
202
+ user = '?' if program else console('id -un', check=True).stdout.strip()
203
+ self.layers.append(_Layer(resources.pop_all(), session, console, kind, host, user))
204
+ self._refresh()
205
+
206
+ def open(self, host=None, **options):
207
+ if self.layers or self.done:
208
+ raise RuntimeError('console has already been opened')
209
+ with ExitStack() as resources:
210
+ local = self.session_factory()
211
+ resources.callback(local.close, strict=True)
212
+ resources.enter_context(local)
213
+ local.set(verbose=False, verbose_stderr=False, check=False)
214
+ shell = resources.enter_context(local.shell('bash'))
215
+ self._push(resources, local, shell, 'local', 'local')
216
+ try:
217
+ if host is not None:
218
+ self.connect(host, **options)
219
+ except BaseException:
220
+ self.close()
221
+ raise
222
+ return self
223
+
224
+ def connect(self, *args, **kwargs):
225
+ layer = self.current
226
+ if layer.kind not in ('local', 'ssh'):
227
+ raise RuntimeError('leave the current sudo/su/shell/program layer before connect(); SSH credentials belong to the connection Session')
228
+ with ExitStack() as resources:
229
+ remote = layer.session.connect(*args, **kwargs)
230
+ resources.callback(remote.close, strict=True)
231
+ resources.enter_context(remote)
232
+ remote.set(verbose=False, verbose_stderr=False, check=False)
233
+ shell = resources.enter_context(remote.shell('bash'))
234
+ self._push(resources, remote, shell, 'ssh', remote.host or '?')
235
+
236
+ def _console_layer(self, kind, *args, **kwargs):
237
+ layer = self.current
238
+ if layer.kind == 'program':
239
+ raise RuntimeError('leave the interactive program before entering another layer')
240
+ with ExitStack() as resources:
241
+ console = resources.enter_context(getattr(layer.console, kind)(*args, **kwargs))
242
+ self._push(resources, layer.session, console,
243
+ 'program' if kind == 'enter' else kind, layer.host,
244
+ program=kind == 'enter')
245
+ if kind == 'enter':
246
+ self._drain_program()
247
+
248
+ def shell(self, *args, **kwargs):
249
+ self._console_layer('shell', *args, **kwargs)
250
+
251
+ def _identity_layer(self, kind, *args, **kwargs):
252
+ if 'password' not in kwargs:
253
+ # su's first positional argument is username; sudo's is password.
254
+ supplied_positionally = len(args) >= (2 if kind == 'su' else 1)
255
+ if not supplied_positionally:
256
+ if self.password_func is getpass and not sys.stdin.isatty():
257
+ raise ValueError('provide password explicitly (password=None for passwordless authentication) when stdin is not a terminal')
258
+ kwargs['password'] = self.password_func(kind + ' password: ')
259
+ self._console_layer(kind, *args, **kwargs)
260
+
261
+ def sudo(self, *args, **kwargs):
262
+ self._identity_layer('sudo', *args, **kwargs)
263
+
264
+ def su(self, *args, **kwargs):
265
+ self._identity_layer('su', *args, **kwargs)
266
+
267
+ def enter(self, *args, **kwargs):
268
+ """Explicitly enter a text program; configure its prompt and exit text."""
269
+ self._console_layer('enter', *args, **kwargs)
270
+
271
+ def _exit_layer(self):
272
+ raise _LeaveLayer()
273
+
274
+ def quit(self):
275
+ """Unwind Python contexts before closing every managed layer."""
276
+ raise _QuitConsole()
277
+
278
+ def help(self, *args, **kwargs):
279
+ if not args and not kwargs:
280
+ self._write(HELP + '\n', self._stdout)
281
+ return None
282
+ return builtins.help(*args, **kwargs)
283
+
284
+ def _bare_helper(self, line):
285
+ try:
286
+ node = ast.parse(line, mode='eval').body
287
+ except SyntaxError:
288
+ return None
289
+ if self.current.kind == 'program':
290
+ if (isinstance(node, ast.Call) and not node.args and not node.keywords
291
+ and isinstance(node.func, ast.Name) and node.func.id in ('exit', 'quit')):
292
+ node = node.func
293
+ if not isinstance(node, ast.Name) or node.id not in ('exit', 'quit'):
294
+ return None
295
+ if (isinstance(node, ast.Name) and node.id in self._helpers
296
+ and self.namespace.get(node.id) is self._helpers[node.id]):
297
+ return node.id
298
+ return None
299
+
300
+ def stop(self, *args, **kwargs):
301
+ return self.current.session.stop(*args, **kwargs)
302
+
303
+ def run(self, source, vars=None, showScript=False):
304
+ """Run a script in the current connection Session; stop returns here."""
305
+ if vars is None:
306
+ vars = dict(self.namespace)
307
+ # Console convenience helpers must not change a child script's
308
+ # ordinary Python exit()/quit()/help() semantics.
309
+ for name, helper in self._helpers.items():
310
+ if vars.get(name) is helper:
311
+ del vars[name]
312
+ return self.current.session.run(source, vars, showScript)
313
+
314
+ def run_file(self, path, **kwargs):
315
+ if __package__:
316
+ from .sshscript import run_file
317
+ else:
318
+ from sshscript import run_file
319
+ return run_file(path, **kwargs)
320
+
321
+ def __getattr__(self, name):
322
+ # Non-environment operations keep the regular API's session/console owner.
323
+ if name in ('upload', 'download', 'pkey', 'set', 'get'):
324
+ return getattr(self.current.session, name)
325
+ if name in ('send', 'input', 'expect', 'start'):
326
+ return getattr(self.current.console, name)
327
+ if name in ('stdout', 'stderr', 'exitcode'):
328
+ if self.current.kind == 'program':
329
+ return getattr(self.current.console, name)
330
+ result = self.current.result
331
+ return getattr(result, name) if result is not None else (None if name == 'exitcode' else '')
332
+ raise AttributeError(name)
333
+
334
+ def leave(self):
335
+ layer = self.layers.pop()
336
+ try:
337
+ layer.resources.close()
338
+ finally:
339
+ if self.layers:
340
+ self._refresh()
341
+ else:
342
+ self.done = True
343
+ self.namespace.update(session=None, console=None)
344
+
345
+ def close(self):
346
+ failures = []
347
+ while self.layers:
348
+ try:
349
+ self.leave()
350
+ except BaseException as error:
351
+ failures.append(error)
352
+ self.done = True
353
+ if failures:
354
+ raise BaseExceptionGroup('console cleanup failed', failures)
355
+
356
+ def _write(self, text, stream):
357
+ if text:
358
+ stream.write(text)
359
+ stream.flush()
360
+
361
+ def exec_command(self, command, *, check=None, timeout=None,
362
+ capture_limit=8 * 1024 * 1024, stop_timeout=3):
363
+ """Execute a dollar command in the current shell and retain its snapshot."""
364
+ enabled = self.current.session.check if check is None else check
365
+ if not isinstance(enabled, bool):
366
+ raise TypeError('check must be bool')
367
+ result = self._command(command, timeout=timeout, capture_limit=capture_limit,
368
+ stop_timeout=stop_timeout)
369
+ if (enabled and result is not None and result.stop_reason == 'completed'
370
+ and result.exitcode != 0):
371
+ error = subprocess.CalledProcessError(result.exitcode, command,
372
+ output=result.stdout, stderr=result.stderr)
373
+ error.result = result
374
+ raise error
375
+ return result
376
+
377
+ def _command(self, line, *, timeout=None, capture_limit=8 * 1024 * 1024,
378
+ stop_timeout=3):
379
+ if self.current.kind == 'program':
380
+ outcome = self.current.console.input(line)
381
+ self._drain_program()
382
+ if outcome == 'exited':
383
+ self.leave()
384
+ return
385
+ # Managed foreground jobs retain shell state, stream output and recover
386
+ # the parent shell after Ctrl-C. Completion is never inferred from silence.
387
+ job = self.current.console.start(line, timeout=timeout, capture_limit=capture_limit,
388
+ stop_timeout=stop_timeout, check=False)
389
+ positions = [0, 0]
390
+ def drain():
391
+ for index, (text, stream) in enumerate(((job.stdout, self._stdout),
392
+ (job.stderr, self._stderr))):
393
+ self._write(text[positions[index]:], stream)
394
+ positions[index] = len(text)
395
+ try:
396
+ while not job.done:
397
+ drain()
398
+ time.sleep(0.03)
399
+ result = job.wait()
400
+ except KeyboardInterrupt:
401
+ result = job.stop()
402
+ self._write('\nKeyboardInterrupt\n', self._stderr)
403
+ finally:
404
+ drain()
405
+ self.namespace['result'] = self.current.result = result
406
+ if result.exitcode not in (0, None):
407
+ self._write(f'[exit {result.exitcode}]\n', self._stderr)
408
+ if result.stdout_truncated or result.stderr_truncated:
409
+ self._write('[output capture limit reached]\n', self._stderr)
410
+ return result
411
+
412
+ def _drain_program(self):
413
+ layer = self.current
414
+ channel = layer.console.channel
415
+ buffers = (channel._stdout, channel._stderr)
416
+ positions = []
417
+ for index, (buffer, stream) in enumerate(zip(buffers, (self._stdout, self._stderr))):
418
+ text = str(buffer)
419
+ previous = (layer.program_positions[index]
420
+ if layer.program_buffers and layer.program_buffers[index] is buffer
421
+ else 0)
422
+ visible = text[previous:]
423
+ if (channel._interactive_layer_exited(channel.executing_lock)
424
+ and len(channel.prompts) > 1):
425
+ parent_prompt = channel.prompts[-2]
426
+ if isinstance(parent_prompt, str):
427
+ visible = visible.replace(parent_prompt, '')
428
+ self._write(visible, stream)
429
+ positions.append(len(text))
430
+ layer.program_buffers = buffers
431
+ layer.program_positions = tuple(positions)
432
+
433
+ def _program_control(self, character):
434
+ """Send Ctrl-C/D without a newline and observe the program's real prompts."""
435
+ self._drain_program()
436
+ channel = self.current.console.channel
437
+ try:
438
+ outcome = channel._input_text(character, timeout=2, append_newline=False)
439
+ except TimeoutError:
440
+ # A program may ignore the control byte. Do not invent an exit.
441
+ outcome = None
442
+ self._drain_program()
443
+ if outcome == 'exited':
444
+ self.leave()
445
+
446
+ def _interrupt(self):
447
+ self.python.resetbuffer()
448
+ self.more = False
449
+ if self.layers and self.current.kind == 'program':
450
+ self._program_control('\x03')
451
+ else:
452
+ self._write('\nKeyboardInterrupt\n', self._stderr)
453
+
454
+ def _eof(self):
455
+ if self.layers and self.current.kind == 'program':
456
+ self._program_control('\x04')
457
+ else:
458
+ self.python.resetbuffer()
459
+ self.more = False
460
+ self.close()
461
+
462
+ def _discard_failed_layers(self):
463
+ while self.layers:
464
+ channel = self.current.console.channel
465
+ if not channel.closed and getattr(channel, '_state', None) not in ('failed', 'closed'):
466
+ break
467
+ self._write('Current shell is unavailable; leaving its environment.\n', self._stderr)
468
+ try:
469
+ self.leave()
470
+ except Exception as error:
471
+ self._write('Cleanup failed: ' + type(error).__name__ + '\n', self._stderr)
472
+
473
+ def execute(self, line):
474
+ """Process one input line; variables and contexts survive later calls."""
475
+ if self.done:
476
+ return
477
+ try:
478
+ helper = self._bare_helper(line) if not self.more else None
479
+ if helper == 'quit':
480
+ self.quit()
481
+ elif helper == 'help':
482
+ self.help()
483
+ elif helper == 'exit':
484
+ self._exit_layer()
485
+ elif not self.more and line.strip() == ':quit':
486
+ self.close()
487
+ elif not self.more and line.strip() == ':help':
488
+ self._write(HELP + '\n', self._stdout)
489
+ elif not self.more and line.strip() == ':status':
490
+ for layer in self.layers:
491
+ self._write(f'{layer.kind}: {layer.host} [{layer.user}]\n', self._stdout)
492
+ self._write('Transfers and SSH credentials use the connection account.\n', self._stdout)
493
+ elif (not self.more and self.current.kind == 'program'
494
+ and not line.lstrip().startswith('$.')):
495
+ self._command(line)
496
+ else:
497
+ with redirect_stdout(self._stdout), redirect_stderr(self._stderr):
498
+ self.more = self.python.push(line)
499
+ except _QuitConsole:
500
+ self.python.resetbuffer()
501
+ self.more = False
502
+ self.close()
503
+ except KeyboardInterrupt:
504
+ self._interrupt()
505
+ except _LeaveLayer:
506
+ self.python.resetbuffer()
507
+ self.more = False
508
+ self.leave()
509
+ except SystemExit as error:
510
+ self.python.resetbuffer()
511
+ self.more = False
512
+ self._write(f'Python/script exited ({error.code}); console remains open.\n', self._stderr)
513
+ finally:
514
+ self._discard_failed_layers()
515
+
516
+ @property
517
+ def prompt(self):
518
+ if self.more:
519
+ return '... '
520
+ layer = self.current
521
+ suffix = ' program' if layer.kind == 'program' else ''
522
+ return f'{layer.host} [{layer.user}]{suffix}> '
523
+
524
+ def interact(self):
525
+ if self.input_func is builtins.input and sys.stdin.isatty():
526
+ # Python loads readline for its own REPL, but not when running a
527
+ # script. Enable terminal editing before our first input() call.
528
+ try:
529
+ import readline # noqa: F401
530
+ except ImportError:
531
+ pass # Minimal Python builds retain input()'s basic editing.
532
+ self._write('SSHScript console. Type help for commands; quit to finish.\n', self._stdout)
533
+ try:
534
+ while not self.done:
535
+ try:
536
+ line = self.input_func(self.prompt)
537
+ except EOFError:
538
+ self._eof()
539
+ continue
540
+ except KeyboardInterrupt:
541
+ self._interrupt()
542
+ continue
543
+ try:
544
+ self.execute(line)
545
+ except KeyboardInterrupt:
546
+ self._interrupt()
547
+ except Exception as error:
548
+ # Avoid echoing credential-bearing expressions or arbitrary
549
+ # server error text; the original exception is available as error.
550
+ self.namespace['error'] = error
551
+ self._write('Operation failed (' + type(error).__name__ + '). Use :status or inspect error.\n', self._stderr)
552
+ finally:
553
+ self.close()
554
+
555
+
556
+ HELP = '''Input is Python with SSHScript dollar syntax; variables persist between inputs.
557
+ $hostname executes a command in the current persistent Bash shell.
558
+ print($.stdout), $.stderr and $.exitcode inspect the last dollar-command result.
559
+ $.connect(host, **options) connects and automatically enters a persistent shell.
560
+ $.sudo(password=...), $.su("user", password=...) enter identity layers.
561
+ $.shell("bash") enters a nested shell.
562
+ $.enter("python3 -q", prompt=">>>", exit="quit()") enters a text program.
563
+ exit / exit() leave one layer; at the local root they end the console.
564
+ Use Python multiline blocks with a blank line to execute, just like a Python REPL.
565
+ Python names: session (connection), console (current identity), result, error, getpass.
566
+ $.run(source) / $.run_file(path) execute scripts; $.stop() ends only that script.
567
+ help / help() show this guide; help(object) shows Python object documentation.
568
+ quit / quit() close all layers. $.exit() is not available.
569
+ :status shows the environment stack. Ctrl-C cancels current input or execution.
570
+ Ctrl-D at the Python prompt closes the console. In enter(), Ctrl-C/D go to the program.
571
+ :help and :quit remain aliases.
572
+ Inside enter(), plain input goes to that program; $. expressions remain controls.
573
+ No input history is persisted. Full-screen programs are not supported in this preview.'''
574
+
575
+
576
+
577
+ def main(argv=None):
578
+ parser = argparse.ArgumentParser(prog='sshscript console', description=HELP,
579
+ formatter_class=argparse.RawDescriptionHelpFormatter)
580
+ parser.add_argument('host', nargs='?')
581
+ parser.add_argument('--username')
582
+ parser.add_argument('--port', type=int)
583
+ parser.add_argument('--pkey-path')
584
+ parser.add_argument('--timeout', type=float)
585
+ parser.add_argument('--ssh-config')
586
+ parser.add_argument('--known-hosts', choices=('local', 'parent', 'chain'))
587
+ parser.add_argument('--known-hosts-path')
588
+ parser.add_argument('--ask-pass', action='store_true')
589
+ args = parser.parse_args(argv)
590
+ options = {name: value for name, value in vars(args).items()
591
+ if name not in ('host', 'ask_pass', 'known_hosts_path') and value is not None}
592
+ if args.host is None and (options or args.ask_pass):
593
+ parser.error('connection options require a host')
594
+ console = InteractiveConsole()
595
+ try:
596
+ console.open()
597
+ if args.known_hosts_path is not None:
598
+ console.current.session.set(known_hosts_path=args.known_hosts_path)
599
+ if args.ask_pass:
600
+ options['password'] = getpass('SSH password: ')
601
+ if args.host is not None:
602
+ console.connect(args.host, **options)
603
+ console.interact()
604
+ finally:
605
+ console.close()
606
+ return 0
@@ -0,0 +1,34 @@
1
+ """Script completion and interactive-scope control, separate from execution errors."""
2
+ from contextvars import ContextVar
3
+ from typing import Any
4
+
5
+
6
+ class ScriptResult(dict[str, Any]):
7
+ """Executed namespace plus completion status; also returned after stop()."""
8
+ def __init__(self, namespace: dict[str, Any], *, exitcode: int = 0,
9
+ stopped: bool = False, message: str = "") -> None:
10
+ super().__init__(namespace)
11
+ self.exitcode = exitcode
12
+ self.stopped = stopped
13
+ self.message = message
14
+
15
+
16
+ class ScriptStopped(BaseException):
17
+ """Internal normal-completion signal, caught by the nearest script runner."""
18
+ def __init__(self, code: int = 0, message: str = "") -> None:
19
+ super().__init__(message)
20
+ self.code = code
21
+ self.message = message
22
+
23
+
24
+ script_active: ContextVar[bool] = ContextVar("sshscript_script_active", default=False)
25
+
26
+
27
+ def stop_script(code: int = 0, message: str = "") -> None:
28
+ if not script_active.get():
29
+ raise RuntimeError("stop() requires a running script; use exit() to leave a console layer")
30
+ if isinstance(code, bool) or not isinstance(code, int):
31
+ raise TypeError("stop code must be an integer")
32
+ if not isinstance(message, str):
33
+ raise TypeError("stop message must be a string")
34
+ raise ScriptStopped(code, message)
sshscript/session.py CHANGED
@@ -51,7 +51,7 @@ if __package__:
51
51
  from .dollar import Dollar
52
52
  from .sessionwrapper import SessionWrapper,SudoConsole,SuConsole
53
53
  from .channelutils import _validate_enter_timeout
54
- from .errorutils import get_logger, SSHScriptExit, SSHScriptBreak, SSHScriptException, dumpScript, listRightIndex
54
+ from .errorutils import get_logger, SSHScriptException, dumpScript, listRightIndex
55
55
  ## v2.0.3 changes from sshscriptparserng to dollarparser
56
56
  from . import dollarparser
57
57
 
@@ -65,9 +65,14 @@ else:
65
65
  from dollar import Dollar
66
66
  from sessionwrapper import SessionWrapper,SudoConsole,SuConsole
67
67
  from channelutils import _validate_enter_timeout
68
- from errorutils import get_logger, SSHScriptException, SSHScriptExit, SSHScriptBreak, dumpScript, listRightIndex
68
+ from errorutils import get_logger, SSHScriptException, dumpScript, listRightIndex
69
69
  import dollarparser
70
70
 
71
+ if __package__:
72
+ from .scriptcontrol import ScriptResult, ScriptStopped, script_active, stop_script
73
+ else:
74
+ from scriptcontrol import ScriptResult, ScriptStopped, script_active, stop_script
75
+
71
76
  ## setup logger
72
77
  _COMMAND_TIMEOUT_UNSET = object()
73
78
  logger = get_logger()
@@ -303,9 +308,6 @@ class ConsoleWrapper:
303
308
  raise
304
309
  self.channel.logger.debug('Unable to finish console cleanup after an exception')
305
310
 
306
- def exit(self):
307
- return self.__exit__(None,None,None)
308
-
309
311
  def __repr__(self):
310
312
  return f'ConsoleWrapper({self.funcname})'
311
313
  ##
@@ -362,7 +364,7 @@ class Session(object):
362
364
  self._host = None
363
365
  self._port = None
364
366
  self._username = None
365
- self.runLocker = threading.Lock()
367
+ self.runLocker = threading.RLock()
366
368
 
367
369
  self.ownerstack = None
368
370
  ## homony i/o of this thread
@@ -673,15 +675,10 @@ class Session(object):
673
675
  while p.parent is not None:
674
676
  p = p.parent
675
677
  return p
676
- @export2Dollar('break')
677
- def _break(self,code=0,message=''):
678
- self.logger.debug('Script requested break (exit_code=%s)', code)
679
- raise SSHScriptBreak(message,code)
680
-
681
678
  @export2Dollar
682
- def exit(self,code=0,message=''):
683
- self.logger.debug('Script requested exit (exit_code=%s)', code)
684
- raise SSHScriptExit(message,code)
679
+ def stop(self, code=0, message=''):
680
+ """Normally finish the nearest script execution, preserving its namespace."""
681
+ return stop_script(code, message)
685
682
 
686
683
  def _read_known_hosts(self):
687
684
  """Read a host-owned trust file strictly; never execute a remote shell."""
@@ -1122,7 +1119,9 @@ class Session(object):
1122
1119
  """Execute Python or .spy source in this session and return its namespace.
1123
1120
 
1124
1121
  vars supplies initial names (default: the caller's locals); execution uses
1125
- a copy. This session is active for the duration of the run. showScript=True
1122
+ a copy. Return ScriptResult, including names defined before stop(), with
1123
+ exitcode/stopped/message completion attributes. Nested stop() ends only
1124
+ its nearest run. This session is active for the run. showScript=True
1126
1125
  displays translated source and returns an empty dict without executing it.
1127
1126
  """
1128
1127
  if vars is None:
@@ -1156,7 +1155,7 @@ class Session(object):
1156
1155
  if is_dollar_script else script
1157
1156
  )
1158
1157
  dumpScript(scriptChunk)
1159
- return {}
1158
+ return ScriptResult({})
1160
1159
 
1161
1160
 
1162
1161
  source_path = filepath or '<str>'
@@ -1185,15 +1184,17 @@ class Session(object):
1185
1184
  else:
1186
1185
  dollarparser._cache_source(source_path, script)
1187
1186
  code = compile(script, source_path, 'exec')
1187
+ active_token = script_active.set(True)
1188
1188
  try:
1189
1189
  exec(code, exec_vars) # Run the modified code inside the module's namespace
1190
- except (SSHScriptBreak, SSHScriptExit):
1191
- # Preserve the control-flow exception until run_file()/the CLI
1192
- # can translate it into the documented process exit status.
1193
- raise
1190
+ except ScriptStopped as stopped:
1191
+ return ScriptResult(exec_vars, exitcode=stopped.code,
1192
+ stopped=True, message=stopped.message)
1194
1193
  else:
1195
- return exec_vars
1196
-
1194
+ return ScriptResult(exec_vars)
1195
+ finally:
1196
+ script_active.reset(active_token)
1197
+
1197
1198
  ## v.1.18
1198
1199
  if not self.runLocker.acquire(timeout=60):
1199
1200
  raise TimeoutError('Timed out waiting for the script execution lock')
@@ -1238,9 +1239,7 @@ class Session(object):
1238
1239
  try:
1239
1240
  ret = runner(script,vars,showScript)
1240
1241
  finally:
1241
- ## self.runLocker might be already released by caller because of timeout
1242
- if self.runLocker.locked():
1243
- self.runLocker.release()
1242
+ self.runLocker.release()
1244
1243
 
1245
1244
  if ret.get('exception'):
1246
1245
  raise ret['exception']
sshscript/session.pyi CHANGED
@@ -5,6 +5,7 @@ from paramiko import MissingHostKeyPolicy
5
5
  from .commandjob import CommandJob, CommandTimeoutError as CommandTimeoutError, JobResult
6
6
  from .commandresult import CommandResult as CommandResult
7
7
  from .sessionwrapper import SessionWrapper
8
+ from .scriptcontrol import ScriptResult as ScriptResult
8
9
 
9
10
  Command = str | list[str] | tuple[str, ...]
10
11
  HostKeyPolicy = MissingHostKeyPolicy | type[MissingHostKeyPolicy] | None
@@ -79,6 +80,8 @@ class Session:
79
80
  def upload(self, src: str | PathLike[str], dst: str, makedirs: bool = ..., overwrite: bool = ...) -> tuple[str, str]: ...
80
81
  def download(self, src: str, dst: str | PathLike[str] | None = ...) -> tuple[str, str]: ...
81
82
  def clear(self) -> None: ...
83
+ def stop(self, code: int = ..., message: str = ...) -> None: ...
84
+ def run(self, script: str, vars: dict[str, Any] | None = ..., showScript: bool = ...) -> ScriptResult: ...
82
85
  def close(self, strict: bool = ...) -> bool: ...
83
86
  disconnect = close
84
87
  def __enter__(self) -> Session: ...
@@ -406,8 +406,9 @@ class SessionWrapper(object):
406
406
  def __repr__(self):
407
407
  return f'SessionWrapper(id:{id(self)}, hijacked:{self.channel.hijacked})'
408
408
 
409
- def _break(self,code=0,message=''):
410
- self.channel.owner.session._break(code,message)
409
+ def stop(self, code=0, message=''):
410
+ """Normally finish the nearest script, including inside a console scope."""
411
+ return self.channel.owner.session.stop(code, message)
411
412
 
412
413
  def _enter_thread(self,thread):
413
414
  self.channel.owner.session._enter_thread(thread)
@@ -426,5 +427,3 @@ class SessionWrapper(object):
426
427
  def __exit__(self,*args):
427
428
  patching.get_thread_stack().pop(self)
428
429
  return False
429
- def exit(self):
430
- return self.__exit__(None,None,None)
@@ -11,6 +11,7 @@ class SessionWrapper:
11
11
  def exitcode(self) -> int: ...
12
12
  @property
13
13
  def closed(self) -> bool: ...
14
+ def stop(self, code: int = ..., message: str = ...) -> None: ...
14
15
  def start(self, command: str, *, timeout: float | None = ..., stop_timeout: float = ..., capture_limit: int = ..., check: bool = ...) -> CommandJob: ...
15
16
  def set(self, *, check: bool = ..., verbose: bool = ..., verbose_stderr: bool = ..., log_level: int | str = ...) -> None: ...
16
17
  def get(self, name: str | None = ...) -> Any: ...
sshscript/spyimporter.py CHANGED
@@ -29,10 +29,12 @@ if __package__:
29
29
  from . import dollarparser
30
30
  from . import patching
31
31
  from .dollar import Dollar
32
+ from .scriptcontrol import ScriptStopped, script_active
32
33
  else:
33
34
  import dollarparser
34
35
  import patching
35
36
  from dollar import Dollar
37
+ from scriptcontrol import ScriptStopped, script_active
36
38
 
37
39
  class SpyLoader(importlib.abc.Loader):
38
40
  """Compile and execute .spy modules while preserving their original source locations."""
@@ -68,7 +70,14 @@ class SpyLoader(importlib.abc.Loader):
68
70
  g["_sshscript_session_"] = stack[-1]
69
71
  module.__dict__.update(g)
70
72
  code = dollarparser.compile_spy(self.path, original_code)
71
- exec(code, module.__dict__) # Run the modified code inside the module's namespace
73
+ active_token = script_active.set(True)
74
+ try:
75
+ exec(code, module.__dict__)
76
+ except ScriptStopped:
77
+ # A stopped imported module is initialized with its partial namespace.
78
+ pass
79
+ finally:
80
+ script_active.reset(active_token)
72
81
 
73
82
 
74
83
  class SpyFileFinder(importlib.abc.MetaPathFinder):
sshscript/sshscript.py CHANGED
@@ -40,8 +40,8 @@ def warning_on_one_line(message, category, filename, lineno, file=None, line=Non
40
40
  if __package__:
41
41
  from ._version import __version__
42
42
  from .sessionsettings import execution_defaults
43
- from .session import Session, CommandResult, CommandJob, CommandTimeoutError, JobResult
44
- from .errorutils import SSHScriptExit, SSHScriptBreak, get_logger, set_logger, SSHScriptException,command_summary
43
+ from .session import ScriptResult, Session, CommandResult, CommandJob, CommandTimeoutError, JobResult
44
+ from .errorutils import get_logger, set_logger, SSHScriptException,command_summary
45
45
  ## 2025/3/3 v2.0.3 feature: import *.spy file directly
46
46
  from . import spyimporter
47
47
  from . import dollarparser
@@ -51,8 +51,8 @@ else:
51
51
  if not mydir in sys.path: sys.path.insert(0,mydir)
52
52
  from _version import __version__
53
53
  from sessionsettings import execution_defaults
54
- from session import Session, CommandResult, CommandJob, CommandTimeoutError, JobResult
55
- from errorutils import SSHScriptExit, SSHScriptBreak, get_logger, set_logger, SSHScriptException,command_summary
54
+ from session import ScriptResult, Session, CommandResult, CommandJob, CommandTimeoutError, JobResult
55
+ from errorutils import get_logger, set_logger, SSHScriptException,command_summary
56
56
  import spyimporter
57
57
  import dollarparser
58
58
  ## 2024/8/16, should remove mydir out of sys.path for python 3.11
@@ -96,8 +96,8 @@ def run_file(
96
96
  execution already failed, that exception remains primary and receives a
97
97
  cleanup-failure note.
98
98
 
99
- Return 0 on normal completion, or the status passed to $.break(status).
100
- $.exit(status) raises SSHScriptExit; other execution errors propagate.
99
+ Return 0 on normal completion, or the status passed to $.stop(status).
100
+ Execution errors and Python SystemExit propagate.
101
101
  Use run_script() for source text and a returned namespace.
102
102
  """
103
103
  if not isinstance(script_path, (str, os.PathLike)):
@@ -132,19 +132,11 @@ def run_file(
132
132
  script_vars['__name__'] = '__main__'
133
133
  script_vars['__file__'] = absfile
134
134
  with spyimporter.spy_imports():
135
- session.run(
135
+ result = session.run(
136
136
  script,
137
137
  script_vars,
138
138
  showScript=showScript,
139
139
  )
140
- except SSHScriptBreak as exc:
141
- exitcode = exc.errno
142
- script_exitcode = exc.errno
143
- outcome = 'break'
144
- except SSHScriptExit as exc:
145
- script_exitcode = exc.errno
146
- outcome = 'exit'
147
- raise
148
140
  except SSHScriptException as exc:
149
141
  primary_exception = exc
150
142
  script_exitcode = exc.errno
@@ -157,8 +149,8 @@ def run_file(
157
149
  exception_type = type(exc).__name__
158
150
  raise
159
151
  else:
160
- outcome = 'completed'
161
- script_exitcode = 0
152
+ outcome = 'stopped' if result.stopped else 'completed'
153
+ script_exitcode = exitcode = result.exitcode
162
154
  finally:
163
155
  if inserted_path:
164
156
  sys.path.remove(script_folder)
@@ -199,8 +191,6 @@ def run_script(script,varGlobals=None,showScript=False):
199
191
  result = the_session.run(script,vars=varGlobals,showScript=showScript)
200
192
  outcome = 'completed'
201
193
  return result
202
- except (SSHScriptBreak, SSHScriptExit):
203
- raise
204
194
  except BaseException as exc:
205
195
  primary_exception = exc
206
196
  exception_type = type(exc).__name__
@@ -278,9 +268,24 @@ def main():
278
268
  set_logger()
279
269
  warnings.formatwarning = warning_on_one_line
280
270
 
271
+ if len(sys.argv) > 1 and sys.argv[1] == 'console':
272
+ if __package__:
273
+ from .interactive import main as console_main
274
+ else:
275
+ # Flat development CLI removes its bootstrap path after imports.
276
+ # Keep it available while the REPL imports local product modules.
277
+ sys.path.insert(0, mydir)
278
+ try:
279
+ from interactive import main as console_main
280
+ return console_main(sys.argv[2:])
281
+ finally:
282
+ sys.path.remove(mydir)
283
+ return console_main(sys.argv[2:])
284
+
281
285
  import argparse
282
286
 
283
- parser = argparse.ArgumentParser(description='SSHScript: automation tools for Subprocess and SSH')
287
+ parser = argparse.ArgumentParser(description='SSHScript: automation tools for Subprocess and SSH',
288
+ epilog='Interactive preview: sshscript console [host] (console --help for options)')
284
289
 
285
290
 
286
291
  parser.add_argument('--script','-s', dest='showScript', action='store_true',
@@ -385,8 +390,6 @@ def main():
385
390
  args.path,
386
391
  showScript=args.showScript,
387
392
  )
388
- except SSHScriptExit as e:
389
- sys.exit(e.errno)
390
393
  except SSHScriptException as e:
391
394
  if args.show_traceback:
392
395
  logger.exception(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshscript
3
- Version: 5.0.0
3
+ Version: 6.0.0
4
4
  Summary: Python automation for local processes, SSH, and SSHScript .spy files
5
5
  Author-email: "Yeh, Hsin-Yuan" <iapyeh@gmail.com>
6
6
  License-Expression: MIT
@@ -31,6 +31,10 @@ Dynamic: license-file
31
31
 
32
32
  # SSHScript
33
33
 
34
+ SSHScript 6.0.0: [interactive console and script completion](CONSOLE.md).
35
+ Run `python3 -B sshscript.py console` from the development checkout.
36
+
37
+
34
38
  [![PyPI](https://img.shields.io/pypi/v/sshscript)](https://pypi.org/project/sshscript/)
35
39
  [![Python](https://img.shields.io/pypi/pyversions/sshscript)](https://pypi.org/project/sshscript/)
36
40
  [![CI](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml/badge.svg?branch=release)](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml)
@@ -50,7 +54,9 @@ output and status, distinguish failure from timeout, and make cleanup and
50
54
  unknown termination explicit. A successful command exit is evidence about
51
55
  that command; verify the deployment or other application outcome separately.
52
56
 
53
- **Current release:** [5.0.0](https://github.com/iapyeh/sshscript/releases/tag/v5.0.0)
57
+ **Source contract: 6.0.0.** The release branch and local artifacts target this
58
+ version; this source update does not publish a package or create a release tag.
59
+ **Latest published release:** [5.0.0](https://github.com/iapyeh/sshscript/releases/tag/v5.0.0)
54
60
  (Production/Stable) · **Python:** 3.11 or newer · **Tested:** Python
55
61
  3.11–3.14 on Linux and macOS
56
62
 
@@ -60,8 +66,10 @@ that command; verify the deployment or other application outcome separately.
60
66
  [Security](https://github.com/iapyeh/sshscript/security/policy) ·
61
67
  [Support](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md)
62
68
 
63
- **Version 5.0.0** preserves the 4.0 command execution contract and changes the
64
- default host-key source to the calling host. Provision its known_hosts file or
69
+ **Version 6.0.0** adds a Python-first interactive console and normal script
70
+ completion through `$.stop()`. It removes `$.exit()`, `$.break()` and their old
71
+ control exceptions; review the [6.0 migration](VERSIONING.md#60-console-and-script-completion-migration).
72
+ It retains the 5.0 calling-host default for host-key sources. Provision its known_hosts file or
65
73
  select `known_hosts="local"` when migrating existing nested connections.
66
74
  Upgrade 3.1.5 automation using the migration guide and application regressions.
67
75
  See [version policy and migration](VERSIONING.md) before upgrading automation.
@@ -153,7 +161,7 @@ pin SSHScript and all transitive dependencies in your application's lock file.
153
161
  To install this release explicitly:
154
162
 
155
163
  ```sh
156
- python3 -m pip install "sshscript==5.0.0"
164
+ python3 -m pip install /path/to/sshscript-6.0.0-py3-none-any.whl
157
165
  ```
158
166
 
159
167
  Use `python3 -m pip install --upgrade sshscript` to upgrade. The optional
@@ -162,7 +170,11 @@ it never installs an update by itself.
162
170
 
163
171
  ## 60-second local quickstart
164
172
 
165
- This example runs on 5.0.0 and retains compatibility with the 3.1.5 command API.
173
+ For 6.0.0, install a verified local wheel as shown above until package publication.
174
+ After publication, pin `sshscript==6.0.0` instead. The latest published 5.0.0
175
+ package does not contain the new console.
176
+
177
+ This example runs on 6.0.0 and retains compatibility with the 3.1.5 command API.
166
178
  The metadata below records that older compatibility baseline.
167
179
 
168
180
  <!-- example: {"id":"readme-quickstart", "profile":"3.1.5", "stdout":"sshscript is ready\nexit code: 0\n"} -->
@@ -607,3 +619,5 @@ for maintainer-only release steps.
607
619
 
608
620
  SSHScript is released under the
609
621
  [MIT License](https://github.com/iapyeh/sshscript/blob/release/LICENSE.txt).
622
+
623
+ *Last modified: 2026-10-22 23:24:55*
@@ -1,9 +1,9 @@
1
1
  _sshscript_cli.py,sha256=Z3BwvYFLKJAmy8Wi6xDUqHiIbXT498_uX4WEiguD9n0,576
2
- sshscript/__init__.py,sha256=bVqkgcifvePikqOTTMBC8bTSFz-UeFjSV-12seFONgw,3114
3
- sshscript/__init__.pyi,sha256=HOvKjFhwqm6OqTfBDkSGHyo2pGcmAB0cHOPz2jEow2c,810
2
+ sshscript/__init__.py,sha256=g-8qM2hSxx9a_OOVnOTCAnTQ4z3uv8rUcF3G7cdG1go,3170
3
+ sshscript/__init__.pyi,sha256=rZfxqkAHa3ZewvHzmaxfABTAyZULWIqgBRO6CH9yGeY,864
4
4
  sshscript/_runtime.py,sha256=wJt8XSxK21Ntt-oSUhEF4lN8Nx10H1X8JyIG2ebaPSo,443
5
- sshscript/_version.py,sha256=ssRZ5iGxPpxYOxW6pHi-PtvxOGEaaBmJt3P5ZoMxybk,77
6
- sshscript/channelgeneric.py,sha256=J_tBZa4xrXLjDN3xjfkHukzynhkT0-bKkz-Ds3RKIaA,75822
5
+ sshscript/_version.py,sha256=OenAa_lZfEcAnQebYG3wLxiSbvUUUVTKx4zVuHmbhEw,77
6
+ sshscript/channelgeneric.py,sha256=-5HdoarpeGwI19wCw1w5qtGPfRv7YLWU3MKbftcts4M,76137
7
7
  sshscript/channelssh.py,sha256=aVdI3KsUoSOujCDLpuI0I3m5YDWZCvIeB8a4J93P7r0,12038
8
8
  sshscript/channelsubprocess.py,sha256=mWPMxFMV6ZMx-VPWPNgtRvOAW9pjRZNtXddl0qY7UQk,12162
9
9
  sshscript/channelutils.py,sha256=0rdC8a-RctLHb28GvGSsKANgRy1L4y45sbry0KB3KaA,33514
@@ -14,23 +14,25 @@ sshscript/commandresult.pyi,sha256=gflxZGoI5V8R6MThGyEXMlJigKOnit61tmG1_BnTTVo,5
14
14
  sshscript/consolejob.py,sha256=QKzhjA_qbiOzg7R-PbhjP9-ZIPAnJba0csjMdJ8RC5g,14201
15
15
  sshscript/dollar.py,sha256=xCesXAU-mj_wVm1-m41Sw_nIZR-Z7Wl3kFnJxQZvFiw,26265
16
16
  sshscript/dollarchanger.py,sha256=BvKqUaPgYmAkXyv_rbmSSbvowneAs_wlYSDm1Hic7PA,31159
17
- sshscript/dollarparser.py,sha256=7cl4QgffzfueeGOPN-NeoLP_3bKm04I9cVtCyag7JcE,9638
18
- sshscript/errorutils.py,sha256=MgwYoPipEVCa9L33FmVoYXl77CD-Mrq958q_ft3cuvw,16386
17
+ sshscript/dollarparser.py,sha256=4fELB7lOEyyEteAr962v8liF5BjB_GmdgT5uJEZjEOU,9735
18
+ sshscript/errorutils.py,sha256=AAZ2kOShZpnrhK25FxC5UiIOj5EjcVTn1fICbV1QDKQ,16114
19
+ sshscript/interactive.py,sha256=xXkiEHS2jN8hZ93hTW4QOglsDU1g5olTt9AYLq4UQkY,26422
19
20
  sshscript/patching.py,sha256=OM6vjGeP0iyBnzpai5BHn9qqkzJp9375npVb97D06LY,6581
20
21
  sshscript/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- sshscript/session.py,sha256=AvaqFhWbNRERE5qJfYp0uWdR8mQlzl2aT32FQSwqcIs,78406
22
- sshscript/session.pyi,sha256=gK0INrScwjTk0WqKGX458Iw-toOkRikRtSvdTvX0_-s,4857
22
+ sshscript/scriptcontrol.py,sha256=M2NEMkI8A-fONa8l0oB_2c9p_uunBa6Kff1a1J53vNg,1342
23
+ sshscript/session.py,sha256=g7qKoq4efY_IT-n8vBbazpfXfDtCqIgqPkB1FdaRtfs,78426
24
+ sshscript/session.pyi,sha256=gObS6RHj4DJqWaqX3RyBcvYHXoNbNvbYYHvykGADqbA,5093
23
25
  sshscript/sessionsettings.py,sha256=QJnjQtPpJBNUbIboICKcuNxZh3yN4vAuL_yw_5LL3d4,4612
24
- sshscript/sessionwrapper.py,sha256=r6BI6xBwrA-nqqa6YsXzaUUtxz5pv_483Xu6Ywlzs4I,19019
25
- sshscript/sessionwrapper.pyi,sha256=vzdJjl-vPB1bP57uXe78jGpjC4ag2t_Aqowg_4ggbVY,1585
26
- sshscript/spyimporter.py,sha256=ASAs2nPBU1mtpI7FquXYJctLFQR_PeGMnhjcw1G6LPg,5574
26
+ sshscript/sessionwrapper.py,sha256=hxW6JzNWaObZ216GkmybsUYBJKj-CaxVs3RKuSSSWpM,19043
27
+ sshscript/sessionwrapper.pyi,sha256=wi7r6kz77yO4EY1fumznK9Fs4IIALZm_B2ytltsQGOk,1654
28
+ sshscript/spyimporter.py,sha256=kdI7lWr1GJO4C_ZJ7qEsx6AuxKfMzeAOr5itkh-9quY,5895
27
29
  sshscript/sshconfig.py,sha256=aPfVOd-bx4k7gBuDeSb55Y812G4QNGeL3sbZPLPdiic,6295
28
- sshscript/sshscript.py,sha256=BXQG1yA-9M3l0JDbCwR3Lg5CCpn3qnJhQ1Xx6Q9-2eA,16940
30
+ sshscript/sshscript.py,sha256=SFSupMAqknSagp3Ewnmzvt42o2gnehbsDMqKRnn3voo,17292
29
31
  sshscript/stdio.py,sha256=Ql_7wX7oJ5vY74uOlCaotwoyqXrNteYdSl-aM4ersVM,16040
30
32
  sshscript/tokenparser.py,sha256=dGHY2s8Twjz0Q-r6nanmAL8VCQxZWqmM6g4KvTgIuzs,22396
31
- sshscript-5.0.0.dist-info/licenses/LICENSE.txt,sha256=FPqC-mLQDBkQ26LK_CAUGEUQQ7eyFTbftywlk2GMlls,1075
32
- sshscript-5.0.0.dist-info/METADATA,sha256=WrYqMxN3lzhnL8whxN7gYOdVw7WKrrAoteeEue7tHBo,29353
33
- sshscript-5.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
34
- sshscript-5.0.0.dist-info/entry_points.txt,sha256=JLkqym2UQso2R41RvFz9RAnCKFY8-nnrYgB4ER-zTGo,50
35
- sshscript-5.0.0.dist-info/top_level.txt,sha256=5d41PAJBhS-f-8aJKJegdhs8BIg_jpigSG83tCQhhfg,25
36
- sshscript-5.0.0.dist-info/RECORD,,
33
+ sshscript-6.0.0.dist-info/licenses/LICENSE.txt,sha256=FPqC-mLQDBkQ26LK_CAUGEUQQ7eyFTbftywlk2GMlls,1075
34
+ sshscript-6.0.0.dist-info/METADATA,sha256=KNYJLXcLG2TMgcywEwRc-aHdKJthpe2PDNVFnWpSZIk,30132
35
+ sshscript-6.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
+ sshscript-6.0.0.dist-info/entry_points.txt,sha256=JLkqym2UQso2R41RvFz9RAnCKFY8-nnrYgB4ER-zTGo,50
37
+ sshscript-6.0.0.dist-info/top_level.txt,sha256=5d41PAJBhS-f-8aJKJegdhs8BIg_jpigSG83tCQhhfg,25
38
+ sshscript-6.0.0.dist-info/RECORD,,