remote-cmd-manager 1.2.3__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.3/remote_cmd_manager.egg-info → remote_cmd_manager-2.0.0}/PKG-INFO +1 -1
  2. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/pyproject.toml +4 -1
  3. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/__init__.py +3 -1
  4. remote_cmd_manager-2.0.0/remote_cmd/_version.py +12 -0
  5. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/cli/main.py +22 -18
  6. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_connection_pool.py +33 -24
  7. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_ssh_client.py +20 -15
  8. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/host.py +4 -5
  9. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/ssh_client.py +41 -23
  10. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/sync_connection_pool.py +33 -24
  11. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/json_host_repository.py +11 -12
  12. {remote_cmd_manager-1.2.3 → 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.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/async_batch_executor.py +25 -42
  17. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/batch_executor.py +39 -140
  18. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/credential_provider.py +4 -4
  19. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/host_service.py +44 -39
  20. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/ssh_service.py +1 -1
  21. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/task_runner.py +1 -1
  22. remote_cmd_manager-2.0.0/remote_cmd/utils/credential_guard.py +126 -0
  23. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/crypto.py +6 -5
  24. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0/remote_cmd_manager.egg-info}/PKG-INFO +1 -1
  25. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/SOURCES.txt +6 -0
  26. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_async_batch_executor.py +50 -5
  27. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_async_ssh_client.py +2 -2
  28. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_batch_executor.py +41 -0
  29. remote_cmd_manager-2.0.0/tests/test_pool_policy.py +73 -0
  30. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_ssh_client.py +2 -2
  31. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_sync_connection_pool.py +22 -5
  32. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/LICENSE +0 -0
  33. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/MANIFEST.in +0 -0
  34. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/README.md +0 -0
  35. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/config.example.yaml +0 -0
  36. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/basic_usage.py +0 -0
  37. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/deploy_script.py +0 -0
  38. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/nginx_batch_update.py +0 -0
  39. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/system_health_check.py +0 -0
  40. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/cli/__init__.py +0 -0
  41. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/__init__.py +0 -0
  42. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/host_manager.py +0 -0
  43. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/__init__.py +0 -0
  44. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/host_repository.py +0 -0
  45. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/__init__.py +0 -0
  46. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/storage_factory.py +0 -0
  47. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/__init__.py +0 -0
  48. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/config.py +0 -0
  49. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/exceptions.py +0 -0
  50. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/logging_utils.py +0 -0
  51. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  52. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  53. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
  54. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  55. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/requirements.txt +0 -0
  56. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/setup.cfg +0 -0
  57. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/__init__.py +0 -0
  58. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/conftest.py +0 -0
  59. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/integration/conftest.py +0 -0
  60. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/integration/test_ssh_connection.py +0 -0
  61. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/performance/__init__.py +0 -0
  62. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/performance/conftest.py +0 -0
  63. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/performance/test_benchmarks.py +0 -0
  64. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_cli.py +0 -0
  65. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_config.py +0 -0
  66. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_credential_provider.py +0 -0
  67. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_crypto.py +0 -0
  68. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_host.py +0 -0
  69. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_host_manager.py +0 -0
  70. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_host_service.py +0 -0
  71. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_keyring_provider.py +0 -0
  72. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_logging_utils.py +0 -0
  73. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_repository.py +0 -0
  74. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_sqlite_repository.py +0 -0
  75. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_ssh_service.py +0 -0
  76. {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_storage_factory.py +0 -0
  77. {remote_cmd_manager-1.2.3 → 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.3
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.3"
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
 
@@ -52,7 +52,9 @@ Version: 1.2.3
52
52
  License: MIT
53
53
  """
54
54
 
55
- __version__ = "1.2.3"
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
@@ -77,7 +78,12 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
77
78
  )
78
79
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output mode")
79
80
  @click.pass_context
80
- 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:
81
87
  """
82
88
  Remote CMD - SSH remote server management tool
83
89
 
@@ -115,7 +121,7 @@ def cli(ctx, config: Optional[str], hosts_file_override: Optional[str], verbose:
115
121
 
116
122
 
117
123
  @cli.group()
118
- def host():
124
+ def host() -> None:
119
125
  """
120
126
  Host management command group
121
127
 
@@ -141,7 +147,7 @@ def host():
141
147
  @click.option("--description", "-d", default="", help="Host description")
142
148
  @click.pass_context
143
149
  def host_add(
144
- ctx,
150
+ ctx: click.Context,
145
151
  name: str,
146
152
  hostname: str,
147
153
  username: str,
@@ -149,7 +155,7 @@ def host_add(
149
155
  key: Optional[str],
150
156
  tag: tuple,
151
157
  description: str,
152
- ):
158
+ ) -> None:
153
159
  """
154
160
  Add a new host
155
161
 
@@ -207,7 +213,7 @@ def host_add(
207
213
  @host.command("list")
208
214
  @click.option("--tag", "-t", help="Filter hosts by tag")
209
215
  @click.pass_context
210
- def host_list(ctx, tag: Optional[str]):
216
+ def host_list(ctx: click.Context, tag: Optional[str]) -> None:
211
217
  """List all hosts"""
212
218
  service: HostService = ctx.obj["service"]
213
219
  hosts = service.list_hosts(tag=tag)
@@ -233,7 +239,7 @@ def host_list(ctx, tag: Optional[str]):
233
239
  @click.argument("name", required=True)
234
240
  @click.confirmation_option(prompt="Are you sure you want to remove this host?")
235
241
  @click.pass_context
236
- def host_remove(ctx, name: str):
242
+ def host_remove(ctx: click.Context, name: str) -> None:
237
243
  """Remove a host"""
238
244
  service: HostService = ctx.obj["service"]
239
245
 
@@ -250,7 +256,7 @@ def host_remove(ctx, name: str):
250
256
  @host.command("show")
251
257
  @click.argument("name", required=True)
252
258
  @click.pass_context
253
- def host_show(ctx, name: str):
259
+ def host_show(ctx: click.Context, name: str) -> None:
254
260
  """Show host details"""
255
261
  service: HostService = ctx.obj["service"]
256
262
 
@@ -272,8 +278,6 @@ def host_show(ctx, name: str):
272
278
  click.echo(f" Auth: {auth_type}")
273
279
  if host.key_filename:
274
280
  # Sanitize key path: show only the filename, not full path
275
- from pathlib import Path
276
-
277
281
  key_name = Path(host.key_filename).name
278
282
  click.echo(f" Key file: {key_name}")
279
283
  tags_str = ", ".join(host.tags) if host.tags else "-"
@@ -290,7 +294,7 @@ def host_show(ctx, name: str):
290
294
  @host.command("test")
291
295
  @click.argument("name", required=True)
292
296
  @click.pass_context
293
- def host_test(ctx, name: str):
297
+ def host_test(ctx: click.Context, name: str) -> None:
294
298
  """Test host connection"""
295
299
  service: HostService = ctx.obj["service"]
296
300
 
@@ -307,7 +311,7 @@ def host_test(ctx, name: str):
307
311
  @click.argument("host_name", required=True)
308
312
  @click.argument("command", required=True)
309
313
  @click.pass_context
310
- def run(ctx, host_name: str, command: str):
314
+ def run(ctx: click.Context, host_name: str, command: str) -> None:
311
315
  """
312
316
  Execute a command on a remote host
313
317
 
@@ -340,7 +344,7 @@ def run(ctx, host_name: str, command: str):
340
344
  @click.argument("local_path", required=True)
341
345
  @click.argument("remote_path", required=True)
342
346
  @click.pass_context
343
- 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:
344
348
  """
345
349
  Upload a file to a remote host
346
350
 
@@ -366,7 +370,7 @@ def upload(ctx, host_name: str, local_path: str, remote_path: str):
366
370
  @click.argument("local_path", required=True)
367
371
  @click.argument("remote_path", required=True)
368
372
  @click.pass_context
369
- 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:
370
374
  """
371
375
  Download a file from a remote host
372
376
 
@@ -398,8 +402,8 @@ def download(ctx, host_name: str, local_path: str, remote_path: str):
398
402
  @click.option("--show-failures", is_flag=True, help="Show only failed hosts")
399
403
  @click.pass_context
400
404
  def batch_run(
401
- ctx,
402
- host_names: tuple,
405
+ ctx: click.Context,
406
+ host_names: tuple[str, ...],
403
407
  command: str,
404
408
  concurrency: int,
405
409
  timeout: int,
@@ -407,7 +411,7 @@ def batch_run(
407
411
  retry_delay: float,
408
412
  use_async: bool,
409
413
  show_failures: bool,
410
- ):
414
+ ) -> None:
411
415
  """
412
416
  Execute a command on multiple hosts in batch
413
417
 
@@ -443,7 +447,7 @@ def batch_run(
443
447
  show_percent=True,
444
448
  ) as bar:
445
449
 
446
- def progress(_completed, _total, _host_name):
450
+ def progress(_completed: int, _total: int, _host_name: str) -> None:
447
451
  bar.update(1)
448
452
 
449
453
  result = executor.execute(
@@ -488,7 +492,7 @@ def batch_run(
488
492
  ctx.exit(1)
489
493
 
490
494
 
491
- def main():
495
+ def main() -> None:
492
496
  """CLI entry point"""
493
497
  cli()
494
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)))