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