直接用一个例子开门见山:我拖了大半年的“整理博客分类”这件事,最后是被我自己写的一个命令行待办事项应用解决的。当时在终端里敲下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.py;cli.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( prog="todo", description="一个简单的命令行待办事项应用", ) subparsers = parser.add_subparsers(dest="command", required=True) # add 子命令 add_parser = subparsers.add_parser("add", help="添加新任务") add_parser.add_argument("title", help="任务标题") add_parser.add_argument("-p", "--priority", choices=["high", "medium", "low"], default="medium") add_parser.add_argument("-d", "--due", help="截止日期,格式 YYYY-MM-DD") # list 子命令 list_parser = subparsers.add_parser("list", help="列出任务") list_parser.add_argument("-a", "--all", action="store_true", help="显示已完成任务") list_parser.add_argument("-p", "--priority", help="按优先级筛选") # done 子命令 done_parser = subparsers.add_parser("done", help="标记任务为完成") done_parser.add_argument("task_id", type=int, help="任务ID") # rm 子命令 rm_parser = subparsers.add_parser("rm", help="删除任务") rm_parser.add_argument("task_id", type=int, 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())required=True很重要,用户只敲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", encoding="utf-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(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(tasks, f, ensure_ascii=False, indent=2) os.replace(tmp_path, path) except Exception: try: os.unlink(tmp_path) except OSError: pass raise损坏时备份成.json.bak这个细节,第一版没有,后来测试时模拟了一次“手残写入半截文件”,整个列表直接清空后才意识到它的必要性。写临时文件再原子替换,是为了防止在写入过程中程序被 Ctrl+C 中断导致主文件损坏。这两条看似不起眼,但确实是“能跑”和“稳定跑”的分水岭。
3.3 模型层:任务的增删改查逻辑
模型层把“任务列表”这个核心数据结构封装成TodoList类,外部只需要调用它的方法,不关心内部怎么排序和找索引。
from datetime import date, datetime class Task: def __init__(self, title, priority="medium", due=None, done=False, task_id=None, created_at=None): 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(timespec="seconds") 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( title=data.get("title", ""), priority=data.get("priority", "medium"), due=data.get("due"), done=data.get("done", False), task_id=data.get("id"), created_at=data.get("created_at"), ) class TodoList: def __init__(self, tasks=None): self.tasks = tasks if tasks else [] def add_task(self, title, priority="medium", due=None): next_id = max([t.id for t in self.tasks], default=0) + 1 task = Task(title, priority, due, task_id=next_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_done=False, priority=None): 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(key=lambda 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], default=0) + 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_done=args.all, priority=args.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 t="todo" alias ta="todo add" alias tl="todo list" alias tdone="todo done" alias trm="todo 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_done=False) self.assertEqual(len(tasks), 1) self.assertEqual(tasks[0].title, "做A") def test_due_sorting(self): self.tl.add_task("晚任务", due="2025-12-31") self.tl.add_task("早任务", due="2025-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 2>&1 | head -20 # 从备份恢复 cp ~/.todo.json.bak ~/.todo.json不过再稳的预防都不如一条操作习惯:定期手动备份主目录下这个文件。我的做法是写了个backup.sh,把它和数据目录一起扔进每天定时的备份任务里。
4.4 多终端同时操作导致覆盖
如果你像我喜欢开多个终端分屏工作,就会遇到“刚才在这个窗口添加的任务,去另一个窗口一看没了”的诡异情况。原因就在于两个进程各自读了自己启动时的旧数据,写的时候互相覆盖。
某些场景下可以引入文件锁来串行化操作,但对方便个人使用的工具而言,平添复杂度。我的实际方案是:约定任务操作尽量在一个终端完成,除非确需,否则不要多终端同时写。数据丢失风险远低于工程复杂度带来的管理负担。
4.5 中文乱码问题:别让你的中文任务变成天书
在 Windows CMD 里运行时,中文标题可能变成乱码。这通常不是程序的问题,而是终端编码和文件编码不匹配。解决方案是从代码侧json.dump时加ensure_ascii=False,写出真正的中文字符;同时把文件保存为 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 LANG=en_US.UTF-8就能解决。
4.7 找不到任务的排查思路
标记完成或删除时报“找不到任务”,常见原因有三个:
- ID 看错了,比如把日期当成了 ID(难免)
- 数据文件里确实没有这个 ID,可能是手动编辑过文件
- 路径不对——曾经用
--file指定过其他存储路径,现在又换回默认路径,读到的是另一份数据
排查时先执行todo list -a看看有哪些 ID,再对照找错。如果手动编辑过 JSON,可能 destructure 造成 ID 不连续,代码是用max+1生成新 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-send(Linux)或osascript(macOS)弹一个系统通知。
写在最后的小技巧
这个项目从想法到可用,大概用了两个晚上。比起功能本身,我更享受的是“按自己的需求掌控工具”的那种自由感——想加什么命令就加,遇到不合理的设计就改,代码就在手边,没有需求评审,没有排期。
如果你照着这个思路做了一个,最后再分享两个小技巧:一是把todo list的输出在.bashrc里做成一个名为t的函数,里面可以加日期着色;二是用git init把~/.todo.json纳入版本管理,每次提交相当于一个时间快照,出错能快速回滚。这个待办应用最大的魅力,不是“更高效”,而是“更懂你”。希望你也动手改一版适合自己的。