用 Python shutil.which 排查命令找不到:PATH 顺序与自动化预检
Python 调用外部命令时报 FileNotFoundError,终端里却能运行同名工具;或者 Hugo 自动化突然用了旧版本。先确认程序究竟会从哪个目录选中可执行文件,再检查工具本身,比反复重装更直接。
本文面向初级到中级读者,用标准库写一个只读命令预检器:批量查找工具、展示 PATH 顺序的影响,并用退出码阻止缺少依赖的任务启动。示例只在新建目录里放置测试文件,不修改系统 PATH、调度器或服务器配置。
1. 方法:先查找,不执行
Python 官方 shutil.which 文档说明,它按搜索路径查找符合访问条件的可执行文件,找不到时返回 None。默认检查存在与执行权限;这不等于实际运行测试。这里显式传入 path=search_path,否则 --path 就只会变成一个没有作用的参数。
本文为 Linux 后台任务采用更严格的输入规则:
- 只接收
hugo这样的命令名,不接收./hugo、完整路径或一整行 shell 命令。 - PATH 的每一项都必须是非空绝对路径;拒绝空项和相对目录,避免查找依赖当前工作目录。目录名可以有空格。
- 进程没有 PATH 时明确报错,要求传入
--path,不偷偷采用默认搜索路径。--path只控制本次检查,不会修改父 shell 或未来子进程的环境。 - 结果保留选中路径的符号链接写法,不用
resolve()追到目标;例如,不应把虚拟环境解释器路径随手替换成系统解释器路径。解释器与环境的关系见 venv 官方说明。
这是一套教程策略,不是所有 shell 的通用规则。关于任务入口的解释器、工作目录和环境设置,参见 Linux 定时任务 PATH 与工作目录排查。
2. 准备:创建两个同名测试工具
使用 Linux、Python 3.9 或更新版本,以及 bash/zsh;不需要第三方依赖。示例在 Linux、Python 3.11.4 上测试。
- 在同一个终端运行以下命令,并留在演示目录。重复实验时换一个新目录名,避免覆盖旧文件:
python3 --version
mkdir command-path-demo
cd command-path-demo
python3 - <<'PY_SETUP'
from pathlib import Path
for directory, label in [("first bin", "FIRST"), ("second bin", "SECOND")]:
folder = Path(directory)
folder.mkdir()
tool = folder / "demo-tool"
tool.write_text(f"#!/bin/sh\nprintf '{label}\\n'\n", encoding="utf-8")
tool.chmod(0o755)
Path("empty bin").mkdir()
PY_SETUP
两个文件都是带执行位的简单 shell 脚本。预检器只查找它们,不运行脚本,所以不应看到 FIRST 或 SECOND。
- 将下一节完整代码保存为当前目录的
check_commands.py。
3. 实现:显式 PATH 与可用于自动化的退出码
"""Inspect bare command names on an explicit Linux PATH without running them."""
import argparse
import json
import os
import shutil
import sys
def inspect_commands(names, search_path):
if search_path is None:
raise ValueError("PATH is unset; pass --path explicitly")
entries = search_path.split(os.pathsep)
if any(not entry or not os.path.isabs(entry) for entry in entries):
raise ValueError("PATH entries must be nonempty absolute paths")
if "\0" in search_path:
raise ValueError("PATH must not contain NUL")
if any(not name or "/" in name or "\0" in name for name in names):
raise ValueError("use bare command names, not file paths")
results = []
for name in names:
hit = shutil.which(name, path=search_path)
results.append({"command": name, "path": hit})
return {"results": results, "ok": all(row["path"] is not None for row in results)}
def main():
cli = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
cli.add_argument("--path", default=os.environ.get("PATH"),
help="PATH to inspect; defaults to the process PATH")
cli.add_argument("commands", nargs="+", help="bare executable names")
args = cli.parse_args()
try:
report = inspect_commands(args.commands, args.path)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print(json.dumps(report))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
results 保留每个命令及其选中路径;缺少工具时对应 path 为 JSON null。找不到一个命令也会继续检查其余命令,最后统一决定退出状态。路径目录不存在时只是没有匹配项,不会自动创建或安装任何东西。
| 退出码 | 含义 | 下一步 |
|---|---|---|
| 0 | 每个名称都找到可执行文件 | 核对路径,再检查版本和真实执行结果 |
| 1 | 至少一个名称未找到 | 修正依赖或搜索路径,暂不运行任务 |
| 2 | 输入或参数错误 | 修正 PATH、命令名或调用方式 |
4. 用法:交换 PATH 顺序,比较选中的文件
python3 check_commands.py --help
python3 check_commands.py --path "$PWD/first bin:$PWD/second bin" demo-tool
python3 check_commands.py --path "$PWD/second bin:$PWD/first bin" demo-tool
两次 JSON 的 ok 都是 true,但第一次的 path 以 /first bin/demo-tool 结尾,第二次以 /second bin/demo-tool 结尾。输出路径的前半部分取决于你的演示目录。
如果检查脚本能启动,目标命令仍找不到,重点看运行该 Python 进程时的 PATH,而不是另一终端的设置。shell 的别名、函数和内建命令不在这个检查器的查找范围内。
5. 验证:成功、缺失与输入错误
将以下代码保存为 test_lookup.py,运行 python3 test_lookup.py。测试使用固定的小型文件,不需要安装 Hugo,也不需要执行测试工具。
from pathlib import Path
import json
import os
import subprocess
import sys
root = Path.cwd()
first = str(root / "first bin")
second = str(root / "second bin")
empty = str(root / "empty bin")
script = str(root / "check_commands.py")
before = {p: p.read_bytes() for p in root.glob("* bin/demo-tool")}
def check(arguments, expected, env=None):
result = subprocess.run(
[sys.executable, script, *arguments],
capture_output=True, text=True, env=env, check=False,
)
assert result.returncode == expected, result
if expected == 2:
assert result.stdout == "" and "ERROR:" in result.stderr, result
return None
assert result.stderr == "", result.stderr
return json.loads(result.stdout)
for path, directory in [(first + ":" + second, first), (second + ":" + first, second)]:
data = check(["--path", path, "demo-tool"], 0)
assert data["results"][0]["path"] == directory + "/demo-tool"
assert data["ok"] is True
print("PASS: PATH order selects the first executable")
data = check(["--path", empty, "demo-tool"], 1)
assert data == {"results": [{"command": "demo-tool", "path": None}], "ok": False}
print("PASS: missing executable returns 1")
for path in ["", ".", first + ":", first + ":relative"]:
check(["--path", path, "demo-tool"], 2)
check(["--path", first, "./demo-tool"], 2)
env = os.environ.copy()
env.pop("PATH", None)
check(["demo-tool"], 2, env=env)
print("PASS: ambiguous PATH and non-bare names rejected")
assert all(p.read_bytes() == content for p, content in before.items())
print("PASS: fixture content unchanged")
预期输出:
PASS: PATH order selects the first executable
PASS: missing executable returns 1
PASS: ambiguous PATH and non-bare names rejected
PASS: fixture content unchanged
检查器自身的 CLI 参数错误也返回 2。上面的测试验证了运行时 PATH 缺失、空项、相对目录和文件内容保持不变;它不测实际调度器的权限与挂载条件。
6. 接到自动化入口前,保留查找与执行的边界
下面用演示工具验证入口分支,不会执行该工具:
if python3 check_commands.py --path "$PWD/first bin" demo-tool; then
printf 'Preflight passed; now validate the real tool version and run the job.\n'
else
status=$?
printf 'Stopped before the job; preflight exit=%s\n' "$status" >&2
exit "$status"
fi
真实任务可以检查 python3 check_commands.py python3 hugo,读取当前进程 PATH 下的结果,再核对工具版本。应在实际任务账号与环境下运行,而不是只用管理员终端验证。
Python 官方 subprocess 文档建议使用完整可执行路径来提高可靠性。若要运行工具,把预检得到并确认可信的路径作为参数列表的第一个元素;不要把整个命令拼成字符串再开启 shell=True。后续的超时、错误与日志处理可按 Python subprocess 超时与日志教程实现。
找到文件不保证能启动:解释器 shebang 失效、文件格式不符、noexec 挂载、运行库缺失,以及检查后文件被替换,都可能让执行失败。不要把它当成安全沙箱或无竞争条件的保证;只在可信目录中使用,仍然检查实际子进程的退出码。
7. 排错与总结
| 现象 | 直接处理 |
|---|---|
path 是 null |
对照该账号的 PATH 与工具安装位置;自己的脚本缺少执行位时才按需执行 chmod u+x 文件名。 |
| 选中了旧版本 | 调整任务入口里可信目录的顺序,然后重新检查并运行工具自己的版本命令。 |
PATH entries must be nonempty absolute paths |
删除首尾冒号与连续冒号产生的空项,把 . 或相对目录改成明确的绝对目录。 |
| shell 能运行,检查器找不到 | 确认它不是别名、函数或内建命令,并比较两个进程的环境。 |
找到了 Python,import 却出错 |
继续检查解释器与模块来源,参见 Python 同名模块遮蔽排查。 |
先显示所选文件路径,再决定是否启动任务,可以把缺少命令与选错版本分开排查。用固定夹具验证 PATH 顺序与失败退出码后,再把预检接到可信的任务入口;实际执行仍需自己的错误处理。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-shutil-which-path-preflight.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。