remote-cmd-manager 2.2.0__tar.gz → 2.4.0__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 (81) hide show
  1. {remote_cmd_manager-2.2.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.4.0}/PKG-INFO +20 -14
  2. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/README.md +16 -13
  3. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/pyproject.toml +3 -0
  4. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/__init__.py +1 -1
  5. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/_version.py +1 -1
  6. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/async_ssh_client.py +141 -13
  7. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/ssh_client.py +161 -28
  8. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_host_runner.py +60 -3
  9. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/async_batch_executor.py +15 -1
  10. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/batch_executor.py +23 -2
  11. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/logging_utils.py +7 -2
  12. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0/remote_cmd_manager.egg-info}/PKG-INFO +20 -14
  13. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_async_batch_executor.py +118 -0
  14. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_async_ssh_client.py +253 -0
  15. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_batch_executor.py +100 -1
  16. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_logging_utils.py +14 -0
  17. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_ssh_client.py +132 -0
  18. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/LICENSE +0 -0
  19. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/MANIFEST.in +0 -0
  20. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/config.example.yaml +0 -0
  21. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/examples/basic_usage.py +0 -0
  22. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/examples/deploy_script.py +0 -0
  23. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/examples/nginx_batch_update.py +0 -0
  24. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/examples/system_health_check.py +0 -0
  25. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/cli/__init__.py +0 -0
  26. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/cli/main.py +0 -0
  27. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/__init__.py +0 -0
  28. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/async_connection_pool.py +0 -0
  29. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/host.py +0 -0
  30. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/host_manager.py +0 -0
  31. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/sync_connection_pool.py +0 -0
  32. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/__init__.py +0 -0
  33. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/host_repository.py +0 -0
  34. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/json_host_repository.py +0 -0
  35. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/sqlite_host_repository.py +0 -0
  36. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/__init__.py +0 -0
  37. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_pool_policy.py +0 -0
  38. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_types.py +0 -0
  39. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/credential_provider.py +0 -0
  40. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/host_service.py +0 -0
  41. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/retry_policy.py +0 -0
  42. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/ssh_service.py +0 -0
  43. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/storage_factory.py +0 -0
  44. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/task_runner.py +0 -0
  45. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/__init__.py +0 -0
  46. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/config.py +0 -0
  47. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/credential_guard.py +0 -0
  48. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/crypto.py +0 -0
  49. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/exceptions.py +0 -0
  50. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/SOURCES.txt +0 -0
  51. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  52. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  53. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
  54. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  55. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/requirements.txt +0 -0
  56. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/setup.cfg +0 -0
  57. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/__init__.py +0 -0
  58. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/conftest.py +0 -0
  59. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/integration/conftest.py +0 -0
  60. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/integration/test_ssh_connection.py +0 -0
  61. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/performance/__init__.py +0 -0
  62. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/performance/conftest.py +0 -0
  63. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/performance/test_benchmarks.py +0 -0
  64. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_cli.py +0 -0
  65. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_config.py +0 -0
  66. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_credential_provider.py +0 -0
  67. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_crypto.py +0 -0
  68. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_exceptions.py +0 -0
  69. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_host.py +0 -0
  70. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_host_manager.py +0 -0
  71. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_host_service.py +0 -0
  72. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_keyring_provider.py +0 -0
  73. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_pool_policy.py +0 -0
  74. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_release_metadata.py +0 -0
  75. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_repository.py +0 -0
  76. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_retry_policy.py +0 -0
  77. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_sqlite_repository.py +0 -0
  78. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_ssh_service.py +0 -0
  79. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_storage_factory.py +0 -0
  80. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_sync_connection_pool.py +0 -0
  81. {remote_cmd_manager-2.2.0 → remote_cmd_manager-2.4.0}/tests/test_task_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote_cmd_manager
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more.
5
5
  Author-email: Vae-Scrooge <vaescrooge@gmail.com>
6
6
  License: MIT
@@ -18,6 +18,9 @@ Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Programming Language :: Python :: 3.9
19
19
  Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
21
24
  Classifier: Topic :: System :: Systems Administration
