Ruff 项目 ty 类型检查器 `unused-awaitable` 诊断深度解析:从 mdtest 测试用例到源码实现
本文围绕 Ruff 仓库中 ty 类型检查器的 unused-awaitable 诊断(lint)展开,以 crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md 这份 mdtest 测试文档为骨架,系统梳理该诊断的触发条件、豁免场景、类型层面的判定规则,并结合 ty_python_semantic crate 的源码实现解释其底层原理。读完本文,你将掌握:ty 如何识别"未被 await 的协程"这类静默错误、Union/Intersection/动态类型分别如何处理、reveal_type 与 assert_type 为何被豁免,以及如何阅读与运行这类以 Markdown 为载体的可执行测试文档。
一、什么是 unused-awaitable 诊断
unused-awaitable 是 ty(Ruff 仓库内以 Rust 实现的 Python 类型检查器)提供的一条稳定 lint 规则,其职责是:检测那些以表达式语句(expression statement)形式出现、却从未被 await 的可等待(awaitable)对象。
在源码中,该 lint 的声明位于 crates/ty_python_semantic/src/types/diagnostic.rs:
declare_lint! {
#[doc = include_str!("../../resources/lint_docs/unused-awaitable.md")]
pub(crate) static UNUSED_AWAITABLE = {
summary: "detects awaitable objects that are used as expression statements without being awaited",
status: LintStatus::stable("0.0.21"),
default_level: Level::Warn,
}
}
从中可以确认三个关键事实:
- 默认级别为
warn:该诊断默认以警告级别报告,而非错误(Level::Error); - 自 0.0.21 起标记为稳定:
LintStatus::stable("0.0.21")表明它不是预览特性; - 文档与代码同源:lint 的官方说明通过
include_str!直接嵌入自 crates/ty_python_semantic/resources/lint_docs/unused-awaitable.md,保证规则文档与实现始终同步。规则索引页 crates/ty/docs/rules.md 中也收录了该规则,标注默认级别为warn、自 0.0.21 加入。
二、为什么"未 await 的协程"是一种缺陷
要理解这条诊断的价值,需要先回顾 Python 的异步语义:
调用一个 async def 函数并不会执行函数体,而是返回一个协程对象(coroutine)。如果这个协程对象从未被 await,函数体内的代码永远不会执行——这几乎总是一个编程错误。Python 解释器在运行时遇到这种情况会发出 RuntimeWarning: coroutine was never awaited,但这类警告极易被忽略。
ty 的优势在于:它在静态检查阶段就能发现这类问题,无需等到运行时。lint 文档 crates/ty_python_semantic/resources/lint_docs/unused-awaitable.md 给出了最直观的示例:
async def fetch_data() -> str:
return "data"
async def main() -> None:
# Warning: coroutine is not awaited
fetch_data() # error
await fetch_data() # OK
三、源码层面:诊断如何在类型推断阶段触发
unused-awaitable 的实现在类型推断器 infer_body 中,位于 crates/ty_python_semantic/src/types/infer/builder.rs。其核心逻辑非常简洁:
fn infer_body(&mut self, suite: &[ast::Stmt]) {
let db = self.db();
for statement in suite {
self.infer_maybe_standalone_statement(statement);
if let ast::Stmt::Expr(ast::StmtExpr {
range: _,
node_index: _,
value,
}) = statement
{
let ty = self.expression_type(value);
if ty.is_awaitable(self.db()) && !self.is_known_function_call(value) {
if let Some(builder) =
self.context.report_lint(&UNUSED_AWAITABLE, value.as_ref())
{
builder.into_diagnostic(format_args!(
"Object of type `{}` is not awaited",
ty.display(db, self.program_environment()),
));
}
}
}
}
self.check_suite_for_redundant_conditions(suite);
}
从实现可以提炼出诊断触发的三个必要条件:
- 语句形态必须是表达式语句(
ast::Stmt::Expr),即该调用单独成行、其结果被丢弃; - 表达式的推断类型必须"可等待"(
ty.is_awaitable(db)); - 该调用不是已知的诊断辅助函数(
!self.is_known_function_call(value))。
满足以上条件时,ty 会报告形如 Object of type \{类型}` is not awaited` 的诊断信息。
3.1 已知函数豁免:reveal_type 与 assert_type
第三个条件的实现是 is_known_function_call,同样位于 builder.rs:
/// Returns `true` if `expr` is a call to a known diagnostic function
/// (e.g., `reveal_type` or `assert_type`) whose return value should not
/// trigger the `unused-awaitable` lint.
fn is_known_function_call(&self, expr: &ast::Expr) -> bool {
let ast::Expr::Call(call) = expr else {
return false;
};
matches!(
self.expression_type(&call.func),
Type::FunctionLiteral(f)
if matches!(
f.known(self.db()),
Some(KnownFunction::RevealType | KnownFunction::AssertType)
)
)
}
reveal_type 和 assert_type 是类型检查阶段的辅助函数,它们的返回值只服务于类型展示与断言,并不代表"忘记了 await"。若不对它们豁免,类型检查器在帮助开发者排查类型问题时反而会制造噪音。
四、类型级判定:is_awaitable 的递归规则
诊断的第二块基石是 Type::is_awaitable,定义于 crates/ty_python_semantic/src/types.rs:
/// Returns `true` if this type is an awaitable that should be awaited before being discarded.
///
/// Currently checks for instances of `types.CoroutineType` (returned by `async def` calls).
/// Unions are considered awaitable only if every element is awaitable.
/// Intersections are considered awaitable if any positive element is awaitable.
fn is_awaitable(self, db: &'db dyn Db) -> bool {
match self {
Type::NominalInstance(instance) => {
matches!(instance.known_class(db), Some(KnownClass::CoroutineType))
}
Type::Union(union) => {
let elements = union.elements(db);
// Guard against empty unions (`Never`), since `all()` on an empty
// iterator returns `true`.
!elements.is_empty() && elements.iter().all(|ty| ty.is_awaitable(db))
}
Type::Intersection(intersection) => intersection
.positive(db)
.iter()
.any(|ty| ty.is_awaitable(db)),
_ => false,
}
}
这段代码揭示了三条重要的判定策略,与测试文档中的用例一一对应:
| 类型形态 | 判定策略 | 语义解释 |
|---|---|---|
| 普通实例(NominalInstance) | 仅当已知类是 types.CoroutineType |
async def 调用返回的正是 CoroutineType 实例 |
| 联合类型(Union) | 所有元素都可等待才触发 | 只要联合中存在非可等待分支,该表达式就"可能不是协程",不应报警 |
| 交集类型(Intersection) | 任一正向元素可等待即触发 | 交集蕴含所有成员约束,只要其中一个是协程,丢弃它就是错误的 |
| 动态类型(Any / Unknown)及所有其他类型 | 不触发 | 动态类型信息不足,避免误报 |
注意 Never(空联合)的特殊处理:all() 对空迭代器返回 true,因此代码先用 !elements.is_empty() 守卫,避免对 Never 类型误报。
五、mdtest 测试文档逐例解读
unused_awaitable.md 本质上是一份 mdtest 测试文档:它以 Markdown 为承载、以 Python 代码块为可执行用例,通过行尾注释声明预期结果(# error: [unused-awaitable] 表示该行应触发此诊断,# revealed: 类型 表示 reveal_type 的输出)。这类文档同时充当规范说明与回归测试,由仓库中的 mdtest 测试框架(crates/mdtest)驱动执行。
下面逐例解析文档中的 11 个场景。
5.1 基础场景:未 await 的协程调用
async def fetch() -> int:
return 42
async def fetch_complex(x) -> int:
return 42
async def main():
fetch() # error: [unused-awaitable]
fetch_complex(lambda: None) # error: [unused-awaitable]
调用 async def 函数会产生必须被 await 的协程。即使参数形式不同(此处第二个调用携带了 lambda 参数),只要结果是 CoroutineType 且作为表达式语句被丢弃,就会触发诊断。
5.2 已 await 的协程:不触发
async def fetch() -> int:
return 42
async def main():
await fetch()
这是正确写法:await fetch() 真正执行了异步函数体,协程被消费,因此没有任何诊断。
5.3 赋值给变量的协程:当前不触发
async def fetch() -> int:
return 42
async def main():
# TODO: ty should eventually warn about unused coroutines assigned to variables
coro = fetch()
协程被赋值给变量 coro 后不再属于"表达式语句被丢弃"的形态,因此当前不会触发诊断。文档中的 TODO 注释表明:ty 未来计划对"赋值后从未使用的协程变量"也发出警告——这正是当前实现的已知边界,值得关注后续演进。
5.4 作为参数传入函数:不触发
async def fetch() -> int:
return 42
async def main():
print(fetch())
当协程作为实参被传递(而不是单独成行的表达式语句)时,它仍有机会被消费,因此不应报警。这与 infer_body 只检查 ast::Stmt::Expr 形态的实现是一致的。
5.5 模块顶层调用:同样触发
async def fetch() -> int:
return 42
fetch() # error: [unused-awaitable]
lint 在 async def 之外同样生效——因为协程依然被丢弃,无论它出现在哪里,这都是一处缺陷。这也印证了 infer_body 对任意语句块的统一处理逻辑。
5.6 联合类型:全部可等待才触发
from types import CoroutineType
from typing import Any
def get_coroutine() -> CoroutineType[Any, Any, int] | CoroutineType[Any, Any, str]:
raise NotImplementedError
async def main():
get_coroutine() # error: [unused-awaitable]
CoroutineType[Any, Any, int] | CoroutineType[Any, Any, str] 的每一个分支都是协程类型,无论实际返回哪种,丢弃它都是错误,因此触发诊断。
5.7 含非可等待分支的联合:不触发
from types import CoroutineType
from typing import Any
def get_maybe_coroutine() -> CoroutineType[Any, Any, int] | int:
raise NotImplementedError
async def main():
get_maybe_coroutine()
联合中混入了 int 这一非可等待分支。该表达式"可能返回普通整数",直接丢弃是合法的常见写法(例如某些回调模式),因此诊断不应触发。这正对应 is_awaitable 中"联合须全部元素可等待"的规则。
5.8 交集类型:含可等待元素即触发
from collections.abc import Coroutine
from types import CoroutineType
from ty_extensions import Intersection
class Foo: ...
class Bar: ...
def get_coroutine() -> Intersection[Coroutine[Foo, Foo, Foo], CoroutineType[Bar, Bar, Bar]]:
raise NotImplementedError
async def main():
get_coroutine() # error: [unused-awaitable]
交集类型 Intersection[...] 表示对象同时满足所有成员约束。只要其中一个正向成员是可等待的,该对象就必然是可等待的,丢弃它同样是缺陷。此例还展示了 ty 扩展模块 ty_extensions.Intersection 的用法(见 crates/ty_python_semantic/resources/mdtest/intersection_types.md)。
5.9 reveal_type 与 assert_type:明确豁免
from typing_extensions import assert_type
from types import CoroutineType
from typing import Any
async def fetch() -> int:
return 42
async def main():
reveal_type(fetch()) # revealed: CoroutineType[Any, Any, int]
assert_type(fetch(), CoroutineType[Any, Any, int])
reveal_type(fetch()) 在类型检查器中显示 fetch() 的类型为 CoroutineType[Any, Any, int],这正是 async def 调用的返回类型;assert_type 则断言该类型与声明一致。二者都是类型检查辅助手段,其参数中的协程并不会被"丢弃",因此不触发诊断——这由前面分析的 is_known_function_call 机制保证。
5.10 普通函数调用:不触发
def compute() -> int:
return 42
def main():
compute()
compute() 返回普通 int,本身不可等待,丢弃它是完全合法的表达式语句,不触发诊断。
5.11 动态类型:不触发
from typing import Any
def get_any() -> Any:
return None
async def main():
get_any()
Any(以及 Unknown)属于动态类型,类型信息不足,ty 采取保守策略不报警,避免对动态代码产生误报。
六、诊断信息的实际形态与报告级别
当上述任一场景触发时,ty 会输出类似如下的诊断:
Object of type `CoroutineType[Any, Any, int]` is not awaited
消息中的类型通过 ty.display(db, env) 格式化,直接呈现协程的具体类型参数。由于 UNUSED_AWAITABLE 的默认级别是 warn,它在 ty 的默认输出中作为警告报告,不会阻断类型检查流程。
与 unused-awaitable 同族的还有 unused-ignore-comment 诊断(见 rules.md),用于检查不再适用的 ty: ignore 抑制注释——这提示 ty 的诊断体系对"抑制机制本身"也有自检能力。如需针对单行抑制,可以参考仓库中 ty: ignore[...] 的注释语法(例如 # ty: ignore[unused-awaitable],抑制机制的实现见 crates/ty_python_semantic/src/suppression.rs)。
七、如何阅读与运行 mdtest 测试文档
unused_awaitable.md 位于 ty_python_semantic 的测试资源目录 crates/ty_python_semantic/resources/mdtest/diagnostics/ 下。这类文档的阅读约定是:
- 每个
##小节代表一个独立测试场景,标题即场景语义; - 代码块中的
# error: [unused-awaitable]声明该行期望触发此诊断; # revealed: 类型声明reveal_type的期望输出;- 无行尾注释的代码块表示"不应触发任何诊断"。
mdtest 框架支持分层配置:文档根部的 TOML 配置块(如 [environment] python-version = "3.10")会被子章节继承或覆盖,具体规则见 crates/ty_python_semantic/resources/mdtest/mdtest_config.md。这些 Markdown 测试由 mdtest harness(crates/mdtest 与 crates/ruff_mdtest)解析并驱动执行,既是类型检查器行为的权威文档,也是持续集成中自动运行的回归测试。
八、总结:从测试文档到实现的一条完整链路
通过 unused-awaitable 这一个诊断,可以看到 ty 项目"文档即测试、测试即规范"的设计风格:
- 行为规范:由 mdtest 文档 unused_awaitable.md 以 11 个可执行用例精确定义;
- 规则声明:由 diagnostic.rs 中的
declare_lint!宏登记,默认级别warn、稳定版 0.0.21; - 触发逻辑:由 builder.rs 在
infer_body中完成"表达式语句 + 可等待类型 + 非已知函数"的三重判定; - 类型判定:由 types.rs 的
is_awaitable递归处理普通实例、Union(全元素)、Intersection(任元素)与动态类型。
掌握了这条链路,你不仅可以准确预测 ty 在何种代码上会报告 unused-awaitable,还能举一反三地理解 ty 其他基于类型推断的 lint(如 redundant-condition、division-by-zero 等)在 crates/ty_python_semantic/resources/mdtest/diagnostics/ 目录下的组织方式与阅读方法。对于在代码中排查"协程忘加 await"类问题,unused-awaitable 是把运行时警告前移到编译期的最佳实践。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0632
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00