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 +6 -2
- sshscript/_version.py +1 -1
- sshscript/commandresult.py +28 -0
- sshscript/dollar.py +3 -2
- sshscript/dollarchanger.py +1 -1
- sshscript/dollarparser.py +4 -0
- sshscript/session.py +86 -17
- sshscript/sshconfig.py +127 -0
- sshscript/sshscript.py +36 -10
- sshscript-3.1.5.dist-info/METADATA +375 -0
- sshscript-3.1.5.dist-info/RECORD +25 -0
- sshscript-3.1.4.dist-info/METADATA +0 -178
- sshscript-3.1.4.dist-info/RECORD +0 -23
- {sshscript-3.1.4.dist-info → sshscript-3.1.5.dist-info}/WHEEL +0 -0
- {sshscript-3.1.4.dist-info → sshscript-3.1.5.dist-info}/entry_points.txt +0 -0
- {sshscript-3.1.4.dist-info → sshscript-3.1.5.dist-info}/licenses/LICENSE.txt +0 -0
- {sshscript-3.1.4.dist-info → sshscript-3.1.5.dist-info}/top_level.txt +0 -0
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
|
@@ -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']
|
sshscript/dollarchanger.py
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
1023
|
-
|
|
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
|
|
1039
|
-
raise
|
|
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
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
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',
|
|
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.
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
[](https://pypi.org/project/sshscript/)
|
|
35
|
+
[](https://pypi.org/project/sshscript/)
|
|
36
|
+
[](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/iapyeh/sshscript/actions/workflows/codeql.yml)
|
|
38
|
+
[](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
|
-
[](https://github.com/iapyeh/sshscript/actions/workflows/ci.yml)
|
|
29
|
-
[](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`.
|
sshscript-3.1.4.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|