8000 Merge pull request #8 from jakkdl/trio103 · python-trio/flake8-async@cdd89ca · GitHub
[go: up one dir, main page]

Skip to content
Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit cdd89ca

Browse files
authored
Merge pull request #8 from jakkdl/trio103
2 parents 18a0d29 + a41f00b commit cdd89ca

7 files changed

+387
-8
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22
*[CalVer, YY.month.patch](https://calver.org/)*
33

4+
## Future
5+
- Add TRIO103: `except BaseException` or `except trio.Cancelled` wit 8000 h a code path that doesn't re-raise
6+
- Add TRIO104: "Cancelled and BaseException must be re-raised" if user tries to return or raise a different exception.
7+
48
## 22.7.4
59
- Added TRIO105 check for not immediately `await`ing async trio functions.
610
- Added TRIO106 check that trio is imported in a form that the plugin can easily parse.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@ pip install flake8-trio
2424
- **TRIO101**: `yield` inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling.
2525
- **TRIO102**: it's unsafe to await inside `finally:` unless you use a shielded
2626
cancel scope with a timeout"
27+
- **TRIO103**: `except BaseException` and `except trio.Cancelled` with a code path that doesn't re-raise. Note that any `raise` statements in loops are ignored since it's tricky to parse loop flow with `break`, `continue` and/or the zero-iteration case.
28+
- **TRIO104**: `Cancelled` and `BaseException` must be re-raised - when a user tries to `return` or `raise` a different exception.
2729
- **TRIO105**: Calling a trio async function without immediately `await`ing it.
2830
- **TRIO106**: trio must be imported with `import trio` for the linter to work

flake8_trio.py

< 8000 span aria-hidden="true" class="f6 text-bold fgColor-success">+151Lines changed: 151 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ def __init__(self) -> None:
4141
super().__init__()
4242
self.problems: List[Error] = []
4343

44+
@classmethod
45+
def run(cls, tree: ast.AST) -> Generator[Error, None, None]:
46+
visitor = cls()
47+
visitor.visit(tree)
48+
yield from visitor.problems
49+
4450

4551
class TrioScope:
4652
def __init__(self, node: ast.Call, funcname: str, packagename: str):
@@ -91,6 +97,7 @@ def has_decorator(decorator_list: List[ast.expr], names: Collection[str]):
9197
return False
9298

9399

100+
# handles 100, 101 and 106
94101
class VisitorMiscChecks(Flake8TrioVisitor):
95102
def __init__(self) -> None:
96103
super().__init__()
@@ -270,6 +277,146 @@ def check_for_trio102(self, node: Union[ast.Await, ast.AsyncFor, ast.AsyncWith])
270277
self.problems.append(make_error(TRIO102, node.lineno, node.col_offset))
271278

272279

280+
# Never have an except Cancelled or except BaseException block with a code path that
281+
# doesn't re-raise the error
282+
class Visitor103_104(Flake8TrioVisitor):
283+
def __init__(self) -> None:
284+
super().__init__()
285+
self.except_name: Optional[str] = ""
286+
self.unraised: bool = False
287+
self.loop_depth = 0
288+
289+
# If an `except` is bare, catches `BaseException`, or `trio.Cancelled`
290+
# set self.unraised, and if it's still set after visiting child nodes
291+
# then there might be a code path that doesn't re-raise.
292+
def visit_ExceptHandler(self, node: ast.ExceptHandler):
293+
def has_exception(node: Optional[ast.expr]):
294+
return (isinstance(node, ast.Name) and node.id == "BaseException") or (
295+
isinstance(node, ast.Attribute)
296+
and isinstance(node.value, ast.Name)
297+
and node.value.id == "trio"
298+
and node.attr == "Cancelled"
299+
)
300+
301+
outer = (self.unraised, self.except_name, self.loop_depth)
302+
marker = None
303+
304+
# we need to not unset self.unraised if this is non-critical to still
305+
# warn about `return`s
306+
307+
# bare except
308+
if node.type is None:
309+
self.unraised = True
310+
marker = (node.lineno, node.col_offset)
311+
# several exceptions
312+
elif isinstance(node.type, ast.Tuple):
313+
for element in node.type.elts:
314+
if has_exception(element):
315+
self.unraised = True
316+
marker = element.lineno, element.col_offset
317+
break
318+
# single exception, either a Name or an Attribute
319+
elif has_exception(node.type):
320+
self.unraised = True
321+
marker = node.type.lineno, node.type.col_offset
322+
323+
if marker is not None:
324+
# save name `as <except_name>`
325+
self.except_name = node.name
326+
self.loop_depth = 0
327+
328+
# visit child nodes. Will unset self.unraised if all code paths `raise`
329+
self.generic_visit(node)
330+
331+
if self.unraised and marker is not None:
332+
self.problems.append(make_error(TRIO103, *marker))
333+
334+
(self.unraised, self.except_name, self.loop_depth) = outer
335+
336+
def visit_Raise(self, node: ast.Raise):
337+
# if there's an unraised critical exception, the raise isn't bare,
338+
# and the name doesn't match, signal a problem.
339+
if (
340+
self.unraised
341+
and node.exc is not None
342+
and not (isinstance(node.exc, ast.Name) and node.exc.id == self.except_name)
343+
):
344+
self.problems.append(make_error(TRIO104, node.lineno, node.col_offset))
345+
346+
# treat it as safe regardless, to avoid unnecessary error messages.
347+
self.unraised = False
348+
349+
self.generic_visit(node)
350+
351+
def visit_Return(self, node: ast.Return):
352+
if self.unraised:
353+
# Error: must re-raise
354+
self.problems.append(make_error(TRIO104, node.lineno, node.col_offset))
355+
self.generic_visit(node)
356+
357+
# Treat Try's as fully covering only if `finally` always raises.
358+
def visit_Try(self, node: ast.Try):
359+
if not self.unraised:
360+
self.generic_visit(node)
361+
return
362+
363+
# in theory it's okay if the try and all excepts re-raise,
364+
# and there is a bare except
365+
# but is a pain to parse and would require a special case for bare raises in
366+
# nested excepts.
367+
for n in (*node.body, *node.handlers, *node.orelse):
368+
self.visit(n)
369+
# re-set unraised to warn about returns in each block
370+
self.unraised = True
371+
372+
# but it's fine if we raise in finally
373+
for n in node.finalbody:
374+
self.visit(n)
375+
376+
# Treat if's as fully covering if both `if` and `else` raise.
377+
# `elif` is parsed by the ast as a new if statement inside the else.
378+
def visit_If(self, node: ast.If):
379+
if not self.unraised:
380+
self.generic_visit(node)
381+
return
382+
383+
body_raised = False
384+
for n in node.body:
385+
self.visit(n)
386+
387+
# does body always raise correctly
388+
body_raised = not self.unraised
389+
390+
self.unraised = True
391+
for n in node.orelse:
392+
self.visit(n)
393+
394+
# if body didn't raise, or it's unraised after else, set unraise
395+
self.unraised = not body_raised or self.unraised
396+
397+
# It's hard to check for full coverage of `raise`s inside loops, so
398+
# we completely disregard them when checking coverage by resetting the
399+
# effects of them afterwards
400+
def visit_For(self, node: Union[ast.For, ast.While]):
401+
outer_unraised = self.unraised
402+
self.loop_depth += 1
403+
for n in node.body:
404+
self.visit(n)
405+
self.loop_depth -= 1
406+
for n in node.orelse:
407+
self.visit(n)
408+
self.unraised = outer_unraised
409+
410+
visit_While = visit_For
411+
412+
def visit_Break(self, node: Union[ast.Break, ast.Continue]):
413+
if self.unraised and self.loop_depth == 0:
414+
self.problems.append(make_error(TRIO104, node.lineno, node.col_offset))
415+
self.generic_visit(node)
416+
417+
visit_Continue = visit_Break
418+
419+
273420
trio_async_functions = (
274421
"aclose_forcefully",
275422
"open_file",
@@ -329,14 +476,14 @@ def from_filename(cls, filename: str) -> "Plugin":
329476
return cls(ast.parse(source))
330477

331478
def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
332-
for cls in Flake8TrioVisitor.__subclasses__():
333-
visitor = cls()
334-
visitor.visit(self._tree)
335-
yield from visitor.problems
479+
for v in Flake8TrioVisitor.__subclasses__():
480+
yield from v.run(self._tree)
336481

337482

338483
TRIO100 = "TRIO100: {} context contains no checkpoints, add `await trio.sleep(0)`"
339484
TRIO101 = "TRIO101: yield inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling"
340485
TRIO102 = "TRIO102: it's unsafe to await inside `finally:` unless you use a shielded cancel scope with a timeout"
486+
TRIO103 = "TRIO103: except Cancelled or except BaseException block with a code path that doesn't re-raise the error"
487+
TRIO104 = "TRIO104: Cancelled (and therefore BaseException) must be re-raised"
341488
TRIO105 = "TRIO105: Trio async function {} must be immediately awaited"
342489
TRIO106 = "TRIO106: trio must be imported with `import trio` for the linter to work"

tests/test_flake8_trio.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import unittest
77
from pathlib import Path
8+
from typing import Iterable
89

910
import pytest
1011
import trio # type: ignore
@@ -15,6 +16,8 @@
1516
TRIO100,
1617
TRIO101,
1718
TRIO102,
19+
TRIO103,
20+
TRIO104,
1821
TRIO105,
1922
TRIO106,
2023
Error,
@@ -26,9 +29,20 @@
2629

2730
class Flake8TrioTestCase(unittest.TestCase):
2831
def assert_expected_errors(self, test_file: str, *expected: Error) -> None:
32+
def trim_messages(messages: Iterable[Error]):
33+
return tuple(((line, col, msg[:7]) for line, col, msg, _ in messages))
34+
2935
filename = Path(__file__).absolute().parent / test_file
3036
plugin = Plugin.from_filename(str(filename))
37+
3138
errors = tuple(plugin.run())
39+
40+
# start with a check with trimmed errors that will make for smaller diff messages
41+
trim_errors = trim_messages(plugin.run())
42+
trim_expected = trim_messages(expected)
43+
self.assertTupleEqual(trim_errors, trim_expected)
44+
45+
# full check
3246
self.assertTupleEqual(errors, expected)
3347

3448
def test_tree(self):
@@ -54,7 +68,6 @@ def test_trio100_py39(self):
5468
)
5569

5670
def test_trio101(self):
57-
self.maxDiff = None
5871
self.assert_expected_errors(
5972
"trio101.py",
6073
make_error(TRIO101, 10, 8),
@@ -84,6 +97,44 @@ def test_trio102(self):
8497
make_error(TRIO102, 123, 12),
8598
)
8699

100+
def test_trio103_104(self):
101+
self.assert_expected_errors(
102+
"trio103_104.py",
103+
make_error(TRIO103, 7, 33),
104+
make_error(TRIO103, 15, 7),
105+
# raise different exception
106+
make_error(TRIO104, 20, 4),
107+
make_error(TRIO104, 22, 4),
108+
make_error(TRIO104, 25, 4),
109+
# if
110+
make_error(TRIO103, 28, 7),
111+
make_error(TRIO103, 35, 7),
112+
# loops
113+
make_error(TRIO103, 47, 7),
114+
make_error(TRIO103, 52, 7),
115+
# nested exceptions
116+
make_error(TRIO104, 67, 8), # weird edge-case
117+
make_error(TRIO103, 61, 7),
118+
make_error(TRIO104, 92, 8),
119+
# make_error(TRIO104, 94, 8), # weird edge-case
120+
# bare except
121+
make_error(TRIO103, 97, 0),
122+
# multi-line
123+
make_error(TRIO103, 111, 4),
124+
# re-raise parent
125+
make_error(TRIO104, 124, 8),
126+
# return
127+
make_error(TRIO104, 134, 8),
128+
make_error(TRIO103, 133, 11),
129+
make_error(TRIO104, 139, 12),
130+
make_error(TRIO104, 141, 12),
131+
make_error(TRIO104, 143, 12),
132+
make_error(TRIO104, 145, 12),
133+
make_error(TRIO103, 137, 11),
134+
make_error(TRIO104, 154, 12),
135+
make_error(TRIO104, 162, 12),
136+
)
137+
87138
def test_trio105(self):
88139
self.assert_expected_errors(
89140
"trio105.py",

tests/test_trio_tests.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
from types import FunctionType
77
from typing import Dict, Optional, Set, Tuple
88

9-
from test_flake8_trio import Flake8TrioTestCase
10-
119

1210
class TestTrioTests(unittest.TestCase):
1311
def runTest(self):
@@ -18,6 +16,9 @@ def runTest(self):
1816
if re.match(r"^trio.*.py", f)
1917
}
2018

19+
# must import outside top-level to avoid running test twice
20+
from test_flake8_trio import Flake8TrioTestCase
21+
2122
# get functions
2223
for o in inspect.getmembers(Flake8TrioTestCase):
2324
if inspect.isfunction(o[1]) and re.match(r"^test_trio\d\d\d", o[0]):

0 commit comments

Comments
 (0)
0