首页
/ Ruff 项目 ty 类型检查器 `unused-awaitable` 诊断深度解析:从 mdtest 测试用例到源码实现

Ruff 项目 ty 类型检查器 `unused-awaitable` 诊断深度解析:从 mdtest 测试用例到源码实现

2026-09-09 11:26:47作者:伍霜盼Ellen

本文围绕 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_typeassert_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);
}

从实现可以提炼出诊断触发的三个必要条件:

  1. 语句形态必须是表达式语句ast::Stmt::Expr),即该调用单独成行、其结果被丢弃;
  2. 表达式的推断类型必须"可等待"ty.is_awaitable(db));
  3. 该调用不是已知的诊断辅助函数!self.is_known_function_call(value))。

满足以上条件时,ty 会报告形如 Object of type \{类型}` is not awaited` 的诊断信息。

3.1 已知函数豁免:reveal_typeassert_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_typeassert_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_typeassert_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/mdtestcrates/ruff_mdtest)解析并驱动执行,既是类型检查器行为的权威文档,也是持续集成中自动运行的回归测试。

八、总结:从测试文档到实现的一条完整链路

通过 unused-awaitable 这一个诊断,可以看到 ty 项目"文档即测试、测试即规范"的设计风格:

  • 行为规范:由 mdtest 文档 unused_awaitable.md 以 11 个可执行用例精确定义;
  • 规则声明:由 diagnostic.rs 中的 declare_lint! 宏登记,默认级别 warn、稳定版 0.0.21;
  • 触发逻辑:由 builder.rsinfer_body 中完成"表达式语句 + 可等待类型 + 非已知函数"的三重判定;
  • 类型判定:由 types.rsis_awaitable 递归处理普通实例、Union(全元素)、Intersection(任元素)与动态类型。

掌握了这条链路,你不仅可以准确预测 ty 在何种代码上会报告 unused-awaitable,还能举一反三地理解 ty 其他基于类型推断的 lint(如 redundant-conditiondivision-by-zero 等)在 crates/ty_python_semantic/resources/mdtest/diagnostics/ 目录下的组织方式与阅读方法。对于在代码中排查"协程忘加 await"类问题,unused-awaitable 是把运行时警告前移到编译期的最佳实践。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
927
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.89 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
602
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
396
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
526