Skip to content

Commit

Permalink
update error descriptions in README to better reflect which errors wo…
Browse files Browse the repository at this point in the history
…rk with which library, and minor updates. 102/103/104 now sees asyncio.exceptions.CancelledError as a critical exception. Fix import/arg detection of asyncio. add asyncio102_asyncio. add async103_all_imported. Finally make test_anyio_from_config autodetect the correct line number. Fix BASE_LIBRARY marker being interpreted as a bool. Make #NOTRIO/#NOASYNCIO/#NOANYIO run the visitor but ignore the result, instead of skipping, to check it doesn't crash. Generalize error-message-library-check.
  • Loading branch information
jakkdl committed Mar 1, 2024
1 parent e97b5d3 commit ced4570
Show file tree
Hide file tree
Showing 13 changed files with 270 additions and 52 deletions.
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,47 +23,47 @@ pip install flake8-async
```

## List of warnings

- **ASYNC100**: A `with trio.fail_after(...):` or `with trio.move_on_after(...):`
- **ASYNC100**: A `with [trio|anyio].fail_after(...):` or `with [trio|anyio].move_on_after(...):`
context does not contain any `await` statements. This makes it pointless, as
the timeout can only be triggered by a checkpoint.
- **ASYNC101**: `yield` inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling.
- **ASYNC102**: It's unsafe to await inside `finally:` or `except BaseException/trio.Cancelled` unless you use a shielded
cancel scope with a timeout.
- **ASYNC103**: `except BaseException`, `except trio.Cancelled` or a bare `except:` with a code path that doesn't re-raise. If you don't want to re-raise `BaseException`, add a separate handler for `trio.Cancelled` before.
- **ASYNC104**: `Cancelled` and `BaseException` must be re-raised - when a user tries to `return` or `raise` a different exception.
- **ASYNC105**: Calling a trio async function without immediately `await`ing it.
- **ASYNC101**: `yield` inside a trio/anyio nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling.
- **ASYNC102**: It's unsafe to await inside `finally:` or `except BaseException/trio.Cancelled/anyio.get_cancelled_exc_class()/asyncio.exceptions.CancelledError` unless you use a shielded cancel scope with a timeout. This is currently not able to detect asyncio shields.
- **ASYNC103**: `except BaseException/trio.Cancelled/anyio.get_cancelled_exc_class()/asyncio.exceptions.CancelledError`, or a bare `except:` with a code path that doesn't re-raise. If you don't want to re-raise `BaseException`, add a separate handler for `trio.Cancelled`/`anyio.get_cancelled_exc_class()`/`asyncio.exceptions.CancelledError` before.
- **ASYNC104**: `trio.Cancelled`/`anyio.get_cancelled_exc_class()`/`asyncio.exceptions.CancelledError`/`BaseException` must be re-raised. The same as ASYNC103, except specifically triggered on `return` or a different exception being raised.
- **ASYNC105**: Calling a trio async function without immediately `await`ing it. This is only supported with trio functions, but you can get similar functionality with a type-checker.
- **ASYNC106**: `trio`/`anyio`/`asyncio` must be imported with `import trio`/`import anyio`/`import asyncio` for the linter to work.
- **ASYNC109**: Async function definition with a `timeout` parameter - use `trio.[fail/move_on]_[after/at]` instead
- **ASYNC110**: `while <condition>: await trio.sleep()` should be replaced by a `trio.Event`.
- **ASYNC109**: Async function definition with a `timeout` parameter - use `[trio/anyio].[fail/move_on]_[after/at]` instead.
- **ASYNC110**: `while <condition>: await [trio/anyio].sleep()` should be replaced by a `[trio|anyio].Event`.
- **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.
- **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.
- **ASYNC113**: Using `nursery.start_soon` in `__aenter__` doesn't wait for the task to begin. Consider replacing with `nursery.start`.
- **ASYNC114**: Startable function (i.e. has a `task_status` keyword parameter) not in `--startable-in-context-manager` parameter list, please add it so ASYNC113 can catch errors when using it.
- **ASYNC115**: Replace `trio.sleep(0)` with the more suggestive `trio.lowlevel.checkpoint()`.
- **ASYNC116**: `trio.sleep()` with >24 hour interval should usually be `trio.sleep_forever()`.
- **ASYNC115**: Replace `[trio|anyio].sleep(0)` with the more suggestive `[trio|anyio].lowlevel.checkpoint()`.
- **ASYNC116**: `[trio|anyio].sleep()` with >24 hour interval should usually be `[trio|anyio].sleep_forever()`.
- **ASYNC118**: Don't assign the value of `anyio.get_cancelled_exc_class()` to a variable, since that breaks linter checks and multi-backend programs.

### Warnings for blocking sync calls in async functions
- **ASYNC200**: User-configured error for blocking sync calls in async functions. Does nothing by default, see [`trio200-blocking-calls`](#trio200-blocking-calls) for how to configure it.
- **ASYNC210**: Sync HTTP call in async function, use `httpx.AsyncClient`.
Note: 22X, 23X and 24X has not had asyncio-specific suggestions written.
- **ASYNC200**: User-configured error for blocking sync calls in async functions. Does nothing by default, see [`async200-blocking-calls`](#async200-blocking-calls) for how to configure it.
- **ASYNC210**: Sync HTTP call in async function, use `httpx.AsyncClient`. This and the other ASYNC21x checks look for usage of `urllib3` and `httpx.Client`, and recommend using `httpx.AsyncClient` as that's the largest http client supporting anyio/trio.
- **ASYNC211**: Likely sync HTTP call in async function, use `httpx.AsyncClient`. Looks for `urllib3` method calls on pool objects, but only matching on the method signature and not the object.
- **ASYNC212**: Blocking sync HTTP call on httpx object, use httpx.AsyncClient.
- **ASYNC220**: Sync process call in async function, use `await nursery.start(trio.run_process, ...)`.
- **ASYNC221**: Sync process call in async function, use `await trio.run_process(...)`.
- **ASYNC222**: Sync `os.*` call in async function, wrap in `await trio.to_thread.run_sync()`.
- **ASYNC230**: Sync IO call in async function, use `trio.open_file(...)`.
- **ASYNC231**: Sync IO call in async function, use `trio.wrap_file(...)`.
- **ASYNC232**: Blocking sync call on file object, wrap the file object in `trio.wrap_file()` to get an async file object.
- **ASYNC240**: Avoid using `os.path` in async functions, prefer using `trio.Path` objects.
- **ASYNC220**: Sync process call in async function, use `await nursery.start([trio|anyio].run_process, ...)`.
- **ASYNC221**: Sync process call in async function, use `await [trio|anyio].run_process(...)`.
- **ASYNC222**: Sync `os.*` call in async function, wrap in `await [trio|anyio].to_thread.run_sync()`.
- **ASYNC230**: Sync IO call in async function, use `[trio|anyio].open_file(...)`.
- **ASYNC231**: Sync IO call in async function, use `[trio|anyio].wrap_file(...)`.
- **ASYNC232**: Blocking sync call on file object, wrap the file object in `[trio|anyio].wrap_file()` to get an async file object.
- **ASYNC240**: Avoid using `os.path` in async functions, prefer using `[trio|anyio].Path` objects.

### Warnings disabled by default
- **ASYNC900**: Async generator without `@asynccontextmanager` not allowed.
- **ASYNC910**: Exit or `return` from async function with no guaranteed checkpoint or exception since function definition.
- **ASYNC900**: Async generator without `@asynccontextmanager` not allowed. You might want to enable this on a codebase since async generators are inherently unsafe and cleanup logic might not be performed. See https://github.com/python-trio/flake8-async/issues/211 and https://discuss.python.org/t/using-exceptiongroup-at-anthropic-experience-report/20888/6 for discussion.
- **ASYNC910**: Exit or `return` from async function with no guaranteed checkpoint or exception since function definition. You might want to enable this on a codebase to make it easier to reason about checkpoints, and make the logic of ASYNC911 correct.
- **ASYNC911**: Exit, `yield` or `return` from async iterable with no guaranteed checkpoint since possible function entry (yield or function definition)
Checkpoints are `await`, `async for`, and `async with` (on one of enter/exit).

### Removed Warnings
- **TRIOxxx**: All error codes are now renamed ASYNCxxx
- **TRIO107**: Renamed to TRIO910
- **TRIO108**: Renamed to TRIO911
- **TRIO117**: Don't raise or catch `trio.[NonBase]MultiError`, prefer `[exceptiongroup.]BaseExceptionGroup`. `MultiError` was removed in trio==0.24.0.
Expand Down
3 changes: 3 additions & 0 deletions flake8_async/visitors/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ def has_exception(node: ast.expr) -> str | None:
"trio.Cancelled",
"anyio.get_cancelled_exc_class()",
"get_cancelled_exc_class()",
"asyncio.exceptions.CancelledError",
"exceptions.CancelledError",
"CancelledError",
):
return name
return None
Expand Down
34 changes: 32 additions & 2 deletions flake8_async/visitors/visitor103_104.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,30 @@
_suggestion_dict: dict[tuple[str, ...], str] = {
("anyio",): "anyio.get_cancelled_exc_class()",
("trio",): "trio.Cancelled",
("asyncio",): "asyncio.exceptions.CancelledError",
}
_suggestion_dict[("anyio", "trio")] = "[" + "|".join(_suggestion_dict.values()) + "]"
# TODO: ugly
for a, b in (("anyio", "trio"), ("anyio", "asyncio"), ("asyncio", "trio")):
_suggestion_dict[(a, b)] = (
"[" + "|".join((_suggestion_dict[(a,)], _suggestion_dict[(b,)])) + "]"
)
_suggestion_dict[
(
"anyio",
"asyncio",
"trio",
)
] = (
"["
+ "|".join(
(
_suggestion_dict[("anyio",)],
_suggestion_dict[("asyncio",)],
_suggestion_dict[("trio",)],
)
)
+ "]"
)

_error_codes = {
"ASYNC103": _async103_common_msg,
Expand Down Expand Up @@ -56,6 +78,7 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler):
marker = critical_except(node)

if marker is None:
# not a critical exception handler
return

# If previous excepts have handled trio.Cancelled, don't do anything - namely
Expand All @@ -69,14 +92,21 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler):
):
error_code = "ASYNC103"
self.cancelled_caught.add("anyio")
elif marker.name in (
"asyncio.exceptions.CancelledError",
"exceptions.CancelledError",
"CancelledError",
):
error_code = "ASYNC103"
self.cancelled_caught.add("asyncio")
else:
if self.cancelled_caught:
return
if len(self.library) < 2:
error_code = f"ASYNC103_{self.library_str}"
else:
error_code = f"ASYNC103_{'_'.join(sorted(self.library))}"
self.cancelled_caught.update("trio", "anyio")
self.cancelled_caught.update("trio", "anyio", "asyncio")

# Don't save the state of cancelled_caught, that's handled in Try and would
# reset it between each except
Expand Down
16 changes: 13 additions & 3 deletions flake8_async/visitors/visitor91x.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ def copy(self):


def checkpoint_statement(library: str) -> cst.SimpleStatementLine:
# logic before this should stop code from wanting to insert the non-existing
# asyncio.lowlevel.checkpoint
assert library != "asyncio"
return cst.SimpleStatementLine(
[cst.Expr(cst.parse_expression(f"await {library}.lowlevel.checkpoint()"))]
)
Expand All @@ -111,6 +114,7 @@ def __init__(self):
self.noautofix: bool = False
self.add_statement: cst.SimpleStatementLine | None = None

# used for inserting import if there's none
self.explicitly_imported_library: dict[str, bool] = {
"trio": False,
"anyio": False,
Expand Down Expand Up @@ -250,8 +254,12 @@ def __init__(self, *args: Any, **kwargs: Any):
self.try_state = TryState()

def should_autofix(self, node: cst.CSTNode, code: str | None = None) -> bool:
return not self.noautofix and super().should_autofix(
node, "ASYNC911" if self.has_yield else "ASYNC910"
return (
not self.noautofix
and super().should_autofix(
node, "ASYNC911" if self.has_yield else "ASYNC910"
)
and self.library != ("asyncio",)
)

def checkpoint_statement(self) -> cst.SimpleStatementLine:
Expand Down Expand Up @@ -359,7 +367,9 @@ def leave_Return(
) -> cst.Return:
if not self.async_function:
return updated_node
if self.check_function_exit(original_node):
if self.check_function_exit(original_node) and self.should_autofix(
original_node
):
self.add_statement = self.checkpoint_statement()
# avoid duplicate error messages
self.uncheckpointed_statements = set()
Expand Down
8 changes: 7 additions & 1 deletion flake8_async/visitors/visitor_utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,17 @@ def __init__(self, *args: Any, **kwargs: Any):
# see imports
if self.options.anyio:
self.add_library("anyio")
if self.options.asyncio:
self.add_library("asyncio")

def visit_Import(self, node: cst.Import):
for alias in node.names:
if m.matches(
alias, m.ImportAlias(name=m.Name("trio") | m.Name("anyio"), asname=None)
alias,
m.ImportAlias(
name=m.Name("trio") | m.Name("anyio") | m.Name("asyncio"),
asname=None,
),
):
assert isinstance(alias.name.value, str)
self.add_library(alias.name.value)
Expand Down
1 change: 1 addition & 0 deletions tests/eval_files/anyio_trio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# ARG --enable=ASYNC220
# NOTRIO
# NOASYNCIO
# set base library so trio doesn't get replaced when running with anyio
# BASE_LIBRARY anyio

# anyio eval will automatically prepend this test with `--anyio`
Expand Down
1 change: 1 addition & 0 deletions tests/eval_files/async102.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# type: ignore
# NOASYNCIO
# asyncio has different mechanisms for shielded scopes, so would raise additional errors in this file.
from contextlib import asynccontextmanager

import trio
Expand Down
7 changes: 5 additions & 2 deletions tests/eval_files/async102_anyio.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# type: ignore
# NOTRIO
# NOASYNCIO
# BASE_LIBRARY anyio
# this test will raise the same errors with trio/asyncio, despite [trio|asyncio].get_cancelled_exc_class not existing
# marked not to run the tests though as error messages will only refer to anyio
import anyio
from anyio import get_cancelled_exc_class

# this one is fine to also run with trio


async def foo(): ...

Expand Down
39 changes: 39 additions & 0 deletions tests/eval_files/async102_asyncio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# type: ignore
# NOANYIO
# NOTRIO
# BASE_LIBRARY asyncio
from contextlib import asynccontextmanager

import asyncio


async def foo():
# asyncio.move_on_after does not exist, so this will raise an error
try:
...
finally:
with asyncio.move_on_after(deadline=30) as s:
s.shield = True
await foo() # error: 12, Statement("try/finally", lineno-5)

try:
pass
finally:
await foo() # error: 8, Statement("try/finally", lineno-3)

# asyncio.CancelScope does not exist, so this will raise an error
try:
pass
finally:
with asyncio.CancelScope(deadline=30, shield=True):
await foo() # error: 12, Statement("try/finally", lineno-4)

# TODO: I think this is the asyncio-equivalent, but functionality to ignore the error
# has not been implemented

try:
...
finally:
await asyncio.shield( # error: 8, Statement("try/finally", lineno-3)
asyncio.wait_for(foo())
)
79 changes: 79 additions & 0 deletions tests/eval_files/async103_all_imported.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# NOASYNCIO
# NOANYIO - don't run it with substitutions
import anyio
import trio
import asyncio
from asyncio.exceptions import CancelledError
from asyncio import exceptions

try:
...
except trio.Cancelled: # ASYNC103: 7, "trio.Cancelled"
...
except (
anyio.get_cancelled_exc_class() # ASYNC103: 4, "anyio.get_cancelled_exc_class()"
):
...
except CancelledError: # ASYNC103: 7, "CancelledError"
...
except: # safe
...

# reordered
try:
...
except (
asyncio.exceptions.CancelledError # ASYNC103: 4, "asyncio.exceptions.CancelledError"
):
...
except (
anyio.get_cancelled_exc_class() # ASYNC103: 4, "anyio.get_cancelled_exc_class()"
):
...
except trio.Cancelled: # ASYNC103: 7, "trio.Cancelled"
...
except: # safe
...

# asyncio supports all three ways of importing asyncio.exceptions.CancelledError
try:
...
except exceptions.CancelledError: # ASYNC103: 7, "exceptions.CancelledError"
...

# catching any one of the exceptions in multi-library files will suppress errors on the bare except. It's unlikely a try block contains code that can raise multiple ones.
try:
...
except (
anyio.get_cancelled_exc_class() # ASYNC103: 4, "anyio.get_cancelled_exc_class()"
):
...
except: # safe ?
...

try:
...
except trio.Cancelled: # ASYNC103: 7, "trio.Cancelled"
...
except: # safe ?
...

try:
...
except (
asyncio.exceptions.CancelledError # ASYNC103: 4, "asyncio.exceptions.CancelledError"
):
...
except: # safe ?
...

# Check we get the proper suggestion when all are imported
try:
...
except BaseException: # ASYNC103_anyio_asyncio_trio: 7, "BaseException"
...

try:
...
except: # ASYNC103_anyio_asyncio_trio: 0, "bare except"
...
3 changes: 3 additions & 0 deletions tests/eval_files/async118.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# 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
from typing import Any

import anyio
Expand Down
17 changes: 14 additions & 3 deletions tests/test_config_and_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,24 @@ def test_anyio_from_config(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"subprocess.Popen",
"[anyio|trio]",
)
err_file = str(Path(__file__).parent / "eval_files" / "anyio_trio.py")
expected = f"{err_file}:12:5: ASYNC220 {err_msg}\n"
err_file = Path(__file__).parent / "eval_files" / "anyio_trio.py"

# find the line with the expected error
for i, line in enumerate(err_file.read_text().split("\n")):
if "# ASYNC220: " in line:
# line numbers start at 1, enumerate starts at 0
lineno = i + 1
break
else:
raise AssertionError("could not find error in file")

# construct the full error message
expected = f"{err_file}:{lineno}:5: ASYNC220 {err_msg}\n"
from flake8.main.cli import main

returnvalue = main(
argv=[
err_file,
str(err_file),
"--config",
str(tmp_path / ".flake8"),
]
Expand Down
Loading

0 comments on commit ced4570

Please sign in to comment.