ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用 Python 打造终端命令行待办事项工具:从 argparse 到 JSON 持久化

用 Python 打造终端命令行待办事项工具:从 argparse 到 JSON 持久化 用了不下十款待办软件从手机里的滴答清单、Trello到桌面端的 Notion、微软待办没一个能坚持超过两周。原因说起来很别扭打开待办应用需要先解锁手机、找到图标、点进去视窗再弹出来这种摩擦足够让一个临时想法在三十秒内消失得干干净净。后来我把需求收敛成一句话——“能不能在终端里用一条命令记下事情”于是就有了这个基于命令行的待办事项应用。它就是一个跑在终端里的待办清单支持添加任务、列出任务、标记完成、删除任务、清空已完成项数据保存在本机一份 JSON 文件里。没有登录、没有云同步、没有弹窗提醒但正因为足够轻它反而成了我每天使用频率最高的效率工具之一。这篇文章适合这样的人日常会打开终端敲命令的开发者想在 shell 里顺手管理任务而不是频繁切窗口的用户以及刚接触 Python 或 argparse 想拿小项目练手的初学者。我会先讲清楚为什么我不选择图形界面、为什么用 Python 而不用纯 shell 或 Node然后给出完整的命令设计、数据模型和可直接复制的代码最后把我在实际使用中踩过的问题整理成排查清单。整个项目大小不超过三百行依赖只有 Python 标准库跨平台可用Bash、Zsh、PowerShell 都能跑。1. 项目整体设计与思路拆解1.1 为什么偏偏要做命令行待办而不是更好的图形界面这里的关键词不是“待办”而是“命令行”。我观察过自己记录任务的真实场景写代码时想到一个要修的 bug改完一个文件后发现还需要补充测试用例刚准备继续工作QQ 消息弹了出来。这段时间里我真正需要的是一瞬间把念头固化下来而不是打开一个需要登录、等待加载、还可能弹更新提示的图形应用。终端恰好具备这种随手可得的特性光标已经在里面直接敲todo add 给订单模块补充超时重试回车完事。整个过程不到两秒而且完全不需要离开当前工作环境。另一个让命令行方案胜出的理由是它天然适合脚本化和批量操作。图形待办应用的核心交互是鼠标点击但命令行待办应用的核心交互是参数和管道。我可以给任务加优先级可以一次性清理所有已完成任务可以统计还有多少未完成事项甚至可以在提交代码之前先跑一句todo list --done-only看看今天的进度。这种能力不是我从一开始就规划好的而是在使用过程中自然冒出来的需求。命令行工具的魅力就在于此它暴露给你的是可以被组合的基础能力而不是锁死在界面里的按钮。当然我也不是说图形版待办一无是处。它擅长展示日历、生成报告、跨设备同步这些是纯文本界面很难做好的。但如果你和我一样大多数任务是在电脑前产生、在终端里处理的命令行方案反而是摩擦最小的一条路径。这个判断的依据是我过去两个月实测下来的数据换成命令行待办之后我对任务的记录频率从每天两三条提高到了十几次差距非常明显。1.2 技术选型Python argparse而不是纯 shell 或 Node确定了要做命令行工具之后下一个问题是用什么实现。我先试过用纯 Bash 写思路是维护一个文本文件每行一条任务。写 add 和 list 还算顺利到了需要查找编号、修改某一行、翻转完成状态的时候文本处理就变得很难看。sed替换依赖正则遇到包含特殊字符的任务文本会直接翻车用数组和循环也能写但代码可读性差得离谱遇到跨平台的 GBK 编码问题更是一筹莫展。所以纯 shell 方案被我快速否定了它适合做二三十行的胶水脚本不适合做结构清晰的小应用。第二个候选是 Node.js用commander库写命令行体验很好npm 生态里也有现成的待办库。但它的一个问题是要装运行时和依赖哪怕用 pkg 打包成单文件在用户的机器上分发也不如一个自带解释器的脚本省事。我的目标是用最简单、最朴素的工程方式解决问题最好任何一台装了 Python 3 的机器都能直接运行。Python 标准库里的argparse负责参数解析json负责数据序列化pathlib负责跨平台路径处理所有东西开箱即用不需要写requirements.txt也不需要npm install。下面是几个主要候选方案的实际对比。我列出来不是为了争论技术栈高低而是记录我当时的真实取舍逻辑。方案参数解析文本持久化跨平台依赖成本可维护性纯 Bash很弱靠手工解析差处理换行和转义麻烦一般Windows 体验差无差Node.js commander好好好需要运行时和依赖中Python argparse好好好Python 3 自带好Go cobra好好好需要编译链好但偏重我最终选了 Python。还有一个不那么技术、但对我非常实际的原因Python 的datetime和json模块处理时间戳和中文非常方便写自定义格式化函数也比在命令行里拼字符串轻松得多。整个应用写完后是一个.py文件我直接把它复制到任意机器的~/bin目录就能用不需要任何额外初始化。对于个人效率工具来说这种“零安装、零配置、零依赖”的感觉比性能更重要。2. 核心功能与数据模型设计2.1 命令设计精简成五个动词加一个统计命令命令行工具的设计核心在于把常用操作收敛成一套简单、好记、无歧义的动词。我没有照搬 GitHub 或者 Git 的多级子命令风格因为待办应用的功能面足够窄单层子命令是最直接的做法。最终定下来的命令集是这些todo add 任务内容 # 添加任务 todo list # 查看所有任务 todo done 3 # 将编号 3 的任务标记为完成 todo delete 3 # 删除编号 3 的任务 todo clear # 清空所有已完成任务 todo count # 展示未完成任务数量这个设计的思路是动词全部对应“动作”名词编号作为参数。为什么不用update、modify这类更通用的词因为我实际使用下来发现待办场景里高频任务就那么几类用一个语义精准的done比通用的update更符合直觉。每次执行完操作后程序都会打印一行反馈比如done: #3 给订单模块补充超时重试这样用户能够立即确认操作结果而不是像很多 Unix 工具那样静默成功。静默在处理脚本时是优点但在交互式命令行里缺乏反馈会让用户产生“这条命令到底执行了没有”的疑虑。任务文本和状态之前还有一个细节我故意让add命令的任务内容参数不带名字前缀直接就是位置参数。这意味着用户可以写todo add 修订 README而不是todo add --text 修订 README。少打一个选项名看起来是小优化但在每天要敲几十次命令行工具的时候省下的每一个字符都在降低使用成本。相比之下优先级这种低频参数我选择了-p短选项默认值是normal只有需要时才会显式指定。2.2 数据存储一份 JSON 文件解决所有问题任务数据存在哪里是这类小工具最需要想清楚的地方。方案无非三种内存、文本文件、数据库。内存显然不行程序一退出数据就没了数据库对于个人待办场景又太重还得处理初始化、连接、SQL 语句。最终我选了本机文件存储具体路径是用户主目录下的.todos.json。选择主目录而不是项目目录是因为待办任务是个人数据不应该跟某个具体项目绑定。无论我在哪个目录下敲命令读写的位置都是同一个。每一条任务的结构我定义成了这样{ id: 3, text: 给订单模块补充超时重试, priority: high, created_at: 2025-12-11T14:32:05, completed_at: null }字段设计基于几个朴素的判断id是整个应用的核心索引所有增删改都围绕它展开所以我用了全局自增整数简单且便于人工记忆text存任务内容priority支持low、normal、high三档created_at记录任务创建时间在列表排序和回溯时有价值completed_at在未完成时是null一旦标记完成就写入时间戳这样列表展示、完成记录筛选取都非常清晰。使用null而不是用 0 或者空字符串是 JSON 语义上最能表达“这个任务还不存在完成时间”的方式。存储文件还承担了一个隐蔽的功能它是整个程序的“单数据源”。所有子命令先load_tasks()读取全部任务操作完再save_tasks()整体写回。对于一个任务量在几百条以内的待办工具这种全量读写的性能完全不是问题但换来的是代码逻辑极度简单——不存在索引、缓存、连接池这些概念。我见过一些同类的小工具动不动引入 sqlite事实上它的优势只有在任务量特别大、查询条件特别复杂时才会体现我这个场景里反而增加无谓复杂度。3. 从零到能用的关键实现3.1 命令行骨架先搭好argparse 子命令的写法整个程序的入口我用argparse的add_subparsers实现。可能有人会问参数解析这种小事手动sys.argv判断不就行了对于单命令工具确实可以但一旦命令数量超过五个手工解析就会陷入边界条件的泥潭。比如todo done 3和todo delete 3都要接收一个整数todo list还要可选支持--done-only这些组合判断写起来很容易出错。argparse 把参数校验、错误提示、--help文档生成都做了我只需要把注意力集中在业务逻辑上。主程序骨架大概是这样的#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import json import sys from datetime import datetime from pathlib import Path TODO_FILE Path.home() / .todos.json def load_tasks(): if not TODO_FILE.exists(): return [] with open(TODO_FILE, encodingutf-8) as f: return json.load(f) def save_tasks(tasks): TODO_FILE.parent.mkdir(parentsTrue, exist_okTrue) tmp TODO_FILE.with_suffix(.tmp) with open(tmp, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) tmp.replace(TODO_FILE) def main(): parser argparse.ArgumentParser(progtodo, description一个简单的命令行待办事项应用) sub parser.add_subparsers(destcommand, requiredTrue) p_add sub.add_parser(add, help添加任务) p_add.add_argument(text, help任务内容) p_add.add_argument(-p, --priority, choices[low, normal, high], defaultnormal) p_add.set_defaults(funccmd_add) p_list sub.add_parser(list, help列出任务) p_list.add_argument(--done-only, actionstore_true) p_list.set_defaults(funccmd_list) p_done sub.add_parser(done, help标记任务为完成) p_done.add_argument(id, typeint) p_done.set_defaults(funccmd_done) p_delete sub.add_parser(delete, help删除任务) p_delete.add_argument(id, typeint) p_delete.set_defaults(funccmd_delete) p_clear sub.add_parser(clear, help清空已完成任务) p_clear.set_defaults(funccmd_clear) p_count sub.add_parser(count, help统计未完成任务数量) p_count.set_defaults(funccmd_count) args parser.parse_args() args.func(args) if __name__ __main__: main()把func绑定到参数对象上是一个很常见的 argparse 用法。每个子命令的解析逻辑和分析器绑定在一起main函数最后统一调用args.func(args)这样新加一个命令只需新增一个解析器和对应的函数不需要改主流程。如果你之前没用过这个模式建议体会一下这个组织的妙处它将“命令分发”和“具体实现”解耦以后想加一个export命令只需要再写一个函数并注册到 subparsers 里。3.2 任务增删改查的实际代码核心模块我拆成了五个函数cmd_add、cmd_list、cmd_done、cmd_delete、cmd_clear另有cmd_count作为实用补充。先看添加任务和列表展示这是使用最频繁的两个功能。def cmd_add(args): tasks load_tasks() new_id max((t[id] for t in tasks), default0) 1 now datetime.now().isoformat(timespecseconds) tasks.append({ id: new_id, text: args.text, priority: args.priority, created_at: now, completed_at: None, }) save_tasks(tasks) print(fadded: {args.text} (id{new_id})) def cmd_list(args): tasks load_tasks() pending [t for t in tasks if t[completed_at] is None] done [t for t in tasks if t[completed_at] is not None] if args.done_only: pending, done [], done for t in pending: mark [ ] print(f{mark} {t[id]:3} {t[text]} {t[priority]}) for t in done: mark [x] print(f{mark} {t[id]:3} {t[text]} ({t[completed_at]}))new_id的算法我用的是“当前最大 id 1”。这是最自然的自增方式能保证任务编号持续累加。有个细节是max的default0当文件中没有任何任务时max()会抛异常必须给一个默认值这样才能计算出第一个任务的 id 是 1。cmd_list把任务分成待办和已完成两个列表分别输出。每次展示时待办在前、已完成在后这个顺序是故意的你打开列表首先看到的是“现在该做什么”而不是被一堆已完成记录占据视线。--done-only参数则用于只想检查完成记录的场景比如复盘今天到底做了多少事。标记完成和删除任务的处理思路类似先根据 id 找到目标任务找不到就打印错误并设置非零退出码。如果找到但任务已经完成我会明确提示它已经处于完成状态而不是重复更新。def cmd_done(args): tasks load_tasks() target next((t for t in tasks if t[id] args.id), None) if target is None: print(f错误不存在编号为 {args.id} 的任务, filesys.stderr) sys.exit(1) if target[completed_at] is None: target[completed_at] datetime.now().isoformat(timespecseconds) save_tasks(tasks) print(fdone: #{args.id} {target[text]}) else: print(f任务 #{args.id} 已经完成了) def cmd_delete(args): tasks load_tasks() target next((t for t in tasks if t[id] args.id), None) if target is None: print(f错误不存在编号为 {args.id} 的任务, filesys.stderr) sys.exit(1) tasks.remove(target) save_tasks(tasks) print(fdeleted: #{args.id} {target[text]})使用next((t for t in tasks if ...), None)查找目标比靠循环加标志位清晰得多。找不到时我会在标准错误输出打印信息同时sys.exit(1)。这个细节在命令行工具里很重要脚本和管道依赖退出码来判断命令是否成功如果错误时也返回 0下游脚本会误判执行结果。清空已完成任务是一个批量写入的场景。def cmd_clear(args): tasks load_tasks() remain [t for t in tasks if t[completed_at] is None] removed len(tasks) - len(remain) if removed 0: print(没有可清理的已完成任务) return save_tasks(remain) print(fcleared: 移除 {removed} 个已完成任务) def cmd_count(args): tasks load_tasks() pending [t for t in tasks if t[completed_at] is None] print(len(pending))这里有一个我特别坚持的细节所有修改操作在保存后都打印一行人类可读的确认信息而count命令只输出一个数字。这是因为count的设计目的就是被脚本调用比如在 shell 提示符里显示待办数量任何额外的解释文本都会污染管道输出。3.3 让输出更像一个真正的终端工具颜色与退出码一个命令行待办应用如果只是黑白文本用起来会很闷也不便于快速扫描优先级。我给列表展示加了 ANSI 颜色高优先级任务用红色中优先级用黄色低优先级用默认色已完成任务用绿色。颜色不是花哨装饰而是信息分层的手段让人一眼看出哪条任务最该先处理。COLOR_RED \033[31m COLOR_YELLOW \033[33m COLOR_GREEN \033[32m COLOR_RESET \033[0m不过有个经验教训不是所有人都喜欢颜色而且某些 CI 或管道环境里 ANSI 转义序列会污染输出。我的做法是提供一个--no-color参数同时在检测到标准输出不是终端时自动禁用颜色。实现方式可以简单判断sys.stdout.isatty()如果输出被重定向到文件或管道就不追加颜色代码。这能避免todo list todos.txt生成的文件里夹杂不可见字符。退出码的设计同样值得提一下正常操作返回 0找不到任务时返回 1参数校验失败时 argparse 返回 2。这样你在 shell 里执行todo done 999 echo 操作成功系统会根据退出码决定是否继续执行后面的命令避免了错误被忽略的可能性。很多第一次写命令行工具的朋友会把所有输出都print到标准输出错误也用print这会让管道处理和脚本判断全都失灵。区分标准输出和标准错误是一个终端工具走向专业的起点。4. 我把项目部署到日常环境的过程4.1 放入 PATH 与别名绑定代码写完后第一步是让todo命令在任何目录下都能直接执行。我把文件保存为todo.py然后复制到/usr/local/bin/todo再执行一次chmod x /usr/local/bin/todo。如果你的 Python 位于~/bin这种非系统目录记得把它加进PATH。Windows 用户则可以把脚本放入某个目录然后在系统环境变量里添加该目录PowerShell 同样可以调用todo。比 PATH 设置更提升日常体验的是别名绑定。全名todo已经不算长但我还是加上了一组短别名因为我发现在真实场景里少敲字符对习惯养成的影响远比自己想象中大。alias ttodo alias tltodo list alias tatodo add把这三行写进~/.bashrc或~/.zshrc后我记录一条任务的完整操作变成了ta 回复项目邮件查看列表变成tl几乎和呼吸一样自然。我还给 Git 提交流程配置了一个联动提交代码之前先运行todo list --done-only顺便确认今天真正完成了什么这比临时想“我今天到底干嘛了”可靠得多。4.2 和 shell 提示符、终端习惯的整合使用一段时间后我开始希望待办数量出现在眼睛常看的位置。第一选择是 shell 提示符。在 Bash 里可以通过$()嵌入命令输出但每次渲染提示符都执行一次todo count会拖慢终端响应即使只是几毫秒也会让提示符变得“粘手”。所以我只在手动需要时才跑todo count或者tl不强行塞进PS1。如果你用 zsh 并且装了 starship 这类现代化提示符工具可以配置一个自定义模块来显示待办数量。starship 的custom段支持指定命令每次渲染提示符时执行并捕获第一行输出。配置大概是这样的[custom.todo] command todo count when true format [$count]($color) 要注意的是我实际配置完发现只要任务数不是极端庞大这个命令的耗时在可接受范围内。但如果你追求极限性能可以给count函数加一个缓存文件比如任务文件变化时才重新统计否则直接读缓存。这个优化不是必须的但它让我们看到“命令行工具 提示符生态”的一个组合思路待办应用不只是孤立的工具它可以深度嵌入工作流。4.3 跨平台运行的坑与处理思路我在 macOS 上写完第一版后把同一个文件放到 Windows 上跑最先暴露的是编码问题。PowerShell 终端默认可能使用 GBK 或 UTF-8 之外的编码直接输出中文会出现乱码。解决方法是文件读写时显式指定encodingutf-8同时输出时用PYTHONIOENCODINGutf-8环境变量兜底。如果你用 Windows Terminal建议在配置里把默认编码切到 UTF-8现在这种环境已经比几年前好很多。另一个跨平台差异是路径。Path.home()在 Windows 上会生成C:\Users\名字\.todos.json在 Linux 上生成/home/名字/.todos.json这一层由pathlib自动处理不需要手动拼接字符串。这点我很有感触以前写 Python 脚本用字符串拼路径遇到\转义问题搞得头大换pathlib后所有平台差异都被封装掉了。跨平台工具里的文件操作强烈建议直接使用pathlib。5. 常见问题与实操排查5.1 数据文件损坏与原子写入我最早写的版本是直接open(TODO_FILE, w)写文件直到有一次终端崩溃我再次打开任务列表时发现文件变成了半截 JSON整个应用直接报错。这个教训让我意识到直接覆盖写文件在异常中断时会造成数据损坏。解决办法是原子写入先写到同目录的临时文件再通过os.replace或者Path.replace进行重命名覆盖。因为重命名在大多数文件系统上是原子操作应用崩溃时要么是旧文件完整要么是新文件完整不会出现一个写了一半的中间状态。tmp TODO_FILE.with_suffix(.tmp) with open(tmp, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) tmp.replace(TODO_FILE)即使发生了最坏情况——JSON 文件只剩一半内容也可以手动补救。任务文件是纯文本用任意编辑器打开把缺失的方括号补上或者直接删掉损坏文件并从备份恢复。毕竟待办数据的价值没有高到需要引入数据库事务但用原子写入这种几行代码就能实现的防护还是很值得的。5.2 中文乱码、Shell 引号丑与长文本输入中文乱码问题我在 4.3 提到过这里补充一个更隐蔽的场景如果你在 Windows 的旧版cmd.exe里运行 Python 脚本即使文件读写指定了 UTF-8命令行参数本身也可能会被系统按 GBK 解码。现代的 Python 3 在处理sys.argv时会尝试使用系统编码Windows 上通常还是能正确拿到中文字符串但输出终端如果不是 UTF-8 就会显示成乱码。最稳妥的办法是优先使用 Windows Terminal 或 VS Code 集成终端并把终端的代码页切换到 UTF-8。Shell 引号是另一个高频新手问题。任务文本里如果包含空格必须用引号包住todo add 分析接口返回值。如果你忘了引号shell 会把分析接口返回值当成两个参数最终只存下第一个词第二个词成为多余参数导致报错。如果你要输入的任务文本本身就包含引号在 Bash 里可以用反斜杠转义或者干脆将整条命令写进双引号再处理引号但经验是绝大多数任务文本根本不需要引号内的引号。更麻烦的是长文本和换行输入。我曾经想记录一个包含三个步骤的复杂任务比如“1. 整理接口文档 2. 更新架构图 3. 发邮件给团队”如果全塞进一行列表展示会很难看如果拆成三条任务又失去了关联性。我的方案是支持换行文本在 Bash 里用$...语法任务文本里的\n会解释成换行在 Python 的 JSON 存储里它天然支持带换行的字符串。列表展示时遇到多行文本我会在每行前面加缩进保证格式不散乱。5.3 并发执行时的文件冲突与备份习惯因为待办文件只有一个理论上如果我在两个终端里同时跑todo add可能出现竞争两个进程同时读取同一个旧文件各自加任务最后写回时互相覆盖。这个问题在单人工具里极其罕见但如果你像我一样在多个终端面板里操作也不是完全不可能。最简单的处理是接受这个限制个人待办应用本身就不会在同一秒内并发写数据遇到覆盖时损失顶多是一条任务记录。如果真想要稳妥可以给文件操作加一个简单的锁文件或者先构建一个足够健壮的原子写入流程。原子写只保证了文件不损坏不能解决“丢失更新”两者要区分清楚。我的建议是不为这个特性引入额外复杂度但养成定期备份~/.todos.json的习惯我每周将它复制到带时间戳的文件里偶尔想回溯一周前的任务时还能用。备份的操作也很命令化比如cp ~/.todos.json ~/.todos.json.backup-$(date %Y%m%d)这条命令配合 cron 或计划任务就能实现自动备份。对个人工具来说这种用命令行原生能力完成扩展的方案比在应用里手写一个云同步要朴素得多也可靠得多。5.4 命令忘干净了怎么办自带的帮助文档无论应用设计多精简总有几个月之后忘记某个参数的时候。argparse 自动生成的--help在这里起了大作用。运行todo --help可以快速看到全部子命令及其用途运行todo add --help可以看到该命令支持的参数和默认值。这个特性是免费的但前提是每个子命令都写了 help 文本。我在新建命令时总是提醒自己多花十秒钟写的描述后面可能会节省十分钟的翻源码时间。我在实际使用中还养成了一个习惯把最常用的用法压缩成几行写在.todos.json旁边的README文件里或者干脆通过todo命令的第一个参数支持todo help输出短提示。不过考虑到复用标准帮助已经是绝大部分人的需求这个自定义帮助命令其实没有太大的必要性。小工具的核心价值是解决问题而不是提供越多的文档越有价值。做了这个命令行待办应用之后我最大的体会是工具的成功不在功能多而在使用阻力小。以前我总想给待办软件加提醒、加标签、加子任务觉得功能齐全才叫专业可真到每天都要用的时候发现一瓶最简单的清单反而最耐用。你可以把这个项目看作一个起点后续想加截止日期、优先级过滤、多项目管理都是在现有数据结构上做增量。最核心的那条设计原则永远不会变让记录任务成为一个几乎无意识的动作这样才能坚持记录才能真的把事情做完。
返回列表