语言: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 }
  ]
}

两个设计点值得注意:

  1. next_id 单独存:删了 #2 之后,下一条还是 #4,不会重复用 #2。否则你按编号操作时可能删错人。
  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

四个细节,都是"实战"和"作业"的区别:

  1. expanduser("~") —— 不要写死 /home/zhangsan。用户换机器就废了。
  2. 文件不存在 → 返回空结构,不报错。第一次运行必须能用。
  3. JSON 坏了 → 警告 + 返回空,不崩。用户手改坏了也不至于彻底不能用。
  4. 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 知识,是工程习惯。 换个语言写,一样用。


十、下一步

想继续练,把这四个项目按顺序做(难度递增):

  1. 文件批量重命名工具 —— 练参数解析和 os.rename
  2. 日志统计脚本 —— 练正则、collections.Counter
  3. 目录同步工具 —— 练 shutil、比较文件哈希
  4. 一个小的 Web API —— 练 http.server(标准库,不用框架)

想学语言本身,回去看 E6 Shell 脚本 对照着看——shell 适合"串命令",Python 适合"处理数据",两个都要会。


代码在 Python 3.8 上实测通过,Linux / macOS 通用。Windows 需把 os.path.expanduser("~") 换成显式路径。