起点
基础课程第 29 篇写了一个 todo.py——单文件、靠 sys.argv 解析参数、JSON 存数据。够用,但不"工业"。
这次我们把它升级成真正的 Python 包,用上高级课所有知识点。
目标
pip install wadely-todo
todo add "买菜" # 用 click 写 CLI
todo list --filter open
todo done 1
todo --version
并且:
- 类型注解 + mypy 通过
- 用 dataclass 表示 Todo
- 有日志
- 有 pytest 单元测试
- 有 pre-commit + ruff
- 能打包发到 PyPI
项目结构
wadely-todo/
├── pyproject.toml
├── README.md
├── .pre-commit-config.yaml
├── src/
│ └── todo/
│ ├── __init__.py
│ ├── models.py Todo 数据类
│ ├── store.py 存储层
│ └── cli.py click CLI
└── tests/
├── conftest.py
└── test_store.py
models.py:dataclass
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Todo:
id: int
text: str
done: bool = False
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
store.py:用 Protocol 定义接口
from pathlib import Path
from typing import Protocol
import json
import logging
from .models import Todo
logger = logging.getLogger(__name__)
class TodoRepo(Protocol):
def add(self, text: str) -> Todo: ...
def list(self, only_open: bool = False) -> list[Todo]: ...
def mark_done(self, todo_id: int) -> Todo | None: ...
class JsonTodoRepo:
def __init__(self, path: Path):
self.path = path
self._items: list[Todo] = self._load()
def _load(self) -> list[Todo]:
if not self.path.exists():
return []
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
return [Todo(**d) for d in data]
except (json.JSONDecodeError, TypeError):
logger.warning("数据损坏,重置")
return []
def _save(self) -> None:
from dataclasses import asdict
self.path.write_text(
json.dumps([asdict(t) for t in self._items], ensure_ascii=False, indent=2),
encoding="utf-8",
)
def add(self, text: str) -> Todo:
next_id = max((t.id for t in self._items), default=0) + 1
todo = Todo(id=next_id, text=text)
self._items.append(todo)
self._save()
logger.info("添加 #%d %s", next_id, text)
return todo
def list(self, only_open: bool = False) -> list[Todo]:
return [t for t in self._items if not (only_open and t.done)]
def mark_done(self, todo_id: int) -> Todo | None:
for t in self._items:
if t.id == todo_id:
t.done = True
self._save()
logger.info("完成 #%d", todo_id)
return t
return None
cli.py:用 click
import logging
import click
from pathlib import Path
from .store import JsonTodoRepo
DATA = Path.home() / ".todo.json"
@click.group()
@click.version_option()
def cli():
"""简单的命令行 todo 工具。"""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@cli.command()
@click.argument("text")
def add(text: str):
"""添加一条新的 todo。"""
repo = JsonTodoRepo(DATA)
todo = repo.add(text)
click.echo(f"✓ #{todo.id} {todo.text}")
@cli.command(name="list")
@click.option("--open", "only_open", is_flag=True, help="只显示未完成")
def list_cmd(only_open: bool):
"""列出所有 todo。"""
repo = JsonTodoRepo(DATA)
todos = repo.list(only_open=only_open)
if not todos:
click.echo("(暂无)")
return
for t in todos:
mark = "x" if t.done else " "
click.echo(f"[{mark}] #{t.id} {t.text}")
@cli.command()
@click.argument("todo_id", type=int)
def done(todo_id: int):
"""标记某个 todo 为完成。"""
repo = JsonTodoRepo(DATA)
if repo.mark_done(todo_id) is None:
click.echo(f"✗ 找不到 #{todo_id}")
raise SystemExit(1)
click.echo(f"✓ 完成 #{todo_id}")
if __name__ == "__main__":
cli()
tests/test_store.py:pytest
import pytest
from pathlib import Path
from todo.store import JsonTodoRepo
@pytest.fixture
def repo(tmp_path: Path):
return JsonTodoRepo(tmp_path / "todo.json")
def test_add_and_list(repo):
repo.add("买菜")
repo.add("写代码")
todos = repo.list()
assert len(todos) == 2
assert todos[0].text == "买菜"
def test_mark_done(repo):
todo = repo.add("买菜")
result = repo.mark_done(todo.id)
assert result.done is True
def test_mark_done_not_found(repo):
assert repo.mark_done(999) is None
@pytest.mark.parametrize("only_open, expected_count", [
(False, 2),
(True, 1),
])
def test_filter(repo, only_open, expected_count):
a = repo.add("a")
repo.add("b")
repo.mark_done(a.id)
assert len(repo.list(only_open=only_open)) == expected_count
pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "wadely-todo"
version = "0.1.0"
description = "简单 Todo 命令行工具"
requires-python = ">=3.10"
dependencies = ["click>=8"]
[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]
[project.scripts]
todo = "todo.cli:cli"
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "SIM"]
[tool.mypy]
strict = true
跑通一遍
pip install -e ".[dev]"
ruff check .
mypy src
pytest
todo add "试试"
todo list
用了多少高级知识点
- 包结构 / pyproject.toml
- dataclass + Protocol
- 类型注解
- pathlib
- logging
- click(库选型)
- pytest + fixture + parametrize
- ruff + mypy 工具链
接下来去哪
恭喜!你已经掌握了 Python 工程化的全套知识。
下一册「Python AI 教程」——从 NumPy 到 LLM API 到 Agent 实战。