sshscript 3.1.4__py3-none-any.whl → 3.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sshscript/__init__.py CHANGED
@@ -26,9 +26,9 @@ Example::
26
26
  from sshscript import Session
27
27
 
28
28
  with Session() as local:
29
- stdout, stderr = local("hostname")
29
+ stdout, stderr, exitcode = local("hostname")
30
30
  with local.connect("user@host") as remote:
31
- stdout, stderr = remote("uname -s")
31
+ stdout, stderr, exitcode = remote("uname -s")
32
32
  print(stdout.strip())
33
33
 
34
34
  Use run_file(path) for a script file, run_script(source) for source text,
@@ -49,8 +49,10 @@ else:
49
49
  from spyimporter import spy_imports
50
50
 
51
51
  run_file = sshscript.run_file
52
+ check_file = sshscript.check_file
52
53
  run_script = sshscript.run_script
53
54
  Session = session.Session
55
+ CommandResult = session.CommandResult
54
56
 
55
57
  set_logger = errorutils.set_logger
56
58
  get_logger = errorutils.get_logger
@@ -73,8 +75,10 @@ SSHScriptException = errorutils.SSHScriptException
73
75
 
74
76
  __all__ = [
75
77
  'run_file',
78
+ 'check_file',
76
79
  'run_script',
77
80
  'Session',
81
+ 'CommandResult',
78
82
  'get_logger',
79
83
  'set_logger',
80
84
  'spy_imports',
sshscript/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Single source of the SSHScript package version."""
2
2
 
3
- __version__ = "3.1.4"
3
+ __version__ = "3.1.5"
@@ -0,0 +1,28 @@
1
+ """Immutable snapshots of completed commands."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class CommandResult:
7
+ """One completed command, with text output and elapsed monotonic seconds.
8
+
9
+ Three-value unpacking/indexing yields ``stdout, stderr, exitcode``.
10
+ Output is a text snapshot, not a live/consuming console buffer.
11
+ host snapshots Session.host (None for a local session).
12
+ """
13
+
14
+ stdout: str
15
+ stderr: str
16
+ exitcode: int
17
+ host: str | None
18
+ duration: float
19
+ command: str | tuple[str, ...]
20
+
21
+ def __iter__(self):
22
+ return iter((self.stdout, self.stderr, self.exitcode))
23
+
24
+ def __len__(self):
25
+ return 3
26
+
27
+ def __getitem__(self, index):
28
+ return (self.stdout, self.stderr, self.exitcode)[index]
sshscript/dollar.py CHANGED
@@ -66,6 +66,7 @@ class Dollar(object):
66
66
  self.for_with = for_with
67
67
 
68
68
  self.command = command
69
+ self.argv = None
69
70
  self.session = session # Session instance in context
70
71
  self.channel = None
71
72
  self.use_shell = use_shell
@@ -461,7 +462,7 @@ class Dollar(object):
461
462
  shell_executable = self.shell_executable or '/bin/sh'
462
463
  cpargs = [shell_executable,'-c',self.command]
463
464
  else:
464
- cpargs = shlex.split(self.command)
465
+ cpargs = list(self.argv) if self.argv is not None else shlex.split(self.command)
465
466
  summary = command_summary(cpargs)
466
467
  kw['text'] = False
467
468
  ## with_pty is always False
@@ -536,7 +537,7 @@ class Dollar(object):
536
537
  summary = command_summary([shell_executable])
537
538
  argc = None
538
539
  else:
539
- argv = shlex.split(self.command)
540
+ argv = self.argv if self.argv is not None else shlex.split(self.command)
540
541
  command = 'exec ' + ' '.join(shlex.quote(str(arg)) for arg in argv)
541
542
  summary = command_summary(argv)
542
543
  argc = summary['arg_count']
@@ -544,7 +544,7 @@ class DollarChanger(ast.NodeTransformer):
544
544
  ## upgrade "try" block one level
545
545
  if isinstance(originNode.value,ast.Try):
546
546
  newnode = originNode.value
547
- ## eg. stdout,stderr = $hostname
547
+ ## eg. stdout,stderr,exitcode = $hostname
548
548
  originNode.value = self._template(self.tmplLineAssignAtBottom.value, node)
549
549
  ## append original assignment to last statement
550
550
  newnode.finalbody.append(originNode)
sshscript/dollarparser.py CHANGED
@@ -145,6 +145,10 @@ def parse(script_path, spyscript):
145
145
  token_script = tokenparser.convert(spyscript)
146
146
  except tokenize.TokenError as error:
147
147
  raise _token_syntax_error(error, script_path, spyscript) from None
148
+ except SyntaxError as error:
149
+ # tokenize also raises IndentationError/TabError with '<tokenize>' as
150
+ # filename; keep these diagnostics attached to the actual .spy file.
151
+ raise _source_syntax_error(error, script_path, spyscript) from None
148
152
 
149
153
  try:
150
154
  tree = ast.parse(token_script, filename=script_path)
sshscript/session.py CHANGED
@@ -37,8 +37,11 @@ import asyncio
37
37
  import warnings
38
38
  import subprocess
39
39
  import socket
40
+ import shlex
40
41
  from select import select
41
42
  if __package__:
43
+ from .commandresult import CommandResult
44
+ from .sshconfig import resolve_connection
42
45
  from .dollar import Dollar
43
46
  from .sessionwrapper import SessionWrapper,SudoConsole,SuConsole
44
47
  from .errorutils import get_logger, SSHScriptExit, SSHScriptBreak, SSHScriptException, dumpScript, listRightIndex
@@ -46,6 +49,8 @@ if __package__:
46
49
  from . import dollarparser
47
50
 
48
51
  else:
52
+ from commandresult import CommandResult
53
+ from sshconfig import resolve_connection
49
54
  ## called directly from the same folder
50
55
  ## see above "try" block for details
51
56
  from dollar import Dollar
@@ -277,7 +282,8 @@ class Session(object):
277
282
  successful block, or attached as a note to an exception from the block. A
278
283
  local session remains reusable.
279
284
 
280
- session(command) aliases exec_command() and returns (stdout, stderr).
285
+ session(command) aliases exec_command() and returns a CommandResult that
286
+ supports three-value stdout/stderr/exitcode unpacking.
281
287
  Read session.exitcode for the last command status. Use shell() when commands
282
288
  must share a working directory or environment, enter() for interactive
283
289
  programs, and sudo()/su() for a console under another user.
@@ -285,9 +291,9 @@ class Session(object):
285
291
  Example::
286
292
 
287
293
  with Session() as local:
288
- stdout, stderr = local("hostname")
294
+ stdout, stderr, exitcode = local("hostname")
289
295
  with local.connect("user@host") as remote:
290
- stdout, stderr = remote("uname -s")
296
+ stdout, stderr, exitcode = remote("uname -s")
291
297
 
292
298
  In .spy files, $command executes in the active session or console, and
293
299
  $.connect(), $.shell(), $.enter(), $.upload(), and $.download() provide the
@@ -349,6 +355,7 @@ class Session(object):
349
355
  ## this value was stored, so user can access its stdout, stderr and exitcode
350
356
  ## by self.stdout and self.stderr, self.exitcode
351
357
  self._lastDollar = None
358
+ self.last_result = None
352
359
  self._attached_stack = None
353
360
  ## added in v2.0.3
354
361
  ## always return the session which is not connected to execute commands by subprocess at localhost
@@ -566,7 +573,7 @@ class Session(object):
566
573
  raise SSHScriptExit(message,code)
567
574
 
568
575
  @export2Dollar
569
- def connect(self,host,username=None,password=None,port=22,policy=None,**kw):
576
+ def connect(self,host,username=None,password=None,port=None,policy=None,*,ssh_config=None,**kw):
570
577
  """Return a connected child session, optionally through this session's SSH link.
571
578
 
572
579
  Use with session.connect("user@host") as remote to scope the connection.
@@ -575,6 +582,10 @@ class Session(object):
575
582
 
576
583
  host may include the username; connect("user@host", password) is supported.
577
584
  pkey_path loads an RSA key from this session's host (local for a new Session).
585
+ Local connections read ~/.ssh/config by default; ssh_config=False opts
586
+ out, and a path selects a config file. Explicit parameters win. Nested
587
+ connections skip local config unless a path is explicitly supplied.
588
+ resolve_connection() previews effective settings without connecting.
578
589
  Other connection keywords are forwarded to SSHClient.connect().
579
590
 
580
591
  Unknown and changed host keys are rejected by default. policy may explicitly
@@ -588,13 +599,26 @@ class Session(object):
588
599
  raise RuntimeError('cannot connect from a closed session')
589
600
 
590
601
  ## host might be in format of "username@hostname"
591
- if '@' in host:
602
+ if isinstance(host, str) and '@' in host:
592
603
  if username and password is None:
593
604
  password = username
594
- username,host = host.split('@')
605
+ username = None
606
+ embedded_username,host = host.rsplit('@', 1)
607
+ if username is None:
608
+ username = embedded_username
595
609
 
596
- has_proxy = 'proxyCommand' in kw
597
610
  is_nested = self._client is not None
611
+ effective = resolve_connection(
612
+ host, username, port,
613
+ ssh_config=False if is_nested and ssh_config is None else ssh_config,
614
+ **kw,
615
+ )
616
+ host = effective.pop('hostname')
617
+ username = effective.pop('username')
618
+ port = effective.pop('port')
619
+ kw = effective
620
+
621
+ has_proxy = 'proxyCommand' in kw
598
622
  logger.debug(
599
623
  'Opening SSH connection (host=%s, port=%s, username=%s, nested=%s, proxy=%s)',
600
624
  host,
@@ -720,6 +744,8 @@ class Session(object):
720
744
  )
721
745
  raise
722
746
 
747
+ resolve_connection = staticmethod(resolve_connection)
748
+
723
749
  ## alias of connect, would be removed later
724
750
  @export2Dollar
725
751
  def open(self,*args,**kw):
@@ -1013,14 +1039,19 @@ class Session(object):
1013
1039
  if self._lastDollar: self._lastDollar.clear()
1014
1040
 
1015
1041
 
1016
- def exec_command(self,cmd:str,*,shell=None,shell_executable=None,
1042
+ def exec_command(self,cmd,*,shell=None,shell_executable=None,check=False,
1017
1043
  _legacy_twodollars=False,**kw):
1018
- """Execute one command and return (stdout, stderr); also available as session(cmd).
1044
+ """Execute one command and return a CommandResult; also session(cmd).
1019
1045
 
1020
1046
  Wait for completion on this session's host. stdout, stderr, and exitcode
1021
1047
  remain available on the session as the latest command result. Check exitcode
1022
- for command failure; execution/transport errors propagate. Local execution
1023
- also accepts check=True to raise for a nonzero command status.
1048
+ for command failure; execution/transport errors propagate. check=True
1049
+ raises CalledProcessError on either backend, after saving last_result.
1050
+ Unpacking yields stdout and stderr text snapshots plus the exit code.
1051
+
1052
+ cmd is a command string or a nonempty list/tuple of string arguments.
1053
+ Argument sequences bypass shell detection and require shell=None/False.
1054
+ Remote sequences are quoted for a POSIX login shell; they are not batches.
1024
1055
 
1025
1056
  shell=None automatically detects shell syntax. shell=False forces direct
1026
1057
  execution; shell=True uses a POSIX shell; shell="bash" selects a named shell.
@@ -1032,11 +1063,31 @@ class Session(object):
1032
1063
 
1033
1064
  Example::
1034
1065
 
1035
- stdout, stderr = session("cat", input="hello")
1066
+ stdout, stderr, exitcode = session("cat", input="hello")
1036
1067
  status = session.exitcode
1037
1068
  """
1038
- if not isinstance(cmd,str):
1039
- raise TypeError(f'command must be str, not {type(cmd).__name__}')
1069
+ if self.closed:
1070
+ raise RuntimeError('cannot execute on a closed session')
1071
+ if self._client is not None and not self.connected:
1072
+ raise BrokenPipeError('SSH transport is not active')
1073
+ if not isinstance(check, bool):
1074
+ raise TypeError('check must be bool')
1075
+ argv = None
1076
+ if isinstance(cmd, (list, tuple)):
1077
+ if not cmd:
1078
+ raise ValueError('argument sequence must not be empty')
1079
+ if not all(isinstance(arg, str) for arg in cmd):
1080
+ raise TypeError('every command argument must be str')
1081
+ if not cmd[0] or any('\x00' in arg for arg in cmd):
1082
+ raise ValueError('executable must be nonempty and arguments cannot contain NUL')
1083
+ if ((shell is not None and shell is not False)
1084
+ or shell_executable is not None or _legacy_twodollars):
1085
+ raise ValueError('argument sequences require shell=None or shell=False')
1086
+ argv = tuple(cmd)
1087
+ cmd = shlex.join(argv)
1088
+ shell = False
1089
+ elif not isinstance(cmd,str):
1090
+ raise TypeError(f'command must be str, list, or tuple, not {type(cmd).__name__}')
1040
1091
  cmd = cmd.strip()
1041
1092
  if not cmd:
1042
1093
  raise ValueError('command must not be empty')
@@ -1057,7 +1108,7 @@ class Session(object):
1057
1108
  )
