diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2a4b4c..ea84525e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog *[CalVer, YY.month.patch](https://calver.org/)* +## Future +- Added TRIO105 check for not immediately `await`ing async trio functions. + ## 22.7.3 - Added TRIO102 check for unsafe checkpoints inside `finally:` blocks diff --git a/README.md b/README.md index 615b3cba..33f8546c 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,4 @@ pip install flake8-trio - **TRIO101** `yield` inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling. - **TRIO102** it's unsafe to await inside `finally:` unless you use a shielded cancel scope with a timeout" +- **TRIO105** Calling a trio async function without immediately `await`ing it. diff --git a/flake8_trio.py b/flake8_trio.py index 9e0ee737..f6ad578b 100644 --- a/flake8_trio.py +++ b/flake8_trio.py @@ -255,6 +255,52 @@ def check_for_trio100(self, node: Union[ast.With, ast.AsyncWith]) -> None: ) +trio_async_functions = ( + "aclose_forcefully", + "open_file", + "open_ssl_over_tcp_listeners", + "open_ssl_over_tcp_stream", + "open_tcp_listeners", + "open_tcp_stream", + "open_unix_socket", + "run_process", + "serve_listeners", + "serve_ssl_over_tcp", + "serve_tcp", + "sleep", + "sleep_forever", + "sleep_until", +) + + +class Visitor105(ast.NodeVisitor): + def __init__(self) -> None: + super().__init__() + self.problems: List[Error] = [] + self.node_stack: List[ast.AST] = [] + + def visit(self, node: ast.AST): + self.node_stack.append(node) + super().visit(node) + self.node_stack.pop() + + def visit_Call(self, node: ast.Call): + if ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "trio" + and node.func.attr in trio_async_functions + and ( + len(self.node_stack) < 2 + or not isinstance(self.node_stack[-2], ast.Await) + ) + ): + self.problems.append( + make_error(TRIO105, node.lineno, node.col_offset, node.func.attr) + ) + self.generic_visit(node) + + class Plugin: name = __name__ version = __version__ @@ -269,7 +315,7 @@ def from_filename(cls, filename: str) -> "Plugin": return cls(ast.parse(source)) def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]: - for v in (Visitor, Visitor102): + for v in (Visitor, Visitor102, Visitor105): visitor = v() visitor.visit(self._tree) yield from visitor.problems @@ -278,3 +324,4 @@ def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]: TRIO100 = "TRIO100: {} context contains no checkpoints, add `await trio.sleep(0)`" TRIO101 = "TRIO101: yield inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling" TRIO102 = "TRIO102: it's unsafe to await inside `finally:` unless you use a shielded cancel scope with a timeout" +TRIO105 = "TRIO105: Trio async function {} must be immediately awaited" diff --git a/tests/test_flake8_trio.py b/tests/test_flake8_trio.py index dfe4acfc..7f24a2bf 100644 --- a/tests/test_flake8_trio.py +++ b/tests/test_flake8_trio.py @@ -1,4 +1,5 @@ import ast +import inspect import os import site import sys @@ -6,10 +7,21 @@ from pathlib import Path import pytest +import trio # type: ignore from hypothesis import HealthCheck, given, settings from hypothesmith import from_grammar, from_node -from flake8_trio import TRIO100, TRIO101, TRIO102, Error, Plugin, Visitor, make_error +from flake8_trio import ( + TRIO100, + TRIO101, + TRIO102, + TRIO105, + Error, + Plugin, + Visitor, + make_error, + trio_async_functions, +) class Flake8TrioTestCase(unittest.TestCase): @@ -71,6 +83,36 @@ def test_trio102(self): make_error(TRIO102, 123, 12), ) + def test_trio105(self): + self.assert_expected_errors( + "trio105.py", + make_error(TRIO105, 25, 4, "aclose_forcefully"), + make_error(TRIO105, 26, 4, "open_file"), + make_error(TRIO105, 27, 4, "open_ssl_over_tcp_listeners"), + make_error(TRIO105, 28, 4, "open_ssl_over_tcp_stream"), + make_error(TRIO105, 29, 4, "open_tcp_listeners"), + make_error(TRIO105, 30, 4, "open_tcp_stream"), + make_error(TRIO105, 31, 4, "open_unix_socket"), + make_error(TRIO105, 32, 4, "run_process"), + make_error(TRIO105, 33, 4, "serve_listeners"), + make_error(TRIO105, 34, 4, "serve_ssl_over_tcp"), + make_error(TRIO105, 35, 4, "serve_tcp"), + make_error(TRIO105, 36, 4, "sleep"), + make_error(TRIO105, 37, 4, "sleep_forever"), + make_error(TRIO105, 38, 4, "sleep_until"), + make_error(TRIO105, 45, 15, "open_file"), + make_error(TRIO105, 50, 8, "open_file"), + ) + + self.assertEqual( + set(trio_async_functions), + { + o[0] + for o in inspect.getmembers(trio) # type: ignore + if inspect.iscoroutinefunction(o[1]) + }, + ) + @pytest.mark.fuzz class TestFuzz(unittest.TestCase): diff --git a/tests/trio105.py b/tests/trio105.py new file mode 100644 index 00000000..d1213617 --- /dev/null +++ b/tests/trio105.py @@ -0,0 +1,51 @@ +import trio + + +async def foo(): + # not async + trio.run() + + # safely awaited + await trio.aclose_forcefully() + await trio.open_file() + await trio.open_ssl_over_tcp_listeners() + await trio.open_ssl_over_tcp_stream() + await trio.open_tcp_listeners() + await trio.open_tcp_stream() + await trio.open_unix_socket() + await trio.run_process() + await trio.serve_listeners() + await trio.serve_ssl_over_tcp() + await trio.serve_tcp() + await trio.sleep() + await trio.sleep_forever() + await trio.sleep_until() + + # errors + trio.aclose_forcefully() + trio.open_file() + trio.open_ssl_over_tcp_listeners() + trio.open_ssl_over_tcp_stream() + trio.open_tcp_listeners() + trio.open_tcp_stream() + trio.open_unix_socket() + trio.run_process() + trio.serve_listeners() + trio.serve_ssl_over_tcp() + trio.serve_tcp() + trio.sleep() + trio.sleep_forever() + trio.sleep_until() + + # safe + async with await trio.open_file() as f: + pass + + # error + async with trio.open_file() as f: + pass + + # safe in theory, but deemed sufficiently poor style that parsing + # it isn't supported + k = trio.open_file() + await k diff --git a/tox.ini b/tox.ini index 03e0f268..6d3be4f3 100644 --- a/tox.ini +++ b/tox.ini @@ -35,6 +35,7 @@ deps = #pytest-xdist hypothesis hypothesmith + trio commands = pytest #{posargs:-n auto}