☰
Python命令行待办清单:JSON存储与argparse实战
2026/10/17 7:48:09 网站建设 项目流程

简介:这是一份面向 Python 初学者的命令行待办事项练习资源,帮助学习者在本地环境中掌握 CLI 程序的增删改查与数据持久化。资源围绕 add_one_task、print_list、delete_task、save_todos、load_todos 五个核心函数展开,适合刚接触 Python 函数封装、文件读写与命令行交互的读者练手。压缩包共 7 个文件,约 295KB,包含 2 个 py 脚本(app.py 主程序与 test.py 测试入口)、1 个 json 配置、1 个 csv 数据文件、1 个 md 说明文档、1 个 gitignore 及 1 个 gif 演示动图,结构轻量、开箱即用。已有 355 人学习关注。通过该资源,读者可对照测试脚本验证实现是否正确,借助演示动图理解预期交互效果,并利用 csv 与 json 文件体会任务数据的保存与加载流程,从而完整走通一个本地待办清单小项目的开发闭环,为后续学习更复杂的命令行工具打下基础。

1. 命令行待办清单:为什么我最终把 GUI 和云端同步都砍了

上周三下午,我在终端里敲下todo add "把周报模板改完",回车,光标闪了一下,任务就进去了。整个过程不到两秒,没有窗口弹出,没有登录弹窗,没有同步转圈。这让我想起之前用过的那些待办应用——启动要三秒,同步要五秒,最后我干脆不记了。Exercise-todo-list-cli-python 就是冲着这个场景来的:一个纯 Python 写的本地待办清单,跑在命令行里,数据只落在你本机的一个 JSON 文件里。它不解决团队协作,不解决跨设备同步,也不解决提醒推送。它解决的是「我坐在电脑前,脑子里突然冒出一件事,想用最短路径把它记下来,并且待会儿还能找回来」。适合谁?适合每天大部分时间泡在终端里的开发者、运维、数据工程从业者,以及那些被 GUI 待办应用启动速度劝退过的人。如果你需要手机端提醒或者多人共享清单,这个资源不适合你,别浪费时间。

2. 拆开这个 CLI:JSON 存储、argparse 与命令分发

2.1 数据层为什么选 JSON 而不是 SQLite

这个项目最核心的决策是存储格式。很多 CLI 待办工具一上来就上 SQLite,理由是「结构化查询方便」。但在这个场景下,SQLite 是过度设计。待办清单的数据量通常不会超过几百条,查询需求无非是「列出全部」「按状态过滤」「按关键词搜」。JSON 文件用 Python 标准库json就能读写,零依赖,零迁移成本。你随时可以用cat看一眼数据长什么样,也可以用jq做临时统计。SQLite 当然也能做到,但你得装sqlite3命令行工具,或者写一段 Python 才能看数据。对于「本地、单人、小数据量」这三个约束同时成立的情况,JSON 是更轻的选择。

常见做法是给每条任务一个稳定的id,用递增整数或者短哈希。递增整数的问题是删除后 id 不连续,但这对本地工具来说无所谓。短哈希的好处是分布式生成不冲突,但本地单人场景用不上。我一般会选递增整数,因为肉眼可读,调试时方便。

数据文件的位置也有讲究。放在脚本同目录下,换目录运行就找不到数据了。放在用户主目录下的隐藏文件夹里,比如~/.exercise_todo/tasks.json,是更稳妥的做法。这样无论你在哪个目录下执行命令,数据都是同一份。代码里用pathlib.Path.home()拼接路径,跨平台兼容性也够用。

2.2 argparse 子命令设计与命令分发逻辑

命令行工具的用户体验,一半取决于参数解析。Python 标准库里的argparse支持子命令(subparsers),正好匹配待办清单的「add / list / done / delete」这套动作。下面是一个可复现的骨架代码,我把它拆成三段:参数定义、命令分发、数据读写。

# todo_cli.py import argparse import json from pathlib import Path from datetime import datetime DATA_DIR = Path.home() / ".exercise_todo" DATA_FILE = DATA_DIR / "tasks.json" def load_tasks(): """读取任务列表,文件不存在时返回空列表""" if not DATA_FILE.exists(): return [] with open(DATA_FILE, "r", encoding="utf-8") as f: return json.load(f) def save_tasks(tasks): """写入任务列表,确保目录存在""" DATA_DIR.mkdir(parents=True, exist_ok=True) with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(tasks, f, ensure_ascii=False, indent=2) def build_parser(): parser = argparse.ArgumentParser(prog="todo", description="本地待办清单") sub = parser.add_subparsers(dest="command", required=True) p_add = sub.add_parser("add", help="添加任务") p_add.add_argument("content", help="任务内容") p_add.add_argument("-p", "--priority", choices=["low", "mid", "high"], default="mid") p_list = sub.add_parser("list", help="列出任务") p_list.add_argument("--done", action="store_true", help="只显示已完成") p_list.add_argument("--pending", action="store_true", help="只显示未完成") p_done = sub.add_parser("done", help="标记完成") p_done.add_argument("task_id", type=int) p_del = sub.add_parser("delete", help="删除任务") p_del.add_argument("task_id", type=int) return parser

