jw.pkg: Fix "make check" static code check fallout

The previous commits have put rules for linting and formatting via ruff, yapf, mypy and pyright into place. They are checked with the make check target, and this commit adds the fixes for the target to succeed.

It does some refactoring where type checking dug up dirty bits, and also adds lots of churn in the Python code. To a good deal, that's owed to mere formatting changes. It would have been better to seperate those from syntax and refactoring fixes into multiple commits, so that the interesting changes don't drown in the formatting nose. However, that would have been a lot of additional work only to be thrown away by later commits, hence this commit has a big diff in one piece. The size of the diff is regrettable but hopefully a one-off: What it buys is automatic format checking for CI and predictble formats for smaller diffs in the future.

Rules that "make check" enforces are, in the following order

- Syntax checkers:

- ruff check . - mypy . - pyright

- Format check:

- yapf --diff --recursive .

The refactoring includes:

- Turn the Result class into a more elaborate object, capable of doing more heavy lifting around stderr and stdout decoding, summarizing outcome, and matching error strings.
Aside from fixing broken type checks, this also removes lots of boilerplate calling code which is currently used for handling possible call outcome scenarios. Trying to access an inexistent, decoded string should raise a meaningful exception by itself now, which removes lots of code with case distinctions.

- Fix Cmd type hierarchy:

- Add the AbstractCmd class above Cmd. This is necessary because the checker rightfully complains it can't instantiate a Cmd instance where constructor arguments were needed. They never were, but the type used at the instantiating code's location in jw.pkg.App so claims.
- Lots of sub- and sub-subcommands are derived from the base class of the invoking command. That provides some properties shared across the ancestor hierarchy of a command, but is semantically unsound. Fix that by introducing jw.pkg.BaseCmd class as a place to provide basic helpers shared across all commands used in a jw.pkg.App's context, and derive all command classes from that afresh. The parent command is still reachable via a common parent property.

Formatting changes are conforming to PEP-8, mostly, with minor tweaks. All in all they include the following changes.

- Remove # -*- coding: utf-8 -*-

The line was needed by Python 2 which is not supported anylonger. For Python 3, the default encoding is UTF-8, anyway.
- Allow to run "make py-format" without having it produce any changes. It's basically "yapf --in-place --recursive ." with some code style settings, see conf/topdir/pyproject.toml. The settings may be debatable. I've had custom tweaks in place on that target, too, but then again, IDEs would have more hassle to integrate that.

- Introduce a 88 character line length limit

- One import per line, reshuffle them semantically, see [tool.isort] in pyproject.toml.

- Hide imports needed for type-checking only behind

if TYPE_CHECKING
- Spaces around assignments accounts for much churn. Having having no spaces in inline parameter list assignments and default parameter values would arguably be more compact where it's useful. On the other hand, I have not found a code formatter which allows spaces around assignments in parameter lists broken into one per line and that's often better than a wall of text.
- Add two spaces before # export, as this seems to be mandated by PEP-8

- Use single quotes by default

Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-05-27 07:16:05 +02:00
commit 6db73873e7
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61
97 changed files with 3229 additions and 1893 deletions

View file

@ -1,32 +1,35 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
from ..FileContext import FileContext as Base
from ..base import Result
from ..FileContext import FileContext as Base
if TYPE_CHECKING:
from ..ExecContext import ExecContext
from ..Uri import Uri
from .Local import Local
class Curl(Base):
def __init__(self, uri: str|Uri, *args, ec: ExecContext|None=None, **kwargs) -> None:
super().__init__(uri=uri, *args, **kwargs)
self.__ec: ExecContext|None = ec
if ec is None:
def __init__(
self, uri: str | Uri, *args, ec: ExecContext | None = None, **kwargs
) -> None:
def __local() -> Local:
from .Local import Local
self.__ec = Local(interactive=False, *args, **kwargs)
return Local(interactive = False, *args, **kwargs)
# MyPy complains for reasons I don't understand:
# E: "__init__" of "FileContext" gets multiple values for keyword # argument
# "uri" [misc]
super().__init__(uri = uri, *args, **kwargs) # type: ignore[misc]
self.__ec = ec if ec else __local()
async def _get(
self,
path: str,
wd: str|None,
throw: bool,
verbose: bool|None,
title: str
self, path: str, wd: str | None, throw: bool, verbose: bool | None, title: str
) -> Result:
cmd = ['curl']
if verbose is None:
@ -38,5 +41,5 @@ class Curl(Base):
path = wd + '/' + path
if not len(path) or path[0] != '/':
path = '/' + path
cmd.append(self.url.to_string + self._chroot(path))
return await self.__ec.run(cmd, throw=throw, verbose=verbose)
cmd.append(self.uri.to_string + self._chroot(path))
return await self.__ec.run(cmd, throw = throw, verbose = verbose)

