Python 基础体系 · 第 70/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 静态类型工程:mypy、Pyright、Stub、渐进迁移和 CI

Python 的类型标注首先是静态分析协议,不是运行时类型约束。解释器通常不会因为变量标注为 int 就自动阻止字符串进入变量;类型检查器在代码运行前读取标注、控制流和导入信息,推导表达式可能的类型,再报告不一致。

Python 官方类型系统规范明确把静态分析、IDE 补全和重构作为主要目标,同时强调 Python 仍然是动态类型语言,类型标注并非强制要求。typing 模块提供了部分运行时反射能力,但完整的运行时校验需要第三方工具实现。(typing.python.org)

这决定了静态类型工程的核心问题不是“要不要给所有代码加类型”,而是:

  1. 类型信息从哪里来;
  2. 类型检查器如何解析这些信息;
  3. 不完整的旧代码如何逐步纳入检查;
  4. CI 如何阻止新的类型债务;
  5. 类型检查发现的问题如何与运行时测试、Lint 和打包流程衔接。

一、先建立正确的模型:类型信息、类型检查器与运行时是三条路径

一个 Python 项目通常同时存在三条不同的数据流:

flowchart LR
    A[Python 源码 .py] --> B[Python 解释器]
    B --> C[运行时行为]

    A --> D[mypy / Pyright]
    E[类型标注 .py]
    F[Stub .pyi]
    G[typeshed / 第三方类型包]
    E --> D
    F --> D
    G --> D
    D --> H[静态诊断]

    I[Ruff / Formatter] --> J[代码质量诊断]
    A --> I

运行时路径关心的是对象实际是什么;静态路径关心的是“根据源代码和类型声明,这个对象可能是什么”。

例如:

def add_one(value: int) -> int:
    return value + 1

add_one("1")

运行时可能得到:

TypeError: can only concatenate str ... to int

静态检查器则可以在执行前报告:

Argument 1 to "add_one" has incompatible type "str"; expected "int"

标注本身不会把 "1" 转换为 1。如果需要运行时转换,必须显式编写:

def add_one(value: int | str) -> int:
    return int(value) + 1

这里的 int | str 是联合类型,表示值可以属于 intstrUnion[int, str]int | str 等价,Python 3.10 起推荐使用 | 写法;Python 3.14 中两种联合类型形式在运行时使用同一类表示。(docs.python.org)


二、静态类型检查到底在证明什么

可以把一个函数看成一个映射:

f:ABf: A \rightarrow B

其中:

  • AA 是函数参数允许的类型集合;
  • BB 是函数返回值承诺的类型集合。

调用函数时,静态检查器试图验证:

实际参数类型A\text{实际参数类型} \subseteq A

函数返回时,检查器试图验证:

函数所有可达返回值类型B\text{函数所有可达返回值类型} \subseteq B

例如:

def parse_port(text: str) -> int:
    return int(text)

调用:

port = parse_port("8080")

推导过程是:

  1. 字面量 "8080" 的类型是 str
  2. parse_port 的参数类型是 str
  3. str 满足参数约束;
  4. 返回值类型根据函数签名为 int
  5. 因此 port 被推导为 int

反例:

def parse_port(text: str) -> int:
    return text

推导过程是:

  1. text 的类型为 str
  2. 函数声明承诺返回 int
  3. str 不满足 int 的返回约束;
  4. 静态检查器报告返回类型错误。

这不是对程序运行结果的数学证明。类型检查器只能依据它理解的语法、类型声明和控制流工作。Anycast、错误的类型谓词、动态导入和未标注代码都可能降低这种保证。


三、Any 是渐进迁移的桥,也是类型安全的泄漏点

Any 表示“允许静态检查器暂时放弃对这个值的具体约束”。它和普通联合类型不同:

value: int | str

表示值只能是 intstr,调用不存在的成员应该报错。

value: Any

表示静态检查器通常允许任意操作:

value: Any

value.missing_method()
value["unknown"]
value(1, 2, 3)

这类操作可能在运行时失败。Any 还会传播:

def load() -> Any:
    return external_api()

result = load()
name = result.name
count = result["count"]

如果 resultAny,很多检查器会继续把 namecount 推导为 Any,从而让错误跨越模块边界。mypy 文档也特别指出,来自 Any 的值通常会继续产生 Any,而未标注参数和缺少泛型参数也会制造隐式 Any。(mypy.readthedocs.io)

因此,渐进迁移中可以暂时容忍 Any,但应区分两种情况:

from typing import Any, cast

raw: Any = read_json()

# 只改变静态视图,不进行运行时检查
config = cast(dict[str, object], raw)

cast 不会转换对象,也不会验证对象。下面两行在运行时都不会改变 data

data = [1, 2, 3]

strings = cast(list[str], data)

此时 strings 的静态类型是 list[str],实际对象仍然是包含整数的列表。mypy 文档明确说明,cast 只是静态检查辅助工具,没有运行时检查。(mypy.readthedocs.io)

对于外部输入,更安全的边界是“解析后再赋予内部类型”:

from collections.abc import Mapping
from typing import TypeGuard, TypedDict


class UserPayload(TypedDict):
    id: int
    name: str


def is_user_payload(value: object) -> TypeGuard[UserPayload]:
    if not isinstance(value, Mapping):
        return False

    return (
        isinstance(value.get("id"), int)
        and isinstance(value.get("name"), str)
    )


def read_user(value: object) -> UserPayload:
    if not is_user_payload(value):
        raise ValueError("invalid user payload")
    return value

这里的 TypeGuard[UserPayload] 告诉类型检查器:当函数返回 True 时,参数可以按 UserPayload 使用。TypeGuard 的判断必须由开发者保证正确;错误的类型谓词会制造静态系统中的不安全路径。(docs.python.org)

Python 3.13 还提供了 TypeIs。它要求被收窄的类型与输入类型存在子类型关系,并且在 False 分支也可以排除目标类型;TypeGuardFalse 分支通常不能进一步收窄。(docs.python.org)


四、与工程边界直接相关的四种类型表达

1. Union:描述多种可能,但不描述它们之间的关系

def format_value(value: int | str) -> str:
    return str(value)

函数体只需要共同操作,因此不必区分具体分支。

如果行为依赖具体类型,需要通过控制流收窄:

def normalize(value: int | str) -> int:
    if isinstance(value, int):
        return value
    return int(value)

推导过程:

  1. 进入函数时,valueint | str
  2. isinstance(value, int) 为真时,当前分支排除 strvalue 收窄为 int
  3. else 分支排除 intvalue 收窄为 str
  4. 两条路径最终都返回 int

反例是只写联合类型,却访问某一成员独有的操作:

def upper(value: int | str) -> str:
    return value.upper()

int 没有 upper 方法,因此检查器必须拒绝这段代码。联合类型表达“可能是多个类型”,并不会自动选择某个类型。


2. Literal:表达有限的值集合

Literal 不只表达“类型是字符串”,还表达“值必须是这些字符串之一”:

from typing import Literal

type OpenMode = Literal["r", "rb", "w", "wb"]


def open_mode(mode: OpenMode) -> None:
    print(mode)

下面的调用应被拒绝:

open_mode("read")

Literal 适合用于协议字段、配置枚举、命令名称和状态标签。官方文档将它定义为让类型检查器知道某个对象的值等价于指定字面量之一。(docs.python.org)

但它不等于运行时校验:

mode = input("mode: ")
open_mode(mode)  # 静态检查器通常不会把任意 str 当作 OpenMode

正确做法是先验证:

def parse_mode(value: str) -> OpenMode:
    if value not in {"r", "rb", "w", "wb"}:
        raise ValueError(f"unsupported mode: {value}")
    return value  # 某些检查器可能需要显式 cast 或更精确的谓词

如果状态很多、状态之间有不同字段,单独使用 Literal 可能不够,应使用带判别字段的联合结构。


3. TypedDict:描述字典的键和值,而不是创建新运行时容器

