本章

进阶篇 / 第 13 章

第 13 章 · 类型注解与代码规范

本章导读:类型注解(type annotation)不是 Python 的运行时负担,而是「写给人、也写给工具看」的契约。本章从注解语法讲到 typing 各模块、结构化子类型 Protocol、dataclass 与类型化 NamedTuple,再讲怎么用 mypy / pyright 做静态检查,最后落到工程规范:PEP 8、ruff、black、docstring 写法与项目配置。学完本章,你不仅代码"长得像样",还能让编辑器给出智能补全、让 CI 在合并前拦下类型错误。

类型注解是什么,不是什么

先澄清三个常见误解:

  1. 注解不会改变运行时行为。def f(x: int) -> int: return x 与 def f(x): return x 生成的字节码完全一样,Python 解释器不检查注解是否被遵守。
  2. 注解不会自动转换。def f(x: int) 传入 "abc" 不会报错,x 依然是字符串。
  3. 注解不是为了别的语言。它主要是给人看(文档 + 可读性)、给工具看(mypy / IDE / pydantic / 序列化库)。

那为什么现代 Python 项目几乎都写注解?因为它换来的东西太值了:

收益说明谁在用
静态检查不运行代码就能发现类型错误mypy、pyright、basedpyright
IDE 智能补全输入 obj. 只列出真实存在的属性VS Code / PyCharm / Jedi
跳转定义点方法名直达实现IDE 通用
运行时校验从注解生成校验器pydantic、dataclasses、beartype、typeguard
序列化 / ORM注解即字段定义SQLAlchemy、Pydantic、dataclasses
文档Sphinx / mkdocstrings 直接读取文档工具链

1.1 从最小例子开始

def add(a: int, b: int) -> int:
    """两数相加。"""
    return a + b

print(add(1, 2))              # 3
print(add(1, "2"))            # TypeError: can only concatenate str (not "int") to str

注意:类型错误仍会在运行到那行时才爆出来,因为运算本身要求类型匹配。真正的「静默」错误例如 a: int = "5" 完全不会报错:

a: int = "5"        # 运行时完全合法,a 是 str
print(type(a))             # <class 'str'>
常见坑「我加了 : int 怎么还能传字符串」——因为注解是承诺,不是检查。要真的检查,请看 s7 的 beartype / typeguard 或第 17–18 章的 pydantic。

1.2 为什么现在应该用 3.10+ 的写法

Python 3.9 起可用内置泛型 list[str],3.10 起可用 X | Y,3.12 起支持 type 语句(本教程基于 3.12)。老的 typing.List[str]、typing.Optional[str]、typing.Union[str, int] 仍可用但已过时:

能力旧写法(typing)新写法(推荐)起始版本
列表List[str]list[str]3.9
字典Dict[str, int]dict[str, int]3.9
元组Tuple[int, ...]tuple[int, ...]3.9
可选Optional[str]str | None3.10
联合Union[A, B]A | B3.10
类型别名X = TypeVar("X") 约束写法type X = ...3.12
自身引用"Tree" 字符串from __future__ import annotations3.7+
# 旧写法,仍然可用但不推荐
from typing import List, Dict, Optional, Union

def old(xs: List[str], meta: Optional[Dict[str, int]]) -> Union[int, str]:
    return len(xs) or str(meta)

# 新写法(Python 3.12 推荐)
def new(xs: list[str], meta: dict[str, int] | None = None) -> int | str:
    return len(xs) or str(meta)

print(old(["a"], None), new(["a"], None))

输出:

1 1
要点from __future__ import annotations(PEP 563 精神)让所有注解变成字符串延迟求值,因此可以前向引用尚未定义的类,且几乎零开销。本教程所有示例都在文件顶部加这一行。3.12 中它已是默认倾向写法。

基础注解语法全表

2.1 变量注解

count: int = 0                 # 类型 + 赋值
name: str                       # 只声明类型,不赋值(仅存在于 __annotations__)
items: list[str] = []           # 空的必须给默认值,或先声明再赋值
mapping: dict[str, float] = {}  # key/value 都标注
maybe: int | None = None        # 可空
matrix: list[list[int]] = [[1, 2], [3, 4]]

print(count, name is None, items, mapping, maybe, matrix)
print(type(None) is type(None))

输出:

0 True [] {} None [[1, 2], [3, 4]]
True
常见坑类属性注解若在类体里直接赋值,会成为类属性,被所有实例共享:
陷阱示例
class Bad:
    items: list = []          # 这是类属性,所有实例共享同一个列表!
    count: int = 0

a, b = Bad(), Bad()
a.items.append(1)
print(b.items)                 # [1]  —— b 也变了
正确做法是用 __init__ 里 self.items: list = [](实例属性),或用 @dataclass + field(default_factory=list)。

2.2 函数注解

from typing import Sequence, Callable, Iterable

def mean(xs: Sequence[float]) -> float:
    """参数接受任何序列,返回浮点均值。"""
    if not xs:
        raise ValueError("空序列无均值")
    return sum(xs) / len(xs)

def retry(times: int = 3, delay: float = 1.0) -> Callable[[Callable], Callable]:
    """返回装饰器:为函数调用加失败重试。"""
    def deco(fn: Callable) -> Callable:
        import time
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last = e
                    time.sleep(delay)
            raise last
        wrapper.__name__ = fn.__name__
        wrapper.__doc__ = fn.__doc__
        return wrapper
    return deco

def iter_even(n: int) -> Iterable[int]:
    for i in range(n):
        if i % 2 == 0:
            yield i

print(f"{mean([1, 2, 3]):.2f}")
print(list(iter_even(7)))

输出:

2.00
[0, 2, 4, 6]
要点容器注解的三个最常见语义约定:list[str] = 「我只会往里加、不换整个列表」;Sequence[str] = 「可以接收 list/tuple/str,只读遍历」;Iterable[str] = 「只要能迭代一次即可」。参数位置放宽(用 Sequence)比收紧(用 list)更友好,这是「注解是承诺」原则的体现。

2.3 *args / **kwargs 的注解

# 位置参数的注解写在 * 之后,** 之前的名字作为元组类型
def add_all(first: int, /, *rest: int, factor: int = 1, **tags: str) -> int:
    total = first + sum(rest)
    return total * factor

print(add_all(1, 2, 3, factor=10, tag_a="x"))

输出:

60

