sshscript 3.1.5__py3-none-any.whl → 4.0.2__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_cli.py ADDED
@@ -0,0 +1,19 @@
1
+ """Minimal console bootstrap; must remain parseable on older Python."""
2
+ import sys
3
+
4
+
5
+ def main():
6
+ # Importing a package submodule executes __init__ first, so this check
7
+ # must be independent of the package's runtime guard.
8
+ if sys.version_info < (3, 11):
9
+ raise SystemExit(
10
+ "SSHScript requires Python 3.11 or newer.\n"
11
+ "Detected Python %s at %s."
12
+ % ('.'.join(map(str, sys.version_info[:3])), sys.executable)
13
+ )
14
+ from sshscript.sshscript import main as run
15
+ return run()
16
+
17
+
18
+ if __name__ == '__main__':
19
+ main()
sshscript/__init__.py CHANGED
@@ -35,6 +35,13 @@ Use run_file(path) for a script file, run_script(source) for source text,
35
35
  and spy_imports() to temporarily enable Python imports of .spy modules.
36
36
  """
37
37
 
38
+ if __package__:
39
+ from ._runtime import require_python as _require_python
40
+ else:
41
+ from _runtime import require_python as _require_python
42
+
43
+ _require_python()
44
+
38
45
  if __package__:
39
46
  from ._version import __version__
40
47
  from . import sshscript
@@ -53,6 +60,9 @@ check_file = sshscript.check_file
53
60
  run_script = sshscript.run_script
54
61
  Session = session.Session
55
62
  CommandResult = session.CommandResult
63
+ CommandJob = session.CommandJob
64
+ CommandTimeoutError = session.CommandTimeoutError
65
+ JobResult = session.JobResult
56
66
 
57
67
  set_logger = errorutils.set_logger
58
68
  get_logger = errorutils.get_logger
@@ -79,6 +89,9 @@ __all__ = [
79
89
  'run_script',
80
90
  'Session',
81
91
  'CommandResult',
92
+ 'CommandJob',
93
+ 'CommandTimeoutError',
94
+ 'JobResult',
82
95
  'get_logger',
83
96
  'set_logger',
84
97
  'spy_imports',
sshscript/__init__.pyi ADDED
@@ -0,0 +1,15 @@
1
+ from typing import Any, ContextManager
2
+ from os import PathLike
3
+ from .session import Session as Session
4
+ from .commandresult import CommandResult as CommandResult
5
+ from .commandjob import CommandJob as CommandJob, JobResult as JobResult, CommandTimeoutError as CommandTimeoutError
6
+ from .errorutils import SSHScriptException as SSHScriptException
7
+
8
+ __version__: str
9
+
10
+ def check_file(script_path: str | PathLike[str]) -> int: ...
11
+ def run_file(script_path: str | PathLike[str], vars: dict[str, Any] | None = ..., showScript: bool = ...) -> int: ...
12
+ def run_script(script: str, varGlobals: dict[str, Any] | None = ..., showScript: bool = ...) -> dict[str, Any]: ...
13
+ def spy_imports() -> ContextManager[None]: ...
14
+ def get_logger() -> Any: ...
15
+ def set_logger(userlogger: Any = ..., logname: str | None = ...) -> Any: ...
sshscript/_runtime.py ADDED
@@ -0,0 +1,14 @@
1
+ """Reject unsupported interpreters before importing SSHScript features."""
2
+ import sys
3
+
4
+
5
+ def require_python(cli=False):
6
+ if sys.version_info < (3, 11):
7
+ message = (
8
+ "SSHScript requires Python 3.11 or newer.\n"
9
+ "Detected Python %s at %s."
10
+ % ('.'.join(map(str, sys.version_info[:3])), sys.executable)
11
+ )
12
+ if cli:
13
+ raise SystemExit(message)
14
+ raise ImportError(message)
sshscript/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Single source of the SSHScript package version."""
2
2
 
3
- __version__ = "3.1.5"
3
+ __version__ = "4.0.2"
@@ -18,6 +18,7 @@
18
18
 
19
19
  import threading, os, sys, re