这段代码的关键点:required=True让用户不输子命令时直接报错,而不是静默进入某个默认行为。--priority用choices限制取值范围,避免脏数据。--done和--pending用store_true,因为它们是互斥的布尔开关,不需要传值。

接下来是命令分发部分。很多人会把逻辑直接写在if args.command == "add"分支里,但更好的做法是每个命令对应一个函数,分发层只做路由。这样单元测试好写,加新命令也不用改分发逻辑。

def cmd_add(args): tasks = load_tasks() new_id = max([t["id"] for t in tasks], default=0) + 1 tasks.append({ "id": new_id, "content": args.content, "priority": args.priority, "done": False, "created_at": datetime.now().isoformat(timespec="seconds") }) save_tasks(tasks) print(f"已添加 #{new_id}: {args.content}") def cmd_list(args): tasks = load_tasks() if args.done: tasks = [t for t in tasks if t["done"]] elif args.pending: tasks = [t for t in tasks if not t["done"]] if not tasks: print("没有匹配的任务") return for t in tasks: mark = "x" if t["done"] else " " print(f"[{mark}] #{t['id']} ({t['priority']}) {t['content']}") def cmd_done(args): tasks = load_tasks() for t in tasks: if t["id"] == args.task_id: t["done"] = True save_tasks(tasks) print(f"已完成 #{args.task_id}") return print(f"未找到 #{args.task_id}") def cmd_delete(args): tasks = load_tasks() before = len(tasks) tasks = [t for t in tasks if t["id"] != args.task_id] if len(tasks) == before: print(f"未找到 #{args.task_id}") return save_tasks(tasks) print(f"已删除 #{args.task_id}")

参数说明:max([t["id"] for t in tasks], default=0) + 1保证 id 递增且不重复,default=0处理空列表的情况。datetime.now().isoformat(timespec="seconds")生成可读的时间戳,精确到秒,避免毫秒带来的噪音。cmd_list里的过滤逻辑用elif而不是两个独立if,因为--done和--pending同时传时应该以第一个为准,而不是取交集。

最后是入口:

def main(): parser = build_parser() args = parser.parse_args() dispatch = { "add": cmd_add, "list": cmd_list, "done": cmd_done, "delete": cmd_delete, } dispatch[args.command](args) if __name__ == "__main__": main()

用字典做分发,比一长串if-elif更清晰,加命令时只改两处:parser 定义和 dispatch 字典。这个模式在小型 CLI 工具里足够用,不需要上click或typer这类第三方库。

2.3 安装与首次运行:从脚本到全局命令

写完脚本后,最直接的运行方式是python todo_cli.py add "测试任务"。但每次都敲python前缀太累。常见做法是加一个 shebang 并赋予执行权限:

chmod +x todo_cli.py ln -s "$(pwd)/todo_cli.py" /usr/local/bin/todo

第一行让脚本可以直接执行,第二行创建软链接,之后在任何目录下敲todo就能调用。注意 shebang 要写#!/usr/bin/env python3,而不是硬编码#!/usr/bin/python,因为不同系统上 Python 的路径不一样。如果你用 Windows,可以把这个脚本所在目录加到 PATH 里,或者写一个todo.bat包装一下。

首次运行todo add "第一条任务"后,检查~/.exercise_todo/tasks.json是否存在,内容是否包含id、content、priority、done、created_at五个字段。如果文件没生成,大概率是DATA_DIR.mkdir那一步没执行到,或者权限不足。用ls -la ~/.exercise_todo/看一眼目录权限。

3. 避坑与排查:五个让我返工过的细节

3.1 中文内容写入后变成乱码

现象:todo add "写周报"之后,todo list显示出来的中文是\u5199\u5468\u62a5这样的转义序列。

原因:json.dump默认ensure_ascii=True,会把所有非 ASCII 字符转义。虽然数据没丢,但肉眼不可读,用cat看文件时很难受。

解决:在json.dump里显式传ensure_ascii=False,并且文件打开时指定encoding="utf-8"。两个地方都要改,只改一个不够。改完之后,JSON 文件里直接存中文,cat和jq都能正常显示。

3.2 多终端同时写入导致数据覆盖

现象:开了两个终端窗口,一个在add,一个在done,后执行的那个把前一个的修改覆盖了。

原因:load_tasks和save_tasks之间没有锁。两个进程各自读到旧数据,各自写回,后写的赢。

解决:本地单人工具上文件锁有点重,但如果你确实会开多个终端,可以用fcntl.flock加一个排他锁。更简单的做法是养成习惯:同一时间只在一个终端里操作。如果非要加锁,在load_tasks打开文件后立刻fcntl.flock(f, fcntl.LOCK_EX),save_tasks写完再释放。Windows 上fcntl不可用,得换msvcrt.locking,跨平台成本不低。我的建议是:单人本地场景,接受这个限制,别过度工程。

