尧图精选

从零打造极简命令行待办事项工具:Python+Click+SQLite实战

🕒 发布时间:2026/10/1 17:40:21 📁 来源:尧图网络
说实话我最早动念写这个项目并不是因为缺一个待办清单而是实在受不了现在桌面端待办软件的重量感。装一个全功能的待办事项应用启动要两秒、内置云同步、捆绑日历和团队协作、还要注册账号大部分功能我根本用不上。真正让我每天坚持用的反而是一个能在终端里一条命令敲下去、立刻看到结果的命令行待办事项应用。这种从启动到看见结果不超过 0.5 秒的体验是任何 GUI 软件都给不了的。这个项目我推荐给两类人一是想练手 Python 工程化、Click 和 SQLite 的初学者它麻雀虽小但五脏俱全二是终端重度用户受够了各种待办 App 的繁琐交互想自己捏一个趁手工具。本文我会从设计取舍、代码实现到发布安装完整过一遍把我实际踩过的坑也一并交代。1. 从装过的待办软件全吃灰说起为什么我最终还是回到命令行先说一个可能反直觉的结论待办事项应用最核心的功能不是提醒不是同步也不是漂亮的日历视图而是创建一条待办的摩擦成本足够低。我试过在桌面应用里新建一条事项流程大概是打开软件、等启动动画、点加号、选分类、输入标题、选截止时间、选优先级、点保存最短也要 20 秒。而打开一个终端窗口输入一条t add 买一箱牛奶回车整个过程不会超过 3 秒。1.1 命令行应用在记录一件事上的天然优势命令行工具的优势不在于炫而在于它天然适合高频、低摩擦的操作。终端永远在桌面上不需要额外启动命令可以被历史记录复用甚至可以写进脚本里批量操作输出可以被管道重定向、被 grep 过滤也能配合 cron 做自动化。这些特性决定了它比 GUI 待办软件更适合随手记这个场景。还有一个被很多人忽略的点命令行工具的数据是透明的。我用 SQLite 存数据所有数据就是~/.todo.db一个文件想备份就复制走想迁移就拷过去不绑定任何厂商的云服务。对于我一个长期主义用户来说这个自由度比好看的用户界面值钱得多。1.2 技术选型为什么是 Python Click SQLite Rich选型这件事我认真纠结过。Node.js 的 Commander 生态也不差Go 的 Cobra 编译出来的单个二进制更是清爽但最终我还是选了 Python理由很实际Python 有Click定义命令行参数是目前所有语言里最省心的装饰器写起来像在搭积木。SQLite是标准库自带的零配置文件、单文件存储天然承受得住一个个人待办工具的写入量。Rich可以把终端表格、颜色、对齐排版做到足以见人的程度不用我手工拼字符串。Python 脚本分发确实比 Go 麻烦一些但通过pip install和 entry point 可以把工具包装成本机全局命令实际上手之后并没有想象中那么啰嗦。1.3 这类项目最值得学习的地方别看它小这个项目把 Python 工程里的几个关键环节都覆盖了命令行交互设计、参数校验、持久化存储、状态流转、终端输出、包分发、Shell 补全。做完之后你获得的不是一个 toy demo而是一个每天都会打开的真实工具。它比任何教程项目都更能驱动你持续完善代码因为你自己就是最严苛的用户。2. 数据模型先行一张表把状态、优先级和备注都装下很多初学者拿到做一个 CLI 待办应用这个需求第一反应就是直接写input()循环然后往列表里 append。我强烈建议不要这样。命令行工具的生命周期比大多数人想象的长你可能会用上几个月甚至几年。如果一开始不把数据结构想清楚后面加需求时就要动刀重写。2.1 功能清单要先收敛别一上来就做重我给自己定的第一版功能范围非常克制命令功能备注t add添加待办支持优先级、备注t ls查看待办默认只显示未完成可按状态/排序过滤t done标记完成按 ID 操作t rm删除待办按 ID 操作t edit修改标题/备注高级功能后期补t stats统计完成情况给自己一点正反馈用的这六条命令覆盖了待办应用的 90% 使用场景。标签系统、截止日期、子任务、提醒通知这些我全部砍掉留给第二版再说。先保证主链路足够顺滑远比一上来就堆功能更重要。2.2 表结构设计宁可多留一个字段也别后面改表我最终的建表语句是这样的CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, note TEXT DEFAULT , priority INTEGER DEFAULT 0, status TEXT DEFAULT pending, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME );各字段的设计理由id用自增主键命令行里直接通过 ID 操作最方便t done 3比t done 买牛奶精确得多。title必填字段不解释。note默认空字符串。很多人觉得备注不重要但实际用起来临时需要记个地址、存个链接时非常加分。priority用整数0/1/2存不要直接存字符串。查询排序时比较整数开销小展示时再映射成低/中/高。status用字符串pending/done而不用布尔值is_done。原因见下一节。created_at默认值CURRENT_TIMESTAMP记录创建时间。completed_at初始为空只有在标记完成时才写入这个时间可以用来做统计报表。2.3 为什么 status 字段要用字符串而不是布尔值这是我的亲身体会。最初我把状态设计成is_done INTEGER DEFAULT 0布尔值简单直接。但用到第二周我就发现不够了我想区分还没做和今天不想做还想知道哪些任务是被跳过的。此时我只能在表外面另开一张状态表或者加第二个字段都非常别扭。改成status TEXT DEFAULT pending之后一切明朗了。pending、done先跑着将来想加doing、canceled、deferred只改展示层和筛选逻辑数据库结构和已有数据完全不用动。这就是枚举字符串的扩展性优势代价其实只是几个字节的存储空间。2.4 存储介质选 JSON 文件还是 SQLite个人工具最常见的两种持久化方案是 JSON 文件和 SQLite。我一开始确实偷懒用过 JSON读文件、改 list、写回去十行代码完成。但用了一周就暴露了问题如果命令中途被 CtrlC 打断JSON 文件可能处于半写状态直接损坏。没有事务done之后再写回文件一旦过程出错数据就丢了。要在列表里查找某条待办必须全量 load 进内存再遍历。SQLite 单文件、零配置却能提供完整的 ACID 保证还能用 SQL 做条件查询和排序。对于命令行工具这种单机场景它几乎是零成本的提升没有任何理由不用它。3. Click 命令体系把用户要做什么翻译成终端里敲什么命令行应用最重要的产品设计其实是命令树和参数的长相。用户不需要你教他拿到一个t之后自然会尝试t add、t ls、t done这种直觉命令。所以命令名要短、要符合动词直觉。3.1 命令树设计全应用只暴露一个组整个应用只有一个入口t。通过click.group()声明组每个功能都是一个子命令。这种结构的优点在于统一性之后加t tomorrow、t report都不会污染主命名空间。import click click.group() def cli(): 一个轻量但好用的命令行待办事项应用 if __name__ __main__: cli()click.group()会自动帮你处理缺失子命令时的帮助信息还会生成很标准的--help文本。这种免费的交互质量是手写argparse很难达到的。子命令用短名字add、ls、done、rm、stats。list我故意改成了ls因为ls在终端语境里几乎等于肌肉记忆而且输入更短。3.2 添加命令的完整实现add子命令负责参数接收和校验。这里有两个设计细节值得说cli.command() click.argument(title) click.option(-p, --priority, typeclick.Choice([low, normal, high]), defaultnormal) click.option(-n, --note, default) def add(title, priority, note): 添加一条新待办 pri_map {low: 0, normal: 1, high: 2} conn get_conn() conn.execute( INSERT INTO todos (title, note, priority) VALUES (?, ?, ?), (title, note, pri_map[priority]), ) conn.commit() conn.close() click.echo(f已添加: {title})第一个细节title用click.argument因为它是必填位置参数priority和note用click.option是可选项。typeclick.Choice会自动拦截非法优先级输入用户如果敲了-p urgent终端会直接报错并列出合法选项比自己在代码里if判断干净得多。第二个细节priority在用户侧用可读性好的单词low/normal/high存库时映射为整数。这样做既保留了终端输入的自然性又保留了数据库端的存储效率。展示数据时再把整数映射回带颜色的文字。3.3 列表命令与参数默认值列表命令是最核心的输出入口我用子命令名ls并加了两个控制参数cli.command(namels) click.option(-s, --status, typeclick.Choice([pending, done, all]), defaultpending) click.option(--sort, typeclick.Choice([priority, created]), defaultpriority) def list_todos(status, sort): 列出待办默认只看未完成的 query SELECT * FROM todos clauses [] args [] if status ! all: clauses.append(status ?) args.append(status) if clauses: query WHERE AND .join(clauses) if sort priority: query ORDER BY priority DESC, id DESC else: query ORDER BY id DESC show_todos(query, args)默认值defaultpending意味着用户直接敲t ls只看到未完成的待办这个默认行为非常关键。一个真实世界的待办工具如果打开以后把三年前的旧账全列出来第一屏基本废了。优先级的默认排序则保证最紧急的事务永远排在前面一眼扫过去就知道今天该干什么。3.4 一个 Click 的经典坑不要把可变对象当默认值写 Click 的时候要注意default参数如果传 list、dict 这类可变对象会在所有调用之间共享状态这是非常经典的坑。比如# 这样是错的 click.option(--tags, default[], multipleTrue)虽然 Click 在某些情况下会帮你拷贝但最稳妥的做法还是设成defaultNone在函数内部再转换成可变对象。养成这个习惯之后你能避掉不少诡异的明明每次调用是独立的为什么列表还在累积的报错。4. SQLite 持久化单文件数据库的 CRUD 落地细节数据层是整个工具的心脏。CLI 工具不像 Web 服务会有大量并发但正因为生命周期短、每个命令都会独立启动连接管理反而要特别讲究。4.1 连接管理一次命令一个短连接我看到有些人喜欢在代码里做一个全局连接对象然后程序跑完再关闭这在 CLI 里其实会埋雷Python 进程退出时如果事务还没提交数据就丢了。我的策略是每个命令函数内部各开各关保证命令结束前一定 commit 和 closeimport sqlite3 from pathlib import Path DB_PATH Path.home() / .todo.db def get_conn(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return connconn.row_factory sqlite3.Row是我强烈建议的一行它让查询结果可以像字典一样用row[title]访问避免用row[0]这种可读性极差的下标方式。4.2 CRUD 的 SQL 实现初始化建表def init_db(): conn get_conn() conn.execute( CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, note TEXT DEFAULT , priority INTEGER DEFAULT 0, status TEXT DEFAULT pending, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME ) ) conn.commit() conn.close()标记完成的命令这里有两个细节值得讲cli.command() click.argument(todo_id, typeint) def done(todo_id): 将指定 ID 的待办标记为完成 conn get_conn() cur conn.execute( UPDATE todos SET statusdone, completed_at? WHERE id?, (datetime.now().strftime(%Y-%m-%d %H:%M:%S), todo_id), ) conn.commit() conn.close() if cur.rowcount 0: raise click.ClickException(f找不到 ID 为 {todo_id} 的待办) click.echo(f已完成: #{todo_id})第一个细节completed_at的值没有用 SQLite 的CURRENT_TIMESTAMP而是用 Python 的datetime.now()自己生成。原因是 SQLite 的CURRENT_TIMESTAMP返回的是 UTC 时间和本地时间会有 8 小时偏差具体我会在第 7 章展开。第二个细节用cur.rowcount判断是否有行被更新。用户经常敲错 ID如果不查 rowcount一个不存在的 ID 会被静默忽略用户以为自己完成了任务实际上什么都没发生。click.ClickException会以红色错误信息退出并且返回非零退出码方便脚本捕获。删除命令同理区别只是 SQL 用DELETE FROM。整个数据层总共不到 60 行够用于上千条待办。4.3 事务与并发这个场景真的不用上 WALSQLite 默认的 journal 模式对于 CLI 工具完全没有问题。每次命令都自动开启事务commit 后数据落盘。唯一值得注意的场景是你同时开了两个终端窗口一个在t add一个在t ls。SQLite 在这种情况下不会损坏数据最多是写操作短暂收到database is locked的报错。如果这个场景让你不安可以用一句 SQL 开启 WAL 模式PRAGMA journal_modeWAL;WAL 模式允许读和写并行对个人工具来说是锦上添花。我个人实测下来没必要为了一个自己写的工具折腾但如果哪天你想跑后台自动化脚本同时前台手动操作建议还是开启。4.4 表结构迁移加字段时最省事的方案计划外需求终归会来。我第二周就想给任务加一个截止时间字段。这时不需要重建表直接在命令行或代码里执行一句ALTER TABLE todos ADD COLUMN due_date DATETIME;这是我推荐的小步迁移方案新字段设成可空老数据自动获得NULL完全不用写迁移脚本。等到你积累的字段超过十个、或者需要删除字段时再考虑引入sqlite-utils或正式的迁移工具。对于个人工具够用就是最好的架构。5. Rich 渲染终端输出的可读性就是另一个产品维度终端应用最容易翻车的地方是输出排版。一个待办工具如果输出是一坨纯文本用户很难在十行里快速找到重要且未完成的任务。我选择Rich来做这件事因为它能用几行代码把表格、颜色、对齐全部搞定。5.1 用表格式输出替代纯文本核心输出函数如下from rich.console import Console from rich.table import Table console Console() def show_todos(rows): table Table(title我的待办) table.add_column(ID, justifyright, stylecyan) table.add_column(内容) table.add_column(优先级, justifycenter) table.add_column(状态, justifycenter) table.add_column(创建时间, justifycenter) for row in rows: table.add_row( str(row[id]), row[title], priority_style(row[priority]), status_style(row[status]), row[created_at], ) console.print(table)Table会自动处理列宽、对齐和边框。特别值得一提的是 Rich 对中文字符宽度的处理终端里中文是全角字符占两个英文字符宽度如果用普通字符串拼接对齐中文长标题会把整行撑乱。Rich 内部实现了宽度计算这也是我选它而不是手写ljust的核心原因。5.2 优先级和状态的颜色编码人眼对颜色非常敏感颜色比文字更快传达优先级信息。我的编码方案优先级颜色含义低青色可以往后放中黄色正常推进高红色今天优先实现方式很简洁def priority_style(p): return {0: [cyan]低[/cyan], 1: [yellow]中[/yellow], 2: [red]高[/red]}.get(p, str(p)) def status_style(s): return [green]完成[/green] if s done else [white]待办[/white]Rich 的颜色标签语法是[颜色]文字[/颜色]核心是成对闭合。颜色方案我验证过主流终端深色背景下红色和黄色都清晰浅色背景下青色可能偏淡所以如果你的终端配色是白底建议把青色改成蓝色。这是一个细节但直接决定可读性。5.3 管道与非 TTY 场景大多数人不会注意这个但 CLI 工具迟早会被放进脚本里t ls | grep 牛奶。此时 stdout 不是终端Rich 会默认关闭颜色输出这其实是正确行为让管道另一头的工具拿到的数据不会混入 ANSI 转义码。如果脚本里需要保留格式Rich 也提供了Console(force_terminalTrue)可以强制开启。我个人建议不要强制。颜色是给人眼看的脚本要的是纯数据。哪怕你很想让t ls today.log里存下带颜色的漂亮日志也忍一忍数据纯净比好看重要。6. 从项目变成工具全局安装、补全脚本与日常包装代码写完只是第一步。真正让它成为每天会用的工具还需要完成最后一公里全局安装、Shell 补全和一些顺手的小包装。6.1 用 pyproject.toml 定义入口我在项目根目录放一个pyproject.toml[project] name todo-cli version 0.1.0 [project.scripts] t todo_cli:cli然后执行pip install -e .[project.scripts]就是 entry point 配置它会在你的 Python 环境里生成一个可执行脚本t调用模块里的cli函数。-e是开发模式之后改代码不用重新安装命令立即生效。这一步做完终端里任意位置敲t都能唤起应用。6.2 Shell 补全让想不起来命令这种事消失Click 自带补全脚本生成机制无需额外依赖。以 Bash 为例eval $(_T_COMPLETEbash_source t)Zsh 则是eval $(_T_COMPLETEzsh_source t)把对应的一行写进~/.bashrc或~/.zshrc重新加载之后敲t add 买再按 Tab就能补全参数选项。这里变量名里的_T就是你的命令名前缀加_COMPLETE如果你的命令是todo变量名就是_TODO_COMPLETE。这个功能能极大降低记忆负担尤其当你加了十几个子命令之后。6.3 Windows 和 PowerShell 场景的一点补充这套方案在 Windows 上同样能用前提是 Python 环境完备。在 PowerShell 里如果你不想关心 exe 路径可以直接定义一个函数包装function t { python -m todo_cli args }这段代码放进$PROFILE即可。这样连虚拟环境的 Scripts 目录都不用加进 PATHpython -m todo_cli会自动从当前激活环境找模块。说到 Windows 的使用场景我自己就经常在 PowerShell 里同时开好几个命令行窗口一边跑到数据库恢复的活儿一边记着待办。这时候t这种全局命令的价值就很明显你不用切窗口找记事本直接在原终端里记一条完事。6.4 与系统能力联动cron 和开机自启进阶玩法是把t ls接入定时任务。例如想在每天早上一开机就看到今天的高优先级任务可以在 macOS 或 Linux 下加一条 cron0 9 * * * t ls -s pending输出会直接出现在定时任务的标准输出里配合wall或桌面通知工具还可以做到弹窗提醒。不过个人经验是提醒功能对我不太起作用——真正常用的反而是我想起来就敲一下t add想核对就敲t ls这个循环本身。工具越轻你越愿意打开它这比任何强制提醒都有效。7. 踩坑记录时区、编码、TTY 与一些没必要踩的坑到这一章我把开发过程中实际踩过的坑集中说完。每一个都对应一个具体的现象如果你照着写这个项目大概率也会遇到。7.1 SQLite 的 CURRENT_TIMESTAMP 是 UTC别直接用这是最大的一个坑。我第一版建表时created_at用了DEFAULT CURRENT_TIMESTAMP发现每天中午添加的任务显示创建时间是凌晨 4 点困惑了半天才反应过来 SQLite 的这个关键词返回的是 UTC 时间。解决方案有两种一是所有时间戳统一存 UTC展示时再转本地时区二是干脆用 Python 生成datetime.now().strftime(%Y-%m-%d %H:%M:%S)再存进去。个人 CLI 工具没有跨时区协作需求我选了第二种最简单也最直观。记住只要你的工具涉及时间字段就要明确知道存的是 UTC 还是本地时间不要存一半靠猜。7.2 中文乱码问题Windows 上必须显式设置编码在 Windows 的 cmd 或 PowerShell 里运行 Python 脚本有时中文会出现乱码因为 Python 默认的输出编码可能是 GBK 或 locale 相关编码。我的解决办法是在入口处显式声明import sys sys.stdout.reconfigure(encodingutf-8)如果你用的是 Python 3.7sys.stdout.reconfigure可以随时修改编码。在 Windows Terminal 配合 UTF-8 代码页中文显示完全正常。旧版 conhost 窗口如果还有问题建议直接换 Windows Terminal这不是工具的 bug是终端本身的编码历史包袱。7.3 Rich 的颜色代码裸奔问题Rich 检测到 stdout 不是 TTY 时默认禁用颜色这在前一章说过是好事。但有一种情况比较难受在部分老版本终端工具里isatty检测不准确ANSI 转义码会直接打出来比如[31m红色[0m出现在屏幕上。排查思路是先用python -c from rich.console import Console; Console().print(test, stylered)单独测试如果正常但你的工具异常八成是你自己包了一层管道把输出结果交给了什么中间进程。绝大多数情况是用户在自己的 shell 配置里把命令输出做了重定向。我的建议是工具内部不要强行改force_terminal优先确认外部调用环境。颜色丢了可读性下降总比把转义码当作数据打到文件里强。7.4 设计取舍别给工具加他认为你需要的功能最后想谈一个没有写在代码里的经验。我见过很多人一写 CLI 工具就停不下来日历视图、番茄钟、甘特图全都想做最后项目烂尾在第三百行。个人工具最健康的成长方式是用多少加多少先有add和ls跑两周发现自己总想筛 overdue 再给ls加参数发现经常误删再给rm加确认提示。每一个功能都被真实需求驱动过而不是被想象的需求驱动。我也一开始就想加子任务树被自己的理智按住了。现在这个工具用了几个月功能不超过十个命令但每个命令我几乎每天都会用。这种小而顺手的状态恰恰是命令行待办应用最该有的样子。如果你也想做一个类似的工具我的建议是今晚就开始mkdir todo-cli、建一个虚拟环境、写一个能跑的add命令剩下的功能交给使用过程中的痛点时刻。它大概率会成为你写过的最值回票价的练手项目。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →