Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 135 additions & 102 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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()
```
24 changes: 24 additions & 0 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, did you consider checking for "assignability to Awaitable"? My instinct is that that's too expensive to evaluate on every single expression statement, but I'm not sure.

/// 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)),
Comment on lines +1014 to +1017
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably have a test case for this branch. CoroutineType is @final, so it's tricky to write an intersection for it that doesn't simplify, but something like this seems to work for me:

diff --git i/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md w/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md
index 162e730d65..edfe302793 100644
--- i/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md
+++ w/crates/ty_python_semantic/resources/mdtest/diagnostics/unused_awaitable.md
@@ -87,6 +87,25 @@ async def main():
     get_maybe_coroutine(True)
 ```
 
+## 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]
+```
+
 ## Non-awaitable expression statement
 
 Regular non-awaitable expression statements should not trigger this lint.

(Formatting diffs with ``` in them is hard...)

_ => 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 {
Expand Down
28 changes: 28 additions & 0 deletions crates/ty_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 31 additions & 3 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions ty.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.