cloudru-qa-ssh-client 1.0.0__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.
ssh_client/options.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .event_hooks import (
6
+ create_request_logger_hook,
7
+ create_request_structlogger_hook,
8
+ create_response_logger_hook,
9
+ create_response_structlogger_hook,
10
+ )
11
+ from .utils.retries import RetryPolicy
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+ from logging import Logger
16
+
17
+ from .client import ClientOption, InitSSHClient
18
+ from .event_hooks import MaskPattern
19
+ from .types import OperationDecorator, OutputCallback, StructuredLogger
20
+
21
+
22
+ def with_request_timeout(timeout: float) -> ClientOption:
23
+ """Set the command timeout.
24
+
25
+ :param timeout: Maximum command duration in seconds.
26
+ :return: Client option.
27
+ """
28
+
29
+ def option(client: InitSSHClient) -> None:
30
+ client.timeout = timeout
31
+
32
+ return option
33
+
34
+
35
+ def with_connection_timeout(timeout: float) -> ClientOption:
36
+ """Set the connection timeout.
37
+
38
+ :param timeout: Maximum connection duration in seconds.
39
+ :return: Client option.
40
+ """
41
+
42
+ def option(client: InitSSHClient) -> None:
43
+ client.connection_timeout = timeout
44
+
45
+ return option
46
+
47
+
48
+ def with_password(username: str, password: str) -> ClientOption:
49
+ """Select password authentication.
50
+
51
+ :param username: SSH login name.
52
+ :param password: Login password.
53
+ :return: Client option.
54
+ """
55
+
56
+ def option(client: InitSSHClient) -> None:
57
+ client.username = username
58
+ client.password = password
59
+ client.private_key_path = None
60
+
61
+ return option
62
+
63
+
64
+ def with_private_key(username: str, private_key_path: str) -> ClientOption:
65
+ """Select private-key authentication.
66
+
67
+ :param username: SSH login name.
68
+ :param private_key_path: Path to the private key file.
69
+ :return: Client option.
70
+ """
71
+
72
+ def option(client: InitSSHClient) -> None:
73
+ client.username = username
74
+ client.private_key_path = private_key_path
75
+ client.password = None
76
+
77
+ return option
78
+
79
+
80
+ def with_known_hosts(path: str) -> ClientOption:
81
+ """Trust server keys from an explicit OpenSSH known_hosts file.
82
+
83
+ :param path: Path to the trusted server keys.
84
+ :return: Client option.
85
+ """
86
+
87
+ def option(client: InitSSHClient) -> None:
88
+ client.known_hosts = path
89
+
90
+ return option
91
+
92
+
93
+ def with_logger(logger: Logger, mask_patterns: Sequence[MaskPattern] | None = None) -> ClientOption:
94
+ """Log sanitized SSH requests and command results.
95
+
96
+ :param logger: Standard logger.
97
+ :param mask_patterns: Literal secrets or regular expressions to mask.
98
+ :return: Client option.
99
+ """
100
+
101
+ def option(client: InitSSHClient) -> None:
102
+ client.add_request_hook(create_request_logger_hook(logger, mask_patterns))
103
+ client.add_response_hook(create_response_logger_hook(logger, mask_patterns))
104
+
105
+ return option
106
+
107
+
108
+ def with_structlogger(logger: StructuredLogger, mask_patterns: Sequence[MaskPattern] | None = None) -> ClientOption:
109
+ """Log sanitized structured SSH requests and command results.
110
+
111
+ :param logger: Structured logger.
112
+ :param mask_patterns: Literal secrets or regular expressions to mask.
113
+ :return: Client option.
114
+ """
115
+
116
+ def option(client: InitSSHClient) -> None:
117
+ client.add_request_hook(create_request_structlogger_hook(logger, mask_patterns))
118
+ client.add_response_hook(create_response_structlogger_hook(logger, mask_patterns))
119
+
120
+ return option
121
+
122
+
123
+ def retry_if_connection_fails(
124
+ retries_attempts_number: int,
125
+ retries_interval_in_seconds: float,
126
+ *,
127
+ reraise_exception: bool = False,
128
+ ) -> ClientOption:
129
+ """Retry failed connections with a bounded delay.
130
+
131
+ :param retries_attempts_number: Maximum attempts.
132
+ :param retries_interval_in_seconds: Delay between attempts.
133
+ :param reraise_exception: Propagate the final connection error.
134
+ :return: Client option.
135
+ """
136
+ policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
137
+
138
+ def option(client: InitSSHClient) -> None:
139
+ client.connection_retry = policy
140
+
141
+ return option
142
+
143
+
144
+ def retry_if_command_fails(
145
+ retries_attempts_number: int,
146
+ retries_interval_in_seconds: float,
147
+ *,
148
+ reraise_exception: bool = False,
149
+ ) -> ClientOption:
150
+ """Retry raised command errors and nonzero exit codes.
151
+
152
+ :param retries_attempts_number: Maximum attempts.
153
+ :param retries_interval_in_seconds: Delay between attempts.
154
+ :param reraise_exception: Propagate the final command failure.
155
+ :return: Client option.
156
+ """
157
+ policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
158
+
159
+ def option(client: InitSSHClient) -> None:
160
+ client.command_retry = policy
161
+
162
+ return option
163
+
164
+
165
+ def with_connect_decorators(*decorators: OperationDecorator) -> ClientOption:
166
+ """Register custom connection wrappers in application order.
167
+
168
+ :param decorators: Wrappers preserving the operation's result type.
169
+ :return: Client option.
170
+ """
171
+
172
+ def option(client: InitSSHClient) -> None:
173
+ for decorator in decorators:
174
+ client.add_connect_decorator(decorator)
175
+
176
+ return option
177
+
178
+
179
+ def with_execute_decorators(*decorators: OperationDecorator) -> ClientOption:
180
+ """Register custom command wrappers in application order.
181
+
182
+ :param decorators: Wrappers preserving the operation's result type.
183
+ :return: Client option.
184
+ """
185
+
186
+ def option(client: InitSSHClient) -> None:
187
+ for decorator in decorators:
188
+ client.add_execute_decorator(decorator)
189
+
190
+ return option
191
+
192
+
193
+ def with_live_execute_callback(
194
+ stdout_callback: OutputCallback | None = None,
195
+ stderr_callback: OutputCallback | None = None,
196
+ ) -> ClientOption:
197
+ """Register default streaming output callbacks.
198
+
199
+ :param stdout_callback: Observer of stdout lines.
200
+ :param stderr_callback: Observer of stderr lines for asynchronous clients.
201
+ :return: Client option.
202
+ """
203
+
204
+ def option(client: InitSSHClient) -> None:
205
+ client.live_stdout_callback = stdout_callback
206
+ client.live_stderr_callback = stderr_callback
207
+
208
+ return option
ssh_client/py.typed ADDED
File without changes
ssh_client/results.py ADDED
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class SSHResult:
6
+ """SSH command result."""
7
+
8
+ stdout: str = ""
9
+ stderr: str = ""
10
+ exit_code: int | None = None
11
+
12
+ def __repr__(self) -> str:
13
+ """Return a concise description of the command result."""
14
+ return f"SSHResult(stdout='{self.stdout}', stderr='{self.stderr}', exit_code={self.exit_code})"
15
+
16
+
17
+ @dataclass
18
+ class LiveSSHResult:
19
+ """SSH command result with real-time data access."""
20
+
21
+ stdout_lines: list[str] = field(default_factory=list)
22
+ stderr_lines: list[str] = field(default_factory=list)
23
+ exit_code: int | None = None
24
+
25
+ def __repr__(self) -> str:
26
+ """Return a concise description of the command result."""
27
+ return (
28
+ f"LiveSSHResult(stdout_lines={len(self.stdout_lines)}, "
29
+ f"stderr_lines={len(self.stderr_lines)}, exit_code={self.exit_code})"
30
+ )
ssh_client/types.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import TYPE_CHECKING, Protocol, TypedDict
5
+
6
+ if TYPE_CHECKING:
7
+ from .results import LiveSSHResult, SSHResult
8
+
9
+ type OutputCallback = Callable[[str], None]
10
+ type InterruptCallback = Callable[[str], bool]
11
+ type SyncOperation[T] = Callable[[], T]
12
+ type AsyncOperation[T] = Callable[[], Awaitable[T]]
13
+
14
+
15
+ class OperationDecorator(Protocol):
16
+ """Wrap an operation while preserving its parameters and result."""
17
+
18
+ def __call__[T](self, operation: Callable[[], T]) -> Callable[[], T]:
19
+ """Return a wrapped operation.
20
+
21
+ :param operation: Deferred connection or command operation.
22
+ :return: Operation with the same result type.
23
+ """
24
+ ...
25
+
26
+
27
+ class RequestHook(Protocol):
28
+ """Observe an SSH request."""
29
+
30
+ def __call__(self, *, host: str, port: int, username: str | None, command: str) -> None:
31
+ """Handle request values."""
32
+ ...
33
+
34
+
35
+ class ResponseHook(Protocol):
36
+ """Observe an SSH command result."""
37
+
38
+ def __call__(self, *, host: str, command: str, result: SSHResult | LiveSSHResult) -> None:
39
+ """Handle completed command output."""
40
+ ...
41
+
42
+
43
+ class StructuredLogger(Protocol):
44
+ """Accept structured SSH log entries."""
45
+
46
+ def info(self, event: str, **values: object) -> object:
47
+ """Write a structured informational event."""
48
+ ...
49
+
50
+
51
+ class Closeable(Protocol):
52
+ """Close a synchronous transport."""
53
+
54
+ def close(self) -> None:
55
+ """Release the transport."""
56
+ ...
57
+
58
+
59
+ class AsyncCloseable(Closeable, Protocol):
60
+ """Wait for transport shutdown."""
61
+
62
+ async def wait_closed(self) -> None:
63
+ """Wait until transport resources are released."""
64
+ ...
65
+
66
+
67
+ class ExecuteOptions(TypedDict, total=False):
68
+ """Keyword settings for a command."""
69
+
70
+ timeout: float | None
71
+ raise_for_status: bool
72
+
73
+
74
+ class LiveExecuteOptions(ExecuteOptions, total=False):
75
+ """Keyword settings for a streaming command."""
76
+
77
+ stdout_callback: OutputCallback | None
78
+ stderr_callback: OutputCallback | None
79
+ interrupt_stdout_callback: InterruptCallback | None
80
+ interrupt_stderr_callback: InterruptCallback | None
@@ -0,0 +1,9 @@
1
+ from .masking import mask_request_data, mask_response_data
2
+ from .retries import retry_if_command_fails, retry_if_connection_fails
3
+
4
+ __all__ = [
5
+ "mask_request_data",
6
+ "mask_response_data",
7
+ "retry_if_command_fails",
8
+ "retry_if_connection_fails",
9
+ ]
@@ -0,0 +1,42 @@
1
+ import re
2
+ from collections.abc import Sequence
3
+
4
+
5
+ def mask_request_data(command: str, mask_patterns: Sequence[str | re.Pattern[str]]) -> str:
6
+ """Mask sensitive data in SSH request command.
7
+
8
+ :param command: Command to mask
9
+ :param mask_patterns: Patterns to use for masking sensitive data
10
+ :returns: Masked command
11
+ """
12
+ masked_command = command
13
+ for pattern in mask_patterns:
14
+ if isinstance(pattern, str):
15
+ masked_command = masked_command.replace(pattern, "****")
16
+ else:
17
+ masked_command = pattern.sub("****", masked_command)
18
+ return masked_command
19
+
20
+
21
+ def mask_response_data(
22
+ stdout: str,
23
+ stderr: str,
24
+ mask_patterns: Sequence[str | re.Pattern[str]],
25
+ ) -> tuple[str, str]:
26
+ """Mask sensitive data in SSH response stdout and stderr.
27
+
28
+ :param stdout: Standard output to mask
29
+ :param stderr: Standard error to mask
30
+ :param mask_patterns: Patterns to use for masking sensitive data
31
+ :returns: Tuple of masked stdout and stderr
32
+ """
33
+ masked_stdout = stdout
34
+ masked_stderr = stderr
35
+ for pattern in mask_patterns:
36
+ if isinstance(pattern, str):
37
+ masked_stdout = masked_stdout.replace(pattern, "****")
38
+ masked_stderr = masked_stderr.replace(pattern, "****")
39
+ else:
40
+ masked_stdout = pattern.sub("****", masked_stdout)
41
+ masked_stderr = pattern.sub("****", masked_stderr)
42
+ return masked_stdout, masked_stderr
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ import asyncssh
9
+ import paramiko
10
+
11
+ from ssh_client.clients.exceptions import CommandNonZeroExitCodeError
12
+ from ssh_client.results import SSHResult
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Awaitable, Callable
16
+ from logging import Logger
17
+
18
+ CONNECTION_ERRORS = (OSError, paramiko.SSHException, asyncssh.Error)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RetryPolicy:
23
+ """Apply bounded retries to connection and command operations."""
24
+
25
+ attempts: int = 3
26
+ interval: float = 5
27
+ reraise: bool = False
28
+
29
+ def __post_init__(self) -> None:
30
+ """Reject settings that cannot execute a bounded retry."""
31
+ if self.attempts < 1 or self.interval < 0:
32
+ message = "Retry attempts must be positive and interval must be nonnegative"
33
+ raise ValueError(message)
34
+
35
+ def connect(self, operation: Callable[[], None]) -> None:
36
+ """Retry a synchronous connection operation.
37
+
38
+ :param operation: Connection attempt.
39
+ """
40
+ for attempt in range(self.attempts):
41
+ try:
42
+ operation()
43
+ except CONNECTION_ERRORS:
44
+ if attempt + 1 == self.attempts:
45
+ if self.reraise:
46
+ raise
47
+ return
48
+ time.sleep(self.interval)
49
+ else:
50
+ return
51
+
52
+ async def connect_async(self, operation: Callable[[], Awaitable[None]]) -> None:
53
+ """Retry an asynchronous connection operation without blocking the event loop.
54
+
55
+ :param operation: Connection attempt.
56
+ """
57
+ for attempt in range(self.attempts):
58
+ try:
59
+ await operation()
60
+ except CONNECTION_ERRORS:
61
+ if attempt + 1 == self.attempts:
62
+ if self.reraise:
63
+ raise
64
+ return
65
+ await asyncio.sleep(self.interval)
66
+ else:
67
+ return
68
+
69
+ def execute(self, operation: Callable[[], SSHResult]) -> SSHResult:
70
+ """Retry raised command errors and nonzero exit codes.
71
+
72
+ :param operation: Command attempt.
73
+ :return: Successful or final failed command result.
74
+ """
75
+ result = SSHResult("", "Command execution failed after retries", -1)
76
+ for attempt in range(self.attempts):
77
+ try:
78
+ result = operation()
79
+ except (*CONNECTION_ERRORS, CommandNonZeroExitCodeError):
80
+ if attempt + 1 == self.attempts and self.reraise:
81
+ raise
82
+ else:
83
+ if result.exit_code == 0:
84
+ return result
85
+ if attempt + 1 < self.attempts:
86
+ time.sleep(self.interval)
87
+ return self._final_result(result)
88
+
89
+ async def execute_async(self, operation: Callable[[], Awaitable[SSHResult]]) -> SSHResult:
90
+ """Retry asynchronous commands without blocking the event loop.
91
+
92
+ :param operation: Command attempt.
93
+ :return: Successful or final failed command result.
94
+ """
95
+ result = SSHResult("", "Command execution failed after retries", -1)
96
+ for attempt in range(self.attempts):
97
+ try:
98
+ result = await operation()
99
+ except (*CONNECTION_ERRORS, CommandNonZeroExitCodeError):
100
+ if attempt + 1 == self.attempts and self.reraise:
101
+ raise
102
+ else:
103
+ if result.exit_code == 0:
104
+ return result
105
+ if attempt + 1 < self.attempts:
106
+ await asyncio.sleep(self.interval)
107
+ return self._final_result(result)
108
+
109
+ def _final_result(self, result: SSHResult) -> SSHResult:
110
+ if self.reraise:
111
+ raise CommandNonZeroExitCodeError.from_result(result)
112
+ return result
113
+
114
+
115
+ def prepare_retry_error_context(error: Exception) -> tuple[str, str]:
116
+ """Return the exception name and message.
117
+
118
+ :param error: Failed operation exception.
119
+ :return: Exception name and description.
120
+ """
121
+ return type(error).__name__, str(error)
122
+
123
+
124
+ def retry_if_connection_fails(
125
+ retries_attempts_number: int = 3,
126
+ retries_interval_in_seconds: float = 5,
127
+ *,
128
+ reraise_exception: bool = False,
129
+ logger: Logger | None = None,
130
+ ) -> Callable[[Callable[[], None]], Callable[[], None]]:
131
+ """Create a synchronous connection retry decorator.
132
+
133
+ :param retries_attempts_number: Maximum attempts.
134
+ :param retries_interval_in_seconds: Delay between attempts.
135
+ :param reraise_exception: Propagate the final connection error.
136
+ :param logger: Optional logger receiving completion records.
137
+ :return: Connection decorator.
138
+ """
139
+ policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
140
+
141
+ def decorate(operation: Callable[[], None]) -> Callable[[], None]:
142
+ def execute() -> None:
143
+ policy.connect(operation)
144
+ if logger is not None:
145
+ logger.debug("SSH connection retry operation completed")
146
+
147
+ return execute
148
+
149
+ return decorate
150
+
151
+
152
+ def retry_if_command_fails(
153
+ retries_attempts_number: int = 3,
154
+ retries_interval_in_seconds: float = 5,
155
+ *,
156
+ reraise_exception: bool = False,
157
+ logger: Logger | None = None,
158
+ ) -> Callable[[Callable[[], SSHResult]], Callable[[], SSHResult]]:
159
+ """Create a synchronous command retry decorator.
160
+
161
+ :param retries_attempts_number: Maximum attempts.
162
+ :param retries_interval_in_seconds: Delay between attempts.
163
+ :param reraise_exception: Propagate final command failure.
164
+ :param logger: Optional logger receiving completion records.
165
+ :return: Command decorator.
166
+ """
167
+ policy = RetryPolicy(retries_attempts_number, retries_interval_in_seconds, reraise=reraise_exception)
168
+
169
+ def decorate(operation: Callable[[], SSHResult]) -> Callable[[], SSHResult]:
170
+ def execute() -> SSHResult:
171
+ result = policy.execute(operation)
172
+ if logger is not None:
173
+ logger.debug("SSH command retry operation completed with exit code %s", result.exit_code)
174
+ return result
175
+
176
+ return execute
177
+
178
+ return decorate