exec-helpers 8.1.3__tar.gz → 8.2.1__tar.gz

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.
Files changed (48) hide show
  1. exec_helpers-8.2.1/.pre-commit-config.yaml +34 -0
  2. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/PKG-INFO +11 -9
  3. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/README.rst +5 -1
  4. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/classifiers.txt +0 -2
  5. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/__init__.py +2 -3
  6. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_helpers.py +40 -0
  7. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_ssh_base.py +122 -30
  8. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_ssh_helpers.py +97 -2
  9. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_subprocess_helpers.py +3 -2
  10. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_version.py +3 -3
  11. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/api.py +37 -15
  12. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/api.py +36 -13
  13. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/exec_result.py +18 -6
  14. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/subprocess.py +112 -34
  15. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/exec_result.py +20 -20
  16. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/proc_enums.py +1 -1
  17. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/ssh_auth.py +42 -29
  18. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/subprocess.py +83 -20
  19. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/PKG-INFO +11 -9
  20. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/SOURCES.txt +2 -3
  21. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/requires.txt +3 -3
  22. exec_helpers-8.2.1/exec_helpers.egg-info/scm_file_list.json +77 -0
  23. exec_helpers-8.2.1/exec_helpers.egg-info/scm_version.json +8 -0
  24. exec_helpers-8.2.1/pyproject.toml +444 -0
  25. exec_helpers-8.1.3/.pre-commit-config.yaml +0 -44
  26. exec_helpers-8.1.3/flake8_requirements.txt +0 -14
  27. exec_helpers-8.1.3/mypy_requirements.txt +0 -1
  28. exec_helpers-8.1.3/pyproject.toml +0 -270
  29. exec_helpers-8.1.3/pytest_requirements.txt +0 -6
  30. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/.editorconfig +0 -0
  31. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/.readthedocs.yaml +0 -0
  32. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/CI_REQUIREMENTS.txt +0 -0
  33. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/LICENSE +0 -0
  34. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/MANIFEST.in +0 -0
  35. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/build_requirements.txt +0 -0
  36. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/__init__.pyi +0 -0
  37. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/_log_templates.py +0 -0
  38. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/__init__.py +0 -0
  39. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/async_api/__init__.pyi +0 -0
  40. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/constants.py +0 -0
  41. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/exceptions.py +0 -0
  42. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/py.typed +0 -0
  43. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers/ssh.py +0 -0
  44. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/dependency_links.txt +0 -0
  45. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/not-zip-safe +0 -0
  46. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/exec_helpers.egg-info/top_level.txt +0 -0
  47. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/requirements.txt +0 -0
  48. {exec_helpers-8.1.3 → exec_helpers-8.2.1}/setup.cfg +0 -0
@@ -0,0 +1,34 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: check-yaml
6
+ - id: check-toml
7
+ - id: end-of-file-fixer
8
+ - id: trailing-whitespace
9
+ - id: mixed-line-ending
10
+
11
+ - repo: https://github.com/astral-sh/ruff-pre-commit
12
+ # Ruff version.
13
+ rev: v0.17.3
14
+ hooks:
15
+ # Run the linter.
16
+ - id: ruff-check
17
+ args: [ --fix ]
18
+ # Run the formatter.
19
+ - id: ruff-format
20
+
21
+ - repo: https://github.com/pre-commit/pygrep-hooks
22
+ rev: v1.10.0 # Use the ref you want to point at
23
+ hooks:
24
+ - id: python-check-blanket-noqa
25
+ - id: python-check-blanket-type-ignore
26
+ - id: rst-directive-colons
27
+ - id: rst-inline-touching-normal
28
+
29
+ - repo: https://github.com/tox-dev/pyproject-fmt
30
+ # Use the sha / tag you want to point at
31
+ # or use `pre-commit autoupdate` to get the latest version
32
+ rev: "v2.30.3"
33
+ hooks:
34
+ - id: pyproject-fmt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exec-helpers
3
- Version: 8.1.3
3
+ Version: 8.2.1
4
4
  Summary: Execution helpers for simplified usage of subprocess and ssh.
5
5
  Author-email: Aleksei Stepanov <penguinolog@gmail.com>
6
6
  Maintainer-email: Aleksei Stepanov <penguinolog@gmail.com>, Antonio Esposito <esposito.cloud@gmail.com>, Dennis Dmitriev <dis-xcom@gmail.com>
@@ -8,14 +8,12 @@ License-Expression: Apache-2.0
8
8
  Project-URL: Documentation, https://exec-helpers.readthedocs.io/
9
9
  Project-URL: Repository, https://github.com/python-useful-helpers/exec-helpers
10
10
  Project-URL: Bug Tracker, https://github.com/python-useful-helpers/exec-helpers/issues
11
- Keywords: subprocess,ssh
11
+ Keywords: ssh,subprocess
12
12
  Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3 :: Only
17
- Classifier: Programming Language :: Python :: 3.9
18
- Classifier: Programming Language :: Python :: 3.10
19
17
  Classifier: Programming Language :: Python :: 3.11
20
18
  Classifier: Programming Language :: Python :: 3.12
21
19
  Classifier: Programming Language :: Python :: 3.13
@@ -23,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.14
23
21
  Classifier: Programming Language :: Python :: 3.15
24
22
  Classifier: Programming Language :: Python :: Implementation :: CPython
25
23
  Classifier: Programming Language :: Python :: Implementation :: PyPy
26
- Requires-Python: >=3.9.0
24
+ Requires-Python: >=3.11.0
27
25
  Description-Content-Type: text/x-rst
28
26
  License-File: LICENSE
29
27
  Requires-Dist: paramiko>=2.4
@@ -33,13 +31,13 @@ Provides-Extra: xml
33
31
  Provides-Extra: lxml
34
32
  Requires-Dist: lxml>=4.6.2; extra == "lxml"
35
33
  Provides-Extra: yaml
36
- Requires-Dist: PyYAML>=3.12; extra == "yaml"
34
+ Requires-Dist: pyyaml>=3.12; extra == "yaml"
37
35
  Provides-Extra: all-formats
38
36
  Requires-Dist: lxml>=4.6.2; extra == "all-formats"
39
- Requires-Dist: PyYAML>=3.12; extra == "all-formats"
37
+ Requires-Dist: pyyaml>=3.12; extra == "all-formats"
40
38
  Provides-Extra: all-formats
41
39
  Requires-Dist: lxml>=4.6.2; extra == "all-formats"
42
- Requires-Dist: PyYAML>=3.12; extra == "all-formats"
40
+ Requires-Dist: pyyaml>=3.12; extra == "all-formats"
43
41
  Dynamic: license-file
44
42
 
45
43
  exec-helpers
@@ -447,7 +445,11 @@ Keyword arguments:
447
445
  - cwd - working directory.
448
446
  - env - environment variables dict.
449
447
 
450
- .. note:: `shell=true` is always set.
448
+ .. note:: The command is executed by the shell by default.
449
+ `Subprocess(shell=False)` (and `async_api.Subprocess(shell=False)`) executes it directly instead:
450
+ the command must be a list of arguments (a string raises `TypeError`),
451
+ and no shell syntax, expansion or `sh -c` inside a chroot is involved.
452
+ `SSHClient` can not do this: the remote side always passes the command to a shell.
451
453
 
452
454
  async_api.Subprocess specific
453
455
  -----------------------------
@@ -403,7 +403,11 @@ Keyword arguments:
403
403
  - cwd - working directory.
404
404
  - env - environment variables dict.
405
405
 
406
- .. note:: `shell=true` is always set.
406
+ .. note:: The command is executed by the shell by default.
407
+ `Subprocess(shell=False)` (and `async_api.Subprocess(shell=False)`) executes it directly instead:
408
+ the command must be a list of arguments (a string raises `TypeError`),
409
+ and no shell syntax, expansion or `sh -c` inside a chroot is involved.
410
+ `SSHClient` can not do this: the remote side always passes the command to a shell.
407
411
 
408
412
  async_api.Subprocess specific
409
413
  -----------------------------
@@ -3,8 +3,6 @@ Intended Audience :: Developers
3
3
  Topic :: Software Development :: Libraries :: Python Modules
4
4
  Programming Language :: Python :: 3
5
5
  Programming Language :: Python :: 3 :: Only
6
- Programming Language :: Python :: 3.9
7
- Programming Language :: Python :: 3.10
8
6
  Programming Language :: Python :: 3.11
9
7
  Programming Language :: Python :: 3.12
10
8
  Programming Language :: Python :: 3.13
@@ -16,14 +16,13 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import contextlib
19
20
  import importlib
20
21
  import typing
21
22
  import warnings
22
23
 
23
- try: # noqa: SIM105,S314,RUF100
24
+ with contextlib.suppress(ImportError):
24
25
  from ._version import version as __version__ # noqa: F401
25
- except ImportError:
26
- pass
27
26
 
28
27
  if typing.TYPE_CHECKING:
29
28
  from collections.abc import Sequence
@@ -9,6 +9,7 @@ import typing
9
9
 
10
10
  if typing.TYPE_CHECKING:
11
11
  from collections.abc import Iterable
12
+ from collections.abc import Mapping
12
13
 
13
14
 
14
15
  def string_bytes_bytearray_as_bytes(src: str | bytes | bytearray) -> bytes:
@@ -96,3 +97,42 @@ def chroot_command(command: str, chroot_path: str | None = None, chroot_exe: str
96
97
  quoted_command = shlex.quote(command)
97
98
  return f"{chroot_exe} {chroot_dst} sh -c {shlex.quote(f'eval {quoted_command}')}"
98
99
  return command
100
+
101
+
102
+ def chroot_argv_command(command: str, chroot_path: str | None = None, chroot_exe: str | None = None) -> str:
103
+ """Prepare command for chroot execution without a shell, only the arguments are joined.
104
+
105
+ :param command: Original command, a result of `shlex.join`.
106
+ :type command: str
107
+ :param chroot_path: chroot path.
108
+ :type chroot_path: str | None
109
+ :param chroot_exe: chroot executable, may carry its own arguments.
110
+ :type chroot_exe: str | None
111
+ :return: Command to be executed with chroot rules if applicable.
112
+ :rtype: str
113
+ """
114
+ if chroot_path and chroot_path != "/":
115
+ return shlex.join([*shlex.split(chroot_exe or "chroot"), chroot_path.strip(), *shlex.split(command)])
116
+ return command
117
+
118
+
119
+ def repr_stdin(stdin: bytes | None) -> str:
120
+ """Describe STDIN data for a debug string without exposing it.
121
+
122
+ :param stdin: Data for STDIN.
123
+ :type stdin: bytes | None
124
+ :return: Size of the data, as the content can hold secrets.
125
+ :rtype: str
126
+ """
127
+ return "None" if stdin is None else f"<{len(stdin)} bytes>"
128
+
129
+
130
+ def repr_env(env: Mapping[str, str] | None) -> str:
131
+ """Describe an environment for a debug string without exposing the values.
132
+
133
+ :param env: Process environment.
134
+ :type env: Mapping[str, str] | None
135
+ :return: Variable names only, as the values can hold secrets.
136
+ :rtype: str
137
+ """
138
+ return "None" if env is None else f"<names: {sorted(env, key=repr)}>"
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
  import concurrent.futures
22
22
  import contextlib
23
23
  import copy
24
- import datetime
24
+ import datetime as dt
25
25
  import getpass
26
26
  import logging
27
27
  import pathlib
@@ -50,8 +50,7 @@ if typing.TYPE_CHECKING:
50
50
  from collections.abc import Iterable
51
51
  from collections.abc import Sequence
52
52
  from types import TracebackType
53
-
54
- from typing_extensions import Self
53
+ from typing import Self
55
54
 
56
55
  from exec_helpers.api import CalledProcessErrorSubClassT
57
56
  from exec_helpers.api import CommandT
@@ -66,8 +65,8 @@ if typing.TYPE_CHECKING:
66
65
 
67
66
  __all__ = ("SSHClientBase", "SshExecuteAsyncResult", "SupportPathT")
68
67
 
69
- KeepAlivePeriodT = typing.Union[int, bool]
70
- SupportPathT = typing.Union[str, pathlib.PurePath]
68
+ KeepAlivePeriodT = int | bool
69
+ SupportPathT = str | pathlib.PurePath
71
70
 
72
71
 
73
72
  class RetryOnExceptions(tenacity.retry_if_exception):
@@ -95,6 +94,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
95
94
  __slots__ = ()
96
95
 
97
96
  @property
97
+ # pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
98
98
  def interface(self) -> paramiko.Channel:
99
99
  """Override original NamedTuple with proper typing.
100
100
 
@@ -104,6 +104,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
104
104
  return super().interface # type: ignore[no-any-return]
105
105
 
106
106
  @property
107
+ # pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
107
108
  def stdin(self) -> paramiko.channel.ChannelStdinFile:
108
109
  """Override original NamedTuple with proper typing.
109
110
 
@@ -118,6 +119,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
118
119
  return super().stdin # type: ignore[return-value]
119
120
 
120
121
  @property
122
+ # pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
121
123
  def stderr(self) -> paramiko.channel.ChannelStderrFile | None:
122
124
  """Override original NamedTuple with proper typing.
123
125
 
@@ -127,6 +129,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
127
129
  return super().stderr
128
130
 
129
131
  @property
132
+ # pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
130
133
  def stdout(self) -> paramiko.channel.ChannelFile | None:
131
134
  """Override original NamedTuple with proper typing.
132
135
 
@@ -136,7 +139,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
136
139
  return super().stdout
137
140
 
138
141
 
139
- class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[SshExecuteAsyncResult]):
142
+ class _SSHExecuteContext(api.ExecuteContext):
140
143
  """SSH Execute context."""
