From ced45706284e9bf76bd8deb0afd35d992026d350 Mon Sep 17 00:00:00 2001
From: jakkdl
Date: Fri, 1 Mar 2024 16:45:25 +0100
Subject: [PATCH] update error descriptions in README to better reflect which
errors work 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.
---
README.md | 46 ++++++-------
flake8_async/visitors/helpers.py | 3 +
flake8_async/visitors/visitor103_104.py | 34 +++++++++-
flake8_async/visitors/visitor91x.py | 16 ++++-
flake8_async/visitors/visitor_utility.py | 8 ++-
tests/eval_files/anyio_trio.py | 1 +
tests/eval_files/async102.py | 1 +
tests/eval_files/async102_anyio.py | 7 +-
tests/eval_files/async102_asyncio.py | 39 +++++++++++
tests/eval_files/async103_all_imported.py | 79 +++++++++++++++++++++++
tests/eval_files/async118.py | 3 +
tests/test_config_and_args.py | 17 ++++-
tests/test_flake8_async.py | 68 +++++++++++++------
13 files changed, 270 insertions(+), 52 deletions(-)
create mode 100644 tests/eval_files/async102_asyncio.py
create mode 100644 tests/eval_files/async103_all_imported.py
diff --git a/README.md b/README.md
index b3ce880..b6126a2 100644
--- a/README.md
+++ b/README.md
@@ -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 : 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 : 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.
diff --git a/flake8_async/visitors/helpers.py b/flake8_async/visitors/helpers.py
index 40b5451..f8521b3 100644
--- a/flake8_async/visitors/helpers.py
+++ b/flake8_async/visitors/helpers.py
@@ -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
diff --git a/flake8_async/visitors/visitor103_104.py b/flake8_async/visitors/visitor103_104.py
index 119d66b..7bd815e 100644
--- a/flake8_async/visitors/visitor103_104.py
+++ b/flake8_async/visitors/visitor103_104.py
@@ -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,
@@ -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
@@ -69,6 +92,13 @@ 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
@@ -76,7 +106,7 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler):
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
diff --git a/flake8_async/visitors/visitor91x.py b/flake8_async/visitors/visitor91x.py
index bbc4739..9fcc5e7 100644
--- a/flake8_async/visitors/visitor91x.py
+++ b/flake8_async/visitors/visitor91x.py
@@ -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()"))]
)
@@ -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,
@@ -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:
@@ -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()
diff --git a/flake8_async/visitors/visitor_utility.py b/flake8_async/visitors/visitor_utility.py
index 220fc47..bf84354 100644
--- a/flake8_async/visitors/visitor_utility.py
+++ b/flake8_async/visitors/visitor_utility.py
@@ -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)
diff --git a/tests/eval_files/anyio_trio.py b/tests/eval_files/anyio_trio.py
index 2c48ab4..4d5fff6 100644
--- a/tests/eval_files/anyio_trio.py
+++ b/tests/eval_files/anyio_trio.py
@@ -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`
diff --git a/tests/eval_files/async102.py b/tests/eval_files/async102.py
index 5338183..5482388 100644
--- a/tests/eval_files/async102.py
+++ b/tests/eval_files/async102.py
@@ -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
diff --git a/tests/eval_files/async102_anyio.py b/tests/eval_files/async102_anyio.py
index ea73c9c..ae1a57d 100644
--- a/tests/eval_files/async102_anyio.py
+++ b/tests/eval_files/async102_anyio.py
@@ -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(): ...
diff --git a/tests/eval_files/async102_asyncio.py b/tests/eval_files/async102_asyncio.py
new file mode 100644
index 0000000..4f28fea
--- /dev/null
+++ b/tests/eval_files/async102_asyncio.py
@@ -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())
+ )
diff --git a/tests/eval_files/async103_all_imported.py b/tests/eval_files/async103_all_imported.py
new file mode 100644
index 0000000..0c1bdc4
--- /dev/null
+++ b/tests/eval_files/async103_all_imported.py
@@ -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"
+ ...
diff --git a/tests/eval_files/async118.py b/tests/eval_files/async118.py
index 1323a60..25ed385 100644
--- a/tests/eval_files/async118.py
+++ b/tests/eval_files/async118.py
@@ -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
diff --git a/tests/test_config_and_args.py b/tests/test_config_and_args.py
index 7d3df93..f9dcea6 100644
--- a/tests/test_config_and_args.py
+++ b/tests/test_config_and_args.py
@@ -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"),
]
diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py
index c16dd55..e86e548 100644
--- a/tests/test_flake8_async.py
+++ b/tests/test_flake8_async.py
@@ -207,7 +207,12 @@ def find_magic_markers(
markers = (f.name for f in fields(found_markers))
pattern = rf'# ({"|".join(markers)})'
for f in re.findall(pattern, content):
- setattr(found_markers, f, True)
+ if f == "BASE_LIBRARY":
+ m = re.search(r"# BASE_LIBRARY (\w*)\n", content)
+ assert m, "invalid 'BASE_LIBRARY' marker"
+ found_markers.BASE_LIBRARY = m.groups()[0]
+ else:
+ setattr(found_markers, f, True)
return found_markers
@@ -229,16 +234,16 @@ def test_eval(
magic_markers = find_magic_markers(content)
# if autofixing, columns may get messed up
ignore_column = autofix
-
- if library == "anyio" and magic_markers.NOANYIO:
- pytest.skip("file marked with NOANYIO")
- if library == "asyncio" and magic_markers.NOASYNCIO:
- pytest.skip("file marked with NOANYIO")
- if library == "trio" and magic_markers.NOTRIO:
- pytest.skip("file marked with NOTRIO")
-
- if library == "asyncio" and autofix:
- pytest.skip("no support for asyncio+autofix currently")
+ only_check_not_crash = False
+
+ # file would raise different errors if transformed to a different library
+ # so we run the checker against it solely to check that it doesn't crash
+ if (
+ (library == "anyio" and magic_markers.NOANYIO)
+ or (library == "asyncio" and magic_markers.NOASYNCIO)
+ or (library == "trio" and magic_markers.NOTRIO)
+ ):
+ only_check_not_crash = True
if library != magic_markers.BASE_LIBRARY:
content = replace_library(
@@ -252,7 +257,9 @@ def test_eval(
# replace all instances of some error with noqa
content = re.sub(r"#[\s]*(error|ASYNC\d\d\d):.*", "# noqa", content)
- expected, parsed_args, enable = _parse_eval_file(test, content)
+ expected, parsed_args, enable = _parse_eval_file(
+ test, content, only_parse_args=only_check_not_crash
+ )
if library != "trio":
parsed_args.insert(0, f"--{library}")
if autofix:
@@ -265,16 +272,26 @@ def test_eval(
plugin = Plugin.from_source(content)
errors = assert_expected_errors(
- plugin, *expected, args=parsed_args, ignore_column=ignore_column
+ plugin,
+ *expected,
+ args=parsed_args,
+ ignore_column=ignore_column,
+ only_check_not_crash=only_check_not_crash,
)
- if library == "anyio":
- # check that error messages refer to 'anyio', or to neither library
+ if only_check_not_crash:
+ return
+
+ # check that error messages refer to current library, or to no library
+ if test not in ("ASYNC103_BOTH_IMPORTED", "ASYNC103_ALL_IMPORTED"):
for error in errors:
message = error.message.format(*error.args)
- assert "anyio" in message or "trio" not in message
+ assert library in message or not any(
+ lib in message for lib in ("anyio", "asyncio", "trio")
+ )
- if autofix and not noqa:
+ # asyncio does not support autofix atm, so should not modify content
+ if autofix and not noqa and library != "asyncio":
check_autofix(
test,
plugin,
@@ -310,7 +327,9 @@ def test_autofix(test: str):
assert plugin.module.code == content, "autofixed file changed when autofixed again"
-def _parse_eval_file(test: str, content: str) -> tuple[list[Error], list[str], str]:
+def _parse_eval_file(
+ test: str, content: str, only_parse_args: bool = False
+) -> tuple[list[Error], list[str], str]:
# version check
check_version(test)
test = test.split("_")[0]
@@ -340,6 +359,9 @@ def _parse_eval_file(test: str, content: str) -> tuple[list[Error], list[str], s
if m := re.match(r"--enable=(.*)", argument):
enabled_codes = m.groups()[0]
+ if only_parse_args:
+ continue
+
# skip commented out lines
if not line or line[0] == "#":
continue
@@ -471,6 +493,7 @@ def assert_expected_errors(
*expected: Error,
args: list[str] | None = None,
ignore_column: bool = False,
+ only_check_not_crash: bool = False,
) -> list[Error]:
# initialize default option values
initialize_options(plugin, args)
@@ -482,6 +505,15 @@ def assert_expected_errors(
for e in *errors, *expected_:
e.col = -1
+ if only_check_not_crash:
+ # Check that this file in fact does report different errors.
+ # Exclude empty errors+expected_ due to noqa runs.
+ assert errors != expected_ or errors == expected_ == [], (
+ "eval file appears to give all the correct errors."
+ " Maybe you can remove the `# NO[ANYIO/TRIO/ASYNCIO]` magic marker?"
+ )
+ return errors
+
print_first_diff(errors, expected_)
assert_correct_lines_and_codes(errors, expected_)
if not ignore_column: