Python本地待办CLI实战:argparse与JSON存储的工程化取舍

发布时间:2026/10/21 1:38:59
Python本地待办CLI实战:argparse与JSON存储的工程化取舍
简介这是一套基于Python的命令行待办事项列表练习项目面向正在学习Python基础语法、文件读写与命令行交互的初学者也适合作为轻量级本地任务管理工具的入门参考。项目要求实现任务的添加、列表展示、按编号删除以及通过CSV或JSON完成本地持久化存储覆盖函数定义、列表操作、异常处理等Python核心知识点。压缩包内共7个文件主要包括两个Python源码文件主程序与测试脚本、一个数据文件、一个配置文件、说明文档和一张操作演示动图整体体积仅295KB结构简洁方便快速上手。目前已有355人学习下载。通过学习该资源可以获得完整的本地待办应用实现脉络借助测试脚本自行验证各函数是否正确结合说明文档和演示动图理解运行效果并在此框架上继续扩展任务优先级、截止日期等个性化功能。1. 本地 Python 待办 CLI 改造实录先别急着上框架在地铁上用 App 记待办最顺手可到了公司就得打开网页端同步偶尔还遇到服务不可用。后来我把一个用 Python 写的待办事项列表 CLI 捡回来重写只跑在本机不上云、不注册、不设密码交互全部在终端完成。它解决的不是团队协作的需求而是那种“随时把想法变成一条任务几秒后还能在下一个终端里看到”的个人工作流。适合对象很清楚习惯键盘操作、愿意读错误信息、对数据留在本地有执念的人。这条实战笔记就是拆解这个 CLI 练习项目怎么做出来的——命令怎么解析、数据怎么存、四个容易翻车的角落。看完你不仅能复现还能顺手打包成单文件给日常脚本用。2. 存储与目录设计为什么用 JSON 而不是数据库也能把事做好2.1 为什么选 CLI 而不是图形界面我见过不少人第一反应是“待办事项难道不应该做个窗体吗”但命令行在这类场景里有几个 GUI 替代不了的好处。第一是零依赖运行。只要机器上有 Python 3拉到任何目录都能跑不需要安装桌面环境、不需要装第三方控件库服务器上连图形界面都没有照样维护任务清单。第二是脚本友好。CLI 天然能被管道、重定向、定时任务调用比如从日志里筛出异常行自动转成一条待办这活儿 GUI 做起来很别扭。第三是输入成本低。在终端里todo add 给某导师发周报比打开 App 点击、选择分类、设置提醒快得多尤其当你的办公场景就是满屏终端时“不离开当前窗口”就是效率。这个练习项目刻意放弃图形界面还有一个原因它把问题域压缩得很干净。你只需要处理两件事——命令怎么进入程序、数据怎么持久化其余全是编辑器里肉眼可见的 Python 代码。对于想快速上手 Python 标准库的新手这种“不装框架、不引外部依赖”的写法反而能把argparse、json、pathlib这几个标准库用透。等后面到了线上环境你会发现这几个库的使用频率远超很多第三方包。2.2 存储方案对比JSON、SQLite、CSV 的取舍存储是待办列表最核心的设计。项目标题里特意强调“仅限本地”这就意味着不需要考虑多人并发、不需要服务端同步但必须考虑数据可读性、可迁移性和崩溃恢复。我当时把三种方案横着比了一遍存储方案优点缺点适合场景JSON 文件纯文本可读、结构灵活、Python 标准库直接支持数据量大时全量读写慢、无并发控制个人工具、千条以内任务SQLite事务与并发支持、查询能力强引入数据库概念、二进制文件不便直接查看多端同步、数据量大、复杂查询CSV/纯文本最轻量、可用任意编辑器改无结构约束多字段容易转义出错极简记录、日志流水对于这个项目的量级——日常几百条任务JSON 的优势非常突出json.load一次读进内存增删改都在列表里操作最后json.dump写回文件整个过程不超过 20 行代码。SQLite 确实更“正规”但要处理连接、游标、事务学习成本翻倍即便用标准库sqlite3代码量也会多出两倍不止。你的重心应该放在功能实现上而不是一开始就被存储细节拖住。选择 JSON 还有一个附加红利文件可以被cat直接查看出问题时你能一眼看出是哪条记录坏了而 SQLite 文件坏了基本只能靠备份恢复。对个人工具来说透明的数据格式比“黑匣子”式的数据库文件更让人安心。后面我会再补一层“原子写”的技巧把 JSON 崩溃恢复的弱点也堵上。2.3 目录结构与模块划分虽然这只是个练习项目但我没有把全部代码塞进一个文件。我在本地用的是这样一个结构todo/ ├── cli.py # 参数解析与命令分发 ├── storage.py # JSON 读、写、原子替换 ├── models.py # 任务字典结构与状态切换 └── requirements.txtcli.py负责识别终端输入的命令比如todo add 写周报、todo list它做完参数解析后调用 storage 层去加载数据调用 models 层去变更状态最后把结果打印回终端。storage.py只关心文件路径、编码、读写安全不关心待办内容长什么样。models.py里定义任务的字段、ID 分配规则、完成状态翻转这样一个函数只管一件事。你可能会觉得“这么小的脚本分三个文件是不是小题大做”我过去也这么想直到有一次在单文件项目里加“优先级排序”功能发现改一个字段要同时翻打印逻辑、存储逻辑和参数逻辑改完心里没底。分层之后改models.py里的字段不影响命令行入口改storage.py的写入策略不影响业务判断测试时也能独立验证某一层。当然如果你只是临时跑一下整段代码写在一个todo.py里完全没问题。但既然题目里带了 “exercise” 字样我更建议按这种分工去写——它逼着你思考哪些逻辑属于输入输出、哪些属于数据持久化、哪些属于业务状态。这个习惯往后迁移到任何项目里都不过时。3. 核心功能实现用 argparse 把 add/list/done 调通3.1 参数解析argparse 子命令构建 CLI 入口命令行工具最容易被新手写成“用sys.argv[1]判断第一个参数”然后一层层if嵌套最后连--help都出不来。标准库argparse自带子命令机制正好是这类工具的标准解法。我最常用的一段骨架代码是这样import argparse def build_parser(): parser argparse.ArgumentParser( progtodo, description仅限本地的待办事项列表, ) sub parser.add_subparsers(destcommand, requiredTrue) add sub.add_parser(add, help添加一条待办) add.add_argument(task, help待办内容) add.add_argument(-p, --priority, choices[low, mid, high], defaultmid, help优先级) list_p sub.add_parser(list, help列出待办) list_p.add_argument(-a, --all, actionstore_true, help同时显示已完成任务) done sub.add_parser(done, help标记某条任务完成) done.add_argument(task_id, typeint, help任务 ID) remove sub.add_parser(remove, help删除某条任务) remove.add_argument(task_id, typeint, help任务 ID) return parser.parse_args()这里最关键的三个点add_subparsers(destcommand, requiredTrue)表示必须有子命令缺省时直接报错并提示比args.command is None再手写判断干净得多typeint让传入的task_id自动从字符串转成整数转换失败时 argparse 会给出清晰报错choices[low, mid, high]把优先级限定在三档用户手滑输入urgent时不会进到业务层。把actionstore_true用在--all上则是把 bool 开关的处理交给框架你只需要读args.all就能判断是否要过滤已完成任务。这段代码放在cli.py顶部即可。真正调用时入口函数会先拿到args.command再据此决定走add_task、list_tasks还是mark_done。argparse 还有一个隐藏价值它自动生成-h/--help帮助信息不需要你手动写使用说明这对命令行工具的可维护性帮助很大。3.2 持久化load_tasks 与 save_tasks 的读写逻辑数据存储我放在storage.py里用pathlib.Path描述文件路径而不是拼字符串。示例代码如下import json import os from pathlib import Path DATA_FILE Path.home() / .todo / tasks.json TMP_FILE DATA_FILE.with_suffix(.json.tmp) def load_tasks(): if not DATA_FILE.exists(): return [] try: with DATA_FILE.open(r, encodingutf-8) as f: return json.load(f) except json.JSONDecodeError: backup DATA_FILE.with_suffix(.json.bak) DATA_FILE.replace(backup) return [] def save_tasks(tasks): DATA_FILE.parent.mkdir(parentsTrue, exist_okTrue) with TMP_FILE.open(w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) os.replace(TMP_FILE, DATA_FILE)逐段解释Path.home()取用户主目录保证不管你在哪个目录下运行数据文件都落在同一位置这是避免“换个目录数据消失”的关键。encodingutf-8是所有中文环境下的标配少了它Windows 上经常写进 GBK 字符读回来就乱码。json.dump(..., ensure_asciiFalse)让中文直接显示成“周报”而不是一堆\u5468文件可读性大幅提升。save_tasks里我做了一个常见做法先写TMP_FILE再调用os.replace把临时文件替换成目标文件。os.replace在 POSIX 和 Windows 上都是原子操作要么旧文件还在要么新文件完整覆盖不会出现“写到一半被 Ctrl-C 打断留下一个截断的 tasks.json”。这就是前面提到的那层补偿也是我在这个练习项目里最想强调的工程习惯。如果你只是直接open(DATA_FILE, w)一旦进程中途崩溃整份清单就完蛋了而且是没有任何后悔药的那种。3.3 任务数据模型与状态翻转在models.py里我定义了任务的结构from datetime import datetime def make_task(text, prioritymid): return { id: 0, text: text, priority: priority, done: False, created_at: datetime.now().isoformat(), } def next_id(tasks): if not tasks: return 1 return max(t[id] for t in tasks) 1 def add_task(tasks, text, prioritymid): task make_task(text, priority) task[id] next_id(tasks) tasks.append(task) return task[id] def toggle_done(tasks, task_id): for t in tasks: if t[id] task_id: t[done] not t[done] return True return FalseID 分配用的是“取当前最大 ID 再 1”而不是拿列表长度当 ID。假如你删掉了第 3 条列表长度变成了 2但已有 ID 是 1、2、4如果用长度分配新任务会拿 ID 3跟之前已删除的 ID 重复逻辑上会造成混乱。用max取最大值能保证 ID 一直递增代价是每次添加任务都做一次O(n)扫描对几千条任务来说可以忽略。done字段没有单独设置complete方法而是用toggle_done翻转状态。这样同一个方法既能“标记完成”也能“取消完成”在命令行工具里特别实用——用户总会不小心把一个任务标错。created_at存 ISO 格式字符串而不是时间戳这样直接查看 JSON 文件的时候能判断任务是什么时候加的必要时还能按时间排序。3.4 格式化输出与全流程验证任务模型有了命令解析有了最后要把列表打印得像个人样。我的输出逻辑是这样def print_tasks(tasks, show_allFalse): lines [] for t in tasks: if t[done] and not show_all: continue flag [x] if t[done] else [ ] prio {high: H, mid: M, low: L}[t[priority]] lines.append(f{t[id]:3} {flag} {prio} {t[text]}) print(\n.join(lines) if lines else 没有待办任务。)flag用[ ]和[x]是几乎所有 CLI 待办工具的惯例一眼能看出完成状态。优先级缩写H/M/L是为了让行宽可控配合 ID 右对齐列表在窄终端里也不会乱。打印时默认过滤已完成任务想看全量就加--all。验证一次完整流程直接在终端里跑python cli.py add 给某导师发开源项目链接 python cli.py add 写季度复盘 -p high python cli.py list python cli.py done 1 python cli.py list --all预期第一遍list输出两行待办第二遍list --all会看到 ID 为 1 的任务变成了[x]。这组命令已经把添加、列表、完成、陈列四件事全部走通剩下的边界情况我放到下一章专门讲坑。4. 避坑/常见问题/排查编码、路径与文件损坏的四个坑4.1 Windows 误打一个表情符号直接抛 UnicodeEncodeError现象在 Windows 的 cmd 或 PowerShell 里执行python cli.py add 明天上线添加成功但一运行python cli.py list终端立刻报错UnicodeEncodeError: gbk codec cant encode character \U0001f680原因Windows 控制台默认用系统本地代码页GBK/cp936解码输出流而任务文本里的 emoji 是四字节 Unicode 字符GBK 根本映射不了。写入 JSON 时因为有ensure_asciiFalse反而没炸炸在打印环节。解决我在cli.py的入口最前面加了一行防御代码if hasattr(sys.stdout, reconfigure): sys.stdout.reconfigure(encodingutf-8)sys.stdout.reconfigure是 Python 3.7 加入的能力能在不改环境变量的前提下把标准输出的编码强制切成 UTF-8。加完之后emoji 和任意 Unicode 字符都能正常打印。如果是在 CI 环境或者编辑器里跑还可以用环境变量PYTHONIOENCODINGutf-8兜底但代码里reconfigure一劳永逸不用每次记住设置环境变量。这个坑不踩一次真不知道我最初以为encode写得对就够了完全没想过输出流是另一套编码。4.2 在不同目录下运行任务清单“凭空消失”现象在/home/user/目录下执行python todo/cli.py list返回“没有待办任务”切到/home/user/todo/再跑任务又全回来了。原因早期版本里DATA_FILE用的是相对路径Path(tasks.json)而相对路径是相对于“当前工作目录”解析的。终端在哪个目录它就找哪个目录下的文件换一个目录就等于换了一堆任务。更隐蔽的版本是把文件路径写成了Path(__file__).parent / tasks.json这看似稳定但一旦你把cli.py复制到别处比如/usr/local/bin/数据位置跟着程序走同样会产生“这个机器上有数据、那个机器上没有”的错觉。解决我最终把数据路径固定为Path.home() / .todo / tasks.json— 用户主目录下的隐藏目录~/.todo与运行目录完全解耦。这样不管你在/tmp下临时测试还是在/var/log里配合脚本跑读出来始终是同一份数据。这是个很小的教训工具型程序数据位置必须独立于调用者所在目录否则迟早会误判成“数据丢了”。4.3 写入中途进程被 killtasks.json 只剩半个中括号现象系统突然断电或手滑 Ctrl-C重新打开后todo list报JSONDecodeError或者直接返回空列表。原因老版本save_tasks直接以w模式打开原文件再写Python 的open(w)会先截断原文件然后才开始写入新内容。这个窗口期里进程被杀、磁盘写满、系统断电原文件就已经只剩残缺内容了。恢复的唯一办法只剩备份对没做备份的人来说这就是一次性事故。解决把写盘改成“临时文件 原子替换”也就是第 3.2 节里写的os.replace(TMP_FILE, DATA_FILE)。注意要先写临时文件写完立刻os.replace两个步骤连在一起。os.replace保证要么新文件完全落盘要么旧文件保持原样不存在中间状态。配合load_tasks里的JSONDecodeError捕获即使损坏场景真出现了程序也会把坏文件挪成.bak至少保住半份数据供人工查看。这一步的成本只有几行但价值极高。4.4 同一份清单被两个终端同时改后保存的赢了现象终端 A 打开清单看到 3 条任务终端 B 同时打开也看到 3 条。A 添加一条变成 4 条并保存B 添加另一条也变成 4 条并保存最终文件里只剩 B 的那条新增A 的新增被整体覆盖。原因待办 CLI 的工作模式是“读整个文件 → 在内存里改动 → 写回整个文件”这种读 - 改 - 写不是原子操作。两个进程都在旧数据基础上修改后写回的人会把先前写回的内容整体冲掉。这个问题在个人使用场景下不常遇到但一旦你把命令挂进 cron 或自动化脚本里两个进程同时触发的概率立刻飙升。解决对纯本地单用户工具我一般会先接受这个限制不做复杂的文件锁因为加锁会让代码分支多出好几倍。但如果确实要解决给个方向参考Unix 下用fcntl.flock对.lock文件加锁Windows 下用msvcrt.locking锁住数据文件锁住后再读 - 改 - 写整个流程最后释放锁。更稳妥的替代方案是干脆把存储层换成 SQLite它有事务能力读改写天然安全。这个项目把“仅限本地、单人使用”写在标题里所以用 JSON 加原子替换已经是性价比最高的方案真到了多进程频繁读写那天迁移到 SQLite 也就两小时工作量。5. 进阶用法打包单文件、终端别名与自动化接入功能跑通只是开始下一步是把工具变成日常能随时召唤的东西。第一个动作是打包成单文件这样就不用每次带着源码目录跑还能拷到没有 Python 环境的机器上应急。常见做法是用 PyInstaller核心命令就一行pip install pyinstaller pyinstaller --onefile --name todo cli.py打包完的可执行文件在dist/todo下拷到/usr/local/bin这类路径后直接敲todo就能用。--name控制生成的可执行文件名--onefile把所有依赖塞进一个二进制文件注意 PyInstaller 不会自动包含你的数据文件但因为我们把数据放到了用户主目录打包后数据读写逻辑完全不受影响。终端别名也是提升日常体验的关键一步。在 shell 配置里加一行alias todopython ~/dev/todo/cli.py有了它添加一条待办就从笨重的python cli.py add ...变成了简单的todo add ...输入成本近乎归零。有人可能会问既然打包了为什么还要 alias——因为开发目录里改代码更方便alias 指向的是源码随时改完随时生效而打包后要重新构建。开发期用 alias稳定后打包发布两面兼顾。自动化接入也是 CLI 工具的拿手好戏。比如把“批量添加”写成一行printf 晚上跑步\n写周报\n整理周刊素材\n | while IFS read -r task; do todo add $task done这样从任何文本源文件重定向、grep结果、日志提取都能生成待办不需要手写 GUI 或数据库连接。验证整个工具是否健壮我还会写一个最简 pytest 用例from models import add_task, toggle_done def test_add_and_done(): tasks [] tid add_task(tasks, 测试任务, high) assert any(t[id] tid for t in tasks) assert toggle_done(tasks, tid) is True assert tasks[0][done] is True这类测试只验证状态逻辑不碰文件系统跑起来很快。从那以后我每次在 model 层改完字段或者调整状态翻转逻辑都会先强制走一遍这三个函数的单元测试再回到终端手动跑一次全流程。数据读写这种“跟持久化绑得太紧”的逻辑反而靠人工命令验证更直接。希望这篇拆解能帮你把这个本地待办 CLI 真正握在手里至少少走我当初踩过的弯路。本文还有配套的精品资源点击获取