diff --git a/docs/changelog.rst b/docs/changelog.rst index 26b10c5..1064da0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,10 @@ Changelog `CalVer, YY.month.patch `_ +25.7.1 +====== +- :ref:`ASYNC102 ` no longer triggered for asyncio due to different cancellation semantics it uses. + 25.5.3 ====== - :ref:`ASYNC115 ` and :ref:`ASYNC116 ` now also checks kwargs. diff --git a/docs/rules.rst b/docs/rules.rst index 4a640aa..afc7943 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -24,8 +24,8 @@ _`ASYNC101` : yield-in-cancel-scope _`ASYNC102` : await-in-finally-or-cancelled ``await`` inside ``finally``, :ref:`cancelled-catching ` ``except:``, or ``__aexit__`` must have shielded :ref:`cancel scope ` with timeout. If not, the async call will immediately raise a new cancellation, suppressing any cancellation that was caught. + Not applicable to asyncio due to edge-based cancellation semantics it uses as opposed to level-based used by trio and anyio. See :ref:`ASYNC120 ` for the general case where other exceptions might get suppressed. - This is currently not able to detect asyncio shields. ASYNC103 : no-reraise-cancelled :ref:`cancelled`-catching exception that does not reraise the exception. diff --git a/docs/usage.rst b/docs/usage.rst index b1bb6c2..4682799 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -33,7 +33,7 @@ adding the following to your ``.pre-commit-config.yaml``: minimum_pre_commit_version: '2.9.0' repos: - repo: https://github.com/python-trio/flake8-async - rev: 25.5.3 + rev: 25.7.1 hooks: - id: flake8-async # args: ["--enable=ASYNC100,ASYNC112", "--disable=", "--autofix=ASYNC"] diff --git a/flake8_async/__init__.py b/flake8_async/__init__.py index c0ba7b3..89eb93d 100644 --- a/flake8_async/__init__.py +++ b/flake8_async/__init__.py @@ -38,7 +38,7 @@ # CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1" -__version__ = "25.5.3" +__version__ = "25.7.1" # taken from https://github.com/Zac-HD/shed diff --git a/flake8_async/visitors/visitor102_120.py b/flake8_async/visitors/visitor102_120.py index 1187e27..1a1dc77 100644 --- a/flake8_async/visitors/visitor102_120.py +++ b/flake8_async/visitors/visitor102_120.py @@ -74,7 +74,8 @@ def async_call_checker( # non-critical exception handlers have the statement name set to "except" if self._critical_scope.name == "except": self._potential_120.append((node, self._critical_scope)) - else: + # not applicable to asyncio due to different cancellation semantics it uses + elif self.library != ("asyncio",): self.error(node, self._critical_scope, error_code="ASYNC102") def visit_Raise(self, node: ast.Raise): @@ -84,10 +85,7 @@ def visit_Raise(self, node: ast.Raise): def is_safe_aclose_call(self, node: ast.Await) -> bool: return ( - # don't mark calls safe in asyncio-only files - # a more defensive option would be `asyncio not in self.library` - self.library != ("asyncio",) - and isinstance(node.value, ast.Call) + isinstance(node.value, ast.Call) # only known safe if no arguments and not node.value.args and not node.value.keywords diff --git a/tests/eval_files/async102.py b/tests/eval_files/async102.py index 3d3f6d6..ecc749a 100644 --- a/tests/eval_files/async102.py +++ b/tests/eval_files/async102.py @@ -1,6 +1,6 @@ # type: ignore # ARG --enable=ASYNC102,ASYNC120 -# NOASYNCIO # TODO: support asyncio shields +# ASYNCIO_NO_ERROR # ASYNC102 not applicable to asyncio from contextlib import asynccontextmanager import trio @@ -310,3 +310,38 @@ async def foo_nested_cs(): # treat __aexit__ as a critical scope async def __aexit__(): await foo() # error: 4, Statement("__aexit__", lineno-1) + + +# exclude `finally: await x.aclose()` with no arguments +async def foo_aclose_noargs(): + # no type tracking in this check, we allow any call that looks like + # `await [...].aclose()` + x = None + + try: + ... + except BaseException: + await x.aclose() + await x.y.aclose() + finally: + await x.aclose() + await x.y.aclose() + + +# should still raise errors if there's args, as that indicates it's a non-standard aclose +async def foo(): + # no type tracking in this check + x = None + + try: + ... + except BaseException: + await x.aclose(foo) # ASYNC102: 8, Statement("BaseException", lineno-1) + await x.aclose(bar=foo) # ASYNC102: 8, Statement("BaseException", lineno-2) + await x.aclose(*foo) # ASYNC102: 8, Statement("BaseException", lineno-3) + await x.aclose(None) # ASYNC102: 8, Statement("BaseException", lineno-4) + finally: + await x.aclose(foo) # ASYNC102: 8, Statement("try/finally", lineno-8) + await x.aclose(bar=foo) # ASYNC102: 8, Statement("try/finally", lineno-9) + await x.aclose(*foo) # ASYNC102: 8, Statement("try/finally", lineno-10) + await x.aclose(None) # ASYNC102: 8, Statement("try/finally", lineno-11) diff --git a/tests/eval_files/async102_aclose.py b/tests/eval_files/async102_aclose.py deleted file mode 100644 index 4cba907..0000000 --- a/tests/eval_files/async102_aclose.py +++ /dev/null @@ -1,26 +0,0 @@ -# type: ignore -# ARG --enable=ASYNC102,ASYNC120 - -# exclude finally: await x.aclose() from async102 and async120, with trio/anyio - -# These magical markers are the ones that ensure trio & anyio don't raise errors: -# ANYIO_NO_ERROR -# TRIO_NO_ERROR - -# See also async102_aclose_args.py - which makes sure trio/anyio raises errors if there -# are arguments to aclose(). - - -async def foo(): - # no type tracking in this check, we allow any call that looks like - # `await [...].aclose()` - x = None - - try: - ... - except BaseException: - await x.aclose() # ASYNC102: 8, Statement("BaseException", lineno-1) - await x.y.aclose() # ASYNC102: 8, Statement("BaseException", lineno-2) - finally: - await x.aclose() # ASYNC102: 8, Statement("try/finally", lineno-6) - await x.y.aclose() # ASYNC102: 8, Statement("try/finally", lineno-7) diff --git a/tests/eval_files/async102_aclose_args.py b/tests/eval_files/async102_aclose_args.py deleted file mode 100644 index 6db0d9f..0000000 --- a/tests/eval_files/async102_aclose_args.py +++ /dev/null @@ -1,24 +0,0 @@ -# type: ignore - -# trio/anyio should still raise errors if there's args -# asyncio will always raise errors - -# See also async102_aclose.py, which checks that trio/anyio marks arg-less aclose() as safe - - -async def foo(): - # no type tracking in this check - x = None - - try: - ... - except BaseException: - await x.aclose(foo) # ASYNC102: 8, Statement("BaseException", lineno-1) - await x.aclose(bar=foo) # ASYNC102: 8, Statement("BaseException", lineno-2) - await x.aclose(*foo) # ASYNC102: 8, Statement("BaseException", lineno-3) - await x.aclose(None) # ASYNC102: 8, Statement("BaseException", lineno-4) - finally: - await x.aclose(foo) # ASYNC102: 8, Statement("try/finally", lineno-8) - await x.aclose(bar=foo) # ASYNC102: 8, Statement("try/finally", lineno-9) - await x.aclose(*foo) # ASYNC102: 8, Statement("try/finally", lineno-10) - await x.aclose(None) # ASYNC102: 8, Statement("try/finally", lineno-11) diff --git a/tests/eval_files/async102_anyio.py b/tests/eval_files/async102_anyio.py index f31eb11..3583774 100644 --- a/tests/eval_files/async102_anyio.py +++ b/tests/eval_files/async102_anyio.py @@ -1,9 +1,9 @@ # type: ignore +# this test will raise the same errors with trio, despite trio.get_cancelled_exc_class not existing +# marked not to run the tests though as error messages will only refer to anyio # NOTRIO -# NOASYNCIO +# ASYNCIO_NO_ERROR # ASYNC102 not applicable to asyncio # 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 diff --git a/tests/eval_files/async102_asyncio.py b/tests/eval_files/async102_asyncio.py deleted file mode 100644 index 5dabb98..0000000 --- a/tests/eval_files/async102_asyncio.py +++ /dev/null @@ -1,48 +0,0 @@ -# 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()) - ) - - -# asyncio.TaskGroup *is* a source of cancellations (on exit) -async def foo_open_nursery_no_cancel(): - try: - pass - finally: - async with asyncio.TaskGroup() as tg: # error: 8, Statement("try/finally", lineno-3) - ... diff --git a/tests/eval_files/async102_trio.py b/tests/eval_files/async102_trio.py index 7f1a5b9..d956c91 100644 --- a/tests/eval_files/async102_trio.py +++ b/tests/eval_files/async102_trio.py @@ -1,5 +1,5 @@ -# NOASYNCIO # NOANYIO - since anyio.Cancelled does not exist +# ASYNCIO_NO_ERROR # ASYNC102 not applicable to asyncio import trio diff --git a/tests/eval_files/noqa_no_autofix.py b/tests/eval_files/noqa_no_autofix.py index 36f98bf..17740c2 100644 --- a/tests/eval_files/noqa_no_autofix.py +++ b/tests/eval_files/noqa_no_autofix.py @@ -1,4 +1,4 @@ -# ARG --enable=ASYNC102 +# ARG --enable=ASYNC109 import trio from typing import Any @@ -8,22 +8,13 @@ async def foo() -> Any: ... -async def foo_no_noqa_102(): - try: - pass - finally: - await foo() # ASYNC102: 8, Statement("try/finally", lineno-3) +async def foo_no_noqa_109(timeout): # ASYNC109: 26, "trio" + ... -async def foo_noqa_102(): - try: - pass - finally: - await foo() # noqa: ASYNC102 +async def foo_noqa_102(timeout): # noqa: ASYNC109 + ... -async def foo_bare_noqa_102(): - try: - pass - finally: - await foo() # noqa +async def foo_bare_noqa_109(timeout): # noqa + ...