22
25
  Classifier: Topic :: System :: Networking
23
26
  Classifier: Environment :: Console
@@ -86,27 +89,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
86
89
 
87
90
  ---
88
91
 
89
- ## v2.2.0 Release Highlights
92
+ ## v2.4.0 Release Highlights
90
93
 
91
- v2.2.0 bounds batch-execution resource usage, makes concurrent SQLite writers safe, tightens
92
- retry classification, and hardens the release pipeline. See the
94
+ v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
95
+ handler resources, with default behavior and public result schemas unchanged. See the
93
96
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
94
97
 
95
- ### Resource & Reliability
98
+ ### Bounded Batch Output
96
99
 
97
- - Internal batch pools are now created lazily per host, capped at one connection, and closed as soon as that host (including all retries) finishes; batch-wide live internal connections are bounded by `max_concurrency` instead of retaining one idle connection per host until the batch ends.
98
- - SQLite writes now use `BEGIN IMMEDIATE` with a configurable `busy_timeout` (default 5000 ms), and the initial `journal_mode=WAL` switch is retried; concurrent `remote-cmd` processes writing the same `hosts.db` no longer fail with `database is locked` or lose updates. Schema and `db_version` are unchanged.
99
- - Pool closure raises the new `PoolClosedError` (still catchable as `RuntimeError`); retry classification is corrected so bare `RuntimeError` is retryable again (v2.0 behavior) while pool closure remains non-retryable. Typed permanent and transient errors are unchanged.
100
+ - `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
101
+ - Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
102
+ - The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
100
103
 
101
- ### Release Engineering
104
+ ### Reliability
102
105
 
103
- - Publish workflow: artifact actions aligned, run-scoped artifact names, duplicate version publication now fails instead of being skipped, `twine check` validates distributions, and the release tag is verified against the package version. Trusted Publishing and the GitHub Release → PyPI flow are unchanged.
104
- - CI documentation drift detection: `docs/api` is regenerated and compared on relevant changes, failing when tracked generated docs are stale (`pdoc` pinned to `15.0.4` on Python 3.12).
105
- - Release metadata validation: a lightweight gate checks the single-source version, the `pyproject.toml` dynamic-version attribute, `[Unreleased]` and matching `[x.y.z]` CHANGELOG headings, and (on release) that the git tag matches the package version.
106
+ - `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
107
+ - Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
108
+
109
+ ### Compatibility
110
+
111
+ - Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
106
112
 
107
113
  ## Table of Contents
108
114
 