View file

@ -1,91 +1,96 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
import os, sys, subprocess, asyncio, pwd, grp, stat
import asyncio
import grp
import os
import pwd
import sys
from functools import cache
from typing import TYPE_CHECKING
from ..ExecContext import ExecContext as Base
from ..base import Result, StatResult
from ..log import *
from ..util import pretty_cmd
from ..ExecContext import ExecContext as Base
from ..log import ERR, NOTICE, log
if TYPE_CHECKING:
from ..Uri import Uri
class Local(Base):
def __init__(self, uri: str|Uri='local', *args, **kwargs) -> None:
def __init__(self, uri: str | Uri = 'local', *args, **kwargs) -> None:
super().__init__(uri, *args, **kwargs)
@cache
def _username(self) -> str:
return pwd.getpwuid(os.getuid()).pw_name,
return pwd.getpwuid(os.getuid()).pw_name
async def _run(
self,
cmd: list[str],
wd: str|None,
wd: str | None,
verbose: bool,
cmd_input: bytes|None,
mod_env: dict[str, str]|None,
cmd_input: bytes | None,
mod_env: dict[str, str] | None,
interactive: bool,
log_prefix: str
log_prefix: str,
) -> Result:
def __log(prio, *args, verbose=verbose):
def __log(prio, *args, verbose = verbose):
if verbose:
log(prio, log_prefix, *args)
def __make_pty_reader(collector: list[bytes], enc_for_verbose: str):
def _read(fd):
ret = os.read(fd, 1024)
if not ret:
return ret
collector.append(ret)
return ret
return _read
cwd: str|None = None
cwd: str | None = None
if wd is not None:
cwd = os.getcwd()
os.chdir(wd)
try:
# -- interactive mode
if interactive:
import pty
def _spawn():
# Apply env in PTY mode by temporarily updating os.environ around spawn.
# Apply env in PTY mode by temporarily updating os.environ
# around spawn.
if mod_env:
old_env = os.environ.copy()
try:
os.environ.update(mod_env)
return pty.spawn(cmd, master_read=reader)
return pty.spawn(cmd, master_read = reader)
finally:
os.environ.clear()
os.environ.update(old_env)
return pty.spawn(cmd, master_read=reader)
return pty.spawn(cmd, master_read = reader)
stdout_chunks: list[bytes] = []
enc_for_verbose = sys.stdout.encoding or "utf-8"
enc_for_verbose = sys.stdout.encoding or 'utf-8'
reader = __make_pty_reader(stdout_chunks, enc_for_verbose)
exit_code = await asyncio.to_thread(_spawn)
# PTY merges stdout/stderr
stdout = b"".join(stdout_chunks) if stdout_chunks else None
stdout = b''.join(stdout_chunks) if stdout_chunks else None
return Result(stdout, None, exit_code)
# -- non-interactive mode
stdin = asyncio.subprocess.DEVNULL if cmd_input is None else asyncio.subprocess.PIPE
stdin = (
asyncio.subprocess.DEVNULL
if cmd_input is None else asyncio.subprocess.PIPE
)
if mod_env:
new_env = os.environ.copy()
@ -94,21 +99,21 @@ class Local(Base):
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=mod_env,
stdin = stdin,
stdout = asyncio.subprocess.PIPE,
stderr = asyncio.subprocess.PIPE,
env = mod_env,
)
stdout_parts: list[bytes] = []
stderr_parts: list[bytes] = []
# -- decoding for verbose output in pipe mode
stdout_log_enc = sys.stdout.encoding or "utf-8"
stderr_log_enc = sys.stderr.encoding or "utf-8"
stdout_log_enc = sys.stdout.encoding or 'utf-8'
stderr_log_enc = sys.stderr.encoding or 'utf-8'
async def read_stream(stream, prio, collector: list[bytes], log_enc: str):
buf = b""
buf = b''
while True:
chunk = await stream.read(4096)
if not chunk:
@ -116,12 +121,12 @@ class Local(Base):
collector.append(chunk)
if verbose:
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
__log(prio, line.decode(log_enc, errors="replace"))
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
__log(prio, line.decode(log_enc, errors = 'replace'))
if verbose and buf:
# flush trailing partial line (no newline)
__log(prio, buf.decode(log_enc, errors="replace"))
__log(prio, buf.decode(log_enc, errors = 'replace'))
tasks = [
asyncio.create_task(
@ -132,7 +137,8 @@ class Local(Base):
),
]
if stdin is asyncio.subprocess.PIPE:
if (cmd_input is not None and stdin is asyncio.subprocess.PIPE
and proc.stdin is not None):
proc.stdin.write(cmd_input)
await proc.stdin.drain()
proc.stdin.close()
@ -140,8 +146,8 @@ class Local(Base):
exit_code = await proc.wait()
await asyncio.gather(*tasks)
stdout = b"".join(stdout_parts) if stdout_parts else None
stderr = b"".join(stderr_parts) if stderr_parts else None
stdout = b''.join(stdout_parts) if stdout_parts else None
stderr = b''.join(stderr_parts) if stderr_parts else None
return Result(stdout, stderr, exit_code)
@ -153,7 +159,9 @@ class Local(Base):
os.unlink(path)
async def _erase(self, path: str) -> None:
if os.isdir(path):
if os.path.isdir(path):
import shutil
shutil.rmtree(path)
return
os.unlink(path)
@ -165,12 +173,12 @@ class Local(Base):
os.mkdir(name, mode)
async def _stat(self, path: str, follow_symlinks: bool) -> StatResult:
return StatResult.from_os(os.stat(path, follow_symlinks=follow_symlinks))
return StatResult.from_os(os.stat(path, follow_symlinks = follow_symlinks))
async def _file_exists(self, path: str) -> bool:
return os.path.exists(path)
async def _chown(self, path: str, owner: str|None, group: str|None) -> None:
async def _chown(self, path: str, owner: str | None, group: str | None) -> None:
uid = pwd.getpwnam(owner).pw_uid if owner else -1
gid = grp.getgrnam(group).gr_gid if group else -1
os.chown(path, uid, gid)
@ -179,6 +187,6 @@ class Local(Base):
os.chmod(path, mode)
async def _is_dir(self, path: str, follow_symlinks: bool) -> bool:
if (not follow_symlinks) and os.islink(path):
if (not follow_symlinks) and os.path.islink(path):
return False
return os.path.isdir(path)

