From abb732c81071dec3cd60b462babfc281e0074467 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Mon, 24 Jun 2024 11:27:42 +0200 Subject: [PATCH 1/5] add b040: exception with note added not reraised or used --- README.rst | 4 ++ bugbear.py | 59 +++++++++++++++++- tests/b040.py | 135 ++++++++++++++++++++++++++++++++++++++++++ tests/test_bugbear.py | 13 ++++ 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 tests/b040.py diff --git a/README.rst b/README.rst index ce0cfcb..89b35ce 100644 --- a/README.rst +++ b/README.rst @@ -351,6 +351,10 @@ MIT Change Log ---------- +Future +~~~~~~ +* Add B040: Exception with added note not reraised. (#474) + 24.4.26 ~~~~~~~ diff --git a/bugbear.py b/bugbear.py index c2a3032..c1a8a5b 100644 --- a/bugbear.py +++ b/bugbear.py @@ -428,11 +428,15 @@ def visit(self, node): self.check_for_b018(node) - def visit_ExceptHandler(self, node): + def visit_ExceptHandler(self, node): # noqa: C901 # too complex if node.type is None: self.errors.append(B001(node.lineno, node.col_offset)) self.generic_visit(node) return + + # used by B040 to check for add_note exceptions that are unraised + self.exceptions_tracked = {node.name: None} + handlers = _flatten_excepthandler(node.type) names = [] bad_handlers = [] @@ -471,6 +475,15 @@ def visit_ExceptHandler(self, node): self.generic_visit(node) + for _exc_name, exc_status in self.exceptions_tracked.items(): + if exc_status is None: + continue + # TODO: if we only track the caught exception, we can directly + # point to `node.name.lineno`. If we track more exceptions we should + # store the node of the variable in `exceptions_tracked` to point to here. + self.errors.append(B040(node.lineno, node.col_offset)) + self.exceptions_tracked.clear() + def visit_UAdd(self, node): trailing_nodes = list(map(type, self.node_window[-4:])) if trailing_nodes == [ast.UnaryOp, ast.UAdd, ast.UnaryOp, ast.UAdd]: @@ -481,6 +494,7 @@ def visit_UAdd(self, node): def visit_Call(self, node): if isinstance(node.func, ast.Attribute): self.check_for_b005(node) + self.check_for_b040_add_note(node.func) else: with suppress(AttributeError, IndexError): if ( @@ -507,15 +521,19 @@ def visit_Call(self, node): self.check_for_b026(node) self.check_for_b028(node) self.check_for_b034(node) + self.check_for_b040_usage(node.args) + self.check_for_b040_usage(node.keywords) self.check_for_b905(node) self.generic_visit(node) def visit_Module(self, node): self.generic_visit(node) - def visit_Assign(self, node): + def visit_Assign(self, node: ast.Assign) -> None: + self.check_for_b040_usage(node.value) if len(node.targets) == 1: t = node.targets[0] + if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name): if (t.value.id, t.attr) == ("os", "environ"): self.errors.append(B003(node.lineno, node.col_offset)) @@ -587,7 +605,12 @@ def visit_Compare(self, node): self.check_for_b015(node) self.generic_visit(node) - def visit_Raise(self, node): + def visit_Raise(self, node: ast.Raise): + if node.exc is None: + self.exceptions_tracked.clear() + else: + self.check_for_b040_usage(node.exc) + self.check_for_b040_usage(node.cause) self.check_for_b016(node) self.check_for_b904(node) self.generic_visit(node) @@ -604,6 +627,7 @@ def visit_JoinedStr(self, node): def visit_AnnAssign(self, node): self.check_for_b032(node) + self.check_for_b040_usage(node.value) self.generic_visit(node) def visit_Import(self, node): @@ -1054,6 +1078,31 @@ def check_for_b035(self, node: ast.DictComp): B035(node.key.lineno, node.key.col_offset, vars=(node.key.id,)) ) + def check_for_b040_add_note(self, node: ast.Attribute) -> None: + if ( + node.attr == "add_note" + and isinstance(node.value, ast.Name) + and node.value.id in self.exceptions_tracked + ): + self.exceptions_tracked[node.value.id] = True + + def check_for_b040_usage(self, node: ast.expr | None) -> None: + def superwalk(node: ast.AST | list[ast.AST]): + if isinstance(node, list): + for n in node: + yield from ast.walk(n) + else: + yield from ast.walk(node) + + if not self.exceptions_tracked or node is None: + return + for name in ( + n.id + for n in superwalk(node) + if isinstance(n, ast.Name) and n.id in self.exceptions_tracked + ): + del self.exceptions_tracked[name] + def _get_assigned_names(self, loop_node): loop_targets = (ast.For, ast.AsyncFor, ast.comprehension) for node in children_in_scope(loop_node): @@ -2166,6 +2215,10 @@ def visit_Lambda(self, node): message="B037 Class `__init__` methods must not return or yield and any values." ) +B040 = Error( + message="B040 Exception with added note not used. Did you forget to raise it?" +) + # Warnings disabled by default. B901 = Error( message=( diff --git a/tests/b040.py b/tests/b040.py new file mode 100644 index 0000000..8cd6203 --- /dev/null +++ b/tests/b040.py @@ -0,0 +1,135 @@ +def arbitrary_fun(*args, **kwargs): + ... + +try: + ... +except Exception as e: + e.add_note("...") # error + +try: + ... +except Exception as e: # safe (handled by most type checkers) + pass + +try: + ... +except Exception as e: + e.add_note("...") + raise # safe + +try: + ... +except Exception as e: + e.add_note("...") + raise e # safe + +try: + ... +except Exception as e: + f = ValueError() + e.add_note("...") # error + raise f + +try: + ... +except Exception as e: + f = ValueError() + e.add_note("...") # safe + raise f from e + +try: + ... +except Exception as e: + e.add_note("...") # safe + foo = e + +try: + ... +except Exception as e: + e.add_note("...") # safe + # not that printing the exception is actually using it, but we treat + # it being used as a parameter to any function as "using" it + print(e) + +try: + ... +except Exception as e: + e.add_note("...") # safe + list(e) + +try: + ... +except Exception as e: + e.add_note("...") # safe + arbitrary_fun(kwarg=e) + +try: + ... +except Exception as e: + e.add_note("...") # safe + arbitrary_fun(*(e,)) + +try: + ... +except Exception as e: + e.add_note("...") # safe + arbitrary_fun(**{"e":e}) + + +try: + ... +except ValueError as e: + e.add_note("") # error +except TypeError as e: + raise e + +mylist = [] +try: + ... +except Exception as e: + mylist.append(e) + e.add_note("") + + +def exc_add_note_not_in_except(): + exc = ValueError() + exc.add_note("") # should maybe error? + +try: + ... +except Exception as e: + e2 = ValueError() + e2.add_note("") # should maybe error? + raise e + +try: + ... +except Exception as e: + e.add_note("") + e = ValueError() + +try: + ... +except Exception as e: # should error, but we don't handle lambdas + e.add_note("") + f = lambda e: e + +try: + ... +except Exception as e: # safe + e.add_note("") + ann_assign_target: Exception = e + +try: + ... +except Exception as e: # should error + e.add_note(str(e)) + +try: + ... +except Exception as e: # should error + e.add_note("") + try: + ... + except ValueError as f: + pass diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py index e84e4e8..b20dbb7 100644 --- a/tests/test_bugbear.py +++ b/tests/test_bugbear.py @@ -46,6 +46,7 @@ B035, B036, B037, + B040, B901, B902, B903, @@ -636,6 +637,18 @@ def test_b037(self) -> None: ) self.assertEqual(errors, expected) + def test_b040(self) -> None: + filename = Path(__file__).absolute().parent / "b040.py" + bbc = BugBearChecker(filename=str(filename)) + errors = list(bbc.run()) + expected = self.errors( + B040(6, 0), + B040(28, 0), + B040(81, 0), + B040(107, 0), + ) + self.assertEqual(errors, expected) + def test_b908(self): filename = Path(__file__).absolute().parent / "b908.py" bbc = BugBearChecker(filename=str(filename)) From 48e3dffdff6d0a9a796c7687d6a655606f72b72b Mon Sep 17 00:00:00 2001 From: jakkdl Date: Mon, 24 Jun 2024 11:47:05 +0200 Subject: [PATCH 2/5] handle two more cases, with temp ugly code --- bugbear.py | 30 ++++++++++++++++++++++++------ tests/b040.py | 5 +++-- tests/test_bugbear.py | 2 ++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/bugbear.py b/bugbear.py index c1a8a5b..373208a 100644 --- a/bugbear.py +++ b/bugbear.py @@ -366,6 +366,7 @@ class BugBearVisitor(ast.NodeVisitor): errors = attr.ib(default=attr.Factory(list)) futures = attr.ib(default=attr.Factory(set)) contexts = attr.ib(default=attr.Factory(list)) + exceptions_tracked: dict[str, bool | None] = attr.ib(default=attr.Factory(dict)) NODE_WINDOW_SIZE = 4 _b023_seen = attr.ib(factory=set, init=False) @@ -435,6 +436,7 @@ def visit_ExceptHandler(self, node): # noqa: C901 # too complex return # used by B040 to check for add_note exceptions that are unraised + old_exceptions_tracked = self.exceptions_tracked self.exceptions_tracked = {node.name: None} handlers = _flatten_excepthandler(node.type) @@ -482,7 +484,7 @@ def visit_ExceptHandler(self, node): # noqa: C901 # too complex # point to `node.name.lineno`. If we track more exceptions we should # store the node of the variable in `exceptions_tracked` to point to here. self.errors.append(B040(node.lineno, node.col_offset)) - self.exceptions_tracked.clear() + self.exceptions_tracked = old_exceptions_tracked def visit_UAdd(self, node): trailing_nodes = list(map(type, self.node_window[-4:])) @@ -492,9 +494,10 @@ def visit_UAdd(self, node): self.generic_visit(node) def visit_Call(self, node): + is_b040_add_note = False if isinstance(node.func, ast.Attribute): self.check_for_b005(node) - self.check_for_b040_add_note(node.func) + is_b040_add_note = self.check_for_b040_add_note(node.func) else: with suppress(AttributeError, IndexError): if ( @@ -521,10 +524,19 @@ def visit_Call(self, node): self.check_for_b026(node) self.check_for_b028(node) self.check_for_b034(node) - self.check_for_b040_usage(node.args) - self.check_for_b040_usage(node.keywords) self.check_for_b905(node) - self.generic_visit(node) + + if not is_b040_add_note: + self.check_for_b040_usage(node.args) + self.check_for_b040_usage(node.keywords) + self.generic_visit(node) + else: + # TODO: temp dirty hack to avoid nested calls within the parameter list + # using the variable itself. (i.e. `e.add_note(str(e))`) + # will clean up, complexity depends on decision of scope of the check + current_exceptions_tracked = self.exceptions_tracked.copy() + self.generic_visit(node) + self.exceptions_tracked = current_exceptions_tracked def visit_Module(self, node): self.generic_visit(node) @@ -1078,13 +1090,15 @@ def check_for_b035(self, node: ast.DictComp): B035(node.key.lineno, node.key.col_offset, vars=(node.key.id,)) ) - def check_for_b040_add_note(self, node: ast.Attribute) -> None: + def check_for_b040_add_note(self, node: ast.Attribute) -> bool: if ( node.attr == "add_note" and isinstance(node.value, ast.Name) and node.value.id in self.exceptions_tracked ): self.exceptions_tracked[node.value.id] = True + return True + return False def check_for_b040_usage(self, node: ast.expr | None) -> None: def superwalk(node: ast.AST | list[ast.AST]): @@ -1101,6 +1115,10 @@ def superwalk(node: ast.AST | list[ast.AST]): for n in superwalk(node) if isinstance(n, ast.Name) and n.id in self.exceptions_tracked ): + if hasattr(node, "lineno"): + print("deleting", node.lineno) + else: + print("deleting0", node[0].lineno) del self.exceptions_tracked[name] def _get_assigned_names(self, loop_node): diff --git a/tests/b040.py b/tests/b040.py index 8cd6203..476bc1f 100644 --- a/tests/b040.py +++ b/tests/b040.py @@ -122,12 +122,13 @@ def exc_add_note_not_in_except(): try: ... -except Exception as e: # should error +except Exception as e: # error e.add_note(str(e)) +# check nesting try: ... -except Exception as e: # should error +except Exception as e: # error e.add_note("") try: ... diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py index b20dbb7..087191b 100644 --- a/tests/test_bugbear.py +++ b/tests/test_bugbear.py @@ -646,6 +646,8 @@ def test_b040(self) -> None: B040(28, 0), B040(81, 0), B040(107, 0), + B040(125, 0), + B040(131, 0), ) self.assertEqual(errors, expected) From d749c3dbdfd60507bfe392d80bb7af1fbe8627b7 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Mon, 24 Jun 2024 12:11:59 +0200 Subject: [PATCH 3/5] add test case, clean up stray debug prints --- bugbear.py | 4 ---- tests/b040.py | 7 +++++++ tests/test_bugbear.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bugbear.py b/bugbear.py index 373208a..10f02af 100644 --- a/bugbear.py +++ b/bugbear.py @@ -1115,10 +1115,6 @@ def superwalk(node: ast.AST | list[ast.AST]): for n in superwalk(node) if isinstance(n, ast.Name) and n.id in self.exceptions_tracked ): - if hasattr(node, "lineno"): - print("deleting", node.lineno) - else: - print("deleting0", node[0].lineno) del self.exceptions_tracked[name] def _get_assigned_names(self, loop_node): diff --git a/tests/b040.py b/tests/b040.py index 476bc1f..1f37f58 100644 --- a/tests/b040.py +++ b/tests/b040.py @@ -114,6 +114,13 @@ def exc_add_note_not_in_except(): e.add_note("") f = lambda e: e +try: + ... +except Exception as e: # should error, but we don't handle nested functions + e.add_note("") + def habla(e): + raise e + try: ... except Exception as e: # safe diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py index 087191b..5669222 100644 --- a/tests/test_bugbear.py +++ b/tests/test_bugbear.py @@ -646,8 +646,8 @@ def test_b040(self) -> None: B040(28, 0), B040(81, 0), B040(107, 0), - B040(125, 0), - B040(131, 0), + B040(132, 0), + B040(138, 0), ) self.assertEqual(errors, expected) From 3260555c3239ba8577774e0891875e65fd5b298d Mon Sep 17 00:00:00 2001 From: jakkdl Date: Wed, 26 Jun 2024 16:45:38 +0200 Subject: [PATCH 4/5] break out b013,b029,b303 handler, clean up test file --- bugbear.py | 70 +++++++++++---------- tests/b040.py | 137 +++++++++++++++++++++++++++--------------- tests/test_bugbear.py | 10 +-- 3 files changed, 132 insertions(+), 85 deletions(-) diff --git a/bugbear.py b/bugbear.py index 10f02af..ad53311 100644 --- a/bugbear.py +++ b/bugbear.py @@ -12,7 +12,7 @@ from contextlib import suppress from functools import lru_cache, partial from keyword import iskeyword -from typing import Dict, List, Set, Union +from typing import Dict, Iterable, Iterator, List, Set, Union import attr import pycodestyle @@ -227,7 +227,7 @@ def _is_identifier(arg): return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", arg.value) is not None -def _flatten_excepthandler(node): +def _flatten_excepthandler(node: ast.expr | None) -> Iterator[ast.expr | None]: if not isinstance(node, ast.Tuple): yield node return @@ -439,36 +439,8 @@ def visit_ExceptHandler(self, node): # noqa: C901 # too complex old_exceptions_tracked = self.exceptions_tracked self.exceptions_tracked = {node.name: None} - handlers = _flatten_excepthandler(node.type) - names = [] - bad_handlers = [] - ignored_handlers = [] - for handler in handlers: - if isinstance(handler, (ast.Name, ast.Attribute)): - name = _to_name_str(handler) - if name is None: - ignored_handlers.append(handler) - else: - names.append(name) - elif isinstance(handler, (ast.Call, ast.Starred)): - ignored_handlers.append(handler) - else: - bad_handlers.append(handler) - if bad_handlers: - self.errors.append(B030(node.lineno, node.col_offset)) - if len(names) == 0 and not bad_handlers and not ignored_handlers: - self.errors.append(B029(node.lineno, node.col_offset)) - elif ( - len(names) == 1 - and not bad_handlers - and not ignored_handlers - and isinstance(node.type, ast.Tuple) - ): - self.errors.append(B013(node.lineno, node.col_offset, vars=names)) - else: - maybe_error = _check_redundant_excepthandlers(names, node) - if maybe_error is not None: - self.errors.append(maybe_error) + names = self.check_for_b013_b029_b030(node) + if ( "BaseException" in names and not ExceptBaseExceptionVisitor(node).re_raised() @@ -728,6 +700,40 @@ def _loop(node, bad_node_types): for child in node.finalbody: _loop(child, (ast.Return, ast.Continue, ast.Break)) + def check_for_b013_b029_b030(self, node: ast.ExceptHandler) -> list[str]: + handlers: Iterable[ast.expr | None] = _flatten_excepthandler(node.type) + names: list[str] = [] + bad_handlers: list[object] = [] + ignored_handlers: list[ast.Name | ast.Attribute | ast.Call | ast.Starred] = [] + + for handler in handlers: + if isinstance(handler, (ast.Name, ast.Attribute)): + name = _to_name_str(handler) + if name is None: + ignored_handlers.append(handler) + else: + names.append(name) + elif isinstance(handler, (ast.Call, ast.Starred)): + ignored_handlers.append(handler) + else: + bad_handlers.append(handler) + if bad_handlers: + self.errors.append(B030(node.lineno, node.col_offset)) + if len(names) == 0 and not bad_handlers and not ignored_handlers: + self.errors.append(B029(node.lineno, node.col_offset)) + elif ( + len(names) == 1 + and not bad_handlers + and not ignored_handlers + and isinstance(node.type, ast.Tuple) + ): + self.errors.append(B013(node.lineno, node.col_offset, vars=names)) + else: + maybe_error = _check_redundant_excepthandlers(names, node) + if maybe_error is not None: + self.errors.append(maybe_error) + return names + def check_for_b015(self, node): if isinstance(self.node_stack[-2], ast.Expr): self.errors.append(B015(node.lineno, node.col_offset)) diff --git a/tests/b040.py b/tests/b040.py index 1f37f58..1f5d6ec 100644 --- a/tests/b040.py +++ b/tests/b040.py @@ -1,52 +1,51 @@ -def arbitrary_fun(*args, **kwargs): - ... +def arbitrary_fun(*args, **kwargs): ... + +# classic case try: ... except Exception as e: - e.add_note("...") # error + e.add_note("...") # error try: ... -except Exception as e: # safe (handled by most type checkers) +except Exception as e: # safe (other linters will catch this) pass try: ... except Exception as e: e.add_note("...") - raise # safe - -try: - ... -except Exception as e: - e.add_note("...") - raise e # safe + raise # safe +# other exception raised try: ... except Exception as e: f = ValueError() - e.add_note("...") # error + e.add_note("...") # error raise f +# raised as cause try: ... except Exception as e: f = ValueError() - e.add_note("...") # safe + e.add_note("...") # safe raise f from e +# assigned to other variable try: ... except Exception as e: - e.add_note("...") # safe + e.add_note("...") # safe foo = e +# "used" in function call try: ... except Exception as e: - e.add_note("...") # safe + e.add_note("...") # safe # not that printing the exception is actually using it, but we treat # it being used as a parameter to any function as "using" it print(e) @@ -54,90 +53,132 @@ def arbitrary_fun(*args, **kwargs): try: ... except Exception as e: - e.add_note("...") # safe + e.add_note("...") # safe list(e) +# kwarg try: ... except Exception as e: - e.add_note("...") # safe + e.add_note("...") # safe arbitrary_fun(kwarg=e) +# stararg try: ... except Exception as e: - e.add_note("...") # safe + e.add_note("...") # safe arbitrary_fun(*(e,)) try: ... except Exception as e: - e.add_note("...") # safe - arbitrary_fun(**{"e":e}) + e.add_note("...") # safe + arbitrary_fun(**{"e": e}) +# multiple ExceptHandlers try: ... except ValueError as e: - e.add_note("") # error + e.add_note("") # error except TypeError as e: raise e +# exception variable used before `add_note` mylist = [] try: ... -except Exception as e: +except Exception as e: # safe mylist.append(e) e.add_note("") -def exc_add_note_not_in_except(): - exc = ValueError() - exc.add_note("") # should maybe error? +# AnnAssign +try: + ... +except Exception as e: # safe + e.add_note("") + ann_assign_target: Exception = e +# special case: e is only used in the `add_note` call itself try: ... -except Exception as e: - e2 = ValueError() - e2.add_note("") # should maybe error? - raise e +except Exception as e: # error + e.add_note(str(e)) + e.add_note(str(e)) +# check nesting try: ... -except Exception as e: +except Exception as e: # error e.add_note("") - e = ValueError() + try: + ... + except ValueError as e: + raise +# questionable if this should error try: ... -except Exception as e: # should error, but we don't handle lambdas +except Exception as e: e.add_note("") - f = lambda e: e + e = ValueError() + +# *** unhandled cases *** + +# This should ideally error +def exc_add_note_not_in_except(): + exc = ValueError() + exc.add_note("") + + +# but not this +def exc_add_note_not_in_except_safe(exc: ValueError): + exc.add_note("") + + +# we currently only check the target of the except handler, even though this clearly +# is hitting the same general pattern. But handling this case without a lot of false +# alarms is very tricky without more infrastructure. try: ... -except Exception as e: # should error, but we don't handle nested functions - e.add_note("") - def habla(e): - raise e +except Exception as e: + e2 = ValueError() # should error + e2.add_note(str(e)) + + +def foo_add_note_in_excepthandler(): + e2 = ValueError() + try: + ... + except Exception as e: + e2.add_note(str(e)) # safe + raise e2 + +# We don't currently handle lambdas or function definitions inside the exception +# handler. This isn't conceptually difficult, but not really worth it with current +# infrastructure try: ... -except Exception as e: # safe +except Exception as e: # should error e.add_note("") - ann_assign_target: Exception = e + f = lambda e: e try: ... -except Exception as e: # error - e.add_note(str(e)) +except Exception as e: # should error + e.add_note("") -# check nesting + def habla(e): + raise e + + +# We also don't track other variables try: ... -except Exception as e: # error - e.add_note("") - try: - ... - except ValueError as f: - pass +except Exception as e: + e3 = e + e3.add_note("") # should error diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py index 5669222..63fff6a 100644 --- a/tests/test_bugbear.py +++ b/tests/test_bugbear.py @@ -642,12 +642,12 @@ def test_b040(self) -> None: bbc = BugBearChecker(filename=str(filename)) errors = list(bbc.run()) expected = self.errors( - B040(6, 0), - B040(28, 0), - B040(81, 0), + B040(7, 0), + B040(24, 0), + B040(83, 0), B040(107, 0), - B040(132, 0), - B040(138, 0), + B040(114, 0), + B040(124, 0), ) self.assertEqual(errors, expected) From 30c7c9178315b8202f45bd330b8b606a9ace6637 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Wed, 26 Jun 2024 17:16:40 +0200 Subject: [PATCH 5/5] simplify and clean up implementation. Replace `attr.ib(default=attr.Factory(...))` with `attr.ib(factory=...)` --- bugbear.py | 85 +++++++++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/bugbear.py b/bugbear.py index 7f55e88..61aeaca 100644 --- a/bugbear.py +++ b/bugbear.py @@ -55,7 +55,7 @@ class BugBearChecker: filename = attr.ib(default="(none)") lines = attr.ib(default=None) max_line_length = attr.ib(default=79) - visitor = attr.ib(init=False, default=attr.Factory(lambda: BugBearVisitor)) + visitor = attr.ib(init=False, factory=lambda: BugBearVisitor) options = attr.ib(default=None) def run(self): @@ -356,17 +356,23 @@ def visit_ExceptHandler(self, node: ast.ExceptHandler): return super().generic_visit(node) +@attr.define +class B040CaughtException: + name: str + has_note: bool + + @attr.s class BugBearVisitor(ast.NodeVisitor): filename = attr.ib() lines = attr.ib() - b008_b039_extend_immutable_calls = attr.ib(default=attr.Factory(set)) - b902_classmethod_decorators = attr.ib(default=attr.Factory(set)) - node_window = attr.ib(default=attr.Factory(list)) - errors = attr.ib(default=attr.Factory(list)) - futures = attr.ib(default=attr.Factory(set)) - contexts = attr.ib(default=attr.Factory(list)) - exceptions_tracked: dict[str, bool | None] = attr.ib(default=attr.Factory(dict)) + b008_b039_extend_immutable_calls = attr.ib(factory=set) + b902_classmethod_decorators = attr.ib(factory=set) + node_window = attr.ib(factory=list) + errors = attr.ib(factory=list) + futures = attr.ib(factory=set) + contexts = attr.ib(factory=list) + b040_caught_exception: B040CaughtException | None = attr.ib(default=None) NODE_WINDOW_SIZE = 4 _b023_seen = attr.ib(factory=set, init=False) @@ -429,15 +435,17 @@ def visit(self, node): self.check_for_b018(node) - def visit_ExceptHandler(self, node): # noqa: C901 # too complex + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: if node.type is None: self.errors.append(B001(node.lineno, node.col_offset)) self.generic_visit(node) return - # used by B040 to check for add_note exceptions that are unraised - old_exceptions_tracked = self.exceptions_tracked - self.exceptions_tracked = {node.name: None} + old_b040_caught_exception = self.b040_caught_exception + if node.name is None: + self.b040_caught_exception = None + else: + self.b040_caught_exception = B040CaughtException(node.name, False) names = self.check_for_b013_b029_b030(node) @@ -449,14 +457,12 @@ def visit_ExceptHandler(self, node): # noqa: C901 # too complex self.generic_visit(node) - for _exc_name, exc_status in self.exceptions_tracked.items(): - if exc_status is None: - continue - # TODO: if we only track the caught exception, we can directly - # point to `node.name.lineno`. If we track more exceptions we should - # store the node of the variable in `exceptions_tracked` to point to here. + if ( + self.b040_caught_exception is not None + and self.b040_caught_exception.has_note + ): self.errors.append(B040(node.lineno, node.col_offset)) - self.exceptions_tracked = old_exceptions_tracked + self.b040_caught_exception = old_b040_caught_exception def visit_UAdd(self, node): trailing_nodes = list(map(type, self.node_window[-4:])) @@ -499,17 +505,18 @@ def visit_Call(self, node): self.check_for_b039(node) self.check_for_b905(node) + # no need for copying, if used in nested calls it will be set to None + current_b040_caught_exception = self.b040_caught_exception if not is_b040_add_note: self.check_for_b040_usage(node.args) self.check_for_b040_usage(node.keywords) - self.generic_visit(node) - else: - # TODO: temp dirty hack to avoid nested calls within the parameter list - # using the variable itself. (i.e. `e.add_note(str(e))`) - # will clean up, complexity depends on decision of scope of the check - current_exceptions_tracked = self.exceptions_tracked.copy() - self.generic_visit(node) - self.exceptions_tracked = current_exceptions_tracked + + self.generic_visit(node) + + if is_b040_add_note: + # Avoid nested calls within the parameter list using the variable itself. + # e.g. `e.add_note(str(e))` + self.b040_caught_exception = current_b040_caught_exception def visit_Module(self, node): self.generic_visit(node) @@ -592,7 +599,7 @@ def visit_Compare(self, node): def visit_Raise(self, node: ast.Raise): if node.exc is None: - self.exceptions_tracked.clear() + self.b040_caught_exception = None else: self.check_for_b040_usage(node.exc) self.check_for_b040_usage(node.cause) @@ -1127,9 +1134,10 @@ def check_for_b040_add_note(self, node: ast.Attribute) -> bool: if ( node.attr == "add_note" and isinstance(node.value, ast.Name) - and node.value.id in self.exceptions_tracked + and self.b040_caught_exception + and node.value.id == self.b040_caught_exception.name ): - self.exceptions_tracked[node.value.id] = True + self.b040_caught_exception.has_note = True return True return False @@ -1141,14 +1149,13 @@ def superwalk(node: ast.AST | list[ast.AST]): else: yield from ast.walk(node) - if not self.exceptions_tracked or node is None: + if not self.b040_caught_exception or node is None: return - for name in ( - n.id - for n in superwalk(node) - if isinstance(n, ast.Name) and n.id in self.exceptions_tracked - ): - del self.exceptions_tracked[name] + + for n in superwalk(node): + if isinstance(n, ast.Name) and n.id == self.b040_caught_exception.name: + self.b040_caught_exception = None + break def _get_assigned_names(self, loop_node): loop_targets = (ast.For, ast.AsyncFor, ast.comprehension) @@ -1835,7 +1842,7 @@ class NameFinder(ast.NodeVisitor): key is name string, value is the node (useful for location purposes). """ - names: Dict[str, List[ast.Name]] = attr.ib(default=attr.Factory(dict)) + names: Dict[str, List[ast.Name]] = attr.ib(factory=dict) def visit_Name( # noqa: B906 # names don't contain other names self, node: ast.Name @@ -1860,7 +1867,7 @@ class NamedExprFinder(ast.NodeVisitor): key is name string, value is the node (useful for location purposes). """ - names: Dict[str, List[ast.Name]] = attr.ib(default=attr.Factory(dict)) + names: Dict[str, List[ast.Name]] = attr.ib(factory=dict) def visit_NamedExpr(self, node: ast.NamedExpr): self.names.setdefault(node.target.id, []).append(node.target)