1058
1109
  shell = True
1059
1110
 
1060
- self._lastDollar = Dollar(
1111
+ execution = Dollar(
1061
1112
  self,
1062
1113
  cmd,
1063
1114
  for_with=False,
@@ -1065,8 +1116,26 @@ class Session(object):
1065
1116
  shell_executable=shell_executable,
1066
1117
  **kw,
1067
1118
  )
1068
- self._lastDollar()
1069
- return self._lastDollar.stdout,self._lastDollar.stderr
1119
+ # Keep actual argv for local exec; the SSH protocol only carries strings.
1120
+ execution.argv = argv
1121
+ self._lastDollar = execution
1122
+ self.last_result = None
1123
+ host = self.host
1124
+ started = time.monotonic()
1125
+ execution()
1126
+ result = CommandResult(
1127
+ str(execution.stdout), str(execution.stderr), execution.exitcode,
1128
+ host, time.monotonic() - started, argv if argv is not None else cmd,
1129
+ )
1130
+ self.last_result = result
1131
+ if check and result.exitcode != 0:
1132
+ error = subprocess.CalledProcessError(
1133
+ result.exitcode, result.command,
1134
+ output=result.stdout, stderr=result.stderr,
1135
+ )
1136
+ error.result = result
1137
+ raise error
1138
+ return result
1070
1139
 
1071
1140
  ## Compatibility aliases for code generated by older parsers.
1072
1141
  def onedollar(self,cmd,**kw):
sshscript/sshconfig.py ADDED
@@ -0,0 +1,127 @@
1
+ """Resolve a documented subset of OpenSSH config without opening connections.
2
+
3
+ Match/Include/canonicalization are rejected, rather than partially interpreted.
4
+ Proxy commands are only started by Session.connect(), never by this resolver.
5
+ """
6
+ import getpass
7
+ import os
8
+ import re
9
+ import shlex
10
+ from urllib.parse import urlsplit
11
+ import warnings
12
+
13
+ import paramiko
14
+
15
+
16
+ class _RawConfig(paramiko.SSHConfig):
17
+ def _expand_variables(self, config, target_hostname):
18
+ # Expansion must happen after explicit API overrides are applied.
19
+ return config
20
+
21
+
22
+ def _expand(value, tokens):
23
+ def substitute(match):
24
+ token = match.group(0)
25
+ if token not in tokens:
26
+ raise ValueError(f'unsupported SSH config token: {token}')
27
+ return str(tokens[token])
28
+ return re.sub(r'%[%A-Za-z]', substitute, value)
29
+
30
+
31
+ def resolve_connection(host, username=None, port=None, *, ssh_config=None, **options):
32
+ """Return effective Paramiko kwargs and proxyCommand without connecting.
33
+
34
+ None reads ~/.ssh/config if it exists; False disables config; a path is
35
+ required to exist. Explicit arguments override config. Only local callers
36
+ should opt into reading local config for an already nested SSH connection.
37
+ """
38
+ if not isinstance(host, str):
39
+ raise TypeError('host must be str')
40
+ if '@' in host:
41
+ user, host = host.rsplit('@', 1)
42
+ if username is None:
43
+ username = user
44
+ if not host or host.startswith('-') or any(c.isspace() or c == '\x00' for c in host):
45
+ raise ValueError('host must be a nonempty hostname or address')
46
+ alias = host
47
+ config = {}
48
+ path = None
49
+ if ssh_config is not False:
50
+ if ssh_config is not None and not isinstance(ssh_config, (str, os.PathLike)):
51
+ raise TypeError('ssh_config must be a path, None, or False')
52
+ path = os.path.expanduser(os.fspath(ssh_config) if ssh_config is not None else '~/.ssh/config')
53
+ try:
54
+ with open(path, encoding='utf-8') as stream:
55
+ source = stream.read()
56
+ except FileNotFoundError:
57
+ if ssh_config is not None:
58
+ raise
59
+ else:
60
+ # Paramiko can execute Match exec during lookup. Reject before parse.
61
+ for line in source.splitlines():
62
+ match = re.match(r'\s*([\w]+)(?:\s|=|$)', line)
63
+ if match and match[1].lower() in ('match', 'include', 'canonicalizehostname'):
64
+ raise NotImplementedError(f'SSH config directive {match[1]} is not supported')
65
+ parser = _RawConfig.from_text(source)
66
+ config = parser.lookup(alias)
67
+ supported = {'hostname', 'user', 'port', 'identityfile', 'proxycommand', 'proxyjump'}
68
+ unsupported = sorted(set(config) - supported)
69
+ if unsupported:
70
+ warnings.warn('SSH config options not applied: ' + ', '.join(unsupported),
71
+ UserWarning, stacklevel=2)
72
+ host = _expand(config.get('hostname', alias), {'%h': alias, '%n': alias, '%%': '%'})
73
+ username = username if username is not None else config.get('user')
74
+ if username is not None and (not isinstance(username, str) or not username
75
+ or any(c.isspace() or c == '\x00' for c in username)):
76
+ raise ValueError('username must be a nonempty string without whitespace')
77
+ port = port if port is not None else int(config.get('port', 22))
78
+ if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
79
+ raise ValueError('port must be an integer between 1 and 65535')
80
+ if not host or host.startswith('-') or any(c.isspace() or c in '\x00%' for c in host):
81
+ raise ValueError('resolved HostName must be a hostname or address')
82
+ tokens = {'%%': '%', '%h': host, '%n': alias, '%p': port,
83
+ '%r': username or getpass.getuser(), '%u': getpass.getuser(),
84
+ '%d': os.path.expanduser('~')}
85
+ if not any(key in options for key in ('key_filename', 'pkey', 'pkey_path')):
86
+ keys = config.get('identityfile', [])
87
+ if keys:
88
+ options['key_filename'] = [os.path.expanduser(_expand(key, tokens)) for key in keys if key.lower() != 'none']
89
+
90
+ explicit_proxy = 'proxyCommand' in options or 'proxyJump' in options
91
+ command = options.pop('proxyCommand', None)
92
+ jump = options.pop('proxyJump', None)
93
+ if not explicit_proxy:
94
+ command, jump = config.get('proxycommand'), config.get('proxyjump')
95
+ if command is not None and not isinstance(command, str):
96
+ raise TypeError('proxyCommand must be a string or None')
97
+ if jump is not None and not isinstance(jump, str):
98
+ raise TypeError('proxyJump must be a string or None')
99
+ if command and command.lower() == 'none':
100
+ command = None
101
+ if jump and jump.lower() == 'none':
102
+ jump = None
103
+ if command and jump:
104
+ raise ValueError('ProxyCommand and ProxyJump cannot both be active; override one explicitly')
105
+ if command:
106
+ # Explicit proxyCommand retains its historical verbatim semantics.
107
+ options['proxyCommand'] = command if explicit_proxy else _expand(command, tokens)
108
+ elif jump:
109
+ jumps = jump.split(',')
110
+ if any(not item or any(c.isspace() or c in '\x00%' for c in item) for item in jumps):
111
+ raise ValueError('invalid ProxyJump chain')
112
+ endpoint = urlsplit('ssh://' + jumps[-1])
113
+ if not endpoint.hostname or endpoint.hostname.startswith('-') or endpoint.password or endpoint.path or endpoint.query or endpoint.fragment:
114
+ raise ValueError('ProxyJump must use [user@]host[:port]')
115
+ # OpenSSH manages hop authentication; Paramiko still verifies the target.
116
+ args = ['ssh', '-F', path if path and os.path.isfile(path) else os.devnull,
117
+ '-o', 'StrictHostKeyChecking=yes', '-o', 'BatchMode=yes',
118
+ '-W', f'[{host}]:{port}']
119
+ if len(jumps) > 1:
120
+ args.extend(['-J', ','.join(jumps[:-1])])
121
+ if endpoint.port is not None:
122
+ args.extend(['-p', str(endpoint.port)])
123
+ if endpoint.username is not None:
124
+ args.extend(['-l', endpoint.username])
125
+ args.append(endpoint.hostname)
126
+ options['proxyCommand'] = shlex.join(args)
127
+ return dict(options, hostname=host, username=username, port=port)
sshscript/sshscript.py CHANGED
@@ -15,8 +15,8 @@
15
15
  # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16
16
  """Public script runners and command-line entry point.
17
17
 
18
- For commands in ordinary Python, use Session: session(command) returns
19
- (stdout, stderr), and session.connect(host) creates a remote child session.
18
+ For commands in ordinary Python, use Session: session(command) returns a
19
+ CommandResult supporting stdout/stderr/exitcode unpacking; connect(host) creates a child.
20
20
  run_file(path) executes one file; run_script(source) returns its namespace.
21
21
  Importing this module does not install .spy import hooks or console logging.
22
22
  """
@@ -32,18 +32,20 @@ def warning_on_one_line(message, category, filename, lineno, file=None, line=Non
32
32
  return '%s:%s: %s: %s\n' % (filename, lineno, category.__name__, message)
33
33
  if __package__:
34
34
  from ._version import __version__
35
- from .session import Session
35
+ from .session import Session, CommandResult
36
36
  from .errorutils import SSHScriptExit, SSHScriptBreak, get_logger, set_logger, SSHScriptException,command_summary
37
37
  ## 2025/3/3 v2.0.3 feature: import *.spy file directly
38
38
  from . import spyimporter
39
+ from . import dollarparser
39
40
  else:
40
41
  ## 2024/8/16, should add mydir into sys.path for python 3.12
41
42
  mydir = os.path.abspath(os.path.dirname(__file__))
42
43
  if not mydir in sys.path: sys.path.insert(0,mydir)
43
44
  from _version import __version__
44
- from session import Session
45
+ from session import Session, CommandResult
45
46
  from errorutils import SSHScriptExit, SSHScriptBreak, get_logger, set_logger, SSHScriptException,command_summary
46
47
  import spyimporter
48
+ import dollarparser
47
49
  ## 2024/8/16, should remove mydir out of sys.path for python 3.11
48
50
  if mydir == sys.path[0]: del sys.path[0]
49
51
 
@@ -55,6 +57,21 @@ sshscript_module = sys.modules[__package__ or __name__]
55
57
  logger = get_logger()
56
58
  spy_imports = spyimporter.spy_imports
57
59
 
60
+ def check_file(script_path):
61
+ """Compile one Python/.spy source file without executing it or its imports.
62
+
63
+ Return 0 on success. SyntaxError retains the original filename and line.
64
+ This checks Python/dollar syntax, not shell syntax or remote availability.
65
+ """
66
+ import tokenize
67
+ if not isinstance(script_path, (str, os.PathLike)):
68
+ raise TypeError('script_path must be str or os.PathLike')
69
+ path = os.path.abspath(os.fspath(script_path))
70
+ with tokenize.open(path) as stream:
71
+ source = stream.read()
72
+ dollarparser.compile_spy(path, source)
73
+ return 0
74
+
58
75
  def run_file(
59
76
  script_path,
60
77
  vars=None,
@@ -283,9 +300,11 @@ def main():
283
300
  help='show the installed version')
284
301
 
285
302
  ## new on v2.0.2
286
- parser.add_argument('--check-updates', '--check', dest='checkversion',
303
+ parser.add_argument('--check-updates', dest='checkversion',
287
304
  action='store_true', default=False,
288
305
  help='check PyPI for a newer compatible stable release (requires internet)')
306
+ parser.add_argument('--check', action='store_true',
307
+ help='check file syntax without execution; without a file, legacy update check')
289
308
 
290
309
  ## new on v3.1.0
291
310
  parser.add_argument(
@@ -329,7 +348,7 @@ def main():
329
348
  ## handling starts
330
349
  if (args.version):
331
350
  print(get_current_version())
332
- elif (args.checkversion):
351
+ elif args.checkversion or (args.check and not args.path):
333
352
  sys.exit(_check_updates())
334
353
 
335
354
  elif args.path:
@@ -346,10 +365,17 @@ def main():
346
365
  os.environ['VERBOSE_STDERR'] = '1'
347
366
 
348
367
  try:
349
- exitcode = run_file(
350
- args.path,
351
- showScript=args.showScript,
352
- )
368
+ if args.check:
369
+ if unknown:
370
+ parser.error('--check accepts exactly one file and no script arguments')
371
+ if args.showScript:
372
+ parser.error('--check and --script cannot be combined')
373
+ exitcode = check_file(args.path)
374
+ else:
375
+ exitcode = run_file(
376
+ args.path,
377
+ showScript=args.showScript,
378
+ )
353
379
  except SSHScriptExit as e:
354
380
  sys.exit(e.errno)
355
381
  except SSHScriptException as e:
@@ -0,0 +1,375 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshscript
3
+ Version: 3.1.5
4
+ Summary: Python automation for local processes, SSH, and SSHScript .spy files
5
+ Author-email: "Yeh, Hsin-Yuan" <iapyeh@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/iapyeh/sshscript
8
+ Project-URL: Documentation, https://iapyeh.github.io/sshscript/v3a/
9
+ Project-URL: Changelog, https://github.com/iapyeh/sshscript/blob/release/CHANGELOG.md
10
+ Project-URL: Releases, https://github.com/iapyeh/sshscript/releases
11
+ Project-URL: Source, https://github.com/iapyeh/sshscript
12
+ Project-URL: Issues, https://github.com/iapyeh/sshscript/issues
13
+ Project-URL: Security, https://github.com/iapyeh/sshscript/security/policy
14
+ Project-URL: Support, https://github.com/iapyeh/sshscript/discussions
15
+ Keywords: automation,paramiko,remote-execution,sftp,ssh,subprocess,system-administration
16
+ Classifier: Development Status :: 5 - Production/Stable
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE.txt
28
+ Requires-Dist: paramiko<5,>=2.11
29
+ Requires-Dist: packaging>=21
30
+ Dynamic: license-file
31
+
32
+ # SSHScript
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/sshscript)](https://pypi.org/project/sshscript/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/sshscript)](https://pypi.org/project/sshscript/)
36
+ [![CI](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml/badge.svg?branch=release)](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml)
37
+ [![CodeQL](https://github.com/iapyeh/sshscript/actions/workflows/codeql.yml/badge.svg?branch=release)](https://github.com/iapyeh/sshscript/actions/workflows/codeql.yml)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/iapyeh/sshscript/blob/release/LICENSE.txt)
39
+
40
+ SSHScript is a Python automation library for running commands locally and over
41
+ SSH through one `Session` API. It also provides optional dollar syntax for
42
+ compact `.spy` automation files.
43
+
44
+ **Current release:** [3.1.5](https://github.com/iapyeh/sshscript/releases/tag/v3.1.5)
45
+ (Production/Stable) · **Python:** 3.11 or newer · **Tested:** Python
46
+ 3.11–3.14 on Linux and macOS
47
+
48
+ [Documentation](https://iapyeh.github.io/sshscript/v3a/) ·
49
+ [PyPI](https://pypi.org/project/sshscript/) ·
50
+ [Changelog](https://github.com/iapyeh/sshscript/blob/release/CHANGELOG.md) ·
51
+ [Security](https://github.com/iapyeh/sshscript/security/policy) ·
52
+ [Support](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md)
53
+
54
+ ## Why SSHScript?
55
+
56
+ - Use the same interface for local subprocesses and remote SSH commands.
57
+ - Traverse nested SSH connections without rebuilding connection logic.
58
+ - Keep ordinary Python functions, packages, exceptions, data processing, and
59
+ threading around your automation.
60
+ - Scope connections, privilege changes, persistent shells, and interactive
61
+ programs with context managers.
62
+ - Read stdout, stderr, and exit status directly after each command.
63
+ - Add concise dollar syntax only where command-shaped notation improves a
64
+ script.
65
+
66
+ ## Install
67
+
68
+ SSHScript requires Python 3.11 or newer. Installing in a virtual environment is
69
+ recommended:
70
+
71
+ ```sh
72
+ python3 -m venv .venv
73
+ . .venv/bin/activate
74
+ python3 -m pip install --upgrade pip
75
+ python3 -m pip install sshscript
76
+ sshscript --version
77
+ ```
78
+
79
+ For a production deployment that requires repeatable dependency resolution,
80
+ pin SSHScript and all transitive dependencies in your application's lock file.
81
+ To install this release explicitly:
82
+
83
+ ```sh
84
+ python3 -m pip install "sshscript==3.1.5"
85
+ ```
86
+
87
+ Use `python3 -m pip install --upgrade sshscript` to upgrade. The optional
88
+ `sshscript --check-updates` command queries PyPI and prints an upgrade command;
89
+ it never installs an update by itself.
90
+
91
+ ## 60-second local quickstart
92
+
93
+ ```python
94
+ import shlex
95
+ import sys
96
+
97
+ from sshscript import Session
98
+
99
+ command = shlex.join([
100
+ sys.executable,
101
+ "-c",
102
+ "print('sshscript is ready')",
103
+ ])
104
+
105
+ with Session() as local:
106
+ stdout, stderr, exitcode = local.exec_command(
107
+ command,
108
+ shell=False,
109
+ check=True,
110
+ )
111
+ print(str(stdout).strip())
112
+ print(f"exit code: {local.exitcode}")
113
+ ```
114
+
115
+ Expected output:
116
+
117
+ ```text
118
+ sshscript is ready
119
+ exit code: 0
120
+ ```
121
+
122
+ `exec_command()` accepts one nonempty command string or a nonempty list/tuple
123
+ of string arguments. An argument sequence means **one command**, not a batch.
124
+ Sequences execute directly on the local host and are quoted for a POSIX login
125
+ shell over SSH; shell operators inside them are literal arguments. They accept
126
+ only `shell=None` or `shell=False`, without `shell_executable`. Use a string
127
+ with `shell=True` when you intentionally need shell operators.
128
+
129
+ ```python
130
+ with Session() as local:
131
+ result = local.exec_command(
132
+ [sys.executable, "-c", "import sys; print(sys.argv[1])", "a; b"],
133
+ check=True,
134
+ timeout=30,
135
+ )
136
+ print(result.stdout, result.exitcode, result.duration)
137
+ stdout, stderr, exitcode = result # three-value unpacking
138
+ ```
139
+
140
+ Each call returns an immutable `CommandResult` containing text `stdout` and
141
+ `stderr`, `exitcode`, `host`, `duration`, and `command`. `host` snapshots
142
+ `session.host` (`None` locally); it does not execute `hostname`. `duration` is
143
+ elapsed monotonic seconds for command execution and output collection,
144
+ including communication and worker cleanup but excluding connection setup.
145
+ `command` is the normalized string or an immutable tuple of arguments.
146
+ `session.last_result` references the most recently completed command; saved
147
+ results remain valid after later commands or session closure. Validation
148
+ failures leave it alone; starting a command clears it until completion.
149
+
150
+ Both local and remote commands accept `check=True` to raise
151
+ `subprocess.CalledProcessError` for a nonzero status **after** preserving the
152
+ result. The exception has text `stdout`/`stderr`, the normalized `cmd`, and a
153
+ `result` attribute. With the default `check=False`, a nonzero status is result
154
+ data. Connection errors and timeouts propagate unchanged.
155
+
156
+ Compatibility: the returned object is no longer a tuple of live output
157
+ buffers. Unpack exactly three values: stdout, stderr, exitcode. Indexing and
158
+ slicing use that same three-value order; old two-value unpacking must change.
159
+ Use `session.stdout`/`session.stderr` for the existing buffer interface.
160
+ Persistent shell and interactive console APIs retain their existing buffer
161
+ and prompt semantics; this result/check contract applies to one-shot Session
162
+ commands, including `$` commands outside persistent consoles.
163
+
164
+ ## First secure SSH connection
165
+
166
+ SSHScript uses Paramiko and verifies system host keys by default. Before the
167
+ first connection, place the server key in the account's standard
168
+ `known_hosts` file and verify its fingerprint through an independent trusted
169
+ channel. Prefer an SSH agent, managed private key, or secret manager over a
170
+ password embedded in source code.
171
+
172
+ ```python
173
+ from sshscript import Session
174
+
175
+ with Session() as local:
176
+ with local.connect(
177
+ "ops@example.net",
178
+ timeout=30,
179
+ banner_timeout=30,
180
+ auth_timeout=30,
181
+ ) as remote:
182
+ result = remote.exec_command(
183
+ ["uname", "-s"],
184
+ check=True,
185
+ timeout=30,
186
+ )
187
+ print(result.stdout.strip())
188
+ ```
189
+
190
+ Unknown and changed host keys are rejected unless the caller explicitly
191
+ supplies a different Paramiko policy. Do not use automatic key acceptance in a
192
+ production workflow unless a separate trusted bootstrap process has already
193
+ verified the key. See the
194
+ [SSHScript v3 documentation](https://iapyeh.github.io/sshscript/v3a/) for
195
+ nested connections, timeouts, file transfer, `sudo`, `su`, and interactive
196
+ programs.
197
+
198
+ ## Reusing SSH configuration
199
+
200
+ Connections from a local session read `~/.ssh/config` if it exists. Supported
201
+ settings are `Host` patterns, `HostName`, `User`, `Port`, `IdentityFile`,
202
+ `ProxyCommand`, and `ProxyJump`. Explicit API arguments override config, then
203
+ built-in defaults apply. `port=None` means unspecified; an explicit `port=22`
204
+ overrides a configured port. Explicit `pkey`, `pkey_path`, or `key_filename`
205
+ overrides configured identity files. Host-key verification remains enabled.
206
+
207
+ ```python
208
+ # Inspect effective settings without connecting or starting a proxy process.
209
+ settings = Session.resolve_connection("production", port=2222)
210
+
211
+ with Session() as local:
212
+ with local.connect("production") as remote:
213
+ result = remote(["uname", "-s"], check=True)
214
+ ```
215
+
216
+ Pass `ssh_config=False` to disable config lookup, or `ssh_config="/path/config"`
217
+ to require a particular file. Nested connections do not read local config
218
+ unless an explicit file is supplied; proxy options remain unsupported on
219
+ nested sessions. `session.host` and `result.host` use the resolved HostName.
220
+ `resolve_connection()` returns effective Paramiko keyword arguments plus
221
+ `proxyCommand`, when applicable; it performs no network operations.
222
+
223
+ Config tokens `%h`, `%n`, `%p`, `%r`, `%u`, `%d`, and `%%` are expanded after
224
+ explicit overrides for identity paths and configured proxy commands. Other
225
+ tokens fail clearly. Explicit `proxyCommand` strings retain their historical
226
+ verbatim behavior. Explicit `proxyCommand=None` disables configured proxies.
227
+ You can also supply `proxyJump="user@bastion:2222"` or a comma-separated chain.
228
+ When both configured proxy types are active, select one explicitly or remove
229
+ the conflict; SSHScript does not implement OpenSSH's first-proxy-wins rule.
230
+
231
+ `ProxyJump` uses the local `ssh` executable to forward to the target, with
232
+ batch authentication and strict host-key checks on jump hosts. It requires
233
+ known host keys and noninteractive authentication for those hops. The target
234
+ connection remains managed and verified by Paramiko. A custom config file is
235
+ also passed to `ssh`; otherwise its user config is used when present.
236
+
237
+ This is a subset of OpenSSH configuration. `Match`, `Include`, and hostname
238
+ canonicalization are rejected before lookup; other unapplied options produce
239
+ a warning. In particular, alternate known-hosts files and identity-agent
240
+ settings are not imported. Treat config and proxy commands as trusted local
241
+ inputs; connecting may execute configured proxy programs.
242
+
243
+ ## Optional dollar syntax
244
+
245
+ Dollar syntax is not ordinary Python syntax. It is normally stored in `.spy`
246
+ files; `run_script(source)` also accepts Dollar syntax from an in-memory string.
247
+ Both forms use the same session and transport implementation as the module API:
248
+
249
+ ```python
250
+ # health.spy
251
+ $hostname
252
+ if $.exitcode != 0:
253
+ raise RuntimeError("hostname failed")
254
+ print($.stdout.strip())
255
+
256
+ with $.connect("ops@example.net"):
257
+ $uname -s
258
+ if $.exitcode != 0:
259
+ raise RuntimeError("remote uname failed")
260
+ ```
261
+
262
+ Run exactly one file with:
263
+
264
+ ```sh
265
+ sshscript health.spy
266
+ ```
267
+
268
+ Check one file without executing its Python, imports, or commands:
269
+
270
+ ```sh
271
+ sshscript --check health.spy
272
+ ```
273
+
274
+ The exit status is 0 for valid syntax, 1 for a syntax/read failure, and 2 for
275
+ invalid CLI usage. Syntax diagnostics show the original file, line, source,
276
+ and caret. `sshscript.check_file(path)` provides the same compile-only check
277
+ and raises source-located `SyntaxError` or filesystem errors. It validates
278
+ Python/dollar syntax, not shell commands, imported modules, or remote hosts.
279
+ `--script` remains available to inspect generated Python without execution.
280
+ For compatibility, **`--check` without a file still checks PyPI for updates**;
281
+ use `--check-updates` explicitly for that purpose.
282
+
283
+ In version 3, a single `$` supports direct commands and shell features such as
284
+ pipelines and redirection. The old `$$` form is retained for compatibility but
285
+ is deprecated. New applications should start with the regular Python module
286
+ API and adopt dollar syntax only when its notation is useful.
287
+
288
+ The CLI and `run_file()` execute one file. Directories, globs, and multiple
289
+ paths are intentionally unsupported. Importing SSHScript does not globally
290
+ enable imports of `.spy` modules; use the temporary `sshscript.spy_imports()`
291
+ context manager when a regular Python program needs that behavior.
292
+
293
+ ## Security model
294
+
295
+ SSHScript executes commands and Python code; it is not a sandbox. Treat every
296
+ `.spy` file, Python module, command string, remote host, and command output as a
297
+ trust boundary. In particular:
298
+
299
+ - never run unreviewed automation with production credentials;
300
+ - avoid shell interpolation of external data;
301
+ - keep credentials, private keys, inventories, and captured production output
302
+ out of source control;
303
+ - verify host keys independently and retain timeouts around network operations;
304
+ - validate site-specific PAM, `sudoers`, shell, and network policy in a
305
+ disposable environment before rollout.
306
+
307
+ See the [security policy](https://github.com/iapyeh/sshscript/security/policy)
308
+ for the complete reporting and security model, and the
309
+ [stable exception contract](https://github.com/iapyeh/sshscript/blob/release/EXCEPTIONS.md)
310
+ for runtime behavior.
311
+
312
+ ## Release confidence and provenance
313
+
314
+ The 3.1 release line uses the following public controls:
315
+
316
+ - CI on Linux and macOS with Python 3.11, 3.12, 3.13, and 3.14;
317
+ - normal and optimized-mode tests, syntax smoke tests, compile checks, and a
318
+ runtime-assertion gate;
319
+ - disposable loopback OpenSSH integration tests for host keys, SFTP, PTY,
320
+ `sudo`/`su`, and timeout behavior;
321
+ - CodeQL and Dependabot;
322
+ - PyPI Trusted Publishing with short-lived OIDC credentials;
323
+ - GitHub build-provenance attestations and a SHA-256 `verified.json` manifest
324
+ attached to the GitHub Release.
325
+
326
+ Download distributions from [PyPI](https://pypi.org/project/sshscript/) or the
327
+ [GitHub Release](https://github.com/iapyeh/sshscript/releases/tag/v3.1.5), not
328
+ from unverified mirrors. These controls establish tested behavior, artifact
329
+ integrity, and release provenance; they are not a substitute for reviewing the
330
+ automation you run or for validating your production environment.
331
+
332
+ ## Compatibility and support
333
+
334
+ | Component | Current policy |
335
+ | --- | --- |
336
+ | SSHScript | Latest 3.1.x receives bug and security fixes |
337
+ | Python | 3.11–3.14 are continuously tested |
338
+ | Platforms | Current Linux and macOS releases |
339
+ | SSH | OpenSSH integration is tested on Ubuntu; other servers are best effort |
340
+ | Paramiko | Runtime dependency is `>=2.11,<5`; CI resolves a compatible release |
341
+ | Windows | Not currently tested or supported |
342
+
343
+ See the [support policy](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md)
344
+ for scope, support channels, and the information needed in a useful bug report.
345
+
346
+ ## Development and verification
347
+
348
+ A release checkout uses the `src/sshscript/` package layout:
349
+
350
+ ```sh
351
+ python3 -m pip install 'paramiko>=2.11,<5' 'packaging>=21' build twine
352
+ python3 tools/run_checks.py
353
+ python3 tools/check_release.py --output /tmp/sshscript-candidate-UNIQUE
354
+ ```
355
+
356
+ The output directory must not already exist. `run_checks.py` locates the test
357
+ suite under `src/sshscript/unittest/` automatically. See the
358
+ [contributing guide](https://github.com/iapyeh/sshscript/blob/release/CONTRIBUTING.md)
359
+ before proposing a change and the
360
+ [release guide](https://github.com/iapyeh/sshscript/blob/release/RELEASING.md)
361
+ for maintainer-only release steps.
362
+
363
+ ## Project policies
364
+
365
+ - [Changelog](https://github.com/iapyeh/sshscript/blob/release/CHANGELOG.md)
366
+ - [Support policy](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md)
367
+ - [Security policy](https://github.com/iapyeh/sshscript/security/policy)
368
+ - [Stable exception contract](https://github.com/iapyeh/sshscript/blob/release/EXCEPTIONS.md)
369
+ - [Contributing guide](https://github.com/iapyeh/sshscript/blob/release/CONTRIBUTING.md)
370
+ - [Code of Conduct](https://github.com/iapyeh/sshscript/blob/release/CODE_OF_CONDUCT.md)
371
+
372
+ ## License
373
+
374
+ SSHScript is released under the
375
+ [MIT License](https://github.com/iapyeh/sshscript/blob/release/LICENSE.txt).
@@ -0,0 +1,25 @@
1
+ sshscript/__init__.py,sha256=yynfg2qm6lJ74Gwcx0ab06e-6Uf9lyEIuR9vabQGERA,2779
2
+ sshscript/_version.py,sha256=QGa2cIG9obcBYmqJvXxzRZUypKmn6yVFUqspO2RjWx4,77
3
+ sshscript/channelgeneric.py,sha256=3kUfggHKx8uasZqYVas7YnwhoIrxcEwEgSn-2lItzxg,73834
4
+ sshscript/channelssh.py,sha256=Obco0J0J1PwUpyVKoWluVn_hGpUw5WrC51SPmuN1tIM,11969
5
+ sshscript/channelsubprocess.py,sha256=hgFgDYeba-GQjlOp8sV0d7xl1imLyDDIEVxPh-49-7w,12092
6
+ sshscript/channelutils.py,sha256=cIH-OcU4EiUMGBNw9r0LI7Z2BSN6zhwdOo7cnxFa4ws,19609
7
+ sshscript/commandresult.py,sha256=0U9t9HglcEpCyehPw-MlkUg-rImSYyFZyHSzJJ8nUlQ,772
8
+ sshscript/dollar.py,sha256=Ei1EiMZTvf6IUe9_e2vsb2do7rta-CoBOaJxqfUq3jU,24711
9
+ sshscript/dollarchanger.py,sha256=mwG9xCJEI6yvH7s_SRebqnfMG5koeqIL7lfnLemhSkQ,30368
10
+ sshscript/dollarparser.py,sha256=7cl4QgffzfueeGOPN-NeoLP_3bKm04I9cVtCyag7JcE,9638
11
+ sshscript/errorutils.py,sha256=MgwYoPipEVCa9L33FmVoYXl77CD-Mrq958q_ft3cuvw,16386
12
+ sshscript/patching.py,sha256=OXBRkAJpy2mC0aswqrhEhlgR5-Zs4K4f93rVcaLVwDg,6488
13
+ sshscript/session.py,sha256=EzKr12APSH27unYmQlqQC7sj95SuEc5jkwdG0vNxATE,61610
14
+ sshscript/sessionwrapper.py,sha256=r8_Q3JkmNSqVXjSeVgClk58bPpPghOVyWS9tnBhdObw,12731
15
+ sshscript/spyimporter.py,sha256=ASAs2nPBU1mtpI7FquXYJctLFQR_PeGMnhjcw1G6LPg,5574
16
+ sshscript/sshconfig.py,sha256=aPfVOd-bx4k7gBuDeSb55Y812G4QNGeL3sbZPLPdiic,6295
17
+ sshscript/sshscript.py,sha256=O4YevYCfKSeBnvyU2hMi6Gao9YcPyULxdR0ge-5p7J4,16489
18
+ sshscript/stdio.py,sha256=Ql_7wX7oJ5vY74uOlCaotwoyqXrNteYdSl-aM4ersVM,16040
19
+ sshscript/tokenparser.py,sha256=9cYokDMXLSnwimZMkkuxH7S8PimhrKZJXLeOfqE5rNc,20053
20
+ sshscript-3.1.5.dist-info/licenses/LICENSE.txt,sha256=FPqC-mLQDBkQ26LK_CAUGEUQQ7eyFTbftywlk2GMlls,1075
21
+ sshscript-3.1.5.dist-info/METADATA,sha256=UM-JZ_tGI6bgf4yh4OUPTP8Nmo60H8hcq1b0kNnDTP8,16065
22
+ sshscript-3.1.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
23
+ sshscript-3.1.5.dist-info/entry_points.txt,sha256=6fcLx-kJz2y_kehCAYQi7NqEuiflzEXiOlCl_E9j9uQ,55
24
+ sshscript-3.1.5.dist-info/top_level.txt,sha256=Y-dzSWzuCbcKE8qSC2fvXTZulaSZVI7qk1lQhq1meMI,10
25
+ sshscript-3.1.5.dist-info/RECORD,,
@@ -1,178 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: sshscript
3
- Version: 3.1.4
4
- Summary: Python automation for local processes, SSH, and SSHScript .spy files
5
- Author-email: "Yeh, Hsin-Yuan" <iapyeh@gmail.com>
6
- License-Expression: MIT
7
- Project-URL: Documentation, https://iapyeh.github.io/sshscript/v3a/
8
- Project-URL: Source, https://github.com/iapyeh/sshscript
9
- Project-URL: Issues, https://github.com/iapyeh/sshscript/issues
10
- Classifier: Development Status :: 5 - Production/Stable
11
- Classifier: Operating System :: MacOS
12
- Classifier: Operating System :: POSIX :: Linux
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.11
15
- Classifier: Programming Language :: Python :: 3.12
16
- Classifier: Programming Language :: Python :: 3.13
17
- Classifier: Programming Language :: Python :: 3.14
18
- Classifier: Topic :: System :: Systems Administration
19
- Requires-Python: >=3.11
20
- Description-Content-Type: text/markdown
21
- License-File: LICENSE.txt
22
- Requires-Dist: paramiko<5,>=2.11
23
- Requires-Dist: packaging>=21
24
- Dynamic: license-file
25
-
26
- # SSHScript
27
-
28
- [![CI](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml/badge.svg?branch=release)](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml)
29
- [![CodeQL](https://github.com/iapyeh/sshscript/actions/workflows/codeql.yml/badge.svg?branch=release)](https://github.com/iapyeh/sshscript/actions/workflows/codeql.yml)
30
-
31
- SSHScript is a Python automation library and `.spy` script runner for executing
32
- commands locally or over SSH. It provides a regular Python `Session` API and a
33
- compact dollar syntax for automation scripts.
34
-
35
- ## Installation
36
-
37
- SSHScript requires Python 3.11 or newer.
38
-
39
- Use `sshscript --version` to display the installed version, or
40
- `sshscript --check-updates` to query PyPI for a newer stable release compatible
41
- with the current Python version. The check only prints an upgrade command;
42
- it does not install anything. `--check` remains an alias. A failed query exits
43
- with status 1; a successful check exits with status 0, whether an update exists
44
- or not. This checks release Python requirements, not dependency resolution or
45
- platform availability.
46
-
47
- ```sh
48
- python3 -m pip install sshscript
49
- ```
50
-
51
- For a release checkout containing `src/sshscript/`, `python3 -m pip install .`
52
- installs that checkout. The flat development checkout is not an installable
53
- package; use the following checks instead:
54
-
55
- ```sh
56
- python3 -m pip install 'paramiko>=2.11,<5' 'packaging>=21' build twine
57
- python3 tools/run_checks.py
58
- python3 tools/check_release.py --output /tmp/sshscript-candidate-UNIQUE
59
- ```
60
-
61
- See [RELEASING.md](RELEASING.md) for synchronization and publishing. SSHScript
62
- 3.1 is the supported production line; a GitHub source update does not by itself
63
- publish a new version to PyPI. See the
64
- [v3.1 documentation](https://iapyeh.github.io/sshscript/v3a/).
65
-
66
- ## Python API
67
-
68
- ```python
69
- from sshscript import Session
70
-
71
- session = Session()
72
- try:
73
- stdout, stderr = session.exec_command("uname -a", shell=False)
74
- print(str(stdout))
75
- print(session.exitcode)
76
- finally:
77
- session.close(strict=True)
78
- ```
79
-
80
- Pass command arguments as a safely quoted string, for example with
81
- `shlex.join()`, and use `shell=False` when shell expansion is not required.
82
-
83
- ## Running `.spy` files
84
-
85
- ```sh
86
- sshscript automation.spy
87
- ```
88
-
89
- The CLI and `run_file()` execute exactly one file. Directories, globs, and
90
- multiple paths are intentionally unsupported. Compose larger automation with
91
- SSHScript include syntax or ordinary Python imports.
92
-
93
- Importing SSHScript does not globally enable Python imports of `.spy` files.
94
- Use the explicit, temporary importer when a regular Python program needs one:
95
-
96
- ```python
97
- import sshscript
98
-
99
- with sshscript.spy_imports():
100
- import automation # loads automation.spy
101
- ```
102
-
103
- `run_file()` enables this importer only for the duration of the script, so
104
- imports between `.spy` files continue to work without additional setup.
105
- Threads created with `threading.Thread(...)` inside a `.spy` file inherit the
106
- session that is active when the thread is constructed, without patching the
107
- process-wide `threading.Thread` class.
108
-
109
- ## SSH host-key security
110
-
111
- SSH connections verify system host keys by default and reject unknown or
112
- changed keys. Load the server key into `known_hosts` before connecting.
113
-
114
- Accepting a new key without verification must be an explicit decision:
115
-
116
- ```python
117
- import paramiko
118
-
119
- remote = session.connect(
120
- "user@new-host.example",
121
- policy=paramiko.AutoAddPolicy(),
122
- )
123
- ```
124
-
125
- Do this only in a trusted bootstrap environment. Interactive SSH sessions do
126
- not forward the complete local process environment; only terminal/locale
127
- defaults and values explicitly supplied through `env={...}` are sent.
128
-
129
- ## Tests
130
-
131
- The canonical credential-free release gate is:
132
-
133
- ```sh
134
- python3 tools/run_checks.py
135
- ```
136
-
137
- It includes normal and optimized unit tests, compile checks, the package assert
138
- scan, and the `.spy` language smoke suite. Public CI additionally provisions a
139
- disposable loopback OpenSSH server to validate real SSH, SFTP, host-key, PTY,
140
- sudo/su and timeout behavior against the built wheel.
141
-
142
- The `.spy` language smoke suite can also be run directly:
143
-
144
- ```sh
145
- python3 sshscript.py unittest/dollar_syntax.spy
146
- ```
147
-
148
- Site-specific and credentialed SSH tests live under `unittest-v3/` and are not
149
- part of the default release gate. See [CONTRIBUTING.md](CONTRIBUTING.md) before
150
- running them.
151
-
152
- ## Project status
153
-
154
- Version 3.1 is production/stable software. Public behavior is covered by the
155
- credential-free release gates and isolated OpenSSH integration CI. Operators
156
- should still validate site-specific PAM, sudoers, network and host-key policy in
157
- a disposable environment before production rollout.
158
-
159
- SSHScript is released under the MIT License.
160
- See [SUPPORT.md](SUPPORT.md), [SECURITY.md](SECURITY.md),
161
- [CONTRIBUTING.md](CONTRIBUTING.md), and [CHANGELOG.md](CHANGELOG.md) for project
162
- policies and release history.
163
-
164
- ## Production exception contract
165
-
166
- SSHScript validates runtime inputs in both normal and optimized Python modes.
167
- See [the stable exception matrix](EXCEPTIONS.md). `AssertionError` was never a
168
- supported SSHScript API contract. User-written `.spy` assertions remain ordinary
169
- Python assertions: `python -O` removes them. Production scripts must use explicit
170
- status checks or `check=True` for command-success handling.
171
-
172
- ```python
173
- with Session() as session:
174
- session.exec_command("false", check=True)
175
- ```
176
-
177
- A nonzero exit status otherwise remains result data, available as
178
- `session.exitcode`.
@@ -1,23 +0,0 @@
1
- sshscript/__init__.py,sha256=cjjUOPbICmiAozH4_3aBpMJkcaUlIFFbVsgDxwgDEc0,2648
2
- sshscript/_version.py,sha256=v4LnAFtvMF2xPQifR-JKsZ93ImGm14rR1pVB-L1wVFE,77
3
- sshscript/channelgeneric.py,sha256=3kUfggHKx8uasZqYVas7YnwhoIrxcEwEgSn-2lItzxg,73834
4
- sshscript/channelssh.py,sha256=Obco0J0J1PwUpyVKoWluVn_hGpUw5WrC51SPmuN1tIM,11969
5
- sshscript/channelsubprocess.py,sha256=hgFgDYeba-GQjlOp8sV0d7xl1imLyDDIEVxPh-49-7w,12092
6
- sshscript/channelutils.py,sha256=cIH-OcU4EiUMGBNw9r0LI7Z2BSN6zhwdOo7cnxFa4ws,19609
7
- sshscript/dollar.py,sha256=lWmF1UNhXMdnNFKanppb-8QrOzgjxwghJh6AbYyGpGY,24600
8
- sshscript/dollarchanger.py,sha256=IlE9afIRDdUq1jFbwL_w_lwW3eqBVpOU4FEC6YYqMNw,30359
9
- sshscript/dollarparser.py,sha256=bwRYbI7m-k1N39fXrFY4D4GtgBkOAEC4tMuefIBYgGo,9374
10
- sshscript/errorutils.py,sha256=MgwYoPipEVCa9L33FmVoYXl77CD-Mrq958q_ft3cuvw,16386
11
- sshscript/patching.py,sha256=OXBRkAJpy2mC0aswqrhEhlgR5-Zs4K4f93rVcaLVwDg,6488
12
- sshscript/session.py,sha256=TnfjcJ0VhP3TqeIw614lOeJhNTKZzhOGFdf9dGyKQrY,58318
13
- sshscript/sessionwrapper.py,sha256=r8_Q3JkmNSqVXjSeVgClk58bPpPghOVyWS9tnBhdObw,12731
14
- sshscript/spyimporter.py,sha256=ASAs2nPBU1mtpI7FquXYJctLFQR_PeGMnhjcw1G6LPg,5574
15
- sshscript/sshscript.py,sha256=CiJmCq-oCXHKTj8GJRfrxpW3lNKncmjLeoC9hmNE4jo,15268
16
- sshscript/stdio.py,sha256=Ql_7wX7oJ5vY74uOlCaotwoyqXrNteYdSl-aM4ersVM,16040
17
- sshscript/tokenparser.py,sha256=9cYokDMXLSnwimZMkkuxH7S8PimhrKZJXLeOfqE5rNc,20053
18
- sshscript-3.1.4.dist-info/licenses/LICENSE.txt,sha256=FPqC-mLQDBkQ26LK_CAUGEUQQ7eyFTbftywlk2GMlls,1075
19
- sshscript-3.1.4.dist-info/METADATA,sha256=w7OCgiyd40FJsvvX8Aaz8-D9n2BR8guPghY6tFquABE,6431
20
- sshscript-3.1.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
21
- sshscript-3.1.4.dist-info/entry_points.txt,sha256=6fcLx-kJz2y_kehCAYQi7NqEuiflzEXiOlCl_E9j9uQ,55
22
- sshscript-3.1.4.dist-info/top_level.txt,sha256=Y-dzSWzuCbcKE8qSC2fvXTZulaSZVI7qk1lQhq1meMI,10
23
- sshscript-3.1.4.dist-info/RECORD,,