语言:Python 3.6+(系统自带就能跑) 代码量:196 行,单文件,零第三方依赖 耗时:读完 30 分钟,边看边敲 2-3 小时 前置:会开终端、会
nano
这一篇不是"教你 Python"。它是让你第一次经历"从空文件到一个真能用的工具"的完整过程。
代码全部在下面,你可以直接复制走。但建议边看边敲——这个差别比你想的大得多。
一、先说清楚做什么、不做什么
做什么:一个纯文本的待办清单,命令行为主。
todo.py add "买牛奶"
todo.py list
todo.py done 1
不做什么(重要,这是设计决定):
- ❌ 不做图形界面
- ❌ 不联网、不注册、不同步
- ❌ 不引第三方库(
pip install一律没有) - ❌ 不支持多用户、不支持标签、不支持优先级
为什么这么克制:项目能不能做完,取决于砍掉多少。一个能跑的三百行程序,比一个写了一半的漂亮架构有用一万倍。
二、先想清楚:数据放哪
这是所有项目的第一问。先定数据,再定代码。
选择了:一个 JSON 文件,放在 ~/.todo.json。
为什么:
| 方案 | 好处 | 坏处 | 结论 |
|---|---|---|---|
| JSON 文件 | 能直接看、能手动改、能备份 | 大了会慢 | ✅ 选它 |
| SQLite | 能查能索引 | 要学 SQL、看不出内容 | 数据量上千再说 |
| 纯文本一行一条 | 最简单 | 带日期就难解析 | 太容易出错 |
| 内存 | 最快 | 关掉就没了 | 不行 |
JSON 长这样:
{
"next_id": 4,
"items": [
{ "id": 1, "text": "买牛奶", "done": false, "created": "2026-10-20 17:58", "due": null }
]
}
两个设计点值得注意:
next_id单独存:删了 #2 之后,下一条还是 #4,不会重复用 #2。否则你按编号操作时可能删错人。items是数组,顺序就是添加顺序。
三、逐段讲代码
段 1:读写文件(15-34 行)
DATA = os.path.expanduser("~/.todo.json")
def load():
if not os.path.exists(DATA):
return {"next_id": 1, "items": []}
try:
with open(DATA, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
print("警告: 数据文件损坏,已按空列表处理", file=sys.stderr)
return {"next_id": 1, "items": []}
data.setdefault("next_id", 1)
data.setdefault("items", [])
return data
四个细节,都是"实战"和"作业"的区别:
expanduser("~")—— 不要写死/home/zhangsan。用户换机器就废了。- 文件不存在 → 返回空结构,不报错。第一次运行必须能用。
- JSON 坏了 → 警告 + 返回空,不崩。用户手改坏了也不至于彻底不能用。
setdefault—— 老版本文件缺字段时自动补上。这是"数据格式演进"的最小做法。
段 2:保存(29-34 行)—— 这里最容易出事故
def save(data):
tmp = DATA + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, DATA)
为什么不直接写原文件?
直接写:open(DATA, "w") ← 这一瞬间原文件已经被清空
← 如果此时断电/崩溃 → 你的数据全没了
正确做法(原子写入):
① 写到 ~/.todo.json.tmp
② os.replace 把 tmp 重命名成正式文件
os.replace 是原子操作——要么旧的完整,要么新的完整,不会出现"写了一半"的状态。
这一条不只是这个项目:任何"保存配置/保存数据"的场景,都该这么写。我在这个站的服务器上做证书和向量库更新时,用的也是同一个模式(先写
.new,校验通过再os.replace)。
段 3:加一条(36-49 行)
def add(text, due=None):
data = load()
item = {
"id": data["next_id"],
"text": text,
"done": False,
"created": datetime.now().strftime("%Y-%m-%d %H:%M"),
"due": due,
}
data["items"].append(item)
data["next_id"] += 1
save(data)
print("已添加 #%d: %s" % (item["id"], text))
注意 ensure_ascii=False:
json.dump(data, f, ensure_ascii=False) # 中文原样存储
json.dump(data, f) # 中文变 \u4e70\u725b\u5976
前者你打开文件能看懂,后者全是转义码。 这个参数不加,你就永远没法手改数据文件。
段 4:列表(51-85 行)—— 用户体验全在这
pending = [x for x in items if not x["done"]]
done = [x for x in items if x["done"]]
分成两组显示,不是混在一起。理由很简单:你打开待办,想看的是"还没做的"。 已完成的是噪音。
过期检测:
overdue = [x for x in pending
if x.get("due") and x["due"] < date.today().isoformat()]
为什么用字符串比较日期? 因为 "2026-10-01" < "2026-10-20" 在 ISO 格式下,字符串比较结果和日期比较完全一致。这是 ISO 8601 格式最大的好处之一——不用解析成日期对象就能比大小。
但如果要做"还有几天"这种计算,就得
datetime.strptime转成对象了。
段 5:完成 / 删除 / 清理(87-120 行)
def done_cmd(item_id):
data = load()
for x in data["items"]:
if x["id"] == item_id:
if x["done"]:
print("#%d 已经是完成状态" % item_id)
return
x["done"] = True
x["finished"] = datetime.now().strftime("%Y-%m-%d %H:%M")
save(data)
重复操作要友好:已经完成的再 done 一次,不该报错,也不该改时间——直接告诉用户"已经是完成状态",然后什么都不做。
删除用的是过滤重建:
data["items"] = [x for x in data["items"] if x["id"] != item_id]
比 list.remove() 好,因为 remove 遇到重复值只删第一个。
段 6:命令行解析(137-192 行)
没有用 argparse,手写的。为什么?
- 只有 5 个命令,手写更直观
argparse会让"读代码"变成"读框架",这是教学项目,可读性优先
但有个真实的取舍点:
text = " ".join(a for a in argv[2:] if a != "--due" and a != due)
这一行在把 todo.py add 买 牛奶 拼回 "买 牛奶"。它也有 bug:如果待办内容里正好有 --due 这个词,会被吃掉。
这不是"我写错了",这是"我知道它有问题但选择不修"。 换成
argparse一行解决,但那会让代码结构复杂一倍。 能说清"哪里不完美、为什么暂时不修",比假装完美更专业。
四、完整代码
保存为 ~/todo.py,然后 chmod +x ~/todo.py。
#!/usr/bin/env python3
"""todo.py —— 一个完全离线的命令行待办工具。"""
import json
import os
import sys
from datetime import datetime, date
DATA = os.path.expanduser("~/.todo.json")
def load():
if not os.path.exists(DATA):
return {"next_id": 1, "items": []}
try:
with open(DATA, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
print("警告: 数据文件损坏,已按空列表处理", file=sys.stderr)
return {"next_id": 1, "items": []}
data.setdefault("next_id", 1)
data.setdefault("items", [])
return data
def save(data):
tmp = DATA + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, DATA)
def add(text, due=None):
data = load()
item = {
"id": data["next_id"],
"text": text,
"done": False,
"created": datetime.now().strftime("%Y-%m-%d %H:%M"),
"due": due,
}
data["items"].append(item)
data["next_id"] += 1
save(data)
print("已添加 #%d: %s" % (item["id"], text))
def listing(show_all=False):
data = load()
items = data["items"]
if not items:
print("还没有待办。用 add 加一条。")
return
pending = [x for x in items if not x["done"]]
done = [x for x in items if x["done"]]
print()
if pending:
print("待办 (%d)" % len(pending))
for x in pending:
flag = ""
if x.get("due"):
flag = " [%s]" % x["due"]
print(" #%-3d %s%s" % (x["id"], x["text"], flag))
else:
print("待办:无")
if show_all and done:
print()
print("已完成 (%d)" % len(done))
for x in done:
print(" #%-3d %s" % (x["id"], x["text"]))
overdue = [x for x in pending
if x.get("due") and x["due"] < date.today().isoformat()]
if overdue:
print()
print("⚠ 已过期 %d 条" % len(overdue))
print()
def done_cmd(item_id):
data = load()
for x in data["items"]:
if x["id"] == item_id:
if x["done"]:
print("#%d 已经是完成状态" % item_id)
return
x["done"] = True
x["finished"] = datetime.now().strftime("%Y-%m-%d %H:%M")
save(data)
print("✓ 完成 #%d: %s" % (item_id, x["text"]))
return
print("找不到 #%d" % item_id, file=sys.stderr)
sys.exit(1)
def remove(item_id):
data = load()
before = len(data["items"])
data["items"] = [x for x in data["items"] if x["id"] != item_id]
if len(data["items"]) == before:
print("找不到 #%d" % item_id, file=sys.stderr)
sys.exit(1)
save(data)
print("已删除 #%d" % item_id)
def clear_done():
data = load()
n = len([x for x in data["items"] if x["done"]])
data["items"] = [x for x in data["items"] if not x["done"]]
save(data)
print("清掉了 %d 条已完成" % n)
def usage():
print("""todo.py —— 离线命令行待办
用法:
todo.py add "买牛奶" 加一条待办
todo.py add "交报告" --due 2026-10-25 带截止日期
todo.py list 列出待办
todo.py list --all 连已完成的也列
todo.py done 3 标记 #3 完成
todo.py remove 3 删除 #3
todo.py clear 清掉所有已完成
数据存在 ~/.todo.json,纯文本,可以直接看。""")
def main(argv):
if len(argv) < 2:
usage()
return 0
cmd = argv[1]
if cmd == "add":
if len(argv) < 3:
print("用法: todo.py add \"内容\" [--due YYYY-MM-DD]", file=sys.stderr)
return 1
due = None
if "--due" in argv:
i = argv.index("--due")
if i + 1 < len(argv):
due = argv[i + 1]
try:
datetime.strptime(due, "%Y-%m-%d")
except ValueError:
print("日期格式应为 YYYY-MM-DD", file=sys.stderr)
return 1
text = " ".join(a for a in argv[2:] if a != "--due" and a != due)
add(text, due)
return 0
if cmd == "list":
listing(show_all="--all" in argv)
return 0
if cmd in ("done", "remove"):
if len(argv) < 3:
print("用法: todo.py %s <编号>" % cmd, file=sys.stderr)
return 1
try:
item_id = int(argv[2])
except ValueError:
print("编号必须是数字", file=sys.stderr)
return 1
if cmd == "done":
done_cmd(item_id)
else:
remove(item_id)
return 0
if cmd == "clear":
clear_done()
return 0
if cmd in ("-h", "--help", "help"):
usage()
return 0
print("未知命令: %s" % cmd, file=sys.stderr)
usage()
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
五、跑起来:真实输出
加三条
$ python3 todo.py add "买牛奶"
已添加 #1: 买牛奶
$ python3 todo.py add "交周报" --due 2026-10-25
已添加 #2: 交周报
$ python3 todo.py add "给打印机换纸"
已添加 #3: 给打印机换纸
看列表
$ python3 todo.py list
待办 (3)
#1 买牛奶
#2 交周报 [2026-10-25]
#3 给打印机换纸
完成一条
$ python3 todo.py done 2
✓ 完成 #2: 交周报
$ python3 todo.py list
待办 (2)
#1 买牛奶
#3 给打印机换纸
连已完成一起看
$ python3 todo.py list --all
待办 (2)
#1 买牛奶
#3 给打印机换纸
已完成 (1)
#2 交周报
过期提醒
$ python3 todo.py add "过期测试" --due 2026-10-01
$ python3 todo.py list
待办 (3)
#1 买牛奶
#3 给打印机换纸
#4 过期测试 [2026-10-01]
⚠ 已过期 1 条
数据文件长什么样
$ cat ~/.todo.json
{
"next_id": 4,
"items": [
{
"id": 1,
"text": "买牛奶",
"done": false,
"created": "2026-10-20 17:58",
"due": null
},
{
"id": 2,
"text": "交周报",
"done": true,
"created": "2026-10-20 17:58",
"due": "2026-10-25",
"finished": "2026-10-20 17:58"
},
{
"id": 3,
"text": "给打印机换纸",
"done": false,
"created": "2026-10-20 17:58",
"due": null
}
]
}
中文是可读的(因为 ensure_ascii=False)。你可以直接手改这个文件,程序也会读。
六、故意把边界情况都试一遍
“能跑通正常路径"说明不了什么,「乱来也不崩」才算过关。
删不存在的
$ python3 todo.py done 999
找不到 #999
$ echo $?
1
退出码 1 = 失败。这样脚本里才能判断对错:
if ! python3 todo.py done 999; then echo "失败了"; fi
日期格式错
$ python3 todo.py add "x" --due 2026-13-45
日期格式应为 YYYY-MM-DD
$ echo $?
1
没有把 2026-13-45 存进去——校验发生在写盘之前。这个顺序很重要。
未知命令
$ python3 todo.py bogus
未知命令: bogus
todo.py —— 离线命令行待办
...
数据文件被改坏了
$ echo "not json" > ~/.todo.json
$ python3 todo.py list
警告: 数据文件损坏,已按空列表处理
还没有待办。用 add 加一条。
$ echo $?
0
不崩、不丢数据、给出警告。 这是"容错"的标准做法。
七、让它像个真工具(三个小步骤)
1. 放到 PATH 里,就不用敲路径了
mkdir -p ~/bin
cp ~/todo.py ~/bin/todo
chmod +x ~/bin/todo
确认 ~/bin 在 PATH 里:
echo $PATH | grep -q "$HOME/bin" && echo "在" || echo "不在"
不在的话,往 ~/.profile 末尾加一行:
export PATH="$HOME/bin:$PATH"
重新登录(或 source ~/.profile)后:
todo add "买牛奶" # 直接用了
2. 加个别名,少敲几个字
echo "alias t='todo'" >> ~/.bashrc
source ~/.bashrc
t add "买牛奶"
t list
t 比 todo 快得多,你会更愿意用它。 工具好不好用,很大程度取决于输入的代价。
3. 每天开机提醒一次(可选)
crontab -e
加一行(每天 9 点显示待办):
0 9 * * * /home/你的用户名/bin/todo list | wall 2>/dev/null
这个只在有终端的会话里有效。更实用的是自己养成
t list的习惯,别依赖提醒。
八、三个可以自己加的功能(练习)
做完这一篇,你已经有了一个能用的东西。下面三个是练习方向,按难度排。
练习 1:搜索(简单)
todo find 牛奶
提示:遍历 items,用 if 关键词 in x["text"] 过滤。
练习 2:按截止日期排序(中等)
todo list --sort due
提示:sorted(pending, key=lambda x: x.get("due") or "9999-99-99")
难点:due 可能是 None,直接排会报错——所以给个"最大的日期"兜底。
练习 3:导出成 Markdown(中等)
todo export > 待办.md
提示:- [ ] 未完成 / - [x] 已完成,这是标准 Markdown 任务列表语法。
好处:导出后能贴进任何笔记软件。
挑战项:加个 todo edit 3 改内容
提示:input() 让用户重输。注意:要处理"用户直接回车”(表示不改)和"输错编号"两种情况。
九、这一篇真正教的东西
代码只是载体。真正值得带走的是这四条:
一、先定数据,再定代码。 数据结构想清楚了,代码自然就出来了。反过来一定返工。
二、原子写入。 任何"保存"都要先写临时文件再替换。这一条适用于所有项目,不只是待办工具。
三、容错优先。 文件不存在 → 建空的;文件坏了 → 警告 + 用空的;参数错了 → 报错 + 退出码 1。 程序不该因为用户乱来就崩。
四、敢砍功能。 不做 GUI、不引库、不多用户。能跑的三百行,胜过写了一半的三千行。
这四条不是 Python 知识,是工程习惯。 换个语言写,一样用。
十、下一步
想继续练,把这四个项目按顺序做(难度递增):
- 文件批量重命名工具 —— 练参数解析和
os.rename - 日志统计脚本 —— 练正则、
collections.Counter - 目录同步工具 —— 练
shutil、比较文件哈希 - 一个小的 Web API —— 练
http.server(标准库,不用框架)
想学语言本身,回去看 E6 Shell 脚本 对照着看——shell 适合"串命令",Python 适合"处理数据",两个都要会。
代码在 Python 3.8 上实测通过,Linux / macOS 通用。Windows 需把 os.path.expanduser("~") 换成显式路径。