diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cee9e1c5..48e58f8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: release: runs-on: ubuntu-latest needs: [pyright, test] - if: github.repository == 'Zac-HD/flake8-trio' && github.ref == 'refs/heads/main' + if: github.repository == 'python-trio/flake8-async' && github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Set up Python 3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 811204a0..da23b5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## Future - Removed TRIO117, MultiError removed in trio 0.24.0 +- Renamed the library from flake8-trio to flake8-async, to indicate the checker supports more than just `trio`. +- Renamed all error codes from TRIOxxx to ASYNCxxx +- Renamed the binary from flake8-trio to flake8-async +- Lots of internal renaming. +- Added asyncio support for ASYNC106 +- added `--library` ## 23.5.1 - TRIO91X now supports comprehensions diff --git a/README.md b/README.md index 1e58a5b5..9fc0a480 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/python-trio/flake8-trio/main.svg)](https://results.pre-commit.ci/latest/github/python-trio/flake8-trio/main) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/python-trio/flake8-async/main.svg)](https://results.pre-commit.ci/latest/github/python-trio/flake8-async/main) [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/) # flake8-async @@ -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. -- **ASYNC106**: `trio`/`anyio` must be imported with `import trio`/`import anyio` 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`. +- **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/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/__init__.py b/flake8_async/__init__.py index 9bb13784..7d69d5b2 100644 --- a/flake8_async/__init__.py +++ b/flake8_async/__init__.py @@ -298,6 +298,19 @@ def add_options(option_manager: OptionManager | ArgumentParser): " suggestions with [anyio|trio]." ), ) + add_argument( + "--asyncio", + # action=store_true + parse_from_config does seem to work here, despite + # https://github.com/PyCQA/flake8/issues/1770 + action="store_true", + required=False, + default=False, + help=( + "Change the default library to be asyncio instead of trio." + " If anyio/trio is imported it will assume that is also available and" + " print suggestions with [asyncio|anyio/trio]." + ), + ) @staticmethod def parse_options(options: Namespace): @@ -342,6 +355,7 @@ def get_matching_codes( startable_in_context_manager=options.startable_in_context_manager, trio200_blocking_calls=options.trio200_blocking_calls, anyio=options.anyio, + asyncio=options.asyncio, disable_noqa=options.disable_noqa, ) diff --git a/flake8_async/base.py b/flake8_async/base.py index 4ebead4a..3f167d38 100644 --- a/flake8_async/base.py +++ b/flake8_async/base.py @@ -32,6 +32,7 @@ class Options: startable_in_context_manager: Collection[str] trio200_blocking_calls: dict[str, str] anyio: bool + asyncio: bool disable_noqa: bool diff --git a/flake8_async/visitors/helpers.py b/flake8_async/visitors/helpers.py index 40b5451d..f8521b3b 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 119d66b6..7bd815e8 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 bbc4739f..52643ee4 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, @@ -145,8 +149,11 @@ def leave_SimpleStatementLine( # possible TODO: generate an error if transforming+visiting is done in a # single pass and emit-error-on-transform can be enabled/disabled. The error can't # be generated in the yield/return since it doesn't know if it will be autofixed. - if self.add_statement is None or not self.should_autofix(original_node): + if self.add_statement is None: return updated_node + + # methods setting self.add_statement should have called self.should_autofix + assert self.should_autofix(original_node) curr_add_statement = self.add_statement self.add_statement = None @@ -250,8 +257,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 +370,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 58784a95..bf843541 100644 --- a/flake8_async/visitors/visitor_utility.py +++ b/flake8_async/visitors/visitor_utility.py @@ -117,11 +117,13 @@ 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: ast.Import): for alias in node.names: name = alias.name - if name in ("trio", "anyio") and alias.asname is None: + if name in ("trio", "anyio", "asyncio") and alias.asname is None: self.add_library(name) @@ -134,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/flake8_async/visitors/visitors.py b/flake8_async/visitors/visitors.py index 9293ab4b..259eb156 100644 --- a/flake8_async/visitors/visitors.py +++ b/flake8_async/visitors/visitors.py @@ -11,6 +11,8 @@ if TYPE_CHECKING: from collections.abc import Mapping +LIBRARIES = ("trio", "anyio", "asyncio") + @error_class class Visitor106(Flake8AsyncVisitor): @@ -19,12 +21,12 @@ class Visitor106(Flake8AsyncVisitor): } def visit_ImportFrom(self, node: ast.ImportFrom): - if node.module in ("trio", "anyio"): + if node.module in LIBRARIES: self.error(node, node.module) def visit_Import(self, node: ast.Import): for name in node.names: - if name.name in ("trio", "anyio") and name.asname is not None: + if name.name in LIBRARIES and name.asname is not None: self.error(node, name.name) diff --git a/pyproject.toml b/pyproject.toml index 1f8562ce..5892babf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ ignore = [ "COM", # flake8-comma, handled by black "ANN", # annotations, handled by pyright/mypy "T20", # flake8-print - "TID252", # relative imports from parent modules https://github.com/python-trio/flake8-trio/pull/196#discussion_r1200413372 + "TID252", # relative imports from parent modules https://github.com/python-trio/flake8-async/pull/196#discussion_r1200413372 "D101", "D102", "D103", diff --git a/setup.py b/setup.py index a1f4cdbb..124abe2a 100755 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def local_file(name: str) -> Path: author="Zac Hatfield-Dodds, John Litborn, and Contributors", author_email="zac@zhd.dev", packages=find_packages(include=["flake8_async", "flake8_async.*"]), - url="https://github.com/python-trio/flake8-trio", + url="https://github.com/python-trio/flake8-async", license="MIT", description="A highly opinionated flake8 plugin for Trio-related problems.", zip_safe=False, diff --git a/tests/autofix_files/async100.py b/tests/autofix_files/async100.py index f899fe37..f15dd43e 100644 --- a/tests/autofix_files/async100.py +++ b/tests/autofix_files/async100.py @@ -1,5 +1,6 @@ # type: ignore # AUTOFIX +# NOASYNCIO import trio diff --git a/tests/autofix_files/async100_simple_autofix.py b/tests/autofix_files/async100_simple_autofix.py index 450e9c1d..71742344 100644 --- a/tests/autofix_files/async100_simple_autofix.py +++ b/tests/autofix_files/async100_simple_autofix.py @@ -1,3 +1,4 @@ +# NOASYNCIO # AUTOFIX import trio diff --git a/tests/autofix_files/async910.py b/tests/autofix_files/async910.py index 1a9e698f..88c3f9fe 100644 --- a/tests/autofix_files/async910.py +++ b/tests/autofix_files/async910.py @@ -382,7 +382,7 @@ async def foo_try_7(): # safe pass -# https://github.com/Zac-HD/flake8-trio/issues/45 +# https://github.com/python-trio/flake8-async/issues/45 async def to_queue(iter_func, queue): async with iter_func() as it: async for x in it: @@ -499,7 +499,7 @@ async def foo_range_5(): # error: 0, "exit", Statement("function definition", l await trio.lowlevel.checkpoint() -# https://github.com/Zac-HD/flake8-trio/issues/47 +# https://github.com/python-trio/flake8-async/issues/47 async def f(): while True: if ...: diff --git a/tests/autofix_files/async910.py.diff b/tests/autofix_files/async910.py.diff index d36009bd..002a82da 100644 --- a/tests/autofix_files/async910.py.diff +++ b/tests/autofix_files/async910.py.diff @@ -196,7 +196,7 @@ + await trio.lowlevel.checkpoint() - # https://github.com/Zac-HD/flake8-trio/issues/47 + # https://github.com/python-trio/flake8-async/issues/47 @@ x,6 x,7 @@ # should error async def foo_comprehension_2(): # error: 0, "exit", Statement("function definition", lineno) diff --git a/tests/autofix_files/noqa.py b/tests/autofix_files/noqa.py index 7d69ee07..a0ca87bc 100644 --- a/tests/autofix_files/noqa.py +++ b/tests/autofix_files/noqa.py @@ -1,5 +1,6 @@ # AUTOFIX # NOANYIO # TODO +# NOASYNCIO # ARG --enable=ASYNC100,ASYNC911 from typing import Any diff --git a/tests/eval_files/anyio_trio.py b/tests/eval_files/anyio_trio.py index 93e8dba6..4d5fff6c 100644 --- a/tests/eval_files/anyio_trio.py +++ b/tests/eval_files/anyio_trio.py @@ -1,6 +1,9 @@ # type: ignore # 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` import trio # isort: skip diff --git a/tests/eval_files/async100.py b/tests/eval_files/async100.py index acf6e706..a9b39c67 100644 --- a/tests/eval_files/async100.py +++ b/tests/eval_files/async100.py @@ -1,5 +1,6 @@ # type: ignore # AUTOFIX +# NOASYNCIO import trio diff --git a/tests/eval_files/async100_noautofix.py b/tests/eval_files/async100_noautofix.py index f16d77b1..2f3624e7 100644 --- a/tests/eval_files/async100_noautofix.py +++ b/tests/eval_files/async100_noautofix.py @@ -1,3 +1,4 @@ +# NOASYNCIO import trio diff --git a/tests/eval_files/async100_simple_autofix.py b/tests/eval_files/async100_simple_autofix.py index 95286b2a..c783f71d 100644 --- a/tests/eval_files/async100_simple_autofix.py +++ b/tests/eval_files/async100_simple_autofix.py @@ -1,3 +1,4 @@ +# NOASYNCIO # AUTOFIX import trio diff --git a/tests/eval_files/async101.py b/tests/eval_files/async101.py index 9404807a..fb60825c 100644 --- a/tests/eval_files/async101.py +++ b/tests/eval_files/async101.py @@ -1,3 +1,4 @@ +# NOASYNCIO # type: ignore import contextlib import contextlib as bla diff --git a/tests/eval_files/async102.py b/tests/eval_files/async102.py index c891bdc9..2d90dd7e 100644 --- a/tests/eval_files/async102.py +++ b/tests/eval_files/async102.py @@ -1,4 +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 @@ -140,7 +142,7 @@ async def foo(): # change of functionality, no longer treated as safe -# https://github.com/Zac-HD/flake8-trio/issues/54 +# https://github.com/python-trio/flake8-async/issues/54 @asynccontextmanager async def foo2(): try: diff --git a/tests/eval_files/async102_anyio.py b/tests/eval_files/async102_anyio.py index 61102257..ae1a57da 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 ASYNC - async def foo(): ... diff --git a/tests/eval_files/async102_asyncio.py b/tests/eval_files/async102_asyncio.py new file mode 100644 index 00000000..4f28feab --- /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/async102_trio.py b/tests/eval_files/async102_trio.py index 58d58a5b..a672bd22 100644 --- a/tests/eval_files/async102_trio.py +++ b/tests/eval_files/async102_trio.py @@ -1,3 +1,4 @@ +# NOASYNCIO # NOANYIO - since anyio.Cancelled does not exist import trio diff --git a/tests/eval_files/async103.py b/tests/eval_files/async103.py index 82bf8e1b..5b010bff 100644 --- a/tests/eval_files/async103.py +++ b/tests/eval_files/async103.py @@ -1,3 +1,4 @@ +# NOASYNCIO # ARG --enable=ASYNC103,ASYNC104 from typing import Any diff --git a/tests/eval_files/async103_all_imported.py b/tests/eval_files/async103_all_imported.py new file mode 100644 index 00000000..0c1bdc41 --- /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/async103_both_imported.py b/tests/eval_files/async103_both_imported.py index 720fa811..64ea9476 100644 --- a/tests/eval_files/async103_both_imported.py +++ b/tests/eval_files/async103_both_imported.py @@ -1,3 +1,4 @@ +# NOASYNCIO # NOANYIO - don't run it with substitutions import anyio import trio diff --git a/tests/eval_files/async103_no_104.py b/tests/eval_files/async103_no_104.py index 4eb7e7e4..94d13372 100644 --- a/tests/eval_files/async103_no_104.py +++ b/tests/eval_files/async103_no_104.py @@ -1,3 +1,4 @@ +# NOASYNCIO # ARG --enable=ASYNC103 # check that partly disabling a visitor works from typing import Any diff --git a/tests/eval_files/async103_trio.py b/tests/eval_files/async103_trio.py index 82aad9ba..9ac4a2c0 100644 --- a/tests/eval_files/async103_trio.py +++ b/tests/eval_files/async103_trio.py @@ -1,4 +1,5 @@ # ARG --enable=ASYNC103,ASYNC104 +# NOASYNCIO # NOANYIO from typing import Any diff --git a/tests/eval_files/async104.py b/tests/eval_files/async104.py index 1701f26a..b7634767 100644 --- a/tests/eval_files/async104.py +++ b/tests/eval_files/async104.py @@ -1,4 +1,5 @@ # ARG --enable=ASYNC103,ASYNC104 +# NOASYNCIO try: ... # raise different exception @@ -13,7 +14,7 @@ try: ... except BaseException as e: - # see https://github.com/Zac-HD/flake8-trio/pull/8#discussion_r932737341 + # see https://github.com/python-trio/flake8-async/pull/8#discussion_r932737341 raise BaseException() from e # error: 4 diff --git a/tests/eval_files/async104_anyio.py b/tests/eval_files/async104_anyio.py index 4e8101f3..054b5160 100644 --- a/tests/eval_files/async104_anyio.py +++ b/tests/eval_files/async104_anyio.py @@ -1,4 +1,5 @@ # type: ignore +# BASE_LIBRARY ANYIO import anyio try: diff --git a/tests/eval_files/async104_trio.py b/tests/eval_files/async104_trio.py index e81c0faf..487169f6 100644 --- a/tests/eval_files/async104_trio.py +++ b/tests/eval_files/async104_trio.py @@ -1,4 +1,5 @@ # NOANYIO +# NOASYNCIO import trio try: diff --git a/tests/eval_files/async105.py b/tests/eval_files/async105.py index 8e18caee..93123bb3 100644 --- a/tests/eval_files/async105.py +++ b/tests/eval_files/async105.py @@ -1,4 +1,5 @@ # NOANYIO +# NOASYNCIO from typing import Any from collections.abc import Coroutine diff --git a/tests/eval_files/async105_anyio.py b/tests/eval_files/async105_anyio.py index bba17e5a..c51e0afd 100644 --- a/tests/eval_files/async105_anyio.py +++ b/tests/eval_files/async105_anyio.py @@ -1,4 +1,6 @@ # NOTRIO +# BASE_LIBRARY anyio +# asyncio obv will not raise any errors on this file import anyio diff --git a/tests/eval_files/async109.py b/tests/eval_files/async109.py index 139e2e5d..4c691836 100644 --- a/tests/eval_files/async109.py +++ b/tests/eval_files/async109.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import trio import trio as anything diff --git a/tests/eval_files/async110.py b/tests/eval_files/async110.py index 2052f8da..ec65f54b 100644 --- a/tests/eval_files/async110.py +++ b/tests/eval_files/async110.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import trio import trio as noerror diff --git a/tests/eval_files/async111.py b/tests/eval_files/async111.py index 40477102..7044c62a 100644 --- a/tests/eval_files/async111.py +++ b/tests/eval_files/async111.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO from typing import Any import trio diff --git a/tests/eval_files/async112.py b/tests/eval_files/async112.py index bb97bd21..f13b6ec5 100644 --- a/tests/eval_files/async112.py +++ b/tests/eval_files/async112.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import functools from functools import partial diff --git a/tests/eval_files/async113.py b/tests/eval_files/async113.py index 998e9e39..d0920ab6 100644 --- a/tests/eval_files/async113.py +++ b/tests/eval_files/async113.py @@ -1,4 +1,5 @@ # mypy: disable-error-code="arg-type,attr-defined" +# NOASYNCIO from contextlib import asynccontextmanager import anyio diff --git a/tests/eval_files/async113_trio.py b/tests/eval_files/async113_trio.py index b03dda96..bf4891f6 100644 --- a/tests/eval_files/async113_trio.py +++ b/tests/eval_files/async113_trio.py @@ -1,4 +1,5 @@ # mypy: disable-error-code="arg-type,call-overload,misc" +# NOASYNCIO import contextlib import contextlib as arbitrary_import_alias_for_contextlib import functools diff --git a/tests/eval_files/async114.py b/tests/eval_files/async114.py index c4f7b5c7..cecdb322 100644 --- a/tests/eval_files/async114.py +++ b/tests/eval_files/async114.py @@ -1,5 +1,8 @@ import trio +# async114 does not care about the imported library, so will raise errors regardless +# of trio/anyio/asyncio + # ARG --startable-in-context-manager=foo diff --git a/tests/eval_files/async115.py b/tests/eval_files/async115.py index 63cabb02..0d747c80 100644 --- a/tests/eval_files/async115.py +++ b/tests/eval_files/async115.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import time import trio diff --git a/tests/eval_files/async116.py b/tests/eval_files/async116.py index ab2f849e..48fac988 100644 --- a/tests/eval_files/async116.py +++ b/tests/eval_files/async116.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import math from math import inf diff --git a/tests/eval_files/async118.py b/tests/eval_files/async118.py index eebc00a5..25ed3858 100644 --- a/tests/eval_files/async118.py +++ b/tests/eval_files/async118.py @@ -1,3 +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/eval_files/async200.py b/tests/eval_files/async200.py index f9e1a947..5e2ddaeb 100644 --- a/tests/eval_files/async200.py +++ b/tests/eval_files/async200.py @@ -46,7 +46,7 @@ async def bar3(): bar() # ASYNC200: 4, "bar", "BAR" # don't error on directly awaited expressions - # https://github.com/Zac-HD/flake8-trio/issues/85 + # https://github.com/python-trio/flake8-async/issues/85 await bar() print(await bar()) diff --git a/tests/eval_files/async22x.py b/tests/eval_files/async22x.py index 40f39b09..b68d2fe8 100644 --- a/tests/eval_files/async22x.py +++ b/tests/eval_files/async22x.py @@ -1,5 +1,6 @@ # type: ignore # ARG --enable=ASYNC220,ASYNC221,ASYNC222 +# NOASYNCIO async def foo(): diff --git a/tests/eval_files/async232.py b/tests/eval_files/async232.py index 0fcc0d35..8c211ba6 100644 --- a/tests/eval_files/async232.py +++ b/tests/eval_files/async232.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import io from io import BufferedRandom, BufferedReader, BufferedWriter, TextIOWrapper from typing import Any, Optional diff --git a/tests/eval_files/async23x.py b/tests/eval_files/async23x.py index e1ea7252..299dd3a1 100644 --- a/tests/eval_files/async23x.py +++ b/tests/eval_files/async23x.py @@ -1,5 +1,6 @@ # type: ignore # ARG --enable=ASYNC230,ASYNC231 +# NOASYNCIO import io import os diff --git a/tests/eval_files/async240.py b/tests/eval_files/async240.py index fbb781cd..3f86d507 100644 --- a/tests/eval_files/async240.py +++ b/tests/eval_files/async240.py @@ -1,4 +1,5 @@ # type: ignore +# NOASYNCIO import os.path from os.path import isfile, normpath, relpath diff --git a/tests/eval_files/async910.py b/tests/eval_files/async910.py index 38c003d8..baa02748 100644 --- a/tests/eval_files/async910.py +++ b/tests/eval_files/async910.py @@ -362,7 +362,7 @@ async def foo_try_7(): # safe pass -# https://github.com/Zac-HD/flake8-trio/issues/45 +# https://github.com/python-trio/flake8-async/issues/45 async def to_queue(iter_func, queue): async with iter_func() as it: async for x in it: @@ -472,7 +472,7 @@ async def foo_range_5(): # error: 0, "exit", Statement("function definition", l await foo() -# https://github.com/Zac-HD/flake8-trio/issues/47 +# https://github.com/python-trio/flake8-async/issues/47 async def f(): while True: if ...: diff --git a/tests/eval_files/no_library.py b/tests/eval_files/no_library.py index de648edc..7db92ba2 100644 --- a/tests/eval_files/no_library.py +++ b/tests/eval_files/no_library.py @@ -1,5 +1,6 @@ # type: ignore # ARG --enable=ASYNC220 # NOANYIO +# NOASYNCIO async def foo(): subprocess.Popen() # ASYNC220: 4, 'subprocess.Popen', "trio" diff --git a/tests/eval_files/noqa.py b/tests/eval_files/noqa.py index 4cde1a73..46585211 100644 --- a/tests/eval_files/noqa.py +++ b/tests/eval_files/noqa.py @@ -1,5 +1,6 @@ # AUTOFIX # NOANYIO # TODO +# NOASYNCIO # ARG --enable=ASYNC100,ASYNC911 from typing import Any diff --git a/tests/eval_files/trio_anyio.py b/tests/eval_files/trio_anyio.py index 2714e29d..25439f26 100644 --- a/tests/eval_files/trio_anyio.py +++ b/tests/eval_files/trio_anyio.py @@ -1,6 +1,7 @@ # type: ignore # ARG --enable=ASYNC220 # NOANYIO +# NOASYNCIO # TODO import trio # isort: skip import anyio # isort: skip diff --git a/tests/test_config_and_args.py b/tests/test_config_and_args.py index ba3841b3..90764b88 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}:10:5: ASYNC220 {err_msg}\n" + err_file = Path(__file__).parent / "eval_files" / "anyio_trio.py" + + # find the line with the expected error + for lineno, line in enumerate( # noqa: B007 # lineno not used in loop body + err_file.read_text().split("\n"), start=1 + ): + if "# ASYNC220: " in line: + 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 2b53bcee..e86e548c 100644 --- a/tests/test_flake8_async.py +++ b/tests/test_flake8_async.py @@ -109,7 +109,8 @@ def check_autofix( plugin: Plugin, unfixed_code: str, generate_autofix: bool, - anyio: bool = False, + library: str = "trio", + base_library: str = "trio", ): # the source code after it's been visited by current transformers visited_code = plugin.module.code @@ -144,9 +145,13 @@ def check_autofix( # if running against anyio, since "eval_files/{test.py}" have replaced trio->anyio, # meaning it's replaced in visited_code, we also replace it in previous generated code # and in the previous diff - if anyio: - previous_autofixed = replace_library(previous_autofixed) - autofix_diff_content = replace_library(autofix_diff_content) + if base_library != library: + previous_autofixed = replace_library( + previous_autofixed, original=base_library, new=library + ) + autofix_diff_content = replace_library( + autofix_diff_content, original=base_library, new=library + ) # save any difference in the autofixed code diff = diff_strings(previous_autofixed, visited_code) @@ -160,7 +165,7 @@ def check_autofix( # if --generate-autofix is specified, which it may be during development, # just silently overwrite the content. - if generate_autofix and not anyio: + if generate_autofix and base_library == library: autofix_files[test].write_text(visited_code) autofix_diff_file.write_text(added_autofix_diff) return @@ -183,10 +188,16 @@ def check_autofix( # markers in the same pass as we parse out errors etc. @dataclass class MagicMarkers: + # Exclude checking a library against a file NOANYIO: bool = False NOTRIO: bool = False + NOASYNCIO: bool = False + # File should raise no errors with this library ANYIO_NO_ERROR: bool = False - ASYNC_NO_ERROR: bool = False + TRIO_NO_ERROR: bool = False + ASYNCIO_NO_ERROR: bool = False + # eval file is written using this library, so no substitution is required + BASE_LIBRARY: str = "trio" def find_magic_markers( @@ -196,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 @@ -204,31 +220,35 @@ def find_magic_markers( # when testing the same file @pytest.mark.parametrize(("test", "path"), test_files, ids=[f[0] for f in test_files]) @pytest.mark.parametrize("autofix", [False, True], ids=["noautofix", "autofix"]) -@pytest.mark.parametrize("anyio", [False, True], ids=["trio", "anyio"]) +@pytest.mark.parametrize("library", ["trio", "anyio", "asyncio"]) @pytest.mark.parametrize("noqa", [False, True], ids=["normal", "noqa"]) def test_eval( test: str, path: Path, autofix: bool, - anyio: bool, + library: str, noqa: bool, generate_autofix: bool, ): content = path.read_text() magic_markers = find_magic_markers(content) - if anyio and magic_markers.NOANYIO: - pytest.skip("file marked with NOANYIO") - # if autofixing, columns may get messed up ignore_column = autofix + 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 magic_markers.NOTRIO: - if not anyio: - pytest.skip("file marked with NOTRIO") - - # if test is marked NOTRIO, it's not written to require substitution - elif anyio: - content = replace_library(content) + if library != magic_markers.BASE_LIBRARY: + content = replace_library( + content, original=magic_markers.BASE_LIBRARY, new=library + ) # if substituting we're messing up columns ignore_column = True @@ -237,30 +257,49 @@ 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) - if anyio: - parsed_args.insert(0, "--anyio") + 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: parsed_args.append(f"--autofix={enable}") - if (anyio and magic_markers.ANYIO_NO_ERROR) or ( - not anyio and magic_markers.ASYNC_NO_ERROR + if (library == "anyio" and magic_markers.ANYIO_NO_ERROR) or ( + library == "trio" and magic_markers.TRIO_NO_ERROR ): expected = [] 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 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: - check_autofix(test, plugin, content, generate_autofix, anyio=anyio) + # asyncio does not support autofix atm, so should not modify content + if autofix and not noqa and library != "asyncio": + check_autofix( + test, + plugin, + content, + generate_autofix, + library=library, + base_library=magic_markers.BASE_LIBRARY, + ) else: # make sure content isn't modified assert content == plugin.module.code @@ -288,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] @@ -318,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 @@ -449,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) @@ -460,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: