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

moar asyncio support, test infra improvements #213

Merged
merged 8 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion flake8_async/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def __iter__(self):

def cmp(self):
# column may be ignored/modified when autofixing, so sort on that last
return self.line, self.code, self.args, self.col
return self.line, self.code, self.args, self.message, self.col
jakkdl marked this conversation as resolved.
Show resolved Hide resolved

# for sorting in tests
def __lt__(self, other: Error) -> bool:
Expand Down
67 changes: 60 additions & 7 deletions flake8_async/visitors/visitor2xx.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,26 @@ def visit_blocking_call(self, node: ast.Call):
# Process invocations 202
@error_class
class Visitor22X(Visitor200):
# not the prettiest, as it requires duplicating eval files, and will give
# nonsensical error messages if using asyncio+trio/anyio
error_codes: Mapping[str, str] = {
"ASYNC220": (
"Sync call {} in async function, use "
"`await nursery.start({}.run_process, ...)`."
),
"ASYNC220_asyncio": (
"Sync call {} in async function, use "
"`asyncio.create_subprocess_[exec/shell]."
),
"ASYNC221": "Sync call {} in async function, use `await {}.run_process(...)`.",
"ASYNC221_asyncio": (
"Sync call {} in async function, use "
"`asyncio.create_subprocess_[exec/shell]."
),
"ASYNC222": "Sync call {} in async function, wrap in `{}.to_thread.run_sync()`.",
"ASYNC222_asyncio": (
"Sync call {} in async function, use `asyncio.loop.run_in_executor`."
),
Comment on lines +179 to +191
Copy link
Member Author

@jakkdl jakkdl Mar 4, 2024

Choose a reason for hiding this comment

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

}

def visit_blocking_call(self, node: ast.Call):
Expand Down Expand Up @@ -222,7 +235,14 @@ def is_p_wait(arg: ast.expr) -> bool:
error_code = "ASYNC220"
break

if error_code is not None:
if error_code is None:
return
if self.library == ("asyncio",):
self.error(node, func_name, error_code=error_code + "_asyncio")
else:
# the asyncio + anyio/trio case is probably not worth special casing,
# so we simply suggest e.g. `[trio/asyncio/anyio].run_process()` despite
# asyncio.run_process not existing
self.error(node, func_name, self.library_str, error_code=error_code)


Expand All @@ -231,6 +251,14 @@ class Visitor23X(Visitor200):
error_codes: Mapping[str, str] = {
"ASYNC230": "Sync call {0} in async function, use `{1}.open_file(...)`.",
"ASYNC231": "Sync call {0} in async function, use `{1}.wrap_file({0})`.",
"ASYNC230_asyncio": (
"Sync call {0} in async function, "
" use a library such as aiofiles or anyio."
),
"ASYNC231_asyncio": (
"Sync call {0} in async function, "
" use a library such as aiofiles or anyio."
),
}

def visit_Call(self, node: ast.Call):
Expand All @@ -249,16 +277,23 @@ def visit_blocking_call(self, node: ast.Call):
error_code = "ASYNC231"
else:
return
self.error(node, func_name, self.library_str, error_code=error_code)
if self.library == ("asyncio",):
self.error(node, func_name, error_code=error_code + "_asyncio")
else:
self.error(node, func_name, self.library_str, error_code=error_code)


@error_class
class Visitor232(Visitor200):
error_codes: Mapping[str, str] = {
"ASYNC232": (
"Blocking sync call {1} on file object {0}, wrap the file object"
"Blocking sync call {1} on file object {0}, wrap the file object "
"in `{2}.wrap_file()` to get an async file object."
)
),
"ASYNC232_asyncio": (
"Blocking sync call {1} on file object {0}, wrap the file object "
" to get an async file object."
),
}

