From 184e2cc0b138729f2573307ffbcf90423e76e91c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 1 Mar 2026 14:49:03 -0500 Subject: [PATCH] [ty] Add a diagnostic for an unused awaitable --- crates/ty/docs/rules.md | 237 ++++++++++-------- .../mdtest/diagnostics/unused_awaitable.md | 151 +++++++++++ crates/ty_python_semantic/src/types.rs | 24 ++ .../src/types/diagnostic.rs | 28 +++ .../src/types/infer/builder.rs | 34 ++- ty.schema.json | 10 + 6 files changed, 379 insertions(+), 105 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index a4db6c70ddf152..5f51db2e505b8c 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -49,7 +49,7 @@ class Derived(Base): # Error: `Derived` does not implement `method` Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -90,7 +90,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -157,7 +157,7 @@ def test(): -> "int": Default level: error · Preview (since 0.0.16) · Related issues · -View source +View source @@ -206,7 +206,7 @@ Foo.method() # Error: cannot call abstract classmethod Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -230,7 +230,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source @@ -261,7 +261,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -293,7 +293,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -324,7 +324,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -356,7 +356,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -388,7 +388,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -416,7 +416,7 @@ type B = A Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -448,7 +448,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -475,7 +475,7 @@ old_func() # emits [deprecated] diagnostic Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -504,7 +504,7 @@ false positives it can produce. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -531,7 +531,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -569,7 +569,7 @@ class A: # Crash at runtime Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -640,7 +640,7 @@ def foo() -> "intt\b": ... Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -672,7 +672,7 @@ def my_function() -> int: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -798,7 +798,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -828,7 +828,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -854,7 +854,7 @@ t[3] # IndexError: tuple index out of range Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -888,7 +888,7 @@ class MyClass: ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -977,7 +977,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1004,7 +1004,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1032,7 +1032,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1066,7 +1066,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1102,7 +1102,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1126,7 +1126,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1153,7 +1153,7 @@ with 1: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1190,7 +1190,7 @@ class Foo(NamedTuple): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -1222,7 +1222,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1251,7 +1251,7 @@ a: str Default level: warn · Added in 0.0.20 · Related issues · -View source +View source @@ -1300,7 +1300,7 @@ class Pet(Enum): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1344,7 +1344,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -1386,7 +1386,7 @@ class D(A): Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -1430,7 +1430,7 @@ class NonFrozenChild(FrozenBase): # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1468,7 +1468,7 @@ class D(Generic[U, T]): ... Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -1547,7 +1547,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -1586,7 +1586,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -1647,7 +1647,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1682,7 +1682,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -1710,7 +1710,7 @@ match x: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1744,7 +1744,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1851,7 +1851,7 @@ Correct use of `@override` is enforced by ty's `invalid-explicit-override` rule. Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -1905,7 +1905,7 @@ AttributeError: Cannot overwrite NamedTuple attribute _asdict Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -1935,7 +1935,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1985,7 +1985,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2011,7 +2011,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2042,7 +2042,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2076,7 +2076,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2125,7 +2125,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2154,7 +2154,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2250,7 +2250,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -2296,7 +2296,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -2323,7 +2323,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2370,7 +2370,7 @@ Bar[int] # error: too few arguments Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2400,7 +2400,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2430,7 +2430,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2464,7 +2464,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -2498,7 +2498,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2529,7 +2529,7 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2576,7 +2576,7 @@ U = TypeVar('U', list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2608,7 +2608,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2643,7 +2643,7 @@ def f(x: dict): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -2674,7 +2674,7 @@ class Foo(TypedDict): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -2729,7 +2729,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -2772,7 +2772,7 @@ def g(arg: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2797,7 +2797,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2830,7 +2830,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2859,7 +2859,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2885,7 +2885,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2909,7 +2909,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -2942,7 +2942,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -2975,7 +2975,7 @@ class B(A): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3002,7 +3002,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3029,7 +3029,7 @@ f(x=1) # Error raised here Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3057,7 +3057,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3089,7 +3089,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3126,7 +3126,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3190,7 +3190,7 @@ def test(): -> "int": Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3217,7 +3217,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -3249,7 +3249,7 @@ class C: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -3283,7 +3283,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3313,7 +3313,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3342,7 +3342,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -3376,7 +3376,7 @@ class F(NamedTuple): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3403,7 +3403,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3431,7 +3431,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3477,7 +3477,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -3514,7 +3514,7 @@ class C(Generic[T]): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3538,7 +3538,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3565,7 +3565,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3593,7 +3593,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -3651,7 +3651,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3676,7 +3676,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3701,7 +3701,7 @@ print(x) # NameError: name 'x' is not defined Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -3740,7 +3740,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3777,7 +3777,7 @@ b1 < b2 < b1 # exception raised here Default level: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -3818,7 +3818,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3840,6 +3840,39 @@ class A: ... A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' ``` +## `unused-awaitable` + + +Default level: warn · +Preview (since 0.0.21) · +Related issues · +View source + + + +**What it does** + +Checks for awaitable objects (such as coroutines) used as expression +statements without being awaited. + +**Why is this bad?** + +Calling an `async def` function returns a coroutine object. If the +coroutine is never awaited, the body of the async function will never +execute, which is almost always a bug. Python emits a +`RuntimeWarning: coroutine was never awaited` at runtime in this case. + +**Examples** + +```python +async def fetch_data() -> str: + return "data" + +async def main() -> None: + fetch_data() # Warning: coroutine is not awaited + await fetch_data() # OK +``` + ## `unused-ignore-comment` @@ -3919,7 +3952,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -3982,7 +4015,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md new file mode 100644 index 00000000000000..225bd10fb02ce0 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md @@ -0,0 +1,151 @@ +# Unused awaitable + +## Basic coroutine not awaited + +Calling an `async def` function produces a coroutine that must be awaited. + +```py +async def fetch() -> int: + return 42 + +async def main(): + fetch() # error: [unused-awaitable] +``` + +## Awaited coroutine is fine + +```py +async def fetch() -> int: + return 42 + +async def main(): + await fetch() +``` + +## Assigned coroutine is fine + +```py +async def fetch() -> int: + return 42 + +async def main(): + # TODO: ty should eventually warn about unused coroutines assigned to variables + coro = fetch() +``` + +## Coroutine passed to a function + +When a coroutine is passed as an argument rather than used as an expression statement, no diagnostic +should be emitted. + +```py +async def fetch() -> int: + return 42 + +async def main(): + print(fetch()) +``` + +## Top-level coroutine call + +The lint fires even outside of `async def`, since the coroutine is still discarded. + +```py +async def fetch() -> int: + return 42 + +fetch() # error: [unused-awaitable] +``` + +## Union of awaitables + +When every element of a union is awaitable, the lint should fire. + +```py +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] +``` + +## Union with non-awaitable + +When a union contains a non-awaitable element, the lint should not fire. + +```py +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() +``` + +## Intersection with awaitable + +When an intersection type contains an awaitable element, the lint should fire. + +```py +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] +``` + +## `reveal_type` and `assert_type` are not flagged + +Calls to `reveal_type` and `assert_type` should not trigger this lint, even when their argument is +an awaitable. + +```py +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]) +``` + +## Non-awaitable expression statement + +Regular non-awaitable expression statements should not trigger this lint. + +```py +def compute() -> int: + return 42 + +def main(): + compute() +``` + +## Dynamic type + +`Any` and `Unknown` types should not trigger the lint. + +```py +from typing import Any + +def get_any() -> Any: + return None + +async def main(): + get_any() +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index f5c5aae5382947..bb395ba2c72ee5 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -995,6 +995,30 @@ impl<'db> Type<'db> { self.is_dynamic() && !self.is_divergent() } + /// 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. + pub(crate) 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, + } + } + /// Is a value of this type only usable in typing contexts? pub fn is_type_check_only(&self, db: &'db dyn Db) -> bool { match self { diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index d8910b48dcdb5a..a44931bef59e2f 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -139,6 +139,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&UNSUPPORTED_BASE); registry.register_lint(&UNSUPPORTED_DYNAMIC_BASE); registry.register_lint(&UNSUPPORTED_OPERATOR); + registry.register_lint(&UNUSED_AWAITABLE); registry.register_lint(&ZERO_STEPSIZE_IN_SLICE); registry.register_lint(&STATIC_ASSERT_ERROR); registry.register_lint(&INVALID_ATTRIBUTE_ACCESS); @@ -2686,6 +2687,33 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Checks for awaitable objects (such as coroutines) used as expression + /// statements without being awaited. + /// + /// ## Why is this bad? + /// Calling an `async def` function returns a coroutine object. If the + /// coroutine is never awaited, the body of the async function will never + /// execute, which is almost always a bug. Python emits a + /// `RuntimeWarning: coroutine was never awaited` at runtime in this case. + /// + /// ## Examples + /// ```python + /// async def fetch_data() -> str: + /// return "data" + /// + /// async def main() -> None: + /// fetch_data() # Warning: coroutine is not awaited + /// await fetch_data() # OK + /// ``` + pub(crate) static UNUSED_AWAITABLE = { + summary: "detects awaitable objects that are used as expression statements without being awaited", + status: LintStatus::preview("0.0.21"), + default_level: Level::Warn, + } +} + declare_lint! { /// ## What it does /// Checks for step size 0 in slices. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 88e53da9ebed1e..3a356dd8084ab8 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -85,8 +85,8 @@ use crate::types::diagnostic::{ POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, TOO_MANY_POSITIONAL_ARGUMENTS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNKNOWN_ARGUMENT, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, - UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, - hint_if_stdlib_attribute_exists_on_other_versions, + UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, UNUSED_AWAITABLE, + USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance, report_call_to_abstract_method, report_cannot_delete_typed_dict_key, @@ -561,6 +561,23 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.context.in_stub() } + /// 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) + ) + ) + } + /// Get the already-inferred type of an expression node, or Unknown. fn expression_type(&self, expr: &ast::Expr) -> Type<'db> { self.try_expression_type(expr).unwrap_or_else(Type::unknown) @@ -3267,7 +3284,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }) => { // If this is a call expression, we would have added a `ReturnsNever` constraint, // meaning this will be a standalone expression. - self.infer_maybe_standalone_expression(value, TypeContext::default()); + let ty = self.infer_maybe_standalone_expression(value, TypeContext::default()); + + 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(self.db()), + )); + } + } } ast::Stmt::If(if_statement) => self.infer_if_statement(if_statement), ast::Stmt::Try(try_statement) => self.infer_try_statement(try_statement), diff --git a/ty.schema.json b/ty.schema.json index 5e43d4cd15c68a..a95d01a2df0b96 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1445,6 +1445,16 @@ } ] }, + "unused-awaitable": { + "title": "detects awaitable objects that are used as expression statements without being awaited", + "description": "## What it does\nChecks for awaitable objects (such as coroutines) used as expression\nstatements without being awaited.\n\n## Why is this bad?\nCalling an `async def` function returns a coroutine object. If the\ncoroutine is never awaited, the body of the async function will never\nexecute, which is almost always a bug. Python emits a\n`RuntimeWarning: coroutine was never awaited` at runtime in this case.\n\n## Examples\n```python\nasync def fetch_data() -> str:\n return \"data\"\n\nasync def main() -> None:\n fetch_data() # Warning: coroutine is not awaited\n await fetch_data() # OK\n```", + "default": "warn", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "unused-ignore-comment": { "title": "detects unused `ty: ignore` comments", "description": "## What it does\nChecks for `ty: ignore` directives that are no longer applicable.\n\n## Why is this bad?\nA `ty: ignore` directive that no longer matches any diagnostic violations is likely\nincluded by mistake, and should be removed to avoid confusion.\n\n## Examples\n```py\na = 20 / 2 # ty: ignore[division-by-zero]\n```\n\nUse instead:\n\n```py\na = 20 / 2\n```\n\n## Options\nSet [`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments)\nto `false` to prevent this rule from reporting unused `type: ignore` comments.",