用 argparse 写完整命令行工具:子命令、输出协议与退出码
脚本开始只有一个文件名参数,后来增加“统计行数”和“查找内容”,再后来需要 JSON 输出供流水线读取。继续在 sys.argv 上判断下标,最先失控的通常是错误处理:少传参数会抛索引异常,拼错选项却被当成文件名,程序失败以后还向标准输出打印一段调试信息。
命令行也是接口。参数的归属、错误的退出码、stdout 是否能被机器稳定消费,都值得在增加第二个子命令时确定下来。argparse 可以完成语法层面的约束,文件能否读取、正则是否合法,则交给执行层处理。
下面用 Python 3.12 标准库写一个 linebox。它有两个动作:count 统计文本行数,find 输出匹配行。每个子命令有自己的帮助,结果支持文本或单个 JSON 文档;参数错误返回 2,读取失败返回 1,正常完成返回 0。
完整程序:解析先结束,文件才开始读
保存为 linebox.py:
import argparse
import json
import re
import sys
from pathlib import Path
class InputError(Exception):
pass
def positive_int(text):
try:
value = int(text)
except ValueError as error:
raise argparse.ArgumentTypeError("must be an integer") from error
if value <= 0:
raise argparse.ArgumentTypeError("must be greater than zero")
return value
def count_lines(args):
with args.file.open("r", encoding=args.encoding) as stream:
total = sum(1 for _ in stream)
return {"command": "count", "file": str(args.file), "lines": total}
def find_lines(args):
flags = re.IGNORECASE if args.ignore_case else 0
try:
pattern = re.compile(args.pattern, flags)
except re.error as error:
raise InputError(f"invalid pattern: {error}") from error
matches = []
with args.file.open("r", encoding=args.encoding) as stream:
for number, line in enumerate(stream, 1):
if pattern.search(line):
matches.append({
"line": number,
"text": line.rstrip("\r\n"),
})
if len(matches) == args.limit:
break
return {"command": "find", "file": str(args.file), "matches": matches}
def add_output_arguments(parser):
parser.add_argument("--encoding", default="utf-8")
parser.add_argument("--format", choices=("text", "json"), default="text")
def build_parser():
parser = argparse.ArgumentParser(
prog="linebox",
description="Count lines or find text matches in a local file.",
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("file", type=Path)
add_output_arguments(count)
count.set_defaults(run=count_lines)
find = commands.add_parser(
"find", help="find lines matching a regular expression",
allow_abbrev=False,
)
find.add_argument("pattern")
find.add_argument("file", type=Path)
find.add_argument("--limit", type=positive_int, default=20)
find.add_argument("--ignore-case", action="store_true")
add_output_arguments(find)
find.set_defaults(run=find_lines)
return parser
def print_result(result, output_format):
if output_format == "json":
print(json.dumps(result, ensure_ascii=False))
elif result["command"] == "count":
print(result["lines"])
else:
for item in result["matches"]:
print(f"{item['line']}:{item['text']}")
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
result = args.run(args)
except InputError as error:
print(f"input error: {error}", file=sys.stderr)
return 2
except (OSError, UnicodeError, LookupError) as error:
print(f"read failed: {error}", file=sys.stderr)
return 1
print_result(result, args.format)
return 0
if __name__ == "__main__":
raise SystemExit(main())
set_defaults(run=...) 把子命令和执行函数绑定起来,主函数不需要再维护一段越来越长的字符串分支。解析器必须要求子命令,才能保证成功解析后一定存在 run。这份保证来自 required=True,不能仅凭“正常用户会输入动作”成立。
positive_int 只处理参数自己的格式与范围,返回一个真正的整数。文件打开留到执行函数,是为了避免解析到一半已经打开文件,后面又因为另一个参数错误退出。type=Path 仅构造路径对象,不会提前创建或读取文件。
帮助、正常结果和退出码都要能验证
在同目录准备文件:
python3 -c 'from pathlib import Path; Path("sample.txt").write_text("Alpha\nerror one\nERROR two\n", encoding="utf-8")'
python3 linebox.py --help
python3 linebox.py find --help
python3 linebox.py count sample.txt
python3 linebox.py find error sample.txt --ignore-case --limit 1 --format json
第三个业务命令输出 3。查找命令返回一个 JSON 对象,matches 里只有行号为 2 的记录。--limit 限制的是匹配结果数,并不承诺最多扫描多少输入行;文件中没有匹配项,就会读到结尾。查找成功但没有匹配,在这里仍是退出码 0,结果是空列表。这是工具自己选择的合同,不能偷偷套用其他命令的习惯。
如果需要把退出规则固定在测试里,可保存 check_linebox.py:
import json
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
SCRIPT = Path(__file__).with_name("linebox.py")
def call(*arguments):
return subprocess.run(
[sys.executable, str(SCRIPT), *map(str, arguments)],
capture_output=True, text=True, timeout=5,
)
with TemporaryDirectory() as directory:
sample = Path(directory) / "sample.txt"
sample.write_text("Alpha\nerror one\nERROR two\n", encoding="utf-8")
normal = call("count", sample, "--format", "json")
assert normal.returncode == 0 and normal.stderr == ""
assert json.loads(normal.stdout)["lines"] == 3
found = call("find", "error", sample, "--ignore-case", "--limit", 1,
"--format", "json")
assert found.returncode == 0
assert json.loads(found.stdout)["matches"] == [
{"line": 2, "text": "error one"}
]
bad_limit = call("find", "error", sample, "--limit", 0)
assert bad_limit.returncode == 2 and bad_limit.stdout == ""
bad_pattern = call("find", "[", sample)
assert bad_pattern.returncode == 2 and bad_pattern.stdout == ""
missing = call("count", Path(directory) / "missing.txt")
assert missing.returncode == 1 and missing.stdout == ""
no_command = call()
assert no_command.returncode == 2
print("normal output, search limit and failure exit codes passed")
执行 python3 check_linebox.py,会验证正常 JSON、匹配上限、错误正则、缺失文件和遗漏子命令。测试还检查失败时 stdout 为空。只检查返回码而不检查输出通道,仍可能把错误提示混进流水线的 JSON 输入。
选项放在哪里,是语法的一部分
本例把 --encoding 与 --format 定义在两个子命令上,因此写法是 linebox count 文件 --format json。它们并不是主解析器的全局选项,不能想当然地放到 count 前面。
项目若需要真正的全局选项,可以把日志级别等定义在主解析器,明确文档中的位置。不要为了“任意位置都能写”,把同名选项同时复制到多个解析器而不测试默认值覆盖:解析结果只有一个命名空间,后续解析器设置的默认值可能改变先前得到的值。
所有解析器都关闭了长选项缩写。否则今天唯一的 --format 可以简写为 --for,以后新增相同前缀选项,原有调用就可能变成歧义。给人手动输入的便利,与给脚本提供稳定语法,是两种取舍。长期供自动化使用的工具,通常应更看重后者。
文件名或查找模式以短横线开头时,还需要结束选项解析的 --。例如查找字面上的 -error,可以把可选参数写在前面,再使用:
python3 linebox.py find --limit 2 -- -error sample.txt
-- 解决的是解析器把参数识别成选项的问题,不负责把正则模式变成字面字符串。若产品要提供纯文本搜索,应另外增加明确的模式选择,不能让使用者靠是否转义来猜当前行为。
输入路径也有明确边界。Path 不会自动替你展开配置字符串里的环境变量;命令行上的星号和波浪号若被展开,常常是外部 shell 做的。通过 Python 的参数列表直接调用工具时,收到的可能就是字面字符串。需要支持哪种路径表达式,应在工具里明确实现并测试,别把交互终端里恰好生效的行为当成跨平台保证。
执行函数接收的 Namespace 只是内部协议,不适合原样暴露成长期业务 API。如果其他 Python 程序也要复用计数逻辑,可以进一步提取接受路径、编码等明确参数的纯函数,由子命令适配。这样改命令行选项名字,不会连带要求所有内部调用者理解解析器对象。反过来,只有一个短脚本时也没必要提前拆出数层框架,保留清晰的函数边界就足够。
错误不要被一个大 except 抹平
argparse 默认在参数错误时写 stderr 并以 2 退出。主函数没有拦住这个 SystemExit;在命令行程序中,这是预期路径。业务代码的读取失败则被转成 1,便于调用者区分“命令写错了”和“执行没完成”。
未知编码抛 LookupError,字节无法按选定编码解码抛 UnicodeError,两者都归入读取失败。程序没有捕获所有 Exception,因此内部键名写错、实现不变量失效等编程错误会留下 traceback。这比每次都只打印“处理失败”更容易修复。
这个工具不适合直接接收不可信用户提供的任意正则并运行在共享服务里:复杂模式可能占用大量 CPU,单行文本也没有设置大小限制。作为受控本地文件工具,这个边界可以接受;变成线上接口后,就要重新设计输入预算与执行隔离,不能只在外面加一层 HTTP。
命令行稳定下来以后,未来增加子命令可以继续保留同一组约定:成功结果走 stdout,诊断走 stderr,解析和业务错误分开,帮助能单独读取。使用者看到的是一条命令,维护者得到的应当是一份可以测试的接口。