20
20
  import time
21
+ import uuid
21
22
  import logging
22
23
  import paramiko
23
24
  import asyncio
@@ -67,6 +68,7 @@ class GenericChannel(object):
67
68
 
68
69
  ## an instance of Dollar
69
70
  self.owner = owner
71
+ self.logger = getattr(owner, "logger", logger)
70
72
 
71
73
  ## see hijack() for details
72
74
  self.hijacked = False
@@ -80,12 +82,13 @@ class GenericChannel(object):
80
82
  ## 如果沒有,則送出一個dumyEcho
81
83
  #self._exitcode_session_id = 0
82
84
  #self._command_session_id = 0
83
- if os.environ.get('VERBOSE'):
85
+ settings = getattr(owner, 'settings', {})
86
+ if settings.get('verbose', bool(os.environ.get('VERBOSE'))):
84
87
  ## verbose-related
85
88
  self.dump2sys = (1,1)
86
89
  self.stdoutPrefix = os.environ.get('VERBOSE_STDOUT_PREFIX','🟩').encode('utf8')
87
90
  self.stderrPrefix = os.environ.get('VERBOSE_STDERR_PREFIX','🟨').encode('utf8')
88
- elif os.environ.get('VERBOSE_STDERR'):
91
+ elif settings.get('verbose_stderr', bool(os.environ.get('VERBOSE_STDERR'))):
89
92
  self.dump2sys = (0,1)
90
93
  self.stdoutPrefix = b''
91
94
  self.stderrPrefix = os.environ.get('VERBOSE_STDERR_PREFIX','🟨').encode('utf8')
@@ -141,6 +144,8 @@ class GenericChannel(object):
141
144
  self._send_call_lock = threading.Lock()
142
145
  self._async_send_lock = None
143
146
  self.send_timeout = 30
147
+ self._console_job = None
148
+ self._console_job_lock = threading.Lock()
144
149
  self.command_timeout = 60
145
150
 
146
151
  @property
@@ -286,7 +291,7 @@ class GenericChannel(object):
286
291
  basetime = self.lastIOAtTime[:]
287
292
  timeouttime = (time.time() + timeout) if timeout else 0
288
293
  ret = True
