项目:CLI TODO 工具
本章我们用前 16 章学到的所有内容,做一个真实可用的命令行 TODO 工具:
- 用
argparse解析子命令 - 用
dataclass表示数据 - 用
pathlib处理路径 - 用 JSON 文件做持久化
- 用
unittest写测试 - 用
with安全地写文件
1. 项目结构
真实项目里我们会拆成多个文件:
todo/
├── todo.py # 主入口(argparse + 调度)
├── store.py # TodoStore 类(CRUD + 持久化)
├── models.py # @dataclass Todo
└── test_store.py # 单元测试
下面我们逐个文件实现。
2. models.py —— 数据类
# models.py
from dataclasses import dataclass, field, asdict
from typing import List
@dataclass
class Todo:
id: int
title: str
done: bool = False
tags: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
return asdict(self)💡为什么用 dataclass?
- 少写
__init__/__repr__/__eq__ - 配合
asdict()一行转字典,方便 JSON 序列化 - IDE 能给完整提示
3. store.py —— 增删改查
# store.py
import json
from pathlib import Path
from typing import List, Optional
from models import Todo
class TodoStore:
def __init__(self, path: Path):
self.path = path
self._todos: List[Todo] = []
self._load()
def _load(self):
if not self.path.exists():
return
data = json.loads(self.path.read_text(encoding="utf-8"))
self._todos = [Todo(**item) for item in data]
def _save(self):
data = [t.to_dict() for t in self._todos]
self.path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
def add(self, title: str, tags: Optional[List[str]] = None) -> Todo:
next_id = (max((t.id for t in self._todos), default=0)) + 1
todo = Todo(id=next_id, title=title, tags=tags or [])
self._todos.append(todo)
self._save()
return todo
def list(self, only_pending: bool = False) -> List[Todo]:
if only_pending:
return [t for t in self._todos if not t.done]
return list(self._todos)
def complete(self, todo_id: int) -> bool:
for t in self._todos:
if t.id == todo_id:
t.done = True
self._save()
return True
return False
def delete(self, todo_id: int) -> bool:
before = len(self._todos)
self._todos = [t for t in self._todos if t.id != todo_id]
if len(self._todos) < before:
self._save()
return True
return Falseℹ️私有方法的约定
下划线开头的 _load / _save 是约定为"内部使用"。Python 不会强制私有,但 IDE 会淡化显示,告诉调用者"请别直接调"。
4. todo.py —— CLI 入口
# todo.py
import argparse
from pathlib import Path
from store import TodoStore
DEFAULT_PATH = Path.home() / ".todo.json"
def main():
parser = argparse.ArgumentParser(prog="todo", description="极简 TODO 工具")
sub = parser.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add", help="添加一条")
p_add.add_argument("title")
p_add.add_argument("--tag", action="append", default=[])
p_list = sub.add_parser("list", help="列出")
p_list.add_argument("--pending", action="store_true")
p_done = sub.add_parser("done", help="标记完成")
p_done.add_argument("id", type=int)
p_del = sub.add_parser("del", help="删除")
p_del.add_argument("id", type=int)
args = parser.parse_args()
store = TodoStore(DEFAULT_PATH)
if args.cmd == "add":
t = store.add(args.title, args.tag)
print(f"添加成功: #{t.id} {t.title}")
elif args.cmd == "list":
for t in store.list(only_pending=args.pending):
mark = "✓" if t.done else " "
print(f"[{mark}] #{t.id} {t.title} tags={t.tags}")
elif args.cmd == "done":
ok = store.complete(args.id)
print("完成" if ok else "找不到该 id")
elif args.cmd == "del":
ok = store.delete(args.id)
print("已删除" if ok else "找不到该 id")
if __name__ == "__main__":
main()使用示例:
$ python todo.py add "学完 Python" --tag python --tag study
添加成功: #1 学完 Python
$ python todo.py list
[ ] #1 学完 Python tags=['python', 'study']
$ python todo.py done 1
完成
$ python todo.py list --pending
(空)5. 可运行的演示
Playground 一次只能跑一个文件,下面把 models.py + store.py 合并成单文件可运行版本,走一遍完整 CRUD 流程:
import json
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import List, Optional
# ---- models ----
@dataclass
class Todo:
id: int
title: str
done: bool = False
tags: List[str] = field(default_factory=list)
def to_dict(self): return asdict(self)
# ---- store ----
class TodoStore:
def __init__(self, path: Path):
self.path = path
self._todos: List[Todo] = []
self._load()
def _load(self):
if not self.path.exists():
return
self._todos = [Todo(**d) for d in json.loads(self.path.read_text(encoding="utf-8"))]
def _save(self):
self.path.write_text(
json.dumps([t.to_dict() for t in self._todos], ensure_ascii=False, indent=2),
encoding="utf-8"
)
def add(self, title, tags=None):
nid = (max((t.id for t in self._todos), default=0)) + 1
t = Todo(id=nid, title=title, tags=tags or [])
self._todos.append(t)
self._save()
return t
def list(self, only_pending=False):
return [t for t in self._todos if not t.done] if only_pending else list(self._todos)
def complete(self, tid):
for t in self._todos:
if t.id == tid:
t.done = True
self._save()
return True
return False
def delete(self, tid):
before = len(self._todos)
self._todos = [t for t in self._todos if t.id != tid]
if len(self._todos) < before:
self._save()
return True
return False
# ---- 演示 ----
store = TodoStore(Path("/tmp/demo_todo.json"))
t1 = store.add("学完 Python 进阶", tags=["python", "study"])
t2 = store.add("写一个 CLI 工具", tags=["python", "project"])
t3 = store.add("看 Go 教程", tags=["go"])
print("--- 全部 ---")
for t in store.list():
mark = "✓" if t.done else " "
print(f"[{mark}] #{t.id} {t.title} tags={t.tags}")
store.complete(t1.id)
print("")
print("--- 标记 #1 完成, 只看未完成 ---")
for t in store.list(only_pending=True):
print(f"[ ] #{t.id} {t.title}")
store.delete(t3.id)
print("")
print("--- 删除 #3, 全部 ---")
for t in store.list():
mark = "✓" if t.done else " "
print(f"[{mark}] #{t.id} {t.title}")
print("")
print("--- 持久化文件内容 ---")
print(store.path.read_text(encoding="utf-8"))6. 单元测试
用 unittest 给 TodoStore 写测试:
import json, unittest
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import List
@dataclass
class Todo:
id: int
title: str
done: bool = False
tags: List[str] = field(default_factory=list)
def to_dict(self): return asdict(self)
class TodoStore:
def __init__(self, path: Path):
self.path = path; self._todos = []; self._load()
def _load(self):
if self.path.exists():
self._todos = [Todo(**d) for d in json.loads(self.path.read_text(encoding="utf-8"))]
def _save(self):
self.path.write_text(json.dumps([t.to_dict() for t in self._todos], ensure_ascii=False), encoding="utf-8")
def add(self, title, tags=None):
nid = (max((t.id for t in self._todos), default=0)) + 1
t = Todo(id=nid, title=title, tags=tags or [])
self._todos.append(t); self._save(); return t
def list(self, only_pending=False):
return [t for t in self._todos if not t.done] if only_pending else list(self._todos)
def complete(self, tid):
for t in self._todos:
if t.id == tid:
t.done = True; self._save(); return True
return False
def delete(self, tid):
before = len(self._todos)
self._todos = [t for t in self._todos if t.id != tid]
if len(self._todos) < before:
self._save(); return True
return False
class TestTodoStore(unittest.TestCase):
def setUp(self):
# 每个测试用独立临时文件
self.path = Path(f"/tmp/todo_test_{id(self)}.json")
self.store = TodoStore(self.path)
def tearDown(self):
if self.path.exists():
self.path.unlink()
def test_add_assigns_unique_ids(self):
a = self.store.add("task A")
b = self.store.add("task B")
self.assertEqual(a.id, 1)
self.assertEqual(b.id, 2)
self.assertEqual(len(self.store.list()), 2)
def test_complete_marks_done(self):
t = self.store.add("task")
self.assertFalse(t.done)
ok = self.store.complete(t.id)
self.assertTrue(ok)
self.assertTrue(self.store.list()[0].done)
def test_complete_missing_returns_false(self):
self.assertFalse(self.store.complete(999))
def test_list_pending_filters(self):
a = self.store.add("A")
self.store.add("B")
self.store.complete(a.id)
pending = self.store.list(only_pending=True)
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0].title, "B")
def test_persistence_round_trip(self):
self.store.add("learn", tags=["py"])
# 用新 store 实例读同一文件,应该能恢复
fresh = TodoStore(self.path)
self.assertEqual(len(fresh.list()), 1)
self.assertEqual(fresh.list()[0].tags, ["py"])
unittest.main(argv=[''], exit=False, verbosity=2)🎯 练习
给上面的 TodoStore 加一个 find(keyword: str) -> list[Todo] 方法,在 title 中做大小写不敏感的子串匹配。然后给这个方法写至少 2 个测试。
# 把 Todo / TodoStore 复制过来,给 TodoStore 加一个 find 方法
# 然后至少写 2 个测试
import json, unittest
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import List
# (省略 Todo / TodoStore 实现,复用上面的)
# 在这里写 find(keyword) 方法
# 然后写测试🎯提示
find实现:[t for t in self._todos if keyword.lower() in t.title.lower()]- 测试 1:插入
"Buy Milk"和"Read Book",搜"milk"应返回 1 个 - 测试 2:搜
"xyz"应返回空列表
小结
- ✅
argparse配合add_subparsers实现"git 风格"的子命令 CLI - ✅
@dataclass+asdict()是 JSON 序列化的好搭档 - ✅
pathlib让路径拼接可读:Path.home() / ".todo.json" - ✅ 持久化文件用
read_text/write_text配encoding="utf-8" - ✅
unittest+ 临时文件 (tempfile/ 自建路径) 是测试的标准模式 - ✅ 拆成
models/store/cli三层让单测只需要测中间层
下一章 项目:异步网页爬虫——把所有异步知识用在一个真正并发抓取数据的项目上。