from typing import TypedDict


class Success(TypedDict):
    kind: Literal["success"]
    value: int


class Failure(TypedDict):
    kind: Literal["failure"]
    message: str


Result = Success | Failure

TypedDict 仍然是普通 dict 的运行时对象;它不会在运行时自动检查键和值。它的约束由类型检查器执行。Python 文档明确说明,TypedDict 要求实例包含指定键及其对应类型,但这个要求不会由运行时自动检查。(docs.python.org)

配合 Literal,可以形成可收窄的响应协议:

def render(result: Result) -> str:
    if result["kind"] == "success":
        return f"value={result['value']}"
    return f"error={result['message']}"

推导过程:

  1. result 初始类型为 Success | Failure
  2. 两个字典类型都有 kind 键;
  3. 判断 result["kind"] == "success" 后,成功分支收窄为 Success
  4. 失败分支收窄为 Failure
  5. 因此 valuemessage 分别只在正确分支中可访问。

可选字段必须显式标注:

from typing import NotRequired, TypedDict


class User(TypedDict):
    id: int
    display_name: NotRequired[str]

display_name 可能不存在,因此不能直接假设:

def get_name(user: User) -> str:
    return user["display_name"]  # 可能触发类型或逻辑问题

应使用默认值:

def get_name(user: User) -> str:
    return user.get("display_name", f"user-{user['id']}")

4. Narrowing:控制流对类型集合进行排除

类型收窄可以理解为集合运算。假设:

T0={int,str,None}T_0 = \{int, str, None\}

对于:

def show(value: int | str | None) -> str:
    if value is None:
        return "missing"
    if isinstance(value, int):
        return str(value + 1)
    return value.upper()

类型变化为:

位置 value 的类型
函数入口 int | str | None
if value is None 真分支 None
第一个 if 的后续 int | str
isinstance(value, int) 真分支 int
最后的分支 str

收窄只在检查器能证明条件可靠时成立。以下边界经常被误解:

def bad(value: int | str) -> str:
    if type(value) is int:
        return str(value)
    return value.upper()

这段代码通常可以工作,但 type(value) is intisinstance(value, int) 的语义不同:

  • type(value) is int 只接受精确的 int
  • isinstance(value, int) 也接受 int 的子类;
  • 不同检查器对复杂用户自定义谓词的推导能力可能不同。

另一个边界是可变容器的不变性:

objects: list[object] = ["a", "b"]
strings: list[str] = objects  # 不安全

如果这允许通过,调用者可以执行:

strings.append("ok")

更危险的是其他代码仍然把 objects 当作可以放入任意 object 的列表:

objects.append(123)

此时 strings 中出现整数。list[str] 不是 list[object] 的子类型,因此不能直接赋值。TypeGuard 可以处理某些“运行时确实检查过全部元素”的情况,但 cast 不能代替检查。


五、mypy 与 Pyright:相同类型规范下的两个工程实现

mypy 和 Pyright 都是 Python 静态类型检查器,但不是同一个程序。它们共享 Python 类型系统中的大量概念,也可能在类型推导、错误码、默认规则、导入解析和插件支持上存在差异。

因此工程上不应把“mypy 通过”理解为“所有类型问题都不存在”,也不应把两个工具的配置简单拼接。更稳妥的做法是:

  • 明确一个主要检查器;
  • 另一个检查器用于编辑器体验、交叉验证或特定代码区域;
  • 对外发布的库优先遵守 typing specification 和公开 API 兼容性;
  • 对每个工具单独维护配置,并在 CI 中固定目标 Python 版本和依赖环境。

mypy 的工作重点

mypy 常见的工程能力包括:

  • 检查指定文件、目录、模块或包;
  • 从配置文件读取项目规则;
  • 根据 Python 版本选择语法和标准库类型信息;
  • 对未标注函数、隐式 Any、缺失返回类型等问题逐步加严;
  • 通过错误码进行细粒度忽略和治理。

