多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

【Bug已解决】Add type hints to public API functions 解决方案

【Bug已解决】Add type hints to public API functions 解决方案 【Bug已解决】Add type hints to public API functions 解决方案一、现象长什么样这是一类「非崩溃但严重拖累工程健康度」的问题一个库的公开 APIpublic API函数没有类型注解type hints导致IDE 无法自动补全参数名与返回类型使用者只能翻源码静态检查工具mypy / pyright / pyright in VS Code对调用处全部放行隐藏大量拼错参数、传错类型的隐患重构时「改了函数签名却没人发现调用方不匹配」直到运行时才炸文档生成工具如 Sphinx autodoc拿不到类型信息文档里参数类型全是Any。在 DeepSpeed 这类大型 C 扩展 Python 封装的项目里尤其明显很多deepspeed.xxx顶层函数、初始化入口、工具函数完全无注解新贡献者贡献代码时常踩「参数顺序记错」「返回值类型不清」的坑。维护者于是开了 issue 请求「给公开 API 加类型注解」。本期讲清楚为什么缺类型提示是真实 Bug、如何在不破坏兼容的前提下系统地补上类型提示以及三层工程化做法。二、背景2.1 类型提示的价值Python 3.5 引入的 PEP 484 类型提示本质是「可选的静态契约」def add(a: int, b: int) - int: return a b它不影响运行时行为但让mypy能在不跑代码的情况下发现add(1, 2)这类错误。对公开库来说类型提示是 API 契约的一部分——使用者靠它理解「传什么、得到什么」。2.2 为什么大型库容易缺类型提示历史包袱很多函数写于类型提示普及前C 扩展封装底层是 C/CPython 层只是薄封装类型推断困难动态返回同一函数根据配置返回不同类型如deepspeed.initialize返回(model, optimizer, ...)元组或不同 engine 子类注解复杂担心破坏from __future__ import annotations引入前注解会在模块加载时求值引用尚未定义的类会报NameError。三、根因3.1 缺注解 - 调用方无保护没有注解时下面这种错误谁也拦不住# 库函数(无注解) def initialize(model, config, parametersNone): ... # 用户调用 engine initialize(configmy_config, modelmy_model, lr1e-3) # lr 不是 initialize 的参数, 但运行时才因意外 kwarg 报错如果initialize有注解且用 mypy 检查lr1e-3会在静态阶段被指出。3.2 动态返回类型造成「调用方只能 guess」deepspeed.initialize返回(model, optimizer, _, lr_scheduler)这种异构元组无注解时使用者不知道第 3 个元素是什么、能不能忽略只能看例子照抄极易出错。3.3 Python 3.9 及以下的前向引用坑在 Python 3.9 里直接写def f() - MyCls:而MyCls在文件后面才定义会NameError。这也劝退了很多贡献者去加注解——其实有标准解法见下文。3.4 一句话根因公开 API 缺类型提示使调用方失去静态契约保护参数拼错、类型传错只能等到运行时暴露同时 IDE 补全与文档生成失效而「前向引用、动态返回、C 扩展封装」等技术顾虑又让维护者迟迟不愿补——最终形成工程健康度负债。四、最小可运行复现下面演示「无注解 - mypy 放行错误调用」与「加注解 - 静态拦截」的对比# ---- 无注解版本 ---- def divide(a, b): return a / b divide(10, 2) # mypy 不报错(因为无注解), 运行时才 TypeError # ---- 加注解版本 ---- def divide(a: float, b: float) - float: return a / b # divide(10, 2) # 取消注释后 mypy 会报: Argument 1 to divide has incompatible type str; expected float用 mypy 跑pip install mypy mypy demo.py无注解版Success: no issues found错误被放过。 有注解版含错误调用error: Argument 1 ... incompatible type str; expected float。这就是类型提示把「运行时崩溃」提前到「提交前」的价值。五、解决方案第一层最小直接修复给公开函数逐一补注解。对大多数纯 Python 封装函数直接标注即可from typing import Optional, Dict, Any, Tuple def get_argument( name: str, default: Optional[Any] None, dtype: type str, ) - Any: ... def initialize( model: torch.nn.Module, config: Dict[str, Any], parameters: Optional[list] None, ) - Tuple[Any, Any, Any, Any]: ...5.1 解决前向引用Python 3.9 兼容DeepSpeed 仍需支持 Python 3.9不能直接用model: torch.nn.Module当torch在文件顶部未 import 完成时求值。用字符串注解或from __future__ import annotationsfrom __future__ import annotations # 让所有注解变成字符串, 延迟求值, 兼容 3.9 from typing import Optional class Engine: ... def build(opts: Optional[Engine]) - Engine: # 即使 Engine 后定义也 OK ...from __future__ import annotations是 Python 3.7 可用、3.9 完全支持的写法是给老项目补注解的最优解。六、解决方案第二层结构性 / 抽象改进第一层是「手写注解」但更系统的是引入 Protocol / 类型别名统一管理复杂返回并把公开 API 收敛到少量入口。6.1 用 Protocol 描述复杂对象deepspeed.initialize返回的 engine 有forward、backward、step等方法可用Protocol描述供调用方获得补全from __future__ import annotations from typing import Protocol, Any, Tuple, Optional class DeepSpeedEngineProtocol(Protocol): def forward(self, *args, **kwargs) - Any: ... def backward(self, loss: Any) - None: ... def step(self) - None: ... def initialize( model: Any, config: dict, parameters: Optional[list] None, ) - Tuple[DeepSpeedEngineProtocol, Any, Any, Any]: ...6.2 pydantic 配置模型替代裸 dictDeepSpeed 配置是嵌套 dict无类型导致config[zero_optimization][stage]拼错无提示。用 pydantic 模型承载from __future__ import annotations from pydantic import BaseModel, Field class ZeroConfig(BaseModel): stage: int Field(ge0, le3) offload_param: dict Field(default_factorydict) class DSConfig(BaseModel): zero_optimization: ZeroConfig Field(default_factoryZeroConfig) fp16: dict Field(default_factorydict) # 调用方拿到 DSConfig, IDE 能补全 .zero_optimization.stage6.3 用 pyright 的py.typed标记纯 Python 库要在包根放一个空的py.typed文件类型检查器才会把你的注解当作「对外契约」touch deepspeed/py.typed并在pyproject.toml里把它纳入打包package-data。七、解决方案第三层断言 / CI 守护把「公开 API 必须有注解」变成 CI 不变量。7.1 mypy 严格模式接入 CI# pyproject.toml [tool.mypy] python_version 3.9 disallow_untyped_defs true # 禁止无注解函数 disallow_incomplete_defs true # 禁止部分注解 warn_return_any true ignore_missing_imports true# CI jobs: type-check: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - run: pip install mypy - run: mypy deepspeed/disallow_untyped_defs true会让任何新增的无注解公开函数直接 CI 失败从而保证「补注解」不退化。7.2 用脚本统计未注解的公开函数import ast, pathlib def count_untyped_public(path: str) - list: tree ast.parse(pathlib.Path(path).read_text()) issues [] for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): if node.name.startswith(_): continue # 私有跳过 args node.args all_args [*args.args, *args.kwonlyargs] untyped [a.arg for a in all_args if a.annotation is None] if node.returns is None: untyped.append(- return) if untyped: issues.append((node.lineno, node.name, untyped)) return issues if __name__ __main__: for ln, name, missing in count_untyped_public(deepspeed/__init__.py): print(fL{ln} {name} 缺注解: {missing})三层叠加直接补注解含from __future__ import annotations 结构改Protocol/pydantic/py.typed 守护mypy 严格 统计脚本 CI类型提示从「负债」变成「契约」。八、补充注解不影响运行时但要注意 eval 时机两个常见坑注解里引用未导入的名字会NameErrorPython 3.9 无from __future__ import annotations时。解决加from __future__ import annotations或改成字符串注解MyClass。from __future__ import annotations下运行时typing.get_type_hints仍会求值字符串——若字符串引用的类在模块外且未 import会在反射时失败。确保相关类可被 import。另外类型提示只是「提示」运行时不做强制校验。若需要运行时校验比如配置 dict 的字段应另用 pydantic /dataclasses 显式assert。九、排查清单当团队决定「给公开 API 加类型提示」时先加from __future__ import annotations规避 Python 3.9 前向引用NameError。从顶层入口函数开始如initialize、get_argument逐步向内。复杂返回用Protocol/TypeVar描述给调用方补全。配置类用 pydantic / dataclass承载替代裸dict。放py.typed标记并打包让外部项目能用你的注解。CI 开disallow_untyped_defs禁止新增无注解函数。写 AST 统计脚本定期列出剩余未注解的公开函数量化进度。注意注解 eval 时机避免get_type_hints因未 import 而失败。十、小结「公开 API 缺类型提示」看似不是崩溃型 bug却真实损害工程质量调用方失去静态契约、IDE 补全失效、文档类型缺失、重构隐患只能运行时暴露。根因在于历史包袱、前向引用顾虑、动态返回与 C 扩展封装让维护者迟迟未补。修复分三层第一层用from __future__ import annotations逐个给公开函数加注解兼容 Python 3.9第二层用Protocol描述复杂返回、pydantic承载配置、py.typed标记对外契约第三层mypy 严格模式 AST 统计脚本接入 CI让「无注解公开函数」成为不可合入的失败。类型提示一旦成为工程习惯大量「运行时才发现的拼错参数」都会被提前到「提交前」拦截。
返回列表