289
- logger.debug(
294
+ self.logger.debug(
290
295
  '%s Waiting for channel output (timeout=%s, silent=%s)',
291
296
  self.prefixOfLog,
292
297
  timeout or None,
@@ -314,7 +319,7 @@ class GenericChannel(object):
314
319
  else:
315
320
  raise TimeoutError(f'wait_for_output exceeded {timeout}')
316
321
 
317
- logger.debug(
322
+ self.logger.debug(
318
323
  '%s Channel output wait completed (received=%s)',
319
324
  self.prefixOfLog,
320
325
  ret,
@@ -395,7 +400,7 @@ class GenericChannel(object):
395
400
  """Return the recorded status, fetching it if needed; -1 while interactive."""
396
401
  if self.hijacked:
397
402
  #raise ValueError('exitcode is not available in current state')
398
- logger.warning(
403
+ self.logger.warning(
399
404
  '%s Exit status is unavailable while the channel is in interactive mode',
400
405
  self.prefixOfLog,
401
406
  )
@@ -410,7 +415,7 @@ class GenericChannel(object):
410
415
  """Log with channel context, preserving format arguments and logging keywords."""
411
416
  if isinstance(level, int) and 0 < level < logging.DEBUG:
412
417
  level = logging.DEBUG
413
- logger.log(level, f'{self.prefixOfLog} {msg}', *args, **kwargs)
418
+ getattr(self, 'logger', logger).log(level, f'{self.prefixOfLog} {msg}', *args, **kwargs)
414
419
 
415
420
  def expect(
416
421
  self,
@@ -1401,7 +1406,7 @@ class GenericChannel(object):
1401
1406
 
1402
1407
  :text: Text to send as input
1403
1408
  """
1404
- logger.debug(
1409
+ self.logger.debug(
1405
1410
  '%s Sending channel input (length=%d, interactive=%s)',
1406
1411
  self.prefixOfLog,
1407
1412
  len(text),
@@ -1482,6 +1487,11 @@ class GenericChannel(object):
1482
1487
  )
1483
1488
 
1484
1489
  def input(self, text, timeout=60):
1490
+ """Append one newline and wait for interactive readiness, exit, or silence.
1491
+
1492
+ Existing newlines are retained. Use send() to write exact text instead.
1493
+ A readiness/silence status does not establish command success.
1494
+ """
1485
1495
  if not isinstance(text, str):
1486
1496
  raise TypeError('input text must be str')
1487
1497
 
@@ -1724,6 +1734,8 @@ class GenericChannel(object):
1724
1734
  stdout = None
1725
1735
  stderr = None
1726
1736
  completed = threading.Event()
1737
+ stderr_completed = threading.Event()
1738
+ stderr_marker = None
1727
1739
 
1728
1740
  try:
1729
1741
  self._exitcode = EXITCODE_DEFAULT
@@ -1754,9 +1766,16 @@ class GenericChannel(object):
1754
1766
  '-@_@-',
1755
1767
  )
1756
1768
 
1769
+ # Separate pipe readers can observe stdout completion before
1770
+ # earlier stderr has been drained. Fence both streams before
1771
+ # returning a completed command's buffers to the public wrapper.
1772
+ trailer = 'echo _TT$?_-@_@-'
1773
+ if not self.owner.get_pty:
1774
+ stderr_marker = '-@stderr-' + uuid.uuid4().hex + '\n'
1775
+ stderr.set_callback(stderr_completed.set, stderr_marker)
1776
+ trailer += "; printf '%s\\n' " + repr(stderr_marker.rstrip('\n')) + ' >&2'
1757
1777
  self.send(
1758
- f'{{ {command}; }} ; '
1759
- 'echo _TT$?_-@_@-\n',
1778
+ f'{{ {command}; }} ; ' + trailer + '\n',
1760
1779
  timeout=self._remaining(deadline),
1761
1780
  )
1762
1781
 
@@ -1794,9 +1813,12 @@ class GenericChannel(object):
1794
1813
  'command completion',
1795
1814
  )
1796
1815
 
1816
+ if stderr_marker is not None:
1817
+ self._wait_event(stderr_completed, deadline, 'stderr completion')
1818
+
1797
1819
  if not self.prompt:
1798
1820
  match = re.compile(
1799
- r'_TT(\d+)_'
1821
+ r'_TT(\d+)_\r?\n?$'
1800
1822
  ).search(str(stdout))
1801
1823
 
1802
1824
  if match is None:
@@ -1826,7 +1848,7 @@ class GenericChannel(object):
1826
1848
 
1827
1849
  :return: Tuple of (stdout, stderr)
1828
1850
  """
1829
- logger.debug(
1851
+ self.logger.debug(
1830
1852
  '%s Dispatching command (interactive=%s, expectations=%d) %s',
1831
1853
  self.prefixOfLog,
1832
1854
  bool(self.prompt),
@@ -1874,7 +1896,7 @@ class GenericChannel(object):
1874
1896
  if m:
1875
1897
  self._exitcode = int(m.group(1))
1876
1898
  else:
1877
- logger.warning(
1899
+ self.logger.warning(
1878
1900
  '%s Unable to parse command exit status',
1879
1901
  self.prefixOfLog,
1880
1902
  )
@@ -1891,7 +1913,7 @@ class GenericChannel(object):
1891
1913
  ## so that when next line is getting $.exitcode, it would get the corrent exitcode
1892
1914
  ## not the exitcode of our modified command.
1893
1915
  while self.executing_lock.locked(): time.sleep(0.01)
1894
- logger.debug(
1916
+ self.logger.debug(
1895
1917
  '%s Command completed (interactive=%s, exit_status=%s)',
1896
1918
  self.prefixOfLog,
1897
1919
  bool(self.prompt),
@@ -1921,7 +1943,7 @@ class GenericChannel(object):
1921
1943
  if self.closed: return
1922
1944
  self.touchIO(True)
1923
1945
  try:
1924
- self._stdout.append(newbytes.decode('utf8'),True)
1946
+ self._stdout.append(self._console_job._decoders[0].decode(newbytes) if self._console_job is not None else newbytes.decode('utf8'),True)
1925
1947
  except UnicodeDecodeError:
1926
1948
  self._stdout.append(newbytes.decode('utf8','replace'),True)
1927
1949
  if self.dump2sys[0]:
@@ -1938,7 +1960,7 @@ class GenericChannel(object):
1938
1960
  if self.closed: return
1939
1961
  self.touchIO(True)
1940
1962
  try:
1941
- self._stderr.append(newbytes.decode('utf8'),True)
1963
+ self._stderr.append(self._console_job._decoders[1].decode(newbytes) if self._console_job is not None else newbytes.decode('utf8'),True)
1942
1964
  except UnicodeDecodeError:
1943
1965
  self._stderr.append(newbytes.decode('utf8','replace'),True)
1944
1966
  if self.dump2sys[1]:
@@ -2000,13 +2022,23 @@ class GenericChannel(object):
2000
2022
  await handler[x](newbytes)
2001
2023
  self._dumpBuf.clear()
2002
2024
  except Exception:
2003
- logger.exception('%s Channel output forwarding failed', self.prefixOfLog)
2025
+ self.logger.exception('%s Channel output forwarding failed', self.prefixOfLog)
2004
2026
  raise
2005
2027
  async def _dump_stdout_err(self):
2006
2028
  handler = [self._dump_stdout,self._dump_stderr]
2007
2029
  for x,newbytes in self._dumpBuf:
2008
2030
  await handler[x](newbytes)
2009
2031
  self._dumpBuf.clear()
2032
+ # One-shot completion must also forward a final unterminated line.
2033
+ for name, stream, prefix in (
2034
+ ('_stdoutDumpBuf', sys.stdout, self.stdoutPrefix),
2035
+ ('_stderrDumpBuf', sys.stderr, self.stderrPrefix),
2036
+ ):
2037
+ pending = getattr(self, name)
2038
+ if pending:
2039
+ stream.buffer.write(prefix + self._native_id.encode() + b':' + pending)
2040
+ stream.buffer.flush()
2041
+ setattr(self, name, b'')
2010
2042
 
2011
2043
  def reset_buffer(self,reason=None):
2012
2044
  """Replace the current stdout/stderr pair; return (new_pair, previous_pair)."""
sshscript/channelssh.py CHANGED
@@ -36,7 +36,8 @@ class ParamikoChannel(object):
36
36
  def __init__(self,sshchannel,get_pty,channel=None):
37
37
  if not isinstance(sshchannel, SSHChannel):
38
38
  raise TypeError('sshchannel must be SSHChannel')
39
- self.sshchannel = sshchannel
39
+ self.sshchannel = sshchannel
40
+ self.logger = sshchannel.logger
40
41
  self.get_pty = get_pty
41
42
  self.suspending = False
42
43
  if channel is None:
@@ -78,7 +79,7 @@ class ParamikoChannel(object):
78
79
 
79
80
  ## two-dollars has no self.command
80
81
  if self.sshchannel.owner.command:
81
- logger.debug(
82
+ self.logger.debug(
82
83
  '[SSHChannel] Executing remote command (host=%s, pty=%s)',
83
84
  self.sshchannel.owner.session.host,
84
85
  self.get_pty,
@@ -118,7 +119,7 @@ class ParamikoChannel(object):
118
119
  except (SSHException,ValueError):
119
120
  if self.channel.closed or self.channel.exit_status_ready():
120
121
  break
121
- logger.exception(
122
+ self.logger.exception(
122
123
  '[SSHChannel] SSH channel read failed (host=%s)',
123
124
  self.sshchannel.owner.session.host,
124
125
  )
@@ -126,7 +127,7 @@ class ParamikoChannel(object):
126
127
  await asyncio.sleep(0.1)
127
128
  except OSError as e:
128
129
  if e.errno != errno.EIO and not self.channel.closed:
129
- logger.exception(
130
+ self.logger.exception(
130
131
  '[SSHChannel] SSH channel reader failed (host=%s)',
131
132
  self.sshchannel.owner.session.host,
132
133
  )
@@ -166,7 +167,7 @@ class ParamikoChannel(object):
166
167
  command result is kept separately from the SSH channel exit status.
167
168
  """
168
169
  host = self.sshchannel.owner.session.host
169
- logger.debug('[SSHChannel] Closing SSH channel (host=%s)', host)
170
+ self.logger.debug('[SSHChannel] Closing SSH channel (host=%s)', host)
170
171
  ## automatically send exit to shell
171
172
  try:
172
173
  if not self.channel.exit_status_ready():
@@ -176,7 +177,7 @@ class ParamikoChannel(object):
176
177
  deadline = time.monotonic() + timeout_seconds
177
178
  while True:
178
179
  if time.monotonic() > deadline:
179
- logger.warning(
180
+ self.logger.warning(
180
181
  '[SSHChannel] Exit status was not received before close timeout; '
181
182
  'forcing channel close (host=%s, timeout=%ss)',
182
183
  host,
@@ -191,7 +192,7 @@ class ParamikoChannel(object):
191
192
  else:
192
193
  time.sleep(0.1)
193
194
  except (SSHException, OSError):
194
- logger.exception('[SSHChannel] Failed to close SSH channel (host=%s)', host)
195
+ self.logger.exception('[SSHChannel] Failed to close SSH channel (host=%s)', host)
195
196
  raise
196
197
  finally:
197
198
  self.channel.close()
@@ -189,7 +189,7 @@ class POpenChannel(GenericChannel):
189
189
  for key, mask in events:
190
190
  # key.fileobj 是原始的 pipe 物件
191
191
  # key.data 是我們剛才註冊的自定義字串
192
- await callback[key.fileobj](os.read(key.fileobj,1024))
192
+ await callback[key.fileobj](os.read(key.fileobj, 65536 if self._console_job is not None else 1024))
193
193
  await asyncio.sleep(interval)
194
194
  except OSError as e:
195
195
  ## when subprocess exited, the file descriptor would be closed, and os.read() would raise OSError with errno.EIO
@@ -200,7 +200,7 @@ class POpenChannel(GenericChannel):
200
200
  sel.close()
201
201
  POpenChannel.count += 1
202
202
  await _reading()
203
- logger.debug(
203
+ self.logger.debug(
204
204
  '[POpen] Subprocess reader stopped (pid=%s, returncode=%s)',
205
205
  self.cp.pid,
206
206
  self.cp.poll(),
@@ -248,7 +248,7 @@ class POpenChannel(GenericChannel):
248
248
  pid = self.cp.pid
249
249
  if self.cp.poll() is None:
250
250
  timeout = 2
251
- logger.debug(
251
+ self.logger.debug(
252
252
  '[POpen] Waiting for subprocess to exit (pid=%s, timeout=%ss)',
253
253
  pid,
254
254
  timeout,
@@ -256,7 +256,7 @@ class POpenChannel(GenericChannel):
256
256
  try:
257
257
  self.cp.wait(timeout)
258
258
  except subprocess.TimeoutExpired:
259
- logger.warning(
259
+ self.logger.warning(
260
260
  '[POpen] Subprocess did not exit before close timeout; '
261
261
  'terminating (pid=%s, timeout=%ss)',
262
262
  pid,
@@ -266,7 +266,7 @@ class POpenChannel(GenericChannel):
266
266
  try:
267
267
  self.cp.wait(timeout)
268
268
  except subprocess.TimeoutExpired:
269
- logger.warning(
269
+ self.logger.warning(
270
270
  '[POpen] Subprocess did not terminate before timeout; '
271
271
  'killing (pid=%s, timeout=%ss)',
272
272
  pid,
@@ -284,7 +284,7 @@ class POpenChannel(GenericChannel):
284
284
  else:
285
285
  fd.close()
286
286
 
287
- logger.debug(
287
+ self.logger.debug(
288
288
  '[POpen] Subprocess channel closed (pid=%s, returncode=%s)',
289
289
  pid,
290
290
  self.cp.poll(),