remote-cmd-manager 1.2.2__tar.gz → 1.2.3__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 (71) hide show
  1. {remote_cmd_manager-1.2.2/remote_cmd_manager.egg-info → remote_cmd_manager-1.2.3}/PKG-INFO +1 -1
  2. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/pyproject.toml +1 -1
  3. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/__init__.py +2 -2
  4. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/cli/main.py +6 -1
  5. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/credential_provider.py +7 -1
  6. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/storage_factory.py +19 -9
  7. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3/remote_cmd_manager.egg-info}/PKG-INFO +1 -1
  8. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/integration/conftest.py +3 -1
  9. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/integration/test_ssh_connection.py +4 -12
  10. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/performance/conftest.py +1 -3
  11. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/performance/test_benchmarks.py +5 -14
  12. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_async_batch_executor.py +11 -13
  13. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_async_ssh_client.py +3 -9
  14. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_batch_executor.py +2 -3
  15. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_ssh_client.py +29 -16
  16. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_storage_factory.py +48 -0
  17. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_sync_connection_pool.py +1 -3
  18. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/LICENSE +0 -0
  19. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/MANIFEST.in +0 -0
  20. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/README.md +0 -0
  21. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/config.example.yaml +0 -0
  22. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/basic_usage.py +0 -0
  23. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/deploy_script.py +0 -0
  24. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/nginx_batch_update.py +0 -0
  25. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/system_health_check.py +0 -0
  26. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/cli/__init__.py +0 -0
  27. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/__init__.py +0 -0
  28. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/async_connection_pool.py +0 -0
  29. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/async_ssh_client.py +0 -0
  30. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/host.py +0 -0
  31. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/host_manager.py +0 -0
  32. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/ssh_client.py +0 -0
  33. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/sync_connection_pool.py +0 -0
  34. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/__init__.py +0 -0
  35. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/host_repository.py +0 -0
  36. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/json_host_repository.py +0 -0
  37. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/sqlite_host_repository.py +0 -0
  38. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/__init__.py +0 -0
  39. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/async_batch_executor.py +0 -0
  40. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/batch_executor.py +0 -0
  41. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/host_service.py +0 -0
  42. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/ssh_service.py +0 -0
  43. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/task_runner.py +0 -0
  44. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/__init__.py +0 -0
  45. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/config.py +0 -0
  46. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/crypto.py +0 -0
  47. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/exceptions.py +0 -0
  48. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/logging_utils.py +0 -0
  49. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/SOURCES.txt +0 -0
  50. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  51. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  52. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/requires.txt +0 -0
  53. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  54. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/requirements.txt +0 -0
  55. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/setup.cfg +0 -0
  56. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/__init__.py +0 -0
  57. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/conftest.py +0 -0
  58. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/performance/__init__.py +0 -0
  59. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_cli.py +0 -0
  60. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_config.py +0 -0
  61. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_credential_provider.py +0 -0
  62. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_crypto.py +0 -0
  63. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_host.py +0 -0
  64. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_host_manager.py +0 -0
  65. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_host_service.py +0 -0
  66. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_keyring_provider.py +0 -0
  67. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_logging_utils.py +0 -0
  68. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_repository.py +0 -0
  69. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_sqlite_repository.py +0 -0
  70. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_ssh_service.py +0 -0
  71. {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/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: 1.2.3
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
+ version = "1.2.3"
8
8
  description = "SSH 远程服务器管理工具 / Python SSH remote server management tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -48,11 +48,11 @@ 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
+ __version__ = "1.2.3"
56
56
  __author__ = "Vae-Scrooge"
57
57
  __email__ = "vae-scrooge@example.com"
58
58
  __license__ = "MIT"
@@ -34,6 +34,7 @@ from remote_cmd.service.credential_provider import (
34
34
  from remote_cmd.service.host_service import HostService
35
35
  from remote_cmd.service.storage_factory import build_repository
36
36
  from remote_cmd.utils.config import get_default_config_path, load_config
37
+ from remote_cmd.utils.crypto import CredentialEncryption
37
38
 
38
39
 
39
40
  def _build_service(config_file: str, storage_backend: Optional[str] = None) -> HostService:
@@ -49,7 +50,11 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
49
50
  explicitly, otherwise inferred from the config file extension:
50
51
  .json -> JsonHostRepository; .db/.sqlite -> SqliteHostRepository.
51
52
  """
52
- repo = build_repository(filepath=config_file, storage_backend=storage_backend)
53
+ repo = build_repository(
54
+ filepath=config_file,
55
+ storage_backend=storage_backend,
56
+ encryption=CredentialEncryption(),
57
+ )
53
58
  cred_provider = ChainCredentialProvider(
54
59
  [
55
60
  EnvCredentialProvider(),
@@ -65,7 +65,13 @@ class EnvCredentialProvider(CredentialProvider):
65
65
 
66
66
  @staticmethod
67
67
  def _host_env_suffix(host_name: str) -> str:
68
- """将主机名转换为环境变量后缀:web1 -> WEB1,my-host -> MY_HOST"""
68
+ """
69
+ 将主机名转换为环境变量后缀:web1 -> WEB1,my-host -> MY_HOST
70
+
71
+ 注意: 非字母数字字符统一归一化为下划线,因此 ``web-1`` 与 ``web_1``
72
+ 会映射到同一个变量 ``..._WEB_1``。若同舰队同时存在这两种命名,
73
+ 需避免依赖同名变量(如需区分请先统一主机命名规范)。
74
+ """
69
75
  normalized = "".join(c if c.isalnum() else "_" for c in host_name).upper()
70
76
  return normalized
71
77
 
@@ -13,6 +13,7 @@
13
13
  >>> repo = build_repository("hosts.json")
14
14
  >>> repo = build_repository("hosts.db")
15
15
  >>> repo = build_repository("hosts.json", storage_backend="sqlite")
16
+ >>> repo = build_repository("hosts.db", encryption=CredentialEncryption())
16
17
  """
17
18
 
18
19
  from pathlib import Path
@@ -21,19 +22,24 @@ from typing import Callable, Optional
21
22
  from remote_cmd.repository.host_repository import HostRepository
22
23
  from remote_cmd.repository.json_host_repository import JsonHostRepository
23
24
  from remote_cmd.repository.sqlite_host_repository import SqliteHostRepository
25
+ from remote_cmd.utils.crypto import CredentialEncryption
24
26
 
25
27
  # 显式 storage_backend 取值 -> 仓库工厂
26
- BACKEND_FACTORIES: dict[str, Callable[[str], HostRepository]] = {
27
- "json": lambda path: JsonHostRepository(filepath=path, auto_load=True),
28
- "sqlite": lambda path: SqliteHostRepository(db_path=path),
29
- "sqlite3": lambda path: SqliteHostRepository(db_path=path),
28
+ BACKEND_FACTORIES: dict[str, Callable[[str, Optional[CredentialEncryption]], HostRepository]] = {
29
+ "json": lambda path, encryption: JsonHostRepository(
30
+ filepath=path, auto_load=True, encryption=encryption
31
+ ),
32
+ "sqlite": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
33
+ "sqlite3": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
30
34
  }
31
35
 
32
36
  # 扩展名 -> 仓库工厂
33
- EXTENSION_FACTORIES: dict[str, Callable[[str], HostRepository]] = {
34
- ".json": lambda path: JsonHostRepository(filepath=path, auto_load=True),
35
- ".db": lambda path: SqliteHostRepository(db_path=path),
36
- ".sqlite": lambda path: SqliteHostRepository(db_path=path),
37
+ EXTENSION_FACTORIES: dict[str, Callable[[str, Optional[CredentialEncryption]], HostRepository]] = {
38
+ ".json": lambda path, encryption: JsonHostRepository(
39
+ filepath=path, auto_load=True, encryption=encryption
40
+ ),
41
+ ".db": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
42
+ ".sqlite": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
37
43
  }
38
44
 
39
45
 
@@ -76,6 +82,7 @@ def resolve_storage_backend(filepath: str, storage_backend: Optional[str] = None
76
82
  def build_repository(
77
83
  filepath: str,
78
84
  storage_backend: Optional[str] = None,
85
+ encryption: Optional[CredentialEncryption] = None,
79
86
  ) -> HostRepository:
80
87
  """
81
88
  根据扩展名或显式存储后端构建 HostRepository。
@@ -83,6 +90,9 @@ def build_repository(
83
90
  Args:
84
91
  filepath: hosts 文件路径
85
92
  storage_backend: 显式指定的存储后端(可选,优先于扩展名推断)
93
+ encryption: 凭据加密器(可选)。传入后仓库在写入时自动加密密码、
94
+ 读取时自动解密,作为防御深度(即使调用方绕过 HostService
95
+ 直接 save() 明文密码,落盘仍是密文)。
86
96
 
87
97
  Returns:
88
98
  HostRepository: 匹配的仓库实例
@@ -92,4 +102,4 @@ def build_repository(
92
102
  """
93
103
  backend = resolve_storage_backend(filepath, storage_backend)
94
104
  factory = BACKEND_FACTORIES[backend]
95
- return factory(filepath)
105
+ return factory(filepath, encryption)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote_cmd_manager
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: SSH 远程服务器管理工具 / Python SSH remote server management tool
5
5
  Author-email: Vae-Scrooge <vaescrooge@gmail.com>
6
6
  License: MIT
@@ -32,7 +32,9 @@ class MockSSHServerInterface(paramiko.ServerInterface):
32
32
  return "password,publickey"
33
33
 
34
34
  def check_channel_exec_request(
35
- self, channel: paramiko.Channel, command: bytes # noqa: ARG002
35
+ self,
36
+ channel: paramiko.Channel, # noqa: ARG002
37
+ command: bytes, # noqa: ARG002
36
38
  ) -> bool:
37
39
  return True
38
40
 
@@ -29,9 +29,7 @@ class TestSSHConnectionIntegration:
29
29
 
30
30
  def test_connect_with_password(self, mock_ssh_server, integration_host):
31
31
  """密码认证连接成功"""
32
- config = ConnectionConfig(
33
- port=integration_host.port, **self.TEST_CONFIG
34
- )
32
+ config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
35
33
  client = SSHClient(config)
36
34
  client.connect()
37
35
  assert client.is_connected()
@@ -39,9 +37,7 @@ class TestSSHConnectionIntegration:
39
37
 
40
38
  def test_connect_and_disconnect(self, mock_ssh_server, integration_host):
41
39
  """连接后断开"""
42
- config = ConnectionConfig(
43
- port=integration_host.port, **self.TEST_CONFIG
44
- )
40
+ config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
45
41
  client = SSHClient(config)
46
42
  client.connect()
47
43
  assert client.is_connected()
@@ -73,18 +69,14 @@ class TestSSHConnectionIntegration:
73
69
 
74
70
  def test_context_manager(self, mock_ssh_server, integration_host):
75
71
  """上下文管理器自动关闭连接"""
76
- config = ConnectionConfig(
77
- port=integration_host.port, **self.TEST_CONFIG
78
- )
72
+ config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
79
73
  with SSHClient(config) as client:
80
74
  assert client.is_connected()
81
75
  assert not client.is_connected()
82
76
 
83
77
  def test_double_disconnect_safe(self, mock_ssh_server, integration_host):
84
78
  """重复 disconnect 不抛异常"""
85
- config = ConnectionConfig(
86
- port=integration_host.port, **self.TEST_CONFIG
87
- )
79
+ config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
88
80
  client = SSHClient(config)
89
81
  client.connect()
90
82
  client.disconnect()
@@ -84,9 +84,7 @@ def patched_async_client():
84
84
  await asyncio.sleep(state["latency"])
85
85
  return CommandResult(command=command, stdout="OK", stderr="", exit_code=0)
86
86
 
87
- with patch(
88
- "remote_cmd.service.async_batch_executor.AsyncSSHClient", _StubClient
89
- ):
87
+ with patch("remote_cmd.service.async_batch_executor.AsyncSSHClient", _StubClient):
90
88
  yield state
91
89
 
92
90
 
@@ -116,9 +116,7 @@ class TestSchedulingOverhead:
116
116
 
117
117
  # --- 异步路径 ---
118
118
  hosts2 = make_hosts(n)
119
- ex_async, _ = async_backend_factory(
120
- hosts2, max_concurrency=concurrency, latency=latency
121
- )
119
+ ex_async, _ = async_backend_factory(hosts2, max_concurrency=concurrency, latency=latency)
122
120
  t0 = time.perf_counter()
123
121
  asyncio.run(ex_async.execute([h.name for h in hosts2], "uptime"))
124
122
  async_wall = time.perf_counter() - t0
@@ -130,8 +128,7 @@ class TestSchedulingOverhead:
130
128
  )
131
129
  # 断言:异步不应慢于同步(框架开销更小)。允许 10% 测量波动。
132
130
  assert async_wall <= sync_wall * 1.1, (
133
- f"异步 {async_wall:.4f}s 慢于同步 {sync_wall:.4f}s,"
134
- f"speedup={speedup:.2f}x"
131
+ f"异步 {async_wall:.4f}s 慢于同步 {sync_wall:.4f}s,speedup={speedup:.2f}x"
135
132
  )
136
133
 
137
134
 
@@ -143,9 +140,7 @@ class TestSchedulingOverhead:
143
140
  class TestScalabilityCurve:
144
141
  """不同规模下记录耗时,输出可扩展性曲线与并发倍率。"""
145
142
 
146
- @pytest.mark.parametrize(
147
- "n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)]
148
- )
143
+ @pytest.mark.parametrize("n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)])
149
144
  def test_scalability_async(self, async_backend_factory, n, concurrency):
150
145
  latency = 0.02
151
146
  hosts = make_hosts(n)
@@ -166,9 +161,7 @@ class TestScalabilityCurve:
166
161
  # 可扩展性断言:实测不应超过理论 1.5 倍
167
162
  assert wall < ideal * 1.5
168
163
 
169
- @pytest.mark.parametrize(
170
- "n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)]
171
- )
164
+ @pytest.mark.parametrize("n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)])
172
165
  def test_scalability_sync(self, sync_backend_factory, n, concurrency):
173
166
  latency = 0.02
174
167
  hosts = make_hosts(n)
@@ -228,9 +221,7 @@ class TestUseAsyncDelegation:
228
221
 
229
222
  from remote_cmd.service.batch_executor import BatchExecutor
230
223
 
231
- with patch(
232
- "remote_cmd.service.async_batch_executor.AsyncSSHClient", _Stub
233
- ):
224
+ with patch("remote_cmd.service.async_batch_executor.AsyncSSHClient", _Stub):
234
225
  ex_delegated = BatchExecutor(
235
226
  host_service=make_mock_service(hosts),
236
227
  max_concurrency=10,
@@ -247,9 +247,7 @@ class TestAsyncConnectionPool:
247
247
  async def test_monitor_loop_handles_exception(self, config, patched_client):
248
248
  """测试:监控循环中异常被捕获并继续运行"""
249
249
  pool = AsyncConnectionPool(config=config, health_check_interval=0.01)
250
- pool._cleanup_expired = AsyncMock(
251
- side_effect=[RuntimeError("boom"), None, None, None]
252
- )
250
+ pool._cleanup_expired = AsyncMock(side_effect=[RuntimeError("boom"), None, None, None])
253
251
  pool._start_monitor()
254
252
  await asyncio.sleep(0.05)
255
253
  pool.stop_monitor()
@@ -259,6 +257,7 @@ class TestAsyncConnectionPool:
259
257
  @pytest.mark.asyncio
260
258
  async def test_create_connection_failure(self, config):
261
259
  """测试:创建连接失败时释放信号量并累计失败数"""
260
+
262
261
  def failing_factory(cfg):
263
262
  client = _client_mock()
264
263
  client.connect = AsyncMock(side_effect=OSError("auth failed"))
@@ -266,10 +265,13 @@ class TestAsyncConnectionPool:
266
265
 
267
266
  pool = AsyncConnectionPool(config=config, max_connections=1)
268
267
  try:
269
- with patch(
270
- "remote_cmd.core.async_connection_pool.AsyncSSHClient",
271
- side_effect=failing_factory,
272
- ), pytest.raises(OSError):
268
+ with (
269
+ patch(
270
+ "remote_cmd.core.async_connection_pool.AsyncSSHClient",
271
+ side_effect=failing_factory,
272
+ ),
273
+ pytest.raises(OSError),
274
+ ):
273
275
  await pool.acquire()
274
276
  assert pool.get_metrics()["failed"] == 1
275
277
  finally:
@@ -299,9 +301,7 @@ class TestAsyncConnectionPool:
299
301
  @pytest.mark.asyncio
300
302
  async def test_monitor_loop_cancelled(self, config, patched_client):
301
303
  """测试:监控任务可被取消"""
302
- pool = AsyncConnectionPool(
303
- config=config, health_check_interval=0.01
304
- )
304
+ pool = AsyncConnectionPool(config=config, health_check_interval=0.01)
305
305
  pool._start_monitor()
306
306
  assert pool._monitor_task is not None
307
307
  await asyncio.sleep(0.05)
@@ -322,9 +322,7 @@ class TestAsyncConnectionPool:
322
322
  @pytest.mark.asyncio
323
323
  async def test_cleanup_expired(self, config, patched_client):
324
324
  """测试:清理过期与空闲超时连接"""
325
- pool = AsyncConnectionPool(
326
- config=config, max_connections=3, max_lifetime=1, idle_timeout=1
327
- )
325
+ pool = AsyncConnectionPool(config=config, max_connections=3, max_lifetime=1, idle_timeout=1)
328
326
  try:
329
327
  c1 = await pool.acquire()
330
328
  c2 = await pool.acquire()
@@ -104,9 +104,7 @@ class TestAsyncSSHClientConnect:
104
104
 
105
105
  @pytest.mark.asyncio
106
106
  async def test_connect_timeout(self, config, patched_asyncssh):
107
- patched_asyncssh.connect = AsyncMock(
108
- side_effect=OSError("Connection timed out")
109
- )
107
+ patched_asyncssh.connect = AsyncMock(side_effect=OSError("Connection timed out"))
110
108
  client = AsyncSSHClient(config)
111
109
  with pytest.raises(SSHConnectionError, match="connection timeout"):
112
110
  await client.connect()
@@ -156,9 +154,7 @@ class TestAsyncSSHClientExecute:
156
154
 
157
155
  @pytest.mark.asyncio
158
156
  async def test_execute_failure_exit_code(self, config, patched_asyncssh, conn_mock):
159
- conn_mock.run = AsyncMock(
160
- return_value=MagicMock(stdout="", stderr="boom", exit_status=127)
161
- )
157
+ conn_mock.run = AsyncMock(return_value=MagicMock(stdout="", stderr="boom", exit_status=127))
162
158
  async with AsyncSSHClient(config) as client:
163
159
  r = await client.execute("badcmd")
164
160
  assert r.success is False
@@ -167,9 +163,7 @@ class TestAsyncSSHClientExecute:
167
163
 
168
164
  @pytest.mark.asyncio
169
165
  async def test_execute_sudo_without_password(self, config, patched_asyncssh, conn_mock):
170
- conn_mock.run = AsyncMock(
171
- return_value=MagicMock(stdout="ok", stderr="", exit_status=0)
172
- )
166
+ conn_mock.run = AsyncMock(return_value=MagicMock(stdout="ok", stderr="", exit_status=0))
173
167
  async with AsyncSSHClient(config) as client:
174
168
  r = await client.execute_sudo("whoami")
175
169
  assert r.success
@@ -282,9 +282,7 @@ class TestBatchExecutor:
282
282
  future_map = {}
283
283
 
284
284
  # 调用 _handle_interrupt
285
- executor._handle_interrupt(
286
- future_map, ["srv1", "srv2", "srv3"], "uptime", existing_results
287
- )
285
+ executor._handle_interrupt(future_map, ["srv1", "srv2", "srv3"], "uptime", existing_results)
288
286
 
289
287
  # 验证:srv1 保留原结果,srv2/srv3 被标记为 user interrupted
290
288
  assert existing_results["srv1"].success is True
@@ -390,6 +388,7 @@ class TestBatchExecutor:
390
388
  pass
391
389
 
392
390
  import logging
391
+
393
392
  with caplog.at_level(logging.WARNING, logger="remote_cmd.service.batch_executor"):
394
393
  executor = BatchExecutor(host_service=service)
395
394
  executor.execute(["srv1"], "uptime", progress_callback=async_callback)
@@ -256,13 +256,19 @@ class TestSSHClientExecute:
256
256
  def test_execute_ssh_exception(self, mock_paramiko):
257
257
  mock_paramiko.exec_command.side_effect = paramiko.SSHException("channel error")
258
258
  config = ConnectionConfig(hostname="h", username="u")
259
- with SSHClient(config) as client, pytest.raises(SSHCommandError, match="command execution failed"):
259
+ with (
260
+ SSHClient(config) as client,
261
+ pytest.raises(SSHCommandError, match="command execution failed"),
262
+ ):
260
263
  client.execute("ls")
261
264
 
262
265
  def test_execute_os_error(self, mock_paramiko):
263
266
  mock_paramiko.exec_command.side_effect = OSError("pipe broken")
264
267
  config = ConnectionConfig(hostname="h", username="u")
265
- with SSHClient(config) as client, pytest.raises(SSHCommandError, match="command execution failed"):
268
+ with (
269
+ SSHClient(config) as client,
270
+ pytest.raises(SSHCommandError, match="command execution failed"),
271
+ ):
266
272
  client.execute("ls")
267
273
 
268
274
  def test_execute_stdout_decoding(self, mock_paramiko):
@@ -323,8 +329,9 @@ class TestSSHClientFileTransfer:
323
329
 
324
330
  def test_upload_missing_local(self, mock_paramiko): # noqa: ARG002
325
331
  config = ConnectionConfig(hostname="h", username="u")
326
- with SSHClient(config) as client, pytest.raises(
327
- SSHFileTransferError, match="Local file not found"
332
+ with (
333
+ SSHClient(config) as client,
334
+ pytest.raises(SSHFileTransferError, match="Local file not found"),
328
335
  ):
329
336
  client.upload_file("/no/file", "/remote/x")
330
337
 
@@ -334,8 +341,9 @@ class TestSSHClientFileTransfer:
334
341
  sftp = mock_paramiko.open_sftp.return_value
335
342
  sftp.put.side_effect = paramiko.SSHException("transfer fail")
336
343
  config = ConnectionConfig(hostname="h", username="u")
337
- with SSHClient(config) as client, pytest.raises(
338
- SSHFileTransferError, match="file upload failed"
344
+ with (
345
+ SSHClient(config) as client,
346
+ pytest.raises(SSHFileTransferError, match="file upload failed"),
339
347
  ):
340
348
  client.upload_file(str(local), "/remote/x")
341
349
 
@@ -352,8 +360,9 @@ class TestSSHClientFileTransfer:
352
360
  sftp = mock_paramiko.open_sftp.return_value
353
361
  sftp.get.side_effect = OSError("disk full")
354
362
  config = ConnectionConfig(hostname="h", username="u")
355
- with SSHClient(config) as client, pytest.raises(
356
- SSHFileTransferError, match="file download failed"
363
+ with (
364
+ SSHClient(config) as client,
365
+ pytest.raises(SSHFileTransferError, match="file download failed"),
357
366
  ):
358
367
  client.download_file("/remote/x", str(tmp_path / "x.txt"))
359
368
 
@@ -377,8 +386,9 @@ class TestSSHClientFileTransfer:
377
386
  sftp = mock_paramiko.open_sftp.return_value
378
387
  sftp.listdir_attr.side_effect = paramiko.SSHException("ls fail")
379
388
  config = ConnectionConfig(hostname="h", username="u")
380
- with SSHClient(config) as client, pytest.raises(
381
- SSHFileTransferError, match="failed to list remote directory"
389
+ with (
390
+ SSHClient(config) as client,
391
+ pytest.raises(SSHFileTransferError, match="failed to list remote directory"),
382
392
  ):
383
393
  client.list_remote_directory("/")
384
394
 
@@ -400,8 +410,9 @@ class TestSSHClientFileTransfer:
400
410
  sftp = mock_paramiko.open_sftp.return_value
401
411
  sftp.remove.side_effect = paramiko.SSHException("rm fail")
402
412
  config = ConnectionConfig(hostname="h", username="u")
403
- with SSHClient(config) as client, pytest.raises(
404
- SSHFileTransferError, match="failed to delete remote file"
413
+ with (
414
+ SSHClient(config) as client,
415
+ pytest.raises(SSHFileTransferError, match="failed to delete remote file"),
405
416
  ):
406
417
  client.remove_remote_file("/remote/x.txt")
407
418
 
@@ -457,8 +468,9 @@ class TestSSHClientFileTransfer:
457
468
  sftp = mock_paramiko.open_sftp.return_value
458
469
  sftp.stat.side_effect = paramiko.SSHException("stat fail")
459
470
  config = ConnectionConfig(hostname="h", username="u")
460
- with SSHClient(config) as client, pytest.raises(
461
- SSHFileTransferError, match="failed to get file info"
471
+ with (
472
+ SSHClient(config) as client,
473
+ pytest.raises(SSHFileTransferError, match="failed to get file info"),
462
474
  ):
463
475
  client.get_remote_file_info("/remote/x.txt")
464
476
 
@@ -501,8 +513,9 @@ class TestSSHClientFileTransfer:
501
513
  sftp.stat.side_effect = OSError("not found")
502
514
  sftp.mkdir.side_effect = paramiko.SSHException("mkdir fail")
503
515
  config = ConnectionConfig(hostname="h", username="u")
504
- with SSHClient(config) as c, pytest.raises(
505
- SSHFileTransferError, match="failed to create remote directory"
516
+ with (
517
+ SSHClient(config) as c,
518
+ pytest.raises(SSHFileTransferError, match="failed to create remote directory"),
506
519
  ):
507
520
  c.create_remote_directory("/a/b")
508
521
 
@@ -7,6 +7,9 @@
7
7
  - 未知扩展名且无显式配置时抛出 ValueError
8
8
  """
9
9
 
10
+ import json
11
+ import sqlite3
12
+
10
13
  import pytest
11
14
 
12
15
  from remote_cmd.repository.json_host_repository import JsonHostRepository
@@ -80,3 +83,48 @@ class TestBuildRepository:
80
83
  """默认 hosts.json 行为保持为 JSON 存储,不破坏兼容性"""
81
84
  repo = build_repository(str(tmp_path / "hosts.json"))
82
85
  assert isinstance(repo, JsonHostRepository)
86
+
87
+ def test_encryption_wired_into_json_repo(self, tmp_path):
88
+ """测试:传入 encryption 后 JSON 仓库在写入时加密密码"""
89
+ from remote_cmd.core.host import Host
90
+ from remote_cmd.utils.crypto import CredentialEncryption
91
+
92
+ path = str(tmp_path / "hosts.json")
93
+ repo = build_repository(path, encryption=CredentialEncryption())
94
+ repo.save(Host(name="srv1", hostname="10.0.0.1", username="admin", password="secret"))
95
+ repo.flush()
96
+
97
+ with open(path, encoding="utf-8") as f:
98
+ raw = json.load(f)
99
+ stored = raw["hosts"]["srv1"]["password"]
100
+ assert stored != "secret"
101
+ assert stored.startswith("$encrypted$") or "encrypted" in stored
102
+
103
+ def test_encryption_wired_into_sqlite_repo(self, tmp_path):
104
+ """测试:传入 encryption 后 SQLite 仓库在写入时加密密码"""
105
+ from remote_cmd.core.host import Host
106
+ from remote_cmd.utils.crypto import CredentialEncryption
107
+
108
+ path = str(tmp_path / "hosts.db")
109
+ repo = build_repository(path, encryption=CredentialEncryption())
110
+ repo.save(Host(name="srv1", hostname="10.0.0.1", username="admin", password="secret"))
111
+
112
+ conn = sqlite3.connect(path)
113
+ try:
114
+ row = conn.execute("SELECT password FROM hosts WHERE name = ?", ("srv1",)).fetchone()
115
+ assert row[0] != "secret"
116
+ finally:
117
+ conn.close()
118
+
119
+ def test_without_encryption_preserves_old_behavior(self, tmp_path):
120
+ """测试:不传 encryption 时保持原有行为(明文直存,兼容)"""
121
+ from remote_cmd.core.host import Host
122
+
123
+ path = str(tmp_path / "hosts.json")
124
+ repo = build_repository(path)
125
+ repo.save(Host(name="srv1", hostname="10.0.0.1", username="admin", password="secret"))
126
+ repo.flush()
127
+
128
+ with open(path, encoding="utf-8") as f:
129
+ raw = json.load(f)
130
+ assert raw["hosts"]["srv1"]["password"] == "secret"
@@ -261,9 +261,7 @@ class TestSyncConnectionPool:
261
261
  # 强行使 conn 空闲超时
262
262
  pool._meta[id(conn)]["last_used"] = time.time() - 1000
263
263
 
264
- conn.execute.return_value = CommandResult(
265
- command="true", stdout="", stderr="", exit_code=0
266
- )
264
+ conn.execute.return_value = CommandResult(command="true", stdout="", stderr="", exit_code=0)
267
265
  conn2 = pool.acquire()
268
266
  assert conn2 is conn # 探活成功,复用
269
267
  pool.close_all()