ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python标准库构建命令行待办事项应用:从设计到实战

用Python标准库构建命令行待办事项应用:从设计到实战 直接用一个例子开门见山我拖了大半年的“整理博客分类”这件事最后是被我自己写的一个命令行待办事项应用解决的。当时在终端里敲下todo add 整理博客分类 --due 2025-06-01几秒钟后它出现在列表里那种掌控感比任何花哨的效率软件都踏实。今天就把这个命令行待办事项应用Todo CLI的完整构建过程拆开讲一遍从需求设计、技术选型到核心功能实现、避坑心得一次性说完。它不依赖任何第三方库用 Python 标准库就能跑适合想动手做点实用工具的开发者、运维朋友也适合刚学 Python 想找个完整练手项目的同学。代码量不大但麻雀虽小五脏俱全文件存储、参数解析、异常处理、单元测试这些正经项目该有的东西它都有。1. 整体设计与思路拆解1.1 为什么非要做个命令行版待办手机上装过不少待办 App最后都卸载了。原因很现实打开 App 要解锁手机、找到图标、等启动页等真正把待办敲进去热情已经没了。我在电脑前的时间远比手机多工作流基本是终端 编辑器如果待办工具能直接活在终端里那么从“想到一件事”到“记录一件事”只需要几秒钟中间没有任何跳转成本。命令行应用的另一个天然优势是可脚本化。我可以把待办事项跟 shell 脚本、cron 定时任务结合起来比如每天早上 9 点自动在终端里展示当天的任务。这在图形界面 App 里几乎不可能轻松实现。再一个就是数据所有权——数据就是本地一个 JSON 文件结构完全透明随时能用 Python 脚本或jq命令分析想迁移到别的系统也毫无障碍。1.2 设计目标与功能范围动手之前先定了几个必须有的核心能力添加任务至少要有标题可选截止日期、优先级列出任务支持按完成状态过滤按优先级或截止日期排序标记完成任务做完了可以勾掉而不是直接删除删除任务某些任务确实不需要了可以彻底移除数据持久化任务数据保存到本地文件下次打开还在一开始也想加子任务、标签、提醒、番茄钟后来理智地砍掉了。待办工具最怕功能堆砌一旦操作变复杂人就不愿意用了。命令行应用尤其如此命令必须短、快、不容易出错。这版就做好核心四件事加、看、勾、删。1.3 技术选型的考量技术栈选了 Python 和标准库的argparse、json、datetime。理由很直接Python 几乎是所有平台自带的解释器不用额外装环境argparse处理命令行参数非常成熟十几行代码就能搞定复杂的子命令结构JSON 格式做数据存储人眼可读、机器解析方便后续扩展成 SQLite 也很容易这个选型思路适合多数个人工具项目优先选自己最熟、生态最稳、迁移成本最低的方案。如果团队统一用 Node写 TypeScript 版也完全可以核心逻辑和交互设计是一样的技术栈只是个壳。1.4 目录结构与模块划分工程结构不复杂但一开始就要把模块边界划清楚否则代码写几天就成一坨了todo/ ├── todo.py # 入口文件命令行解析与命令分发 ├── storage.py # 数据存储层读写JSON文件 ├── models.py # 数据模型层任务对象及相关操作 ├── cli.py # 命令处理层每个子命令的对应逻辑 └── tests/ └── test_todo.py # 单元测试todo.py很薄只负责接住用户输入并抛给cli.pycli.py调用models.py的操作函数models.py通过storage.py读写磁盘上的数据。分层的好处是以后想把 JSON 存储换成数据库只需要改storage.py模型层和 CLI 层完全不用动。2. 核心细节解析与实操要点2.1 数据模型怎么设计一条“任务”一条任务最核心的字段必须有id、title、done、created_at、priority、due。id我用自增整数实现简单且界面友好用户的认知负担小。created_at存 ISO 格式字符串排序时直接按字符串比较也能工作因为 ISO 格式的字典序就是时间顺序。优先级用枚举值表示high、medium、low。门槛不能设太高用户记住三档比记住“重要紧急四象限”容易多了。截止日期只存日期不存时间因为待办粒度是“天”而非“时刻”。这里我用了date.fromisoformat()来解析和校验用户输入格式不对时直接给清晰报错比在业务代码里层层判断要省心得多。2.2 存储设计JSON 文件踩的坑与选择用 JSON 文件做持久化第一版踩了两个坑。第一个是并发写——如果两个终端窗口同时操作后写的会把先写的整个覆盖掉导致数据丢失。解决方式是每次写入前先读一次最新内容合并后再写并且写的过程用tempfile改成“先写临时文件再原子替换”最大程度避免写一半崩溃导致文件损坏。第二个坑是增量写。最开始图省事每次加点东西就把整个列表 dump 一遍任务多了以后性能会越来越难受。但实测下来个人用户任务量撑死几百条JSON 文件也就几 KB全量重写根本感觉不到延迟。所以这个地方我的判断是优先保证代码简单和正确性能优化留到确实需要时再做。这就是所谓“过早优化是万恶之源”在个人项目里的真实体现。存储文件路径也做了处理默认放在用户主目录下的~/.todo.json而不是跟脚本放一起。因为脚本可能装在 /usr/local/bin当前目录可能是任意地方只有放主目录才能保证“无论在哪敲命令读到的都是同一份数据”。这个细节很多人都忽略导致换了个目录就“失忆”了。提示如果想让项目更健壮可以在写入前备份上一个版本的文件比如.todo.json.bak一旦 JSON 解析失败能自动恢复。这个小习惯在真正丢了数据时会救你一命。2.3 命令行交互设计不能让你每次敲一堆参数命令行交互的黄金法则是默认值友好。添加任务时如果不指定优先级默认就是medium不指定截止日期就是“没有截止日期”。这样用户大多数时候只需要敲一行简短命令。命令名称要短常用的就四个todo addtodo listtodo donetodo rm所有子命令都提供短选项-p表示优先级-d表示截止日期-a表示列出所有状态的任务。用argparse的子命令功能实现天然支持自动补全提示和帮助信息。这里特别提醒每个子命令都要写清晰的 help 文本因为命令行应用最忌讳用户猜。3. 实操过程与核心环节实现3.1 骨架先让命令能被“接住”先从最薄的入口开始建todo.py负责解析第一个参数并分发到具体函数。#!/usr/bin/env python3 Todo CLI - 一个简单的命令行待办事项应用 import argparse import sys from cli import handle_add, handle_list, handle_done, handle_delete def main(): parser argparse.ArgumentParser( progtodo, description一个简单的命令行待办事项应用, ) subparsers parser.add_subparsers(destcommand, requiredTrue) # add 子命令 add_parser subparsers.add_parser(add, help添加新任务) add_parser.add_argument(title, help任务标题) add_parser.add_argument(-p, --priority, choices[high, medium, low], defaultmedium) add_parser.add_argument(-d, --due, help截止日期格式 YYYY-MM-DD) # list 子命令 list_parser subparsers.add_parser(list, help列出任务) list_parser.add_argument(-a, --all, actionstore_true, help显示已完成任务) list_parser.add_argument(-p, --priority, help按优先级筛选) # done 子命令 done_parser subparsers.add_parser(done, help标记任务为完成) done_parser.add_argument(task_id, typeint, help任务ID) # rm 子命令 rm_parser subparsers.add_parser(rm, help删除任务) rm_parser.add_argument(task_id, typeint, help任务ID) args parser.parse_args() if args.command add: handle_add(args) elif args.command list: handle_list(args) elif args.command done: handle_done(args) elif args.command delete: handle_delete(args) if __name__ __main__: sys.exit(main())requiredTrue很重要用户只敲todo不带任何子命令时直接得到一个清晰的帮助提示而不是一个难以理解的报错。3.2 存储层读写 JSON 要稳、要安全存储层核心就读和写两个函数加上一个路径解析函数。import json import os import tempfile from pathlib import Path DEFAULT_FILE Path.home() / .todo.json def get_storage_path(): 返回存储文件路径默认 ~/.todo.json env_path os.environ.get(TODO_FILE) if env_path: return Path(env_path) return DEFAULT_FILE def load_tasks(): 从磁盘读取任务列表 path get_storage_path() if not path.exists(): return [] try: with open(path, r, encodingutf-8) as f: data json.load(f) return data if isinstance(data, list) else [] except (json.JSONDecodeError, OSError): # 备份损坏的文件返回空列表 backup path.with_suffix(.json.bak) try: path.replace(backup) except OSError: pass print(f警告: 任务文件损坏已备份到 {backup}) return [] def save_tasks(tasks): 将任务列表安全写入磁盘 path get_storage_path() path.parent.mkdir(parentsTrue, exist_okTrue) fd, tmp_path tempfile.mkstemp(dirpath.parent, suffix.tmp) try: with os.fdopen(fd, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) os.replace(tmp_path, path) except Exception: try: os.unlink(tmp_path) except OSError: pass raise损坏时备份成.json.bak这个细节第一版没有后来测试时模拟了一次“手残写入半截文件”整个列表直接清空后才意识到它的必要性。写临时文件再原子替换是为了防止在写入过程中程序被 CtrlC 中断导致主文件损坏。这两条看似不起眼但确实是“能跑”和“稳定跑”的分水岭。3.3 模型层任务的增删改查逻辑模型层把“任务列表”这个核心数据结构封装成TodoList类外部只需要调用它的方法不关心内部怎么排序和找索引。from datetime import date, datetime class Task: def __init__(self, title, prioritymedium, dueNone, doneFalse, task_idNone, created_atNone): self.id task_id self.title title self.priority priority if priority in (high, medium, low) else medium self.done done self.created_at created_at or datetime.now().isoformat(timespecseconds) self.due due # 格式: YYYY-MM-DD 或 None def to_dict(self): return { id: self.id, title: self.title, priority: self.priority, done: self.done, created_at: self.created_at, due: self.due, } classmethod def from_dict(cls, data): return cls( titledata.get(title, ), prioritydata.get(priority, medium), duedata.get(due), donedata.get(done, False), task_iddata.get(id), created_atdata.get(created_at), ) class TodoList: def __init__(self, tasksNone): self.tasks tasks if tasks else [] def add_task(self, title, prioritymedium, dueNone): next_id max([t.id for t in self.tasks], default0) 1 task Task(title, priority, due, task_idnext_id) self.tasks.append(task) return task def find_task(self, task_id): for task in self.tasks: if task.id task_id: return task return None def list_tasks(self, show_doneFalse, priorityNone): tasks [t for t in self.tasks if show_done or not t.done] if priority: tasks [t for t in tasks if t.priority priority] # 排序未完成在前优先级高在前截止日期早在前 priority_order {high: 0, medium: 1, low: 2} tasks.sort(keylambda t: ( t.done, priority_order.get(t.priority, 1), t.due or 9999-99-99, )) return tasks def mark_done(self, task_id): task self.find_task(task_id) if task is None: return False task.done True return True def delete_task(self, task_id): task self.find_task(task_id) if task is None: return False self.tasks.remove(task) return True自增 ID 的实现用了max([t.id for t in self.tasks], default0) 1。这里没做 ID 复用即使删掉第 3 条下一条新任务还是 5 而不是 3。这个选择是故意的避免用户的肌肉记忆出错——删除 3 后新建的任务如果叫 3很容易误操作。如果要支持 ID 复用逻辑会更复杂但好处并不明显。排序策略也是多次调整后的结果先保证未完成在前面然后按优先级排最后按截止日期排。没有截止日期的排到最后。这个顺序贴合日常使用场景打开一眼就能看到今天该干什么。3.4 CLI 层把用户的敲命令变成对模型的操作cli.py里的处理函数不能只调用一两个方法就完事还要负责输出格式和错误提示。from datetime import date, datetime from models import TodoList from storage import load_tasks, save_tasks def _get_todo_list(): return TodoList(load_tasks()) def _save(todo_list): save_tasks([t.to_dict() for t in todo_list.tasks]) def _validate_due(due_str): 校验截止日期格式不对就报错退出 try: return date.fromisoformat(due_str).isoformat() except ValueError: print(f截止日期格式不正确: {due_str}应为 YYYY-MM-DD) raise SystemExit(1) def handle_add(args): todo_list _get_todo_list() due _validate_due(args.due) if args.due else None task todo_list.add_task(args.title, args.priority, due) _save(todo_list) print(f已添加任务 #{task.id}: {task.title}) def handle_list(args): todo_list _get_todo_list() tasks todo_list.list_tasks(show_doneargs.all, priorityargs.priority) if not tasks: print(暂无任务干点正事吧) return now date.today() for task in tasks: status [x] if task.done else [ ] flag if task.due: due_date date.fromisoformat(task.due) days_left (due_date - now).days if task.done: flag elif days_left 0: flag (已逾期) elif days_left 0: flag (今天截止) elif days_left 3: flag f (剩{days_left}天) print(f{status} #{task.id:3} {task.title} 优先级:{task.priority} 截止:{task.due or 无}{flag}) def handle_done(args): todo_list _get_todo_list() if todo_list.mark_done(args.task_id): _save(todo_list) print(f任务 #{args.task_id} 已完成干得漂亮) else: print(f找不到任务 #{args.task_id}) def handle_delete(args): todo_list _get_todo_list() if todo_list.delete_task(args.task_id): _save(todo_list) print(f任务 #{args.task_id} 已删除) else: print(f找不到任务 #{args.task_id})_validate_due在校验失败时直接SystemExit(1)让程序带着错误码退出这在脚本环境中非常关键——后续可以方便地在 bash 里用if todo add ...判断命令是否成功。列表输出的日期判断我给加上了“今天截止”“已逾期”“剩几天”的人性化提示。成本很低但对用户价值极高——很多时候你打开待办列表只需要知道一件事“哪个今天必须弄完”。输出列对齐用f#{task.id:3}让不同位数的 ID 不会错位。3.5 体验优化shell 别名与壁纸级的效率提升命令本身敲起来已经不长但我还是习惯加上 shell 别名进一步缩短alias ttodo alias tatodo add alias tltodo list alias tdonetodo done alias trmtodo rm这样实际使用变成ta 给项目写文档 -p high -d 2025-06-20 tl tdone 3这还不算完。配合cron可以实现早上自动列出当天的任务0 9 * * * echo 今日待办 todo list输出会出现在 shell 的启动信息里。当然这依赖你打开终端的时间刚好在 9 点一个更实用的方案是加到 shell 的登录提示里# 在 ~/.bashrc 或 ~/.zshrc 中 echo 今日待办 todo list每次打开一个新终端窗口自动看到当前还欠着哪些债。这个体验一旦习惯就回不去了。3.6 测试不能只靠“我觉得没问题”用unittest写了几个核心用例确保后续改动不会悄悄破坏已有功能。import unittest from datetime import date, timedelta from models import TodoList class TestTodoList(unittest.TestCase): def setUp(self): self.tl TodoList() def test_add_task_generates_increasing_id(self): t1 self.tl.add_task(第一个任务) t2 self.tl.add_task(第二个任务) self.assertEqual(t1.id, 1) self.assertEqual(t2.id, 2) def test_mark_done_returns_false_for_missing_task(self): self.assertFalse(self.tl.mark_done(999)) def test_list_filters_finished_tasks(self): self.tl.add_task(做A) t2 self.tl.add_task(做B) self.tl.mark_done(t2.id) tasks self.tl.list_tasks(show_doneFalse) self.assertEqual(len(tasks), 1) self.assertEqual(tasks[0].title, 做A) def test_due_sorting(self): self.tl.add_task(晚任务, due2025-12-31) self.tl.add_task(早任务, due2025-01-01) tasks self.tl.list_tasks() self.assertEqual(tasks[0].title, 早任务) self.assertEqual(tasks[1].title, 晚任务) if __name__ __main__: unittest.main()测试要点不是覆盖所有函数的海量用例而是用最少用例抓住最容易出错的逻辑ID 生成、任务查找、完成状态过滤、排序。将来扩展功能时跑一遍测试可以立刻发现哪些旧行为被意外破坏了。4. 常见问题与排查技巧实录4.1 命令行参数含空格怎么办最常见的坑是任务标题里有空格用户直接敲todo add 写博客 总结结果变成添加两个任务“写博客”和“总结”。解决方案是必须用引号todo add 写博客 总结argparse在解析时把引号里的内容当作一个整体传入代码侧不需要任何额外处理。我体会到最好在帮助文本里显式加一句“多词标题请用引号括起来”能少很多咨询。提示Windows CMD 的用户注意了CMD 里的引号传递规则和 bash 不同有时需要\转义。实测在 PowerShell 里用单引号更省心。4.2 “命令行过长”错误的隐藏陷阱网上经常看到“命令行过长”这样的报错比如“123运行 startapplication 时出错。命令行过长”。这通常不是待办工具自身的问题而是 Windows 系统对命令行参数长度有上限默认 8191 字符。如果任务标题非常长或者批量传入大量参数就会触发这个限制。解决思路有三个一是任务标题别超过 200 字二是批量操作改用文件方式输入三是在 Windows 上尽量避免一次性拼接大量内容。命令行工具的设计原则就是短小精悍真正需要记录长篇内容时应该用笔记软件而不是硬塞给待办工具。4.3 JSON 文件损坏最不想遇到但必须预防的问题试过几次在写入进行到一半时直接关闭终端或系统崩溃。下次运行程序时JSON 解析失败所有任务“凭空消失”。这是让用户崩溃的灾难场景。存储层代码里的except json.JSONDecodeError分支会先把损坏文件备份为.todo.json.bak再返回空列表至少保证程序能正常启动。恢复方法也很简单# 手动检查损坏的文件 python -m json.tool ~/.todo.json 21 | head -20 # 从备份恢复 cp ~/.todo.json.bak ~/.todo.json不过再稳的预防都不如一条操作习惯定期手动备份主目录下这个文件。我的做法是写了个backup.sh把它和数据目录一起扔进每天定时的备份任务里。4.4 多终端同时操作导致覆盖如果你像我喜欢开多个终端分屏工作就会遇到“刚才在这个窗口添加的任务去另一个窗口一看没了”的诡异情况。原因就在于两个进程各自读了自己启动时的旧数据写的时候互相覆盖。某些场景下可以引入文件锁来串行化操作但对方便个人使用的工具而言平添复杂度。我的实际方案是约定任务操作尽量在一个终端完成除非确需否则不要多终端同时写。数据丢失风险远低于工程复杂度带来的管理负担。4.5 中文乱码问题别让你的中文任务变成天书在 Windows CMD 里运行时中文标题可能变成乱码。这通常不是程序的问题而是终端编码和文件编码不匹配。解决方案是从代码侧json.dump时加ensure_asciiFalse写出真正的中文字符同时把文件保存为 UTF-8 编码。.py文件第一行加# -*- coding: utf-8 -*-是老旧写法Python 3 默认源码就是 UTF-8不是根因。真正要检查的是终端代码页。在 Windows 上运行chcp 65001这条命令把 CMD 切到 UTF-8 代码页中文就不会乱码了。虽然现在 Windows Terminal 已经默认支持 UTF-8但老 CMD 用户还是会遇到。4.6 命令行编码不一致的排查思路如果你看到“命令行输入javac乱码”这类问题很可能不是todo工具的锅而是环境变量JAVA_TOOL_OPTIONS或系统的file.encoding设置有问题。排查思路分三步先确认文件本身编码再确认终端编码最后确认是否有环境变量从中捣乱。# 查看文件编码 file -bi ~/.todo.json # 查看系统语言环境 echo $LANG多数情况是LANG没设成 UTF-8 相关值在.bashrc里加一行export LANGen_US.UTF-8就能解决。4.7 找不到任务的排查思路标记完成或删除时报“找不到任务”常见原因有三个ID 看错了比如把日期当成了 ID难免数据文件里确实没有这个 ID可能是手动编辑过文件路径不对——曾经用--file指定过其他存储路径现在又换回默认路径读到的是另一份数据排查时先执行todo list -a看看有哪些 ID再对照找错。如果手动编辑过 JSON可能 destructure 造成 ID 不连续代码是用max1生成新 ID 的最后一条如果被删了新任务会接在更小的 ID 后面但列表里绝对找不到“更大但已删”的 ID。这种情况不用慌说明逻辑没问题只是你对 ID 的记忆过期了。5. 进阶扩展思路5.1 按期归档已完成任务任务列表会越来越长长到失去参考价值。一个实用的扩展是添加todo archive命令把已完成任务移动到一个独立的~/.todo-archive.json。这样列表保持精简数据也不丢。# 归档扩展的伪代码 def handle_archive(args): todo_list _get_todo_list() done_tasks [t for t in todo_list.tasks if t.done] todo_list.tasks [t for t in todo_list.tasks if not t.done] # 把 done_tasks 追加到归档文件 save_archive(done_tasks) _save(todo_list) print(f已归档 {len(done_tasks)} 个任务)5.2 提供 JSON 导出方便接其他工具数据只有能流出才能产生更多价值。可以加一个todo export命令把所有任务导出成指定格式比如 CSV 或 JSON。这样就能把待办事项丢进 Excel 做周报统计或者接入爬虫系统自动生成日报。5.3 定时提醒的轻量方案不引入任何第三方依赖的前提下配合系统自带的at或cron即可实现简单的提醒能力。想做得“实时”一些我现在的方案是写了个后台常驻脚本每 5 分钟扫一次任务列表发现今天截止还没完成的任务就用notify-sendLinux或osascriptmacOS弹一个系统通知。写在最后的小技巧这个项目从想法到可用大概用了两个晚上。比起功能本身我更享受的是“按自己的需求掌控工具”的那种自由感——想加什么命令就加遇到不合理的设计就改代码就在手边没有需求评审没有排期。如果你照着这个思路做了一个最后再分享两个小技巧一是把todo list的输出在.bashrc里做成一个名为t的函数里面可以加日期着色二是用git init把~/.todo.json纳入版本管理每次提交相当于一个时间快照出错能快速回滚。这个待办应用最大的魅力不是“更高效”而是“更懂你”。希望你也动手改一版适合自己的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进