mypy 默认会读取当前目录中的 mypy.ini.mypy.inipyproject.tomlsetup.cfg;命令行参数可以覆盖配置。--strict 是一组预定义严格规则的集合,但其具体组成可能随 mypy 版本变化,不应把它当成永久不变的标准。(mypy.readthedocs.io)

一个适合新项目或已迁移核心包的配置可以写为:

# pyproject.toml
[tool.mypy]
python_version = "3.14"
files = ["src", "tests"]
check_untyped_defs = true
disallow_untyped_defs = true
disallow_any_generics = true
warn_return_any = true
warn_unused_ignores = true
no_implicit_optional = true
strict_equality = true

[[tool.mypy.overrides]]
module = "legacy_package.*"
ignore_errors = true

[[tool.mypy.overrides]]
module = "untyped_dependency.*"
ignore_missing_imports = true

注意 ignore_missing_imports 的风险:它可能让外部模块变成 Any,进而把不确定性传播到项目内部。只有在依赖确实没有可用类型信息、且已经明确记录边界时,才应局部使用,而不是全局开启。mypy 文档把缺失第三方类型信息列为 Any 泄漏的重要来源。(mypy.readthedocs.io)

Pyright 的工作重点

Pyright 通常同时提供:

  • 命令行类型检查器;
  • 编辑器语言服务;
  • 基于工作区的导入解析、补全和跳转能力。

Pyright 配置支持 offbasicstandardstrict 等检查模式,也可以通过 report... 规则逐项调整。官方文档还区分了 includeexcludeignorestrict 和自定义 stubPath 等配置概念。(github.com)

例如:

{
  "include": ["src", "tests"],
  "exclude": ["**/__pycache__", ".venv"],
  "pythonVersion": "3.14",
  "typeCheckingMode": "standard",
  "reportMissingImports": "error",
  "reportMissingTypeStubs": "warning",
  "reportUnknownParameterType": "error",
  "reportUnknownArgumentType": "error",
  "reportUnknownMemberType": "error",
  "strict": ["src/core"]
}

这里的含义是:

  • 整个项目使用 standard
  • src/core 目录进入严格检查;
  • 新代码中的未知参数、未知实参和未知成员成为错误;
  • 没有类型 Stub 的第三方依赖先作为警告;
  • 后续可以把 reportMissingTypeStubs 提升为错误,或者为依赖补充 Stub。

Pyright 官方的渐进流程也是先建立最小配置,再处理外部库类型、逐步添加标注,最后按文件或目录启用严格模式。(github.com)


六、Stub:把类型信息从实现代码中分离出来

Stub 是只包含类型信息、不包含实际运行逻辑的 .pyi 文件。它适用于:

  1. 没有类型标注的 Python 包;
  2. 不希望把标注写入实现文件的库;
  3. C 扩展、二进制扩展等无法直接分析实现代码的模块;
  4. 第三方维护的类型补充包;
  5. 为旧代码提供渐进式外部接口描述。

Python typing 规范把 Stub 定义为提供模块类型信息的文件;如果检查器找到对应 Stub,通常会优先使用 Stub,而不是读取真实实现模块。(typing.python.org)

假设项目结构如下:

project/
├── src/
│   └── legacy_client/
│       ├── __init__.py
│       └── client.py
├── stubs/
│   └── legacy_client/
│       ├── __init__.pyi
│       └── client.pyi
└── pyrightconfig.json

实现文件:

# src/legacy_client/client.py
class Client:
    def __init__(self, endpoint, timeout=5):
        self.endpoint = endpoint
        self.timeout = timeout

    def get(self, path):
        ...

Stub 文件:

# stubs/legacy_client/client.pyi
class Client:
    endpoint: str
    timeout: float

    def __init__(self, endpoint: str, timeout: float = 5.0) -> None: ...
    def get(self, path: str) -> bytes: ...

Pyright 配置:

{
  "include": ["src"],
  "stubPath": "stubs",
  "pythonVersion": "3.14",
  "typeCheckingMode": "strict"
}

调用方:

from legacy_client.client import Client