3.3 删除任务后 id 出现空洞,误以为数据丢了

现象:删了 #3 之后,list显示 #1、#2、#4,用户以为 #3 的数据损坏了。

原因:id 是递增分配的,删除不会重排。这是设计如此,不是 bug。

解决:在list输出里不要暗示 id 连续。如果用户实在介意,可以在delete之后加一句提示「id 不会重排,属正常现象」。或者换一种 id 方案:用uuid4().hex[:8]生成短哈希,删除后也不会让人觉得「中间缺了什么」。但短哈希的代价是肉眼不可读,输入时要复制粘贴。权衡下来,递增整数加一句提示更实用。

3.4 任务内容带空格时被截断

现象:todo add 买 牛奶 和 面包只存进去了「买」。

原因:argparse的content参数默认只接收一个位置参数,空格后面的内容被当成额外参数,而 parser 没有定义它们,所以报错或截断。

解决:把content改成nargs="+",然后在函数里用" ".join(args.content)拼回完整字符串。或者要求用户加引号:todo add "买 牛奶 和 面包"。前者对用户更友好,后者实现更简单。我一般选前者,因为少一次引号输入就少一次摩擦。

3.5 脚本换目录后找不到数据文件

现象:在~/projects/todo/下运行正常,cd到~/tmp/再运行,list显示为空。

原因:数据文件路径用了相对路径,比如open("tasks.json"),导致数据文件跟着当前工作目录走。

解决:用Path.home() / ".exercise_todo" / "tasks.json"构造绝对路径。Path.home()在 Linux、macOS、Windows 上都能正确返回用户主目录。如果你想让数据文件可配置,加一个环境变量TODO_DATA_DIR,代码里用os.environ.get("TODO_DATA_DIR", str(Path.home() / ".exercise_todo"))读取。这样默认行为不变,有特殊需求的人可以覆盖。

4. 进阶:用 shell 补全和别名把输入成本压到最低

4.1 给 todo 加上 Tab 补全

argparse本身不提供 shell 补全,但你可以用argcomplete这个库,三行代码就能接上。安装pip install argcomplete,然后在main()里加:

import argcomplete # 在 parser.parse_args() 之前 argcomplete.autocomplete(parser)

接着在~/.bashrc里加eval "$(register-python-argcomplete todo)",重新打开终端后,敲todo <Tab>就能列出add、list、done、delete四个子命令。再敲todo done <Tab>,如果补全脚本写得够细,还能列出当前未完成任务的 id。不过argcomplete对动态补全的支持需要额外写 completer 函数,默认只补全静态选项。对于这个工具,补全子命令已经能省不少事。

4.2 用别名把高频操作缩到两个字母

我自己的习惯是在~/.bashrc里加这几行:

alias ta='todo add' alias tl='todo list --pending' alias td='todo done' alias tdel='todo delete'

这样ta "改周报"就是添加,tl就是看未完成,td 5就是完成 #5。别名不改变工具本身,但把高频路径的击键次数从七八次降到两三次。注意tdel不要缩成td,因为td已经被done占了,删除操作需要多一个字母来降低误触概率。

4.3 用 jq 做临时统计和导出

因为数据是纯 JSON,你可以用jq做很多工具本身没提供的查询。比如统计各优先级的任务数:

jq '[.[] | select(.done == false)] | group_by(.priority) | map({priority: .[0].priority, count: length})' ~/.exercise_todo/tasks.json

输出会是[{"priority":"high","count":2},{"priority":"mid","count":5}]这样的结构。再比如导出所有未完成任务的内容,每行一条:

jq -r '.[] | select(.done == false) | .content' ~/.exercise_todo/tasks.json

这些命令不需要改 Python 代码,直接在 shell 里组合。这也是选 JSON 而不是 SQLite 的一个隐性好处:整个 Unix 工具链都能直接操作你的数据。

4.4 验证数据完整性的一个笨办法

我每隔一段时间会跑一遍这个检查:用 Python 读一遍 JSON,确认每条记录都有id、content、done三个必需字段,且id不重复。

import json from pathlib import Path data = json.loads((Path.home() / ".exercise_todo" / "tasks.json").read_text(encoding="utf-8")) ids = [t["id"] for t in data] assert len(ids) == len(set(ids)), "存在重复 id" for t in data: for key in ("id", "content", "done"): assert key in t, f"#{t.get('id', '?')} 缺少字段 {key}" print(f"检查通过,共 {len(data)} 条任务")

这个脚本不修改数据,只读校验。如果你手动编辑过 JSON 文件,或者用其他工具写入过,跑一遍能提前发现结构问题。从那以后我每次手动改完数据文件,都强制走一遍这个校验,比等到todo list报 KeyError 再回头找要省事得多。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询