View file

@ -1,16 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import Any, TYPE_CHECKING
import abc
import os
import pwd
import sys
import os, abc, sys, pwd
from enum import Flag, auto
from typing import TYPE_CHECKING
from ..util import pretty_cmd
from ..log import *
from ..base import Result
from ..ExecContext import ExecContext
from ..log import DEBUG, ERR, INFO, NOTICE, WARNING, log
from ..Uri import Uri
if TYPE_CHECKING:
@ -24,48 +24,50 @@ class SSHClient(ExecContext):
ModEnv = auto()
Wd = auto()
def __init__(self, uri: Uri|str, caps: Caps=Caps(0), *args, **kwargs) -> None:
def __init__(self, uri: Uri | str, caps: Caps = Caps(0), *args, **kwargs) -> None:
uri = Uri.pimp(uri)
if uri.username is None:
uri.set_username(pwd.getpwuid(os.getuid()).pw_name)
super().__init__(uri=uri, *args, **kwargs)
super().__init__(uri = uri, *args, **kwargs)
self.__caps = caps
@abc.abstractmethod
async def _run_ssh(
self,
cmd: list[str],
wd: str|None,
wd: str | None,
verbose: bool,
cmd_input: bytes|None,
mod_env: dict[str, str]|None,
cmd_input: bytes | None,
mod_env: dict[str, str] | None,
interactive: bool,
log_prefix: str
log_prefix: str,
) -> Result:
pass
async def _run(
self,
cmd: list[str],
wd: str|None,
wd: str | None,
verbose: bool,
cmd_input: bytes|None,
mod_env: dict[str, str]|None,
cmd_input: bytes | None,
mod_env: dict[str, str] | None,
interactive: bool,
log_prefix: str
log_prefix: str,
) -> Result:
def __log(prio: int, *args):
log(prio, log_prefix, *args)
def __log_block(prio: int, title: str, block: str):
def __log_block(prio: int, title: str, block: bytes | str | None):
if self.__caps & self.Caps.LogOutput:
return
if not block:
return
encoding = sys.stdout.encoding or 'utf-8'
block = block.decode(encoding).strip()
if not block:
return
if isinstance(block, bytes):
encoding = sys.stdout.encoding or 'utf-8'
block = block.decode(encoding).strip()
# Needed to pacify pyright: block can't be anything else at this point
assert isinstance(block, str)
delim = f'---- {title} ----'
__log(prio, f',{delim}')
for line in block.splitlines():
@ -79,44 +81,49 @@ class SSHClient(ExecContext):
raise NotImplementedError('Interactive SSH is not yet implemented')
if mod_env is not None and not self.__caps & self.Caps.ModEnv:
raise NotImplementedError('Passing an environment to SSH commands is not yet implemented')
raise NotImplementedError(
'Passing an environment to SSH commands is not yet implemented'
)
ret = await self._run_ssh(
cmd=cmd,
wd=wd,
verbose=verbose,
cmd_input=cmd_input,
mod_env=mod_env,
interactive=interactive,
log_prefix=log_prefix
cmd = cmd,
wd = wd,
verbose = verbose,
cmd_input = cmd_input,
mod_env = mod_env,
interactive = interactive,
log_prefix = log_prefix,
)
if verbose:
__log_block(NOTICE, 'stdout', ret.stdout)
__log_block(NOTICE, 'stderr', ret.stderr)
__log_block(NOTICE, 'stdout', ret.stdout_str_or_none)
__log_block(NOTICE, 'stderr', ret.stderr_str_or_none)
if ret.status != 0:
__log(WARNING, f'Exit code {ret.status}')
return ret
@property
def hostname(self) -> str|None:
def hostname(self) -> str | None:
return self.uri.hostname
@property
def port(self) -> int|None:
def port(self) -> int | None:
return self.uri.port
@property
def username(self) -> str|None:
def username(self) -> str | None:
return self.uri.username
@property
def password(self) -> str|None:
def password(self) -> str | None:
return self.uri.password
def ssh_client(*args, type: str|list[str]|None=None, **kwargs) -> SSHClient: # export
def ssh_client(
*args, type: str | list[str] | None = None, **kwargs
) -> SSHClient: # export
from importlib import import_module
errors: list[str] = []
if type is None:
val = os.getenv('JW_DEFAULT_SSH_CLIENT')
@ -128,11 +135,12 @@ def ssh_client(*args, type: str|list[str]|None=None, **kwargs) -> SSHClient: # e
type = [type]
for name in type:
try:
ret = getattr(import_module(f'jw.pkg.lib.ec.ssh.{name}'), name)(*args, **kwargs)
ret = getattr(import_module(f'jw.pkg.lib.ec.ssh.{name}'),
name)(*args, **kwargs)
log(INFO, f'Using SSH-client "{name}"')
return ret
except Exception as e:
msg = f'Can\'t instantiate SSH client class {name} ({str(e)})'
msg = f"Can't instantiate SSH client class {name} ({str(e)})"
errors.append(msg)
log(DEBUG, f'{msg}, trying next')
msg = f'No working SSH clients for {" ".join([str(arg) for arg in args])}'

