rrun-cli 0.1.2__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
5
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -21,6 +21,9 @@ Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
27
  Dynamic: license-file
25
28
 
26
29
  # rrun
@@ -100,6 +103,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
103
  | `rrun config` | Diagnose the machines.json source chain |
101
104
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
102
105
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
103
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
104
108
 
105
109
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -121,11 +125,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
121
125
  "defaults": { "windows": { "os": "Windows" } },
122
126
  "machines": [
123
127
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
124
- "user": "admin", "password": "secret" }
128
+ "user": "admin", "password": "secret" },
129
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
130
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
125
131
  ]
126
132
  }
127
133
  ```
128
134
 
135
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
136
+
129
137
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
130
138
 
131
139
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -145,12 +153,12 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
145
153
  2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
146
154
  3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
147
155
 
148
- If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
156
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
149
157
 
150
158
  ## Security
151
159
 
152
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
153
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
160
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
161
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
154
162
  - The audit log never records passwords, and `--env` values are logged as keys only.
155
163
 
156
164
  ## Auditing & state
@@ -168,7 +176,7 @@ pipx uninstall rrun-cli
168
176
 
169
177
  ## Contributing
170
178
 
171
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
179
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
172
180
 
173
181
  ## License
174
182
 
@@ -75,6 +75,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
75
75
  | `rrun config` | Diagnose the machines.json source chain |
76
76
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
77
77
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
78
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
78
79
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
79
80
 
80
81
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -96,11 +97,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
96
97
  "defaults": { "windows": { "os": "Windows" } },
97
98
  "machines": [
98
99
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
99
- "user": "admin", "password": "secret" }
100
+ "user": "admin", "password": "secret" },
101
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
102
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
100
103
  ]
101
104
  }
102
105
  ```
103
106
 
107
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
108
+
104
109
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
105
110
 
106
111
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -120,12 +125,12 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
120
125
  2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
121
126
  3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
122
127
 
123
- If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
128
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
124
129
 
125
130
  ## Security
126
131
 
127
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
128
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
132
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
133
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
129
134
  - The audit log never records passwords, and `--env` values are logged as keys only.
130
135
 
131
136
  ## Auditing & state
@@ -143,7 +148,7 @@ pipx uninstall rrun-cli
143
148
 
144
149
  ## Contributing
145
150
 
146
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
151
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
147
152
 
148
153
  ## License
149
154
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rrun-cli"
7
- version = "0.1.2"
7
+ version = "0.2.1"
8
8
  description = "Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,6 +24,9 @@ classifiers = [
24
24
  # 纯标准库实现,零第三方依赖。控制端仅需本机有 ssh + sshpass。
25
25
  dependencies = []
26
26
 
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "ruff>=0.6"]
29
+
27
30
  [project.urls]
28
31
  Homepage = "https://github.com/waqiju/rrun"
29
32
  Repository = "https://github.com/waqiju/rrun"
@@ -43,3 +46,16 @@ where = ["src"]
43
46
 
44
47
  [tool.setuptools.package-data]
45
48
  rrun = ["remote-requirements.txt"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+
53
+ [tool.ruff]
54
+ target-version = "py310"
55
+ line-length = 120
56
+ src = ["src"]
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "I", "UP", "B", "BLE"]
60
+ # E501 交给格式化宽限:项目内大量中文注释 + 单行 wrapper 模板,硬限 120 收益低
61
+ ignore = ["E501"]
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """rrun — 远程机器执行模块(Run Remote)。
3
2
 
4
3
  约定:read/write/edit 等文件操作始终发生在本地;任何远程执行都是
@@ -12,8 +11,9 @@ try:
12
11
 
13
12
  __version__ = _pkg_version("rrun-cli")
14
13
  except Exception: # noqa: BLE001 - 未安装(源码直跑)时回退
15
- __version__ = "0.1.2"
14
+ __version__ = "0.2.1"
16
15
 
16
+ from .doctor import DoctorResult, check_machine
17
17
  from .executor import (
18
18
  AUDIT_LOG,
19
19
  EXIT_TIMEOUT,
@@ -30,7 +30,7 @@ from .setup import SetupResult, load_requirements, setup_machine
30
30
 
31
31
  __all__ = [
32
32
  "AUDIT_LOG", "EXIT_TIMEOUT", "EXIT_TRANSPORT_ERROR", "RRUN_HOME",
33
- "ExecResult", "Machine", "SetupResult", "SourceInfo",
34
- "build_ps_wrapper", "candidate_sources", "close_mux", "detect_remote_python",
33
+ "DoctorResult", "ExecResult", "Machine", "SetupResult", "SourceInfo",
34
+ "build_ps_wrapper", "candidate_sources", "check_machine", "close_mux", "detect_remote_python",
35
35
  "load_machines", "load_requirements", "resolve_machine", "run", "scan_sources", "setup_machine",
36
36
  ]
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """rrun CLI — run local scripts on remote machines over SSH stdin pipes.
3
2
 
4
3
  Core contract: script content (UTF-8, CJK welcome) is piped to the remote
@@ -12,6 +11,7 @@ Usage:
12
11
  rrun exec mac_mini --lang python temp/x.py
13
12
  rrun setup <host|--all> [--force] # provision the unified python env (3.12 venv)
14
13
  rrun pip <host> -- list # run pip in the unified venv
14
+ rrun doctor <host|--all> # health-check ssh + auth + remote python
15
15
  rrun machines # list machines (redacted, with source)
16
16
  rrun config # diagnose the machines.json source chain
17
17
  rrun close [<host>|--all] # close ssh multiplexed connections
@@ -29,6 +29,8 @@ Conventions:
29
29
  255=ssh transport error; 124=local timeout.
30
30
  - Inline -c content is archived under ~/.rrun/drops/ for replay.
31
31
  - Audit: every execution appends to ~/.rrun/log/remote-exec.jsonl.
32
+ - Auth: machines.json "password" => sshpass; leave it empty for key-based
33
+ ssh ("identity_file" optional); "port" overrides the default ssh port 22.
32
34
 
33
35
  Remote stdout -> local stdout, stderr -> stderr; the exit code is the remote one.