client = Client("https://example.test", timeout=2.5)
payload = client.get("/health")

此时 payload 被视为 bytes。如果写成:

client.get(123)

检查器会根据 Stub 报告参数类型错误。

Stub 的关键风险是声明漂移:真实实现已经改变,但 .pyi 没有同步更新。例如实现实际返回 str,Stub 却仍然声明 bytes,静态检查会通过,但生产代码可能出现运行时错误。因此 Stub 不是自动生成的真相,而是一个需要测试和维护的 API 契约。

发布库时,如果类型信息内嵌在包中,通常需要在包中声明 py.typed,表示该包希望类型检查器使用其内置类型信息;如果类型以独立包发布,则应遵循类型信息分发规范。Stub 还应覆盖公开接口,而不是把私有实现细节全部暴露出来。(typing.python.org)


七、渐进迁移:不是“关闭所有错误”,而是建立单调收紧的边界

旧项目直接启用严格检查,通常会遇到三类问题:

error: Function is missing a type annotation
error: Call to untyped function in typed context
error: Returning Any from function declared to return "X"

这些错误并不代表检查器失效,而是说明项目原本没有为函数边界提供足够信息。

第一步:固定检查范围和解释器版本

python -m mypy --python-version 3.14 src
pyright

前置条件:

  • 当前环境已经安装项目依赖;
  • mypy 和 Pyright 使用与项目一致的虚拟环境;
  • CI 与本地使用相同的 Python 主版本;
  • 导入路径不会因为从仓库根目录或安装后运行而变化。

如果本地能导入而 CI 不能导入,优先检查环境和包布局,不要先添加 ignore

第二步:先标注公共边界

迁移顺序应优先考虑:

def fetch_user(user_id: int) -> User:
    ...

而不是先给每个局部变量加标注。输入参数和返回值决定模块之间的数据流,实例属性决定对象状态;它们比局部变量更能减少跨模块的不确定性。

例如:

class UserService:
    def __init__(self, repository: UserRepository) -> None:
        self.repository = repository

    def find(self, user_id: int) -> User | None:
        return self.repository.find(user_id)

只要 UserRepository 的接口稳定,调用方就能知道:

  • user_id 必须是 int
  • 结果可能是 User
  • 找不到时是 None
  • 调用方必须处理 None

第三步:禁止新的隐式 Any

可以先不要求所有历史文件立即严格,但要求新改动不能继续扩大 Any

def decode(payload: object) -> dict[str, object]:
    if not isinstance(payload, dict):
        raise TypeError("payload must be a dict")
    return payload

与下面的写法相比:

def decode(payload):
    return payload

第一种写法把输入边界明确为 object,要求调用者或函数体进行验证;第二种写法会让参数和返回值缺少可靠信息。

第四步:建立严格区域

常见做法是按目录、模块或文件推进:

src/
├── legacy/
├── adapters/
└── domain/

可以先让 domain/ 严格,因为领域模型通常具有稳定接口,再处理 adapters/,最后处理依赖动态行为较多的 legacy/

迁移的单调性应满足:

新提交后的未知类型数量迁移前未知类型数量\text{新提交后的未知类型数量} \leq \text{迁移前未知类型数量}

如果使用错误基线文件或全局忽略,可能出现相反情况:旧错误被隐藏,新错误继续增加。更可靠的策略是:

  • 对历史错误建立明确基线;
  • CI 检查新增错误数不能增加;
  • 新模块直接使用严格规则;
  • 对每个 ignore 要求错误码和原因;
  • 定期删除已经不需要的忽略。

八、函数重载与 Stub 中的 API 精度

当返回类型依赖输入参数时,普通联合类型表达不出这种关系:

def get_item(index_or_slice: int | slice) -> int | bytes:
    ...

调用者得到的只能是 int | bytes,即使传入的是 int,检查器也无法知道一定返回 int

此时可以使用 @overload

from typing import overload


@overload
def get_item(index_or_slice: int) -> int: ...


@overload
def get_item(index_or_slice: slice) -> bytes: ...