109
- - [v2.2.0 Release Highlights](#v220-release-highlights)
115
+ - [v2.4.0 Release Highlights](#v240-release-highlights)
110
116
  - [Why Remote CMD?](#why-remote-cmd)
111
117
  - [Quick Start](#quick-start)
112
118
  - [Use Cases](#use-cases)
@@ -254,7 +260,7 @@ with SSHClient(config) as client:
254
260
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
255
261
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
256
262
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
257
- | **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
263
+ | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
258
264
  | **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
259
265
  | **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
260
266
  | **Connection Test** | Test all host SSH connections and report status |
@@ -36,27 +36,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
36
36
 
37
37
  ---
38
38
 
39
- ## v2.2.0 Release Highlights
39
+ ## v2.4.0 Release Highlights
40
40
 
41
- v2.2.0 bounds batch-execution resource usage, makes concurrent SQLite writers safe, tightens
42
- retry classification, and hardens the release pipeline. See the
41
+ v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
42
+ handler resources, with default behavior and public result schemas unchanged. See the
43
43
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
44
44
 
45
- ### Resource & Reliability
45
+ ### Bounded Batch Output
46
46
 
47
- - Internal batch pools are now created lazily per host, capped at one connection, and closed as soon as that host (including all retries) finishes; batch-wide live internal connections are bounded by `max_concurrency` instead of retaining one idle connection per host until the batch ends.
48
- - SQLite writes now use `BEGIN IMMEDIATE` with a configurable `busy_timeout` (default 5000 ms), and the initial `journal_mode=WAL` switch is retried; concurrent `remote-cmd` processes writing the same `hosts.db` no longer fail with `database is locked` or lose updates. Schema and `db_version` are unchanged.
49
- - Pool closure raises the new `PoolClosedError` (still catchable as `RuntimeError`); retry classification is corrected so bare `RuntimeError` is retryable again (v2.0 behavior) while pool closure remains non-retryable. Typed permanent and transient errors are unchanged.
47
+ - `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
48
+ - Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
49
+ - The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
50
50
 
51
- ### Release Engineering
51
+ ### Reliability
52
52
 
53
- - Publish workflow: artifact actions aligned, run-scoped artifact names, duplicate version publication now fails instead of being skipped, `twine check` validates distributions, and the release tag is verified against the package version. Trusted Publishing and the GitHub Release → PyPI flow are unchanged.
54
- - CI documentation drift detection: `docs/api` is regenerated and compared on relevant changes, failing when tracked generated docs are stale (`pdoc` pinned to `15.0.4` on Python 3.12).
55
- - Release metadata validation: a lightweight gate checks the single-source version, the `pyproject.toml` dynamic-version attribute, `[Unreleased]` and matching `[x.y.z]` CHANGELOG headings, and (on release) that the git tag matches the package version.
53
+ - `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
54
+ - Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
55
+
56
+ ### Compatibility
57
+
58
+ - Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
56
59
 
57
60
  ## Table of Contents
58
61
 
59
- - [v2.2.0 Release Highlights](#v220-release-highlights)
62
+ - [v2.4.0 Release Highlights](#v240-release-highlights)
60
63
  - [Why Remote CMD?](#why-remote-cmd)
61
64
  - [Quick Start](#quick-start)
62
65
  - [Use Cases](#use-cases)
@@ -204,7 +207,7 @@ with SSHClient(config) as client:
204
207
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
205
208
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
206
209
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
207
- | **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
210
+ | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
208
211
  | **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
209
212
  | **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
210
213
  | **Connection Test** | Test all host SSH connections and report status |
@@ -22,6 +22,9 @@ classifiers = [
22
22
  "Programming Language :: Python :: 3.9",
23
23
  "Programming Language :: Python :: 3.10",
24
24
  "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
25
28
  "Topic :: System :: Systems Administration",
26
29
  "Topic :: System :: Networking",
27
30
  "Environment :: Console",
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
48
48
  - 文档: 参见 docs/ 目录
49
49
 
50
50
  Author: Vae-Scrooge
51
- Version: 2.2.0(单一真相源见 remote_cmd._version)
51
+ Version: 2.4.0(单一真相源见 remote_cmd._version)
52
52
  License: MIT
53
53
  """
54
54
 
@@ -9,4 +9,4 @@
9
9
  - ``__version__`` 由 ``remote_cmd/__init__.py`` 导入并继续作为公共 API 导出。
10
10
  """
11
11
 
12
- __version__ = "2.2.0"
12
+ __version__ = "2.4.0"
@@ -14,9 +14,12 @@
14
14
  - 密码、密钥等敏感信息不入日志(沿用项目 SensitiveDataFilter 规范)。
15
15
  """
16
16
 
17
+ import asyncio
18
+ import contextlib
17
19
  import logging
18
20
  import shlex
19
21
  import stat
22
+ from collections.abc import Awaitable, Callable, Sequence
20
23
  from pathlib import Path
21
24
  from typing import Any, Optional
22
25
 
@@ -34,6 +37,7 @@ from remote_cmd.utils.exceptions import (
34
37
  SSHConnectionError,
35
38
  SSHFileTransferError,
36
39
  SSHTimeoutError,
40
+ ValidationError,
37
41
  )
38
42
 
39
43
  logger = logging.getLogger(__name__)
@@ -286,45 +290,169 @@ class AsyncSSHClient:
286
290
  # ------------------------------------------------------------------
287
291
  # SFTP / 文件传输
288
292
  # ------------------------------------------------------------------
289
- async def _get_sftp(self) -> asyncssh.SFTPClient:
293
+ async def _get_sftp(self, timeout: Optional[float] = None) -> asyncssh.SFTPClient:
294
+ """获取 SFTP 客户端(延迟初始化)。
295
+
296
+ v2.3:打开 SFTP channel 本身也受有效超时约束,避免握手阶段永久挂起。
297
+ """
290
298
  conn = await self._get_conn()
291
299
  if self._sftp is None:
300
+ effective = self._effective_sftp_timeout(timeout)
292
301
  try:
293
- self._sftp = await conn.start_sftp_client()
302
+ self._sftp = await asyncio.wait_for(conn.start_sftp_client(), timeout=effective)
303
+ except asyncio.TimeoutError as e:
304
+ raise SSHFileTransferError(
305
+ f"failed to open SFTP channel: timed out after {effective:g} seconds"
306
+ ) from e
294
307
  except (OSError, asyncssh.Error) as e:
295
308
  raise SSHFileTransferError(f"failed to open SFTP channel: {e}") from e
296
309
  return self._sftp
297
310
 
298
- async def upload_file(self, local_path: str, remote_path: str) -> None:
299
- """异步上传本地文件到远程服务器。"""
300
- sftp = await self._get_sftp()
311
+ def _effective_sftp_timeout(self, timeout: Optional[float]) -> float:
312
+ """返回 SFTP 操作的有效 inactivity 超时(秒)。
313
+
314
+ 显式 timeout 优先,否则回退到 ``ConnectionConfig.timeout``。
315
+ """
316
+ if timeout is not None:
317
+ if timeout <= 0:
318
+ raise ValidationError(f"timeout must be > 0, got: {timeout}")
319
+ return timeout
320
+ return self.config.timeout
321
+
322
+ def _discard_sftp(self) -> None:
323
+ """关闭并丢弃缓存的 SFTP 会话(超时后防止复用可能失步的会话)。"""
324
+ sftp, self._sftp = self._sftp, None
325
+ if sftp is not None:
326
+ with contextlib.suppress(Exception):
327
+ sftp.exit()
328
+
329
+ async def _run_sftp_operation(
330
+ self,
331
+ operation: Callable[[Callable[..., None]], Awaitable[Any]],
332
+ timeout: Optional[float],
333
+ description: str,
334
+ ) -> Any:
335
+ """运行 SFTP 操作并施加 inactivity(静默)超时。
336
+
337
+ asyncssh 的 SFTP API 没有原生超时参数,因此使用 watchdog:
338
+ ``operation`` 收到一个 progress 回调(传给 ``put``/``get`` 的
339
+ ``progress_handler``),每次有数据进展就刷新时间戳;超过有效超时
340
+ 没有任何进展则取消操作、丢弃失步的 SFTP 会话并抛出
341
+ ``SSHFileTransferError``。只要数据持续流动,长时间传输不会被打断。
342
+ """
343
+ effective = self._effective_sftp_timeout(timeout)
344
+ loop = asyncio.get_running_loop()
345
+ last_activity = loop.time()
346
+
347
+ def _mark_progress(*_args: Any) -> None:
348
+ nonlocal last_activity
349
+ last_activity = loop.time()
350
+
351
+ task = asyncio.ensure_future(operation(_mark_progress))
352
+ interval = min(0.5, max(effective / 10.0, 0.01))
353
+ cancelled: Optional[asyncio.CancelledError] = None
354
+ try:
355
+ while True:
356
+ done, _pending = await asyncio.wait({task}, timeout=interval)
357
+ if task in done:
358
+ return task.result()
359
+ if loop.time() - last_activity >= effective:
360
+ break
361
+ except asyncio.CancelledError as exc:
362
+ # 外层取消:记录后统一走清理路径(不在此处 await,避免
363
+ # 取消事件再次进入本处理器)
364
+ cancelled = exc
365
+
366
+ # 统一清理:立即丢弃可能失步的 SFTP 会话;取消并等待子任务收尾。
367
+ # shield 确保清理期间到达的再次取消只打断"等待"本身,而不会
368
+ # 把清理过程转换成超时错误或被整体吞掉。
369
+ self._discard_sftp()
370
+ task.cancel()
371
+ try:
372
+ await asyncio.shield(task)
373
+ except asyncio.CancelledError:
374
+ if not task.cancelled():
375
+ # 清理期间外层再次取消:保留取消语义
376
+ raise
377
+ except Exception: # noqa: BLE001 - 子任务失败不应掩盖超时/取消语义
378
+ pass
379
+
380
+ if cancelled is not None:
381
+ raise cancelled
382
+ raise SSHFileTransferError(
383
+ f"{description} timed out after {effective:g} seconds of inactivity"
384
+ )
385
+
386
+ async def upload_file(
387
+ self,
388
+ local_path: str,
389
+ remote_path: str,
390
+ timeout: Optional[float] = None,
391
+ ) -> None:
392
+ """异步上传本地文件到远程服务器。
393
+
394
+ Args:
395
+ local_path: 本地文件路径
396
+ remote_path: 远程目标路径
397
+ timeout: inactivity 超时(秒);None 表示使用
398
+ ``ConnectionConfig.timeout``。静默超过该时长即中止
399
+ """
400
+ sftp = await self._get_sftp(timeout)
301
401
  local_file = Path(local_path)
302
402
  if not local_file.exists():
303
403
  raise SSHFileTransferError(f"Local file not found: {local_path}")
304
404
  logger.info(f"uploading file: {local_path} -> {remote_path}")
405
+
406
+ async def _operation(progress: Callable[..., None]) -> None:
407
+ await sftp.put(str(local_file), remote_path, progress_handler=progress)
408
+
305
409
  try:
306
- await sftp.put(str(local_file), remote_path)
410
+ await self._run_sftp_operation(_operation, timeout, "file upload")
307
411
  except (OSError, asyncssh.Error) as e:
308
412
  raise SSHFileTransferError(f"file upload failed: {e}") from e
309
413
  logger.info("file upload finished")
310
414
 
311
- async def download_file(self, remote_path: str, local_path: str) -> None:
312
- """异步从远程服务器下载文件到本地。"""
313
- sftp = await self._get_sftp()
415
+ async def download_file(
416
+ self,
417
+ remote_path: str,
418
+ local_path: str,
419
+ timeout: Optional[float] = None,
420
+ ) -> None:
421
+ """异步从远程服务器下载文件。
422
+
423
+ Args:
424
+ remote_path: 远程文件路径
425
+ local_path: 本地目标路径
426
+ timeout: inactivity 超时(秒);None 表示使用
427
+ ``ConnectionConfig.timeout``。静默超过该时长即中止
428
+ """
429
+ sftp = await self._get_sftp(timeout)
314
430
  local_file = Path(local_path)
315
431
  local_file.parent.mkdir(parents=True, exist_ok=True)
316
432
  logger.info(f"downloading file: {remote_path} -> {local_path}")
433
+
434
+ async def _operation(progress: Callable[..., None]) -> None:
435
+ await sftp.get(remote_path, str(local_file), progress_handler=progress)
436
+
317
437
  try:
318
- await sftp.get(remote_path, str(local_file))
438
+ await self._run_sftp_operation(_operation, timeout, "file download")
319
439
  except (OSError, asyncssh.Error) as e:
320
440
  raise SSHFileTransferError(f"file download failed: {e}") from e
321
441
  logger.info("file download finished")
322
442
 
323
- async def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
443
+ async def list_remote_directory(
444
+ self,
445
+ remote_path: str = ".",
446
+ timeout: Optional[float] = None,
447
+ ) -> list[RemoteFileEntry]:
324
448
  """异步列出远程目录内容(结构与同步 SSHClient 一致)。"""
325
- sftp = await self._get_sftp()
449
+ sftp = await self._get_sftp(timeout)
450
+
451
+ async def _operation(_progress: Callable[..., None]) -> Sequence[Any]:
452
+ return await sftp.readdir(remote_path)
453
+
326
454
  try:
327
- names = await sftp.readdir(remote_path)
455
+ names = await self._run_sftp_operation(_operation, timeout, "list remote directory")
328
456
  except (OSError, asyncssh.Error) as e:
329
457
  raise SSHFileTransferError(f"failed to list remote directory: {e}") from e
330
458