34
36
  """
@@ -39,6 +41,7 @@ import sys
39
41
  import time
40
42
  from pathlib import Path
41
43
 
44
+ from .doctor import check_machine
42
45
  from .executor import RRUN_HOME, _check_ascii, _ssh_run, close_mux, run
43
46
  from .registry import load_machines, resolve_machine, scan_sources
44
47
  from .setup import setup_machine, venv_python_or_die
@@ -69,7 +72,7 @@ def cmd_exec(ns) -> int:
69
72
  try:
70
73
  machine = resolve_machine(ns.host)
71
74
  except KeyError as e:
72
- raise SystemExit(f"[remote-exec] {e}")
75
+ raise SystemExit(f"[remote-exec] {e}") from None
73
76
 
74
77
  content = ns.content
75
78
  file = ns.script or ""
@@ -98,7 +101,7 @@ def cmd_exec(ns) -> int:
98
101
  mux=not ns.no_mux,
99
102
  )
100
103
  except (ValueError, RuntimeError) as e:
101
- raise SystemExit(f"[remote-exec] {e}")
104
+ raise SystemExit(f"[remote-exec] {e}") from None
102
105
 
103
106
  if not ns.quiet:
104
107
  via = script_for_log or "<stdin>"
@@ -113,7 +116,7 @@ def cmd_exec(ns) -> int:
113
116
  if result.timed_out:
114
117
  print(f"[remote-exec] local timeout ({ns.timeout}s), ssh client killed", file=sys.stderr)
115
118
  elif result.transport_error:
116
- print(f"[remote-exec] ssh transport error (unreachable / auth failure / dropped), exit=255", file=sys.stderr)
119
+ print("[remote-exec] ssh transport error (unreachable / auth failure / dropped), exit=255", file=sys.stderr)
117
120
  if not ns.quiet:
118
121
  print(f"[remote-exec] exit={result.exit_code} took {result.duration:.1f}s", file=sys.stderr)
119
122
  return result.exit_code
@@ -199,7 +202,7 @@ def cmd_pip(ns) -> int:
199
202
  try:
200
203
  machine = resolve_machine(ns.host)
201
204
  except KeyError as e:
202
- raise SystemExit(f"[pip] {e}")
205
+ raise SystemExit(f"[pip] {e}") from None
203
206
  args = list(ns.pargs or []) + list(getattr(ns, "args", []) or [])
204
207
  if not args:
205
208
  raise SystemExit("[pip] missing pip args, e.g.: rrun pip <host> -- list")
@@ -207,7 +210,7 @@ def cmd_pip(ns) -> int:
207
210
  _check_ascii(args, "pip args")
208
211
  py = venv_python_or_die(ns.host)
209
212
  except (ValueError, RuntimeError) as e:
210
- raise SystemExit(f"[pip] {e}")
213
+ raise SystemExit(f"[pip] {e}") from None
211
214
  joined = " ".join(args)
212
215
  if machine.is_windows:
213
216
  remote_cmd = f'"{py}" -m pip {joined}'
@@ -223,6 +226,35 @@ def cmd_pip(ns) -> int:
223
226
  return rc
224
227
 
225
228
 
229
+ def cmd_doctor(ns) -> int:
230
+ if ns.all:
231
+ hosts = [m.name for m in load_machines()]
232
+ elif ns.host:
233
+ hosts = [ns.host]
234
+ else:
235
+ raise SystemExit("[doctor] specify a host, or --all to check every machine")
236
+ results = []
237
+ if len(hosts) == 1:
238
+ print(f"[doctor] checking {hosts[0]} ...", file=sys.stderr)
239
+ results.append(check_machine(hosts[0]))
240
+ else:
241
+ from concurrent.futures import ThreadPoolExecutor, as_completed
242
+ with ThreadPoolExecutor(max_workers=ns.jobs) as pool:
243
+ futs = {pool.submit(check_machine, h): h for h in hosts}
244
+ for f in as_completed(futs):
245
+ r = f.result()
246
+ results.append(r)
247
+ print(f"[doctor] {r.host}: {'ok' if r.ok else 'FAIL'} ({r.latency_s:.1f}s)", file=sys.stderr)
248
+ print(f"\n{'host':<24} {'result':<6} {'ssh':<5} {'python':<10} {'via':<14} note")
249
+ for r in sorted(results, key=lambda x: x.host):
250
+ ssh = "ok" if r.ssh_ok else "FAIL"
251
+ ver = r.python_version or "-"
252
+ via = ("unified venv" if r.is_unified_venv else ("fallback" if r.python_path else "-"))
253
+ note = "; ".join(r.checks) if r.ok else r.message
254
+ print(f"{r.host:<24} {'ok' if r.ok else 'FAIL':<6} {ssh:<5} {ver:<10} {via:<14} {note[:90]}")
255
+ return 0 if all(r.ok for r in results) else 1
256
+
257
+
226
258
  def cmd_close(ns) -> int:
227
259
  if ns.all:
228
260
  hosts = [m.name for m in load_machines()]
@@ -285,6 +317,12 @@ def main() -> None:
285
317
  pp.add_argument("--timeout", type=float, default=300.0, help="local timeout in seconds (default 300)")
286
318
  pp.set_defaults(func=cmd_pip)
287
319
 
320
+ dp = sub.add_parser("doctor", help="health-check a machine (ssh + auth + remote python)")
321
+ dp.add_argument("host", nargs="?", help="machine name/ip; omit with --all")
322
+ dp.add_argument("--all", action="store_true", help="check every machine (opens real ssh connections)")
323
+ dp.add_argument("--jobs", type=int, default=6, help="concurrency for --all (default 6)")
324
+ dp.set_defaults(func=cmd_doctor)
325
+
288
326
  cp = sub.add_parser("close", help="close ssh ControlMaster multiplexed connections")
289
327
  cp.add_argument("host", nargs="?", help="machine name/ip; omit with --all")
290
328
  cp.add_argument("--all", action="store_true", help="close connections for all machines")
@@ -293,7 +331,7 @@ def main() -> None:
293
331
  # argparse 对 -- 的处理与子解析器/位置参数组合有 quirk,手动切分更可靠:
294
332
  # 第一个 -- 之后的全部内容原样作为脚本参数
295
333
  argv = sys.argv[1:]
296
- passthrough: "list[str] | None" = None
334
+ passthrough: list[str] | None = None
297
335
  if "--" in argv:
298
336
  i = argv.index("--")
299
337
  argv, passthrough = argv[:i], argv[i + 1:]
@@ -0,0 +1,109 @@
1
+ """doctor:单机健康检查(ssh 连通性 + 认证 + 远端 python 环境)。
2
+
3
+ 默认只查显式指定的一台机器——检查会产生真实 ssh 连接(副作用),
4
+ 连全部机器必须显式 --all。
5
+ """
6
+
7
+ import time
8
+ from dataclasses import dataclass, field
9
+
10
+ from .executor import (
11
+ POSIX_BASE_CANDIDATES,
12
+ POSIX_PYTHON_CANDIDATES,
13
+ PS_REMOTE_CMD,
14
+ VENV_PY_POSIX,
15
+ VENV_PY_WIN,
16
+ WINDOWS_BASE_CANDIDATES,
17
+ WINDOWS_PYTHON_CANDIDATES,
18
+ _ssh_run,
19
+ build_ps_wrapper,
20
+ check_remote_pip,
21
+ probe_python,
22
+ )
23
+ from .registry import Machine, resolve_machine
24
+
25
+
26
+ @dataclass
27
+ class DoctorResult:
28
+ host: str
29
+ ok: bool = False
30
+ ssh_ok: bool = False
31
+ latency_s: float = 0.0
32
+ python_path: str = ""
33
+ python_version: str = ""
34
+ is_unified_venv: bool = False
35
+ checks: "list[str]" = field(default_factory=list) # 已通过的检查项描述
36
+ message: str = "" # 失败原因 / 建议
37
+
38
+
39
+ def _venv_missing_guidance(machine: Machine, found_version: str, mux: bool) -> str:
40
+ """venv 缺失时的指引:审查基座资格(ensurepip),说明 setup 将会走哪条路。"""
41
+ base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
42
+ base = probe_python(machine, base_cands, mux, require_ensurepip=True)
43
+ if base:
44
+ head = "unified venv missing" if found_version else "no python 3.12 found"
45
+ return f"{head}; run: rrun setup {machine.name}"
46
+ hint = " (Debian/Ubuntu: sudo apt install python3.12-venv)" if not machine.is_windows else ""
47
+ if found_version:
48
+ return (f"unified venv missing; base python {found_version} lacks ensurepip{hint}; "
49
+ f"rrun setup {machine.name} will install a standalone python")
50
+ return f"no python 3.12 found; rrun setup {machine.name} will install a standalone python"
51
+
52
+
53
+ def check_machine(host: str, mux: bool = True, timeout: float = 30.0) -> DoctorResult:
54
+ """检查单台机器:ssh 回显 → 远端 python 探测。不抛异常,结果全部汇入 DoctorResult。"""
55
+ try:
56
+ machine = resolve_machine(host)
57
+ except KeyError as e:
58
+ return DoctorResult(host=host, message=str(e))
59
+ r = DoctorResult(host=machine.name)
60
+
61
+ # 1) ssh 连通 + 认证(一个最小回显脚本)
62
+ t0 = time.time()
63
+ try:
64
+ if machine.is_windows:
65
+ rc, out, err, _ = _ssh_run(machine, PS_REMOTE_CMD,
66
+ build_ps_wrapper("Write-Output rrun-ok"), timeout, mux)
67
+ else:
68
+ rc, out, err, _ = _ssh_run(machine, "bash -s", b"echo rrun-ok\n", timeout, mux)
69
+ except RuntimeError as e:
70
+ r.message = str(e)
71
+ return r
72
+ r.latency_s = time.time() - t0
73
+ if rc != 0 or b"rrun-ok" not in out:
74
+ detail = err.decode("utf-8", "replace").strip()[:200]
75
+ r.message = (f"ssh check failed (exit={rc})"
76
+ + (f": {detail}" if detail else ""))
77
+ return r
78
+ r.ssh_ok = True
79
+ r.checks.append(f"ssh ok ({r.latency_s:.2f}s)")
80
+
81
+ # 2) 远端 python 探测(exec 热路径同款候选链;不命中不算 ssh 失败)
82
+ venv_py = VENV_PY_WIN if machine.is_windows else VENV_PY_POSIX
83
+ candidates = WINDOWS_PYTHON_CANDIDATES if machine.is_windows else POSIX_PYTHON_CANDIDATES
84
+ try:
85
+ hit = probe_python(machine, candidates, mux)
86
+ except RuntimeError as e:
87
+ r.message = str(e)
88
+ return r
89
+ if hit:
90
+ r.python_path, r.python_version = hit
91
+ # POSIX 候选含 $HOME,远端展开后回显的是绝对路径,不能与常量直接等值比较;
92
+ # 用后缀判定(Windows 候选是绝对路径常量,等值即可)。
93
+ if machine.is_windows:
94
+ r.is_unified_venv = r.python_path.lower() == venv_py.lower()
95
+ else:
96
+ r.is_unified_venv = r.python_path == venv_py or r.python_path.endswith(
97
+ "/.remote-machine/venv/bin/python")
98
+ tag = "unified venv" if r.is_unified_venv else "fallback (not the unified venv)"
99
+ r.checks.append(f"python {r.python_version} at {r.python_path} [{tag}]")
100
+ if r.is_unified_venv:
101
+ if not check_remote_pip(machine, r.python_path, mux):
102
+ r.message = f"unified venv is broken (pip missing); repair: rrun setup {machine.name}"
103
+ return r
104
+ else:
105
+ r.message = _venv_missing_guidance(machine, r.python_version, mux)
106
+ else:
107
+ r.message = _venv_missing_guidance(machine, "", mux)
108
+ r.ok = r.ssh_ok and bool(hit) and r.is_unified_venv
109
+ return r
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """远程执行核心:本地脚本 → ssh stdin 管道 → 远端解释器执行。
3
2
 
