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.
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Unpack, cast
6
+
7
+ import asyncssh
8
+
9
+ from ssh_client.client import BaseAsyncSSHClient
10
+ from ssh_client.config import ExecutionSettings
11
+ from ssh_client.results import LiveSSHResult, SSHResult
12
+
13
+ from .exceptions import CommandNonZeroExitCodeError, SSHClientNotDefinedError
14
+
15
+ if TYPE_CHECKING:
16
+ from contextlib import AbstractAsyncContextManager
17
+
18
+ from ssh_client.types import AsyncOperation, ExecuteOptions, InterruptCallback, LiveExecuteOptions, OutputCallback
19
+
20
+
21
+ @dataclass
22
+ class _StreamReader:
23
+ process: asyncssh.SSHClientProcess[str]
24
+ lines: list[str]
25
+ callbacks: tuple[OutputCallback | None, ...]
26
+ interrupt: InterruptCallback | None
27
+
28
+ async def read(self, stream: asyncssh.SSHReader[str]) -> None:
29
+ async for raw in stream:
30
+ if not raw:
31
+ continue
32
+ line = raw.removesuffix("\n").removesuffix("\r")
33
+ self.lines.append(line)
34
+ for callback in self.callbacks:
35
+ if callback is not None:
36
+ callback(line)
37
+ if self.interrupt is not None and self.interrupt(line):
38
+ self.process.send_signal("INT")
39
+ return
40
+
41
+
42
+ class AsyncSSHClient(BaseAsyncSSHClient[asyncssh.SSHClientConnection]):
43
+ """Run asynchronous SSH commands with trusted server keys."""
44
+
45
+ async def connect(self) -> None:
46
+ """Open a connection after checking the server host key."""
47
+ if self._connected:
48
+ return
49
+ operation: AsyncOperation[None] = self._connect_attempt
50
+ operation = self._decorate(operation, self._connect_decorators)
51
+ if self.connection_retry is None:
52
+ await operation()
53
+ else:
54
+ await self.connection_retry.connect_async(operation)
55
+
56
+ async def execute(self, command: str | list[str], **options: Unpack[ExecuteOptions]) -> SSHResult:
57
+ """Execute a command and wait for connection cleanup.
58
+
59
+ :param command: Shell command or preformatted shell fragments.
60
+ :param options: Timeout and keyword-only status-check settings.
61
+ :return: Captured output and exit status.
62
+ """
63
+ settings = ExecutionSettings(**options)
64
+ text = " ".join(command) if isinstance(command, list) else command
65
+ try:
66
+ await self.connect()
67
+ self._request(text)
68
+
69
+ async def execute() -> SSHResult:
70
+ return await self._execute_command(text, settings)
71
+
72
+ operation: AsyncOperation[SSHResult] = execute
73
+ operation = self._decorate(operation, self._execute_decorators)
74
+ result = (
75
+ await operation() if self.command_retry is None else await self.command_retry.execute_async(operation)
76
+ )
77
+ self._response(text, result)
78
+ if settings.raise_for_status and result.exit_code != 0:
79
+ raise CommandNonZeroExitCodeError.from_result(result)
80
+ return result
81
+ finally:
82
+ await self.aclose()
83
+
84
+ async def execute_live(self, command: str | list[str], **options: Unpack[LiveExecuteOptions]) -> LiveSSHResult:
85
+ """Stream stdout and stderr concurrently and release the connection.
86
+
87
+ :param command: Shell command or preformatted shell fragments.
88
+ :param options: Timeout, status checking, output callbacks and interrupt callbacks.
89
+ :return: Collected output lines and exit status.
90
+ """
91
+ settings = ExecutionSettings(**options)
92
+ text = " ".join(command) if isinstance(command, list) else command
93
+ try:
94
+ await self.connect()
95
+ self._request(text)
96
+ result = await self._execute_live_command(text, settings)
97
+ self._response(text, result)
98
+ if settings.raise_for_status and result.exit_code != 0:
99
+ raise CommandNonZeroExitCodeError.from_result(result)
100
+ return result
101
+ finally:
102
+ await self.aclose()
103
+
104
+ async def _connect_attempt(self) -> None:
105
+ await self.aclose()
106
+ if not self.username:
107
+ message = "Username must be set by authentication options (with_password or with_private_key)"
108
+ raise ValueError(message)
109
+ client_keys: list[str] | tuple[()] = [self.private_key_path] if self.private_key_path is not None else ()
110
+ self._client = await asyncssh.connect(
111
+ self.host,
112
+ port=self.port,
113
+ username=self.username,
114
+ password=self.password,
115
+ client_keys=client_keys,
116
+ known_hosts=self.known_hosts if self.known_hosts is not None else (),
117
+ connect_timeout=self.connection_timeout if self.connection_timeout is not None else self.timeout,
118
+ )
119
+ self._connected = True
120
+
121
+ def _require_client(self) -> asyncssh.SSHClientConnection:
122
+ if self._client is None or not self._connected:
123
+ raise SSHClientNotDefinedError
124
+ return self._client
125
+
126
+ async def _execute_command(self, command: str, settings: ExecutionSettings) -> SSHResult:
127
+ async with asyncio.timeout(self.timeout if settings.timeout is None else settings.timeout):
128
+ result = await self._require_client().run(command, encoding="utf-8")
129
+ output = result.stdout.decode("utf-8") if isinstance(result.stdout, bytes) else result.stdout
130
+ errors = result.stderr.decode("utf-8") if isinstance(result.stderr, bytes) else result.stderr
131
+ return SSHResult(output or "", errors or "", result.returncode)
132
+
133
+ async def _execute_live_command(self, command: str, settings: ExecutionSettings) -> LiveSSHResult:
134
+ async with cast(
135
+ "AbstractAsyncContextManager[asyncssh.SSHClientProcess[str]]",
136
+ self._require_client().create_process(command, encoding="utf-8"),
137
+ ) as process:
138
+ output: list[str] = []
139
+ errors: list[str] = []
140
+ stdout = _StreamReader(
141
+ process,
142
+ output,
143
+ (settings.stdout_callback, self.live_stdout_callback),
144
+ settings.interrupt_stdout_callback,
145
+ )
146
+ stderr = _StreamReader(
147
+ process,
148
+ errors,
149
+ (settings.stderr_callback, self.live_stderr_callback),
150
+ settings.interrupt_stderr_callback,
151
+ )
152
+ async with asyncio.timeout(self.timeout if settings.timeout is None else settings.timeout):
153
+ await asyncio.gather(stdout.read(process.stdout), stderr.read(process.stderr))
154
+ await process.wait()
155
+ return LiveSSHResult(output, errors, process.returncode)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from ssh_client.results import LiveSSHResult, SSHResult
4
+
5
+
6
+ class SSHClientNotDefinedError(Exception):
7
+ """Report an operation without an authenticated transport."""
8
+
9
+ def __init__(self, message: str = "SSH client is not defined") -> None:
10
+ """Keep the transport failure reason.
11
+
12
+ :param message: Failure description.
13
+ """
14
+ self.message = message
15
+ super().__init__(message)
16
+
17
+
18
+ class CommandNonZeroExitCodeError(Exception):
19
+ """Report a command which returned a failing exit status."""
20
+
21
+ @classmethod
22
+ def from_result(cls, result: SSHResult | LiveSSHResult) -> CommandNonZeroExitCodeError:
23
+ """Describe the failed command result.
24
+
25
+ :param result: Failed command output and exit status.
26
+ :return: Exception describing the command failure.
27
+ """
28
+ stderr = result.stderr if isinstance(result, SSHResult) else "\n".join(result.stderr_lines)
29
+ return cls(f"Command failed with exit code {result.exit_code}: {stderr}")
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from codecs import getincrementaldecoder
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Unpack
7
+
8
+ import paramiko
9
+
10
+ from ssh_client.client import BaseSSHClient
11
+ from ssh_client.config import ExecutionSettings
12
+ from ssh_client.results import LiveSSHResult, SSHResult
13
+
14
+ from .exceptions import CommandNonZeroExitCodeError, SSHClientNotDefinedError
15
+
16
+ if TYPE_CHECKING:
17
+ from codecs import IncrementalDecoder
18
+
19
+ from ssh_client.types import ExecuteOptions, LiveExecuteOptions, OutputCallback
20
+
21
+
22
+ @dataclass
23
+ class _LiveOutput:
24
+ settings: ExecutionSettings
25
+ callbacks: tuple[OutputCallback | None, ...]
26
+ lines: list[str] = field(default_factory=list)
27
+ pending: str = ""
28
+ decoder: IncrementalDecoder = field(default_factory=getincrementaldecoder("utf-8"))
29
+
30
+ def receive(self, data: bytes, *, final: bool = False) -> bool:
31
+ self.pending += self.decoder.decode(data, final=final)
32
+ while "\n" in self.pending:
33
+ line, self.pending = self.pending.split("\n", 1)
34
+ if self._line(line.removesuffix("\r")):
35
+ return True
36
+ return bool(final and self.pending and self._line(self.pending))
37
+
38
+ def _line(self, line: str) -> bool:
39
+ self.lines.append(line)
40
+ for callback in self.callbacks:
41
+ if callback is not None:
42
+ callback(line)
43
+ interrupt = self.settings.interrupt_stdout_callback
44
+ return interrupt is not None and interrupt(line)
45
+
46
+
47
+ class SyncSSHClient(BaseSSHClient[paramiko.SSHClient]):
48
+ """Run SSH commands through Paramiko with trusted server keys."""
49
+
50
+ def connect(self) -> None:
51
+ """Open a connection after checking the server host key."""
52
+ if self._connected:
53
+ return
54
+ operation = self._decorate(self._connect_attempt, self._connect_decorators)
55
+ if self.connection_retry is None:
56
+ operation()
57
+ else:
58
+ self.connection_retry.connect(operation)
59
+
60
+ def execute(self, command: str | list[str], **options: Unpack[ExecuteOptions]) -> SSHResult:
61
+ """Execute a command and close its connection.
62
+
63
+ :param command: Shell command or preformatted shell fragments.
64
+ :param options: Timeout and keyword-only status-check settings.
65
+ :return: Captured output and exit status.
66
+ """
67
+ settings = ExecutionSettings(**options)
68
+ text = " ".join(command) if isinstance(command, list) else command
69
+ try:
70
+ self.connect()
71
+ self._request(text)
72
+
73
+ def execute() -> SSHResult:
74
+ return self._execute_command(text, settings)
75
+
76
+ operation = self._decorate(execute, self._execute_decorators)
77
+ result = operation() if self.command_retry is None else self.command_retry.execute(operation)
78
+ self._response(text, result)
79
+ if settings.raise_for_status and result.exit_code != 0:
80
+ raise CommandNonZeroExitCodeError.from_result(result)
81
+ return result
82
+ finally:
83
+ self.close()
84
+
85
+ def execute_live(self, command: str | list[str], **options: Unpack[LiveExecuteOptions]) -> LiveSSHResult:
86
+ """Stream command lines through callbacks and close the connection.
87
+
88
+ Paramiko uses a terminal so that interrupting output also stops the remote process;
89
+ stderr is therefore merged into stdout.
90
+
91
+ :param command: Shell command or preformatted shell fragments.
92
+ :param options: Timeout, status checking, stdout callbacks and interrupt callback.
93
+ :return: Collected output lines and exit status.
94
+ """
95
+ settings = ExecutionSettings(**options)
96
+ text = " ".join(command) if isinstance(command, list) else command
97
+ try:
98
+ self.connect()
99
+ self._request(text)
100
+ result = self._execute_live_command(text, settings)
101
+ self._response(text, result)
102
+ if settings.raise_for_status and result.exit_code != 0:
103
+ raise CommandNonZeroExitCodeError.from_result(result)
104
+ return result
105
+ finally:
106
+ self.close()
107
+
108
+ def _connect_attempt(self) -> None:
109
+ self.close()
110
+ if not self.username:
111
+ message = "Username must be set by authentication options (with_password or with_private_key)"
112
+ raise ValueError(message)
113
+ self._client = paramiko.SSHClient()
114
+ if self.known_hosts is None:
115
+ self._client.load_system_host_keys()
116
+ else:
117
+ self._client.load_host_keys(self.known_hosts)
118
+ try:
119
+ self._client.connect(
120
+ hostname=self.host,
121
+ port=self.port,
122
+ username=self.username,
123
+ password=self.password,
124
+ key_filename=self.private_key_path,
125
+ timeout=self.connection_timeout if self.connection_timeout is not None else self.timeout,
126
+ allow_agent=self.password is None and self.private_key_path is None,
127
+ look_for_keys=self.password is None and self.private_key_path is None,
128
+ )
129
+ except BaseException:
130
+ self.close()
131
+ raise
132
+ self._connected = True
133
+
134
+ def _require_client(self) -> paramiko.SSHClient:
135
+ if self._client is None or not self._connected:
136
+ raise SSHClientNotDefinedError
137
+ return self._client
138
+
139
+ def _execute_command(self, command: str, settings: ExecutionSettings) -> SSHResult:
140
+ transport = self._require_client()
141
+ stdin, stdout, stderr = transport.exec_command(command, timeout=settings.timeout or self.timeout)
142
+ stdin.close()
143
+ try:
144
+ return self._collect_output(stdout.channel, settings)
145
+ finally:
146
+ stdout.close()
147
+ stderr.close()
148
+
149
+ def _collect_output(self, channel: paramiko.Channel, settings: ExecutionSettings) -> SSHResult:
150
+ output = bytearray()
151
+ errors = bytearray()
152
+ deadline = time.monotonic() + (self.timeout if settings.timeout is None else settings.timeout)
153
+ while True:
154
+ if channel.recv_ready():
155
+ output.extend(channel.recv(65536))
156
+ if channel.recv_stderr_ready():
157
+ errors.extend(channel.recv_stderr(65536))
158
+ if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
159
+ return SSHResult(output.decode("utf-8"), errors.decode("utf-8"), channel.recv_exit_status())
160
+ self._wait_for_output(deadline)
161
+
162
+ def _execute_live_command(self, command: str, settings: ExecutionSettings) -> LiveSSHResult:
163
+ stdin, stdout, stderr = self._require_client().exec_command(
164
+ command,
165
+ timeout=settings.timeout or self.timeout,
166
+ get_pty=True,
167
+ )
168
+ stdin.close()
169
+ output = _LiveOutput(settings, (settings.stdout_callback, self.live_stdout_callback))
170
+ deadline = time.monotonic() + (self.timeout if settings.timeout is None else settings.timeout)
171
+ try:
172
+ while True:
173
+ if stdout.channel.recv_ready() and output.receive(stdout.channel.recv(65536)):
174
+ stdout.channel.send(b"\x03")
175
+ stdout.channel.close()
176
+ return LiveSSHResult(output.lines, [], -1)
177
+ if stdout.channel.exit_status_ready() and not stdout.channel.recv_ready():
178
+ output.receive(b"", final=True)
179
+ return LiveSSHResult(output.lines, [], stdout.channel.recv_exit_status())
180
+ self._wait_for_output(deadline)
181
+ finally:
182
+ stdout.close()
183
+ stderr.close()
184
+
185
+ @staticmethod
186
+ def _wait_for_output(deadline: float) -> None:
187
+ if time.monotonic() >= deadline:
188
+ message = "SSH command timed out"
189
+ raise TimeoutError(message)
190
+ time.sleep(0.01)
ssh_client/config.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .types import InterruptCallback, OutputCallback
8
+ from .utils.retries import RetryPolicy
9
+
10
+
11
+ @dataclass
12
+ class SSHClientConfig:
13
+ """Store connection and command settings."""
14
+
15
+ username: str | None = None
16
+ password: str | None = None
17
+ private_key_path: str | None = None
18
+ known_hosts: str | None = None
19
+ timeout: float = 30
20
+ connection_timeout: float | None = None
21
+ connection_retry: RetryPolicy | None = None
22
+ command_retry: RetryPolicy | None = None
23
+ live_stdout_callback: OutputCallback | None = None
24
+ live_stderr_callback: OutputCallback | None = None
25
+
26
+
27
+ @dataclass
28
+ class ExecutionSettings:
29
+ """Group command settings passed to execution helpers."""
30
+
31
+ timeout: float | None = None
32
+ raise_for_status: bool = False
33
+ stdout_callback: OutputCallback | None = None
34
+ stderr_callback: OutputCallback | None = None
35
+ interrupt_stdout_callback: InterruptCallback | None = None
36
+ interrupt_stderr_callback: InterruptCallback | None = None
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .results import LiveSSHResult
7
+ from .utils.masking import mask_request_data, mask_response_data
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+ from logging import Logger
12
+
13
+ from .results import SSHResult
14
+ from .types import RequestHook, ResponseHook, StructuredLogger
15
+
16
+ type MaskPattern = str | re.Pattern[str]
17
+
18
+
19
+ def _outputs(result: SSHResult | LiveSSHResult, patterns: Sequence[MaskPattern]) -> tuple[str, str]:
20
+ if isinstance(result, LiveSSHResult):
21
+ return mask_response_data("\n".join(result.stdout_lines), "\n".join(result.stderr_lines), patterns)
22
+ return mask_response_data(result.stdout, result.stderr, patterns)
23
+
24
+
25
+ def create_request_logger_hook(logger: Logger, mask_patterns: Sequence[MaskPattern] | None = None) -> RequestHook:
26
+ """Create a sanitized request log callback.
27
+
28
+ :param logger: Standard logger.
29
+ :param mask_patterns: Literal secrets or regular expressions.
30
+ :return: Request observer.
31
+ """
32
+
33
+ def log_request(*, host: str, port: int, username: str | None, command: str) -> None:
34
+ logger.info(
35
+ "SSH request: host=%s, port=%s, username=%s, command=%s",
36
+ host,
37
+ port,
38
+ username,
39
+ mask_request_data(command, mask_patterns or ()),
40
+ )
41
+
42
+ return log_request
43
+
44
+
45
+ def create_response_logger_hook(logger: Logger, mask_patterns: Sequence[MaskPattern] | None = None) -> ResponseHook:
46
+ """Create a sanitized command result log callback.
47
+
48
+ :param logger: Standard logger.
49
+ :param mask_patterns: Literal secrets or regular expressions.
50
+ :return: Response observer.
51
+ """
52
+
53
+ def log_response(*, host: str, command: str, result: SSHResult | LiveSSHResult) -> None:
54
+ output, errors = _outputs(result, mask_patterns or ())
55
+ logger.info(
56
+ "SSH response: host=%s, exit_code=%s, stdout=%s, stderr=%s, command=%s",
57
+ host,
58
+ result.exit_code,
59
+ output,
60
+ errors,
61
+ mask_request_data(command, mask_patterns or ()),
62
+ )
63
+
64
+ return log_response
65
+
66
+
67
+ def create_request_structlogger_hook(
68
+ logger: StructuredLogger,
69
+ mask_patterns: Sequence[MaskPattern] | None = None,
70
+ ) -> RequestHook:
71
+ """Create a sanitized structured request observer.
72
+
73
+ :param logger: Structured logger.
74
+ :param mask_patterns: Literal secrets or regular expressions.
75
+ :return: Request observer.
76
+ """
77
+
78
+ def log_request(*, host: str, port: int, username: str | None, command: str) -> None:
79
+ logger.info(
80
+ "ssh request sent",
81
+ host=host,
82
+ port=port,
83
+ username=username,
84
+ command=mask_request_data(command, mask_patterns or ()),
85
+ )
86
+
87
+ return log_request
88
+
89
+
90
+ def create_response_structlogger_hook(
91
+ logger: StructuredLogger,
92
+ mask_patterns: Sequence[MaskPattern] | None = None,
93
+ ) -> ResponseHook:
94
+ """Create a sanitized structured command result observer.
95
+
96
+ :param logger: Structured logger.
97
+ :param mask_patterns: Literal secrets or regular expressions.
98
+ :return: Response observer.
99
+ """
100
+
101
+ def log_response(*, host: str, command: str, result: SSHResult | LiveSSHResult) -> None:
102
+ output, errors = _outputs(result, mask_patterns or ())
103
+ logger.info(
104
+ "ssh response received",
105
+ host=host,
106
+ exit_code=result.exit_code,
107
+ stdout=output,
108
+ stderr=errors,
109
+ command=mask_request_data(command, mask_patterns or ()),
110
+ )
111
+
112
+ return log_response
File without changes
@@ -0,0 +1,83 @@
1
+ """Command examples with host key verification and connection cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ import structlog
10
+
11
+ from ssh_client import AsyncSSHClient, SyncSSHClient, options
12
+
13
+ if TYPE_CHECKING:
14
+ from ssh_client.types import StructuredLogger
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def sync_example() -> None:
20
+ """Run a command with an SSH agent key and the default known_hosts."""
21
+ with SyncSSHClient("localhost", 22, options.with_private_key("qa-user", "/path/to/private/key")) as client:
22
+ result = client.execute("uname -a", timeout=10, raise_for_status=True)
23
+ logger.info("Remote system: %s", result.stdout)
24
+
25
+
26
+ async def async_example() -> None:
27
+ """Run an asynchronous command with an explicit list of trusted servers."""
28
+ async with AsyncSSHClient(
29
+ "localhost",
30
+ 22,
31
+ options.with_private_key("qa-user", "/path/to/private/key"),
32
+ options.with_known_hosts("/path/to/known_hosts"),
33
+ options.with_logger(logger),
34
+ ) as client:
35
+ result = await client.execute("uname -a", timeout=10, raise_for_status=True)
36
+ logger.info("Remote system: %s", result.stdout)
37
+
38
+
39
+ def advanced_sync_example() -> None:
40
+ """Pass command lines to a handler and limit reconnects."""
41
+ with SyncSSHClient(
42
+ "localhost",
43
+ 22,
44
+ options.with_private_key("qa-user", "/path/to/private/key"),
45
+ options.with_live_execute_callback(lambda line: logger.info("Output: %s", line)),
46
+ options.retry_if_connection_fails(3, 1, reraise_exception=True),
47
+ ) as client:
48
+ result = client.execute_live("printf 'first\\nsecond\\n'", timeout=10, raise_for_status=True)
49
+ logger.info("Command exit status: %s", result.exit_code)
50
+
51
+
52
+ async def advanced_async_example() -> None:
53
+ """Override settings for one command using a separate client copy."""
54
+ async with AsyncSSHClient(
55
+ "localhost",
56
+ 22,
57
+ options.with_private_key("qa-user", "/path/to/private/key"),
58
+ ) as client:
59
+ async with client.with_options(options.with_request_timeout(10)) as temporary:
60
+ result = await temporary.execute_live("printf 'one\\ntwo\\n'", raise_for_status=True)
61
+ logger.info("Output: %s", result.stdout_lines)
62
+
63
+
64
+ def structlog_example() -> None:
65
+ """Log a command through structlog with the specified text masked."""
66
+ structured = cast("StructuredLogger", structlog.get_logger())
67
+ with SyncSSHClient(
68
+ "localhost",
69
+ 22,
70
+ options.with_private_key("qa-user", "/path/to/private/key"),
71
+ options.with_structlogger(structured, mask_patterns=["sensitive-value"]),
72
+ ) as client:
73
+ result = client.execute("printf 'sensitive-value'", raise_for_status=True)
74
+ logger.info("Command exit status: %s", result.exit_code)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ logging.basicConfig(level=logging.INFO)
79
+ sync_example()
80
+ asyncio.run(async_example())
81
+ advanced_sync_example()
82
+ asyncio.run(advanced_async_example())
83
+ structlog_example()