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
CI / Lint, Typecheck & Test (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# 迭代 16:ops 工具完善
|
||||
|
||||
## 本轮目标
|
||||
|
||||
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%
|
||||
|
||||
## 遗留事项
|
||||
|
||||
无。本轮目标全部达成。
|
||||
@@ -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})")
|
||||
|
||||
@@ -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 中")
|
||||
|
||||
@@ -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 中")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 可能返回多行, 取第一个
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user