diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md
index a4db6c70ddf15..5f51db2e505b8 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 0000000000000..225bd10fb02ce
--- /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 f5c5aae538294..bb395ba2c72ee 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 d8910b48dcdb5..a44931bef59e2 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 88e53da9ebed1..3a356dd8084ab 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 5e43d4cd15c68..a95d01a2df0b9 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.",