141
144
 
142
145
  __slots__ = (
@@ -223,8 +226,8 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
223
226
  """
224
227
  return (
225
228
  f"<SSHClient().open_execute_context("
226
- f"command={self.command!r}, "
227
- f"stdin={self.stdin!r}, "
229
+ f"command={self._masked_command!r}, "
230
+ f"stdin={_helpers.repr_stdin(self.stdin)}, "
228
231
  f"open_stdout={self.open_stdout!r}, "
229
232
  f"open_stderr={self.open_stderr!r}, "
230
233
  f"get_pty={self.__get_pty!r}, "
@@ -309,7 +312,7 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
309
312
  stderr = None
310
313
  _stdin: paramiko.channel.ChannelFile
311
314
  with chan.makefile("wb") as _stdin:
312
- started = datetime.datetime.now(tz=datetime.timezone.utc)
315
+ started = dt.datetime.now(tz=dt.UTC)
313
316
  if self.__sudo_mode:
314
317
  chan.exec_command(self.command) # nosec # Sanitize on caller side
315
318
  if not stdout.channel.closed:
@@ -334,13 +337,8 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
334
337
  res_stdout = None
335
338
 
336
339
  # noinspection PyArgumentList
337
- return SshExecuteAsyncResult(
338
- interface=chan,
339
- stdin=_stdin,
340
- stderr=stderr,
341
- stdout=res_stdout,
342
- started=started,
343
- )
340
+ # The fields are typed by properties of the subclass, which type checkers do not accept as constructor arguments
341
+ return SshExecuteAsyncResult._make((chan, _stdin, stderr, res_stdout, started))
344
342
 
345
343
  def __exit__(
346
344
  self,
@@ -457,6 +455,15 @@ class SSHClientBase(api.ExecHelper):
457
455
  :type keepalive: int | bool
458
456
  :param allow_ssh_agent: Use SSH Agent if available.
459
457
  :type allow_ssh_agent: bool
458
+ :param missing_host_key_policy: Missing host key policy class. See paramiko.MissingHostKeyPolicy for details.
459
+ :type missing_host_key_policy: type[paramiko.MissingHostKeyPolicy]
460
+ :param connect_timeout: Timeout in seconds for the TCP connection, the SSH banner, the key exchange and the
461
+ authentication of every connection step, including hosts reached through `proxy_to`.
462
+ An authentication timeout is reported as an authentication failure. By default: None
463
+ (paramiko defaults: no TCP and key exchange limit, banner 15 s, authentication 30 s). A connection attempt
464
+ is retried 3 times with a 3 s pause, so a hung host takes up to 3 times the timeout plus 6 s to report.
465
+ :type connect_timeout: float | None
466
+ :raises ValueError: connect_timeout is not positive.
460
467
 
461
468
  .. note:: auth has priority over username/password/private_keys.
462
469
  .. note::
@@ -475,12 +482,17 @@ class SSHClientBase(api.ExecHelper):
475
482
  .. versionchanged:: 7.0.0 keepalive_mode is removed.
476
483
  .. versionchanged:: 7.4.0 Return of keepalive_mode to prevent mix with a keepalive period. Default is `False`.
477
484
  .. versionchanged:: 8.0.0 Expose SSH Agent usage override.
485
+ .. versionchanged:: 8.1.4 Allow specifying missing_host_key_policy.
486
+ .. versionchanged:: 8.2.1 Added connect_timeout.
487
+ .. versionchanged:: 8.2.1 Direct connection to the local host accepts the host keys of this machine without
488
+ the unknown host key warning; a key of another server is handled by the missing host key policy as before.
478
489
  """
479
490
 
480
491
  __slots__ = (
481
492
  "__allow_agent",
482
493
  "__auth_mapping",
483
494
  "__conn_chain",
495
+ "__connect_timeout",
484
496
  "__hostname",
485
497
  "__keepalive_mode",
486
498
  "__keepalive_period",
@@ -491,6 +503,7 @@ class SSHClientBase(api.ExecHelper):
491
503
  "__ssh_config",
492
504
  "__sudo_mode",
493
505
  "__verbose",
506
+ "_missing_host_key_policy",
494
507
  )
495
508
 
496
509
  def __hash__(self) -> int:
@@ -515,13 +528,19 @@ class SSHClientBase(api.ExecHelper):
515
528
  sock: paramiko.ProxyCommand | paramiko.Channel | socket.socket | None = None,
516
529
  keepalive: KeepAlivePeriodT = 1,
517
530
  allow_ssh_agent: bool = True,
531
+ missing_host_key_policy: type[paramiko.MissingHostKeyPolicy] = paramiko.WarningPolicy,
532
+ connect_timeout: float | None = None,
518
533
  ) -> None:
519
- """Main SSH Client helper."""
534
+ """Initialize the SSH client helper."""
520
535
  self.__sudo_mode = False
521
536
  self.__keepalive_period: int = int(keepalive)
522
537
  self.__keepalive_mode = False
523
538
  self.__verbose: bool = verbose
524
539
  self.__sock = sock
540
+ self._missing_host_key_policy = missing_host_key_policy
541
+ if connect_timeout is not None and connect_timeout <= 0:
542
+ raise ValueError(f"connect_timeout should be positive, got {connect_timeout!r}")
543
+ self.__connect_timeout = connect_timeout
525
544
 
526
545
  self.__ssh: paramiko.SSHClient
527
546
  self.__sftp: paramiko.SFTPClient | None = None
@@ -744,18 +763,35 @@ class SSHClientBase(api.ExecHelper):
744
763
  """
745
764
  return self.__ssh
746
765
 
766
+ def __connect(self) -> None:
767
+ """Open the connection, with retries unless a socket was supplied.
768
+
769
+ A failed attempt closes the supplied socket, so there is nothing to retry on:
770
+ the next attempt would only fail on the closed socket and hide the original error.
771
+ """
772
+ with self.lock:
773
+ if self.__sock is None:
774
+ self.__connect_with_retries()
775
+ else:
776
+ self.__connect_once()
777
+
747
778
  @tenacity.retry(
748
779
  retry=RetryOnExceptions(retry_on=paramiko.SSHException, reraise=paramiko.AuthenticationException),
749
780
  stop=tenacity.stop.stop_after_attempt(3),
750
781
  wait=tenacity.wait.wait_fixed(3),
751
782
  reraise=True,
752
783
  )
753
- def __connect(self) -> None:
754
- """Main method for connection open."""
784
+ def __connect_with_retries(self) -> None:
785
+ """Open the connection, retry on the connection errors."""
786
+ self.__connect_once()
787
+
788
+ def __connect_once(self) -> None:
789
+ """Open the connection, one attempt."""
755
790
  with self.lock:
756
791
  if self.__sock is not None:
757
792
  self.__ssh = paramiko.SSHClient()
758
- self.__ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
793
+ self.__ssh.load_system_host_keys()
794
+ self.__ssh.set_missing_host_key_policy(self._missing_host_key_policy)
759
795
  self.auth.connect(
760
796
  client=self.__ssh,
761
797
  hostname=self.hostname,
@@ -763,6 +799,7 @@ class SSHClientBase(api.ExecHelper):
763
799
  log=self.__verbose,
764
800
  sock=self.__sock,
765
801
  allow_ssh_agent=self.allow_ssh_agent,
802
+ timeout=self.__connect_timeout,
766
803
  )
767
804
  else:
768
805
  self.__ssh = self.__get_client()
@@ -770,6 +807,27 @@ class SSHClientBase(api.ExecHelper):
770
807
  transport: paramiko.Transport = self._ssh_transport
771
808
  transport.set_keepalive(1 if self.__keepalive_period else 0) # send keepalive packets
772
809
 
810
+ def __host_key_policy(self, hostname: str) -> paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]:
811
+ """Select the missing host key policy for a host which is connected to directly.
812
+
813
+ :param hostname: Host to connect to, without a proxy or a jump host.
814
+ :type hostname: str
815
+ :return: Configured policy; for the local host a policy, which accepts the keys of this machine first.
816
+ :rtype: paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]
817
+
818
+ The local host is normally absent from `known_hosts`, so its own keys are the reference. A key of another
819
+ server (any local address can be a container, a tunnel or another server) goes to the configured policy as
820
+ before, so nothing which worked fails because of it. Without readable keys the configured policy decides.
821
+ The own key is accepted without asking the configured policy, so that policy does not see it.
822
+ """
823
+ try:
824
+ keys = _ssh_helpers.local_host_keys() if _ssh_helpers.is_local_host(hostname) else ()
825
+ except OSError: # the answer only silences a warning, so a failure of the lookup must not fail the connection
826
+ keys = ()
827
+ if keys:
828
+ return _ssh_helpers.LocalHostKeyPolicy(keys, fallback=self._missing_host_key_policy)
829
+ return self._missing_host_key_policy
830
+
773
831
  def __get_client(self) -> paramiko.SSHClient:
774
832
  """Connect using connection chain information.
