sshscript 6.0.0__py3-none-any.whl → 6.1.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
@@ -43,12 +43,14 @@ else:
43
43
  _require_python()
44
44
 
45
45
  if __package__:
46
+ from .interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError
46
47
  from ._version import __version__
47
48
  from . import sshscript
48
49
  from . import session
49
50
  from . import errorutils
50
51
  from .spyimporter import spy_imports
51
52
  else:
53
+ from interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError
52
54
  from _version import __version__
53
55
  import sshscript
54
56
  import session
@@ -85,6 +87,7 @@ SSHScriptException = errorutils.SSHScriptException
85
87
  # return _global['____sshscript____'][0]
86
88
 
87
89
  __all__ = [
90
+ 'Console', 'ConsoleUpdate', 'Environment', 'SwitchRequest', 'SwitchCredentials', 'SwitchOutcome', 'CredentialsRequiredError', 'ConsoleInitializationError', 'ConsoleCleanupError',
88
91
  'run_file',
89
92
  'check_file',
90
93
  'run_script',
sshscript/__init__.pyi CHANGED
@@ -14,3 +14,5 @@ def run_script(script: str, varGlobals: dict[str, Any] | None = ..., showScript:
14
14
  def spy_imports() -> ContextManager[None]: ...
15
15
  def get_logger() -> Any: ...
16
16
  def set_logger(userlogger: Any = ..., logname: str | None = ...) -> Any: ...
17
+
18
+ from .interactive import (Console as Console, ConsoleUpdate as ConsoleUpdate, Environment as Environment, SwitchRequest as SwitchRequest, SwitchCredentials as SwitchCredentials, SwitchOutcome as SwitchOutcome, CredentialsRequiredError as CredentialsRequiredError, ConsoleInitializationError as ConsoleInitializationError, ConsoleCleanupError as ConsoleCleanupError)
sshscript/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Single source of the SSHScript package version."""
2
2
 
3
- __version__ = "6.0.0"
3
+ __version__ = "6.1.0"
sshscript/interactive.py CHANGED
@@ -6,7 +6,8 @@ import code
6
6
  from io import StringIO
7
7
  import tokenize
8
8
  from contextlib import ExitStack, redirect_stdout, redirect_stderr
9
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, field
10
+ import threading
10
11
  from getpass import getpass
11
12
  import sys
12
13
  import time
@@ -15,9 +16,13 @@ import subprocess
15
16
  if __package__:
16
17
  from . import dollarparser, tokenparser, patching
17
18
  from .session import Session
19
+ from .commandresult import CommandResult
20
+ from .commandjob import JobResult
18
21
  else:
19
22
  import dollarparser, tokenparser, patching
20
23
  from session import Session
24
+ from commandresult import CommandResult
25
+ from commandjob import JobResult
21
26
 
22
27
 
23
28
  class _LeaveLayer(BaseException):
@@ -75,6 +80,7 @@ class _PythonConsole(code.InteractiveConsole):
75
80
  def __init__(self, namespace):
76
81
  super().__init__(namespace)
77
82
  self.cell = 1
83
+ self.last_error = None
78
84
 
79
85
  def runsource(self, source, filename='<input>', symbol='single'):
80
86
  filename = f'<sshscript-console-{self.cell}>'
@@ -130,24 +136,27 @@ class _PythonConsole(code.InteractiveConsole):
130
136
  return True
131
137
  except (SyntaxError, OverflowError, ValueError):
132
138
  self.cell += 1
139
+ self.last_error = type(sys.exc_info()[1]).__name__
133
140
  self.showsyntaxerror(filename)
134
141
  return False
135
142
  self.cell += 1
136
143
  self.runcode(compiled)
137
144
  return False
138
145
 
146
+ def showsyntaxerror(self, filename=None):
147
+ self.write("SyntaxError: input compilation failed.\n")
148
+
139
149
  def runcode(self, code_object):
140
150
  try:
141
151
  exec(code_object, self.locals)
142
152
  except (_LeaveLayer, _QuitConsole, SystemExit, KeyboardInterrupt):
143
153
  raise
144
154
  except BaseException as error:
155
+ self.last_error = type(error).__name__
145
156
  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')
157
+ # Source and exception text may contain credentials.
158
+ self.write(f'{type(error).__name__}: operation failed; inspect error for details.\n')
159
+
151
160
 
152
161
 
153
162
  @dataclass
@@ -163,20 +172,96 @@ class _Layer:
163
172
  program_positions: tuple = (0, 0)
164
173
 
165
174
 
166
- class InteractiveConsole:
167
- """Own a stack of connection/console contexts and a persistent Python namespace.
168
175
 
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.
176
+ class CredentialsRequiredError(ValueError):
177
+ """A managed identity switch needs an explicit credential decision."""
178
+
179
+
180
+ class ConsoleInitializationError(RuntimeError):
181
+ """Initialization failed; the supplied Session needs reconciliation."""
182
+ session_reuse = 'unknown'
183
+
184
+
185
+ class ConsoleCleanupError(RuntimeError):
186
+ """Cleanup or the post-cleanup Session probe failed; reuse is unknown."""
187
+ session_reuse = 'unknown'
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class Environment:
192
+ kind: str
193
+ host: str
194
+ connection_user: str | None
195
+ effective_user: str
196
+ identity_verified: bool
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class SwitchRequest:
201
+ kind: str
202
+ username: str | None
203
+ environment: Environment
204
+ credential_supplied: bool
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class SwitchCredentials:
209
+ password: str | None = field(repr=False)
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class SwitchOutcome:
214
+ request: SwitchRequest
215
+ success: bool
216
+ environment: Environment
217
+ error_type: str | None = None
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class ConsoleUpdate:
222
+ state: str
223
+ stdout: str = ''
224
+ stderr: str = ''
225
+ result: CommandResult[int | None] | None = None
226
+ error_type: str | None = None
227
+ session_reuse: str = 'in_use'
228
+
229
+
230
+
231
+ class Console:
232
+ """Persistent Python/dollar console shared by terminal and embedded callers.
233
+
234
+ Borrow a supplied Session by default; explicitly transfer ownership with
235
+ ownership="owned". Use a context manager or always call close().
172
236
  """
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
237
+
238
+ def __init__(self, session, *, ownership='borrowed', terminal=False,
239
+ get_pty=True, credential_resolver=None, switch_observer=None,
240
+ stdout=None, stderr=None, input_func=None, password_func=None):
241
+ if ownership not in ('borrowed', 'owned'):
242
+ raise ValueError('ownership must be borrowed or owned')
243
+ if session.closed:
244
+ raise RuntimeError('Session is closed')
245
+ self._creator = threading.get_ident()
246
+ self._session = session
247
+ self._ownership = ownership
248
+ self._terminal = terminal
249
+ self._resolver = credential_resolver
250
+ self._observer = switch_observer
251
+ self._capture = (StringIO(), StringIO())
252
+ self._job = None
253
+ self._job_positions = [0, 0]
254
+ self._state = 'ready'
255
+ self._error_type = None
256
+ self._reuse = 'in_use'
257
+ self._cleanup_error = None
258
+ self._uncertain = False
259
+ self._last_result = None
260
+ self._submitting = False
261
+ self._stdout = stdout if stdout is not None else (sys.stdout if terminal else self._capture[0])
262
+ self._stderr = stderr if stderr is not None else (sys.stderr if terminal else self._capture[1])
177
263
  self.input_func = input_func or input
178
- self.password_func = password_func or getpass
179
- self.session_factory = session_factory
264
+ self.password_func = password_func or (self._terminal_password if terminal else self._missing_password)
180
265
  self.layers = []
181
266
  self.namespace = {'__name__': '__console__', 'getpass': self.password_func,
182
267
  'control': self, '_console_control_': self,
@@ -186,6 +271,22 @@ class InteractiveConsole:
186
271
  self.python = _PythonConsole(self.namespace)
187
272
  self.more = False
188
273
  self.done = False
274
+ # Do not enter/exit the external Session or mutate its execution settings.
275
+ try:
276
+ with ExitStack() as resources:
277
+ wrapper = session.shell('bash', get_pty=get_pty)
278
+ wrapper.channel.dump2sys = (0, 0)
279
+ shell = resources.enter_context(wrapper)
280
+ self._push(resources, session, shell, 'ssh' if session.host else 'local',
281
+ session.host or 'local')
282
+ except BaseException:
283
+ self._reuse = 'unknown'
284
+ if ownership == 'owned':
285
+ try:
286
+ session.close(strict=True)
287
+ except BaseException:
288
+ pass
289
+ raise ConsoleInitializationError('console initialization failed; Session reuse is unknown') from None
189
290
 
190
291
  @property
191
292
  def current(self):
@@ -203,61 +304,9 @@ class InteractiveConsole:
203
304
  self.layers.append(_Layer(resources.pop_all(), session, console, kind, host, user))
204
305
  self._refresh()
205
306
 
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
307
  def shell(self, *args, **kwargs):
249
308
  self._console_layer('shell', *args, **kwargs)
250
309
 
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
310
  def sudo(self, *args, **kwargs):
262
311
  self._identity_layer('sudo', *args, **kwargs)
263
312
 
@@ -322,7 +371,7 @@ class InteractiveConsole:
322
371
  # Non-environment operations keep the regular API's session/console owner.
323
372
  if name in ('upload', 'download', 'pkey', 'set', 'get'):
324
373
  return getattr(self.current.session, name)
325
- if name in ('send', 'input', 'expect', 'start'):
374
+ if name in ('send', 'input', 'expect'):
326
375
  return getattr(self.current.console, name)
327
376
  if name in ('stdout', 'stderr', 'exitcode'):
328
377
  if self.current.kind == 'program':
@@ -331,28 +380,6 @@ class InteractiveConsole:
331
380
  return getattr(result, name) if result is not None else (None if name == 'exitcode' else '')
332
381
  raise AttributeError(name)
333
382
 
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
383
  def _write(self, text, stream):
357
384
  if text:
358
385
  stream.write(text)
@@ -366,7 +393,7 @@ class InteractiveConsole:
366
393
  raise TypeError('check must be bool')
367
394
  result = self._command(command, timeout=timeout, capture_limit=capture_limit,
368
395
  stop_timeout=stop_timeout)
369
- if (enabled and result is not None and result.stop_reason == 'completed'
396
+ if (enabled and result is not None and (not isinstance(result, JobResult) or result.stop_reason == 'completed')
370
397
  and result.exitcode != 0):
371
398
  error = subprocess.CalledProcessError(result.exitcode, command,
372
399
  output=result.stdout, stderr=result.stderr)
@@ -374,41 +401,6 @@ class InteractiveConsole:
374
401
  raise error
375
402
  return result
376
403
 
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
404
  def _drain_program(self):
413
405
  layer = self.current
414
406
  channel = layer.console.channel
@@ -442,14 +434,10 @@ class InteractiveConsole:
442
434
  self._drain_program()
443
435
  if outcome == 'exited':
444
436
  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
437
  else:
452
- self._write('\nKeyboardInterrupt\n', self._stderr)
438
+ # _input_text consumes a matched prompt from its output buffers.
439
+ # Preserve the protocol result: poll cannot rediscover that prompt.
440
+ self._state = 'waiting_input' if outcome == 'prompt' else 'running'
453
441
 
454
442
  def _eof(self):
455
443
  if self.layers and self.current.kind == 'program':
@@ -459,18 +447,66 @@ class InteractiveConsole:
459
447
  self.more = False
460
448
  self.close()
461
449
 
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)
450
+ @property
451
+ def prompt(self):
452
+ if self.more:
453
+ return '... '
454
+ layer = self.current
455
+ suffix = ' program' if layer.kind == 'program' else ''
456
+ return f'{layer.host} [{layer.user}]{suffix}> '
457
+
458
+ def interact(self):
459
+ if self.input_func is builtins.input and sys.stdin.isatty():
460
+ # Python loads readline for its own REPL, but not when running a
461
+ # script. Enable terminal editing before our first input() call.
468
462
  try:
469
- self.leave()
470
- except Exception as error:
471
- self._write('Cleanup failed: ' + type(error).__name__ + '\n', self._stderr)
463
+ import readline # noqa: F401
464
+ except ImportError:
465
+ pass # Minimal Python builds retain input()'s basic editing.
466
+ self._write('SSHScript console. Type help for commands; quit to finish.\n', self._stdout)
467
+ try:
468
+ while not self.done:
469
+ try:
470
+ line = self.input_func(self.prompt)
471
+ except EOFError:
472
+ self.eof()
473
+ continue
474
+ except KeyboardInterrupt:
475
+ self.interrupt()
476
+ continue
477
+ try:
478
+ self.submit(line)
479
+ while self._job is not None:
480
+ update = self.poll()
481
+ if update.state == 'failed':
482
+ diagnostic = (f'exit {update.result.exitcode}' if update.result is not None
483
+ and update.result.exitcode is not None
484
+ else update.error_type or 'operation failed')
485
+ self._write(f'[{diagnostic}]\n', self._stderr)
486
+ if self._job is not None:
487
+ time.sleep(0.03)
488
+ except KeyboardInterrupt:
489
+ self.interrupt()
490
+ except Exception as error:
491
+ # Avoid echoing credential-bearing expressions or arbitrary
492
+ # server error text; the original exception is available as error.
493
+ self.namespace['error'] = error
494
+ self._write('Operation failed (' + type(error).__name__ + '). Use :status or inspect error.\n', self._stderr)
495
+ finally:
496
+ self.close()
472
497
 
473
- def execute(self, line):
498
+ def _release_layer(self):
499
+ layer = self.layers.pop()
500
+ try:
501
+ layer.resources.close()
502
+ finally:
503
+ if self.layers:
504
+ self._refresh()
505
+ else:
506
+ self.done = True
507
+ self.namespace.update(session=None, console=None)
508
+
509
+ def _submit_line(self, line):
474
510
  """Process one input line; variables and contexts survive later calls."""
475
511
  if self.done:
476
512
  return
@@ -482,17 +518,10 @@ class InteractiveConsole:
482
518
  self.help()
483
519
  elif helper == 'exit':
484
520
  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
521
  elif not self.more and line.strip() == ':status':
490
522
  for layer in self.layers:
491
523
  self._write(f'{layer.kind}: {layer.host} [{layer.user}]\n', self._stdout)
492
524
  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
525
  else:
497
526
  with redirect_stdout(self._stdout), redirect_stderr(self._stderr):
498
527
  self.more = self.python.push(line)
@@ -507,50 +536,393 @@ class InteractiveConsole:
507
536
  self.more = False
508
537
  self.leave()
509
538
  except SystemExit as error:
539
+ self.python.last_error = type(error).__name__
510
540
  self.python.resetbuffer()
511
541
  self.more = False
512
- self._write(f'Python/script exited ({error.code}); console remains open.\n', self._stderr)
542
+ self._write('Python/script exited; console remains open.\n', self._stderr)
513
543
  finally:
514
544
  self._discard_failed_layers()
515
545
 
546
+ @staticmethod
547
+ def _terminal_password(prompt='Password: '):
548
+ if not sys.stdin.isatty():
549
+ raise CredentialsRequiredError('terminal password input requires a terminal or host-provided credentials')
550
+ return getpass(prompt)
551
+
552
+ @staticmethod
553
+ def _missing_password(*args, **kwargs):
554
+ raise CredentialsRequiredError('non-terminal console requires host-provided credentials')
555
+
556
+ def _check(self, *, idle=False):
557
+ if threading.get_ident() != self._creator:
558
+ raise RuntimeError('Console operations require the constructing thread')
559
+ if idle and self._job is not None:
560
+ raise RuntimeError('poll or interrupt the foreground job before another operation')
561
+
516
562
  @property
517
- def prompt(self):
518
- if self.more:
519
- return '... '
563
+ def connection(self):
564
+ """The current connection Session; its login identity is unchanged by su/sudo."""
565
+ self._check()
566
+ return self.current.session
567
+
568
+ @property
569
+ def environments(self):
570
+ self._check()
571
+ return tuple(Environment(layer.kind, layer.host, layer.session.username,
572
+ (self.layers[index - 1].user if layer.kind == 'program'
573
+ and index else layer.user), layer.kind != 'program')
574
+ for index, layer in enumerate(self.layers))
575
+
576
+ @property
577
+ def session_reuse(self):
578
+ return self._reuse
579
+
580
+ def _identity_layer(self, kind, *args, **kwargs):
581
+ self._check(idle=True)
582
+ username = (args[0] if kind == 'su' and args else kwargs.get('username'))
583
+ if kind == 'sudo' and len(args) > 1:
584
+ username = args[1]
585
+ supplied = 'password' in kwargs or len(args) >= (2 if kind == 'su' else 1)
586
+ request = SwitchRequest(kind, username, self.environments[-1], supplied)
587
+ try:
588
+ credential = self._resolver(request) if self._resolver else None
589
+ if credential is not None:
590
+ if not isinstance(credential, SwitchCredentials):
591
+ raise TypeError('credential_resolver must return SwitchCredentials or None')
592
+ # Normalize positional password arguments before overriding them.
593
+ if len(args) >= (2 if kind == 'su' else 1):
594
+ args = list(args)
595
+ args[1 if kind == 'su' else 0] = credential.password
596
+ kwargs.pop('password', None)
597
+ else:
598
+ kwargs['password'] = credential.password
599
+ supplied = True
600
+ if not supplied and not self._terminal:
601
+ raise CredentialsRequiredError('provide credentials or explicit password=None')
602
+ if not supplied:
603
+ kwargs['password'] = self.password_func(kind + ' password: ')
604
+ self._console_layer(kind, *args, **kwargs)
605
+ except BaseException as error:
606
+ if self._observer:
607
+ self._observer(SwitchOutcome(request, False, self.environments[-1],
608
+ type(error).__name__))
609
+ raise
610
+ if self._observer:
611
+ self._observer(SwitchOutcome(request, True, self.environments[-1]))
612
+
613
+ def _console_layer(self, kind, *args, **kwargs):
614
+ self._check(idle=True)
520
615
  layer = self.current
521
- suffix = ' program' if layer.kind == 'program' else ''
522
- return f'{layer.host} [{layer.user}]{suffix}> '
616
+ if layer.kind == 'program':
617
+ raise RuntimeError('leave the interactive program before entering another layer')
618
+ with ExitStack() as resources:
619
+ console = resources.enter_context(getattr(layer.console, kind)(*args, **kwargs))
620
+ self._push(resources, layer.session, console,
621
+ 'program' if kind == 'enter' else kind, layer.host,
622
+ program=kind == 'enter')
623
+ if kind == 'enter':
624
+ self._drain_program()
523
625
 
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)
626
+ self._state = 'running' if kind == 'enter' else 'completed'
627
+ self._last_result, self._error_type = None, None
628
+ if kind == 'enter':
629
+ self._program_started = time.monotonic()
630
+
631
+ def connect(self, *args, **kwargs):
632
+ self._check(idle=True)
633
+ layer = self.current
634
+ if layer.kind not in ('local', 'ssh'):
635
+ raise RuntimeError('leave the current sudo/su/shell/program layer before connect(); SSH credentials belong to the connection Session')
636
+ with ExitStack() as resources:
637
+ remote = layer.session.connect(*args, **kwargs)
638
+ resources.callback(remote.close, strict=True)
639
+ resources.enter_context(remote)
640
+ wrapper = remote.shell('bash')
641
+ wrapper.channel.dump2sys = (0, 0)
642
+ shell = resources.enter_context(wrapper)
643
+ self._push(resources, remote, shell, 'ssh', remote.host or '?')
644
+
645
+ def _discard_failed_layers(self):
646
+ if any(layer.console.channel.closed or
647
+ getattr(layer.console.channel, '_state', None) in ('failed', 'closed')
648
+ for layer in self.layers):
649
+ self._uncertain = True
650
+ self._state = 'failed'
651
+ self._reuse = 'unknown'
652
+
653
+ def _command(self, line, *, timeout=None, capture_limit=8 * 1024 * 1024,
654
+ stop_timeout=3):
533
655
  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
656
+ if self.current.kind == 'program':
657
+ return self.send_input(line).result
658
+ # Managed foreground jobs retain shell state, stream output and recover
659
+ # the parent shell after Ctrl-C. Completion is never inferred from silence.
660
+ job = self.current.console.start(line, timeout=timeout, capture_limit=capture_limit,
661
+ stop_timeout=stop_timeout, check=False)
662
+ positions = [0, 0]
663
+ def drain():
664
+ for index, (text, stream) in enumerate(((job.stdout, self._stdout),
665
+ (job.stderr, self._stderr))):
666
+ self._write(text[positions[index]:], stream)
667
+ positions[index] = len(text)
668
+ try:
669
+ while not job.done:
670
+ drain()
671
+ time.sleep(0.03)
672
+ result = job.wait()
673
+ except KeyboardInterrupt:
674
+ result = job.stop()
675
+ self._write('\nKeyboardInterrupt\n', self._stderr)
676
+ finally:
677
+ drain()
678
+ self.namespace['result'] = self.current.result = result
679
+ if result.exitcode not in (0, None):
680
+ self._write(f'[exit {result.exitcode}]\n', self._stderr)
681
+ if result.stdout_truncated or result.stderr_truncated:
682
+ self._write('[output capture limit reached]\n', self._stderr)
683
+ except Exception as error:
684
+ self._last_result = getattr(error, 'result', None)
685
+ if self._last_result is not None and self._last_result.termination_status != 'confirmed':
686
+ self._uncertain, self._reuse = True, 'unknown'
687
+ raise
688
+ self._last_result = result
689
+ if result is not None and result.termination_status != 'confirmed':
690
+ self._uncertain, self._reuse = True, 'unknown'
691
+ if result is not None:
692
+ self._state = ('completed' if result.stop_reason == 'completed'
693
+ and result.exitcode == 0 else 'failed')
694
+ return result
695
+
696
+ def _update(self, *, consume=True):
697
+ output = []
698
+ for buffer in self._capture:
699
+ output.append(buffer.getvalue())
700
+ if consume:
701
+ buffer.seek(0)
702
+ buffer.truncate(0)
703
+ return ConsoleUpdate(self._state, *output, self._last_result,
704
+ self._error_type, self._reuse)
705
+
706
+ def submit(self, line):
707
+ """Evaluate one Python/dollar line, returning a structured update.
708
+
709
+ Shell dollar commands block until completion; use start_command for polling.
710
+ Operation errors are reported by safe type, without source/error text.
711
+ """
712
+ self._check(idle=True)
713
+ if self.done:
714
+ return self._update()
715
+ if self.current.kind == 'program' and not line.lstrip().startswith('$.'):
716
+ if self._bare_helper(line) is None:
543
717
  try:
544
- self.execute(line)
718
+ return self.send_input(line)
545
719
  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)
720
+ return self.interrupt()
721
+ self._state, self._error_type, self._last_result = 'completed', None, None
722
+ self.python.last_error = None
723
+ self._submitting = True
724
+ try:
725
+ self._submit_line(line)
726
+ except ConsoleCleanupError:
727
+ raise
728
+ except (Exception, KeyboardInterrupt, SystemExit) as error:
729
+ self._state, self._error_type = 'failed', type(error).__name__
552
730
  finally:
731
+ self._submitting = False
732
+ if self.python.last_error is not None:
733
+ self._state, self._error_type = 'failed', self.python.last_error
734
+ elif self.more:
735
+ self._state = 'incomplete'
736
+ if self.done:
737
+ self._state = 'closed' if self._reuse != 'unknown' else 'failed'
738
+ return self._update()
739
+
740
+ def start(self, command, *, timeout=60, stop_timeout=3,
741
+ capture_limit=1024 * 1024, check=False):
742
+ """Start and track a foreground job, including prompt-level $.start()."""
743
+ self._check(idle=True)
744
+ if self.more:
745
+ raise RuntimeError('finish or interrupt incomplete Python input first')
746
+ self._job = self.current.console.start(command, timeout=timeout,
747
+ stop_timeout=stop_timeout,
748
+ capture_limit=capture_limit, check=check)
749
+ self._job_positions = [0, 0]
750
+ self._state, self._error_type, self._last_result = 'running', None, None
751
+ return self._job
752
+
753
+ def start_command(self, command, *, timeout=60, stop_timeout=3,
754
+ capture_limit=1024 * 1024):
755
+ """Start a foreground shell command and return its first update."""
756
+ self.start(command, timeout=timeout, stop_timeout=stop_timeout,
757
+ capture_limit=capture_limit, check=False)
758
+ return self.poll()
759
+
760
+ def _drain_job(self):
761
+ for index, (text, stream) in enumerate(((self._job.stdout, self._stdout),
762
+ (self._job.stderr, self._stderr))):
763
+ self._write(text[self._job_positions[index]:], stream)
764
+ self._job_positions[index] = len(text)
765
+
766
+ def _finish_job(self, *, stop=False):
767
+ job = self._job
768
+ try:
769
+ self._last_result = job.stop() if stop else job.wait()
770
+ self._state = ('completed' if self._last_result.stop_reason == 'completed'
771
+ and self._last_result.exitcode == 0 else 'failed')
772
+ except Exception as error:
773
+ self._last_result = getattr(error, 'result', None)
774
+ self._state, self._error_type = 'failed', type(error).__name__
775
+ if self._last_result is None or self._last_result.termination_status != 'confirmed':
776
+ self._uncertain = True
777
+ self._reuse = 'unknown'
778
+ finally:
779
+ self._drain_job()
780
+ self._job = None
781
+ if self._last_result is not None:
782
+ self.namespace['result'] = self.current.result = self._last_result
783
+ if self._last_result.termination_status != 'confirmed':
784
+ self._uncertain, self._reuse = True, 'unknown'
785
+
786
+ def poll(self):
787
+ """Return available output; never wait for a running command."""
788
+ self._check()
789
+ if self._job is not None:
790
+ self._drain_job()
791
+ if self._job.done:
792
+ self._finish_job()
793
+ elif self.layers and self.current.kind == 'program':
794
+ channel = self.current.console.channel
795
+ texts = (str(channel._stdout), str(channel._stderr))
796
+ parent = channel.prompts[-2] if len(channel.prompts) > 1 else None
797
+ prompt = channel.prompt
798
+ if isinstance(parent, str) and parent != prompt and any(parent in text for text in texts):
799
+ channel._mark_interactive_layer_exited(channel.executing_lock)
800
+ self._drain_program()
801
+ self.leave()
802
+ else:
803
+ if isinstance(prompt, str) and any(prompt in text for text in texts):
804
+ self._state = 'waiting_input'
805
+ self._drain_program()
806
+ self._discard_failed_layers()
807
+ return self._update()
808
+
809
+ def send_input(self, text, *, newline=True, timeout=1):
810
+ """Send a raw write or one line to an enter() program."""
811
+ self._check()
812
+ if self._job is not None:
813
+ raise RuntimeError('shell jobs do not accept input; use enter() for an interactive program')
814
+ if self.current.kind != 'program':
815
+ raise RuntimeError('input requires a running job or enter() program')
816
+ self._last_result, self._error_type = None, None
817
+ try:
818
+ if newline:
819
+ outcome = self.current.console.input(text, timeout=timeout)
820
+ else:
821
+ self.current.console.send(text)
822
+ outcome = None
823
+ self._drain_program()
824
+ if outcome == 'exited':
825
+ self.leave()
826
+ else:
827
+ self._state = 'waiting_input' if outcome == 'prompt' else 'running'
828
+ except TimeoutError:
829
+ self._state, self._error_type = 'running', 'TimeoutError'
830
+ self._discard_failed_layers()
831
+ return self._update()
832
+
833
+ def _interrupt(self):
834
+ self.python.resetbuffer()
835
+ self.more = False
836
+ if self.layers and self.current.kind == 'program':
837
+ self._program_control('\x03')
838
+ else:
839
+ self._write('\nKeyboardInterrupt\n', self._stderr)
840
+ self._state = 'ready'
841
+ self._error_type = 'KeyboardInterrupt'
842
+
843
+ def interrupt(self):
844
+ self._check()
845
+ if self._job is not None:
846
+ self._finish_job(stop=True)
847
+ else:
848
+ self._interrupt()
849
+ return self._update()
850
+
851
+ def eof(self):
852
+ self._check(idle=True)
853
+ self._eof()
854
+ return self._update()
855
+
856
+ def leave(self):
857
+ self._check(idle=True)
858
+ if len(self.layers) == 1:
859
+ return self.close()
860
+ layer = self.current
861
+ program = layer.kind == 'program'
862
+ output = (str(layer.console.channel._stdout), str(layer.console.channel._stderr)) if program else None
863
+ try:
864
+ self._release_layer()
865
+ if layer.console.channel._failure is not None:
866
+ raise RuntimeError('environment recovery failed')
867
+ if program:
868
+ status = self.current.console.exitcode
869
+ self._last_result = CommandResult(*output, status, layer.session.host, time.monotonic() - self._program_started, '<interactive>')
870
+ self.namespace['result'] = self.current.result = self._last_result
871
+ self._state = 'completed' if status == 0 else 'failed'
872
+ except BaseException:
873
+ self._uncertain, self._reuse = True, 'unknown'
874
+ raise ConsoleCleanupError('layer cleanup failed; Session reuse is unknown') from None
875
+
876
+ def close(self):
877
+ self._check()
878
+ if self.done:
879
+ if self._cleanup_error:
880
+ raise self._cleanup_error
881
+ return self._update()
882
+ failures = []
883
+ if self._job is not None:
884
+ self._finish_job(stop=True)
885
+ while self.layers:
886
+ layer = self.current
887
+ if layer.console.channel._failure is not None:
888
+ self._uncertain = True
889
+ try:
890
+ self._release_layer()
891
+ if layer.console.channel._failure is not None:
892
+ self._uncertain = True
893
+ except BaseException as error:
894
+ failures.append(type(error).__name__)
895
+ self.done = True
896
+ try:
897
+ if self._ownership == 'owned':
898
+ self._session.close(strict=True)
899
+ self._reuse = 'closed'
900
+ elif not failures and not self._uncertain:
901
+ self._session.exec_command('true', check=True, command_timeout=3)
902
+ self._reuse = 'confirmed'
903
+ except BaseException as error:
904
+ failures.append(type(error).__name__)
905
+ if failures or self._uncertain:
906
+ self._state, self._reuse = 'failed', 'unknown'
907
+ self._cleanup_error = ConsoleCleanupError('console cleanup or Session recovery unconfirmed')
908
+ raise self._cleanup_error from None
909
+ self._state = 'closed'
910
+ return self._update(consume=not self._submitting)
911
+
912
+ def __enter__(self):
913
+ self._check()
914
+ return self
915
+
916
+ def __exit__(self, exc_type, exc_value, traceback):
917
+ try:
553
918
  self.close()
919
+ except BaseException as cleanup:
920
+ if exc_value is not None:
921
+ raise BaseExceptionGroup('host operation and console cleanup failed',
922
+ [exc_value, cleanup]) from None
923
+ raise
924
+ return False
925
+
554
926
 
555
927
 
556
928
  HELP = '''Input is Python with SSHScript dollar syntax; variables persist between inputs.
@@ -568,9 +940,8 @@ help / help() show this guide; help(object) shows Python object documentation.
568
940
  quit / quit() close all layers. $.exit() is not available.
569
941
  :status shows the environment stack. Ctrl-C cancels current input or execution.
570
942
  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
943
  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.'''
944
+ No input history is persisted. Full-screen programs are not supported.'''
574
945
 
575
946
 
576
947
 
@@ -591,16 +962,16 @@ def main(argv=None):
591
962
  if name not in ('host', 'ask_pass', 'known_hosts_path') and value is not None}
592
963
  if args.host is None and (options or args.ask_pass):
593
964
  parser.error('connection options require a host')
594
- console = InteractiveConsole()
965
+ session = Session()
595
966
  try:
596
- console.open()
597
967
  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()
968
+ session.set(known_hosts_path=args.known_hosts_path)
969
+ with Console(session, ownership='owned', terminal=True) as console:
970
+ if args.ask_pass:
971
+ options['password'] = getpass('SSH password: ')
972
+ if args.host is not None:
973
+ console.connect(args.host, **options)
974
+ console.interact()
604
975
  finally:
605
- console.close()
976
+ session.close(strict=True)
606
977
  return 0
@@ -0,0 +1,71 @@
1
+ from typing import Any, Callable, Literal, TextIO
2
+ from dataclasses import dataclass
3
+ from .session import Session
4
+ from .commandjob import JobResult, CommandJob
5
+ from .commandresult import CommandResult
6
+
7
+ ConsoleState = Literal['ready', 'incomplete', 'running', 'waiting_input', 'completed', 'failed', 'closed']
8
+ SessionReuse = Literal['in_use', 'confirmed', 'unknown', 'closed']
9
+ class CredentialsRequiredError(ValueError): ...
10
+ class ConsoleInitializationError(RuntimeError):
11
+ session_reuse: str
12
+ class ConsoleCleanupError(RuntimeError):
13
+ session_reuse: str
14
+ @dataclass(frozen=True)
15
+ class Environment:
16
+ kind: str
17
+ host: str
18
+ connection_user: str | None
19
+ effective_user: str
20
+ identity_verified: bool
21
+ @dataclass(frozen=True)
22
+ class SwitchRequest:
23
+ kind: str
24
+ username: str | None
25
+ environment: Environment
26
+ credential_supplied: bool
27
+ @dataclass(frozen=True)
28
+ class SwitchCredentials:
29
+ password: str | None
30
+ @dataclass(frozen=True)
31
+ class SwitchOutcome:
32
+ request: SwitchRequest
33
+ success: bool
34
+ environment: Environment
35
+ error_type: str | None = ...
36
+ @dataclass(frozen=True)
37
+ class ConsoleUpdate:
38
+ state: ConsoleState
39
+ stdout: str = ...
40
+ stderr: str = ...
41
+ result: CommandResult[int | None] | None = ...
42
+ error_type: str | None = ...
43
+ session_reuse: SessionReuse = ...
44
+ class Console:
45
+ namespace: dict[str, Any]
46
+ more: bool
47
+ done: bool
48
+ def __init__(self, session: Session, *, ownership: Literal['borrowed', 'owned'] = ..., terminal: bool = ..., get_pty: bool = ..., credential_resolver: Callable[[SwitchRequest], SwitchCredentials | None] | None = ..., switch_observer: Callable[[SwitchOutcome], None] | None = ..., stdout: TextIO | None = ..., stderr: TextIO | None = ..., input_func: Callable[[str], str] | None = ..., password_func: Callable[[str], str] | None = ...) -> None: ...
49
+ @property
50
+ def connection(self) -> Session: ...
51
+ @property
52
+ def environments(self) -> tuple[Environment, ...]: ...
53
+ @property
54
+ def session_reuse(self) -> SessionReuse: ...
55
+ def submit(self, line: str) -> ConsoleUpdate: ...
56
+ def start(self, command: str, *, timeout: float | None = ..., stop_timeout: float = ..., capture_limit: int = ..., check: bool = ...) -> CommandJob: ...
57
+ def start_command(self, command: str, *, timeout: float | None = ..., stop_timeout: float = ..., capture_limit: int = ...) -> ConsoleUpdate: ...
58
+ def poll(self) -> ConsoleUpdate: ...
59
+ def send_input(self, text: str, *, newline: bool = ..., timeout: float = ...) -> ConsoleUpdate: ...
60
+ def interrupt(self) -> ConsoleUpdate: ...
61
+ def eof(self) -> ConsoleUpdate: ...
62
+ def leave(self) -> ConsoleUpdate | None: ...
63
+ def close(self) -> ConsoleUpdate: ...
64
+ def shell(self, *args: Any, **kwargs: Any) -> None: ...
65
+ def su(self, *args: Any, **kwargs: Any) -> None: ...
66
+ def sudo(self, *args: Any, **kwargs: Any) -> None: ...
67
+ def enter(self, *args: Any, **kwargs: Any) -> None: ...
68
+ def connect(self, *args: Any, **kwargs: Any) -> None: ...
69
+ def interact(self) -> None: ...
70
+ def __enter__(self) -> Console: ...
71
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Literal[False]: ...
sshscript/sshscript.py CHANGED
@@ -38,6 +38,7 @@ import warnings
38
38
  def warning_on_one_line(message, category, filename, lineno, file=None, line=None):
39
39
  return '%s:%s: %s: %s\n' % (filename, lineno, category.__name__, message)
40
40
  if __package__:
41
+ from .interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError
41
42
  from ._version import __version__
42
43
  from .sessionsettings import execution_defaults
43
44
  from .session import ScriptResult, Session, CommandResult, CommandJob, CommandTimeoutError, JobResult
@@ -49,6 +50,7 @@ else:
49
50
  ## 2024/8/16, should add mydir into sys.path for python 3.12
50
51
  mydir = os.path.abspath(os.path.dirname(__file__))
51
52
  if not mydir in sys.path: sys.path.insert(0,mydir)
53
+ from interactive import Console, ConsoleUpdate, Environment, SwitchRequest, SwitchCredentials, SwitchOutcome, CredentialsRequiredError, ConsoleInitializationError, ConsoleCleanupError
52
54
  from _version import __version__
53
55
  from sessionsettings import execution_defaults
54
56
  from session import ScriptResult, Session, CommandResult, CommandJob, CommandTimeoutError, JobResult
@@ -285,7 +287,7 @@ def main():
285
287
  import argparse
286
288
 
287
289
  parser = argparse.ArgumentParser(description='SSHScript: automation tools for Subprocess and SSH',
288
- epilog='Interactive preview: sshscript console [host] (console --help for options)')
290
+ epilog='Interactive console: sshscript console [host] (console --help for options)')
289
291
 
290
292
 
291
293
  parser.add_argument('--script','-s', dest='showScript', action='store_true',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshscript
3
- Version: 6.0.0
3
+ Version: 6.1.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
@@ -13,7 +13,7 @@ Project-URL: Issues, https://github.com/iapyeh/sshscript/issues
13
13
  Project-URL: Security, https://github.com/iapyeh/sshscript/security/policy
14
14
  Project-URL: Support, https://github.com/iapyeh/sshscript/discussions
15
15
  Keywords: automation,paramiko,remote-execution,sftp,ssh,subprocess,system-administration
16
- Classifier: Development Status :: 5 - Production/Stable
16
+ Classifier: Development Status :: 4 - Beta
17
17
  Classifier: Operating System :: MacOS
18
18
  Classifier: Operating System :: POSIX :: Linux
19
19
  Classifier: Programming Language :: Python :: 3
@@ -31,7 +31,12 @@ Dynamic: license-file
31
31
 
32
32
  # SSHScript
33
33
 
34
- SSHScript 6.0.0: [interactive console and script completion](CONSOLE.md).
34
+ **Development status:** SSHScript is still under active development. Backward
35
+ compatibility is not maintained at this stage. APIs, CLI behavior, configuration
36
+ and data formats may change; we provide only brief notes on major differences.
37
+ Compatibility work is done only when explicitly requested by the maintainer.
38
+
39
+ SSHScript 6.1.0: [borrow an existing Session for interactive operation](EMBEDDED_CONSOLE.md).
35
40
  Run `python3 -B sshscript.py console` from the development checkout.
36
41
 
37
42
 
@@ -54,10 +59,9 @@ output and status, distinguish failure from timeout, and make cleanup and
54
59
  unknown termination explicit. A successful command exit is evidence about
55
60
  that command; verify the deployment or other application outcome separately.
56
61
 
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)
60
- (Production/Stable) · **Python:** 3.11 or newer · **Tested:** Python
62
+ **Release contract: 6.1.0.** Install the matching distribution and verify its version.
63
+ [Release notes](https://github.com/iapyeh/sshscript/releases/tag/v6.1.0)
64
+ (development-stage project) · **Python:** 3.11 or newer · **Tested:** Python
61
65
  3.11–3.14 on Linux and macOS
62
66
 
63
67
  [Documentation](https://iapyeh.github.io/sshscript/v3a/) ·
@@ -66,13 +70,14 @@ version; this source update does not publish a package or create a release tag.
66
70
  [Security](https://github.com/iapyeh/sshscript/security/policy) ·
67
71
  [Support](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md)
68
72
 
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
73
- select `known_hosts="local"` when migrating existing nested connections.
74
- Upgrade 3.1.5 automation using the migration guide and application regressions.
75
- See [version policy and migration](VERSIONING.md) before upgrading automation.
73
+ **Version 6.1.0** adds public `Console(existing_session)` integration for terminals,
74
+ host applications and AI agents. Python variables, shell state and text programs
75
+ persist; structured updates distinguish readiness, execution, failure and cleanup.
76
+ Borrowed Sessions remain owned by the host. Credential/policy hooks and verified
77
+ Session reuse keep integration explicit. Read the [integration contract](EMBEDDED_CONSOLE.md).
78
+ Earlier 6.0 console internals, open()/execute() entry points and :help/:quit aliases
79
+ are removed; use Console(session), submit(), help/quit and structured updates.
80
+ The 5.0 host-key source and SSH/SFTP login identity contracts remain unchanged.
76
81
 
77
82
  ## AI agent ready
78
83
 
@@ -161,7 +166,7 @@ pin SSHScript and all transitive dependencies in your application's lock file.
161
166
  To install this release explicitly:
162
167
 
163
168
  ```sh
164
- python3 -m pip install /path/to/sshscript-6.0.0-py3-none-any.whl
169
+ python3 -m pip install "sshscript==6.1.0"
165
170
  ```
166
171
 
167
172
  Use `python3 -m pip install --upgrade sshscript` to upgrade. The optional
@@ -170,11 +175,9 @@ it never installs an update by itself.
170
175
 
171
176
  ## 60-second local quickstart
172
177
 
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.
178
+ Install `sshscript==6.1.0` for the Console integration documented here.
176
179
 
177
- This example runs on 6.0.0 and retains compatibility with the 3.1.5 command API.
180
+ This example runs on 6.1.0 and retains compatibility with the 3.1.5 command API.
178
181
  The metadata below records that older compatibility baseline.
179
182
 
180
183
  <!-- example: {"id":"readme-quickstart", "profile":"3.1.5", "stdout":"sshscript is ready\nexit code: 0\n"} -->
@@ -620,4 +623,8 @@ for maintainer-only release steps.
620
623
  SSHScript is released under the
621
624
  [MIT License](https://github.com/iapyeh/sshscript/blob/release/LICENSE.txt).
622
625
 
623
- *Last modified: 2026-10-22 23:24:55*
626
+ For embedding an existing connected Session, see the new public
627
+ [Console integration API](EMBEDDED_CONSOLE.md). CLI and embedding applications
628
+ share one implementation; earlier 6.0 CLI compatibility is not maintained.
629
+
630
+ *Last modified: 2026-10-23 18:39:31*
@@ -1,8 +1,8 @@
1
1
  _sshscript_cli.py,sha256=Z3BwvYFLKJAmy8Wi6xDUqHiIbXT498_uX4WEiguD9n0,576
2
- sshscript/__init__.py,sha256=g-8qM2hSxx9a_OOVnOTCAnTQ4z3uv8rUcF3G7cdG1go,3170
3
- sshscript/__init__.pyi,sha256=rZfxqkAHa3ZewvHzmaxfABTAyZULWIqgBRO6CH9yGeY,864
2
+ sshscript/__init__.py,sha256=w1R2S4jFzAQ6ZcY9hLsTYfw2THUAA_Ie7kRrV_V-WEM,3730
3
+ sshscript/__init__.pyi,sha256=7C-4yfLCoW3PFBcF1LAsoHhmPp7yTdyojUZzy6EsZFU,1231
4
4
  sshscript/_runtime.py,sha256=wJt8XSxK21Ntt-oSUhEF4lN8Nx10H1X8JyIG2ebaPSo,443
5
- sshscript/_version.py,sha256=OenAa_lZfEcAnQebYG3wLxiSbvUUUVTKx4zVuHmbhEw,77
5
+ sshscript/_version.py,sha256=18tqpxL6-3J4vlEKJRxH4VdBZd3mAtpQhnM-Te6HiI4,77
6
6
  sshscript/channelgeneric.py,sha256=-5HdoarpeGwI19wCw1w5qtGPfRv7YLWU3MKbftcts4M,76137
7
7
  sshscript/channelssh.py,sha256=aVdI3KsUoSOujCDLpuI0I3m5YDWZCvIeB8a4J93P7r0,12038
8
8
  sshscript/channelsubprocess.py,sha256=mWPMxFMV6ZMx-VPWPNgtRvOAW9pjRZNtXddl0qY7UQk,12162
@@ -16,7 +16,8 @@ sshscript/dollar.py,sha256=xCesXAU-mj_wVm1-m41Sw_nIZR-Z7Wl3kFnJxQZvFiw,26265
16
16
  sshscript/dollarchanger.py,sha256=BvKqUaPgYmAkXyv_rbmSSbvowneAs_wlYSDm1Hic7PA,31159
17
17
  sshscript/dollarparser.py,sha256=4fELB7lOEyyEteAr962v8liF5BjB_GmdgT5uJEZjEOU,9735
18
18
  sshscript/errorutils.py,sha256=AAZ2kOShZpnrhK25FxC5UiIOj5EjcVTn1fICbV1QDKQ,16114
19
- sshscript/interactive.py,sha256=xXkiEHS2jN8hZ93hTW4QOglsDU1g5olTt9AYLq4UQkY,26422
19
+ sshscript/interactive.py,sha256=sBs_i19Wwi8xy443svSLUl8HFVcUnZdfKvLPRkb57tY,41931
20
+ sshscript/interactive.pyi,sha256=aLcBewStMQYqY4UIKcouvyw7gUl8wLxdyn9D70Vz2Fs,3184
20
21
  sshscript/patching.py,sha256=OM6vjGeP0iyBnzpai5BHn9qqkzJp9375npVb97D06LY,6581
21
22
  sshscript/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
23
  sshscript/scriptcontrol.py,sha256=M2NEMkI8A-fONa8l0oB_2c9p_uunBa6Kff1a1J53vNg,1342
@@ -27,12 +28,12 @@ sshscript/sessionwrapper.py,sha256=hxW6JzNWaObZ216GkmybsUYBJKj-CaxVs3RKuSSSWpM,1
27
28
  sshscript/sessionwrapper.pyi,sha256=wi7r6kz77yO4EY1fumznK9Fs4IIALZm_B2ytltsQGOk,1654
28
29
  sshscript/spyimporter.py,sha256=kdI7lWr1GJO4C_ZJ7qEsx6AuxKfMzeAOr5itkh-9quY,5895
29
30
  sshscript/sshconfig.py,sha256=aPfVOd-bx4k7gBuDeSb55Y812G4QNGeL3sbZPLPdiic,6295
30
- sshscript/sshscript.py,sha256=SFSupMAqknSagp3Ewnmzvt42o2gnehbsDMqKRnn3voo,17292
31
+ sshscript/sshscript.py,sha256=2-tEXDhgw3TumiSaNyxCM6EV7SqYPe55OLpsLkeVqmY,17669
31
32
  sshscript/stdio.py,sha256=Ql_7wX7oJ5vY74uOlCaotwoyqXrNteYdSl-aM4ersVM,16040
32
33
  sshscript/tokenparser.py,sha256=dGHY2s8Twjz0Q-r6nanmAL8VCQxZWqmM6g4KvTgIuzs,22396
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,,
34
+ sshscript-6.1.0.dist-info/licenses/LICENSE.txt,sha256=FPqC-mLQDBkQ26LK_CAUGEUQQ7eyFTbftywlk2GMlls,1075
35
+ sshscript-6.1.0.dist-info/METADATA,sha256=GDaIe5PZBI-Y3j922M3UpvRSOs955CNiwkxyj8aB3hI,30512
36
+ sshscript-6.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
37
+ sshscript-6.1.0.dist-info/entry_points.txt,sha256=JLkqym2UQso2R41RvFz9RAnCKFY8-nnrYgB4ER-zTGo,50
38
+ sshscript-6.1.0.dist-info/top_level.txt,sha256=5d41PAJBhS-f-8aJKJegdhs8BIg_jpigSG83tCQhhfg,25
39
+ sshscript-6.1.0.dist-info/RECORD,,