[ty] Add diagnostics for invalid await expressions (#19711)
Some checks are pending
CI / mkdocs (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## Summary

This PR adds a new lint, `invalid-await`, for all sorts of reasons why
an object may not be `await`able, as discussed in astral-sh/ty#919.
Precisely, `__await__` is guarded against being missing, possibly
unbound, or improperly defined (expects additional arguments or doesn't
return an iterator).

Of course, diagnostics need to be fine-tuned. If `__await__` cannot be
called with no extra arguments, it indicates an error (or a quirk?) in
the method signature, not at the call site. Without any doubt, such an
object is not `Awaitable`, but I feel like talking about arguments for
an *implicit* call is a bit leaky.
I didn't reference any actual diagnostic messages in the lint
definition, because I want to hear feedback first.

Also, there's no mention of the actual required method signature for
`__await__` anywhere in the docs. The only reference I had is the
`typing` stub. I basically ended up linking `[Awaitable]` to ["must
implement
`__await__`"](https://docs.python.org/3/library/collections.abc.html#collections.abc.Awaitable),
which is insufficient on its own.

## Test Plan

The following code was tested:
```python
import asyncio
import typing


class Awaitable:
    def __await__(self) -> typing.Generator[typing.Any, None, int]:
        yield None
        return 5


class NoDunderMethod:
    pass


class InvalidAwaitArgs:
    def __await__(self, value: int) -> int:
        return value


class InvalidAwaitReturn:
    def __await__(self) -> int:
        return 5


class InvalidAwaitReturnImplicit:
    def __await__(self):
        pass


async def main() -> None:
    result = await Awaitable()  # valid
    result = await NoDunderMethod()  # `__await__` is missing
    result = await InvalidAwaitReturn()  # `__await__` returns `int`, which is not a valid iterator 
    result = await InvalidAwaitArgs()  # `__await__` expects additional arguments and cannot be called implicitly
    result = await InvalidAwaitReturnImplicit()  # `__await__` returns `Unknown`, which is not a valid iterator


asyncio.run(main())
```

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
Andrii Turov 2025-08-15 00:38:33 +03:00 committed by GitHub
parent f6093452ed
commit 957320c0f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 661 additions and 84 deletions

View file

@ -0,0 +1,105 @@
# Invalid await diagnostics
<!-- snapshot-diagnostics -->
## Basic
This is a test showcasing a primitive case where an object is not awaitable.
```py
async def main() -> None:
await 1 # error: [invalid-await]
```
## Custom type with missing `__await__`
This diagnostic also points to the class definition if available.
```py
class MissingAwait:
pass
async def main() -> None:
await MissingAwait() # error: [invalid-await]
```
## Custom type with possibly unbound `__await__`
This diagnostic also points to the method definition if available.
```py
from datetime import datetime
class PossiblyUnbound:
if datetime.today().weekday() == 0:
def __await__(self):
yield
async def main() -> None:
await PossiblyUnbound() # error: [invalid-await]
```
## `__await__` definition with extra arguments
Currently, the signature of `__await__` isn't checked for conformity with the `Awaitable` protocol
directly. Instead, individual anomalies are reported, such as the following. Here, the diagnostic
reports that the object is not implicitly awaitable, while also pointing at the function parameters.
```py
class InvalidAwaitArgs:
def __await__(self, value: int):
yield value
async def main() -> None:
await InvalidAwaitArgs() # error: [invalid-await]
```
## Non-callable `__await__`
This diagnostic doesn't point to the attribute definition, but complains about it being possibly not
awaitable.
```py
class NonCallableAwait:
__await__ = 42
async def main() -> None:
await NonCallableAwait() # error: [invalid-await]
```
## `__await__` definition with explicit invalid return type
`__await__` must return a valid iterator. This diagnostic also points to the method definition if
available.
```py
class InvalidAwaitReturn:
def __await__(self) -> int:
return 5
async def main() -> None:
await InvalidAwaitReturn() # error: [invalid-await]
```
## Invalid union return type
When multiple potential definitions of `__await__` exist, all of them must be proper in order for an
instance to be awaitable. In this specific case, no specific function definition is highlighted.
```py
import typing
from datetime import datetime
class UnawaitableUnion:
if datetime.today().weekday() == 6:
def __await__(self) -> typing.Generator[typing.Any, None, None]:
yield
else:
def __await__(self) -> int:
return 5
async def main() -> None:
await UnawaitableUnion() # error: [invalid-await]
```

View file

@ -0,0 +1,41 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - Basic
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | async def main() -> None:
2 | await 1 # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `Literal[1]` is not awaitable
--> src/mdtest_snippet.py:2:11
|
1 | async def main() -> None:
2 | await 1 # error: [invalid-await]
| ^
|
::: stdlib/builtins.pyi:337:7
|
335 | _LiteralInteger = _PositiveInteger | _NegativeInteger | Literal[0] # noqa: Y026 # TODO: Use TypeAlias once mypy bugs are fixed
336 |
337 | class int:
| --- type defined here
338 | """int([x]) -> integer
339 | int(x, base=10) -> integer
|
info: `__await__` is missing
info: rule `invalid-await` is enabled by default
```

View file

@ -0,0 +1,41 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - Custom type with missing `__await__`
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | class MissingAwait:
2 | pass
3 |
4 | async def main() -> None:
5 | await MissingAwait() # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `MissingAwait` is not awaitable
--> src/mdtest_snippet.py:5:11
|
4 | async def main() -> None:
5 | await MissingAwait() # error: [invalid-await]
| ^^^^^^^^^^^^^^
|
::: src/mdtest_snippet.py:1:7
|
1 | class MissingAwait:
| ------------ type defined here
2 | pass
|
info: `__await__` is missing
info: rule `invalid-await` is enabled by default
```

View file

@ -0,0 +1,47 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - Custom type with possibly unbound `__await__`
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | from datetime import datetime
2 |
3 | class PossiblyUnbound:
4 | if datetime.today().weekday() == 0:
5 | def __await__(self):
6 | yield
7 |
8 | async def main() -> None:
9 | await PossiblyUnbound() # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `PossiblyUnbound` is not awaitable
--> src/mdtest_snippet.py:9:11
|
8 | async def main() -> None:
9 | await PossiblyUnbound() # error: [invalid-await]
| ^^^^^^^^^^^^^^^^^
|
::: src/mdtest_snippet.py:5:13
|
3 | class PossiblyUnbound:
4 | if datetime.today().weekday() == 0:
5 | def __await__(self):
| --------------- method defined here
6 | yield
|
info: `__await__` is possibly unbound
info: rule `invalid-await` is enabled by default
```

View file

@ -0,0 +1,45 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - Invalid union return type
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | import typing
2 | from datetime import datetime
3 |
4 | class UnawaitableUnion:
5 | if datetime.today().weekday() == 6:
6 |
7 | def __await__(self) -> typing.Generator[typing.Any, None, None]:
8 | yield
9 | else:
10 |
11 | def __await__(self) -> int:
12 | return 5
13 |
14 | async def main() -> None:
15 | await UnawaitableUnion() # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `UnawaitableUnion` is not awaitable
--> src/mdtest_snippet.py:15:11
|
14 | async def main() -> None:
15 | await UnawaitableUnion() # error: [invalid-await]
| ^^^^^^^^^^^^^^^^^^
|
info: `__await__` returns `Generator[Any, None, None] | int`, which is not a valid iterator
info: rule `invalid-await` is enabled by default
```

View file

@ -0,0 +1,35 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - Non-callable `__await__`
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | class NonCallableAwait:
2 | __await__ = 42
3 |
4 | async def main() -> None:
5 | await NonCallableAwait() # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `NonCallableAwait` is not awaitable
--> src/mdtest_snippet.py:5:11
|
4 | async def main() -> None:
5 | await NonCallableAwait() # error: [invalid-await]
| ^^^^^^^^^^^^^^^^^^
|
info: `__await__` is possibly not callable
info: rule `invalid-await` is enabled by default
```

View file

@ -0,0 +1,43 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - `__await__` definition with extra arguments
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | class InvalidAwaitArgs:
2 | def __await__(self, value: int):
3 | yield value
4 |
5 | async def main() -> None:
6 | await InvalidAwaitArgs() # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `InvalidAwaitArgs` is not awaitable
--> src/mdtest_snippet.py:6:11
|
5 | async def main() -> None:
6 | await InvalidAwaitArgs() # error: [invalid-await]
| ^^^^^^^^^^^^^^^^^^
|
::: src/mdtest_snippet.py:2:18
|
1 | class InvalidAwaitArgs:
2 | def __await__(self, value: int):
| ------------------ parameters here
3 | yield value
|
info: `__await__` requires arguments and cannot be called implicitly
info: rule `invalid-await` is enabled by default
```

View file

@ -0,0 +1,43 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_await.md - Invalid await diagnostics - `__await__` definition with explicit invalid return type
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_await.md
---
# Python source files
## mdtest_snippet.py
```
1 | class InvalidAwaitReturn:
2 | def __await__(self) -> int:
3 | return 5
4 |
5 | async def main() -> None:
6 | await InvalidAwaitReturn() # error: [invalid-await]
```
# Diagnostics
```
error[invalid-await]: `InvalidAwaitReturn` is not awaitable
--> src/mdtest_snippet.py:6:11
|
5 | async def main() -> None:
6 | await InvalidAwaitReturn() # error: [invalid-await]
| ^^^^^^^^^^^^^^^^^^^^
|
::: src/mdtest_snippet.py:2:9
|
1 | class InvalidAwaitReturn:
2 | def __await__(self) -> int:
| ---------------------- method defined here
3 | return 5
|
info: `__await__` returns `int`, which is not a valid iterator
info: rule `invalid-await` is enabled by default
```