4
3
  设计要点(Phase 0 spike 实测验证):
@@ -164,18 +163,35 @@ def build_bash_command(args=(), workdir: str = "", env: "dict | None" = None) ->
164
163
  return cmd
165
164
 
166
165
 
167
- def _ssh_run(machine: Machine, remote_cmd: str, stdin: bytes, timeout: "float | None",
168
- mux: bool) -> "tuple[int, bytes, bytes, bool]":
169
- """返回 (exit_code, stdout, stderr, timed_out)。exit 255 即传输层错误。"""
170
- sshpass = shutil.which("sshpass")
171
- if not sshpass:
172
- raise RuntimeError("sshpass not found on this machine (install: sudo apt install sshpass / brew install hudochenkov/sshpass/sshpass)")
166
+ def _ssh_args(machine: Machine, mux: bool) -> "list[str]":
167
+ """组装 ssh/sshpass 参数:认证(密码走 sshpass,否则 key + BatchMode)、端口、mux。"""
168
+ args: list[str] = []
169
+ if machine.password:
170
+ sshpass = shutil.which("sshpass")
171
+ if not sshpass:
172
+ raise RuntimeError("sshpass not found on this machine (install: sudo apt install sshpass / brew install hudochenkov/sshpass/sshpass)")
173
+ args += [sshpass, "-p", machine.password]
174
+ args.append("ssh")
175
+ if machine.password:
176
+ args += ["-o", "NumberOfPasswordPrompts=1"] # 密码错误快速失败,不反复提示
177
+ else:
178
+ # key 认证:禁交互提示(防密码 prompt 把 stdin 脚本吃掉/挂起)
179
+ args += ["-o", "BatchMode=yes"]
180
+ if machine.identity_file:
181
+ args += ["-i", str(Path(machine.identity_file).expanduser())]
182
+ args += SSH_BASE_OPTS
183
+ if machine.port != 22:
184
+ args += ["-p", str(machine.port)]
173
185
  if mux:
174
186
  CONTROL_PATH_DIR.mkdir(mode=0o700, exist_ok=True)
175
- args = [sshpass, "-p", machine.password, "ssh", *SSH_BASE_OPTS]
176
- if mux:
177
187
  args += MUX_OPTS
178
- args += [machine.target, remote_cmd]
188
+ return args
189
+
190
+
191
+ def _ssh_run(machine: Machine, remote_cmd: str, stdin: bytes, timeout: "float | None",
192
+ mux: bool) -> "tuple[int, bytes, bytes, bool]":
193
+ """返回 (exit_code, stdout, stderr, timed_out)。exit 255 即传输层错误。"""
194
+ args = [*_ssh_args(machine, mux), machine.target, remote_cmd]
179
195
  try:
180
196
  p = subprocess.run(args, input=stdin, capture_output=True,
181
197
  timeout=timeout if timeout and timeout > 0 else None)
@@ -189,27 +205,33 @@ _PY_VER_PRINT_CODE = "import sys;print(*sys.version_info[:3],sep=chr(46))"
189
205
  _PY_VER_CHECK_CODE = (
190
206
  _PY_VER_PRINT_CODE + f";sys.exit(0 if sys.version_info[:2]=={REQUIRED_PY_MAJOR_MINOR} else 1)"
191
207
  )
208
+ # 基座资格加码:ensurepip 必须可 import(Debian/Ubuntu 把 ensurepip 整体拆进 python3.x-venv
209
+ # 包,缺失时系统 python 版本对却建不了 venv——不算合格基座)。import 失败即非零退出,候选跳过。
210
+ _PY_BASE_CHECK_CODE = "import ensurepip;" + _PY_VER_CHECK_CODE
192
211
 
193
212
 
194
- def _ps_probe_script(candidates) -> str:
195
- """Windows 探测脚本:逐候选校验版本==3.12.x,命中输出 path|x.y.z,全灭 exit 1。"""
213
+ def _ps_probe_script(candidates, require_ensurepip: bool = False) -> str:
214
+ """Windows 探测脚本:逐候选校验(版本==3.12.x,require_ensurepip 时加 ensurepip 资格),
215
+ 命中输出 path|x.y.z,全灭 exit 1。"""
216
+ check = _PY_BASE_CHECK_CODE if require_ensurepip else _PY_VER_CHECK_CODE
196
217
  arr = ",".join(_ps_quote(c) for c in candidates)
197
218
  return (
198
219
  f"foreach($c in @({arr})){{"
199
220
  "if(Test-Path $c){"
200
- f"$v=& $c -X utf8 -c \"{_PY_VER_CHECK_CODE}\" 2>$null;"
221
+ f"$v=& $c -X utf8 -c \"{check}\" 2>$null;"
201
222
  "if($LASTEXITCODE -eq 0){Write-Output ($c+'|'+($v -join ''));exit 0}"
202
223
  "}}"
203
224
  )
204
225
 
205
226
 
206
- def _bash_probe_script(candidates) -> str:
227
+ def _bash_probe_script(candidates, require_ensurepip: bool = False) -> str:
207
228
  """posix 探测脚本:同 Windows 语义。候选为可信常量($HOME 需保留展开,不可 shlex.quote)。"""
229
+ check = _PY_BASE_CHECK_CODE if require_ensurepip else _PY_VER_CHECK_CODE
208
230
  items = " ".join(f'"{c}"' for c in candidates)
209
231
  return (
210
232
  f"for p in {items}; do\n"
211
233
  ' command -v "$p" >/dev/null 2>&1 || continue\n'
212
- f' v=$("$p" -X utf8 -c \'{_PY_VER_CHECK_CODE}\' 2>/dev/null) || continue\n'
234
+ f' v=$("$p" -X utf8 -c \'{check}\' 2>/dev/null) || continue\n'
213
235
  ' echo "$p|$v"\n'
214
236
  " exit 0\n"
215
237
  "done\n"
@@ -217,14 +239,19 @@ def _bash_probe_script(candidates) -> str:
217
239
  )
218
240
 
219
241
 
220
- def probe_python(machine: Machine, candidates, mux: bool = True) -> "tuple[str, str] | None":
221
- """在候选中找版本==3.12.x 的远端 python,单次往返;返回 (路径, 版本),全灭返回 None"""
242
+ def probe_python(machine: Machine, candidates, mux: bool = True,
243
+ require_ensurepip: bool = False) -> "tuple[str, str] | None":
244
+ """在候选中找版本==3.12.x 的远端 python,单次往返;返回 (路径, 版本),全灭返回 None。
245
+
246
+ require_ensurepip=True 用于 setup/doctor 的基座资格审查:无 ensurepip 的 python
247
+ 建不了 venv(Debian/Ubuntu 拆包 python3.x-venv 的典型场景),跳过。
248
+ """
222
249
  if machine.is_windows:
223
250
  rc, out, _, _ = _ssh_run(machine, PS_REMOTE_CMD,
224
- build_ps_wrapper(_ps_probe_script(candidates)), 60, mux)
251
+ build_ps_wrapper(_ps_probe_script(candidates, require_ensurepip)), 60, mux)
225
252
  else:
226
253
  rc, out, _, _ = _ssh_run(machine, "bash -s",
227
- _bash_probe_script(candidates).encode("utf-8"), 60, mux)
254
+ _bash_probe_script(candidates, require_ensurepip).encode("utf-8"), 60, mux)
228
255
  if rc != 0 or not out.strip():
229
256
  return None
230
257
  line = out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
@@ -246,6 +273,18 @@ def probe_python_version(machine: Machine, python_path: str, mux: bool = True) -
246
273
  return out.decode("utf-8", "replace").strip().splitlines()[-1].strip()
247
274
 
248
275
 
276
+ def check_remote_pip(machine: Machine, python_path: str, mux: bool = True) -> bool:
277
+ """检查指定远端 python 是否带 pip(半拉子 venv 检测:venv python 能跑,
278
+ 但若创建时 ensurepip 失败过,venv 里就没有 pip)。"""
279
+ if machine.is_windows:
280
+ payload = build_ps_wrapper(f"& {_ps_quote(python_path)} -m pip --version *>$null")
281
+ rc, _, _, _ = _ssh_run(machine, PS_REMOTE_CMD, payload, 30, mux)
282
+ else:
283
+ rc, _, _, _ = _ssh_run(machine, "bash -s",
284
+ f'"{python_path}" -m pip --version >/dev/null 2>&1\n'.encode(), 30, mux)
285
+ return rc == 0
286
+
287
+
249
288
  def detect_remote_python(machine: Machine, override: str = "", mux: bool = True) -> "tuple[str, str]":
250
289
  """解析远端 python,返回 (路径, 版本)。基线锁 3.12;machines.json "python" 字段/参数可覆盖(跳过校验)。"""
251
290
  key = (machine.ip, override or machine.python)
