Skip to content

Issue 1600: Forbid await in for loop#3330

Merged
sobolevn merged 22 commits intowemake-services:masterfrom
skv0zsneg:forbid-await-in-for-loop
Mar 11, 2025
Merged

Issue 1600: Forbid await in for loop#3330
sobolevn merged 22 commits intowemake-services:masterfrom
skv0zsneg:forbid-await-in-for-loop

Conversation

@skv0zsneg
Copy link
Copy Markdown
Contributor

I have made things!

Checklist

  • I have double checked that there are no unrelated changes in this pull request (old patches, accidental config files, etc)
  • I have created at least one test case for the changes I have made
  • I have updated the documentation for the changes I have made
  • I have added my changes to the CHANGELOG.md

Related issues

Closes #1600

Hi! I had a lot of pleasure while working on this issue.

I added new ASTViolation for forbid await in for loop, but not sure about docstring detail. If it need to content more informative, I'll add it.

About documentation I guess it fills automatically using docstrings in ASTViolation subclasses, so I mark as done third point in the checklist

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

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

Thanks!

async def foo():
for _ in range(1):
await some()
"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't forget to test:

  • list comp [await x for x in ...]
  • set comp
  • dict comp
  • gen exprs



@final
class AwaitInLoopViolation(ASTViolation):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not a consistency violation, this is a best practice.


code_that_breaks = """
async def foo():
for _ in range(1):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please, check that await in async for works.

@skv0zsneg
Copy link
Copy Markdown
Contributor Author

Added comprehensions checking, checked for async for working, added more tests, moved violation to best practice.

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

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

👍

Comment thread tests/fixtures/noqa/noqa.py Outdated
Comment on lines +754 to +761
await test_function()


async def test_await_in_list_comp():
list_comprehension = [await test_function() for _ in range(10)] # noqa: WPS476
set_comprehension = {await test_function() for _ in range(10)} # noqa: WPS476
dict_comprehension = {some_hashable: await test_function() for some_hashable in range(10)} # noqa: WPS476
generator_comprehension = (await test_function() for _ in range(10)) # noqa: WPS476
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
await test_function()
async def test_await_in_list_comp():
list_comprehension = [await test_function() for _ in range(10)] # noqa: WPS476
set_comprehension = {await test_function() for _ in range(10)} # noqa: WPS476
dict_comprehension = {some_hashable: await test_function() for some_hashable in range(10)} # noqa: WPS476
generator_comprehension = (await test_function() for _ in range(10)) # noqa: WPS476

Just one error is fine.

Comment thread tests/test_checker/test_noqa.py Outdated
'WPS473': 0,
'WPS474': 1,
'WPS475': 1,
'WPS476': 5,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
'WPS476': 5,
'WPS476': 1,


generator_comprehension = """
async def foo():
result = (await some() for i in range(10))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't forget about async comprehensions :)
They should allow await

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm did not work with async Python as much as I wish 😢
Can you please correct me - async comprehensions it is something like this [await some_two() async for _ in some_one()]?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yes :)

There is a better way to control repeated coroutines in ``for`` loops.

Solution:
Using :function:`asyncio.gather` or :class:`asyncio.TaskGroup`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Using :function:`asyncio.gather` or :class:`asyncio.TaskGroup`
Use :function:`asyncio.gather`,
:function:`asyncio.wait`,
or :class:`asyncio.TaskGroup`

