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 +3 -0
- sshscript/__init__.pyi +2 -0
- sshscript/_version.py +1 -1
- sshscript/interactive.py +565 -194
- sshscript/interactive.pyi +71 -0
- sshscript/sshscript.py +3 -1
- {sshscript-6.0.0.dist-info → sshscript-6.1.0.dist-info}/METADATA +27 -20
- {sshscript-6.0.0.dist-info → sshscript-6.1.0.dist-info}/RECORD +12 -11
- {sshscript-6.0.0.dist-info → sshscript-6.1.0.dist-info}/WHEEL +0 -0
- {sshscript-6.0.0.dist-info → sshscript-6.1.0.dist-info}/entry_points.txt +0 -0
- {sshscript-6.0.0.dist-info → sshscript-6.1.0.dist-info}/licenses/LICENSE.txt +0 -0
- {sshscript-6.0.0.dist-info → sshscript-6.1.0.dist-info}/top_level.txt +0 -0
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
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
470
|
-
except
|
|
471
|
-
|
|
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
|
|
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(
|
|
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
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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.
|
|
718
|
+
return self.send_input(line)
|
|
545
719
|
except KeyboardInterrupt:
|
|
546
|
-
self.
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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
|
-
|
|
965
|
+
session = Session()
|
|
595
966
|
try:
|
|
596
|
-
console.open()
|
|
597
967
|
if args.known_hosts_path is not None:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 ::
|
|
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
|
|
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
|
-
**
|
|
58
|
-
|
|
59
|
-
**
|
|
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.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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=
|
|
3
|
-
sshscript/__init__.pyi,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
34
|
-
sshscript-6.
|
|
35
|
-
sshscript-6.
|
|
36
|
-
sshscript-6.
|
|
37
|
-
sshscript-6.
|
|
38
|
-
sshscript-6.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|