@@ -356,7 +395,11 @@ def _write_audit(result: ExecResult, args, workdir: str, env: "dict | None",
356
395
  def close_mux(host: str) -> "tuple[int, str]":
357
396
  """关闭某台机器的 ControlMaster 复用连接。"""
358
397
  machine = resolve_machine(host)
359
- p = subprocess.run(
360
- ["ssh", "-O", "exit", "-o", f"ControlPath={CONTROL_PATH}", machine.target],
361
- capture_output=True, text=True, timeout=15)
398
+ args = ["ssh", "-O", "exit", "-o", f"ControlPath={CONTROL_PATH}"]
399
+ if machine.port != 22:
400
+ args += ["-p", str(machine.port)]
401
+ if machine.identity_file:
402
+ args += ["-i", str(Path(machine.identity_file).expanduser())]
403
+ args.append(machine.target)
404
+ p = subprocess.run(args, capture_output=True, text=True, timeout=15)
362
405
  return p.returncode, (p.stdout + p.stderr).strip()
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """机器注册表:多来源加载 machines.json,按优先级 merge,解析 name/ip/hostname → Machine。
3
2
 
4
3
  来源链(高 → 低优先级;同名机器高优先级覆盖;同名/ip 冲突静默处理,不刷警告):
@@ -24,7 +23,7 @@ from pathlib import Path
24
23
  ENV_MACHINES_JSON = "RRUN_CONFIG"
25
24
  ENV_MACHINES_JSON_LEGACY = "REMOTE_MACHINE_CONFIG"
26
25
 
27
- _KNOWN_KEYS = {"name", "ip", "user", "password", "os", "hostname", "description", "python"}
26
+ _KNOWN_KEYS = {"name", "ip", "user", "password", "os", "hostname", "description", "python", "port", "identity_file"}
28
27
 
29
28
 
30
29
  @dataclass
@@ -32,11 +31,13 @@ class Machine:
32
31
  name: str
33
32
  ip: str
34
33
  user: str
35
- password: str = field(repr=False) # 凭据不进 repr/日志
34
+ password: str = field(repr=False, default="") # 凭据不进 repr/日志;留空 = 走 key 认证
36
35
  os: str = "Windows" # 原样取值:Windows / Mac / Linux
37
36
  hostname: str = ""
38
37
  description: str = ""
39
38
  python: str = "" # 可选:显式指定远端 python 路径,跳过自动探测
39
+ port: int = 22 # SSH 端口
40
+ identity_file: str = "" # 可选:私钥路径(password 为空时走默认 key 链/agent)
40
41
  source: str = "" # 来源文件路径(多来源 merge 时记录出处)
41
42
 
42
43
  @property
@@ -56,6 +57,7 @@ class Machine:
56
57
  return {
57
58
  "name": self.name, "ip": self.ip, "os": self.os, "user": self.user,
58
59
  "hostname": self.hostname, "description": self.description,
60
+ "port": self.port, "auth": "password" if self.password else "key",
59
61
  "default_lang": self.default_lang, "source": self.source,
60
62
  }
61
63
 
@@ -72,7 +74,7 @@ class SourceInfo:
72
74
 
73
75
  def candidate_sources() -> "list[tuple[str, Path]]":
74
76
  """返回 (标签, 路径) 有序来源链(高→低优先级),按 resolved path 去重。"""
75
- cands: "list[tuple[str, Path]]" = []
77
+ cands: list[tuple[str, Path]] = []
76
78
  for env_name in (ENV_MACHINES_JSON, ENV_MACHINES_JSON_LEGACY):
77
79
  env = os.environ.get(env_name, "")
78
80
  env_paths = [x for x in env.split(os.pathsep) if x.strip()]
@@ -90,8 +92,8 @@ def candidate_sources() -> "list[tuple[str, Path]]":
90
92
  cands.append(("legacy", Path(r"C:\tools\remote-machine\machines.json")))
91
93
  else:
92
94
  cands.append(("legacy", Path.home() / ".remote-machine" / "machines.json"))
93
- seen: "set[str]" = set()
94
- uniq: "list[tuple[str, Path]]" = []
95
+ seen: set[str] = set()
96
+ uniq: list[tuple[str, Path]] = []
95
97
  for label, p in cands:
96
98
  try:
97
99
  key = os.path.normcase(str(p.resolve()))
@@ -113,6 +115,8 @@ def _load_file(path: Path) -> "list[Machine]":
113
115
  base = defaults.get(str(m.get("os", "")).lower()) or {}
114
116
  merged = {**base, **m} # 机器自身字段优先于 defaults
115
117
  kwargs = {k: v for k, v in merged.items() if k in _KNOWN_KEYS}
118
+ if "port" in kwargs:
119
+ kwargs["port"] = int(kwargs["port"])
116
120
  machine = Machine(**kwargs)
117
121
  machine.source = str(path)
118
122
  result.append(machine)
@@ -123,7 +127,7 @@ def load_machines(path: "str | os.PathLike | None" = None) -> "list[Machine]":
123
127
  """加载机器清单。显式传 path = 单文件模式;否则按来源链 merge(同名高优先级胜)。"""
124
128
  if path:
125
129
  return _load_file(Path(path))
126
- merged: "dict[str, Machine]" = {}
130
+ merged: dict[str, Machine] = {}
127
131
  for _label, p in candidate_sources():
128
132
  if not p.is_file():
129
133
  continue
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  """setup:初始化远端统一 python 环境(基线 3.12 + 统一 venv + 阿里云 pip 源)。
3
2
 
4
3
  布局(幂等,可重复执行):
@@ -39,6 +38,7 @@ from .executor import (
39
38
  _check_ascii,
40
39
  _ssh_run,
41
40
  build_ps_wrapper,
41
+ check_remote_pip,
42
42
  probe_python,
43
43
  probe_python_version,
44
44
  )
@@ -55,6 +55,8 @@ PBS_ASSETS = {
55
55
  ("Windows", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-pc-windows-msvc-install_only.tar.gz",
56
56
  ("Mac", "arm64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-apple-darwin-install_only.tar.gz",
57
57
  ("Mac", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-apple-darwin-install_only.tar.gz",
58
+ ("Linux", "x86_64"): f"cpython-{PBS_VER}+{PBS_TAG}-x86_64-unknown-linux-gnu-install_only.tar.gz",
59
+ ("Linux", "aarch64"): f"cpython-{PBS_VER}+{PBS_TAG}-aarch64-unknown-linux-gnu-install_only.tar.gz",
58
60
  }
59
61
  PBS_DOWNLOAD_BASE = f"https://github.com/astral-sh/python-build-standalone/releases/download/{PBS_TAG}/"
60
62
  CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "") or Path.home() / ".cache") / "rrun"
@@ -110,6 +112,13 @@ def _ensure_local_tarball(os_name: str, arch: str) -> Path:
110
112
  return path
111
113
 
112
114
 
115
+ def _pbs_platform(uname_sm: str) -> "tuple[str, str]":
116
+ """`uname -sm` 输出 -> PBS_ASSETS 键('Linux x86_64'->('Linux','x86_64'),'Darwin arm64'->('Mac','arm64'))。"""
117
+ parts = uname_sm.split()
118
+ sys_name, arch = (parts + ["", ""])[:2]
119
+ return {"Darwin": "Mac", "Linux": "Linux"}.get(sys_name, sys_name), arch
120
+
121
+
113
122
  def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
114
123
  """推送 standalone python 到远端并解压,返回基座 python 路径。"""
115
124
  if machine.is_windows:
@@ -124,11 +133,10 @@ def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
124
133
  )
125
134
  base_py = BASE_PY_WIN
126
135
  else:
127
- rc, out, _, _ = _ssh_run(machine, "uname -m", b"", 30, mux)
128
- arch = out.decode("utf-8", "replace").strip()
129
- os_name = "Mac"
136
+ rc, out, _, _ = _ssh_run(machine, "uname -sm", b"", 30, mux)
137
+ os_name, arch = _pbs_platform(out.decode("utf-8", "replace"))
130
138
  if (os_name, arch) not in PBS_ASSETS:
131
- raise RuntimeError(f"[{machine.name}] unsupported architecture: {arch}")
139
+ raise RuntimeError(f"[{machine.name}] no standalone python build for platform: {os_name} {arch}")
132
140
  extract_cmd = (
133
141
  'tools="$HOME/.remote-machine"; mkdir -p "$tools" '
134
142
  '&& rm -rf "$tools/python312" '
@@ -150,7 +158,11 @@ def _install_standalone(machine: Machine, mux: bool, timeout: float) -> str:
150
158
 
151
159
 
152
160
  def _build_ps_setup(base_py: str, pkgs: "list[str]", force: bool) -> str:
153
- """Windows 一键脚本:建 venv(缺/force 时)→ pip.ini → 装依赖 → 自检。全 ASCII。"""
161
+ """Windows 一键脚本:建 venv(缺/坏/force 时)→ pip.ini → 装依赖 → 自检。全 ASCII。
162
+
163
+ “坏” = bin/python 在但 pip 不在:上次 venv 创建中途失败(如 ensurepip 缺失)
164
+ 留下的半拉子 venv,直接重建(幂等自愈)。
165
+ """
154
166
  pkg_args = " ".join(pkgs)
155
167
  imports = ";".join(f"import {_req_import_name(p)}" for p in pkgs) or "pass"
156
168
  force_lit = "$true" if force else "$false"
@@ -160,6 +172,10 @@ $venv="$tools\\venv"
160
172
  $venvPy="$venv\\Scripts\\python.exe"
161
173
  New-Item -ItemType Directory -Force $tools | Out-Null
162
174
  if({force_lit} -and (Test-Path $venv)){{Remove-Item -Recurse -Force $venv}}
175
+ if(Test-Path $venvPy){{
176
+ & $venvPy -m pip --version *>$null
177
+ if($LASTEXITCODE -ne 0){{Remove-Item -Recurse -Force $venv}}
178
+ }}
163
179
  if(-not (Test-Path $venvPy)){{
164
180
  if('{base_py}' -eq ''){{Write-Error "no base python";exit 1}}
165
181
  & '{base_py}' -m venv $venv
@@ -185,6 +201,9 @@ tools="$HOME/.remote-machine"
185
201
  venv="$tools/venv"
186
202
  mkdir -p "$tools"
187
203
  if [ "{'1' if force else '0'}" = "1" ]; then rm -rf "$venv"; fi
204
+ if [ -x "$venv/bin/python" ] && ! "$venv/bin/python" -m pip --version >/dev/null 2>&1; then
205
+ rm -rf "$venv"
206
+ fi
188
207
  if [ ! -x "$venv/bin/python" ]; then
189
208
  "{base_py}" -m venv "$venv"
190
209
  fi
@@ -210,13 +229,20 @@ def setup_machine(host: str, force: bool = False, mux: bool = True,
210
229
  base_cands = WINDOWS_BASE_CANDIDATES if machine.is_windows else POSIX_BASE_CANDIDATES
211
230
 
212
231
  hit = probe_python(machine, [venv_py], mux)
213
- need_create = force or not hit
232
+ # 半拉子 venv(python 能跑但 pip 不在——上次创建中途失败的残留)也视为待重建;
233
+ # 否则远端脚本删掉坏 venv 后会拿空 base_py 去重建,必然失败
234
+ venv_broken = bool(hit) and not check_remote_pip(machine, hit[0], mux)
235
+ need_create = force or not hit or venv_broken
214
236
  base_py = ""
215
237
  if need_create:
216
- base_hit = probe_python(machine, base_cands, mux)
238
+ # 基座资格:版本 3.12 且带 ensurepip(缺 ensurepip 的系统 python 建不了 venv,
239
+ # Debian/Ubuntu 未装 python3.x-venv 的典型场景)——不合格则装 standalone 基座
240
+ base_hit = probe_python(machine, base_cands, mux, require_ensurepip=True)
217
241
  if base_hit:
218
242
  base_py = base_hit[0]
219
243
  else:
244
+ print(f"[setup] [{machine.name}] no venv-capable python 3.12 base found; "
245
+ f"installing standalone python ...", file=sys.stderr)
220
246
  base_py = _install_standalone(machine, mux, timeout)
221
247
  result.installed_standalone = True
222
248
  result.created_venv = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rrun-cli
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Run local scripts on remote machines over SSH via stdin pipes — no escaping/encoding hell. Supports python/powershell/bash, with a unified remote Python 3.12 venv provisioner.
5
5
  Author: waqiju
6
6
  License-Expression: MIT
@@ -21,6 +21,9 @@ Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
27
  Dynamic: license-file
25
28
 
26
29
  # rrun
@@ -100,6 +103,7 @@ The full set of hard-won conventions and internals: [docs/remote-exec-convention
100
103
  | `rrun config` | Diagnose the machines.json source chain |
101
104
  | `rrun setup <host\|--all> [--force]` | Provision the unified remote Python 3.12 venv (idempotent) |
102
105
  | `rrun pip <host> -- list` | Run pip inside the remote unified venv |
106
+ | `rrun doctor <host\|--all>` | Health-check ssh + auth + remote python (`--all` opens real connections to every machine) |
103
107
  | `rrun close [<host>\|--all]` | Close ssh ControlMaster multiplexed connections |
104
108
 
105
109
  Useful `exec` flags: `--lang bash|powershell|python`, `--workdir`, `--env K=V`, `--timeout`, `--python <path>` (skip detection), `--no-mux`, `-q`.
@@ -121,11 +125,15 @@ Credentials live in local `machines.json` files — see [machines.template.json]
121
125
  "defaults": { "windows": { "os": "Windows" } },
122
126
  "machines": [
123
127
  { "name": "my-win-box", "ip": "192.168.1.10", "os": "Windows",
124
- "user": "admin", "password": "secret" }
128
+ "user": "admin", "password": "secret" },
129
+ { "name": "cloud-vm", "ip": "1.2.3.4", "port": 2222, "os": "Linux",
130
+ "user": "root", "identity_file": "~/.ssh/id_ed25519" }
125
131
  ]
126
132
  }
127
133
  ```
128
134
 
135
+ Per-machine fields: `name`/`ip`/`user` are required; `password` (plaintext, via sshpass) or leave it empty for **key-based auth** (`identity_file` optional — the default ssh key chain / agent / `~/.ssh/config` applies, with `BatchMode=yes` so a missing key fails fast instead of prompting); `port` (default `22`); `os` (`Windows` / `Mac` / `Linux`); optional `hostname` (also resolvable), `description`, `python` (explicit remote interpreter, skips auto-detection).
136
+
129
137
  Sources are merged by machine name, highest priority first (all optional, failures skipped silently):
130
138
 
131
139
  1. `$RRUN_CONFIG` (os.pathsep-separated, multiple files allowed)
@@ -145,12 +153,12 @@ For `python` scripts, rrun requires a **3.12.x** interpreter on the remote, prob
145
153
  2. Standalone base: `...\python312\python.exe` / `~/.remote-machine/python312/bin/python3`
146
154
  3. Existing installs: `C:\Python\Python312\python.exe` / `python3.12` on PATH
147
155
 
148
- If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks Python 3.12, a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
156
+ If none match, run `rrun setup <host>`. It is idempotent and non-destructive: if the machine lacks a **venv-capable** Python 3.12 — none installed, or the interpreter cannot create venvs (Debian/Ubuntu split `ensurepip` into the separate `python3.12-venv` package) — a [python-build-standalone](https://github.com/astral-sh/python-build-standalone) tarball (Windows/macOS/Linux) is downloaded to the local cache (`~/.cache/rrun/`) and streamed over ssh stdin — no registry, no PATH changes, no admin rights, and the remote never touches GitHub. A half-created venv left by an interrupted earlier setup (python runs but pip is missing) is detected and recreated automatically. `rrun doctor <host>` reports all of these states before you provision. The venv gets its own `pip.ini`/`pip.conf` (global pip config untouched) plus the standard packages from the bundled `remote-requirements.txt` (override with `~/.rrun/remote-requirements.txt`).
149
157
 
150
158
  ## Security
151
159
 
152
- - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it.
153
- - rrun authenticates with passwords via `sshpass`; **key-based authentication is not supported yet** (on the roadmap).
160
+ - `machines.json` stores **plaintext passwords**. Keep it local, `chmod 600`, never commit it — or leave `password` empty and use key-based auth instead.
161
+ - Key-based auth runs ssh with `BatchMode=yes` (no interactive prompts; a missing/unauthorized key fails fast instead of eating the script from stdin).
154
162
  - The audit log never records passwords, and `--env` values are logged as keys only.
155
163
 
156
164
  ## Auditing & state
@@ -168,7 +176,7 @@ pipx uninstall rrun-cli
168
176
 
169
177
  ## Contributing
170
178
 
171
- Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e .` → hack → smoke-test with `rrun machines`. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
179
+ Issues and PRs are welcome. Development setup: clone → `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"` → hack → `pytest` + `ruff check .` → smoke-test with `rrun machines`. CI runs unit tests, ruff, and an end-to-end suite against a real sshd container. Releases are cut by pushing a `vX.Y.Z` tag; CI builds and publishes to PyPI via trusted publishing. See [CHANGELOG.md](CHANGELOG.md).
172
180
 
173
181
  ## License
174
182
 
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/rrun/__init__.py
5
5
  src/rrun/__main__.py
6
+ src/rrun/doctor.py
6
7
  src/rrun/executor.py
7
8
  src/rrun/registry.py
8
9
  src/rrun/remote-requirements.txt
@@ -11,4 +12,9 @@ src/rrun_cli.egg-info/PKG-INFO
11
12
  src/rrun_cli.egg-info/SOURCES.txt
12
13
  src/rrun_cli.egg-info/dependency_links.txt
13
14
  src/rrun_cli.egg-info/entry_points.txt
14
- src/rrun_cli.egg-info/top_level.txt
15
+ src/rrun_cli.egg-info/requires.txt
16
+ src/rrun_cli.egg-info/top_level.txt
17
+ tests/test_cli.py
18
+ tests/test_executor.py
19
+ tests/test_registry.py
20
+ tests/test_setup.py
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ ruff>=0.6
@@ -0,0 +1,29 @@
1
+ """CLI 辅助函数测试:--env 解析、inline 落盘。"""
2
+
3
+ import pytest
4
+
5
+ import rrun.__main__ as cli
6
+
7
+
8
+ class TestParseEnv:
9
+ def test_pairs(self):
10
+ assert cli._parse_env(["A=1", "B=x=y"]) == {"A": "1", "B": "x=y"}
11
+
12
+ def test_none(self):
13
+ assert cli._parse_env(None) == {}
14
+
15
+ def test_bad_pair(self):
16
+ with pytest.raises(SystemExit):
17
+ cli._parse_env(["NO_EQUALS"])
18
+
19
+
20
+ class TestDropInline:
21
+ def test_drop_by_lang(self, tmp_path, monkeypatch):
22
+ monkeypatch.setattr(cli, "INLINE_DROP_DIR", tmp_path / "drops")
23
+ p = cli._drop_inline("mac1", "python", "print('你好')\n")
24
+ assert p.suffix == ".py" and "mac1" in p.name
25
+ assert p.read_text(encoding="utf-8") == "print('你好')\n"
26
+
27
+ def test_unknown_lang_txt(self, tmp_path, monkeypatch):
28
+ monkeypatch.setattr(cli, "INLINE_DROP_DIR", tmp_path / "drops")
29
+ assert cli._drop_inline("h", "weird", "x").suffix == ".txt"
@@ -0,0 +1,156 @@
1
+ """executor 纯函数测试:PS wrapper、bash 命令、ASCII 校验、ssh 参数组装。"""
2
+
3
+ import base64
4
+
5
+ import pytest
6
+
7
+ from rrun.executor import (
8
+ _PY_BASE_CHECK_CODE,
9
+ _PY_VER_CHECK_CODE,
10
+ _bash_probe_script,
11
+ _check_ascii,
12
+ _posix_prefix,
13
+ _ps_probe_script,
14
+ _ps_quote,
15
+ _ssh_args,
16
+ build_bash_command,
17
+ build_ps_wrapper,
18
+ )
19
+ from rrun.registry import Machine
20
+
21
+
22
+ def _win(**kw):
23
+ kw.setdefault("os", "Windows")
24
+ return Machine(name="w", ip="1.1.1.1", user="u", **kw)
25
+
26
+
27
+ class TestCheckAscii:
28
+ def test_ascii_ok(self):
29
+ _check_ascii(["abc", "x=1"], "args")
30
+
31
+ @pytest.mark.parametrize("bad", ["中文", "café", "a​b"])
32
+ def test_non_ascii_rejected(self, bad):
33
+ with pytest.raises(ValueError, match="ASCII"):
34
+ _check_ascii([bad], "args")
35
+
36
+
37
+ class TestPsWrapper:
38
+ def test_pure_ascii_single_line(self):
39
+ payload = build_ps_wrapper('Write-Output "你好,世界"')
40
+ assert payload.count(b"\n") == 1
41
+ payload.decode("ascii") # 必须纯 ASCII
42
+
43
+ def test_script_content_roundtrips_via_base64(self):
44
+ code = 'Write-Output "你好 $HOME `n"'
45
+ line = build_ps_wrapper(code).decode("ascii")
46
+ b64 = line.split("FromBase64String('")[1].split("'")[0]
47
+ assert base64.b64decode(b64).decode("utf-8") == code
48
+
49
+ def test_args_workdir_env(self):
50
+ line = build_ps_wrapper("x", args=["a", "b'c"], workdir="C:\\work",
51
+ env={"K": "v"}).decode("ascii")
52
+ assert "Set-Location 'C:\\work'" in line
53
+ assert "$env:K='v'" in line
54
+ assert "'b''c'" in line # PS 单引号转义
55
+ assert "$__args=@('a','b''c')" in line
56
+
57
+ def test_non_ascii_args_rejected(self):
58
+ with pytest.raises(ValueError, match="ASCII"):
59
+ build_ps_wrapper("x", args=["中文"])
60
+
61
+ def test_exit_code_passthrough_tail(self):
62
+ line = build_ps_wrapper("x").decode("ascii")
63
+ assert "exit $LASTEXITCODE" in line
64
+ assert "ScriptBlock" in line
65
+
66
+
67
+ class TestBashCommand:
68
+ def test_plain(self):
69
+ assert build_bash_command() == "bash -s"
70
+
71
+ def test_args_workdir_env(self):
72
+ cmd = build_bash_command(args=["a b", "c"], workdir="/tmp/x y", env={"K": "v v"})
73
+ assert cmd.startswith("cd '/tmp/x y' && env K='v v' bash -s -- ")
74
+ assert "'a b'" in cmd and cmd.endswith(" c")
75
+
76
+ def test_non_ascii_args_rejected(self):
77
+ with pytest.raises(ValueError, match="ASCII"):
78
+ build_bash_command(args=["中文"])
79
+
80
+
81
+ class TestProbeScripts:
82
+ def test_bash_probe_default_is_version_only(self):
83
+ s = _bash_probe_script(["python3.12"])
84
+ assert "sys.version_info" in s
85
+ assert "ensurepip" not in s # exec 热路径不做基座资格加码
86
+
87
+ def test_bash_probe_require_ensurepip(self):
88
+ s = _bash_probe_script(["python3.12", "/usr/local/bin/python3.12"], require_ensurepip=True)
89
+ assert "import ensurepip" in s
90
+
91
+ def test_ps_probe_require_ensurepip(self):
92
+ s = _ps_probe_script([r"C:\Python\Python312\python.exe"], require_ensurepip=True)
93
+ assert "import ensurepip" in s
94
+
95
+ def test_ps_probe_default_is_version_only(self):
96
+ assert "ensurepip" not in _ps_probe_script([r"C:\py\python.exe"])
97
+
98
+ def test_check_codes_contain_no_quotes(self):
99
+ # bash 探测用单引号包裹检查代码,代码内出现引号会破坏组装
100
+ for code in (_PY_VER_CHECK_CODE, _PY_BASE_CHECK_CODE):
101
+ assert "'" not in code and '"' not in code
102
+
103
+
104
+ class TestPosixPrefix:
105
+ def test_empty(self):
106
+ assert _posix_prefix() == ""
107
+
108
+ def test_quoting(self):
109
+ assert _posix_prefix(workdir="a b") == "cd 'a b' && "
110
+ assert _posix_prefix(env={"K": "v v"}) == "env K='v v' "
111
+
112
+
113
+ class TestPsQuote:
114
+ def test_escape(self):
115
+ assert _ps_quote("a'b") == "'a''b'"
116
+
117
+
118
+ class TestSshArgs:
119
+ def test_password_uses_sshpass(self, monkeypatch):
120
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass" if name == "sshpass" else None)
121
+ args = _ssh_args(_win(password="pw"), mux=False)
122
+ assert args[:3] == ["/usr/bin/sshpass", "-p", "pw"]
123
+ assert "NumberOfPasswordPrompts=1" in args
124
+ assert "BatchMode=yes" not in args
125
+
126
+ def test_password_without_sshpass_raises(self, monkeypatch):
127
+ monkeypatch.setattr("shutil.which", lambda name: None)
128
+ with pytest.raises(RuntimeError, match="sshpass not found"):
129
+ _ssh_args(_win(password="pw"), mux=False)
130
+
131
+ def test_key_auth_needs_no_sshpass(self, monkeypatch):
132
+ monkeypatch.setattr("shutil.which", lambda name: None) # 无 sshpass 也能走 key
133
+ args = _ssh_args(_win(password="", identity_file="~/.ssh/id_ed25519"), mux=False)
134
+ assert args[0] == "ssh"
135
+ assert "BatchMode=yes" in args
136
+ i = args.index("-i")
137
+ assert args[i + 1].endswith(".ssh/id_ed25519")
138
+
139
+ def test_custom_port(self, monkeypatch):
140
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
141
+ args = _ssh_args(_win(password="pw", port=2222), mux=False)
142
+ i = args.index("-p", 2) # 跳过 sshpass -p
143
+ assert args[i + 1] == "2222"
144
+
145
+ def test_default_port_no_flag(self, monkeypatch):
146
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
147
+ args = _ssh_args(_win(password="pw"), mux=False)
148
+ assert "-p" not in args[3:] # sshpass 的 -p 之后不再有 -p
149
+
150
+ def test_mux_adds_control_opts(self, monkeypatch, tmp_path):
151
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/sshpass")
152
+ import rrun.executor as ex
153
+ monkeypatch.setattr(ex, "CONTROL_PATH_DIR", tmp_path / ".ssh")
154
+ args = _ssh_args(_win(password="pw"), mux=True)
155
+ assert "ControlMaster=auto" in " ".join(args)
156
+ assert (tmp_path / ".ssh").is_dir()
@@ -0,0 +1,131 @@
1
+ """registry 纯函数测试:来源链、merge 优先级、defaults、端口/key 字段。"""
2
+
3
+ import json
4
+ import os
5
+
6
+ import pytest
7
+
8
+ from rrun import registry
9
+ from rrun.registry import Machine, _load_file, load_machines, resolve_machine
10
+
11
+
12
+ def _write(path, machines, defaults=None):
13
+ data = {"machines": machines}
14
+ if defaults:
15
+ data["defaults"] = defaults
16
+ path.write_text(json.dumps(data), encoding="utf-8")
17
+ return path
18
+
19
+
20
+ @pytest.fixture()
21
+ def isolated(monkeypatch, tmp_path):
22
+ """隔离全部来源:HOME 指向临时目录、cwd 切换、两个 env var 清空。"""
23
+ monkeypatch.setenv("RRUN_CONFIG", "")
24
+ monkeypatch.setenv("REMOTE_MACHINE_CONFIG", "")
25
+ home = tmp_path / "home"
26
+ (home / ".rrun").mkdir(parents=True)
27
+ monkeypatch.setenv("HOME", str(home)) # POSIX 下 Path.home() 读 HOME
28
+ cwd = tmp_path / "cwd"
29
+ cwd.mkdir()
30
+ monkeypatch.chdir(cwd)
31
+ return tmp_path
32
+
33
+
34
+ class TestMachine:
35
+ def test_defaults(self):
36
+ m = Machine(name="a", ip="1.1.1.1", user="u")
37
+ assert m.port == 22 and m.identity_file == "" and m.password == ""
38
+ assert m.os == "Windows" and m.is_windows and m.default_lang == "powershell"
39
+
40
+ def test_public_dict_redacts_password(self):
41
+ m = Machine(name="a", ip="1.1.1.1", user="u", password="secret")
42
+ d = m.public_dict()
43
+ assert "secret" not in json.dumps(d)
44
+ assert d["auth"] == "password"
45
+ m2 = Machine(name="b", ip="2.2.2.2", user="u")
46
+ assert m2.public_dict()["auth"] == "key"
47
+
48
+ def test_password_not_in_repr(self):
49
+ assert "secret" not in repr(Machine(name="a", ip="1.1.1.1", user="u", password="secret"))
50
+
51
+
52
+ class TestLoadFile:
53
+ def test_defaults_applied_per_os(self, tmp_path):
54
+ p = _write(
55
+ tmp_path / "m.json",
56
+ [{"name": "w1", "ip": "1.1.1.1", "os": "Windows"},
57
+ {"name": "m1", "ip": "2.2.2.2", "os": "Mac", "user": "macuser"}],
58
+ defaults={"windows": {"user": "winuser", "password": "wpw"},
59
+ "mac": {"password": "mpw"}},
60
+ )
61
+ machines = {m.name: m for m in _load_file(p)}
62
+ assert machines["w1"].user == "winuser" and machines["w1"].password == "wpw"
63
+ assert machines["m1"].user == "macuser" and machines["m1"].password == "mpw"
64
+
65
+ def test_port_and_identity_file(self, tmp_path):
66
+ p = _write(tmp_path / "m.json",
67
+ [{"name": "a", "ip": "1.1.1.1", "user": "u", "port": 2222,
68
+ "identity_file": "~/.ssh/id_ed25519"}])
69
+ (m,) = _load_file(p)
70
+ assert m.port == 2222 and m.identity_file == "~/.ssh/id_ed25519"
71
+
72
+ def test_port_string_cast_to_int(self, tmp_path):
73
+ p = _write(tmp_path / "m.json", [{"name": "a", "ip": "1.1.1.1", "user": "u", "port": "2222"}])
74
+ assert _load_file(p)[0].port == 2222
75
+
76
+ def test_unknown_keys_ignored(self, tmp_path):
77
+ p = _write(tmp_path / "m.json", [{"name": "a", "ip": "1.1.1.1", "user": "u", "zzz": 1}])
78
+ assert _load_file(p)[0].name == "a"
79
+
80
+
81
+ class TestSourceChain:
82
+ def test_cwd_beats_user(self, isolated):
83
+ _write(isolated / "cwd" / "machines.json",
84
+ [{"name": "a", "ip": "1.1.1.1", "user": "cwd_user"}])
85
+ _write(isolated / "home" / ".rrun" / "machines.json",
86
+ [{"name": "a", "ip": "1.1.1.1", "user": "home_user"}])
87
+ (m,) = load_machines()
88
+ assert m.user == "cwd_user"
89
+
90
+ def test_env_beats_cwd_and_multi_path(self, isolated, monkeypatch):
91
+ f1 = _write(isolated / "env1.json", [{"name": "a", "ip": "1.1.1.1", "user": "env_user"}])
92
+ f2 = _write(isolated / "env2.json", [{"name": "b", "ip": "2.2.2.2", "user": "u2"}])
93
+ _write(isolated / "cwd" / "machines.json", [{"name": "a", "ip": "1.1.1.1", "user": "cwd"}])
94
+ monkeypatch.setenv("RRUN_CONFIG", os.pathsep.join([str(f1), str(f2)]))
95
+ machines = {m.name: m for m in load_machines()}
96
+ assert machines["a"].user == "env_user" and "b" in machines
97
+
98
+ def test_machines_d_sorted_and_below_user(self, isolated):
99
+ rrun_home = isolated / "home" / ".rrun"
100
+ (rrun_home / "machines.d").mkdir(parents=True)
101
+ _write(rrun_home / "machines.json", [{"name": "a", "ip": "1.1.1.1", "user": "user_main"}])
102
+ _write(rrun_home / "machines.d" / "10-x.json",
103
+ [{"name": "a", "ip": "1.1.1.1", "user": "from_d"}, {"name": "b", "ip": "2.2.2.2", "user": "u"}])
104
+ machines = {m.name: m for m in load_machines()}
105
+ assert machines["a"].user == "user_main" and machines["b"].user == "u"
106
+
107
+ def test_broken_source_skipped(self, isolated):
108
+ (isolated / "cwd" / "machines.json").write_text("{not json", encoding="utf-8")
109
+ _write(isolated / "home" / ".rrun" / "machines.json",
110
+ [{"name": "a", "ip": "1.1.1.1", "user": "u"}])
111
+ assert [m.name for m in load_machines()] == ["a"]
112
+ errors = [i for i in registry.scan_sources() if i.error]
113
+ assert len(errors) == 1
114
+
115
+ def test_explicit_path_single_file_mode(self, isolated, tmp_path):
116
+ _write(isolated / "cwd" / "machines.json", [{"name": "cwd", "ip": "1.1.1.1", "user": "u"}])
117
+ p = _write(tmp_path / "pinned.json", [{"name": "pinned", "ip": "2.2.2.2", "user": "u"}])
118
+ assert [m.name for m in load_machines(p)] == ["pinned"]
119
+
120
+
121
+ class TestResolve:
122
+ def test_by_name_ip_hostname(self, tmp_path):
123
+ p = _write(tmp_path / "m.json",
124
+ [{"name": "a", "ip": "1.1.1.1", "user": "u", "hostname": "host-a"}])
125
+ for key in ("a", "1.1.1.1", "host-a"):
126
+ assert resolve_machine(key, p).name == "a"
127
+
128
+ def test_not_found_lists_available(self, tmp_path):
129
+ p = _write(tmp_path / "m.json", [{"name": "a", "ip": "1.1.1.1", "user": "u"}])
130
+ with pytest.raises(KeyError, match="a\\(1.1.1.1\\)"):
131
+ resolve_machine("nope", p)
@@ -0,0 +1,79 @@
1
+ """setup 纯函数测试:依赖解析、装机脚本生成。"""
2
+
3
+ import rrun.setup as st
4
+ from rrun.setup import _build_bash_setup, _build_ps_setup, _req_import_name, load_requirements
5
+
6
+
7
+ class TestReqImportName:
8
+ def test_plain(self):
9
+ assert _req_import_name("requests") == "requests"
10
+
11
+ def test_version_specs(self):
12
+ assert _req_import_name("requests>=2.31") == "requests"
13
+ assert _req_import_name("urllib3<2") == "urllib3"
14
+ assert _req_import_name("pip==24.0") == "pip"
15
+
16
+ def test_extras(self):
17
+ assert _req_import_name("requests[socks]>=2") == "requests"
18
+
19
+
20
+ class TestLoadRequirements:
21
+ def test_builtin_default(self, tmp_path, monkeypatch):
22
+ monkeypatch.setattr(st, "REQUIREMENTS_OVERRIDE", tmp_path / "nope.txt")
23
+ reqs = load_requirements()
24
+ assert reqs and all(not r.startswith("#") for r in reqs)
25
+
26
+ def test_user_override_wins(self, tmp_path, monkeypatch):
27
+ p = tmp_path / "reqs.txt"
28
+ p.write_text("# comment\nfoo>=1\n\nbar\n", encoding="utf-8")
29
+ monkeypatch.setattr(st, "REQUIREMENTS_OVERRIDE", p)
30
+ assert load_requirements() == ["foo>=1", "bar"]
31
+
32
+
33
+ class TestPbsAssets:
34
+ def test_linux_assets_present(self):
35
+ assert ("Linux", "x86_64") in st.PBS_ASSETS
36
+ assert ("Linux", "aarch64") in st.PBS_ASSETS
37
+
38
+ def test_pbs_platform_mapping(self):
39
+ assert st._pbs_platform("Linux x86_64") == ("Linux", "x86_64")
40
+ assert st._pbs_platform("Linux aarch64") == ("Linux", "aarch64")
41
+ assert st._pbs_platform("Darwin arm64") == ("Mac", "arm64")
42
+ assert st._pbs_platform("Darwin x86_64") == ("Mac", "x86_64")
43
+
44
+ def test_pbs_platform_edge(self):
45
+ assert st._pbs_platform("") == ("", "")
46
+ assert st._pbs_platform("FreeBSD") == ("FreeBSD", "")
47
+
48
+ def test_all_mapped_posix_platforms_have_assets(self):
49
+ for uname in ("Linux x86_64", "Linux aarch64", "Darwin arm64", "Darwin x86_64"):
50
+ assert st._pbs_platform(uname) in st.PBS_ASSETS
51
+
52
+
53
+ class TestSetupScripts:
54
+ def test_bash_setup(self):
55
+ s = _build_bash_setup("/usr/bin/python3.12", ["requests>=2", "foo"], force=False)
56
+ assert '"/usr/bin/python3.12" -m venv "$venv"' in s
57
+ assert "pip install" in s and "requests>=2 foo" in s
58
+ assert "import requests;import foo" in s
59
+ assert "[ \"0\" = \"1\" ]" in s # force off
60
+
61
+ def test_bash_setup_force(self):
62
+ assert "[ \"1\" = \"1\" ]" in _build_bash_setup("p", ["requests"], force=True)
63
+
64
+ def test_bash_setup_heals_half_broken_venv(self):
65
+ # 上次 venv 创建中途失败(ensurepip 缺失等)留下的半拉子 venv:检测无 pip 并重建
66
+ s = _build_bash_setup("/usr/bin/python3.12", ["requests"], force=False)
67
+ assert '"$venv/bin/python" -m pip --version' in s
68
+ assert 'rm -rf "$venv"' in s
69
+
70
+ def test_ps_setup_ascii_and_content(self):
71
+ s = _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
72
+ assert "$true" not in s and "$false" in s
73
+ assert "pip.ini" in s and "requests" in s and "import requests" in s
74
+
75
+ def test_ps_setup_force(self):
76
+ assert "if($true" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=True)
77
+
78
+ def test_ps_setup_heals_half_broken_venv(self):
79
+ assert "-m pip --version" in _build_ps_setup("C:\\py\\python.exe", ["requests"], force=False)
File without changes
File without changes