Comment on lines +2989 to +2994
urls = [
"https://wemake-python-styleguide.readthedocs.io/",
"https://flake8.pycqa.org/",
"https://docs.astral.sh/ruff/",
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
urls = [
"https://wemake-python-styleguide.readthedocs.io/",
"https://flake8.pycqa.org/",
"https://docs.astral.sh/ruff/",
]

Comment on lines +2996 to +3000
async def request():
tasks = []
for url in urls:
tasks.append(parse_content(url))
parsed_content = await asyncio.gather(*tasks)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
async def request():
tasks = []
for url in urls:
tasks.append(parse_content(url))
parsed_content = await asyncio.gather(*tasks)
async def request():
tasks = [parse_content(url) for url in urls]
parsed_content = await asyncio.gather(*tasks)

if isinstance(node, ast.While | ast.AsyncFor):
return

for subnode in ast.walk(node):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that we should do the other way, we need to search for await and then find For in its parents. See nodes.py for a helper function.

Copy link
Copy Markdown
Contributor Author

@skv0zsneg skv0zsneg Mar 8, 2025

Choose a reason for hiding this comment

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

Not sure that I understand you correctly.

In this method we getting nodes that could be AnyLoop | AnyComprehension, so we already in the loop node. Why we need walk through parents? If we found await in child nodes it already triggered violation.

Sorry if it is dumb question I have't work with ast package until your project 🙃

Any way I rewrote method in last commits with walk through parents logic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok, let's keep it this way :)

@skv0zsneg
Copy link
Copy Markdown
Contributor Author

Well, yes, wps really make me suffer 😄

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

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

Thank you! We are almost there.

):
return

bad_loops = (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we use class-level constants for tuples like this. proper name would be _forbidden_await_loops

| ast.ListComp
| ast.SetComp
)
await_sub_nodes = walk.get_subnodes_by_type(node, ast.Await)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Basically, that's why I said that it would be easier to visit await nodes first and then find forbidden parents from it. See

def get_closest_parent(
node: ast.AST,
parents: _IsInstanceContainer,
) -> ast.AST | None:
"""Returns the closes parent of a node of requested types."""
parent = get_parent(node)
while True:
if parent is None:
return None
if isinstance(parent, parents):
return parent
parent = get_parent(parent)

Basically, you would just have an await node, pass it into container = get_closest_parent(node, self._forbidden_await_loops) and then check that the container is not an async comprehension. That's it. Three lines of code.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 9, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (2c4b96e) to head (f63f2e9).
Report is 103 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #3330   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          357       358    +1     
  Lines        11708     11761   +53     
  Branches       801       803    +2     
=========================================
+ Hits         11708     11761   +53     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@skv0zsneg
Copy link
Copy Markdown
Contributor Author

I'm change method as you said this is really decreese rows number 💯

Also I added test for multiply comprehensions in one including async.

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

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

Thanks! Almost there.

if not isinstance(evaled, ast.Name) and bool(evaled):
self.add_violation(InfiniteWhileLoopViolation(node))

def _check_await_inside_loop(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It would be easier to use visit_Await method and the call _check_await_inside_loop with ast.Await node :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Got it! I should put it in WrongLoopVisitor or somewhere else?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if it fits the complexity rules - yes!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It unfortunately did not fit D:
Moved it to new class.

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

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

Just one comment :)

Comment on lines +260 to +265
if node_parent is not None:
if isinstance(node_parent, AnyComprehension) and all(
comprehension.is_async
for comprehension in node_parent.generators
):
return
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if node_parent is not None:
if isinstance(node_parent, AnyComprehension) and all(
comprehension.is_async
for comprehension in node_parent.generators
):
return
if (
isinstance(node_parent, AnyComprehension)
and all(
comprehension.is_async
for comprehension in node_parent.generators
):
# async comprehensions are allowed to use `await`
return

Copy link
Copy Markdown
Contributor Author

@skv0zsneg skv0zsneg Mar 10, 2025

Choose a reason for hiding this comment

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

You want me to add this comment?
# async comprehensions are allowed to use await

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not only a comment, but change how ifs are nested

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

change how ifs are nested

In this case tests are falling 😢
This will have to do some changes again, if it's ok I'll do it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Push changes 👍

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

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

Thank you! Great job!

@sobolevn sobolevn merged commit 03339e4 into wemake-services:master Mar 11, 2025
@skv0zsneg
Copy link
Copy Markdown
Contributor Author

Thank you! I have a lot of fun and learning new things doing this task.

Hope I can help project solving another issues and bugs, if you don't mind 🤓

@sobolevn
Copy link
Copy Markdown
Member

Sure, please, go ahead!

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.

Forbid await in for loop

2 participants