def get_item(index_or_slice: int | slice) -> int | bytes:
    if isinstance(index_or_slice, int):
        return 42
    return b"data"

调用:

number = get_item(0)       # int
chunk = get_item(slice(0)) # bytes

重载的实现函数不是给调用者匹配的签名;检查器使用前面的 @overload 声明选择返回类型。实现必须能够接受所有重载允许的参数,并返回所有重载声明的返回类型。typing 规范把这描述为:实现的输入签名必须覆盖所有重载输入,重载返回类型必须可赋值给实现返回类型。(typing.python.org)

Stub 中只写重载签名,不写实现:

# api.pyi
from typing import overload

@overload
def open_resource(name: str, raw: Literal[True]) -> bytes: ...

@overload
def open_resource(name: str, raw: Literal[False] = False) -> str: ...

常见反例是把实现签名写得过窄:

@overload
def parse(value: str) -> str: ...

@overload
def parse(value: int) -> int: ...

def parse(value: str) -> str:  # 错误:不能处理 int
    return value

这会导致 Stub 或源码自身不一致。修复方式是扩大实现输入和输出:

def parse(value: str | int) -> str | int:
    return value

九、CI:把类型检查变成可重复的质量门

CI 的价值不在于“运行一次命令”,而在于保证以下条件稳定:

  1. 使用锁定或可复现的依赖环境;
  2. 使用明确的 Python 版本;
  3. 检查同一组源文件;
  4. 失败时输出可诊断结果;
  5. 类型债务不能随着提交增加。

一个最小的 GitHub Actions 示例:

name: quality

on:
  pull_request:
  push:
    branches: [main]

jobs:
  type-check:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Install project and type checkers
        run: |
          python -m pip install --upgrade pip
          python -m pip install -e ".[dev]"
          python -m pip install mypy pyright

      - name: Run mypy
        run: python -m mypy

      - name: Run Pyright
        run: pyright

这个配置假设项目的开发依赖和构建配置已经正确声明。若项目使用其他环境管理工具,应让 CI 使用项目自己的锁文件和安装命令,避免“检查器装好了,但实际依赖没装全”。

推荐将静态检查与运行时测试分开:

  tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: python -m pip install -e ".[test]"
      - run: python -m pytest

原因是两者证明的对象不同:

  • mypy/Pyright 检查静态类型约束;
  • pytest 检查实际执行路径;
  • Ruff 检查规则、未使用导入和部分可疑代码;
  • Formatter 检查文本格式。

类型检查通过不等于测试通过;测试通过也不等于类型契约完整。


十、CI 失败时的诊断顺序

1. Cannot find implementation or library stub

先确认:

python -c "import package; print(package.__file__)"
python -m pip show package

如果运行时也无法导入,这是依赖或包布局问题,不是类型问题。

如果运行时能导入但检查器找不到类型信息,检查:

  • 依赖是否提供内置标注;
  • 是否需要独立类型包;
  • Stub 路径是否配置;
  • 当前 Python 版本是否与类型包支持范围匹配。

Pyright 通过 stubPath 查找自定义 Stub,并提供 reportMissingTypeStubs 等诊断规则。(github.com)

2. Returning Any

沿数据流向前追踪:

def build_user() -> User:
    data = external_load()  # Any
    return data              # Any 泄漏

不要直接写:

return cast(User, data)

除非已经在边界验证过 data。更好的修复是给 external_load 补类型声明,或者在进入领域代码前解析为 User

3. 两个检查器结果不同

先检查:

  • Python 目标版本是否相同;
  • 是否使用了不同的虚拟环境;
  • src 布局和导入根目录是否一致;
  • 一个工具是否读取了 Stub,另一个工具读取了实现;
  • 是否启用了不同的严格规则;
  • 是否存在插件、类型注释或错误码差异。

不要为了消除差异而把规则全部关闭。先构造最小案例:

from typing import reveal_type

value = get_value()
reveal_type(value)

mypy 可以用 reveal_type() 查看推导结果;调试完成后应删除这些调用。(mypy.readthedocs.io)

4. 类型检查通过但运行时仍然失败

重点检查三类不一致:

value = cast(User, raw)

第一类是错误的 cast;第二类是 Stub 与实现漂移;第三类是外部数据未验证却直接进入内部模型。

静态类型是开发者和工具之间的契约。如果契约是假的,检查器越严格,错误自信越高。


十一、与 Ruff、格式化和导入排序的职责边界

类型检查器不应承担所有代码质量工作,Ruff 也不应被当作类型检查器的替代品。

一个清晰的职责划分是:

Formatter       统一文本布局
Ruff            语法级、风格级、部分语义级规则
mypy/Pyright    类型一致性、导入类型、控制流收窄
pytest          运行时行为
构建/安装检查    包元数据、资源文件、发布内容

例如:

from typing import TypedDict


class Config(TypedDict):
    timeout: int

导入排序工具可能只关心导入分组和顺序;类型检查器还要判断 Config 的使用是否满足结构约束。二者互补,不能互相替代。

规则治理时,应避免同一问题由多个工具重复报错。类型相关规则应保留给 mypy/Pyright,命名、导入、未使用变量和格式问题交给 Ruff 或 Formatter。CI 中可以采用:

ruff check .
ruff format --check .
python -m mypy
pyright
python -m pytest

命令顺序可以按反馈速度调整,但失败信息必须保留原始工具名和退出状态,不能把所有错误合并成模糊的“质量检查失败”。


十二、库作者与应用作者的取舍不同

应用项目控制全部代码时,可以把类型迁移策略设计成“内部逐步严格、边界集中解析”。库项目则必须优先考虑调用者体验和跨检查器兼容性:

  • 公共函数参数和返回值应稳定;
  • 公共字典协议优先使用 TypedDict 或数据类;
  • 输入值有限时使用 Literal
  • 参数与返回值存在关系时使用 overload
  • 动态能力应通过 Protocol、回调类型或明确的 Any 边界表达;
  • 发布前验证 Stub 与实现的一致性;
  • 不要把私有成员错误地写进公共 Stub。

Stub 的优势是隐藏实现细节、描述扩展模块和为旧包补充类型;代价是多维护一份接口声明。内嵌标注减少了声明漂移,但可能让实现代码更复杂。两者没有脱离场景的绝对优劣,关键在于公开 API 是否有稳定、可验证的类型契约。


十三、一个可落地的收敛路径

对于已有项目,可以按以下状态推进:

stateDiagram-v2
    [*] --> 未配置
    未配置 --> 可运行检查: 固定 Python 版本和导入环境
    可运行检查 --> 边界有类型: 标注公共函数和对象属性
    边界有类型 --> 控制Any泄漏: 启用未知类型诊断
    控制Any泄漏 --> 核心目录严格: 启用 strict
    核心目录严格 --> 新代码强制: CI 阻止新增错误
    新代码强制 --> 全项目严格: 清理历史忽略和 Stub 漂移

每次状态转换都应有可验证条件:

  • 未配置 → 可运行检查:本地和 CI 能用同样命令完成导入和检查;
  • 可运行检查 → 边界有类型:公共函数不再依赖隐式 Any
  • 边界有类型 → 控制 Any 泄漏:未知值在适配层被解析或显式隔离;
  • 控制 Any 泄漏 → 核心目录严格:核心模块可以启用严格规则而不产生无法解释的历史错误;
  • 核心目录严格 → 新代码强制:新增文件和修改行不能继续扩大债务;
  • 新代码强制 → 全项目严格:旧忽略逐步删除,Stub 和实现通过测试保持一致。

静态类型工程的最终目标不是让每一行代码都充满标注,而是让不确定性停留在少数明确边界,并让跨模块的数据流具有可检查的契约。mypy 和 Pyright 是执行这种契约的工具,Stub 是补充契约的载体,渐进迁移是把动态系统逐步纳入约束的过程,CI 则负责让已经获得的安全性不会在下一次提交中退化。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。