def __init__(self, *args: Any, **kwargs: Any):
Expand All @@ -279,13 +314,30 @@ def visit_blocking_call(self, node: ast.Call):
and (anno := self.variables.get(node.func.value.id))
and (anno in io_file_types or f"io.{anno}" in io_file_types)
):
self.error(node, node.func.attr, node.func.value.id, self.library_str)
if self.library == ("asyncio",):
self.error(
node,
node.func.attr,
node.func.value.id,
error_code="ASYNC232_asyncio",
)
else:
self.error(
node,
node.func.attr,
node.func.value.id,
self.library_str,
error_code="ASYNC232",
)


@error_class
class Visitor24X(Visitor200):
error_codes: Mapping[str, str] = {
"ASYNC240": "Avoid using os.path, prefer using {1}.Path objects.",
"ASYNC240_asyncio": (
"Avoid using os.path, use a library such as aiopath or anyio."
),
}

def __init__(self, *args: Any, **kwargs: Any):
Expand Down Expand Up @@ -324,10 +376,11 @@ def visit_ImportFrom(self, node: ast.ImportFrom):
def visit_Call(self, node: ast.Call):
if not self.async_function:
return
error_code = "ASYNC240_asyncio" if self.library == ("asyncio",) else "ASYNC240"
func_name = ast.unparse(node.func)
if func_name in self.imports_from_ospath:
self.error(node, func_name, self.library_str)
self.error(node, func_name, self.library_str, error_code=error_code)
elif (m := re.fullmatch(r"os\.path\.(?P<func>.*)", func_name)) and m.group(
"func"
) in self.os_funcs:
self.error(node, m.group("func"), self.library_str)
self.error(node, m.group("func"), self.library_str, error_code=error_code)
6 changes: 4 additions & 2 deletions flake8_async/visitors/visitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ def visit_With(self, node: ast.With | ast.AsyncWith):
var_name = item.optional_vars.id

# check for trio.open_nursery
nursery = get_matching_call(item.context_expr, "open_nursery")
nursery = get_matching_call(
item.context_expr, "open_nursery", base=("trio",)
)
Comment on lines -96 to +98
Copy link
Member Author

Choose a reason for hiding this comment

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

nurseries only exist in trio. Could expand the check to work with anyio taskgroups though

Copy link
Member

Choose a reason for hiding this comment

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

Let's leave that for a future PR, but yes - it'd be great for the ecosystem to support this for anyio.TaskGroup, and at that point we might as well support asyncio.TaskGroup too.


# `isinstance(..., ast.Call)` is done in get_matching_call
body_call = cast("ast.Call", node.body[0].value)
Expand Down Expand Up @@ -209,7 +211,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
self.error(node, node.name)


# Suggests replacing all `trio.sleep(0)` with the more suggestive
# Suggests replacing all `[trio|anyio].sleep(0)` with the more suggestive
# `trio.lowlevel.checkpoint()`
@error_class
class Visitor115(Flake8AsyncVisitor):
Expand Down
2 changes: 1 addition & 1 deletion tests/autofix_files/async100.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# type: ignore
# AUTOFIX
# NOASYNCIO
# ASYNCIO_NO_ERROR # timeout primitives are named differently in asyncio

import trio

Expand Down
2 changes: 1 addition & 1 deletion tests/autofix_files/async100_simple_autofix.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# NOASYNCIO
# ASYNCIO_NO_ERROR - no asyncio.move_on_after
# AUTOFIX
import trio

Expand Down
3 changes: 1 addition & 2 deletions tests/autofix_files/noqa.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# AUTOFIX
# NOANYIO # TODO
# NOASYNCIO
# NOASYNCIO - does not trigger on ASYNC100
# ARG --enable=ASYNC100,ASYNC911
from typing import Any

Expand Down
1 change: 0 additions & 1 deletion tests/autofix_files/noqa_testing.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# AUTOFIX
# NOANYIO # TODO
Zac-HD marked this conversation as resolved.
Show resolved Hide resolved
# ARG --enable=ASYNC911
import trio

