fix(ops): 修复 dockercmd/msdownload/sglang 的 check=False 静默失败问题,改用 check=True + try/except 打印 rich 错误信息;taskkill 检查 returncode 打印结果;clr/which 加注释说明 check=False 原因;补 9 个错误路径测试覆盖 CalledProcessError/FileNotFoundError。
CI / Lint, Typecheck & Test (push) Has been cancelled

This commit is contained in:
2026-07-07 14:44:48 +08:00
parent 02ef858718
commit 5831c92f97
10 changed files with 209 additions and 7 deletions
+54
View File
@@ -0,0 +1,54 @@
# 迭代 16ops 工具完善
## 本轮目标
1. **修复 silent failures** —— dockercmd/msdownload/sglang 使用 `check=False` 导致失败被吞,改为 `check=True` + try/except 打印 rich 错误
2. **改善 taskkill 容错路径** —— taskkill 在 pkill 无匹配时打印提示而非静默;clr 保持容错但加注释说明原因
3. **补错误路径测试** —— 为新增 try/except 块补 CalledProcessError / FileNotFoundError 路径测试
## 改动文件清单
### 源码改动
- `src/pyflowx/ops/infra/dockercmd.py` —— `check=True` + try/except CalledProcessError/FileNotFoundError
- `src/pyflowx/ops/infra/msdownload.py` —— `check=True` + try/except CalledProcessError/FileNotFoundError
- `src/pyflowx/ops/infra/sglang.py` —— install/run 改 `check=True` + try/except
- `src/pyflowx/ops/system/taskkill.py` —— 保留 `check=False`(pkill 返回 1 表示无匹配,非错误),但检查 returncode 打印成功/失败
- `src/pyflowx/ops/system/clr.py` —— 注释说明 check=False 原因(清屏失败不影响后续工作)
- `src/pyflowx/ops/system/which.py` —— 注释说明 check=False 原因(where/which 返回非零表示未找到命令)
### 测试改动
- `tests/cli/test_envdev.py` —— TestDockerLoginTencent 新增 3 个错误路径测试(success/CalledProcessError/FileNotFoundError
- `tests/cli/test_llm.py` —— TestMsdownloadRun 新增 2 个(CalledProcessError/FileNotFoundError),TestInstallSglang 新增 2 个,TestRunSglang 新增 2 个
- `tests/cli/test_system_run.py` —— TestTaskkillRun 改进 test_prints_progress(断言成功路径),新增 test_returncode_nonzero_prints_failure
## 关键设计
### 1. check=False 修复策略
按场景区分:
- **失败需感知**dockercmd/msdownload/sglang)→ `check=True` + `try/except subprocess.CalledProcessError` 打印 rich 错误
- **失败可容忍但需检查**(taskkill)→ 保持 `check=False`,但检查 `returncode` 打印结果
- **失败无关紧要**clr/which)→ 保持 `check=False`,加注释说明原因
### 2. 错误路径测试策略
测试发现现有 6 个工具的测试已存在(在 test_system_run.py / test_llm.py / test_envdev.py 中),但仅覆盖成功路径。本轮重点补充:
- **CalledProcessError 路径**subprocess.run 抛 CalledProcessError 时,函数应捕获并打印包含 returncode 的错误信息,不向上抛出
- **FileNotFoundError 路径**:命令本身不存在(如 docker/uvx/uv/python 未安装)时,函数应打印"命令未找到"提示,不向上抛出
- **taskkill returncode 非零路径**pkill 返回 1 表示无匹配进程,应打印"未找到匹配进程或终止失败"
mock 模式:`monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(err))` —— 用生成器抛异常的技巧让 lambda 能抛出指定异常。
## 验证结果
- **ruff check**All checks passed!
- **ruff format --check**33 files already formatted
- **pyrefly check**0 errors (7 suppressed)
- **pytest**1351 passed in 9.82s
- **coverage**97.38%>= 95%),所有改动的 ops 文件(dockercmd/msdownload/sglang/taskkill/clr/which)覆盖率 100%
## 遗留事项
无。本轮目标全部达成。
+12 -2
View File
@@ -23,5 +23,15 @@ def docker_login_tencent(username: str = "") -> None:
Docker 用户名 (为空时由 docker 交互式提示输入)
"""
user = username or getpass.getuser()
subprocess.run(["docker", "login", "--username", user, _DOCKER_MIRROR_TENCENT], check=False)
print(f"已尝试登录腾讯云镜像仓库 (用户: {user})")
try:
subprocess.run(
["docker", "login", "--username", user, _DOCKER_MIRROR_TENCENT],
check=True,
)
except subprocess.CalledProcessError as e:
print(f"docker login 失败 (returncode={e.returncode}): {e}")
return
except FileNotFoundError:
print("docker 命令未找到,请确认 Docker 已安装并在 PATH 中")
return
print(f"已登录腾讯云镜像仓库 (用户: {user})")
+6 -1
View File
@@ -38,4 +38,9 @@ def msdownload_run(name: str, target_type: str = "model", download_dir: str | No
cmd = ["uvx", "modelscope", "download", f"--{target_type}", name, "--local_dir", str(out_dir)]
print(f"下载 {target_type}: {name} -> {out_dir}")
subprocess.run(cmd, check=False)
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"下载失败 (returncode={e.returncode}): {e}")
except FileNotFoundError:
print("uvx 命令未找到,请确认 uv 已安装并在 PATH 中")
+12 -2
View File
@@ -29,7 +29,12 @@ def install_sglang() -> None:
return
print("正在安装 sglang[all]...")
subprocess.run(["uv", "install", "sglang[all]"], check=False)
try:
subprocess.run(["uv", "install", "sglang[all]"], check=True)
except subprocess.CalledProcessError as e:
print(f"安装失败 (returncode={e.returncode}): {e}")
except FileNotFoundError:
print("uv 命令未找到,请确认 uv 已安装并在 PATH 中")
@px.tool("sglang", subcommand="run", help="启动 SGLang 服务", needs=["install"])
@@ -84,4 +89,9 @@ def run_sglang(
log_level,
]
print(f"启动 SGLang: {model_dir} (port={port}, ctx={ctx_len}, mem={mem_fraction})")
subprocess.run(cmd, check=False)
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"SGLang 启动失败 (returncode={e.returncode}): {e}")
except FileNotFoundError:
print(f"{python_bin} 命令未找到,请确认 Python 已安装并在 PATH 中")
+1
View File
@@ -17,4 +17,5 @@ def clear_screen_run() -> None:
Windows 调用 ``cls``, Linux/macOS 调用 ``clear``.
"""
# 清屏失败(如非 TTY 环境)不影响后续工作,故 check=False 容忍
subprocess.run(platform_command(["cls"], ["clear"]), check=False)
+6 -1
View File
@@ -27,4 +27,9 @@ def taskkill_run(process_names: list[str]) -> None:
for name in process_names:
print(f"终止进程: {name}")
subprocess.run([*cmd_prefix, f"{name}*"], check=False)
# pkill 返回 1 表示无匹配进程(非错误),故 check=False + 手动检查 returncode
result = subprocess.run([*cmd_prefix, f"{name}*"], check=False)
if result.returncode == 0:
print(f" 已发送终止信号: {name}")
else:
print(f" 未找到匹配进程或终止失败 (returncode={result.returncode}): {name}")
+1
View File
@@ -26,6 +26,7 @@ def which_run(commands: list[str]) -> None:
which_cmd = platform_command(["where"], ["which"])[0]
for cmd in commands:
# where/which 返回非零表示未找到命令(正常情况),故 check=False + 手动检查 returncode
result = subprocess.run([which_cmd, cmd], capture_output=True, text=True, check=False)
if result.returncode == 0:
# Windows 的 where 可能返回多行, 取第一个
+32
View File
@@ -160,6 +160,38 @@ class TestDockerLoginTencent:
assert "myuser" in ran_cmds[0]
def test_success_prints_logged_in(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""登录成功时应打印已登录信息."""
monkeypatch.setattr(subprocess, "run", lambda *_, **__: MagicMock(returncode=0))
docker_login_tencent("myuser")
captured = capsys.readouterr()
assert "已登录腾讯云镜像仓库" in captured.out
assert "myuser" in captured.out
def test_called_process_error_prints_failure(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""docker login 返回非零时应打印失败信息并不抛出."""
err = subprocess.CalledProcessError(returncode=1, cmd=["docker", "login"])
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(err))
docker_login_tencent("myuser")
captured = capsys.readouterr()
assert "docker login 失败" in captured.out
assert "returncode=1" in captured.out
assert "已登录" not in captured.out
def test_file_not_found_prints_no_docker(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""docker 命令不存在时应打印提示并不抛出."""
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(FileNotFoundError()))
docker_login_tencent("myuser")
captured = capsys.readouterr()
assert "docker 命令未找到" in captured.out
assert "已登录" not in captured.out
# ---------------------------------------------------------------------- #
# setup_linux_system_mirror
+70
View File
@@ -70,6 +70,28 @@ class TestMsdownloadRun:
assert "--dataset" in ran_cmds[0]
def test_called_process_error_prints_failure(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""下载命令返回非零时应打印失败信息并不抛出."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
err = subprocess.CalledProcessError(returncode=2, cmd=["uvx", "modelscope"])
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(err))
msdownload_run("foo/bar")
captured = capsys.readouterr()
assert "下载失败" in captured.out
assert "returncode=2" in captured.out
def test_file_not_found_prints_no_uvx(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""uvx 命令不存在时应打印提示并不抛出."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(FileNotFoundError()))
msdownload_run("foo/bar")
captured = capsys.readouterr()
assert "uvx 命令未找到" in captured.out
# ---------------------------------------------------------------------- #
# install_sglang
@@ -99,6 +121,28 @@ class TestInstallSglang:
assert ran_cmds == [["uv", "install", "sglang[all]"]]
def test_install_called_process_error_prints_failure(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""uv install 返回非零时应打印失败信息并不抛出."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
err = subprocess.CalledProcessError(returncode=1, cmd=["uv", "install", "sglang[all]"])
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(err))
install_sglang()
captured = capsys.readouterr()
assert "安装失败" in captured.out
assert "returncode=1" in captured.out
def test_install_file_not_found_prints_no_uv(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""uv 命令不存在时应打印提示并不抛出."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(FileNotFoundError()))
install_sglang()
captured = capsys.readouterr()
assert "uv 命令未找到" in captured.out
# ---------------------------------------------------------------------- #
# run_sglang
@@ -167,3 +211,29 @@ class TestRunSglang:
assert "--tool-call-parser" in cmd
qwen_idx = cmd.index("--tool-call-parser")
assert cmd[qwen_idx + 1] == "qwen"
def test_run_called_process_error_prints_failure(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""sglang 启动失败时应打印失败信息并不抛出."""
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
monkeypatch.setattr(Path, "exists", lambda _self: True)
err = subprocess.CalledProcessError(returncode=1, cmd=["python", "-m", "sglang.launch_server"])
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(err))
run_sglang(model=str(tmp_path))
captured = capsys.readouterr()
assert "SGLang 启动失败" in captured.out
assert "returncode=1" in captured.out
def test_run_file_not_found_prints_no_python(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""python 命令不存在时应打印提示并不抛出."""
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
monkeypatch.setattr(Path, "expanduser", lambda _self: tmp_path)
monkeypatch.setattr(Path, "exists", lambda _self: True)
monkeypatch.setattr(subprocess, "run", lambda *_, **__: (_ for _ in ()).throw(FileNotFoundError()))
run_sglang(model=str(tmp_path))
captured = capsys.readouterr()
assert "命令未找到" in captured.out
+15 -1
View File
@@ -72,10 +72,24 @@ class TestTaskkillRun:
def test_prints_progress(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
"""应打印每个进程的终止信息."""
monkeypatch.setattr(Constants, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess, "run", lambda *_, **__: MagicMock())
monkeypatch.setattr(subprocess, "run", lambda *_, **__: MagicMock(returncode=0))
taskkill_run(["chrome.exe"])
captured = capsys.readouterr()
assert "chrome.exe" in captured.out
assert "已发送终止信号" in captured.out
def test_returncode_nonzero_prints_failure(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""returncode 非零时应打印未找到/失败信息."""
monkeypatch.setattr(Constants, "IS_WINDOWS", False)
mock_result = MagicMock(returncode=1)
monkeypatch.setattr(subprocess, "run", lambda *_, **__: mock_result)
taskkill_run(["nonexistent"])
captured = capsys.readouterr()
assert "未找到匹配进程或终止失败" in captured.out
assert "returncode=1" in captured.out
assert "已发送终止信号" not in captured.out
# ---------------------------------------------------------------------- #