引言
命令行工具写出来不只是给人敲的。在自动化脚本里、在 CI 流水线里,它是另一位「程序员」的组件:调用方需要知道两件事——这次运行成功了吗? 如果成功了,结果是什么? 前者就是退出码(exit code),后者是标准输出上的数据。本系列第 1-3 篇把参数入口收拾停当,第 4、5 篇把运行过程的日志打理清楚,本篇补上工具对外接口的最后一环:用 sys.exit 表达成败、用 JSON 交付机器可读的结果。
本篇覆盖四块:退出码的语义与惯例(0/1/2 与取模 256)、sys.exit 与 SystemExit 异常的传播(引用第四章第 8 篇的异常层次)、main() 返回 int 配合 sys.exit(main()) 的工程模式、以及 --json 机器可读输出与 quiet/verbose/json 三态设计,最后处理管道被提前关闭时的 BrokenPipeError。衔接预告:第 7 篇的单元测试会直接测 main() 的返回 int——本片的模式是它的前提;第 8 篇待办工具将完整采用本文的退出码与 JSON 设计;第 10 篇测验统一考察本章。
概念与原理
退出码语义:0 是成功,其余是失败
进程结束时向父进程(或 Shell)交回一个小整数,就是退出码。惯例只有一条铁律:0 表示成功,非 0 表示失败。这个约定跨平台成立——Windows 命令提示符里的 %errorlevel%、bash 的 $? 观察的同一件事。在 0 之上,POSIX 世界沉淀出更细的编号习惯:1 为一般错误(运行时出了问题),2 为用法错误(命令行参数不对)——argparse 在参数校验失败时就退出 2(第 2 篇里 parse_args 报错后进程自动终止,如今答案揭晓:它调用的是 sys.exit(2))。bash 自身还有两层约定:126 表示命令存在但不可执行、127 表示命令不存在;128+N 表示进程被编号为 N 的信号杀死。Python 程序自己只需守住 0/1/2 三档,其余编号留给系统层。还有一个如影随形的细节:POSIX 上退出码对 256 取模——sys.exit(300) 在 shell 里观察到的是 44(300 % 256)。所以写入代码的退出码应当控制在 0-255 之间,取模行为只是防御性兜底。
sys.exit 是抛异常的退出
sys.exit(n) 并不像 C 的 exit() 那样直接终止进程,而是抛出一个 SystemExit 异常。第四章第 8 篇的异常层次图里有它一席:SystemExit 是 BaseException 的直系子类,不在 Exception 之下——因此用 except Exception: 兜底永远接不住它,这是「退出」与「错误处理」刻意隔离的设计:SystemExit 表达「我要走了」,不是「出错了」。作为异常,它享受异常的一切待遇:沿途执行 finally 清理(第四章讲过 finally 在任何情况下都会执行)、可以被 except BaseException 捕获并检查它的 code 属性——捕获后程序并不会自动退出,是否退出由你的代码决定。sys.exit() 的变体:无参数或 None 相当于 0;传入字符串则把字符串打到 stderr 并以 1 退出——「带消息退出」的快捷方式。
main() 返回 int 与 sys.exit(main()) 模式
命令行程序的经典骨架:逻辑全在 main() 里,main() 返回退出码,入口只做一件事——sys.exit(main())。为什么绕这一圈?因为退出码从此变成函数的返回值而不是「进程的副作用」:main() 可以被 import 后直接调用、返回值可以断言、可以被第 7 篇的单元测试逐个分支验证;同时 if __name__ == "__main__" 的语义回归最初设计——被 import 时绝不退出进程。这是「可测试的边界」思想的最小实践,也是从「脚本」走向「程序」的分水岭。
人读与机器读:三态设计
同一个工具面对两类消费者。人类看终端:要点是排版清晰、信息适量,--verbose 给细节、--quiet 给最少;脚本消费 stdout:要的是结构稳定、语义精确——JSON 是第七章第 7 篇学过的通用结构化格式,json.dumps(..., ensure_ascii=False, indent=2) 保证中文可读、缩进亲切(注意 sys.stdout 默认 UTF-8,与文件写入的编码议题呼应)。设计上把输出模式做成互斥三态:默认给人读的概要、--quiet 单值、--verbose 逐项、--json 全结构化;同时立一条纯净度铁律:数据只走 stdout,日志与错误只走 stderr(第 4、5 篇的通道分工在此兑现)——否则脚本把 stdout 交给 json.loads 时会迎面撞上日志行。错误信息写 stderr 是纪律:调用方只看得到退出码与非零的输出,两者共同构成失败语义。对机器调用者而言,完整契约是两段式:先查退出码(非零即失败、直接放弃解析),为零才解析 stdout;失败时读 stderr 用于人工排障。把这两步写进调用脚本,是自动化最基本的健壮性习惯。
操作与实现
实测一:骨架与透过 shell 观察退出码
import sys
def main():
args = sys.argv[1:]
if not args:
print("缺少要处理的任务名", file=sys.stderr)
return 1
print("完成:", args[0])
return 0
if __name__ == "__main__":
sys.exit(main())
python job.py
echo "退出码=$?"
python job.py 数据备份
echo "退出码=$?"
缺少要处理的任务名
退出码=1
完成: 数据备份
退出码=0
两个分支的退出码通过 bash 的 $? 如实呈现:缺参数时 stderr 收到提示、退出 1;正常路径 stdout 收到结果、退出 0。return 1 与 sys.exit(main()) 分离后,若把这段逻辑搬进测试框架,只需 assert main(["数据备份"]) == 0 与 main([]) == 1。
实测二:用法错误退 2、字符串消息退 1、取模 256
import argparse
parser = argparse.ArgumentParser(prog="args_error")
parser.add_argument("num", type=int)
args = parser.parse_args()
print(args.num ** 2)
python args_error.py not_a_number 2>err.txt
echo "退出码=$?"
echo "--- stderr ---"
cat err.txt
python -c "import sys; sys.exit('数据库连接失败')" 2>msg.txt
echo "退出码=$?"
cat msg.txt
python -c "import sys; sys.exit(300)"
echo "退出码=$?"
退出码=2
--- stderr ---
usage: args_error [-h] num
args_error: error: argument num: invalid int value: 'not_a_number'
退出码=1
数据库连接失败
退出码=44
三组实验逐一验证:argparse 参数类型校验失败 → 使用说明与错误信息进 stderr、退出码 2(第 2 篇「自动报错」的机制侧面);sys.exit("消息") → 消息进 stderr、退出 1;sys.exit(300) → shell 观察到 44,即取模 256 的实证。
实测三:SystemExit 是可以被接住的异常
import sys
def check(value):
if value < 0:
sys.exit(2)
try:
check(-1)
except SystemExit as e:
print("SystemExit 被捕获,code =", e.code)
SystemExit 被捕获,code = 2
code 属性带着当时传给 sys.exit 的整数;捕获之后程序继续运行、退出码归本段代码决定。再看继承关系导致的反面事实——except Exception 抓不到它:
import sys
try:
sys.exit(3)
except Exception as e:
print("except Exception 分支:不会执行")
except BaseException as e:
print("SystemExit 是 BaseException 的子类,code =", e.code)
SystemExit 是 BaseException 的子类,code = 3
第一个 except Exception 直接跳过,落进 except BaseException 分支。工程含义:想「捕获一切致命情况再做清理」必须用 BaseException 或显式接 SystemExit;反过来,except Exception 写得多严实,也拦不住一个 sys.exit。
实测四:wc_local.py——三态输出与机器可读 JSON
把全部技能组装成一个小工具:统计文本文件的行数、词数与字符数(词数按空白字符切分,行数按换行符计、结尾无换行也算一行,字符数含换行符)。保存为 wc_local.py:
import argparse
import json
import sys
def count_stats(path):
with open(path, encoding="utf-8") as f:
text = f.read()
return {
"path": path,
"lines": text.count("\n") + (0 if text.endswith("\n") else 1),
"words": len(text.split()),
"characters": len(text),
}
def main(argv=None):
parser = argparse.ArgumentParser(
description="统计文本文件的行数、词数与字符数"
)
parser.add_argument("path", help="要统计的文件路径")
parser.add_argument("--json", action="store_true", help="输出机器可读的 JSON")
parser.add_argument("--quiet", action="store_true", help="只输出词数")
parser.add_argument("--verbose", action="store_true", help="逐项输出统计")
args = parser.parse_args(argv)
try:
stats = count_stats(args.path)
except OSError as e:
print(f"无法读取 {args.path}:{e}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(stats, ensure_ascii=False, indent=2))
elif args.quiet:
print(stats["words"])
elif args.verbose:
print("文件:", stats["path"])
print("行数:", stats["lines"])
print("词数:", stats["words"])
print("字符数:", stats["characters"])
print("词数按空白字符切分统计")
else:
print("行数 %d 词数 %d 字符数 %d" % (stats["lines"], stats["words"], stats["characters"]))
return 0
if __name__ == "__main__":
sys.exit(main())
printf 'hello world python\nsecond line here\nthird line\n' > sample.txt
python wc_local.py sample.txt
python wc_local.py --quiet sample.txt
python wc_local.py --json sample.txt
echo "退出码=$?"
行数 3 词数 8 字符数 47
8
{
"path": "sample.txt",
"lines": 3,
"words": 8,
"characters": 47
}
退出码=0
--json 的 stdout 是纯净的单一 JSON 文档,脚本侧 json.loads 一行收下;--quiet 输出单个数字,适合嵌入 shell 变量;默认模式给人读概要。失败路径同样有章程:文件不存在时错误进 stderr、退出码 1,stdout 一个字符都不给:
python wc_local.py no_such.txt
echo "退出码=$?"
无法读取 no_such.txt:[Errno 2] No such file or directory: 'no_such.txt'
退出码=1
三种数据模式与两种失败语义合在一起,就是这个工具对外的完整契约。注意 --json 与 --verbose 同时给出时,--json 优先(if 链的次序即优先级)——三态设计必须规定好优先级,否则输出模式悬而未决。
实测五:管道提前关闭——BrokenPipeError
python wc_local.py 大文件 | head -n 1 这类场景中,head 读完第一行就退出、关闭了管道读端,此时写端继续写就会撞上「管道破裂」。先看不处理的后果,把下面的脚本存为 pipe_naive.py:
for i in range(100000):
print(f"第 {i} 行:诊断信息")
python pipe_naive.py 2>err_naive.txt | head -n 2
echo "python 退出码=${PIPESTATUS[0]}"
echo "--- stderr 最后一行 ---"
tail -n 1 err_naive.txt
第 0 行:诊断信息
第 1 行:诊断信息
python 退出码=1
--- stderr 最后一行 ---
OSError: [Errno 22] Invalid argument
程序带着一整页 traceback 噪音退出 1。(本机是 Windows + Git Bash 的 msys 管道,实测报 OSError: [Errno 22] Invalid argument;在 Linux/macOS 上同样位置通常是 BrokenPipeError: [Errno 32] Broken pipe——不同平台的 errno 不同,但都是 OSError 家族。这也是为何捕获时用 except OSError 一段代码两边通吃。)处理模式:接住 OSError 后,先把 stdout 重定向到系统空设备 devnull——防止解释器退出时再次 flush 已死管道又报一遍错——再以 1 退出:
import os
import sys
def main():
for i in range(100000):
print(f"第 {i} 行:诊断信息")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except OSError:
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, sys.stdout.fileno())
sys.exit(1)
python pipe_demo.py 2>err_final.txt | head -n 2
echo "python 退出码=${PIPESTATUS[0]}"
echo "--- stderr(应保持干净) ---"
cat err_final.txt
第 0 行:诊断信息
第 1 行:诊断信息
python 退出码=1
--- stderr(应保持干净) ---
stderr 干净了,退出码 1 语义明确:「输出通道被下游关闭,任务未完成」。管道场景里还有个 bash 细节:cmd1 | cmd2 这整条管道的 $? 是最后一个命令 cmd2 的退出码,要看 python 本体必须用 bash 的 ${PIPESTATUS[0]}——这正是上面两个 bash 块用它的原因。
易错点与陷阱
1. main() 忘了 return,一切错误都变成功
main() 自然结束返回 None,sys.exit(None) 等价于 sys.exit(0)。于是「处理完错误忘记 return 1」的 main 会让工具在出错时依然退出 0——自动化流水线会把它当成功处理,静默放过故障。对策:main 的所有路径显式 return 整数,并把「成功才 return 0」当纪律写进代码审查清单。
2. 想用 except Exception 兜底拦下退出
SystemExit 继承自 BaseException 而非 Exception(实测三第二段验证过),except Exception 接不住;意图「捕获一切」的代码必须用 except BaseException,并通常要立刻 sys.exit 转交退出意图,否则被吞掉的退出码会让调用方困惑。
3. 机器可读输出被污染的两种姿势
第一种是往 stdout 里 print 日志/进度(应走 stderr,第 4、5 篇的通道纪律);第二种是 --json 与其它模式混出多段内容——JSON 消费者 json.loads 一次只能吃一个文档。守住两条:数据 stdout、噪音 stderr;输出模式互斥并规定优先级。
4. 在管道里观察退出码被 head 误导
python pipe_demo.py | head -n 2 之后立刻 echo $?,拿到的是 head 的退出码(0),python 的真实退出码被管道吞掉。要取前一个命令的退出码必须用 ${PIPESTATUS[0]}。顺手一提:BrokenPipeError 不处理时还会叠加「退出码看似正常」的错觉——实测五里未处理版本退出 1,但那 1 是 traceback 终止换来的,与明确的失败语义不是一回事。
小结
本篇补齐了命令行工具对外的完整接口:退出码(0 成功、1 一般错误、2 用法错误,取模 256 的兜底)、sys.exit 抛出 SystemExit 的异常式退出(BaseException 直系、code 属性、可被接住)、main() 返回 int 与 sys.exit(main()) 的可测试骨架、以及「数据走 stdout、日志走 stderr」的纯净原则下的三态输出(默认/quiet/verbose/json)。机器可读输出由 json.dumps(ensure_ascii=False, indent=2) 交付,管道破裂用 except OSError 加 devnull 重定向干净收场。至此本章的「参数-日志-输出」三连环全部打通:第 8 篇将把 argparse 的骨架、logging 的通道、本篇的契约组装成完整的待办事项工具。
练习与思考题
- 给 wc_local.py 增加
--lines选项:只输出行数(机器可用),并让它与--json、--quiet的优先级有明确文档;为每种组合写一行运行说明。 - 解释
sys.exit(main())中 main 返回None时退出码是多少,并用实测验证「忘记 return 1 的错误分支」会被调用方误判为成功。 - 阅读 argparse 的错误路径:为什么
parse_args失败后程序不会再执行后面的代码?它与 SystemExit 有什么关系?(提示:查看parse_args内部对error()的处理,再对照本文实测二。) - 用
except BrokenPipeError替换实测五的except OSError在本机运行:现象如何?结合本机报出的 errno 解释为什么except OSError是更稳的写法。