若接受任意关键字:

from typing import Any

def log_event(name: str, **payload: Any) -> None:
    print(f"[{name}] {payload}")

log_event("login", user="alice", ip="10.0.0.1")

输出:

[login] {'user': 'alice', 'ip': '10.0.0.1'}

typing 模块核心工具

3.1 Any / cast / NoReturn

from typing import Any, NoReturn, TypeVar, cast

T = TypeVar("T")       # 类型变量:泛型占位符

def first_or(xs: list[T], default: T | None = None) -> T | None:
    return xs[0] if xs else default

def parse_int(s: str) -> int | NoReturn:   # NoReturn = 永不返回
    s = s.strip()
    if s.isdigit():
        return int(s)
    raise ValueError(f"不是整数: {s}")

def dump(obj: Any) -> str:                # Any = 放弃检查,慎用
    import json
    return json.dumps(obj, ensure_ascii=False)

print(first_or([1.5, 2.0]))                 # 1.5
print(parse_int(" 42 "))                     # 42
print(dump({"a": 1}))                        # {"a": 1}
try:
    parse_int("abc")
except ValueError as e:
    print("失败:", e)

# cast:告诉检查器「我知道它是 X」,不做运行时转换
value: object = "42"
print(cast(int, value) + 1)                # 运行时是 str + int -> TypeError
常见坑cast 不是类型转换函数。上面最后一行会抛 TypeError: can only concatenate str (not "int") to str,因为 cast 只在静态层面「撒谎」,运行时原样返回。cast 的正确用途是「我已经在别处校验过了,但类型检查器不知道」。

3.2 Literal / Final / ClassVar / Annotated

from typing import Literal, Final, ClassVar, Annotated

# Literal:只能取字面量那几个值
type Direction = Literal["north", "south"]

def move(d: Direction, step: int = 1) -> str:
    return f"{d}*{step}"

print(move("north", 2))     # north*2
try:
    move("up")              # 静态检查会报错,运行时正常
except Exception:
    pass

# Final:常量,赋值后不可改(检查器层面)
TIMEOUT: Final[int] = 30

# ClassVar:类变量,不参与实例 __init__
class Config:
    ENV: ClassVar[str] = "prod"
    debug: bool = False

print(TIMEOUT, Config.ENV, Config().ENV, Config.debug)

# Annotated:给类型附加元数据(校验、序列化、单位等)
Port = Annotated[int, "取值范围 1-65535"]
def serve(port: Port) -> str:
    return f"listening on :{port}"
print(serve(8080))

输出:

north*2
30 prod prod False
listening on :8080
要点Annotated[T, ...] 是现代类型系统的「元数据携带者」:beartype 用它做运行时校验,pydantic 用它加验证规则与 JSON Schema 描述,FastAPI 用它生成接口文档。这是「一个注解,多方复用」的关键机制。

3.3 TypeVar 与泛型

from typing import TypeVar, Generic, Sequence

T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")

class Stack(Generic[T]):
    """一个类型安全的栈。"""
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        if not self._items:
            raise IndexError("栈空")
        return self._items.pop()
    def peek(self) -> T | None:
        return self._items[-1] if self._items else None

s: Stack[int] = Stack()
s.push(1); s.push(2)
print(s.pop(), s.peek())
s.push("错误类型")            # 静态检查会报错;运行时不报错

# 通用函数泛型
def first_item(data: Sequence[T]) -> T:
    if not data:
        raise ValueError("空")
    return data[0]

print(first_item([1, 2]), first_item(["a", "b"]))

输出:

2 1
1 a

3.4 ParamSpec 与 Concatenate(进阶可选)

from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")     # 代表「一整组参数签名」
R = TypeVar("R")

def with_logging(fn: Callable[P, R]) -> Callable[P, R]:
    """给任意函数加日志,签名完全保持。"""
    import functools
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"调用 {fn.__name__}{args}{kwargs}")
        return fn(*args, **kwargs)
    return wrapper

@with_logging
def add(a: int, b: int) -> int:
    return a + b

print(add(1, 2))
print(add.__name__)          # 名字被 wraps 保留

输出:

调用 add(1, 2){}
3
add

Protocol:结构化子类型

Python 是鸭子类型语言:只要有 quack() 就能当鸭子。但鸭子类型没有名字,注解写不了。结构化子类型(structural subtyping)由 Protocol 引入:只要方法签名匹配,就算实现了这个「接口」,且不需要显式继承。

protocol_demo.py

from typing import Protocol, runtime_checkable, Iterable

@runtime_checkable
class Readable(Protocol):
    """任何能给我一个字符串的就算数。"""
    def read(self, n: int = -1) -> str: ...

@runtime_checkable
class HasName(Protocol):
    name: str                     # 属性也可以

def dump(obj: Readable) -> str:
    return obj.read().strip().upper()

class FileLike:
    def __init__(self, text: str) -> None:
        self._text = text
    def read(self, n: int = -1) -> str:
        return self._text if n < 0 else self._text[:n]

class Response:                   # 和 FileLike 毫无继承关系
    def read(self, n: int = -1) -> str:
        return "HTTP/1.1 200 OK\nContent-Type: text/html"

print(dump(FileLike("  hello  ")))
print(dump(Response()))

# runtime_checkable 让你能在运行时 isinstance(只检查方法名存在)
print(isinstance(FileLike("x"), Readable))     # True
print(isinstance(42, Readable))                 # False

class Product:
    name = "键盘"
print(isinstance(Product(), HasName))           # True(结构匹配)

输出:

HELLO
HTTP/1.1 200 OK
CONTENT-TYPE: TEXT/HTML
True
False
True
能力说明是否推荐
class P(Protocol)接口类,不能实例化✓ 核心用法
@runtime_checkable允许 isinstance(只查方法名,不查签名)△ 谨慎用,误报率高
显式继承 Protocolclass Impl(P),变成名义子类型△ 一般不需要
协议继承class Readable2(Readable, Protocol) 组合多个协议✓ 常用
泛型协议class Container(Protocol[T])✓ 容器抽象常用
技巧Protocol 最大的价值是依赖倒置:业务函数声明「我需要一个能 read 的东西」,于是测试时你可以传一个假的、只实现了 read 的小类,而不必要求它继承任何东西——这就是第 12 章「组合优于继承」的类型化表达。

泛型 Protocol:容器抽象

from typing import Protocol, TypeVar, Iterator, runtime_checkable

T_co = TypeVar("T_co", covariant=True)     # 协变:只出现在返回位置

@runtime_checkable
class Iterable_(Protocol[T_co]):
    def __iter__(self) -> Iterator[T_co]: ...

@runtime_checkable
class Sized(Protocol):
    def __len__(self) -> int: ...

def longest(items: Iterable_["Sized"]) -> Sized:
    return max(items, key=len)             # 要求元素有 __len__

class Bag:
    def __init__(self, items: list) -> None:
        self.items = items
    def __len__(self) -> int:
        return len(self.items)

print(longest([[1], [1, 2, 3], [1, 2]]).items)      # [1, 2, 3]
print(isinstance([1, 2], Iterable_))                # True
print(isinstance("abc", Iterable_))                # True(str 也是 Iterable)

输出:

[1, 2, 3]
True
True

dataclass 与类型化数据结构

第 12 章讲了 dataclass,本章从类型角度补齐它的注解用法,并引入它的两个「表亲」:TypedDict 与 NamedTuple。

5.1 dataclass:注解即构造函数签名

typed_dataclass.py

from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from decimal import Decimal
from typing import ClassVar

class Status(Enum):
    DRAFT = "draft"
    ACTIVE = "active"

@dataclass(frozen=True, slots=True, order=True)
class Product:
    sku: str
    name: str
    price: Decimal
    stock: int = 0
    status: Status = Status.DRAFT
    tags: list[str] = field(default_factory=list)

    KIND: ClassVar[str] = "product"        # 不进 __init__

    def __post_init__(self) -> None:
        if self.price < 0:
            raise ValueError("价格不能为负")
        if self.stock < 0:
            raise ValueError("库存不能为负")

    @property
    def is_available(self) -> bool:
        return self.stock > 0 and self.status is Status.ACTIVE

    def discounted(self, percent: Decimal) -> Product:
        return Product(self.sku, self.name, self.price * (1 - percent), self.stock)

p1 = Product("KB-001", "机械键盘", Decimal("299.00"), stock=12)
p2 = Product("MS-002", "鼠标", Decimal("99.50"), status=Status.ACTIVE)
print(p1, p1.KIND, Product.KIND)
print(p1.is_available, p2.is_available)
print(p1 < p2)                                  # order=True -> 可比较
print(p1.discounted(Decimal("0.1")))
print(hash(p1) == hash(p1))                      # frozen -> 可哈希
try:
    Product("X", "坏", Decimal("-1"))
except ValueError as e:
    print("创建失败:", e)

输出:

Product(sku='KB-001', name='机械键盘', price=Decimal('299.00'), stock=12, status=<Status.DRAFT: 'draft'>, tags=[])
product product
False True
False
Product(sku='KB-001', name='机械键盘', price=Decimal('269.10'), stock=12, status=<Status.DRAFT: 'draft'>, tags=[])
True
创建失败: 价格不能为负
常见坑使用 frozen=True 时不要在 __post_init__ 里给实例赋新属性——会抛 FrozenInstanceError。需要「可变计算字段」时用 field(init=False, default=..., compare=False) 预声明,或者干脆放弃 frozen 改用普通 dataclass + 外部缓存。

5.2 TypedDict:描述 dict 的形状

from typing import TypedDict, Required, NotRequired

class UserDict(TypedDict):
    name: Required[str]          # 必填
    age: NotRequired[int]        # 可选(Python 3.11+ 内置)
    email: str                    # total=False 关闭时也可选

def register(u: UserDict) -> str:
    return f"{u['name']}({u.get('age', '未知年龄')})"

print(register({"name": "张三", "age": 18}))
print(register({"name": "李四"}))
print(register({"name": "王五", "email": "w@x.com"}))

# 运行时它就是普通 dict
u: UserDict = {"name": "赵六", "age": 22}
print(type(u), u["name"], sorted(u.keys()))

输出:

张三(18)
李四(未知年龄)
王五(未知年龄)
<class 'dict'> 赵六 ['age', 'name']
要点TypedDict 只是给 dict 加了类型约束,运行时零成本、无校验——写错键名仍会正常运行,只是静态检查会报错。它比 dict[str, Any] 信息量大得多,是描述 API 返回体(尤其是 JSON)的首选。

5.3 NamedTuple:轻量不可变记录

from typing import NamedTuple

class Point(NamedTuple):
    x: int
    y: int
    label: str = "原点"

p = Point(1, 2)
print(p, p.x, p[1], len(p))
print(p._asdict())                    # 转 dict
q = p._replace(y=9)                   # 复制并改字段
print(q)
print(p + (1,))                        # 当元组拼接

# 与默认值配合良好,适合表示「函数返回多值」
def divmod_int(a: int, b: int) -> tuple[int, int]:
    if b == 0:
        raise ZeroDivisionError
    return a // b, a % b

r = divmod_int(17, 5)
print(r, type(r))

输出:

Point(x=1, y=2, label='原点') 1 2 3
{'x': 1, 'y': 2, 'label': '原点'}
Point(x=1, y=9, label='原点')
(1, 2, '原点')
(3, 2) <class 'tuple'>
需求选择是否可变额外能力
普通业务对象,要方法、要校验@dataclass默认可变,可 frozen方法、property、继承
极简记录,追求性能/紧凑NamedTuple不可变tuple 全部能力 + 字段名
描述 dict 的键值形状TypedDictdict 本身可变键名静态检查
必须来自其他数据源(API/文件)pydantic.BaseModel可变运行时校验 + 序列化
固定的一组常量Enum不可变唯一性、可比较

5.4 用属性访问代替魔法字典

当一个函数要返回很多字段时,裸元组会让人「第 3 个值到底是啥」。用 NamedTuple 或 dataclass 返回,代码可读性立刻提升:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Stats:
    count: int
    total: float
    mean: float
    minimum: float
    maximum: float

def compute(xs: list[float]) -> Stats:
    return Stats(
        count=len(xs),
        total=round(sum(xs), 2),
        mean=round(sum(xs) / len(xs), 2),
        minimum=min(xs),
        maximum=max(xs),
    )

s = compute([3, 1, 4, 1, 5, 9, 2, 6])
print(s)
print(f"均值 {s.mean},极差 {s.maximum - s.minimum:.1f}")

输出:

Stats(count=8, total=31.0, mean=3.88, minimum=1, maximum=9)
均值 3.88,极差 8.0

静态类型检查:mypy 与 pyright

注解本身不检查任何东西,必须配一个检查器(type checker)。两大主流:

工具开发方特点适合
mypyPython 官方生态 / Jukka Lehtosalo历史最久、插件多、渐进式友好存量项目、CI 集成
pyrightMicrosoft速度快、类型推断更准、语言服务器完备VS Code 编辑器、新项目
basedpyrightpyright 社区增强分支更严格、默认规则更合理、诊断更友好追求严格的新项目
ty Astral(ruff 同厂)极快,新一代检查器CI 快速反馈

6.1 安装与最小配置

# 安装
pip install mypy            # 或 pip install pyright / basedpyright

# pyproject.toml
# [tool.mypy]
# python_version = "3.12"
# strict = true               # 最严格模式,强烈推荐新项目开启
# warn_unused_configs = true
# disallow_any_generics = true
# no_implicit_reexport = false
# exclude = ["^build/", "^\\.venv/"]

# 运行
mypy src/
mypy --strict src/

6.2 一个能被 mypy 抓出错误的例子

# demo_types.py —— 故意埋了 4 个类型错误
def greet(name: str) -> str:
    return "你好," + name

age = "30"                 # 错误 1:推断为 str,却当作数字用
result = greet(42)         # 错误 2:给 str 参数传 int
items: list[int] = [1, 2]
items.append("三")         # 错误 3:list[int] 里塞 str

def get_total(prices: list[float]) -> float:
    return sum(prices) / len(prices)   # 错误 4:prices 可能为空(如果开了 strict 的部分检查)

def first(xs: list[str]) -> str:
    return xs[0]           # 错误 5:可能 IndexError(strict 下会提示需 NoReturn 处理)

mypy 输出(大致):

demo_types.py:4: error: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
demo_types.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
demo_types.py:7: error: Argument 1 to "append" of "list" has incompatible type "str"; expected "int"  [arg-type]
demo_types.py:10: error: Unsupported operand types for / ("float" and "int")  [operator]
demo_types.py:13: error: Returning Any from function declared to return "str"  [no-any-return]
要点前三个错误如果不写注解,mypy 只能靠推断发现一部分(比如 greet(42) 抓不到,因为没注解时是 Any)。「注解 + 检查器」是一套组合拳,缺一不可。

6.3 渐进式严格化策略

存量项目不可能一天切成 --strict。推荐路径:

  1. 第 1 步:只开基础检查,mypy src/,把存量包的报错用 # type: ignore 或 exclude 隔离。
  2. 第 2 步:新代码强制注解(CI 里对变更文件跑 strict)。
  3. 第 3 步:逐包把 # type: ignore 消掉,加 disallow_untyped_defs。
  4. 第 4 步:全局开 strict = true,此时覆盖率应已在 90%+。
# config.py
# import untyped
# untyped = ["legacy/", "third_party_vendor/"]

# tool: mypy
# [mypy-legacy.*]
# disallow_untyped_defs = false
# ignore_errors = true

6.4 CI 中集成

# .github/workflows/ci.yml(片段)
# name: type-check
# on: [push, pull_request]
# jobs:
#   check:
#     runs-on: ubuntu-latest
#     steps:
#       - uses: actions/checkout@v4
#       - uses: actions/setup-python@v5
#         with: { python-version: "3.12" }
#       - run: pip install -r requirements-dev.txt
#       - run: ruff check .
#       - run: mypy --strict src
#       - run: pytest -q

6.5 什么时候注解反而添乱

  • 写胶水脚本、一次性代码:# type: ignore 反而比注解更省事。
  • 大量 Any 流(动态 dict / JSON):写了注解等于没写,还给人虚假的安全感。
  • 热循环里的纯数值计算:int/float 注解在纯 Python 里没有运行时成本,但会牺牲一点「写起来爽」的手感。
  • 与运行时校验混用时要小心:注解声明 int,实际还需要 beartype 之类的工具在入口强制,否则承诺是空的。

从注解到运行时校验

注解是静态承诺。要在运行时强制它,需要专门的库。beartype 零配置、性能好,适合函数入口;pydantic 功能最全,适合数据模型。

beartype_demo.py

from beartype import beartype   # pip install beartype

@beartype
def divide(a: int, b: int) -> float:
    """被 beartype 装饰后,参数类型会被运行时强制。"""
    return a / b

print(divide(7, 2))          # 3.5
try:
    divide("8", 2)           # 函数体都没执行就被拦下
except Exception as e:
    print(type(e).__name__, ":", e)

输出:

3.5
BeartypeCallHintParamViolation: Function divide() parameter a must be of type int, not str

pydantic_demo.py(预告第 18 章)

from pydantic import BaseModel, Field, field_validator

class UserIn(BaseModel):
    """从 JSON 反序列化的输入模型:注解即校验规则。"""
    name: str = Field(min_length=1, max_length=50, description="用户名")
    age: int = Field(ge=0, le=150)
    email: str | None = None

    @field_validator("email")
    @classmethod
    def check_email(cls, v: str | None) -> str | None:
        if v and "@" not in v:
            raise ValueError("邮箱格式不正确")
        return v

u = UserIn(name="张三", age=18, email="z@x.com")
print(u.model_dump())
try:
    UserIn(name="", age=200)
except Exception as e:
    print("校验失败:", str(e).splitlines()[0])
print(UserIn.model_json_schema()["properties"]["name"])

输出:

{'name': '张三', 'age': 18, 'email': 'z@x.com'}
1 validation error for UserIn
{'maxLength': 50, 'minLength': 1, 'type': 'string', 'title': 'Name', 'description': '用户名'}
要点这就是「注解即契约」的最高形态:同一份 UserIn 同时是类型声明、运行时校验器、JSON Schema 源、以及 OpenAPI 文档源。第 18 章讲 FastAPI 时会完整展开。

PEP 8 与代码风格规范

PEP 8(Python Enhancement Proposal 8)是 Python 官方风格指南。核心原则只有一句:一致性优先于个人偏好。团队里的风格统一比「哪种更好」重要得多。

8.1 硬性规则速查(缩进、行长、命名)

