起点

基础课程第 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 实战。