775
833
 
@@ -779,11 +837,33 @@ class SSHClientBase(api.ExecHelper):
779
837
  :raises RuntimeError: Unexpected state.
780
838
  :raises ConnectionError: Cannot get SSH transport.
781
839
  """
840
+ opened: list[paramiko.SSHClient] = []
841
+ try:
842
+ return self.__connect_chain(opened)
843
+ except BaseException:
844
+ for client in reversed(opened): # paramiko leaves the transports of a failed connect running
845
+ client.close()
846
+ raise
847
+
848
+ def __connect_chain(self, opened: list[paramiko.SSHClient]) -> paramiko.SSHClient:
849
+ """Connect the hosts of the connection chain one by one.
850
+
851
+ :param opened: Every client created here is added to the list at once, for the cleanup on failure.
852
+ :type opened: list[paramiko.SSHClient]
853
+ :return: Paramiko ssh connection object of the last host.
854
+ :rtype: paramiko.SSHClient
855
+ :raises ValueError: ProxyCommand found in a connection chain after the first host reached an.
856
+ :raises RuntimeError: Unexpected state.
857
+ :raises ConnectionError: Cannot get SSH transport.
858
+ """
859
+ config, auth = self.__conn_chain[0]
782
860
 
783
861
  last_ssh_client: paramiko.SSHClient = paramiko.SSHClient()
784
- last_ssh_client.set_missing_host_key_policy(paramiko.WarningPolicy()) # noqa: S507,RUF100
785
-
786
- config, auth = self.__conn_chain[0]
862
+ opened.append(last_ssh_client)
863
+ last_ssh_client.load_system_host_keys()
864
+ last_ssh_client.set_missing_host_key_policy(
865
+ self._missing_host_key_policy if config.proxycommand else self.__host_key_policy(config.hostname)
866
+ )
787
867
 
788
868
  auth.connect(
789
869
  last_ssh_client,
@@ -791,11 +871,14 @@ class SSHClientBase(api.ExecHelper):
791
871
  port=config.port or 22,
792
872
  sock=paramiko.ProxyCommand(config.proxycommand) if config.proxycommand else None,
793
873
  allow_ssh_agent=self.allow_ssh_agent,
874
+ timeout=self.__connect_timeout,
794
875
  )
795
876
 
796
877
  for config, auth in self.__conn_chain[1:]: # start has another logic, so do it out of cycle
797
878
  ssh = paramiko.SSHClient()
798
- ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) # noqa: S507,RUF100
879
+ opened.append(ssh)
880
+ ssh.load_system_host_keys()
881
+ ssh.set_missing_host_key_policy(self._missing_host_key_policy)
799
882
 
800
883
  if config.proxyjump:
801
884
  transport = last_ssh_client.get_transport()
@@ -812,6 +895,7 @@ class SSHClientBase(api.ExecHelper):
812
895
  port=config.port or 22,
813
896
  sock=sock,
814
897
  allow_ssh_agent=self.allow_ssh_agent,
898
+ timeout=self.__connect_timeout,
815
899
  )
816
900
  last_ssh_client = ssh
817
901
  continue
@@ -823,10 +907,11 @@ class SSHClientBase(api.ExecHelper):
823
907
  return last_ssh_client
824
908
 
825
909
  def __connect_sftp(self) -> None:
826
- """SFTP connection opener."""
910
+ """Open the SFTP channel, reconnecting the SSH session first if it was closed."""
827
911
  with self.lock:
912
+ transport = self._ssh_transport # a failed reconnect is not "SFTP unavailable": let it propagate
828
913
  try:
829
- self.__sftp = self.__ssh.open_sftp()
914
+ self.__sftp = transport.open_sftp_client()
830
915
  except paramiko.SSHException:
831
916
  self.logger.warning("SFTP enable failed! SSH only is accessible.")
832
917
 
@@ -836,6 +921,11 @@ class SSHClientBase(api.ExecHelper):
836
921
 
837
922
  :rtype: paramiko.sftp_client.SFTPClient
838
923
  :raises paramiko.SSHException: SFTP connection failed.
924
+ :raises ConnectionError: SSH session was closed and the reconnect failed.
925
+ :raises paramiko.SSHException: SSH session was closed and the reconnect failed (the original exception).
926
+
927
+ .. versionchanged:: 8.2.1 The SFTP channel is opened through the reconnecting transport getter,
928
+ so a closed SSH session is restored instead of failing with `AttributeError`.
839
929
  """
840
930
  if self.__sftp is not None:
841
931
  return self.__sftp
@@ -869,7 +959,7 @@ class SSHClientBase(api.ExecHelper):
869
959
  """
870
960
  try:
871
961
  self.__ssh.close()
872
- except BaseException as e: # pragma: no cover # NOSONAR
962
+ except BaseException as e: # noqa: BLE001 # pragma: no cover # NOSONAR
873
963
  with contextlib.suppress(AttributeError):
874
964
  self.logger.debug(f"Exception in {self!s} destructor call: {e}")
875
965
  self.__sftp = None
@@ -900,7 +990,7 @@ class SSHClientBase(api.ExecHelper):
900
990
 
901
991
  @sudo_mode.setter
902
992
  def sudo_mode(self, mode: bool) -> None:
903
- """Persistent sudo mode change for connection object.
993
+ """Change the persistent sudo mode of the connection object.
904
994
 
905
995
  :param mode: Sudo status: enabled | disabled.
906
996
  :type mode: bool
@@ -1053,7 +1143,7 @@ class SSHClientBase(api.ExecHelper):
1053
1143
  result.read_stderr(src=async_result.stderr, log=self.logger if log_stderr else None, verbose=verbose)
1054
1144
 
1055
1145
  def poll_pipes() -> None:
1056
- """Polling task for FIFO buffers."""
1146
+ """Poll FIFO buffers."""
1057
1147
  while not async_result.interface.status_event.is_set():
1058
1148
  time.sleep(0.1)
1059
1149
  if async_result.stdout or async_result.stderr:
@@ -1147,6 +1237,7 @@ class SSHClientBase(api.ExecHelper):
1147
1237
  sudo_mode=self.sudo_mode,
1148
1238
  auth=self.auth,
1149
1239
  logger=self.logger,
1240
+ log_mask_re=self.log_mask_re,
1150
1241
  **kwargs,
1151
1242
  )
1152
1243
 
@@ -1556,6 +1647,7 @@ class SSHClientBase(api.ExecHelper):
1556
1647
  sock=sock,
1557
1648
  ssh_auth_map=ssh_auth_map if ssh_auth_map is not None else self.__auth_mapping,
1558
1649
  keepalive=int(keepalive),
1650
+ connect_timeout=self.__connect_timeout,
1559
1651
  )
1560
1652
 
1561
1653
  def execute_through_host(
@@ -1779,7 +1871,7 @@ class SSHClientBase(api.ExecHelper):
1779
1871
  results[remote.hostname, remote.port] = result
1780
1872
  if result.exit_code not in prep_expected:
1781
1873
  errors[remote.hostname, remote.port] = result
1782
- except Exception as e: # noqa: PERF203
1874
+ except Exception as e: # noqa: BLE001 # stored and reported to the caller
1783
1875
  raised_exceptions[remote.hostname, remote.port] = e
1784
1876
 
1785
1877
  if raised_exceptions: # always raise