项目规则说明
缩进4 个空格严禁混用 Tab,PEP 8 只允许空格
行宽不超过 79(现代项目常放宽到 88–100)黑色格式化器默认 88
续行对齐括号 / 缩进 4 / 垂直对齐括号内换行优于反斜杠
函数/类名snake_casedef calculate_total()
常量UPPER_SNAKE_CASEMAX_RETRIES = 3
类名CapWordsclass UserRepository
变量/函数snake_case含下划线表示「内部」
包/模块short_lower_case避免下划线,尽量短
私有成员_leading_underscorePython 没有真私有,只是约定
类内强私有__double_underscore触发名字改写(name mangling)
公开成员public不带下划线即「公开 API」
导入标准库 / 第三方 / 本地,三组空行分隔组内按字母序
运算符两侧两侧各一个空格(除括号紧邻时)a = b + c,f(x) 不加

8.2 好代码与坏代码对照

# ❌ 反例集合
import os,sys,json
from typing import*
def process(a,b,c=1):
    d={}
    for x in a:
        if x['type']=='user' and x.get('active',True)==True:
            d[x['id']]=x
    return json.dumps(d)
class user_account:
    def __init__(self,ID,name):
        self.ID=ID;self.name=name
# ✅ 正例
"""用户账户处理工具。

提供按类型过滤并序列化账户的函数。
"""

import json
from typing import Any


def process(records: list[dict[str, Any]], threshold: int = 1) -> str:
    """筛选出活跃用户并序列化为 JSON。

    Args:
        records: 原始记录列表。
        threshold: 保留数量上限。

    Returns:
        JSON 字符串。
    """
    filtered: dict[int, dict[str, Any]] = {}
    for record in records:
        is_user = record["type"] == "user"
        is_active = record.get("active", True)
        if is_user and is_active:
            filtered[record["id"]] = record
            if len(filtered) >= threshold:
                break
    return json.dumps(filtered, ensure_ascii=False, indent=2)


class UserAccount:
    """用户账户。"""

    def __init__(self, user_id: int, name: str) -> None:
        self.user_id = user_id
        self.name = name

8.3 空白与换行的心理线

# ✅ 函数内部逻辑用空行分组,让阅读者看到「步骤」
def process_payment(order, gateway):
    validate_order(order)                    # 第一步:校验
    amount = calculate_amount(order)

    if amount <= 0:                        # 第二步:分支处理
        raise ValueError("金额异常")
    receipt = gateway.charge(order.user_id, amount)

    audit.log(receipt)                        # 第三步:记录
    return receipt


# ❌ 反例:全挤在一起,没有视觉分组
def process_payment(order, gateway):
    validate_order(order)
    amount = calculate_amount(order)
    if amount <= 0:
        raise ValueError("金额异常")
    receipt = gateway.charge(order.user_id, amount)
    audit.log(receipt)
    return receipt
技巧PEP 8 说「用空行表达逻辑段落」。经验法则:每个空行前代表一个完整的「做什么」步骤。读代码时你几乎不用思考就能看出函数分几步做,代码就是清晰的。

格式化与静态检查工具链

现代 Python 项目的风格工具已经收敛到两个主角:ruff(lint + 部分 format)与 black(纯格式化)。

9.1 工具分工

工具类型能做什么不能做什么
black格式化器自动排版,风格统一,争论终结者不查错、不管 import 顺序(默认)
ruff format格式化器black 的更快替代,兼容 99%同 black
ruff checkLinter未使用导入、bug 模式、简化建议、安全检查不改代码风格(除非 --fix)
flake8Linter传统方案,已基本被 ruff 取代速度慢
isort导入排序整理 import 顺序ruff 内置同类规则
pylintLinter非常严格的风格与设计检查噪音多,配置复杂
mypy / pyright类型检查器类型错误不管风格

9.2 一份开箱即用的 pyproject.toml

[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.12"

[dependency-groups]
dev = ["ruff>=0.6", "mypy>=1.11", "pytest>=8", "beartype>=0.20"]

[tool.ruff]
line-length = 88
target-version = "py312"
src = ["src"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "ANN", "D"]
ignore = ["D203", "D213", "ANN401"]     # ANN401: 允许 *args: Any

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "D100", "D103"]     # 测试里允许 assert、免文档字符串

[tool.ruff.lint.pydocstyle]
convention = "google"                     # 文档字符串风格统一

[tool.black]
line-length = 88
target-version = ["py312"]

