前言命令行工具CLIcommand-line interface的形态很固定工具名 子命令 参数比如todo add 买牛奶、todo list -p、todo done 3。要写好它只需要三件事——参数解析、业务处理、数据持久化。本文用纯标准库把这三件事搭起来。一个特别常见的做法是手工解析sys.argv判断sys.argv[1] add再取sys.argv[2]。小脚本里看着能用问题在于它经不起扩展todo.py add 内容里 有 空格该怎么处理-p和--pending要不要都支持用户输入todo.py不带参数时该怎么办每加一个子命令这段手工逻辑就长一截。标准库的argparse正是为解决这些问题而生的它自动生成帮助信息、自动做类型转换、自动报错。第二个误解是「待办数据随便存反正是自己的小工具」。数据一旦丢了就真丢了所以写入要用先写临时文件、再原子替换的方式避免写到一半出错把原文件毁掉。同时json.dump默认会把中文转成\uXXXX转义序列文件能读但完全没法用眼睛看需要显式关掉这个行为。需要提醒的是Python 2.7 已于 2020 年 1 月 1 日停止维护argparse里的add_subparsers(requiredTrue)是 Python 3.7 才支持的写法Python 2 里没有这个参数。一、子命令怎么设计argparse的用法是分两层的外层ArgumentParser负责全局选项再通过add_subparsers()派生出一批子解析器每个子解析器对应一个子命令。子命令参数作用addtext位置参数新增一条待办list-p/--pending可选开关列出待办可只看未完成doneid整数把某条标为完成rmid整数删除某条关键技巧是set_defaults(func...)把「这个子命令该调用哪个函数」直接存进解析结果里。主函数拿到args之后只需要一句args.func(args, ...)不用再写一长串if args.command add: ... elif ...。以后加子命令只要注册一个新的子解析器主函数一个字都不用改。位置参数id必须写typeint。命令行传来的永远先是字符串不声明类型就会拿3去和整数3比较结果永远是找不到。二、数据怎么存用一个 JSON 文件当数据库结构很简单一个自增的next_id加一个条目列表。字段类型说明next_idint下一条待办要用的编号itemslist每条含id/text/done/created_at编号不重用删掉 3 号之后再新增拿到的还是 4 号。这样历史记录里的编号不会张冠李戴。时间统一存带时区的 UTC 时间避免跨时区时列表排序错乱。三、完整实现以下代码存成todo.py即可使用需要 Python 3.8# 适用于 Python 3.8import argparseimport jsonimport osimport sysfrom datetime import datetime, timezonefrom pathlib import PathDATA_FILE Path(os.environ.get(TODO_FILE)or Path.home() / .todos.json)def load(path):if not path.exists():return {next_id: 1, items: []}with path.open(r, encodingutf-8) as fp:return json.load(fp)def save(path, data):# 先写临时文件再原子替换避免中途失败毁掉原文件tmp path.with_suffix(path.suffix .tmp)with tmp.open(w, encodingutf-8) as fp:json.dump(data, fp, ensure_asciiFalse, indent2)os.replace(tmp, path)def find(data, todo_id):for item in data[items]:if item[id] todo_id:return itemreturn Nonedef cmd_add(args, data, path):item {id: data[next_id],text: args.text,done: False,created_at: datetime.now(timezone.utc).isoformat(timespecseconds),}data[items].append(item)data[next_id] 1save(path, data)print(已添加 #{}: {}.format(item[id], item[text]))return 0def cmd_list(args, data, path):items data[items]if args.pending:items [item for item in items if not item[done]]if not items:print(没有待办事项)return 0for item in items:mark x if item[done] else print([{}] #{:3} {}.format(mark, item[id], item[text]))return 0def cmd_done(args, data, path):item find(data, args.id)if item is None:print(找不到 #{},请先 list 看看.format(args.id), filesys.stderr)return 1if item[done]:print(#{} 已经是完成状态.format(args.id))return 0item[done] Truesave(path, data)print(已完成 #{}: {}.format(args.id, item[text]))return 0def cmd_rm(args, data, path):item find(data, args.id)if item is None:print(找不到 #{},请先 list 看看.format(args.id), filesys.stderr)return 1data[items] [i for i in data[items] if i[id] ! args.id]save(path, data)print(已删除 #{}: {}.format(args.id, item[text]))return 0def build_parser():parser argparse.ArgumentParser(progtodo, description命令行待办事项管理器)sub parser.add_subparsers(destcommand, requiredTrue)p_add sub.add_parser(add, help添加一条待办)p_add.add_argument(text, help待办内容用引号包住整句)p_add.set_defaults(funccmd_add)p_list sub.add_parser(list, help列出待办)p_list.add_argument(-p, --pending, actionstore_true,help只显示未完成的)p_list.set_defaults(funccmd_list)p_done sub.add_parser(done, help标记某条为已完成)p_done.add_argument(id, typeint, help待办编号)p_done.set_defaults(funccmd_done)p_rm sub.add_parser(rm, help删除某条待办)p_rm.add_argument(id, typeint, help待办编号)p_rm.set_defaults(funccmd_rm)return parserdef main(argvNone):args build_parser().parse_args(argv)data load(DATA_FILE)return args.func(args, data, DATA_FILE)if __name__ __main__:sys.exit(main())用法示例python todo.py add 买牛奶python todo.py add 写周报python todo.py listpython todo.py done 1python todo.py list -ppython todo.py rm 2常见坑点1. 手工解析sys.argv❌if sys.argv[1] add: ...不带参数时IndexError ✅ 用argparse的add_subparsersargparse自带-h帮助、类型转换和友好的报错手工解析这些全都要自己写。2.add_subparsers不写requiredTrue❌parser.add_subparsers(destcommand)✅parser.add_subparsers(destcommand, requiredTrue)不写的话用户输入python todo.py会静默退出、什么都不做也不报错非常难排查。required参数需要 Python 3.7。3. 位置参数不声明typeint❌p_done.add_argument(id)然后if item[id] args.id✅p_done.add_argument(id, typeint)命令行传来的默认是字符串3 3永远是False表现出来就是明明有这条却说找不到。4.json.dump不关ensure_ascii❌json.dump(data, fp)✅json.dump(data, fp, ensure_asciiFalse)默认会把中文写成一堆\uXXXX转义序列文件虽然合法但人根本读不了出问题时没法用编辑器直接修。5. 直接覆盖写原文件❌with open(path, w) as fp: json.dump(data, fp)✅ 写path.tmp再os.replace(tmp, path)序列化过程中抛异常比如数据里有不可序列化的对象就会留下半截文件所有待办一起报废。6.open()不写encoding❌open(path)/path.open()✅path.open(r, encodingutf-8)中文 Windows 的默认编码是 GBK换机器或换系统后读同一个文件就会乱码。7. 遍历列表的同时删元素❌for item in data[items]: if ...: data[items].remove(item)✅data[items] [i for i in data[items] if i[id] ! args.id]边遍历边删会跳过元素结果删了但没删干净而且不报错。8. 出错信息打到标准输出❌print(找不到 #3)✅print(找不到 #3, filesys.stderr)并返回非 0 退出码 错误走 stderr、退出码非 0才能被 shell 脚本、链和 CI 正确识别。总结关注点错误做法正确做法参数解析手工读sys.argvargparse 子解析器子命令分派一长串if/elifset_defaults(func...)数字参数保持字符串typeint中文写入默认ensure_asciiensure_asciiFalse落盘直接覆写临时文件 os.replace错误输出混在 stdoutstderr 非 0 退出码CLI 工具的骨架就是「解析 → 处理 → 落盘」三段。argparse负责第一段视图函数负责第二段原子写入负责第三段。把set_defaults(func...)这个分派技巧用上这个工具从四条命令扩展到四十条主函数都不用动一行。