@@ -41,6 +41,12 @@ def __init__(self) -> None:
41
41
super ().__init__ ()
42
42
self .problems : List [Error ] = []
43
43
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
+
44
50
45
51
class TrioScope :
46
52
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]):
91
97
return False
92
98
93
99
100
+ # handles 100, 101 and 106
94
101
class VisitorMiscChecks (Flake8TrioVisitor ):
95
102
def __init__ (self ) -> None :
96
103
super ().__init__ ()
@@ -270,6 +277,146 @@ def check_for_trio102(self, node: Union[ast.Await, ast.AsyncFor, ast.AsyncWith])
270
277
self .problems .append (make_error (TRIO102 , node .lineno , node .col_offset ))
271
278
272
279
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
F438
span>.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
+
273
420
trio_async_functions = (
274
421
"aclose_forcefully" ,
275
422
"open_file" ,
@@ -329,14 +476,14 @@ def from_filename(cls, filename: str) -> "Plugin":
329
476
return cls (ast .parse (source ))
330
477
331
478
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 )
336
481
337
482
338
483
TRIO100 = "TRIO100: {} context contains no checkpoints, add `await trio.sleep(0)`"
339
484
TRIO101 = "TRIO101: yield inside a nursery or cancel scope is only safe when implementing a context manager - otherwise, it breaks exception handling"
340
485
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"
341
488
TRIO105 = "TRIO105: Trio async function {} must be immediately awaited"
342
489
TRIO106 = "TRIO106: trio must be imported with `import trio` for the linter to work"
0 commit comments