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: