Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: refactor sem analysis of the await-not-async on generators and list comprehension #18152

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

josetapadas
Copy link

Fixes #18124

This PR proposes the introduction of two extra checks in order to error or any await or async within list comprehensions but not when inside a GeneratorExpression.

To achieve this we propose:

  • when visiting a ListComprehension we should also check for the existing of an await expression and error outside a courotine
  • error on the same scenario when visiting a DictionaryComprehension
  • when visiting a AwaitExpression we add an extra check to verify if it is inside a GeneratorExpression to not fail

@josetapadas josetapadas force-pushed the 18124-await-not-async-gen-list-fix branch from dacc60f to ada4720 Compare November 13, 2024 10:25

This comment has been minimized.

@josetapadas josetapadas force-pushed the 18124-await-not-async-gen-list-fix branch from 2ffa91e to 43fc65a Compare November 13, 2024 11:18
@josetapadas josetapadas force-pushed the 18124-await-not-async-gen-list-fix branch from 63bad66 to e3004a1 Compare November 13, 2024 11:22

This comment has been minimized.

@@ -80,6 +80,8 @@ def with_additional_msg(self, info: str) -> ErrorMessage:
ASYNC_FOR_OUTSIDE_COROUTINE: Final = '"async for" outside async function'
ASYNC_WITH_OUTSIDE_COROUTINE: Final = '"async with" outside async function'

AWAIT_WITH_OUTSIDE_COROUTINE: Final = '"await" outside coroutine ("async def")'
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd prefer this message to be consistent with previous two: "await" outside async function, but probably that isn't worth changing an existing message.

Copy link
Author

Choose a reason for hiding this comment

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

Yes, I moved it here and kept the same message that was defined before. Took the liberty to now also provide another key in here for the hardcoded message for "await outside functions" that was as well present as a string when we visit Await Expressions specifically.

@@ -80,6 +80,8 @@ def with_additional_msg(self, info: str) -> ErrorMessage:
ASYNC_FOR_OUTSIDE_COROUTINE: Final = '"async for" outside async function'
ASYNC_WITH_OUTSIDE_COROUTINE: Final = '"async with" outside async function'

AWAIT_WITH_OUTSIDE_COROUTINE: Final = '"await" outside coroutine ("async def")'
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you really intend to call it AWAIT_WITH_OUTSIDE_COROUTINE? This looks like a copying issue to me, why WITH? And please remove the blank line before this defn, those three messages are related to each other

Copy link
Author

Choose a reason for hiding this comment

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

indeed was a yanking error. thank you for pointing that out.

expr,
code=codes.AWAIT_NOT_ASYNC,
serious=True,
)

expr.generator.accept(self)

Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't visit_set_comprehension below use the same logic?

@@ -6090,7 +6104,12 @@ def visit_set_comprehension(self, expr: SetComprehension) -> None:
def visit_dictionary_comprehension(self, expr: DictionaryComprehension) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

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

And here as well? Probably worth extracting into a helper method

Copy link
Author

Choose a reason for hiding this comment

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

extracted it to a small method and apply the check on both dict and list comprehensions visits

@@ -1013,13 +1013,17 @@ async def foo(x: int) -> int: ...

# These are allowed in some cases:
top_level = await foo(1) # E: "await" outside function [top-level-await]
crasher = [await foo(x) for x in [1, 2, 3]] # E: "await" outside function [top-level-await]
crasher = [await foo(x) for x in [1, 2, 3]] # E: "await" outside coroutine ("async def") [await-not-async] \
Copy link
Contributor

Choose a reason for hiding this comment

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

More explicit tests would be really helpful here. Consider at least checking all of the following in different scopes (module level, non-async function body and async function body at least; the former two should produce the same errors and async function should allow all of those):

(await x for x in xs)  # OK
[await x for x in xs]  # E
{await x for x in xs}  # E
{0: await x for x in xs}  # E
{await x: 0 for x in xs}  # E

(x async for x in xs)  # OK
[x async for x in xs]  # E
{x async for x in xs}  # E
{x: 0 async for x in xs}  # E

(x for x in await xs)  # E
[x for x in await xs]  # E
{x for x in await xs}  # E
{x: 0 for x in await xs}  # E

There's already testAsyncForOutsideCoroutine for the 2nd block, the 3rd one is probably not interesting (follows regular rules), but the 1st one isn't exercised yet.

@josetapadas
Copy link
Author

Thank you for the review @sterliakov 🙏 I have now pushed the suggested changes

Copy link
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

@@ -6085,12 +6107,21 @@ def visit_set_comprehension(self, expr: SetComprehension) -> None:
if not self.is_func_scope() or not self.function_stack[-1].is_coroutine:
self.fail(message_registry.ASYNC_FOR_OUTSIDE_COROUTINE, expr, code=codes.SYNTAX)
Copy link
Contributor

Choose a reason for hiding this comment

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

There's now a minor inconsistency - serious=True is passed everywhere except this call.

self.fail(
message_registry.ASYNC_FOR_OUTSIDE_COROUTINE,
expr,
code=codes.SYNTAX,
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't really like the use of codes.SYNTAX vs codes.AWAIT_NOT_ASYNC here. Prior to this PR AWAIT_NOT_ASYNC was only used once.

As far as I understand, the purpose of using a special code was to support "specific" environments: e.g. IPython allows top-level awaits, so it should be easy to disable such a check when checking IPython snippets.

I see two possible resolutions here:

  1. Declare that we only type-check true python code. Then codes.AWAIT_NOT_ASYNC can be safely removed altogether and replaced with codes.SYNTAX
  2. Declare that we want to support such environments. Then all these checks become more complicated: when at module scope (note it isn't same as not self.is_func_scope()), produce codes.AWAIT_NOT_ASYNC, otherwise produce codes.SYNTAX. This can also be extracted into a helper like def require_async_scope(self, message). This logic is close to what visit_await_expr was doing before.

I somewhat prefer 2 as that isn't that much work to do, but can live with 1 either.

Disclaimer: please note I'm not a mypy maintainer and just try to help sometimes. You'd better ask for a maintainer's opinion here: @brianschubert do you have any preference?

Copy link
Contributor

@sterliakov sterliakov Nov 14, 2024

Choose a reason for hiding this comment

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

OTOH 2 can also be misleading and deserves an explicit mention in the docs.


def bad() -> None:
# These are always critical / syntax issues:
y = [await foo(x) for x in [1, 2, 3]] # E: "await" outside coroutine ("async def") [await-not-async]
async def good() -> None:
y = [await foo(x) for x in [1, 2, 3]] # OK

Copy link
Contributor

Choose a reason for hiding this comment

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

This is an artefact of --update-data - please remove

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

await-not-async missed & false alarms with genexp and list comprehensions
2 participants