[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
disallow_untyped_defs = true
pretty = true

说明:

  • E pycodestyle 错误,F Pyflakes,I isort 规则
  • UP pyupgrade:自动提示「List → list」「Union → |」等现代化写法
  • ANN 强制注解,D 强制 docstring,B bugbear(可变默认参数等)
  • SIM 可简化代码,RUF ruff 特有规则

9.3 常用命令

# 格式化:把代码排成统一风格
ruff format .              # 或 black .

# 静态检查 + 自动修复
ruff check . --fix         # 修能修的(未使用导入等)
ruff check .               # 只报告不修改(适合 CI)

# 类型检查
mypy --strict src
pyright src

# 全部一起跑(本地 & CI 保持一致)
pre-commit run --all-files

# .pre-commit-config.yaml
# repos:
#   - repo: https://github.com/astral-sh/ruff-pre-commit
#     rev: v0.6.9
#     hooks:
#       - id: ruff          # 修 import 顺序、未使用变量
#         args: [--fix]
#       - id: ruff-format   # 统一格式
#   - repo: https://github.com/pre-commit/mirrors-mypy
#     rev: v1.11.2
#     hooks:
#       - id: mypy
#         additional_dependencies: [types-requests]
常见坑① 不要在同一个项目里同时用 black 和 ruff format 之外的第三方格式化(如 autopep8 + yapf),它们会互相打架。② ruff check --fix 有时会改动代码逻辑边界(如重写可迭代为生成器表达式),修复后务必 review diff。③ 格式化工具会让「代码行数变多/变少」,行数不能作为代码量指标。

Docstring:写给三个月后的自己

Docstring 是模块/类/函数的三引号字符串,遵循特定格式后可以被 Sphinx、IDE、help() 读取。

10.1 三种风格对比

风格示例谁在用
GoogleArgs: / Returns: / Raises:Google 内部、多数中文教程
NumpyParameters 表格区科学计算、数据分析库
reST / Sphinx:param x: :returns:传统 Python 库、Read the Docs
要点团队必须统一选一种,并在 [tool.ruff.lint.pydocstyle] 里设置 convention 让工具强制。本教程全文采用 Google 风格。

10.2 完整模板

"""把用户数据导出为多种格式的小工具。

模块级 docstring 用三引号,概述一句话,说明用途。
可以有多行段落。
"""

import csv
import io


def export_users(users: list[dict], fmt: str = "csv") -> str:
    """把用户列表导出为指定格式的字符串。

    Args:
        users: 用户字典列表,每个字典需含 name 与 age 键。
        fmt: 输出格式,支持 "csv" 与 "tsv"。

    Returns:
        格式化后的字符串。

    Raises:
        ValueError: 当 fmt 不是受支持的格式时。

    Example:
        >>> export_users([{"name": "张三", "age": 18}], "csv")
        'name,age\\n张三,18'
    """
    if fmt not in ("csv", "tsv"):
        raise ValueError(f"不支持的格式: {fmt}")

    buf = io.StringIO()
    writer = csv.DictWriter(buf, fieldnames=["name", "age"], delimiter="\t" if fmt == "tsv" else ",")
    writer.writeheader()
    writer.writerows(users)
    return buf.getvalue()


class UserStore:
    """内存中的用户存储。

    Attributes:
        _data: 内部字典,按 id 索引用户。
    """

    def __init__(self) -> None:
        self._data: dict[int, dict] = {}

    def add(self, uid: int, **fields: object) -> dict:
        """新增或更新一个用户。

        Args:
            uid: 用户唯一标识。
            **fields: 用户字段,如 name、age。

        Returns:
            存储后的完整用户字典。
        """
        self._data[uid] = {"id": uid, **fields}
        return self._data[uid]


if __name__ == "__main__":
    print(export_users([{"name": "张三", "age": 18}]))
    print(UserStore().add(1, name="李四", age=20))

输出:

name,age
张三,18
{'id': 1, 'name': '李四', 'age': 20}

10.3 什么该写、什么不必写

对象要不要写理由
模块要(简短)告诉读者这个模块解决什么问题
公共类要说明职责、不变量、典型用法
公共函数要Args / Returns / Raises 三件套
魔术方法要说明返回什么语义
私有函数(下划线开头)可选逻辑一眼能懂就不必凑字数
getter/setter不必重复注解 + 属性名已经说清
测试函数通常不写函数名 test_xxx_when_yyy 已表达意图
常见坑docstring 只写在函数第一行才有文档工具识别;写在别的位置(先写了别的语句)会被忽略。另外,docstring 不是注释——它会被 help() 读取并出现在文档里,写错就会误导人。

10.4 类型注解与 docstring 的分工

from typing import Optional

def parse_config(text: str, *, strict: bool = True) -> Optional[dict[str, str]]:
    """解析 "k=v" 形式的配置文本。

    Args:
        text: 配置文本,每行一个键值对。

        strict: 为 True 时遇到无法解析的行直接抛异常;
            为 False 时跳过并继续。

    Returns:
        解析得到的字典;strict 为 False 且全部行都无效时返回 None。
    """
    result: dict[str, str] = {}
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            if strict:
                raise ValueError(f"无效行: {line}")
            return None
        k, v = line.split("=", 1)
        result[k.strip()] = v.strip()
    return result or None

print(parse_config("a=1\n# c\nb=2"))
print(parse_config("bad", strict=False))

输出:

{'a': '1', 'b': '2'}
None
要点分工原则:注解说「类型是什么」,docstring 说「为什么这样设计、有哪些坑」。类型写 Optional[dict[str, str]],但「strict=False 时返回 None」这类语义必须写在 docstring 里,注解表达不了。

综合工程示例:一个类型良好的项目

把本章所有内容拼成一个可运行的小项目,展示「类型 + 规范」如何协同。

inventory.py

"""库存管理核心模块。

演示:Protocol 依赖注入 + dataclass 数据模型 + Enum 状态
+ 完整注解 + Google 风格 docstring。
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
from typing import Any, Iterable, Iterator, Literal, Protocol

# ——— 依赖抽象(Protocol)———

class Clock(Protocol):
    """时间来源抽象,便于测试时注入假时钟。"""
    def now_iso(self) -> str: ...

class Storage(Protocol):
    """持久化抽象:任何能存/取记录的都可注入。"""
    def save(self, record: dict[str, Any]) -> None: ...
    def load_all(self) -> list[dict[str, Any]]: ...

# ——— 具体实现 ———

class SystemClock:
    """使用真实系统时间的时钟。"""
    def now_iso(self) -> str:
        from datetime import datetime
        return datetime.now().isoformat(timespec="seconds")

class MemoryStorage:
    """内存存储,仅用于演示与测试。"""
    def __init__(self) -> None:
        self._rows: list[dict[str, Any]] = []
    def save(self, record: dict[str, Any]) -> None:
        self._rows.append(record)
    def load_all(self) -> list[dict[str, Any]]:
        return list(self._rows)

# ——— 领域模型 ———

class Category(Literal["food", "book", "tool"]):
    """商品分类字面量。"""

class ItemState(Enum):
    IN_STOCK = "in_stock"
    LOW = "low"
    OUT = "out"

@dataclass(frozen=True, slots=True)
class Item:
    """一个库存条目(不可变值对象)。"""
    sku: str
    name: str
    price: Decimal
    qty: int
    category: Category = "tool"
    low_threshold: int = field(default=5, compare=False)

    def __post_init__(self) -> None:
        if self.price < 0:
            raise ValueError(f"{self.sku}: 价格不能为负")
        if self.qty < 0:
            raise ValueError(f"{self.sku}: 数量不能为负")

    @property
    def state(self) -> ItemState:
        """根据数量推导库存状态。"""
        if self.qty == 0:
            return ItemState.OUT
        if self.qty <= self.low_threshold:
            return ItemState.LOW
        return ItemState.IN_STOCK

    @property
    def total_value(self) -> Decimal:
        """库存总价值。"""
        return self.price * self.qty

    def to_dict(self) -> dict[str, Any]:
        """转为可 JSON 序列化的字典。"""
        return {
            "sku": self.sku,
            "name": self.name,
            "price": str(self.price),
            "qty": self.qty,
            "category": self.category,
            "state": self.state.value,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Item":
        """从字典还原,非法数据抛 ValueError。"""
        try:
            return cls(
                sku=str(data["sku"]),
                name=str(data["name"]),
                price=Decimal(str(data["price"])),
                qty=int(data["qty"]),
                category=data.get("category", "tool"),
            )
        except (KeyError, ValueError) as e:
            raise ValueError(f"非法商品数据: {data}") from e

# ——— 服务层 ———

class InventoryService:
    """库存服务:管理条目并写入存储。"""

    def __init__(self, storage: Storage, clock: Clock) -> None:
        self._storage = storage
        self._clock = clock
        self._items: dict[str, Item] = {}
        self._log: list[str] = []

    def __iter__(self) -> Iterator[Item]:
        """支持 for item in service。"""
        return iter(self._items.values())

    def add(self, item: Item) -> None:
        """添加或覆盖一个条目。

        Args:
            item: 商品条目。
        """
        self._items[item.sku] = item
        self._storage.save({"op": "add", "at": self._clock.now_iso(), **item.to_dict()})
        self._log.append(f"[{self._clock.now_iso()}] add {item.sku}")

    def find_by_category(self, category: Category) -> list[Item]:
        """按分类筛选。"""
        return [i for i in self._items.values() if i.category == category]

    def low_stock(self) -> list[Item]:
        """返回所有低库存条目。"""
        return [i for i in self._items.values() if i.state is not ItemState.IN_STOCK]

    def total_value(self) -> Decimal:
        """总库存价值。"""
        return sum((i.total_value for i in self._items.values()), Decimal("0"))

    def export_json(self) -> str:
        """导出为 JSON 字符串。"""
        return json.dumps([i.to_dict() for i in self], ensure_ascii=False, indent=2)

    def import_json(self, text: str) -> int:
        """从 JSON 导入,返回成功条数。"""
        ok = 0
        for row in json.loads(text):
            self.add(Item.from_dict(row))
            ok += 1
        return ok


class FakeClock:
    """测试用假时钟:时间由外部控制,逐秒递增。"""
    def __init__(self, start: int = 0) -> None:
        self.t = start
    def now_iso(self) -> str:
        self.t += 1
        return f"2024-01-01T00:00:{self.t:02d}"


def main() -> None:
    """演示入口。"""
    storage, clock = MemoryStorage(), FakeClock()
    svc = InventoryService(storage, clock)

    items: Iterable[Item] = [
        Item("F001", "苹果", Decimal("5.50"), 20, "food", low_threshold=10),
        Item("B001", "Python 入门", Decimal("79.00"), 3, "book", low_threshold=5),
        Item("T001", "螺丝刀", Decimal("15.80"), 0, "tool", low_threshold=2),
    ]
    for it in items:
        svc.add(it)

    print("总价值:", svc.total_value())
    print("食品类:", [i.name for i in svc.find_by_category("food")])
    for i in svc.low_stock():
        print(f"  缺货/低库存: {i.name} ({i.state.value})")

    blob = svc.export_json()
    print(blob[:120], "...")

    svc2 = InventoryService(MemoryStorage(), FakeClock())
    print("重新导入条数:", svc2.import_json(blob))
    print("数量一致:", svc2.total_value() == svc.total_value())


if __name__ == "__main__":
    main()

输出:

总价值: 346.40
食品类: ['苹果']
  缺货/低库存: Python 入门 (low)
  缺货/低库存: 螺丝刀 (out)
[
  {
    "sku": "F001",
    "name": "苹果",
    "price": "5.50",
    "qty": 20,
    "category": "food",
    "state": "in_stock"
  },
  ...
重新导入条数: 3
数量一致: True
技巧这个示例展示了一个重要工程习惯:服务类只依赖 Protocol,具体实现(MemoryStorage / FakeClock)在外部注入。这让单元测试可以零成本替换时钟与存储,不需要 mock 库、不需要数据库。

工程规范清单与常见反模式

12.1 项目风格落地清单

环节工具执行时机
格式化ruff format(或 black)保存时 / pre-commit
静态检查ruff check --fix保存时 / pre-commit
类型检查mypy --strictpre-commit / CI
测试pytestpre-commit / CI
文档mkdocstrings + ruff pydocstyleCI 构建

12.2 反模式清单

  1. 注解撒谎:写 -> int 实返回 str。注解是承诺,写错比不写更糟。
  2. 滥用 Any 逃避思考:把参数全标 Any 等于关掉类型系统,还给人虚假安全感。
  3. 类型别名当全局变量用:UserId = str 在 3.12 应用 type UserId = str,两者在类型层面含义不同(3.12 的 type 是真正的惰性别名)。
  4. 过度注解样板代码:给三行 getter 加三行注解没人感谢你。
  5. 手改格式对抗工具:任何人手工排版去「对抗」black,下次提交全被重排,徒增 diff 噪音。
  6. docstring 与代码不同步:改逻辑不改注释,比没注释更糟。
  7. 类名缩写:class UIMgr 不好;写 class UIManager 或 class Config。
  8. 用 get_ 前缀命名所有方法:Python 不用 get_x(),直接 x() 或 x 属性。
  9. 魔法数字与魔法字符串裸奔:抽成常量或 Enum,让「为什么是这个值」有处可查。
  10. 导入通配符:from x import * 让静态检查完全失效,且易冲突。

12.3 什么时候可以不管规范

规范服务于协作,不服务于教条。以下情况可以放宽:一次性脚本、Jupyter 探索性分析、教学示例(为了突出概念可暂略类型)。但一旦代码进入仓库、超过一周后你自己还要读它,就该按规范走。

要点把工具配进 pre-commit,是让规范「零意志力」执行的关键。一旦全靠自觉,几周后必然滑坡。配置一次,之后每次提交自动检查。

本章小结

  • 类型注解不改变运行时行为,它的价值在于文档、IDE 补全、静态检查、运行时校验与序列化;现代项目应统一使用 list[str]、X | None(3.10+)写法,并在文件顶部加 from __future__ import annotations 支持前向引用。
  • typing 核心:Any(放弃检查,慎用)、cast(静态撒谎,非运行时转换)、NoReturn(永不返回)、Literal(字面量约束)、Final(常量)、ClassVar(类变量)、Annotated(携带元数据供 beartype / pydantic / FastAPI 复用)。
  • 泛型用 TypeVar + Generic[T];装饰器保持签名用 ParamSpec;协变返回位置用 T_co。
  • Protocol 提供结构化子类型:只要方法匹配就算实现,无需继承。它是依赖倒置与「组合优于继承」的类型化表达,配合 @runtime_checkable 可做有限的 isinstance 检查。
  • 参数注解要「放宽」:接收方用 Sequence/Iterable,返回方才用具体的 list。
  • dataclass(可写方法、可继承)、NamedTuple(紧凑不可变)、TypedDict(描述 dict 形状)、Enum(有限取值)、pydantic.BaseModel(需运行时校验时)各有分工。
  • 类型检查器:mypy(生态最全)、pyright(更快更准)、basedpyright(更严格)、ty(极快)。存量项目用「分步严格化」渐进推进,CI 中对变更文件强制 strict。
  • 注解只是承诺,需要 beartype(轻量运行时强制)或 pydantic(全功能)才能真正校验。
  • PEP 8 核心是一致性:4 空格、snake_case 函数、CapWords 类、UPPER 常量、公开 API 不带下划线、续行优先用括号、import 分三组、空行表达逻辑段落。
  • 工具链现代标准是 ruff(check + format)+ mypy + pytest,全部挂在 pre-commit 与 CI 上;不要再引入 flake8/pylint/autopep8 的混搭组合。
  • docstring 统一用 Google 风格:模块说用途,函数写 Args / Returns / Raises / Example,类型归注解、语义归 docstring。

练习题

  1. 为第 5 章的数据结构补全注解:写一个 Stack[T] 与 Queue[T] 泛型类,要求:泛型参数 T;push/pop/peek/__len__/__iter__ 全部带正确注解;空栈 pop 抛 IndexError;写 Google 风格 docstring;能通过 mypy --strict 且通过 ruff check。

    参考答案要点T = TypeVar("T");class Stack(Generic[T]);内部 self._items: list[T] = [];pop(self) -> T 先判空抛错再 return self._items.pop()(这一步同时让 mypy 确信非空);peek(self) -> T | None。docstring 的 Args: 段要列出 T 的语义约束。
  2. Protocol 化你的依赖:写一个 HttpClient(Protocol)(方法 get(url) -> str、post(url, data: dict) -> str),实现 RequestsClient(用标准库 urllib 模拟)与 FakeClient(返回固定数据)。再写一个 Downloader 类只依赖 Protocol,演示如何用 FakeClient 写测试而不需要真实网络。

    参考答案要点class HttpClient(Protocol): ... 只声明方法签名;Downloader.__init__(self, client: HttpClient);测试时 Downloader(FakeClient({...}));无需任何 mock 库。注意 FakeClient 不需要继承 HttpClient,这正是结构化子类型的意义。
  3. 用 dataclass 重构第 12 章的 Order:把 Order 改成 @dataclass(frozen=True, slots=True),包含 no: str、items: list[LineItem]、state: OrderState、created_at: datetime,要求:total 是 property;状态迁移写成方法并校验合法性;提供 to_json_dict()。要求通过 mypy strict。

    参考答案要点frozen=True 下状态变化需要 object.__setattr__ 或 dataclasses.replace(self, state=OrderState.PAID);created_at 用 field(default_factory=datetime.now);items 用 field(default_factory=list);非法迁移在 pay() 里显式 if self.state is not OrderState.NEW: raise RuntimeError。
  4. 开启严格模式并消灭所有报错:把本章的 inventory.py 放进一个目录,加入 pyproject.toml(mypy strict + ruff ANN/D 规则),运行 mypy --strict . 与 ruff check .,把所有告警修干净。记录你遇到的三类典型报错及修法。

    参考答案要点常见三类:① 缺返回类型注解 → 补 -> None;② 可空值未收窄 → 用 if x is None: raise 或 assert;③ Returning Any → 给 dict[str, Any] 显式转换或收窄来源。Annotated 与 Final 可减少一批 ANN 报错。
  5. 写一套 docstring 并生成文档:为 inventory.py 的每个公开类与函数写 Google 风格 docstring(含 Args/Returns/Raises 与至少 2 个 Example),配置 [tool.ruff.lint.pydocstyle] convention = "google",确保 ruff check 通过 D 规则。然后用 help() 或 mkdocstrings 验证能被正确读取。

    参考答案要点D 规则会强制:模块 docstring(D100)、公共类(D101)、公共方法(D102)、__init__ 除外(D107)。Example 用 >>> 行内 doctest 格式,最好写成可直接被 pytest --doctest-modules 执行的例子。命令行 python -c "import inventory; help(inventory.InventoryService)" 验证。
  6. 运行时校验对比实验:分别用纯注解、@beartype、@pydantic.validate_call 三种方式给同一个函数 def calc(x: int, y: int) -> float 加校验,各传一次错误类型参数,比较报错信息与性能(用 timeit 跑 10 万次)。

    参考答案要点纯注解无运行时检查(不报错);beartype 报 BeartypeCallHintParamViolation,开销极小(一次 isinstance);pydantic 功能最全但开销最大(要构造并校验模型),适合边界处而非热循环。结论:热路径用注解+静态检查,入口/边界用 beartype 或 pydantic。
  7. 重构反模式代码:拿一段含 from typing import *、魔法字符串、get_ 命名、混合 Tab/空格、无 docstring 的代码,把它整理为规范代码。要求列出「改了哪些地方、为什么改」的对照表,并在 pyproject 中启用对应 ruff 规则证明。

    参考答案要点对照表应涵盖:导入改为显式且分组(F401 未使用导入会报错)、魔法字符串提为 Enum(S105 类规则)、get_total() 改 total()、补全所有注解(ANN)、补 docstring(D)、统一引号与空行(E/W)。最后 ruff check . 应 0 error。
  8. 配置 pre-commit 门禁:写 .pre-commit-config.yaml,串起 ruff(--fix)、ruff-format、mypy、trailing-whitespace、end-of-file-fixer、check-yaml 五类钩子;在代码里故意制造 3 个问题(未使用导入、格式错、类型错),运行 pre-commit run --all-files 验证它们被自动修复或拦截,并写出修复前后的 git diff。

    参考答案要点安装 pip install pre-commit && pre-commit install;注意 ruff-format 与 check-yaml 可能冲突顺序(先修再格式化);某些钩子(如 mypy)需要 additional_dependencies 才能导入项目依赖;pre-commit run --all-files --show-diff-on-failure 便于排查。
Python 完全教程 · 第 13 章