remote-cmd-manager 1.2.2__tar.gz → 2.0.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 (77) hide show
  1. {remote_cmd_manager-1.2.2/remote_cmd_manager.egg-info → remote_cmd_manager-2.0.0}/PKG-INFO +1 -1
  2. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/pyproject.toml +4 -1
  3. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/__init__.py +4 -2
  4. remote_cmd_manager-2.0.0/remote_cmd/_version.py +12 -0
  5. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/cli/main.py +28 -19
  6. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_connection_pool.py +33 -24
  7. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_ssh_client.py +20 -15
  8. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/host.py +4 -5
  9. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/ssh_client.py +41 -23
  10. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/sync_connection_pool.py +33 -24
  11. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/json_host_repository.py +11 -12
  12. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/sqlite_host_repository.py +12 -10
  13. remote_cmd_manager-2.0.0/remote_cmd/service/_host_runner.py +115 -0
  14. remote_cmd_manager-2.0.0/remote_cmd/service/_pool_policy.py +126 -0
  15. remote_cmd_manager-2.0.0/remote_cmd/service/_types.py +93 -0
  16. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/async_batch_executor.py +25 -42
  17. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/batch_executor.py +39 -140
  18. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/credential_provider.py +11 -5
  19. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/host_service.py +44 -39
  20. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/ssh_service.py +1 -1
  21. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/storage_factory.py +19 -9
  22. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/task_runner.py +1 -1
  23. remote_cmd_manager-2.0.0/remote_cmd/utils/credential_guard.py +126 -0
  24. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/crypto.py +6 -5
  25. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0/remote_cmd_manager.egg-info}/PKG-INFO +1 -1
  26. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/SOURCES.txt +6 -0
  27. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/integration/conftest.py +3 -1
  28. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/integration/test_ssh_connection.py +4 -12
  29. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/performance/conftest.py +1 -3
  30. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/performance/test_benchmarks.py +5 -14
  31. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_async_batch_executor.py +61 -18
  32. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_async_ssh_client.py +5 -11
  33. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_batch_executor.py +43 -3
  34. remote_cmd_manager-2.0.0/tests/test_pool_policy.py +73 -0
  35. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_ssh_client.py +31 -18
  36. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_storage_factory.py +48 -0
  37. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_sync_connection_pool.py +23 -8
  38. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/LICENSE +0 -0
  39. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/MANIFEST.in +0 -0
  40. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/README.md +0 -0
  41. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/config.example.yaml +0 -0
  42. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/basic_usage.py +0 -0
  43. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/deploy_script.py +0 -0
  44. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/nginx_batch_update.py +0 -0
  45. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/system_health_check.py +0 -0
  46. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/cli/__init__.py +0 -0
  47. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/__init__.py +0 -0
  48. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/host_manager.py +0 -0
  49. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/__init__.py +0 -0
  50. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/host_repository.py +0 -0
  51. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/__init__.py +0 -0
  52. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/__init__.py +0 -0
  53. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/config.py +0 -0
  54. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/exceptions.py +0 -0
  55. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/logging_utils.py +0 -0
  56. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  57. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  58. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
  59. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  60. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/requirements.txt +0 -0
  61. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/setup.cfg +0 -0
  62. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/__init__.py +0 -0
  63. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/conftest.py +0 -0
  64. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/performance/__init__.py +0 -0
  65. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_cli.py +0 -0
  66. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_config.py +0 -0
  67. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_credential_provider.py +0 -0
  68. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_crypto.py +0 -0
  69. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_host.py +0 -0
  70. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_host_manager.py +0 -0
  71. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_host_service.py +0 -0
  72. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_keyring_provider.py +0 -0
  73. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_logging_utils.py +0 -0
  74. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_repository.py +0 -0
  75. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_sqlite_repository.py +0 -0
  76. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_ssh_service.py +0 -0
  77. {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.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: 1.2.2
3
+ Version: 2.0.0
4
4
  Summary: SSH 远程服务器管理工具 / Python SSH remote server management tool
5
5
  Author-email: Vae-Scrooge <vaescrooge@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remote_cmd_manager"
7
- version = "1.2.2"
7
+ dynamic = ["version"]
8
8
  description = "SSH 远程服务器管理工具 / Python SSH remote server management tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -63,6 +63,9 @@ Download = "https://pypi.org/project/remote_cmd_manager/"
63
63
  [project.scripts]
64
64
  remote-cmd = "remote_cmd.cli.main:main"
65
65
 
66
+ [tool.setuptools.dynamic]
67
+ version = { attr = "remote_cmd._version.__version__" }
68
+
66
69
  [tool.setuptools.packages.find]
67
70
  include = ["remote_cmd*"]
68
71
 
@@ -48,11 +48,13 @@ Remote CMD - SSH 远程服务器管理工具
48
48
  - 文档: 参见 docs/ 目录
49
49
 
50
50
  Author: Vae-Scrooge
51
- Version: 1.2.2
51
+ Version: 1.2.3
52
52
  License: MIT
53
53
  """
54
54
 
55
- __version__ = "1.2.2"
55
+ # 版本号单一真相源(见 remote_cmd._version)
56
+ from remote_cmd._version import __version__
57
+
56
58
  __author__ = "Vae-Scrooge"
57
59
  __email__ = "vae-scrooge@example.com"
58
60
  __license__ = "MIT"
@@ -0,0 +1,12 @@
1
+ """
2
+ 版本号单一真相源(无任何 import,纯常量)
3
+
4
+ 供 ``remote_cmd/__init__.py`` 与 ``pyproject.toml`` 的 setuptools 动态版本读取。
5
+
6
+ 设计约定:
7
+ - 本模块不 import 任何东西,无副作用——setuptools 通过 ``attr`` 解析版本时
8
+ 不会触发 ``remote_cmd`` 包级 import(避免干净构建环境缺依赖时 ImportError)。
9
+ - ``__version__`` 由 ``remote_cmd/__init__.py`` 导入并继续作为公共 API 导出。
10
+ """
11
+
12
+ __version__ = "2.0.0"
@@ -18,6 +18,7 @@ Main commands:
18
18
 
19
19
  import getpass
20
20
  import os
21
+ from pathlib import Path
21
22
  from typing import Any, Optional
22
23
 
23
24
  import click
@@ -34,6 +35,7 @@ from remote_cmd.service.credential_provider import (
34
35
  from remote_cmd.service.host_service import HostService
35
36
  from remote_cmd.service.storage_factory import build_repository
36
37
  from remote_cmd.utils.config import get_default_config_path, load_config
38
+ from remote_cmd.utils.crypto import CredentialEncryption
37
39
 
38
40
 
39
41
  def _build_service(config_file: str, storage_backend: Optional[str] = None) -> HostService:
@@ -49,7 +51,11 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
49
51
  explicitly, otherwise inferred from the config file extension:
50
52
  .json -> JsonHostRepository; .db/.sqlite -> SqliteHostRepository.
51
53
  """
52
- repo = build_repository(filepath=config_file, storage_backend=storage_backend)
54
+ repo = build_repository(
55
+ filepath=config_file,
56
+ storage_backend=storage_backend,
57
+ encryption=CredentialEncryption(),
58
+ )
53
59
  cred_provider = ChainCredentialProvider(
54
60
  [
55
61
  EnvCredentialProvider(),
@@ -72,7 +78,12 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
72
78
  )
73
79
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output mode")
74
80
  @click.pass_context
75
- def cli(ctx, config: Optional[str], hosts_file_override: Optional[str], verbose: bool):
81
+ def cli(
82
+ ctx: click.Context,
83
+ config: Optional[str],
84
+ hosts_file_override: Optional[str],
85
+ verbose: bool,
86
+ ) -> None:
76
87
  """
77
88
  Remote CMD - SSH remote server management tool
78
89
 
@@ -110,7 +121,7 @@ def cli(ctx, config: Optional[str], hosts_file_override: Optional[str], verbose:
110
121
 
111
122
 
112
123
  @cli.group()
113
- def host():
124
+ def host() -> None:
114
125
  """
115
126
  Host management command group
116
127
 
@@ -136,7 +147,7 @@ def host():
136
147
  @click.option("--description", "-d", default="", help="Host description")
137
148
  @click.pass_context
138
149
  def host_add(
139
- ctx,
150
+ ctx: click.Context,
140
151
  name: str,
141
152
  hostname: str,
142
153
  username: str,
@@ -144,7 +155,7 @@ def host_add(
144
155
  key: Optional[str],
145
156
  tag: tuple,
146
157
  description: str,
147
- ):
158
+ ) -> None:
148
159
  """
149
160
  Add a new host
150
161
 
@@ -202,7 +213,7 @@ def host_add(
202
213
  @host.command("list")
203
214
  @click.option("--tag", "-t", help="Filter hosts by tag")
204
215
  @click.pass_context
205
- def host_list(ctx, tag: Optional[str]):
216
+ def host_list(ctx: click.Context, tag: Optional[str]) -> None:
206
217
  """List all hosts"""
207
218
  service: HostService = ctx.obj["service"]
208
219
  hosts = service.list_hosts(tag=tag)
@@ -228,7 +239,7 @@ def host_list(ctx, tag: Optional[str]):
228
239
  @click.argument("name", required=True)
229
240
  @click.confirmation_option(prompt="Are you sure you want to remove this host?")
230
241
  @click.pass_context
231
- def host_remove(ctx, name: str):
242
+ def host_remove(ctx: click.Context, name: str) -> None:
232
243
  """Remove a host"""
233
244
  service: HostService = ctx.obj["service"]
234
245
 
@@ -245,7 +256,7 @@ def host_remove(ctx, name: str):
245
256
  @host.command("show")
246
257
  @click.argument("name", required=True)
247
258
  @click.pass_context
248
- def host_show(ctx, name: str):
259
+ def host_show(ctx: click.Context, name: str) -> None:
249
260
  """Show host details"""
250
261
  service: HostService = ctx.obj["service"]
251
262
 
@@ -267,8 +278,6 @@ def host_show(ctx, name: str):
267
278
  click.echo(f" Auth: {auth_type}")
268
279
  if host.key_filename:
269
280
  # Sanitize key path: show only the filename, not full path
270
- from pathlib import Path
271
-
272
281
  key_name = Path(host.key_filename).name
273
282
  click.echo(f" Key file: {key_name}")
274
283
  tags_str = ", ".join(host.tags) if host.tags else "-"
@@ -285,7 +294,7 @@ def host_show(ctx, name: str):
285
294
  @host.command("test")
286
295
  @click.argument("name", required=True)
287
296
  @click.pass_context
288
- def host_test(ctx, name: str):
297
+ def host_test(ctx: click.Context, name: str) -> None:
289
298
  """Test host connection"""
290
299
  service: HostService = ctx.obj["service"]
291
300
 
@@ -302,7 +311,7 @@ def host_test(ctx, name: str):
302
311
  @click.argument("host_name", required=True)
303
312
  @click.argument("command", required=True)
304
313
  @click.pass_context
305
- def run(ctx, host_name: str, command: str):
314
+ def run(ctx: click.Context, host_name: str, command: str) -> None:
306
315
  """
307
316
  Execute a command on a remote host
308
317
 
@@ -335,7 +344,7 @@ def run(ctx, host_name: str, command: str):
335
344
  @click.argument("local_path", required=True)
336
345
  @click.argument("remote_path", required=True)
337
346
  @click.pass_context
338
- def upload(ctx, host_name: str, local_path: str, remote_path: str):
347
+ def upload(ctx: click.Context, host_name: str, local_path: str, remote_path: str) -> None:
339
348
  """
340
349
  Upload a file to a remote host
341
350
 
@@ -361,7 +370,7 @@ def upload(ctx, host_name: str, local_path: str, remote_path: str):
361
370
  @click.argument("local_path", required=True)
362
371
  @click.argument("remote_path", required=True)
363
372
  @click.pass_context
364
- def download(ctx, host_name: str, local_path: str, remote_path: str):
373
+ def download(ctx: click.Context, host_name: str, local_path: str, remote_path: str) -> None:
365
374
  """
366
375
  Download a file from a remote host
367
376
 
@@ -393,8 +402,8 @@ def download(ctx, host_name: str, local_path: str, remote_path: str):
393
402
  @click.option("--show-failures", is_flag=True, help="Show only failed hosts")
394
403
  @click.pass_context
395
404
  def batch_run(
396
- ctx,
397
- host_names: tuple,
405
+ ctx: click.Context,
406
+ host_names: tuple[str, ...],
398
407
  command: str,
399
408
  concurrency: int,
400
409
  timeout: int,
@@ -402,7 +411,7 @@ def batch_run(
402
411
  retry_delay: float,
403
412
  use_async: bool,
404
413
  show_failures: bool,
405
- ):
414
+ ) -> None:
406
415
  """
407
416
  Execute a command on multiple hosts in batch
408
417
 
@@ -438,7 +447,7 @@ def batch_run(
438
447
  show_percent=True,
439
448
  ) as bar:
440
449
 
441
- def progress(_completed, _total, _host_name):
450
+ def progress(_completed: int, _total: int, _host_name: str) -> None:
442
451
  bar.update(1)
443
452
 
444
453
  result = executor.execute(
@@ -483,7 +492,7 @@ def batch_run(
483
492
  ctx.exit(1)
484
493
 
485
494
 
486
- def main():
495
+ def main() -> None:
487
496
  """CLI entry point"""
488
497
  cli()
489
498
 
@@ -20,6 +20,12 @@ from typing import Any, Optional
20
20
 
21
21
  from remote_cmd.core.async_ssh_client import AsyncSSHClient
22
22
  from remote_cmd.core.ssh_client import ConnectionConfig
23
+ from remote_cmd.service._pool_policy import (
24
+ ConnectionMeta,
25
+ idle_expired,
26
+ lifetime_expired,
27
+ should_close,
28
+ )
23
29
 
24
30
  logger = logging.getLogger(__name__)
25
31
 
@@ -55,6 +61,9 @@ class AsyncConnectionPool:
55
61
  self._semaphore = asyncio.Semaphore(max_connections)
56
62
  self._lock = asyncio.Lock()
57
63
 
64
+ # 生命周期状态:close_all() 后置 True,禁止再借用/归还
65
+ self._closed = False
66
+
58
67
  # 指标
59
68
  self._total_created = 0
60
69
  self._total_reconnects = 0
@@ -65,7 +74,7 @@ class AsyncConnectionPool:
65
74
  self._monitor_task: Optional[asyncio.Task[None]] = None
66
75
 
67
76
  # 连接元数据(副表,避免侵入 AsyncSSHClient 私有属性)
68
- self._meta: dict[int, dict[str, Any]] = {}
77
+ self._meta: dict[int, ConnectionMeta] = {}
69
78
 
70
79
  # ------------------------------------------------------------------
71
80
  # 指标
@@ -98,7 +107,10 @@ class AsyncConnectionPool:
98
107
 
99
108
  Raises:
100
109
  SSHConnectionError: 创建连接失败
110
+ RuntimeError: 连接池已关闭(close_all 之后)
101
111
  """
112
+ if self._closed:
113
+ raise RuntimeError("connection pool is closed")
102
114
  await self._semaphore.acquire()
103
115
  try:
104
116
  # 优先复用空闲连接
@@ -119,9 +131,15 @@ class AsyncConnectionPool:
119
131
  """归还连接到池中(如已断开/超时则关闭)。"""
120
132
  if conn is None:
121
133
  return
134
+ # 池已关闭:不把连接放回空闲队列(避免游离连接),直接关闭并释放槽位
135
+ if self._closed:
136
+ await self._close_connection(conn)
137
+ self._semaphore.release()
138
+ self._total_released += 1
139
+ return
122
140
  meta = self._meta.get(id(conn))
123
141
  if meta is not None:
124
- meta["last_used"] = time.time()
142
+ meta.last_used = time.time()
125
143
 
126
144
  if not conn.is_connected():
127
145
  await self._close_connection(conn)
@@ -129,10 +147,7 @@ class AsyncConnectionPool:
129
147
  return
130
148
 
131
149
  # 生命周期 / 空闲超时则关闭
132
- if meta and (
133
- time.time() - meta["created_at"] > self._max_lifetime
134
- or time.time() - meta["last_used"] > self._idle_timeout
135
- ):
150
+ if meta and should_close(meta, self._max_lifetime, self._idle_timeout, True):
136
151
  await self._close_connection(conn)
137
152
  self._semaphore.release()
138
153
  return
@@ -162,18 +177,18 @@ class AsyncConnectionPool:
162
177
  raise
163
178
  self._connections.append(client)
164
179
  now = time.time()
165
- self._meta[id(client)] = {
166
- "created_at": now,
167
- "last_used": now,
168
- "conn_id": uuid.uuid4().hex,
169
- }
180
+ self._meta[id(client)] = ConnectionMeta(
181
+ created_at=now,
182
+ last_used=now,
183
+ conn_id=uuid.uuid4().hex,
184
+ )
170
185
  self._total_created += 1
171
186
  return client
172
187
 
173
188
  def _touch(self, conn: AsyncSSHClient) -> None:
174
189
  meta = self._meta.get(id(conn))
175
190
  if meta is not None:
176
- meta["last_used"] = time.time()
191
+ meta.last_used = time.time()
177
192
 
178
193
  async def _check_connection(self, conn: AsyncSSHClient) -> bool:
179
194
  if not conn.is_connected():
@@ -181,14 +196,12 @@ class AsyncConnectionPool:
181
196
  meta = self._meta.get(id(conn))
182
197
  if meta is None:
183
198
  return True
184
- age = time.time() - meta["created_at"]
185
- if age > self._max_lifetime:
186
- logger.debug("connection %s exceeded max lifetime", meta.get("conn_id", "?")[:8])
199
+ if lifetime_expired(meta.created_at, self._max_lifetime):
200
+ logger.debug("connection %s exceeded max lifetime", meta.conn_id[:8])
187
201
  return False
188
202
  # 连接刚使用过(空闲未超时)则信任其状态,避免频繁探活开销
189
203
  # (与 SyncConnectionPool._check_connection 保持一致)
190
- idle = time.time() - meta["last_used"]
191
- if idle < self._idle_timeout:
204
+ if not idle_expired(meta.last_used, self._idle_timeout):
192
205
  return True
193
206
  # 空闲较久才触发轻量探活:发出一个无害命令
194
207
  try:
@@ -239,15 +252,10 @@ class AsyncConnectionPool:
239
252
  keep: list[AsyncSSHClient] = []
240
253
  for conn in snapshot:
241
254
  meta = self._meta.get(id(conn))
242
- if meta is None:
255
+ if should_close(meta, self._max_lifetime, self._idle_timeout, conn.is_connected(), now):
243
256
  await self._close_connection(conn)
244
257
  continue
245
- age = now - meta["created_at"]
246
- idle = now - meta["last_used"]
247
- if age < self._max_lifetime and idle < self._idle_timeout and conn.is_connected():
248
- keep.append(conn)
249
- else:
250
- await self._close_connection(conn)
258
+ keep.append(conn)
251
259
  # 将存活连接放回同一队列对象
252
260
  async with self._lock:
253
261
  for conn in keep:
@@ -275,6 +283,7 @@ class AsyncConnectionPool:
275
283
 
276
284
  async def close_all(self) -> None:
277
285
  """关闭池中所有连接并停止监控。"""
286
+ self._closed = True
278
287
  self.stop_monitor()
279
288
  for conn in list(self._connections):
280
289
  await self._close_connection(conn)
@@ -22,7 +22,7 @@ from typing import Any, Optional
22
22
 
23
23
  import asyncssh
24
24
 
25
- from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig
25
+ from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig, RemoteFileEntry
26
26
  from remote_cmd.utils.exceptions import (
27
27
  SSHCommandError,
28
28
  SSHConnectionError,
@@ -195,7 +195,8 @@ class AsyncSSHClient:
195
195
  + "; "
196
196
  )
197
197
  full_command = f"{env_str}cd ~ && {command}"
198
- logger.debug(f"executing command: {command}")
198
+ # 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
199
+ logger.debug("executing remote command")
199
200
  try:
200
201
  result = await conn.run(
201
202
  full_command,
@@ -204,7 +205,7 @@ class AsyncSSHClient:
204
205
  env={k: str(v) for k, v in (environment or {}).items()},
205
206
  )
206
207
  except (OSError, asyncssh.Error) as e:
207
- raise SSHCommandError(f"command execution failed '{command}': {e}") from e
208
+ raise SSHCommandError(f"command execution failed: {e}") from e
208
209
 
209
210
  stdout_data = (
210
211
  result.stdout
@@ -242,14 +243,16 @@ class AsyncSSHClient:
242
243
  timeout=timeout,
243
244
  )
244
245
  except (OSError, asyncssh.Error) as e:
245
- raise SSHCommandError(f"sudo command execution failed '{command}': {e}") from e
246
+ raise SSHCommandError(f"sudo command execution failed: {e}") from e
246
247
 
247
248
  try:
248
249
  proc.stdin.write(password + "\n")
249
250
  proc.stdin.write_eof()
250
- result = await proc.wait()
251
+ # 与 execute 的 conn.run(timeout=...) 语义对齐:timeout 覆盖整个命令执行
252
+ # wall-clock,避免挂起的 sudo(如等待密码)无限等待
253
+ result = await proc.wait(timeout=timeout)
251
254
  except (OSError, asyncssh.Error) as e:
252
- raise SSHCommandError(f"sudo command execution failed '{command}': {e}") from e
255
+ raise SSHCommandError(f"sudo command execution failed: {e}") from e
253
256
 
254
257
  stdout_data = (
255
258
  result.stdout
@@ -305,7 +308,7 @@ class AsyncSSHClient:
305
308
  raise SSHFileTransferError(f"file download failed: {e}") from e
306
309
  logger.info("file download finished")
307
310
 
308
- async def list_remote_directory(self, remote_path: str = ".") -> list[dict[str, Any]]:
311
+ async def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
309
312
  """异步列出远程目录内容(结构与同步 SSHClient 一致)。"""
310
313
  sftp = await self._get_sftp()
311
314
  try:
@@ -313,18 +316,20 @@ class AsyncSSHClient:
313
316
  except (OSError, asyncssh.Error) as e:
314
317
  raise SSHFileTransferError(f"failed to list remote directory: {e}") from e
315
318
 
316
- entries: list[dict[str, Any]] = []
319
+ entries: list[RemoteFileEntry] = []
317
320
  for entry in names:
318
321
  attrs = entry.attrs
319
322
  mode = attrs.permissions if hasattr(attrs, "permissions") else None
323
+ raw_size = attrs.size if hasattr(attrs, "size") else None
324
+ raw_mtime = attrs.mtime if hasattr(attrs, "mtime") else 0
320
325
  entries.append(
321
- {
322
- "name": entry.filename,
323
- "size": attrs.size if hasattr(attrs, "size") else 0,
324
- "mode": oct(int(mode))[-3:] if mode else "000",
325
- "mtime": attrs.mtime if hasattr(attrs, "mtime") else 0,
326
- "is_dir": bool(mode & stat.S_IFDIR) if mode else False,
327
- }
326
+ RemoteFileEntry(
327
+ name=str(entry.filename),
328
+ size=raw_size if raw_size is not None else 0,
329
+ mode=oct(int(mode))[-3:] if mode else "000",
330
+ mtime=raw_mtime,
331
+ is_dir=bool(mode & stat.S_IFDIR) if mode else False,
332
+ )
328
333
  )
329
334
  return entries
330
335
 
@@ -9,7 +9,8 @@
9
9
  - 与连接配置的转换
10
10
  """
11
11
 
12
- from dataclasses import asdict, dataclass
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
13
14
  from typing import Any, Optional
14
15
 
15
16
  from remote_cmd.core.ssh_client import ConnectionConfig
@@ -49,11 +50,11 @@ class Host:
49
50
  port: int = 22
50
51
  password: Optional[str] = None
51
52
  key_filename: Optional[str] = None
52
- tags: Optional[list[str]] = None
53
+ tags: list[str] = field(default_factory=list)
53
54
  description: str = ""
54
55
 
55
56
  def __post_init__(self):
56
- """初始化后处理:设置默认标签列表"""
57
+ """初始化后处理:归一化外部传入的 None 标签(兼容旧数据)"""
57
58
  if self.tags is None:
58
59
  self.tags = []
59
60
 
@@ -124,8 +125,6 @@ class Host:
124
125
  )
125
126
  # 脱敏密钥路径:仅显示文件名
126
127
  if data.get("key_filename"):
127
- from pathlib import Path
128
-
129
128
  data["key_filename"] = Path(data["key_filename"]).name
130
129
  return data
131
130
 
@@ -137,6 +137,28 @@ class CommandResult:
137
137
  return f"{status} [{self.exit_code}] {self.command}"
138
138
 
139
139
 
140
+ @dataclass
141
+ class RemoteFileEntry:
142
+ """
143
+ 远程文件/目录条目信息
144
+
145
+ 描述远程目录中的单个条目(文件或目录),替代弱类型的字典返回。
146
+
147
+ Attributes:
148
+ name: 文件/目录名
149
+ size: 文件大小(字节)
150
+ mode: 权限模式(八进制字符串)
151
+ mtime: 修改时间戳
152
+ is_dir: 是否为目录
153
+ """
154
+
155
+ name: str
156
+ size: int
157
+ mode: str
158
+ mtime: Any
159
+ is_dir: bool
160
+
161
+
140
162
  # ============================================================================
141
163
  # SSH 客户端类
142
164
  # ============================================================================
@@ -166,7 +188,7 @@ class SSHClient:
166
188
  ... print(result.stdout)
167
189
  """
168
190
 
169
- def __init__(self, config: ConnectionConfig):
191
+ def __init__(self, config: ConnectionConfig) -> None:
170
192
  """
171
193
  初始化 SSH 客户端
172
194
 
@@ -375,7 +397,8 @@ class SSHClient:
375
397
  raise SSHConnectionError("not connected, call connect() first")
376
398
 
377
399
  try:
378
- logger.debug(f"executing command: {command}")
400
+ # 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
401
+ logger.debug("executing remote command")
379
402
 
380
403
  # 构建环境变量设置命令
381
404
  # 安全:对 value 做 shlex.quote 转义,防止包含 shell 元字符
@@ -408,7 +431,7 @@ class SSHClient:
408
431
  return result
409
432
 
410
433
  except (paramiko.SSHException, OSError) as e:
411
- raise SSHCommandError(f"command execution failed '{command}': {e}") from e
434
+ raise SSHCommandError(f"command execution failed: {e}") from e
412
435
 
413
436
  def execute_sudo(
414
437
  self,
@@ -464,7 +487,7 @@ class SSHClient:
464
487
  exit_code=exit_code,
465
488
  )
466
489
  except (paramiko.SSHException, OSError) as e:
467
- raise SSHCommandError(f"sudo command execution failed '{command}': {e}") from e
490
+ raise SSHCommandError(f"sudo command execution failed: {e}") from e
468
491
 
469
492
  # ========================================================================
470
493
  # 文件传输方法
@@ -532,7 +555,7 @@ class SSHClient:
532
555
  except (paramiko.SSHException, OSError) as e:
533
556
  raise SSHFileTransferError(f"file download failed: {e}") from e
534
557
 
535
- def list_remote_directory(self, remote_path: str = ".") -> list[dict[str, Any]]:
558
+ def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
536
559
  """
537
560
  列出远程目录内容
538
561
 
@@ -540,12 +563,7 @@ class SSHClient:
540
563
  remote_path: 远程目录路径,默认为当前目录
541
564
 
542
565
  Returns:
543
- List[Dict[str, Any]]: 目录项信息列表,每个字典包含:
544
- - name: 文件/目录名
545
- - size: 文件大小(字节)
546
- - mode: 权限模式(八进制字符串)
547
- - mtime: 修改时间戳
548
- - is_dir: 是否为目录
566
+ List[RemoteFileEntry]: 目录项信息列表
549
567
 
550
568
  Raises:
551
569
  SSHFileTransferError: 列出目录失败时抛出
@@ -554,22 +572,22 @@ class SSHClient:
554
572
  Example:
555
573
  >>> entries = client.list_remote_directory("/home/user")
556
574
  >>> for entry in entries:
557
- ... print(f"{entry['name']}: {entry['size']} bytes")
575
+ ... print(f"{entry.name}: {entry.size} bytes")
558
576
  """
559
577
  sftp = self._get_sftp()
560
578
 
561
579
  try:
562
- entries = []
580
+ entries: list[RemoteFileEntry] = []
563
581
  for entry in sftp.listdir_attr(remote_path):
564
582
  mode = entry.st_mode if entry.st_mode is not None else 0
565
583
  entries.append(
566
- {
567
- "name": entry.filename,
568
- "size": entry.st_size,
569
- "mode": oct(mode)[-3:] if mode else "000",
570
- "mtime": entry.st_mtime,
571
- "is_dir": bool(mode & stat.S_IFDIR) if mode else False,
572
- }
584
+ RemoteFileEntry(
585
+ name=entry.filename,
586
+ size=entry.st_size,
587
+ mode=oct(mode)[-3:] if mode else "000",
588
+ mtime=entry.st_mtime,
589
+ is_dir=bool(mode & stat.S_IFDIR) if mode else False,
590
+ )
573
591
  )
574
592
  return entries
575
593
  except (paramiko.SSHException, OSError) as e:
@@ -579,7 +597,7 @@ class SSHClient:
579
597
  """创建远程目录(支持递归创建)"""
580
598
  sftp = self._get_sftp()
581
599
 
582
- def _makedirs(sftp_client, remote_path):
600
+ def _makedirs(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
583
601
  if remote_path == "/":
584
602
  return
585
603
  try:
@@ -607,9 +625,9 @@ class SSHClient:
607
625
  """删除远程目录"""
608
626
  sftp = self._get_sftp()
609
627
 
610
- def _rm_recursive(sftp_client, remote_path):
628
+ def _rm_recursive(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
611
629
  """递归删除目录内容,先收集后删除以避免不一致状态"""
612
- entries = []
630
+ entries: list[tuple[str, bool]] = []
613
631
  try:
614
632
  for entry in sftp_client.listdir_attr(remote_path):
615
633
  entries.append((entry.filename, bool(entry.st_mode & stat.S_IFDIR)))