diff --git a/docs/changelog.rst b/docs/changelog.rst index 57a511f..4c12398 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,13 +4,17 @@ Changelog *[CalVer, YY.month.patch](https://calver.org/)* +24.5.5 +====== +- Add :ref:`ASYNC120 ` create-task-no-reference + 24.5.4 ====== -- Add ASYNC913: Indefinite loop with no guaranteed checkpoint. -- Fix bugs in ASYNC910 and ASYNC911 autofixing where they sometimes didn't add a library import. -- Fix crash in ASYNC911 when trying to autofix a one-line ``while ...: yield`` +- Add :ref:`ASYNC913 `: Indefinite loop with no guaranteed checkpoint. +- Fix bugs in :ref:`ASYNC910 ` and :ref:`ASYNC911 ` autofixing where they sometimes didn't add a library import. +- Fix crash in :ref:`ASYNC911 ` when trying to autofix a one-line ``while ...: yield`` - Add :ref:`exception-suppress-context-managers`. Contextmanagers that may suppress exceptions. -- ASYNC91x now treats checkpoints inside ``with contextlib.suppress`` as unreliable. +- :ref:`ASYNC91x ` now treats checkpoints inside ``with contextlib.suppress`` as unreliable. 24.5.3 ====== diff --git a/docs/rules.rst b/docs/rules.rst index 64162d7..d05490b 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -70,6 +70,10 @@ _`ASYNC119` : yield-in-cm-in-async-gen ``yield`` in context manager in async generator is unsafe, the cleanup may be delayed until ``await`` is no longer allowed. We strongly encourage you to read `PEP 533 `_ and use `async with aclosing(...) `_, or better yet avoid async generators entirely (see `ASYNC900`_ ) in favor of context managers which return an iterable :ref:`channel/stream/queue `. +_`ASYNC120` : create-task-no-reference + Calling :func:`asyncio.create_task` without saving the result. A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done. + Note that this rule won't check whether the variable the result is saved in is susceptible to being garbage-collected itself. See the asyncio documentation for best practices. + You might consider instead using a :ref:`TaskGroup ` and calling :meth:`asyncio.TaskGroup.create_task` to avoid this problem, and gain the advantages of structured concurrency with e.g. better cancellation semantics. Blocking sync calls in async functions diff --git a/flake8_async/__init__.py b/flake8_async/__init__.py index 749584c..587a4ef 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__ = "24.5.4" +__version__ = "24.5.5" # taken from https://github.com/Zac-HD/shed diff --git a/flake8_async/visitors/visitors.py b/flake8_async/visitors/visitors.py index 58d8d1f..ccc9780 100644 --- a/flake8_async/visitors/visitors.py +++ b/flake8_async/visitors/visitors.py @@ -5,8 +5,17 @@ import ast from typing import TYPE_CHECKING, Any, cast -from .flake8asyncvisitor import Flake8AsyncVisitor -from .helpers import disabled_by_default, error_class, get_matching_call, has_decorator +import libcst as cst + +from .flake8asyncvisitor import Flake8AsyncVisitor, Flake8AsyncVisitor_cst +from .helpers import ( + disabled_by_default, + error_class, + error_class_cst, + get_matching_call, + has_decorator, + identifier_to_string, +) if TYPE_CHECKING: from collections.abc import Mapping @@ -332,6 +341,53 @@ def visit_Yield(self, node: ast.Yield): visit_Lambda = visit_AsyncFunctionDef +@error_class_cst +class Visitor120(Flake8AsyncVisitor_cst): + error_codes: Mapping[str, str] = { + "ASYNC120": "asyncio.create_task() called without saving the result" + } + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.safe_to_create_task: bool = False + + def visit_Assign(self, node: cst.CSTNode): + self.save_state(node, "safe_to_create_task") + self.safe_to_create_task = True + + def visit_CompIf(self, node: cst.CSTNode): + self.save_state(node, "safe_to_create_task") + self.safe_to_create_task = False + + def visit_Call(self, node: cst.Call): + if ( + isinstance(node.func, (cst.Name, cst.Attribute)) + and identifier_to_string(node.func) == "asyncio.create_task" + and not self.safe_to_create_task + ): + self.error(node) + self.visit_Assign(node) + + visit_NamedExpr = visit_Assign + visit_AugAssign = visit_Assign + visit_IfExp_test = visit_CompIf + + # because this is a Flake8AsyncVisitor_cst, we need to manually call restore_state + def leave_Assign( + self, original_node: cst.CSTNode, updated_node: cst.CSTNode + ) -> Any: + self.restore_state(original_node) + return updated_node + + leave_Call = leave_Assign + leave_CompIf = leave_Assign + leave_NamedExpr = leave_Assign + leave_AugAssign = leave_Assign + + def leave_IfExp_test(self, node: cst.IfExp): + self.restore_state(node) + + @error_class @disabled_by_default class Visitor900(Flake8AsyncVisitor): diff --git a/tests/eval_files/async120.py b/tests/eval_files/async120.py new file mode 100644 index 0000000..824d151 --- /dev/null +++ b/tests/eval_files/async120.py @@ -0,0 +1,74 @@ +# BASE_LIBRARY asyncio +# TRIO_NO_ERROR +# ANYIO_NO_ERROR + +from typing import Any + +import asyncio + + +def handle_things(*args: object): ... + + +class TaskStorer: + def __init__(self): + self.tasks: set[Any] = set() + + def __ior__(self, obj: object): + self.tasks.add(obj) + + def __iadd__(self, obj: object): + self.tasks.add(obj) + + +async def foo(): + args: Any + asyncio.create_task(*args) # ASYNC120: 4 + + k = asyncio.create_task(*args) + + mylist = [] + mylist.append(asyncio.create_task(*args)) + + handle_things(asyncio.create_task(*args)) + + (l := asyncio.create_task(*args)) + + mylist = [asyncio.create_task(*args)] + + task_storer = TaskStorer() + task_storer |= asyncio.create_task(*args) + task_storer += asyncio.create_task(*args) + + mylist = [asyncio.create_task(*args) for i in range(10)] + + # non-call usage is fine + asyncio.create_task + asyncio.create_task = args + + # more or less esoteric ways of not saving the value + + [asyncio.create_task(*args)] # ASYNC120: 5 + + (asyncio.create_task(*args) for i in range(10)) # ASYNC120: 5 + + args = 1 if asyncio.create_task(*args) else 2 # ASYNC120: 16 + + args = (i for i in range(10) if asyncio.create_task(*args)) # ASYNC120: 36 + + # not supported, it can't be used as a context manager + with asyncio.create_task(*args) as k: # type: ignore[attr-defined] # ASYNC120: 9 + ... + + # import aliasing is not supported (this would raise ASYNC106 bad-async-library-import) + from asyncio import create_task + + create_task(*args) + + # nor is assigning it + boo = asyncio.create_task + boo(*args) + + # or any lambda thing + my_lambda = lambda: asyncio.create_task(*args) + my_lambda(*args) diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py index 3520c50..f7dcb68 100644 --- a/tests/test_flake8_async.py +++ b/tests/test_flake8_async.py @@ -476,6 +476,7 @@ def _parse_eval_file( "ASYNC116", "ASYNC117", "ASYNC118", + "ASYNC120", "ASYNC912", }