Python subprocess 通配符不展开:参数列表、空格文件名与输出重定向
终端里能用的 *.txt,放进 Python 的 subprocess.run() 后却原样传给了程序;把 > 或 | 放进参数列表,也没有产生文件或管道。通常不是 Python 找不到文件,而是把 shell 的语法当成了程序参数。
本文面向初级到中级读者,先打印子进程实际收到的参数,再用 Python 显式选择文件、逐个执行统计,并将结果重定向到文件。全程保持 shell=False,不拼接 shell 命令。范围是可信本地目录中的小型 UTF-8 文本文件,不是任意外部命令的安全执行框架。
1. 方法:先区分参数与 shell 语法
Python 的 subprocess 参数说明区分了直接执行程序与通过 shell 执行。这里用参数列表:每个元素就是一个参数,空格不需要额外引号,通配符、变量与重定向符号不会由 shell 解释。
处理方式很直接:文件匹配交给 glob,文件输出交给 stdout,固定 Python 子程序用 sys.executable 启动。遇到可执行程序找不到,先参考 PATH 与命令预检,不要通过切换 shell=True 掩盖问题。
2. 准备:创建小型输入目录
使用已有的 Python 3.10 或更新版本,无第三方依赖。本文命令在 Linux、Python 3.11.4 下验证,适用于 bash/zsh。所有操作都在新目录内进行;重复实验时更换目录名,避免覆盖旧样例。
python3 --version
mkdir subprocess-argv-demo
cd subprocess-argv-demo
mkdir inputs
printf 'alpha\nbeta\n' > 'inputs/report one.txt'
printf 'gamma\n' > inputs/second.txt
printf 'hidden\n' > inputs/.private.txt
三个文件中,.private.txt 用于验证隐藏文件策略。将后续三个脚本保存在当前 subprocess-argv-demo 目录,不要放进 inputs。
3. 复现:打印子进程收到的内容
- 将以下代码保存为
show_argv.py:
import json
import sys
print(json.dumps(sys.argv[1:], ensure_ascii=True))
- 执行参数探针:
python3 - <<'PY_PROBE'
import subprocess
import sys
result = subprocess.run(
[sys.executable, "show_argv.py", "*.txt", "report one.txt",
"|", "$HOME", ">", "result.txt"],
shell=False, check=True, capture_output=True,
text=True, encoding="utf-8", timeout=5,
)
print(result.stdout, end="")
PY_PROBE
输出如下,且不会创建 result.txt:
["*.txt", "report one.txt", "|", "$HOME", ">", "result.txt"]
report one.txt 保持为一个参数。列表里的 "report one.txt" 是 Python 字符串写法,不是传给程序的引号;不要再在字符串里添加一层 shell 引号。| 在这里也只是普通字符,不会连接两个程序。
4. 实现:显式匹配,再逐个调用
- 将单文件统计器保存为
count_text.py:
"""Count nonempty lines in one trusted UTF-8 text file. Python 3.10+."""
import argparse
import json
from pathlib import Path
import sys
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", type=Path)
args = parser.parse_args()
try:
with args.path.open(encoding="utf-8") as handle:
count = sum(1 for line in handle if line.strip())
except (OSError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps({"file": args.path.name, "nonempty_lines": count},
ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
- 将批量入口保存为
batch_count.py,与统计器放在一起:
"""Count top-level non-hidden *.txt files without invoking a shell."""
import argparse
import glob
import json
from pathlib import Path
import subprocess
import sys
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
args = parser.parse_args()
worker = Path(__file__).resolve().with_name("count_text.py")
try:
root = args.directory.resolve(strict=True)
if not root.is_dir():
raise ValueError("input must be a directory")
names = sorted(glob.glob("*.txt", root_dir=root))
if not names:
raise ValueError("no matching *.txt files")
results = []
for name in names:
path = root / name
if not path.is_file():
raise ValueError(f"not a regular file: {name!r}")
result = subprocess.run(
[sys.executable, str(worker), "--", str(path)],
shell=False, stdin=subprocess.DEVNULL,
capture_output=True, text=True, encoding="utf-8",
check=True, timeout=10,
)
results.append(json.loads(result.stdout))
except subprocess.CalledProcessError as exc:
print(f"ERROR: worker exit={exc.returncode}; "
f"{exc.stderr.strip()}", file=sys.stderr)
return 1
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps({"files": results}, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
这里的 *.txt 是代码中的固定匹配规则,不是用户输入的 shell 表达式。按 glob 官方文档,root_dir 指定搜索根目录;结果需要自行排序。这种模式只匹配顶层非隐藏名称,不递归,也不展开 ~。因此 .private.txt 不参与统计。
每次只传一个绝对文件路径,避免把全部文件挤进一次调用。-- 是本例统计器的 argparse 选项终止标记,不是所有工具都支持的通用协议。目录匹配为 *.txt 时会被明确拒绝;指向普通文件的符号链接会被接受,所以这不是路径隔离工具。
- 查看用法并执行:
python3 batch_count.py --help
python3 batch_count.py inputs
第二条命令输出:
{"files": [{"file": "report one.txt", "nonempty_lines": 2}, {"file": "second.txt", "nonempty_lines": 1}]}
没有匹配文件返回 1,不将“未处理任何输入”当成成功;参数错误返回 2。子程序失败、读取失败或超时返回 1,错误写入 stderr。父程序只有在全部文件处理成功后才输出 JSON,避免把半份批次报告当成完整结果。
5. 输出文件:用 stdout,而不是参数里的大于号
在同一目录运行:
python3 - <<'PY_REDIRECT'
from pathlib import Path
import subprocess
import sys
with Path("counts.json").open("xb") as output:
subprocess.run(
[sys.executable, "batch_count.py", "inputs"],
shell=False, stdin=subprocess.DEVNULL,
stdout=output, check=True, timeout=30,
)
PY_REDIRECT
xb 要求 counts.json 尚不存在,防止覆盖旧报告;重复运行应换新文件名。重定向在启动前就打开文件,子程序失败时可能留下空文件,不能只凭文件存在判断成功。正式更新需要保留旧版本的报告时,参考 Python 原子写入 JSON。
这里让 stderr 留在终端,只有 stdout 写进 JSON 文件。后续步骤必须检查退出码;不要在命令失败后继续解析残留报告。
6. 验证:成功、隐藏文件与空匹配
将下面代码保存为 check_demo.py,运行 python3 check_demo.py。它依赖第 2 节原样创建的输入,以及第 3 节未生成 result.txt 的状态。
import json
from pathlib import Path
import subprocess
import sys
probe = subprocess.run(
[sys.executable, "show_argv.py", "*.txt", "report one.txt",
"|", "$HOME", ">", "result.txt"],
capture_output=True, text=True, encoding="utf-8", check=True, timeout=5,
)
assert json.loads(probe.stdout) == [
"*.txt", "report one.txt", "|", "$HOME", ">", "result.txt",
]
result = subprocess.run(
[sys.executable, "batch_count.py", "inputs"],
capture_output=True, text=True, encoding="utf-8", timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"files": [
{"file": "report one.txt", "nonempty_lines": 2},
{"file": "second.txt", "nonempty_lines": 1},
]}
assert not Path("result.txt").exists()
Path("empty").mkdir(exist_ok=True)
failed = subprocess.run(
[sys.executable, "batch_count.py", "empty"],
capture_output=True, text=True, encoding="utf-8", timeout=30,
)
assert failed.returncode == 1
assert failed.stdout == ""
assert "no matching *.txt files" in failed.stderr
assert Path("inputs/.private.txt").read_text() == "hidden\n"
print("PASS: exact arguments, counts, hidden-file exclusion, empty-match failure")
成功时输出 PASS: exact arguments, counts, hidden-file exclusion, empty-match failure。空匹配测试刻意触发失败,不会产生成功 JSON。该检查不修改原始输入,只创建一个空测试目录。
7. 排错与边界
| 现象 | 原因与操作 |
|---|---|
程序提示找不到 *.txt |
直接传入了通配符;先在 Python 中匹配,再传具体路径。 |
| 文件名多了引号 | 把 shell 引号写进了参数值;移除字符串内的额外引号。 |
no matching *.txt files |
检查目录、扩展名大小写和隐藏文件;本例不递归。 |
ERROR: worker exit=1 |
查看紧随其后的读取错误;修复文件编码、权限或缺失的 count_text.py 后重跑。 |
counts.json 已存在 |
保留旧文件,改用新的输出文件名。 |
| 想在列表里添加 ` | ` |
捕获输出会占用内存,本例子程序每次仅返回一小条 JSON,但一行输入仍可能很长,最终报告也随文件数增长。文件可能在匹配后被改动;请在停止写入的可信目录使用,不把它当快照或完整性审计。超时和日志的进一步处理见 subprocess 超时、错误与日志文件。
shell=False 不会限制程序权限,也不能阻止被调用工具将参数解释为选项。本文只启动自己编写的 Python 脚本;不要改成拼接不可信字符串并开启 shell,相关边界见 Python subprocess 安全说明。
8. 小结
先打印真实参数,再决定哪些步骤需要由 Python 实现。匹配文件、调用程序和重定向输出分别处理,不让 shell 语法混进参数列表。接入自动化前,验证成功、空匹配与失败退出,再消费输出报告。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-subprocess-literal-arguments.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。