
命令行待办事项应用听起来不像什么了不得的东西但真正动手写一个并且坚持用下来之后你会发现它比大多数花里胡哨的待办软件都靠谱。我最早用那些GUI待办工具换台电脑就要重新登录、重新配置想要导出数据还得折腾格式后来索性自己写了一个基于命令行的待办事项应用放在自己的代码仓库里新机器克隆下来直接跑。关键数据就是一个JSON文件备份、迁移、版本管理都方便。这篇文章就把这个项目的完整思路拆一遍从需求拆解到技术选型再到具体实现和踩坑记录。1. 动手之前需求拆解与方案选型1.1 为什么我不建议一上来就写代码很多人拿到做一个待办事项应用的需求第一反应是打开编辑器写界面、加按钮。但如果目标是自己用、长期维护、跨平台图形界面反而是最重的选择。命令行界面在场景上天然占优轻量不依赖图形库一个脚本文件就够了启动时间不会超过200毫秒。可脚本化我可以把mytodo list --status done放到计划任务里每天自动导出完成记录再配合shell管道做统计。可组合输出是纯文本可以用grep、awk、sort等工具二次处理。图形界面做不到这种复用。适合远程场景无论登录到哪台服务器只要装了Python就能用不需要图形桌面环境。把需求想清楚再动手可以避免后续返工。我这里的需求定义其实是四个字快进快出也就是从打开终端到记录一条任务花费的时间不能超过两秒。围绕这个核心指标我把GUI方案直接否定掉选择了命令行。开发一个命令行待办事项应用要做的技术事并不少参数解析、数据存储、状态管理、输出格式化、错误处理。每一样都是编程基本功所以这个项目非常适合作为练习项目也是给自己打造趁手工具的好起点。不要因为命令行三个字就觉得它简陋真正好用的命令行工具在交互设计上同样是花了很多心思的。1.2 核心功能清单我先把功能分成必须和可选两档。必须功能功能命令说明添加任务todo add 写季度总结记录标题同时可指定优先级和截止日期查看任务todo list默认显示未完成任务支持按状态和优先级过滤标记完成todo done 3通过任务ID标记完成删除任务todo delete 3删除某条任务清空已完成todo clear一键清空所有已完成任务帮助todo --help输出使用说明可选功能编辑标题、修改截止日期、搜索、统计、归档、导入导出。我在第一版里只实现了必须功能后面根据实际使用慢慢加。这是一个很重要的原则功能范围一开始不要铺太大否则项目会迟迟无法收尾。清单确定后我需要确定命令格式。参考Unix工具的习惯我采用子命令式而非flag式。也就是todo add 任务而不是todo --add 任务。子命令的好处是扩展性强后续加todo stats、todo sync不会让参数解析变得复杂。1.3 技术选型为什么是Python确定需求后我对技术选型做了一个简单对比。可选语言包括Python、Go、Rust、Node.js。作为一个个人工具维护成本和跨平台能力是关键。Python标准库自带argparse和json不需要任何第三方依赖跨平台支持极佳语法直观适合快速迭代。Go编译后为单个二进制分发方便启动极快但标准库没有专门用于CLI的高级交互框架代码量会略大。Rust性能极好但学习曲线陡开发效率略低。Node.js生态强但需要运行时对于简单工具来说偏重。最终我选择了Python。原因很简单手里有Python 3.8以上的环境就能直接跑不需要虚拟环境、不需要pip安装依赖。数据存储用JSON文件不用SQLite因为待办事项数据量通常就是几十几百条JSON文件足够承载而且可读性好方便调试和备份。如果后续数据量真的大到JSON文件读写吃力可以无损迁移到SQLite那时再动手也来得及这是先用简单方案做到有需要再升级的思路。命令行工具的核心是解决问题不是炫耀技术。2. 核心实现细节数据模型与存储设计2.1 任务数据的字段与状态流转数据模型是整个应用的基石。我定义的任务字段如下字段类型说明idint自增IDtitlestr任务标题statusstrtodo / in_progress / doneprioritystrhigh / medium / lowcreated_atstr创建时间ISO 8601格式due_datestr截止日期YYYY-MM-DD可为空completed_atstr完成时间可为空用Python的dataclass描述from dataclasses import dataclass, field, asdict from datetime import datetime dataclass class Task: id: int title: str status: str todo priority: str medium created_at: str field(default_factorylambda: datetime.now().isoformat(timespecseconds)) due_date: str None completed_at: str None状态为什么分三档而不是只有待办/已完成因为实际使用中经常会出现开始做了但没做完的场景比如一个任务周五做到一半下周要接着做。所以保留一个in_progress状态列表里可以单独看。状态流转路径为todo - in_progress - done todo - done 任意状态 - deleted我允许todo直接跳到done因为有些任务创建时其实已经做完了比如顺手记录一个成果。状态流转不强制搞复杂的有限状态机保证简单直接。2.2 JSON持久化与原子写入存储位置我定为~/.mytodo/tasks.json。为什么会放在用户目录而不是程序目录因为用户目录对当前用户始终可写且不会因为程序被安装到只读目录而出错。同时这个位置也方便备份直接复制文件即可。加载和保存的核心代码如下import json import os DEFAULT_PATH os.path.expanduser(~/.mytodo/tasks.json) class TodoStore: def __init__(self, pathDEFAULT_PATH): self.path path self.data self._load() def _load(self): if not os.path.exists(self.path): return {version: 1, next_id: 1, tasks: []} try: with open(self.path, r, encodingutf-8) as f: data json.load(f) except json.JSONDecodeError: # 文件损坏时尝试恢复备份 backup_path self.path .bak if os.path.exists(backup_path): with open(backup_path, r, encodingutf-8) as f: data json.load(f) else: data {version: 1, next_id: 1, tasks: []} # 保证必备字段存在避免旧数据导致崩溃 data.setdefault(version, 1) data.setdefault(next_id, 1) data.setdefault(tasks, []) return data def save(self): os.makedirs(os.path.dirname(self.path), exist_okTrue) # 先将当前文件备份再原子替换 if os.path.exists(self.path): with open(self.path, r, encodingutf-8) as src, \ open(self.path .bak, w, encodingutf-8) as dst: dst.write(src.read()) tmp_path self.path .tmp with open(tmp_path, w, encodingutf-8) as f: json.dump(self.data, f, ensure_asciiFalse, indent2) os.replace(tmp_path, self.path)保存时我做了两件事先把当前文件复制为.bak备份再写入临时文件并通过os.replace原子替换。原因是最讨厌的故障是写到一半程序崩了JSON文件变成半个花括号这会让整个任务数据都打不开。而原子替换保证了任何时刻目标文件要么是旧版本要么是新版本不会出现半写成状态。备份文件则提供了最后一道防线即使新文件损坏旧备份还在。2.3 时间处理统一ISO格式时间是一个容易出幺蛾子的地方。我统一使用ISO 8601格式字符串存储datetime.now().isoformat(timespecseconds)这样既保留时区偏移也方便比较排序。截止日期单独用YYYY-MM-DD格式因为它是日期语义不需要时分秒。解析用户输入的截止日期时用datetime.strptime(date_str, %Y-%m-%d)并捕获ValueError给出友好提示。实际使用中用户很容易输入2024/1/1或2024-1-1这种格式所以我在解析时做了几个常用格式的容错from datetime import datetime def parse_due_date(text): for fmt in (%Y-%m-%d, %Y/%m/%d, %Y.%m.%d): try: return datetime.strptime(text, fmt).date().isoformat() except ValueError: continue raise ValueError(无法识别的日期格式请使用 YYYY-MM-DD)日期解析常常被忽略但却是用户最容易抱怨的地方。给足格式容错能明显提升工具的使用体验。3. 实操过程从零搭建命令行待办应用3.1 项目结构与初始化我先讲一下项目结构。作为个人工具我建议不要一开始就把代码拆得特别散。我第一版是单文件todo.py全部逻辑约200行。随着功能增加单文件变得难维护于是拆成mytodo/ ├── todo.py # 命令行入口参数解析和分发 ├── models.py # Task 数据模型 ├── store.py # TodoStore 存储层 ├── commands.py # 各子命令的实现 └── tests/ └── test_todo.py # 单元测试不过这篇分享为了演示方便我会使用单文件的写法来展示核心逻辑。读者在自己复刻时如果只是自己用单文件完全够如果考虑后续扩展建议照上面的目录拆。初始化这一步很简单新建目录然后建虚拟环境不需要。因为不依赖外部包直接建文件就能跑。如果之后要加颜色输出可以考虑安装colorama但第一版我坚持零依赖。3.2 命令解析与参数设计命令解析使用argparse。这里有一个设计心机我把每个子命令的函数用set_defaults(funcxxx)绑定解析完成后直接args.func(args)调用避免写一长串if/else分发。import argparse def build_parser(): parser argparse.ArgumentParser( progtodo, description一个简单的命令行待办事项应用, epilog示例todo add 写周报 -p high -d 2024-06-30 ) sub parser.add_subparsers(destcommand, metavarcommand) # add add_p sub.add_parser(add, help添加任务) add_p.add_argument(title, help任务标题) add_p.add_argument(-p, --priority, choices[high, medium, low], defaultmedium, help优先级) add_p.add_argument(-d, --due, help截止日期格式 YYYY-MM-DD) add_p.set_defaults(funccmd_add) # list list_p sub.add_parser(list, aliases[ls], help查看任务) list_p.add_argument(-s, --status, choices[todo, in_progress, done], help按状态过滤) list_p.add_argument(-p, --priority, choices[high, medium, low], help按优先级过滤) list_p.set_defaults(funccmd_list) # done done_p sub.add_parser(done, help标记完成) done_p.add_argument(task_id, typeint) done_p.set_defaults(funccmd_done) # delete delete_p sub.add_parser(delete, aliases[rm], help删除任务) delete_p.add_argument(task_id, typeint) delete_p.set_defaults(funccmd_delete) # clear clear_p sub.add_parser(clear, help清空已完成任务) clear_p.set_defaults(funccmd_clear) return parser参数名称上我尽量贴近Unix习惯短选项-p、-d同时提供长选项。列表命令给了一个ls别名这能让经常用终端的人更快上手。注意别名不能和已有命令冲突否则argparse会报错。3.3 核心功能代码实现主入口很简单import sys def main(argvNone): parser build_parser() args parser.parse_args(argv) if args.command is None: parser.print_help() return 1 # 注入存储对象 store TodoStore() args.store store try: args.func(args) except ValueError as e: print(f错误: {e}, filesys.stderr) return 1 return 0 if __name__ __main__: raise SystemExit(main())重点是所有子命令函数的第一个参数是args通过args.store访问存储层。这样做的原因是可以方便在测试中替换一个临时存储对象执行命令。添加任务的实现def cmd_add(args): task Task( idargs.store.data[next_id], titleargs.title.strip(), priorityargs.priority, due_dateparse_due_date(args.due) if args.due else None, ) args.store.data[next_id] 1 args.store.data[tasks].append(asdict(task)) args.store.save() print(f已添加任务 #{task.id}: {task.title})列表的实现def cmd_list(args): tasks [] for item in args.store.data[tasks]: task Task(**item) if args.status and task.status ! args.status: continue if args.priority and task.priority ! args.priority: continue tasks.append(task) # 排序未完成优先按优先级、截止日期 priority_rank {high: 0, medium: 1, low: 2} status_rank {todo: 0, in_progress: 0, done: 1} tasks.sort(keylambda t: ( status_rank[t.status], priority_rank.get(t.priority, 2), t.due_date or 9999-12-31 )) if not tasks: print(没有任务) return # 输出每行 for t in tasks: status_icon [x] if t.status done else ([~] if t.status in_progress else [ ]) due f (到期 {t.due_date}) if t.due_date else print(f{t.id:3} {status_icon} {t.title}{due} [{t.priority}])这里有一个排序的细节t.due_date or 9999-12-31的意思是没有截止日期的任务排到最后不会干扰按截止日期排列的任务。输出行首任务ID右对齐方便眼睛快速锁定ID。标记完成和删除的代码逻辑类似def cmd_done(args): task find_task(args.store, args.task_id) if task is None: raise ValueError(f任务 #{args.task_id} 不存在) task[status] done task[completed_at] datetime.now().isoformat(timespecseconds) args.store.save() print(f完成: {task[title]}) def cmd_delete(args): tasks args.store.data[tasks] task find_task(args.store, args.task_id) if task is None: raise ValueError(f任务 #{args.task_id} 不存在) tasks.remove(task) args.store.save() print(f已删除: {task[title]})find_task可以直接遍历列表比较id因为数据量小def find_task(store, task_id): for item in store.data[tasks]: if item[id] task_id: return item return None3.4 数据校验与错误处理命令行工具的错误提示必须讲人话。我在三个地方做了校验标题不能为空。add时如果用户只传空白字符直接报错。日期格式错误时用友好提示代替Python traceback。任务ID不存在时明确提示任务 #12 不存在而不是让程序自然崩溃。实现方式是在main里统一捕获ValueError。这样业务代码里只需要raise ValueError(...)入口处集中转换成错误: ...输出到stderr退出码返回1。这条规范非常重要因为任何命令行工具都应该遵循成功返回0失败返回非0的约定这样在shell脚本里才能用退出码判断运行结果。另外我也把KeyboardInterrupt处理了一下用户按CtrlC中断程序时不打印堆栈只安静退出。if __name__ __main__: try: raise SystemExit(main()) except KeyboardInterrupt: print(已取消) raise SystemExit(130)4. 常见问题与排查技巧实录4.1 argparse的隐藏坑argparse用起来方便但有几个点容易踩。第一个坑子命令的参数放在哪里。比如todo list --status done是子命令list的参数而todo --status done list则会被当成主parser的未知参数报错。所以使用时要记住--status这类子命令参数必须放在子命令单词后面。第二个坑默认值到底是None还是指定的值。如果给参数设置了defaultNone那么无法区分用户没传和用户显式传了None这在某些场景下会出问题。要判断是否传参应该使用argparse.SUPPRESS或用requiredFalse default特殊哨兵值。第三个坑parse_args遇到非法参数会直接调用parser.error()内部抛SystemExit(2)如果集成到其他程序里使用解析函数可能被这个SystemExit打断。解决方式是捕获SystemExit或改用parse_known_args()去忽略部分未知参数。比如args, remaining parser.parse_known_args()如果我们的工具要支持把多余参数透传给内部脚本用parse_known_args就非常有用了。虽然待办应用用不到但这是一个通用经验。4.2 数据文件并发与损坏命令行工具通常不会并发执行但我在实际使用中遇到过两个终端同时操作导致数据丢失的情况。原因是两个进程同时读取旧JSON文件各自修改后写入后写者覆盖先写者的数据。简单的解决思路有两个保存前重新读取一次最新文件合并后再写回。加文件锁同一时刻只允许一个进程操作。文件锁在Linux下可以用fcntlWindows下用msvcrt但跨平台代码会稍微复杂。个人工具我建议采用保存前重新加载的策略加一个简单锁文件import os import fcntl def acquire_lock(): lock_path os.path.expanduser(~/.mytodo/.lock) f open(lock_path, w) try: fcntl.flock(f, fcntl.LOCK_EX) except OSError: pass return f不过代码里是否真的需要加锁还是要看场景。如果只是个人使用通常不需要这么复杂。我把这个列为进阶议题但至少要知道有这个问题。如果数据文件损坏了刚才提到的.bak备份就会派上用场。4.3 中文对齐问题命令行输出最尴尬的问题之一就是中文对齐。因为我用f{t.id:3}和固定宽度格式化但print在计算宽度时按英文宽度算中文会被认为一个字符宽度实际终端里却占两列。结果就是标题中文字符数量不成对时列表对不齐。解决方法是写一个display_width函数import unicodedata def display_width(text): width 0 for ch in text: if unicodedata.east_asian_width(ch) in (W, F): width 2 else: width 1 return width def pad_text(text, total_width): return text * max(0, total_width - display_width(text))这是很多CLI工具都会遇到的问题提前处理能让输出格式看起来更专业。当然如果你追求简单也可以直接在输出里隐藏ID对不齐的小问题反正数据量小。4.4 颜色输出与重定向兼容给待办应用加彩色输出确实好看比如高优先级任务显示红色完成的任务显示绿色。但我强烈建议颜色只应该在终端支持时开启否则输出被重定向到文件或管道时会混入一堆\x1b[31m转义序列非常难看。做法是检测sys.stdout.isatty()import sys def colorize(text, color_code): if not sys.stdout.isatty(): return text return f\033[{color_code}m{text}\033[0mWindows的旧版cmd不支持ANSI颜色Python 3.6之后在新版Win10终端上基本可用但为了兼容老环境可以考虑引入colorama。不过为了保持零依赖我还是用isatty检测加ANSI码够用。如果你觉得这些细节不重要那至少记住脚本解析优先颜色随缘这个原则。5. 进阶扩展方向与我的实操心得5.1 从能用到好用的五个增强功能基础版做完之后我根据自己的使用习惯陆续加了几个功能这里按性价比排序搜索功能。使用todo search 关键词内部遍历标题做子串匹配支持不区分大小写。很简单但很实用。编辑功能。todo edit 3 --title 新标题避免删除后重建导致的ID变化。统计视图。todo stats输出总数、未完成数、已完成数、按优先级分布。我在每周复盘时会用一下。自然语言日期。todo add 写稿 -d 明天用dateparser这样的库解析会更方便但为了零依赖我自己写了一个简单映射支持今天明天下周X这类短语。归档功能。把完成时间超过30天的任务自动归档到archive.json保持主列表干净。这五个功能的实现逻辑都不复杂和之前的代码模式完全一致核心还是读取store修改data保存。每加一个功能我都会补一个测试保证旧功能不被破坏。5.2 我踩过的坑和沉淀的经验这个项目写了至少三版最大的收获不是会写argparse了而是对命令行工具设计有了更深的体感。分享几条经验第一命令的默认行为要安全。比如todo clear默认只清已完成任务而不是清空全部宁可多一步todo clear --all也不能让用户误删除不可恢复的数据。第二数据格式要预留版本号。我的JSON里有一个version: 1字段将来需要迁移到新结构时可以通过版本号写不同的迁移逻辑。没有版本号的配置文件升级时只能靠猜。第三测试一定不能省。命令行应用虽然小但涉及日期解析、状态转换、文件读写逻辑分支很多。不写测试的话改一个排序规则可能就悄悄破坏了别的地方。我给每个子命令都写了黑盒测试直接调用main([...])然后检查返回值和输出内容。第四不要为了高级而引入复杂依赖。这个项目我坚持零第三方依赖意味着在任何一台有Python的机器上都能跑。这保证了工具的通用性。等到某个需求真的需要依赖库时再引入也不迟。最后再分享一个小技巧如果你也想做一个命令行待办事项应用不必追求一步到位。先花一个晚上把最核心的add、list、done三个命令跑通然后立刻放进日常使用里感受哪里不顺手再去改。我所有后续版本的功能几乎都是从真实使用中产生的需求而不是一开始设计出来的。工具最后好不好用取决于你愿意在它身上花多少真实使用时间而不是写了多少行代码。