Expand Down
1 change: 0 additions & 1 deletion tests/eval_files/anyio_trio.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# type: ignore
# ARG --enable=ASYNC220
# NOTRIO
# NOASYNCIO
# set base library so trio doesn't get replaced when running with anyio
# BASE_LIBRARY anyio

Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async100.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# type: ignore
# AUTOFIX
# NOASYNCIO
# ASYNCIO_NO_ERROR # timeout primitives are named differently in asyncio

import trio

Expand Down
23 changes: 23 additions & 0 deletions tests/eval_files/async100_asyncio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# TRIO_NO_ERROR
# ANYIO_NO_ERROR
# BASE_LIBRARY asyncio
# ASYNCIO_NO_ERROR # TODO

import asyncio
import asyncio.timeouts


async def foo():
# py>=3.11 re-exports these in the main asyncio namespace
with asyncio.timeout_at(10): # type: ignore[attr-defined]
...
with asyncio.timeout_at(10): # type: ignore[attr-defined]
...
with asyncio.timeout(10): # type: ignore[attr-defined]
...
with asyncio.timeouts.timeout_at(10):
...
with asyncio.timeouts.timeout_at(10):
...
with asyncio.timeouts.timeout(10):
...
2 changes: 1 addition & 1 deletion tests/eval_files/async100_noautofix.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# NOASYNCIO
# ASYNCIO_NO_ERROR - no asyncio.move_on_after
import trio


Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async100_simple_autofix.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# NOASYNCIO
# ASYNCIO_NO_ERROR - no asyncio.move_on_after
# AUTOFIX
import trio

Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async101.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# NOASYNCIO
# ASYNCIO_NO_ERROR - no nursery/cancelscope in asyncio
# type: ignore
Copy link
Member

@Zac-HD Zac-HD Mar 4, 2024

Choose a reason for hiding this comment

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

See above for a future PR; there is asyncio.TaskGroup.

I also suspect that async with timeout() is doing something similar? But at this point users really just have to switch to structured concurrency if they want sane error handling.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh right right, trio.[move_on/fail]_[after/at] are just wrapper functions for cancelscopes.
It might still be good (and fairly easy) to raise errors for an asyncio user doing it, even if our real suggestion is for them to switch to anyio.

Secret plan is for asyncio users to start running our checks, then when getting tons of useful errors realize they should switch to anyio 😈

import contextlib
import contextlib as bla
Expand Down
3 changes: 1 addition & 2 deletions tests/eval_files/async102.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# type: ignore
# NOASYNCIO
# asyncio has different mechanisms for shielded scopes, so would raise additional errors in this file.
# NOASYNCIO # TODO: support asyncio shields
from contextlib import asynccontextmanager

import trio
Expand Down
1 change: 0 additions & 1 deletion tests/eval_files/async103.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# NOASYNCIO
# ARG --enable=ASYNC103,ASYNC104

from typing import Any
Expand Down
1 change: 0 additions & 1 deletion tests/eval_files/async103_no_104.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# NOASYNCIO
# ARG --enable=ASYNC103
# check that partly disabling a visitor works
from typing import Any
Expand Down
1 change: 0 additions & 1 deletion tests/eval_files/async104.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# ARG --enable=ASYNC103,ASYNC104
# NOASYNCIO
try:
...
# raise different exception
Expand Down
4 changes: 2 additions & 2 deletions tests/eval_files/async105.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# NOANYIO
# NOASYNCIO
# ANYIO_NO_ERROR # not supported, and no plans to
# ASYNCIO_NO_ERROR # not supported, and no plans to
from typing import Any
from collections.abc import Coroutine

Expand Down
1 change: 0 additions & 1 deletion tests/eval_files/async109.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# type: ignore
# NOASYNCIO
import trio
import trio as anything

Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async110.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# type: ignore
# NOASYNCIO
# ASYNCIO_NO_ERROR - not yet supported # TODO
import trio
import trio as noerror

Expand Down
4 changes: 3 additions & 1 deletion tests/eval_files/async111.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# type: ignore
# NOASYNCIO
# ASYNC111: Variable, from context manager opened inside nursery, passed to start[_soon] might be invalidly accessed while in use, due to context manager closing before the nursery. This is usually a bug, and nurseries should generally be the inner-most context manager.
# It's possible there's an equivalent asyncio construction/gotcha, but methods are differently named, so this file will not raise any errors
# ASYNCIO_NO_ERROR # no nurseries in asyncio. Though maybe the bug is relevant for TaskGroups
Copy link
Member

Choose a reason for hiding this comment

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

Plausibly, yeah.

from typing import Any

import trio
Expand Down
4 changes: 3 additions & 1 deletion tests/eval_files/async112.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# type: ignore
# NOASYNCIO
# ASYNC112: Nursery body with only a call to nursery.start[_soon] and not passing itself as a parameter can be replaced with a regular function call.
# ASYNCIO_NO_ERROR - # TODO: expand check to work with asyncio.TaskGroup
# ANYIO_NO_ERROR - # TODO: expand check to work with anyio.TaskGroup
import functools
from functools import partial

Expand Down
4 changes: 2 additions & 2 deletions tests/eval_files/async113.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# mypy: disable-error-code="arg-type,attr-defined"
# NOASYNCIO
# NOASYNCIO - with two "base libraries" this file will raise errors even if substituting one of them
from contextlib import asynccontextmanager

import anyio
import trio

# NOANYIO - requires no substitution check
# NOANYIO - this file checks both libraries


@asynccontextmanager
Expand Down
6 changes: 4 additions & 2 deletions tests/eval_files/async113_trio.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# mypy: disable-error-code="arg-type,call-overload,misc"
# NOASYNCIO
# ASYNC113: Using nursery.start_soon in __aenter__ doesn't wait for the task to begin. Consider replacing with nursery.start.
import contextlib
import contextlib as arbitrary_import_alias_for_contextlib
import functools
Expand All @@ -11,7 +11,9 @@
import trio

# ARG --startable-in-context-manager=custom_startable_function
# NOANYIO

# ANYIO_NO_ERROR - anyio uses TaskGroups, not nurseries
Copy link
Member

Choose a reason for hiding this comment

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

We should support anyio for this though, open an issue?

Copy link
Member Author

Choose a reason for hiding this comment

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

This one, as opposed to 101, 111, and 112 is supported. Updated comment to refer to async113.py which tests anyio functionality.

# ASYNCIO_NO_ERROR - no nurseries in asyncio


@asynccontextmanager
Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async115.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# type: ignore
# NOASYNCIO
# ASYNCIO_NO_ERROR # no asyncio.lowlevel.checkpoint()
import time

import trio
Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async116.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# type: ignore
# NOASYNCIO
# ASYNCIO_NO_ERROR - no asyncio.sleep_forever, so check intentionally doesn't trigger.
import math
from math import inf

Expand Down
9 changes: 5 additions & 4 deletions tests/eval_files/async118.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# NOTRIO
# NOASYNCIO
# This raises the same errors on trio/asyncio, which is a bit silly, but inconsequential
# marked not to run the tests though as error messages will only refer to anyio
# `get_cancelled_exc_class` will trigger errors even if anyio isn't imported, out of
# an abundance of caution. But `anyio.get_cancelled_exc_class` will not
# NOTRIO # anyio-specific check, see above
# NOASYNCIO # anyio-specific check, see above
# BASE_LIBRARY anyio
from typing import Any

import anyio
Expand Down
2 changes: 1 addition & 1 deletion tests/eval_files/async22x.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# type: ignore
# ARG --enable=ASYNC220,ASYNC221,ASYNC222
# NOASYNCIO
# NOASYNCIO - specifies error message differently


async def foo():
Expand Down
Loading
Loading