春江暮客

春江暮客的个人学习分享网站

用 Python shutil.which 排查命令找不到:PATH 顺序与自动化预检

2026-10-31 技术
用 Python shutil.which 排查命令找不到:PATH 顺序与自动化预检

Python 调用外部命令时报 FileNotFoundError,终端里却能运行同名工具;或者 Hugo 自动化突然用了旧版本。先确认程序究竟会从哪个目录选中可执行文件,再检查工具本身,比反复重装更直接。

本文面向初级到中级读者,用标准库写一个只读命令预检器:批量查找工具、展示 PATH 顺序的影响,并用退出码阻止缺少依赖的任务启动。示例只在新建目录里放置测试文件,不修改系统 PATH、调度器或服务器配置。

1. 方法:先查找,不执行

Python 官方 shutil.which 文档说明,它按搜索路径查找符合访问条件的可执行文件,找不到时返回 None。默认检查存在与执行权限;这不等于实际运行测试。这里显式传入 path=search_path,否则 --path 就只会变成一个没有作用的参数。

本文为 Linux 后台任务采用更严格的输入规则:

  1. 只接收 hugo 这样的命令名,不接收 ./hugo、完整路径或一整行 shell 命令。
  2. PATH 的每一项都必须是非空绝对路径;拒绝空项和相对目录,避免查找依赖当前工作目录。目录名可以有空格。
  3. 进程没有 PATH 时明确报错,要求传入 --path,不偷偷采用默认搜索路径。--path 只控制本次检查,不会修改父 shell 或未来子进程的环境。
  4. 结果保留选中路径的符号链接写法,不用 resolve() 追到目标;例如,不应把虚拟环境解释器路径随手替换成系统解释器路径。解释器与环境的关系见 venv 官方说明。

这是一套教程策略,不是所有 shell 的通用规则。关于任务入口的解释器、工作目录和环境设置,参见 Linux 定时任务 PATH 与工作目录排查。

2. 准备:创建两个同名测试工具

使用 Linux、Python 3.9 或更新版本,以及 bash/zsh;不需要第三方依赖。示例在 Linux、Python 3.11.4 上测试。

  1. 在同一个终端运行以下命令,并留在演示目录。重复实验时换一个新目录名,避免覆盖旧文件:
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。

  1. 将下一节完整代码保存为当前目录的 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 顺序与失败退出码后,再把预检接到可信的任务入口;实际执行仍需自己的错误处理。

标签

1024 12306 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它