上周末我又一次打开手机里的待办软件时,突然意识到自己已经被它绑架了:启动要等加载,添加任务要点好几个按钮,跨设备同步偶尔还要闹脾气。可我就想要一个三秒能记录、文件能直接备份、在命令行里随时能调的待办清单。纠结了半天,决定自己写一个。这个项目最终成为了一个只有几百行的 Python 命令行应用,核心命令包括 add、list、done、undone、rm、clear,数据默认存在用户目录下的 JSON 文件里,支持优先级、标签、截止日期和完成时间。今天这篇文章,我就把从需求拆解、技术选型、编码实现、测试到打包发布的过程完整写出来。适合那些常年在终端里工作、又不想被各种待办软件绑架的人参考。
1. 不是闲得慌:一个命令行待办应用到底解决什么问题
先别急着动手写代码。我见过太多个人项目,上来就安装框架、设计模块,最后因为需求不清烂尾。命令行工具也一样,动手前最应该做的是把“我到底需要它干什么”写清楚。
1.1 GUI 待办软件让我越用越别扭的四个点
第一个别扭是数据不透明。我用过的多数待办应用,数据都存在厂商的服务器或者私有目录里。想导出来?要么不支持,要么绕好几圈。对我来说,待办内容是高度私人化的,放在一个我能直接读到的文件里才有安全感。
第二个别扭是交互链路太长。添加一个任务,用鼠标点完往往要好几步;而命令行里一条todo add "给植物浇水"就够了。这个差距看起来不大,但每天重复十几次之后,累计的时间成本很可观。
第三个别扭是没办法脚本化。我偶尔需要把一批任务从文本文件导入,GUI 应用做这种事非常痛苦;命令行天然支持管道、循环,能轻松嵌入到工作流里。比如我可以用一个 for 循环一次性把十几条任务加进去,这在图形界面里几乎做不到。
第四个别扭是使用场景单一。手机上的待办再漂亮,到了服务器上、SSH 会话里就彻底没用;而一个 CLI 工具在任何有 Python 的机器上都能跑。我平时需要在一台没有浏览器的老旧机器上查任务,命令行待办几乎是唯一合理的选择。
1.2 需求清单与主动舍弃的功能
基于以上痛点,我的 MVP 需求定了六个动作:添加任务,列出任务,标记完成,重新打开,删除单个任务,清空已完成。每个任务需要包含标题、优先级、标签、截止日期、创建时间和完成时间。
我没有做弹窗提醒,是因为个人任务如果还要靠软件催,那已经说明任务管理本身出问题了;也没有做云同步,一个 JSON 文件配合同步盘已经够我用了;更不做项目管理、看板、燃尽图这些功能——它们是另一个量级的工具,塞进个人 CLI 里只会增加维护成本。功能永远是可以后加的,但一开始就铺开,很容易把事情搞复杂。
1.3 从“能用”到“顺手”的三条原则
需求定了,后面所有设计都围绕三条原则:
第一,命令要短。能少敲一个字母就少敲一个字母,所以子命令取名是 add、list、done,而不是 create、display、finish。日常用下来,短命令的好处比想象中明显。
第二,输出要可读。列表默认把未完成任务排前面,高优先级排前面,一眼能看到今天该干什么。如果任务多了,至少要保证每一行的结构一致,方便用眼睛快速扫描。
第三,错误要能接受。比如 delete 一个不存在的 id,不能直接把堆栈抛出来,而是尽量给一句人话。命令行工具是给人用的,不是给调试器看的。
这三条看起来简单,但实际写代码时很容易跑偏。我提醒自己:任何一个功能如果会拖慢日常操作,就值得重新思考。
2. 技术选型与数据格式:Python、argparse、JSON 的三角关系
2.1 Python 标准库为什么够用
有人可能会问:为什么不用 Go、Rust,或者干脆用 Shell 写?我承认追求启动速度和单文件分发的话,Go 更合适;但个人工具的开发效率更重要。Python 3.8 以上自带的 argparse、json、pathlib、tempfile 足以覆盖这个项目的全部需求,不需要 import 任何第三方库。这意味着你在任何一台装上 Python 的机器上 clone 下来就能直接跑,不用考虑依赖地狱。
另外一个原因是 JSON 处理太顺手了,字典读出来就是一个任务对象,数据层几乎不需要额外代码。启动时加载几十 KB 的 JSON,耗时在几十毫秒级别,对个人使用完全不是瓶颈。如果你真的在意启动性能,等到用户量大了再说,个人工具不需要提前优化到毫秒级。
2.2 为什么数据存储先选 JSON 而不是 SQLite
很多读者第一反应是用 SQLite。它确实是个好选择,但我故意先把 JSON 用起来。原因有三:一是数据文件可以直接 cat 出来看,做版本管理时 diff 也能看懂;二是任务结构简单,根本不需要 SQL 语句来处理;三是对于单机单用户、单次写入量几十 KB 的场景,JSON 的性能完全不是瓶颈。
真正的风险是并发写入和文件损坏,这些我会用代码层防护。当然,我并没有把路堵死:只要 TodoStore 暴露的方法不变,以后数据量上来或者需要按条件复杂查询时,把内部实现换成 SQLite 是顺手的事。SQLite 虽然也自带 sqlite3 标准库,但建表、事务、迁移这些都需要额外代码,对于一个 MVP 来说有点重了。
2.3 项目结构与分层思路
todo-cli/ ├── pyproject.toml ├── README.md └── todo_cli/ ├── __init__.py ├── store.py └── cli.pystore.py 负责所有对数据文件的操作:加载、保存、生成 id、查找任务等;cli.py 只负责把用户输入的命令变成对 store 的调用,再打印结果。分层的目的很简单:cli 层最不稳定,随时可能改参数格式;store 层相对稳定,测试和复用时都靠它。如果你以后想加一个交互式 TUI 界面,cli 层可以整个换掉,store 层完全不用动。
3. 核心命令逐行实现:从 add 到 clear 的完整编码
3.1 任务对象设计
先定义一条任务长什么样。我选择用 JSON 对象而非数组,这样每条任务有名字字段,后续扩展不会破坏旧数据。下面是一个实际存储的任务示例:
{ "id": 1, "title": "写完待办应用", "done": false, "priority": "高", "tags": ["写作", "工具"], "due_date": "2024-06-30", "created_at": "2024-06-25 10:30:00", "completed_at": null }id 是整数,从 1 开始递增;done 表示完成状态;priority 只允许高、中、低三种;tags 是字符串数组;due_date 只存日期不存时分秒,因为待办看的是一天,不是某个分钟;created_at 保存创建时刻,completed_at 在完成时写入、取消完成时清空,这两个时间用来统计每天完成多少任务很好用。
3.2 store.py:把数据库和业务逻辑放在一层
store 层我写成一个 TodoStore 类。初始化时传入文件路径,核心方法是 load 和 save。为了避免新建任务时 id 冲突,我维护了 next_id 字段:即使删掉 id 为 5 的任务,next_id 也不会回退,所以不会出现新任务突然沿用旧 id 导致列表显示混乱。实现如下:
import json import os import sys import tempfile from datetime import datetime from pathlib import Path class TodoStore: def __init__(self, path): self.path = Path(path) def load(self): if not self.path.exists(): return [], 1 try: data = json.loads(self.path.read_text(encoding="utf-8")) except json.JSONDecodeError: backup = self.path.with_name( self.path.name + ".corrupt-" + datetime.now().strftime("%Y%m%d%H%M%S") ) self.path.replace(backup) print(f"检测到损坏文件,已备份为 {backup}", file=sys.stderr) return [], 1 tasks = data.get("tasks", []) next_id = data.get("next_id", max((t["id"] for t in tasks), default=0) + 1) for t in tasks: t.setdefault("done", False) t.setdefault("priority", "中") t.setdefault("tags", []) t.setdefault("due_date", None) t.setdefault("created_at", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) t.setdefault("completed_at", None) return tasks, next_id def save(self, tasks, next_id): self.path.parent.mkdir(parents=True, exist_ok=True) payload = {"next_id": next_id, "tasks": tasks} fd, tmp_name = tempfile.mkstemp( dir=self.path.parent, prefix=".todo-", suffix=".tmp" ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) f.flush() os.fsync(f.fileno()) os.replace(tmp_name, self.path) except Exception: if os.path.exists(tmp_name): os.unlink(tmp_name) raise这段代码比普通教程多了两件事:一是 mkstemp 创建临时文件,二是 os.replace 原子替换。我在这里先埋个伏笔,后面专门展开。load 里还做了字段兼容:如果旧版本数据没有 tags 或 due_date,setdefault 会补默认值,避免用户升级后直接程序崩溃。
3.3 cli.py:用 argparse 把用户输入翻译成命令
cli 层用 argparse 的子命令实现。没有用第三方库,一方面是想保持零依赖,另一方面 argparse 在标准库里表现已经够好,只是写法上稍微啰嗦一点。顶部需要导入 datetime,因为 add 和 done 命令要写时间戳。核心代码分为 parser 配置和命令函数两块。
import argparse import os import sys from datetime import datetime from pathlib import Path from todo_cli.store import TodoStore def get_store(args=None): path = os.environ.get("TODO_FILE", str(Path.home() / ".todo.json")) if args is not None and getattr(args, "file", None): path = args.file return TodoStore(path) def build_parser(): parser = argparse.ArgumentParser(prog="todo", description="一个简单的命令行待办事项工具") parser.add_argument("-f", "--file", help="指定存储文件路径,默认 ~/.todo.json") sub = parser.add_subparsers(dest="command", required=True) p_add = sub.add_parser("add", help="添加任务") p_add.add_argument("title", help="任务标题") p_add.add_argument("-p", "--priority", choices=["高", "中", "低"], default="中") p_add.add_argument("-t", "--tags", nargs="*", default=[], help="标签,空格分隔") p_add.add_argument("-d", "--due", default=None, help="截止日期,如 2024-12-31") p_list = sub.add_parser("list", help="查看任务") p_list.add_argument("--done", choices=["yes", "no"], help="只显示已完成或未完成") p_done = sub.add_parser("done", help="完成任务") p_done.add_argument("id", type=int) p_undone = sub.add_parser("undone", help="重新打开任务") p_undone.add_argument("id", type=int) p_rm = sub.add_parser("rm", help="删除任务") p_rm.add_argument("id", type=int) sub.add_parser("clear", help="清除已完成任务") return parser然后是命令函数和 main 入口。注意每个函数第一步都是store.load(),拿到当前任务列表和 next_id,操作完再store.save()。这样所有命令都遵守同一个读写模型,不容易出现数据不一致。
PRIORITY_ORDER = {"高": 0, "中": 1, "低": 2} def cmd_add(store, args): tasks, next_id = store.load() tasks.append({ "id": next_id, "title": args.title, "done": False, "priority": args.priority, "tags": args.tags, "due_date": args.due, "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_at": None, }) store.save(tasks, next_id + 1) print(f"已添加任务 #{next_id}: {args.title}") def cmd_list(store, args): tasks, _ = store.load() if args.done == "yes": tasks = [t for t in tasks if t["done"]] elif args.done == "no": tasks = [t for t in tasks if not t["done"]] tasks.sort( key=lambda t: ( t["done"], PRIORITY_ORDER.get(t["priority"], 3), t["created_at"], ) ) if not tasks: print("没有任务,先享受一下清净吧") return for t in tasks: box = "[x]" if t["done"] else "[ ]" due = f" 截止:{t['due_date']}" if t.get("due_date") else "" tags = " #" + " #".join(t.get("tags") or []) if t.get("tags") else "" print(f"{box} {t['id']:>3} {t['title']}{due}{tags}") def set_done(store, id_, done): tasks, next_id = store.load() for t in tasks: if t["id"] == id_: t["done"] = done t["completed_at"] = ( datetime.now().strftime("%Y-%m-%d %H:%M:%S") if done else None ) store.save(tasks, next_id) print("完成" if done else "已重新打开") return raise SystemExit(f"找不到任务 {id_}") def cmd_rm(store, id_): tasks, next_id = store.load() new_tasks = [t for t in tasks if t["id"] != id_] if len(new_tasks) == len(tasks): raise SystemExit(f"找不到任务 {id_}") store.save(new_tasks, next_id) print(f"已删除任务 {id_}") def cmd_clear(store): tasks, next_id = store.load() left = [t for t in tasks if not t["done"]] store.save(left, next_id) print(f"已清除 {len(tasks) - len(left)} 个已完成任务") def main(): parser = build_parser() args = parser.parse_args() store = get_store(args) if args.command == "add": cmd_add(store, args) elif args.command == "list": cmd_list(store, args) elif args.command == "done": set_done(store, args.id, True) elif args.command == "undone": set_done(store, args.id, False) elif args.command == "rm": cmd_rm(store, args.id) elif args.command == "clear": cmd_clear(store) if __name__ == "__main__": main()3.4 输出怎么排才好看
列表输出我坚持三件事:完成状态用[x]和[ ]表示,一眼可扫;ID 固定宽度右对齐,多任务时列表不会乱;优先级不直接打印“高/中/低”,而是靠排序位置体现。标签用 # 号前缀,复制回命令行时不至于混淆。
如果一条任务超过一屏,我不会在 CLI 里做分页,直接让终端滚动,省得引入交互逻辑。日常使用中,任务列表通常不会超过二十条,真到了几十条,更应该做的是清理已完成项,而不是优化显示。
4. 数据会不会被写坏:原子写入与损坏恢复
4.1 直接写文件的危险场景
第一次写 TodoStore.save 时,我最朴素的实现是:
with open(self.path, "w", encoding="utf-8") as f: json.dump(payload, f)看上去没问题。直到有次我在添加任务时不小心按了 Ctrl+C,彼时进程刚好已经截断原文件、还没写完新内容。再打开文件,只有一个残缺的 JSON 结尾,整个清单变成了一堆expecting property name报错。那一刻我才意识到,直接打开文件写入的模型在“中断”面前有多脆弱。
这个问题的本质是:open(path, "w")会先清空原文件,再写入新内容。写入过程一旦被中断,原文件已经没了,新文件又不完整,数据就找不回来了。所以个人工具也要认真对待持久化,不能因为数据量小就忽视。
4.2 原子写入的正确姿势
标准做法是三步:先在目标目录里创建一个临时文件,把完整内容写进去,调用 flush 把用户态缓冲刷到内核,再 fsync 把内核缓冲刷到磁盘,最后用 os.replace 覆盖目标文件。
为什么临时文件必须和目标文件在同一目录?因为 os.replace 只有在同一文件系统内才是原子操作;不同目录可能涉及跨设备复制,就不能保证覆盖的原子性。mkstemp 会在指定目录生成一个唯一名字的文件,正好满足这个条件。
我在 save 方法里已经实现了这个流程。你可能会觉得每次保存都做 fsync 有点慢,但对于一个个人 CLI 工具来说,多出的几毫秒完全无感,换取的是数据文件不容易损坏,非常值。
4.3 读取容错与字段兼容
读文件时同样要考虑异常。如果 JSON 解析失败,最简单的处理是备份损坏文件而不是直接删除,避免用户事后想抢救数据却找不到。我会把损坏文件改名为.corrupt-时间戳,然后打印一条提示到标准错误,再以空任务列表继续运行。
字段兼容也是一开始就要想的:未来加字段时,旧文件没有新字段,程序不能一读就 KeyError。setdefault 在这里很管用,它为每条任务补齐缺省值。比如旧版本的任务没有 due_date,load 之后会自动补上 None,不会影响 list 命令。
4.4 一起来模拟一次文件损坏
我做了个简单实验:把一个正常生成的 todo.json 末尾的}删掉,然后运行todo list。程序先打印一条检测到损坏文件的消息,然后给出空列表;再看目录发现多了一个.corrupt-20240625...文件,原文件变成空任务。
这个策略当然不是万能的:如果文件被改成了语法合法但内容错误的 JSON,例如 tasks 字段被改成字符串,程序仍会成功加载,但后续操作会出现异常。所以要尽量在日常操作中依赖原子写入防止损坏,而不是依赖事后恢复。备份文件本身也不应该一直堆积,我通常每周清理一次。
5. 测试不是可选项:pytest 与 CLI 隔离
5.1 为什么优先测存储层而不是整条 CLI
命令行应用的测试有个尴尬:如果每次都通过 subprocess 调命令去测,断言输出、退出码、环境变量会很啰嗦,而且 CLI 的参数一旦调整,测试也得跟着改。所以我选择把几乎全部逻辑放在 TodoStore 里,CLI 只是一层很薄的翻译层。
这样一来,pytest 可以直接调用 store 的方法,既快又稳。真正需要手工验证的,只有 argparse 的参数解析和最终的用户体验,这部分本来就该靠人来做。自动化测试覆盖存储层和核心业务逻辑,性价比最高。
5.2 用 TODO_FILE 环境变量做测试隔离
测试数据绝对不能落到真实的 ~/.todo.json,否则跑一次测试就把本地清单污染了。我在 get_store 里设计了 TODO_FILE 环境变量作为最高优先级覆盖项,测试里用 monkeypatch.setenv 指向临时目录下的文件。每个测试跑完,临时目录会被自动清理,互不干扰。
下面是两个典型的测试用例,分别验证添加任务和标记完成。通过tmp_path创建临时目录,再通过monkeypatch设置环境变量,整个过程不碰真实用户数据。
import pytest from todo_cli.store import TodoStore def _make_task(id_): return { "id": id_, "title": "测试任务", "done": False, "priority": "中", "tags": [], "due_date": None, "created_at": "2024-01-01 00:00:00", "completed_at": None, } def test_add_and_load(tmp_path, monkeypatch): data = tmp_path / "todo.json" monkeypatch.setenv("TODO_FILE", str(data)) store = TodoStore(str(data)) tasks, next_id = store.load() assert tasks == [] assert next_id == 1 store.save([_make_task(1)], 2) loaded, next_id = store.load() assert len(loaded) == 1 assert loaded[0]["title"] == "测试任务" assert next_id == 2 def test_done_toggles_state(tmp_path, monkeypatch): data = tmp_path / "todo.json" monkeypatch.setenv("TODO_FILE", str(data)) store = TodoStore(str(data)) store.save([_make_task(1)], 2) state, _ = store.load() state[0]["done"] = True state[0]["completed_at"] = "2024-01-02 00:00:00" store.save(state, 2) new_state, _ = store.load() assert new_state[0]["done"] is True assert new_state[0]["completed_at"] == "2024-01-02 00:00:00"5.3 关键测试用例
除了上面两个,我还会测删除、清空和损坏恢复。删除用例检查删除后列表长度是否减少,清空用例检查未完成任务是否保留,损坏恢复用例会手动写入一串非法 JSON,再确认 load 不会抛异常且会生成备份文件。
测试代码本身并不复杂,关键是把环境变量隔离做好。如果有一天你发现测试把真实清单改乱了,不用怀疑,一定是某个测试没设置 TODO_FILE。这种问题一旦出现,修复起来很简单,但会浪费不少时间。
5.4 踩过的两个坑
第一个坑,测试中用了 pytest 的tmp_pathfixture,但如果测试函数里忘了把TODO_FILE指向临时文件,程序就会去读写真实用户目录。我一开始就吃过亏,后来在conftest.py里写了一个自动 fixture,所有测试默认指向临时目录。
第二个坑,argparse设置subparsers为 required=True 后,在不传命令时不同 Python 版本的行为略有差异。有些版本会直接报错退出,有些版本则解析出 command=None,需要你在 main 里判断。为了稳妥,我的 build_parser 明确传了 required=True,让不传命令时直接报错,而不是程序悄悄往下跑。
6. 从“我这个项目能跑”到“别人也能用”:打包与体验打磨
6.1 pyproject.toml 与 console_scripts 入口
要让 todo 变成全局命令,最规范的方式是声明 console_scripts 入口。这样无论用 pip、pipx 还是直接源码安装,都会自动生成可执行脚本。配置文件如下:
[build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] name = "todo-cli" version = "0.1.0" description = "一个轻量的命令行待办事项工具" readme = "README.md" requires-python = ">=3.8" [project.scripts] todo = "todo_cli.cli:main" [tool.setuptools.packages.find] include = ["todo_cli*"]然后在项目根目录执行pip install -e .,就能用todo命令了。为什么不用 Shell alias?因为 alias 只能针对固定路径,不能跨 shell、不能随着虚拟环境切换;entry point 是标准机制,装好之后就是一个真正的可执行命令。
6.2 日常使用中的三个小技巧
我实际用下来的三个小技巧:
第一,在 shell 配置里写alias t='todo',经常用的命令能短则短。加了 alias 之后,t add "买牛奶"和t ls用起来很舒服,值得为它多配一行。
第二,利用环境变量区分上下文。工作清单和个人清单分开存放,TODO_FILE=~/work.todo.json t list,互不干扰。甚至在处理不同项目时,可以用不同的存储文件,像小型数据库一样给每个上下文开一个“库”。
第三,不要急着加颜色。先用纯文本跑一段时间,你会发现输出简洁反而更快;真需要高亮时,可以在打印时判断是否连接终端,再决定是否加 ANSI 码。颜色只是锦上添花,不是核心体验。
6.3 后续扩展的边界在哪里
目前我最想加的能力是自然语言日期解析,比如todo add "开会" --due 明天;其次是按标签过滤和导出 Markdown。但我也提醒自己,每加一个功能都会增加学习和维护成本。个人工具最好的状态是:核心命令足够顺手,扩展功能用插件方式慢慢加,不追求一次性做到位。
我现在最常用的场景是:早上打开终端,输入t list --done no看今天的安排;写代码到一半想起事情,直接t add记下来;晚上t done 3收工。数据文件躺在我的同步目录里,即使换台电脑也能无缝接上。这个周末你也可以照着文中的思路,用你熟悉的技术栈写一个属于自己的命令行待办事项工具。动手之后你会发现,最重要的不是代码本身,而是“我到底需要什么”这个答案。