sshkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sshkit/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """安全的 SSH 客户端工具."""
2
+
3
+ from .client import (
4
+ CommandResult,
5
+ SshClient,
6
+ SshError,
7
+ SshErrorKind,
8
+ SshLoopHandler,
9
+ )
10
+
11
+ __all__ = [
12
+ "CommandResult",
13
+ "SshClient",
14
+ "SshError",
15
+ "SshErrorKind",
16
+ "SshLoopHandler",
17
+ ]
18
+
19
+ __version__ = "0.1.0"
sshkit/client.py ADDED
@@ -0,0 +1,498 @@
1
+ import socket
2
+ import threading
3
+ import time
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from math import isfinite
7
+ from typing import Dict, Optional
8
+
9
+ import paramiko
10
+
11
+
12
+ # =========================
13
+ # SSH 错误类型定义
14
+ # =========================
15
+ class SshErrorKind(Enum):
16
+ NOT_CONNECTED = "not_connected"
17
+ CONNECTION = "connection"
18
+ AUTHENTICATION = "authentication"
19
+ KEY_LOAD = "key_load"
20
+ HOST_KEY = "host_key"
21
+ TIMEOUT = "timeout"
22
+ TRANSPORT = "transport"
23
+
24
+
25
+ # =========================
26
+ # SSH 统一异常定义
27
+ # 使用 kind 区分错误类型,避免定义过多异常子类
28
+ # =========================
29
+ class SshError(Exception):
30
+ def __init__(
31
+ self,
32
+ kind: SshErrorKind,
33
+ message: str,
34
+ cause: Optional[Exception] = None,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.kind = kind
38
+ self.message = message
39
+ self.cause = cause
40
+
41
+ def __str__(self) -> str:
42
+ return self.message
43
+
44
+ def title_text(self) -> str:
45
+ if self.kind == SshErrorKind.AUTHENTICATION:
46
+ return "SSH 认证失败"
47
+ if self.kind == SshErrorKind.KEY_LOAD:
48
+ return "SSH 私钥加载失败"
49
+ if self.kind == SshErrorKind.HOST_KEY:
50
+ return "SSH 主机密钥校验失败"
51
+ if self.kind == SshErrorKind.CONNECTION:
52
+ return "SSH 连接失败"
53
+ if self.kind == SshErrorKind.NOT_CONNECTED:
54
+ return "SSH 尚未连接"
55
+ if self.kind == SshErrorKind.TIMEOUT:
56
+ return "SSH 操作超时"
57
+ if self.kind == SshErrorKind.TRANSPORT:
58
+ return "SSH 传输异常"
59
+ return "SSH 未知异常"
60
+
61
+ def build_alert_message(self, host_name: str, ip: str, port: int) -> str:
62
+ return f"{self.title_text()}: ({host_name} {ip}:{port}), 错误: {self}"
63
+
64
+
65
+ # =========================
66
+ # 命令执行结果
67
+ # =========================
68
+ @dataclass(frozen=True)
69
+ class CommandResult:
70
+ exit_status: int
71
+ stdout_text: str
72
+ stderr_text: str
73
+
74
+
75
+ # =========================
76
+ # 循环处理器基类
77
+ # handler 自己保存首次运行状态和上一轮错误
78
+ # run 和 on_error 统一签名
79
+ # =========================
80
+ class SshLoopHandler:
81
+ def __init__(self) -> None:
82
+ self.is_first_run = True
83
+ self.last_error: Optional[Exception] = None
84
+
85
+ def run(self, ssh_client: "SshClient") -> None:
86
+ raise NotImplementedError("子类必须实现 run 方法")
87
+
88
+ def on_error(self, ssh_client: "SshClient") -> None:
89
+ raise NotImplementedError("子类必须实现 on_error 方法")
90
+
91
+
92
+ class _RejectUnknownHostKeyPolicy(paramiko.RejectPolicy):
93
+ def missing_host_key(self, client, hostname, key) -> None:
94
+ raise SshError(
95
+ kind=SshErrorKind.HOST_KEY,
96
+ message=f"SSH 主机密钥未在 known_hosts 中找到: {hostname}",
97
+ )
98
+
99
+
100
+ class SshClient:
101
+ def __init__(
102
+ self,
103
+ hostname: str,
104
+ ip: str,
105
+ port: int,
106
+ username: str,
107
+ password: Optional[str] = None,
108
+ key_path: Optional[str] = None,
109
+ connect_timeout_seconds: float = 3.0,
110
+ keepalive_interval_seconds: int = 15,
111
+ key_passphrase: Optional[str] = None,
112
+ known_hosts_path: Optional[str] = None,
113
+ ) -> None:
114
+ if password is None and key_path is None:
115
+ raise ValueError("必须提供 password 或 key_path")
116
+ if password is not None and key_path is not None:
117
+ raise ValueError("password 和 key_path 只能提供一个")
118
+ if not 1 <= port <= 65535:
119
+ raise ValueError("port 必须在 1 到 65535 之间")
120
+ if not isfinite(connect_timeout_seconds) or connect_timeout_seconds <= 0:
121
+ raise ValueError("connect_timeout_seconds 必须大于 0")
122
+ if not isfinite(keepalive_interval_seconds) or keepalive_interval_seconds < 0:
123
+ raise ValueError("keepalive_interval_seconds 不能小于 0")
124
+
125
+ self.hostname = hostname
126
+ self.ip = ip
127
+ self.port = port
128
+ self._username = username
129
+ self._password = password
130
+ self._key_path = key_path
131
+ self._key_passphrase = key_passphrase
132
+ self._known_hosts_path = known_hosts_path
133
+ self._connect_timeout_seconds = connect_timeout_seconds
134
+ self._keepalive_interval_seconds = keepalive_interval_seconds
135
+ self._client: Optional[paramiko.SSHClient] = None
136
+
137
+ def __enter__(self) -> "SshClient":
138
+ self.connect()
139
+ return self
140
+
141
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
142
+ self.close()
143
+
144
+ # =========================
145
+ # 连接状态检查
146
+ # 这里只做轻量检查,不保证下一次操作一定成功
147
+ # =========================
148
+ def is_connected(self) -> bool:
149
+ if self._client is None:
150
+ return False
151
+ transport = self._client.get_transport()
152
+ return transport is not None and transport.is_active()
153
+
154
+ # =========================
155
+ # 建立 SSH 连接
156
+ # 这里只负责建立连接,不负责重试策略
157
+ # =========================
158
+ def connect(self) -> None:
159
+ if self.is_connected():
160
+ return
161
+ if self._client is not None:
162
+ self._close_quietly(self._client)
163
+ self._client = None
164
+
165
+ client = paramiko.SSHClient()
166
+ client.set_missing_host_key_policy(_RejectUnknownHostKeyPolicy())
167
+ connect_kwargs = {
168
+ "hostname": self.ip,
169
+ "port": self.port,
170
+ "username": self._username,
171
+ "timeout": self._connect_timeout_seconds,
172
+ "auth_timeout": self._connect_timeout_seconds,
173
+ "banner_timeout": self._connect_timeout_seconds,
174
+ "allow_agent": False,
175
+ "look_for_keys": False,
176
+ }
177
+
178
+ try:
179
+ try:
180
+ client.load_system_host_keys()
181
+ if self._known_hosts_path is not None:
182
+ client.load_host_keys(self._known_hosts_path)
183
+ except (OSError, UnicodeError) as exc:
184
+ raise SshError(
185
+ kind=SshErrorKind.CONNECTION,
186
+ message=f"SSH known_hosts 配置失败: {exc}",
187
+ cause=exc,
188
+ ) from exc
189
+ except paramiko.hostkeys.InvalidHostKey as exc:
190
+ raise SshError(
191
+ kind=SshErrorKind.HOST_KEY,
192
+ message=f"SSH known_hosts 内容无效: {exc}",
193
+ cause=exc,
194
+ ) from exc
195
+
196
+ if self._key_path is not None:
197
+ connect_kwargs["pkey"] = self._load_private_key(
198
+ self._key_path,
199
+ self._key_passphrase,
200
+ )
201
+ else:
202
+ connect_kwargs["password"] = self._password
203
+
204
+ client.connect(**connect_kwargs)
205
+ transport = client.get_transport()
206
+ if transport is not None:
207
+ transport.set_keepalive(self._keepalive_interval_seconds)
208
+ self._client = client
209
+
210
+ except paramiko.AuthenticationException as exc:
211
+ self._close_quietly(client)
212
+ raise SshError(
213
+ kind=SshErrorKind.AUTHENTICATION,
214
+ message=f"SSH 认证失败: {exc}",
215
+ cause=exc,
216
+ ) from exc
217
+ except paramiko.BadHostKeyException as exc:
218
+ self._close_quietly(client)
219
+ raise SshError(
220
+ kind=SshErrorKind.HOST_KEY,
221
+ message=f"SSH 主机密钥校验失败: {exc}",
222
+ cause=exc,
223
+ ) from exc
224
+ except SshError:
225
+ self._close_quietly(client)
226
+ raise
227
+ except socket.timeout as exc:
228
+ self._close_quietly(client)
229
+ raise SshError(
230
+ kind=SshErrorKind.TIMEOUT,
231
+ message=f"SSH 连接超时: {exc}",
232
+ cause=exc,
233
+ ) from exc
234
+ except (socket.error, paramiko.SSHException) as exc:
235
+ self._close_quietly(client)
236
+ raise SshError(
237
+ kind=SshErrorKind.CONNECTION,
238
+ message=f"SSH 连接失败: {exc}",
239
+ cause=exc,
240
+ ) from exc
241
+
242
+ # =========================
243
+ # 关闭 SSH 连接
244
+ # 保持幂等,允许重复调用
245
+ # =========================
246
+ def close(self) -> None:
247
+ if self._client is not None:
248
+ self._close_quietly(self._client)
249
+ self._client = None
250
+
251
+ # =========================
252
+ # 执行远端命令
253
+ # 不自动连接,不自动重试,不自动关闭 SSH client
254
+ # =========================
255
+ def execute(
256
+ self,
257
+ command: str,
258
+ env: Optional[Dict[str, str]] = None,
259
+ timeout_seconds: Optional[float] = None,
260
+ ) -> CommandResult:
261
+ if (
262
+ timeout_seconds is not None
263
+ and (not isfinite(timeout_seconds) or timeout_seconds <= 0)
264
+ ):
265
+ raise ValueError("timeout_seconds 必须大于 0")
266
+ if self._client is None or not self.is_connected():
267
+ raise SshError(
268
+ kind=SshErrorKind.NOT_CONNECTED,
269
+ message="SSH 连接尚未建立或已失活,请由上层决定是否重连",
270
+ )
271
+
272
+ stdin = None
273
+ channel = None
274
+ timeout_timer = None
275
+ timed_out = threading.Event()
276
+ deadline = (
277
+ time.monotonic() + timeout_seconds
278
+ if timeout_seconds is not None
279
+ else None
280
+ )
281
+
282
+ try:
283
+ transport = self._client.get_transport()
284
+ if transport is None or not transport.is_active():
285
+ raise SshError(
286
+ kind=SshErrorKind.NOT_CONNECTED,
287
+ message="SSH 连接尚未建立或已失活,请由上层决定是否重连",
288
+ )
289
+
290
+ open_timeout = (
291
+ max(0.0, deadline - time.monotonic())
292
+ if deadline is not None
293
+ else None
294
+ )
295
+ if deadline is not None:
296
+ timeout_timer = threading.Timer(
297
+ open_timeout,
298
+ self._abort_client_on_timeout,
299
+ args=(self._client, timed_out),
300
+ )
301
+ timeout_timer.daemon = True
302
+ timeout_timer.start()
303
+
304
+ channel = transport.open_session(timeout=open_timeout)
305
+ remaining_timeout = (
306
+ max(0.0, deadline - time.monotonic())
307
+ if deadline is not None
308
+ else None
309
+ )
310
+ if timed_out.is_set() or self._deadline_expired(deadline):
311
+ raise self._command_timeout(command)
312
+
313
+ channel.settimeout(remaining_timeout)
314
+ if env:
315
+ channel.update_environment(env)
316
+ channel.exec_command(command)
317
+ stdin = channel.makefile_stdin("wb", -1)
318
+ self._close_quietly(stdin)
319
+
320
+ stdout_chunks = bytearray()
321
+ stderr_chunks = bytearray()
322
+ while True:
323
+ for stdout_read_attempt in range(16):
324
+ if not channel.recv_ready():
325
+ break
326
+ stdout_chunks.extend(channel.recv(65536))
327
+ if timed_out.is_set() or self._deadline_expired(deadline):
328
+ raise self._command_timeout(command)
329
+
330
+ for stderr_read_attempt in range(16):
331
+ if not channel.recv_stderr_ready():
332
+ break
333
+ stderr_chunks.extend(channel.recv_stderr(65536))
334
+ if timed_out.is_set() or self._deadline_expired(deadline):
335
+ raise self._command_timeout(command)
336
+
337
+ if timed_out.is_set():
338
+ raise self._command_timeout(command)
339
+
340
+ if channel.exit_status_ready() or channel.closed:
341
+ continue_reading = channel.recv_ready() or channel.recv_stderr_ready()
342
+ if not continue_reading:
343
+ break
344
+
345
+ if timed_out.is_set() or self._deadline_expired(deadline):
346
+ raise self._command_timeout(command)
347
+ time.sleep(0.01)
348
+
349
+ if timed_out.is_set() or self._deadline_expired(deadline):
350
+ raise self._command_timeout(command)
351
+
352
+ exit_status = channel.recv_exit_status()
353
+ if exit_status < 0:
354
+ raise SshError(
355
+ kind=SshErrorKind.TRANSPORT,
356
+ message="SSH 命令未返回有效退出状态",
357
+ )
358
+ return CommandResult(
359
+ exit_status=exit_status,
360
+ stdout_text=bytes(stdout_chunks).decode("utf-8", errors="replace"),
361
+ stderr_text=bytes(stderr_chunks).decode("utf-8", errors="replace"),
362
+ )
363
+
364
+ except socket.timeout as exc:
365
+ raise SshError(
366
+ kind=SshErrorKind.TIMEOUT,
367
+ message=f"SSH 命令执行超时: {exc}",
368
+ cause=exc,
369
+ ) from exc
370
+ except (socket.error, EOFError, paramiko.SSHException) as exc:
371
+ if timed_out.is_set() or self._deadline_expired(deadline):
372
+ raise self._command_timeout(command, exc) from exc
373
+ raise SshError(
374
+ kind=SshErrorKind.TRANSPORT,
375
+ message=f"SSH 传输异常: {exc}",
376
+ cause=exc,
377
+ ) from exc
378
+ finally:
379
+ if timeout_timer is not None:
380
+ timeout_timer.cancel()
381
+ self._close_quietly(channel)
382
+ self._close_quietly(stdin)
383
+
384
+ # =========================
385
+ # 循环运行入口
386
+ # handler 自己保存首次运行状态和最近一次错误
387
+ # =========================
388
+ def loop_run(
389
+ self,
390
+ handler: SshLoopHandler,
391
+ interval_seconds: float,
392
+ ) -> None:
393
+ if not isfinite(interval_seconds):
394
+ raise ValueError("interval_seconds 必须是有限数值")
395
+
396
+ while True:
397
+ try:
398
+ if not self.is_connected():
399
+ self.connect()
400
+ handler.run(self)
401
+ handler.last_error = None
402
+ except Exception as exc:
403
+ handler.last_error = exc
404
+ if isinstance(exc, SshError) and self._should_reset_connection(exc):
405
+ self.close()
406
+ try:
407
+ handler.on_error(self)
408
+ except Exception:
409
+ pass
410
+ finally:
411
+ handler.is_first_run = False
412
+
413
+ if interval_seconds <= 0:
414
+ break
415
+ time.sleep(interval_seconds)
416
+
417
+ @staticmethod
418
+ def _deadline_expired(deadline: Optional[float]) -> bool:
419
+ return deadline is not None and time.monotonic() >= deadline
420
+
421
+ @staticmethod
422
+ def _command_timeout(
423
+ command: str,
424
+ cause: Optional[Exception] = None,
425
+ ) -> SshError:
426
+ return SshError(
427
+ kind=SshErrorKind.TIMEOUT,
428
+ message=f"SSH 命令执行超时: {command}",
429
+ cause=cause,
430
+ )
431
+
432
+ @staticmethod
433
+ def _abort_client_on_timeout(
434
+ client: paramiko.SSHClient,
435
+ timed_out: threading.Event,
436
+ ) -> None:
437
+ timed_out.set()
438
+ SshClient._close_quietly(client)
439
+
440
+ @staticmethod
441
+ def _close_quietly(resource) -> None:
442
+ if resource is None:
443
+ return
444
+ try:
445
+ resource.close()
446
+ except Exception:
447
+ pass
448
+
449
+ # =========================
450
+ # 判断哪些错误需要重置连接
451
+ # =========================
452
+ @staticmethod
453
+ def _should_reset_connection(exc: SshError) -> bool:
454
+ return exc.kind in {
455
+ SshErrorKind.CONNECTION,
456
+ SshErrorKind.NOT_CONNECTED,
457
+ SshErrorKind.TIMEOUT,
458
+ SshErrorKind.TRANSPORT,
459
+ }
460
+
461
+ # =========================
462
+ # 加载私钥
463
+ # 依次尝试常见私钥格式
464
+ # =========================
465
+ @staticmethod
466
+ def _load_private_key(
467
+ key_path: str,
468
+ key_passphrase: Optional[str] = None,
469
+ ):
470
+ key_loaders = [
471
+ paramiko.Ed25519Key.from_private_key_file,
472
+ paramiko.RSAKey.from_private_key_file,
473
+ paramiko.ECDSAKey.from_private_key_file,
474
+ ]
475
+ last_error: Optional[Exception] = None
476
+ password_error: Optional[Exception] = None
477
+
478
+ for key_loader in key_loaders:
479
+ try:
480
+ return key_loader(key_path, password=key_passphrase)
481
+ except (FileNotFoundError, PermissionError, OSError) as exc:
482
+ raise SshError(
483
+ kind=SshErrorKind.KEY_LOAD,
484
+ message=f"无法读取私钥文件: {key_path}: {exc}",
485
+ cause=exc,
486
+ ) from exc
487
+ except (paramiko.SSHException, ValueError) as exc:
488
+ last_error = exc
489
+ if isinstance(exc, paramiko.PasswordRequiredException):
490
+ password_error = exc
491
+
492
+ if password_error is not None:
493
+ last_error = password_error
494
+ raise SshError(
495
+ kind=SshErrorKind.KEY_LOAD,
496
+ message=f"无法加载私钥文件: {key_path}",
497
+ cause=last_error,
498
+ ) from last_error
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshkit
3
+ Version: 0.1.0
4
+ Summary: 安全的 SSH 客户端工具
5
+ Author: yanguangshaonian
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/yanguangshaonian/sshkit
8
+ Project-URL: Repository, https://github.com/yanguangshaonian/sshkit
9
+ Project-URL: Issues, https://github.com/yanguangshaonian/sshkit/issues
10
+ Keywords: ssh,paramiko,remote-command
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Networking
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: paramiko>=3.4
23
+ Dynamic: license-file
24
+
25
+ # sshkit
26
+
27
+ 一个基于 Paramiko 的 SSH 客户端工具,提供安全的主机密钥校验,远程命令执行,统一异常和循环处理能力.
28
+
29
+ ## 安装
30
+
31
+ ```bash
32
+ python -m pip install sshkit
33
+ ```
34
+
35
+ ## 快速使用
36
+
37
+ SSH 主机密钥默认使用 `RejectPolicy` 校验.连接前,请确保目标主机已存在于系统 `known_hosts` 或指定的文件中.
38
+
39
+ ```python
40
+ from sshkit import SshClient, SshError
41
+
42
+ client = SshClient(
43
+ hostname="trade.example.com",
44
+ ip="192.0.2.10",
45
+ port=22,
46
+ username="deploy",
47
+ key_path="/home/deploy/.ssh/id_ed25519",
48
+ known_hosts_path="/home/deploy/.ssh/known_hosts",
49
+ )
50
+
51
+ try:
52
+ client.connect()
53
+ result = client.execute("uname -a", timeout_seconds=5.0)
54
+ print(result.exit_status)
55
+ print(result.stdout_text)
56
+ finally:
57
+ client.close()
58
+ ```
59
+
60
+ 也可以使用上下文管理器:
61
+
62
+ ```python
63
+ from sshkit import SshClient
64
+
65
+ with SshClient(
66
+ hostname="trade.example.com",
67
+ ip="192.0.2.10",
68
+ port=22,
69
+ username="deploy",
70
+ password="password",
71
+ known_hosts_path="/home/deploy/.ssh/known_hosts",
72
+ ) as client:
73
+ result = client.execute("hostname", timeout_seconds=5.0)
74
+ ```
75
+
76
+ `password` 和 `key_path` 必须二选一.加密私钥可以通过 `key_passphrase` 传入 passphrase.程序不会自动使用 `ssh-agent` 或本地默认私钥.
77
+
78
+ ## 错误处理
79
+
80
+ 连接和命令错误统一使用 `SshError`,通过 `SshError.kind` 区分错误类型:
81
+
82
+ ```python
83
+ from sshkit import SshError, SshErrorKind
84
+
85
+ try:
86
+ client.connect()
87
+ except SshError as error:
88
+ if error.kind == SshErrorKind.AUTHENTICATION:
89
+ print("认证失败")
90
+ elif error.kind == SshErrorKind.HOST_KEY:
91
+ print("主机密钥校验失败")
92
+ ```
93
+
94
+ ## 开发
95
+
96
+ ```bash
97
+ python -m pip install -e .
98
+ python -m pytest
99
+ python -m build
100
+ python -m twine check dist/*
101
+ ```
102
+
103
+ ## 发布到 PyPI
104
+
105
+ 项目提供了交互式发布脚本.它会读取项目元数据并提示发布目标,然后检查工具,清理旧产物,运行测试,构建 wheel/sdist,执行 `twine check`,最后上传到选定的仓库.
106
+
107
+ 不要把 PyPI token 写入代码,配置文件或 git.需要认证时,脚本会交给 `twine` 处理.
108
+
109
+ 直接运行并交互选择目标:
110
+
111
+ ```bash
112
+ ./publish.sh
113
+ ```
114
+
115
+ 也可以直接指定目标:
116
+
117
+ ```bash
118
+ ./publish.sh testpypi
119
+ ./publish.sh pypi
120
+ ```
121
+
122
+ 只构建和校验,不上传:
123
+
124
+ ```bash
125
+ ./publish.sh testpypi --dry-run
126
+ ```
127
+
128
+ 跳过测试或保留构建产物:
129
+
130
+ ```bash
131
+ ./publish.sh testpypi --skip-tests
132
+ ./publish.sh testpypi --keep-artifacts
133
+ ```
134
+
135
+ 发布流程默认会在退出时清理 `build`、`dist`、`*.egg-info`、`__pycache__`、`.pytest_cache` 和 `*.pyc`. 只清理这些临时文件而不发布:
136
+
137
+ ```bash
138
+ ./publish.sh --clean-only
139
+ ```
140
+
141
+ `--keep-build` 仍可作为 `--keep-artifacts` 的兼容别名. 使用 `--keep-artifacts` 时, 发布产物和构建缓存会保留, 便于排查问题.
142
+
143
+ 建议先上传 TestPyPI,再验证安装流程:
144
+
145
+ ```bash
146
+ python -m pip install --index-url https://test.pypi.org/simple/ \
147
+ --no-deps sshkit==0.1.0
148
+ ```
149
+
150
+ 上面的 TestPyPI 安装命令只安装测试仓库中的 `sshkit`.运行库代码前,请确保依赖已经从正式 PyPI 安装:
151
+
152
+ ```bash
153
+ python -m pip install "paramiko>=3.4"
154
+ ```
155
+
156
+ 确认 TestPyPI 安装正常后,再执行:
157
+
158
+ ```bash
159
+ ./publish.sh pypi
160
+ ```
161
+
162
+ ## 许可证
163
+
164
+ MIT License,详见 [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ sshkit/__init__.py,sha256=8ZOZ5Q6q8b1BR14zb2w2W4_b0IUKqJ9yvlnTpDVaeno,282
2
+ sshkit/client.py,sha256=ZFzmnGx9TG-ua8u6Q_Ex-CI5ROSvOmwdrlxke55MuVw,17303
3
+ sshkit-0.1.0.dist-info/licenses/LICENSE,sha256=-ApOjGox8M1XgXG_tzt5mUo6YmMqh2MMgK74j7xurew,1073
4
+ sshkit-0.1.0.dist-info/METADATA,sha256=pXzXi_296Pp_W518FSE93ImOWSoDpCkwfkVBYBKBjy4,4115
5
+ sshkit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ sshkit-0.1.0.dist-info/top_level.txt,sha256=46JFXNG8deH1ilYn--xOG_NpohkP03jyJzKmmxEs_Fk,7
7
+ sshkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yanguangshaonian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sshkit