View file

@ -1,11 +1,15 @@
# -*- coding: utf-8 -*-
import asyncio
import os
import shlex
import shutil
import signal
import sys
import os, sys, shlex, asyncio, asyncssh, shutil, signal
import asyncssh
from ...log import *
from ...base import Result
from ...log import DEBUG, ERR, NOTICE, log
from ..SSHClient import SSHClient as Base
from .util import join_cmd
_USE_DEFAULT_KNOWN_HOSTS = object()
@ -25,15 +29,18 @@ class AsyncSSH(Base):
super().__init__(
uri,
caps = self.Caps.LogOutput | self.Caps.Wd | self.Caps.Interactive | self.Caps.ModEnv,
**kwargs
caps = self.Caps.LogOutput
| self.Caps.Wd
| self.Caps.Interactive
| self.Caps.ModEnv,
**kwargs,
)
self.__client_keys = client_keys
self.__known_hosts = known_hosts
self.__term_type = term_type or os.environ.get('TERM', 'xterm')
self.__connect_timeout = connect_timeout
self.__conn: asyncssh.SSHClientConnection|None = None
self.__conn: asyncssh.SSHClientConnection | None = None
async def _open(self) -> None:
await super()._open()
@ -48,7 +55,7 @@ class AsyncSSH(Base):
log(DEBUG, f'Failed to close connection ({str(e)}, ignored)')
self.__conn = None
def _connect_kwargs(self, hide_secrets: bool=False) -> dict:
def _connect_kwargs(self, hide_secrets: bool = False) -> dict:
kwargs: dict = {
'host': self.hostname,
'port': self.port,
@ -72,7 +79,7 @@ class AsyncSSH(Base):
except Exception as e:
msg = f'-------------------- Failed to connect ({str(e)})'
log(ERR, ',', msg)
for key, val in self._connect_kwargs(hide_secrets=True).items():
for key, val in self._connect_kwargs(hide_secrets = True).items():
log(ERR, f'| {key:<20} = {val}')
log(ERR, '`', msg)
raise
@ -94,10 +101,13 @@ class AsyncSSH(Base):
@staticmethod
def _get_local_term_size() -> tuple[int, int, int, int]:
cols, rows = shutil.get_terminal_size(fallback=(80, 24))
cols, rows = shutil.get_terminal_size(fallback = (80, 24))
xpixel = ypixel = 0
try:
import fcntl, termios, struct
import fcntl
import struct
import termios
packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b'\0' * 8)
rows2, cols2, xpixel, ypixel = struct.unpack('HHHH', packed)
if cols2 > 0 and rows2 > 0:
@ -126,9 +136,9 @@ class AsyncSSH(Base):
buf += chunk
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
log(prio, log_prefix, line.decode(log_enc, errors='replace'))
log(prio, log_prefix, line.decode(log_enc, errors = 'replace'))
if verbose and buf:
log(prio, log_prefix, buf.decode(log_enc, errors='replace'))
log(prio, log_prefix, buf.decode(log_enc, errors = 'replace'))
async def _run_interactive_on_conn(
self,
@ -222,7 +232,8 @@ class AsyncSSH(Base):
sys.stderr.flush()
try:
import termios, tty
import termios
import tty
old_tty_state = termios.tcgetattr(stdin_fd)
tty.setraw(stdin_fd)
@ -257,7 +268,9 @@ class AsyncSSH(Base):
exit_code = completed.exit_status
if exit_code is None:
exit_code = completed.returncode if completed.returncode is not None else -1
exit_code = (
completed.returncode if completed.returncode is not None else -1
)
stdout = b''.join(stdout_parts) if stdout_parts else None
return Result(stdout, None, exit_code)
@ -278,6 +291,7 @@ class AsyncSSH(Base):
if old_tty_state is not None:
try:
import termios
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty_state)
except Exception:
pass
@ -331,7 +345,7 @@ class AsyncSSH(Base):
await proc.stdin.drain()
proc.stdin.write_eof()
completed = await proc.wait(check=False)
completed = await proc.wait(check = False)
await task
exit_code = completed.exit_status
@ -346,14 +360,13 @@ class AsyncSSH(Base):
cmd: list[str],
wd: str | None,
verbose: bool,
cmd_input: str | None,
cmd_input: bytes | None,
mod_env: dict[str, str] | None,
interactive: bool,
log_prefix: str,
) -> Result:
try:
if interactive:
if self._has_local_tty():
return await self._run_interactive_on_conn(
@ -421,7 +434,7 @@ class AsyncSSH(Base):
await proc.stdin.drain()
proc.stdin.write_eof()
completed = await proc.wait(check=False)
completed = await proc.wait(check = False)
await asyncio.gather(*tasks)
stdout = b''.join(stdout_parts) if stdout_parts else None
@ -429,7 +442,9 @@ class AsyncSSH(Base):
exit_code = completed.exit_status
if exit_code is None:
exit_code = completed.returncode if completed.returncode is not None else -1
exit_code = (
completed.returncode if completed.returncode is not None else -1
)
return Result(stdout, stderr, exit_code)

View file

@ -1,5 +1,7 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ...base import InputMode
@ -8,18 +10,14 @@ from ..SSHClient import SSHClient as Base
from .util import join_cmd
if TYPE_CHECKING:
from ...base import Result
from ...base import Input, Result
class Exec(Base):
def __init__(self, uri, *args, **kwargs) -> None:
self.__askpass: str|None = None
self.__askpass_orig: dict[str, str|None] = dict()
super().__init__(
uri = uri,
caps = self.Caps.ModEnv,
**kwargs
)
self.__askpass: str | None = None
self.__askpass_orig: dict[str, str | None] = dict()
super().__init__(uri = uri, caps = self.Caps.ModEnv, **kwargs)
def __del__(self):
for key, val in self.__askpass_orig.items():
@ -32,36 +30,53 @@ class Exec(Base):
def __init_askpass(self):
if self.__askpass is None and self.password is not None:
import sys, tempfile
import sys
import tempfile
prefix = os.path.basename(sys.argv[0]) + '-'
f = tempfile.NamedTemporaryFile(mode='w+t', prefix=prefix, delete=False)
f = tempfile.NamedTemporaryFile(
mode = 'w+t', prefix = prefix, delete = False
)
os.chmod(f.name, 0o0700)
self.__askpass = f.name
f.write(f'#!/bin/bash\n\necho -n "{self.password}\n"')
f.close()
for key, val in {'SSH_ASKPASS': self.__askpass, 'SSH_ASKPASS_REQUIRE': 'force'}.items():
for key, val in {
'SSH_ASKPASS': self.__askpass,
'SSH_ASKPASS_REQUIRE': 'force',
}.items():
self.__askpass_orig[key] = os.getenv(key)
os.environ[key] = val
async def _run_ssh(
self,
cmd: list[str],
wd: str|None,
wd: str | None,
verbose: bool,
cmd_input: bytes|None,
mod_env: dict[str, str]|None,
cmd_input: bytes | None,
mod_env: dict[str, str] | None,
interactive: bool,
log_prefix: str
log_prefix: str,
) -> Result:
def __pub_cmd_input(cmd_input: bytes | None) -> Input:
if cmd_input is None:
if interactive:
return InputMode.Interactive
return InputMode.NonInteractive
return cmd_input
self.__init_askpass()
if cmd_input is None:
cmd_input = InputMode.Interactive if interactive else InputMode.NonInteractive
opts: dict[str, str] = []
opts: list[str] = []
if mod_env:
for key, val in mod_env.items():
opts.extend(['-o', f'SetEnv {key}="{val}"'])
if self.username:
opts.extend(['-l', self.username])
if self.port is not None:
pots.extend(['-p', str(self.port)])
return await run_cmd(['ssh', *opts, self.hostname, join_cmd(cmd)], cmd_input=cmd_input, throw=False)
opts.extend(['-p', str(self.port)])
return await run_cmd(
['ssh', *opts, self.hostname, join_cmd(cmd)],
cmd_input = __pub_cmd_input(cmd_input),
throw = False,
)

View file

@ -2,44 +2,46 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import paramiko # type: ignore # error: Library stubs not installed for "paramiko"
import paramiko # type: ignore[import-untyped] # error: Library stubs not installed for "paramiko"
from ...log import *
from ...base import Result
from ...log import ERR, log
from ..SSHClient import SSHClient as Base
from .util import join_cmd
if TYPE_CHECKING:
from typing import Any
import paramiko.agent # type: ignore[import-untyped]
import paramiko.SCPClient # type: ignore[import-untyped]
class Paramiko(Base):
def __init__(self, uri, *args, **kwargs) -> None:
super().__init__(
uri,
*args,
caps = self.Caps.ModEnv,
**kwargs
)
self.__timeout: float|None = None # Untested
self.___client: Any|None = None
kwargs['caps'] = (self.Caps.ModEnv, )
super().__init__(uri, *args, **kwargs)
self.__timeout: float | None = None # Untested
self.___client: Any | None = None
@property
def __client(self) -> Any:
if self.___client is None:
ret = paramiko.SSHClient()
ret.set_missing_host_key_policy(paramiko.AutoAddPolicy())
hostname = self.hostname
if hostname is None:
raise Exception('Tried to run connect without target hostname')
try:
ret.connect(
hostname = self.hostname,
username = self.username,
allow_agent = True
hostname = hostname, username = self.username, allow_agent = True
)
except Exception as e:
log(ERR, f'Failed to connect to {self.hostname} ({str(e)})')
raise
s = ret.get_transport().open_session()
transport = ret.get_transport()
if transport is None:
raise Exception(f'Failed to get SSH transport for {hostname}')
s = transport.open_session()
# set up the agent request handler to handle agent requests from the server
paramiko.agent.AgentRequestHandler(s)
self.___client = ret
@ -47,7 +49,7 @@ class Paramiko(Base):
@property
def __scp(self) -> Any:
return SCPClient(self.__client.get_transport())
return paramiko.SCPClient(self.__client.get_transport())
async def _open(self) -> None:
await super()._open()
@ -63,13 +65,13 @@ class Paramiko(Base):
cmd: list[str],
wd: str | None,
verbose: bool,
cmd_input: str | None,
cmd_input: bytes | None,
mod_env: dict[str, str] | None,
interactive: bool,
log_prefix: str,
) -> Result:
try:
kwargs: [str, Any] = {}
kwargs: dict[str, Any] = {}
if mod_env is not None:
kwargs['environment'] = mod_env
stdin, stdout, stderr = self.__client.exec_command(
@ -78,7 +80,7 @@ class Paramiko(Base):
**kwargs,
)
except Exception as e:
log(ERR, f'Command failed for {self.uri}: "{join_cmd(cmd)}"')
log(ERR, f'Command failed for {self.uri}: "{join_cmd(cmd)}" ({str(e)})')
raise
if cmd_input is not None:
stdin.write(cmd_input)

View file

@ -1,19 +1,29 @@
# -*- coding: utf-8 -*-
import shlex
from typing import Iterable
import shlex
DEFAULT_SHELL_OPERATORS = {
# redirections
">", ">>", "<", "<<", "<<-", "<&", ">&", "<>", ">|",
"1>", "1>>", "2>", "2>>",
# pipelines / control
"|", "||", "&", "&&", ";",
# grouping
"(", ")",
'>',
'>>',
'<',
'<<',
'<<-',
'<&',
'>&',
'<>',
'>|',
'1>',
'1>>',
'2>',
'2>>', # pipelines / control
'|',
'||',
'&',
'&&',
';', # grouping
'(',
')',
}
def join_cmd(