Python argparse 子命令实战:必需操作、参数范围与特殊文件名
脚本最初只有一个功能,后来又加了预览、统计和一串可选参数:漏传操作时不知道该做什么,输错参数缩写却意外执行,文件名以 - 开头又被当成选项。把操作拆成明确的子命令,可以让这些错误在读取文件之前就被发现。
本文面向初级到中级读者,用标准库 argparse 写一个只读文本工具:count 统计行数,preview 查看前几行。代码不修改输入文件,也不安装依赖或改动系统配置。
1. 方法:把操作和参数分开
- 必须选择
count或preview,不能省略操作。每个子命令绑定一个函数。 --lines只属于preview,范围为 1–1000;默认 5。count不接受这个选项。- 主解析器和两个子解析器都关闭长选项缩写,避免
--lin被当成--lines。 - 只有解析成功后才打开文件。参数错误返回 2;读取或解码失败返回 1;成功返回 0,stdout 只输出一份 JSON。
Python 官方子命令文档给出了 add_subparsers() 和 set_defaults() 的用法。本文把解析规则集中在 build_parser(),业务函数不再直接索引 sys.argv。
若脚本已经从配置文件读取默认值,可以结合 tomllib 任务配置教程,但不要让子命令的默认值无意覆盖配置中的显式设置。
2. 准备:创建独立演示目录
使用现有 Python 3.9 或更新版本,无需 pip 安装。下面的命令在 Linux、Python 3.11.4 下验证,可在 bash 或 zsh 中执行。
- 在同一个终端执行,重复实验时使用新的目录名,避免覆盖旧文件:
python3 --version
mkdir text-cli-demo
cd text-cli-demo
python3 - <<'PY_DATA'
from pathlib import Path
Path("sample.txt").write_text("alpha\n你好\nlast", encoding="utf-8")
Path("-sample.txt").write_bytes(Path("sample.txt").read_bytes())
PY_DATA
- 将下一节代码保存为当前目录的
text_tool.py。演示文件最后一行没有换行符,仍应计为一行。
3. 实现:必需子命令、范围检查与函数分发
"""Read a local UTF-8 text file using count or preview. Python 3.9+."""
import argparse
from itertools import islice
import json
from pathlib import Path
import sys
def line_limit(raw):
try:
value = int(raw)
except ValueError as exc:
raise argparse.ArgumentTypeError("lines must be an integer in 1..1000") from exc
if not 1 <= value <= 1000:
raise argparse.ArgumentTypeError("lines must be an integer in 1..1000")
return value
def count_file(args):
with args.path.open(encoding="utf-8") as stream:
total = sum(1 for _ in stream)
return {"command": "count", "path": str(args.path), "lines": total}
def preview_file(args):
with args.path.open(encoding="utf-8") as stream:
lines = [line.removesuffix("\n") for line in islice(stream, args.lines)]
return {
"command": "preview", "path": str(args.path),
"lines": lines, "limit": args.lines,
}
def build_parser():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
commands = parser.add_subparsers(dest="command", required=True)
count = commands.add_parser("count", help="count text lines", allow_abbrev=False)
count.add_argument("path", type=Path, help="local UTF-8 text file")
count.set_defaults(func=count_file)
preview = commands.add_parser("preview", help="preview first lines", allow_abbrev=False)
preview.add_argument("path", type=Path, help="local UTF-8 text file")
preview.add_argument("--lines", type=line_limit, default=5, help="1..1000 (default: 5)")
preview.set_defaults(func=preview_file)
return parser
def main():
args = build_parser().parse_args()
try:
result = args.func(args)
except (OSError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
type=Path 只把字符串转换为路径对象,不检查文件是否存在。实际打开文件放在业务函数中,且在 with 结束后才生成成功报告,失败时不会留下半份 JSON。
argparse 的 type 参数可接收转换函数;这里把无效整数和越界值统一转成 ArgumentTypeError。它们不是文件错误,因此由参数解析阶段返回 2。主解析器的设置不是子解析器的全局开关,所以每个 add_parser() 都显式传入 allow_abbrev=False。
文件按 UTF-8 严格解码,文本模式将 CRLF、CR 和 LF 换行统一为 \n,详见 open() 官方说明。count 统计迭代得到的文本行,不等同于统计字节中的换行符;空文件为 0,最后一行不必有换行符。
preview 使用 islice()限制行数,只移除行末的一个换行符,保留空行和其他空白。行数限制不是字节或内存上限:单行很长时仍会占用较多内存;解码器也可能预读后面的数据。它不是完整文件的编码校验器。本文仅用于可信的普通本地文本文件,不用于设备、管道或无限数据源。
4. 运行:帮助、成功输出与特殊文件名
- 先查看帮助,再运行两个操作:
python3 text_tool.py --help
python3 text_tool.py preview --help
python3 text_tool.py count sample.txt
python3 text_tool.py preview --lines 2 sample.txt
后两条命令应分别输出:
{"command": "count", "path": "sample.txt", "lines": 3}
{"command": "preview", "path": "sample.txt", "lines": ["alpha", "\u4f60\u597d"], "limit": 2}
ensure_ascii=True 让 JSON 用转义形式表示中文,避免输出终端编码影响结果;json.loads() 会还原为中文,不是内容丢失。
- 文件名以
-开头时,在子命令的选项之后放--,再传文件名:
python3 text_tool.py preview --lines 2 -- -sample.txt
输出中的 path 应为 -sample.txt,lines 仍为 alpha 和 你好。-- 后面的所有内容都按位置参数处理,因此不要把 --lines 放到它后面;./-sample.txt 也是一种避免歧义的写法。
- 手动检查两类失败。请在交互终端执行;启用
set -e的脚本可能在第一条失败命令后停止:
python3 text_tool.py preview --lines 0 sample.txt
echo "exit=$?"
python3 text_tool.py count missing.txt
echo "exit=$?"
第一组显示参数用法和 lines must be an integer in 1..1000,随后 exit=2;第二组显示 ERROR:,随后 exit=1。自动化脚本应检查退出码,不要只检查报告文件是否存在。调度环境路径不一致时,可按 cron 中的 Python 路径与工作目录排查固定解释器和工作目录。
5. 验证:用断言覆盖成功和错误分支
将以下代码保存为 test_text_tool.py,放在 text_tool.py 旁边。它在当前目录创建独立临时目录,结束时自动清理,不覆盖演示输入:
"""Run beside text_tool.py; only temporary fixture files are created."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path("text_tool.py").resolve()
def run(folder, *args):
return subprocess.run(
[sys.executable, str(script), *args], cwd=folder,
capture_output=True, text=True, check=False,
)
with tempfile.TemporaryDirectory(prefix="cli-test-", dir=".") as name:
folder = Path(name).resolve()
sample = folder / "sample.txt"
sample.write_bytes("alpha\n你好\nlast".encode("utf-8"))
(folder / "-sample.txt").write_bytes(sample.read_bytes())
(folder / "empty.txt").write_bytes(b"")
(folder / "crlf.txt").write_bytes(b"one\r\ntwo\r\n")
(folder / "bad.txt").write_bytes(b"\xff")
before = {p.name: p.read_bytes() for p in folder.iterdir()}
for filename, expected in [("sample.txt", 3), ("empty.txt", 0), ("crlf.txt", 2)]:
result = run(folder, "count", filename)
assert result.returncode == 0 and result.stderr == "", result
assert json.loads(result.stdout)["lines"] == expected
result = run(folder, "preview", "--lines", "2", "--", "-sample.txt")
assert result.returncode == 0 and result.stderr == "", result
assert json.loads(result.stdout) == {
"command": "preview", "path": "-sample.txt",
"lines": ["alpha", "你好"], "limit": 2,
}
result = run(folder, "preview", "sample.txt")
assert result.returncode == 0 and json.loads(result.stdout)["limit"] == 5
for args in [[], ["unknown"], ["count"], ["count", "--lines", "2", "sample.txt"],
["preview", "--lin", "2", "sample.txt"],
*[["preview", "--lines", value, "sample.txt"]
for value in ["0", "-1", "1001", "abc"]]]:
result = run(folder, *args)
assert result.returncode == 2 and result.stdout == "" and result.stderr, result
for filename in ["missing.txt", "bad.txt"]:
result = run(folder, "count", filename)
assert result.returncode == 1 and result.stdout == "", result
assert result.stderr.startswith("ERROR:"), result
for args in [["--help"], ["preview", "--help"]]:
result = run(folder, *args)
assert result.returncode == 0 and "usage:" in result.stdout and not result.stderr
assert before == {p.name: p.read_bytes() for p in folder.iterdir()}
print("PASS: subcommands, limits, --, UTF-8, newlines, errors, help, unchanged inputs")
执行:
python3 test_text_tool.py
期望输出:
PASS: subcommands, limits, --, UTF-8, newlines, errors, help, unchanged inputs
测试检查中文、末行无换行、空文件、CRLF、帮助、必需子命令、越界参数、拒绝缩写、错误退出码,以及输入文件字节不变。它不是性能基准,也不模拟权限变化和并发文件修改。
6. 故障排查
| 现象 | 具体修复 |
|---|---|
提示缺少 command |
在脚本名后加 count 或 preview,不要去掉 required=True 来掩盖漏参。 |
count 不接受 --lines |
改用 preview --lines 2 sample.txt;不同操作只接收自己的选项。 |
--lin 报错 |
写完整的 --lines,保留关闭缩写的设置。 |
-sample.txt 被当成选项 |
使用 preview --lines 2 -- -sample.txt,或传 ./-sample.txt。 |
ERROR: 中含解码错误 |
先确认源文件真实编码,再转换成 UTF-8;不要用 errors="ignore" 静默丢字。 |
| 只想看日志末尾 | 本文预览的是开头;使用 deque 读取日志末尾的方法。 |
7. 总结
让子命令表达操作,让参数转换函数负责类型和范围,再把业务逻辑绑定到独立函数。先验证命令行,再读取文件,并用退出码区分参数错误与运行错误,就能把小脚本稳定地接入自动化。新增操作时,为它增加独立子解析器和对应测试,而不是继续堆叠含义模糊的开关。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-argparse-subcommands-validation.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。