From 0cb23ad8a88036ca497376847846aa68d9d96290 Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Tue, 6 Oct 2026 20:24:42 -0300 Subject: [PATCH 1/2] Add B047: except handler shadowed by an earlier one A handler like `except ValueError:` placed after `except Exception:` can never run, since the earlier handler already catches every ValueError. The same goes for a bare `except:` after `except BaseException:`. Like B014, only builtin exceptions are checked, since we can't know the hierarchy of other classes from the AST. Catching the exact same name twice is still left to B025. Closes #349 --- README.rst | 9 ++++ bugbear.py | 42 +++++++++++++++++ tests/eval_files/b047.py | 85 ++++++++++++++++++++++++++++++++++ tests/eval_files/b047_py311.py | 13 ++++++ 4 files changed, 149 insertions(+) create mode 100644 tests/eval_files/b047.py create mode 100644 tests/eval_files/b047_py311.py diff --git a/README.rst b/README.rst index 280732e..32eeb82 100644 --- a/README.rst +++ b/README.rst @@ -315,6 +315,13 @@ There is no additional safety in using ``delattr`` if you know the attribute nam **B044**: `assert ` is always true. Did you forget `all()`? +.. _B047: + +**B047**: Unreachable ``except`` handler. An earlier handler already catches a builtin +exception that this one is a subclass of, e.g. ``except ValueError:`` after +``except Exception:``, or a bare ``except:`` after ``except BaseException:``. Only builtin +exceptions are checked, since the hierarchy of other classes isn't known from the source. + Opinionated warnings ~~~~~~~~~~~~~~~~~~~~ @@ -517,6 +524,8 @@ Change Log UNRELEASED ~~~~~~~~~~ +* B047: New check for ``except`` handlers that can never run because an earlier + handler already catches a builtin parent exception (#349) * B031: only treat a bare ``groupby()`` call as ``itertools.groupby`` when the module doesn't bind ``groupby`` to something else, such as its own ``def groupby`` (#356) diff --git a/bugbear.py b/bugbear.py index b170f7e..1d369bf 100644 --- a/bugbear.py +++ b/bugbear.py @@ -911,6 +911,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: def visit_Try(self, node: ast.Try | ast.TryStar) -> None: self.check_for_b012(node) self.check_for_b025(node) + self.check_for_b047(node) self.generic_visit(node) def visit_TryStar(self, node: ast.TryStar) -> None: @@ -2209,6 +2210,40 @@ def check_for_b025(self, node: ast.Try | ast.TryStar) -> None: for duplicate in duplicates: self.add_error("B025", node, duplicate, self.in_trystar) + def check_for_b047(self, node: ast.Try | ast.TryStar) -> None: + # Only builtin exceptions are checked, since we can't know the class + # hierarchy of anything else from the AST. + caught: list[tuple[str, type]] = [] + for handler in node.handlers: + if handler.type is None: + # a bare `except:` catches the same as `except BaseException:` + names = ["BaseException"] + elif isinstance(handler.type, ast.Tuple): + names = [e.id for e in handler.type.elts if isinstance(e, ast.Name)] + elif isinstance(handler.type, ast.Name): + names = [handler.type.id] + else: + continue + + current = [] + for name in names: + exc = getattr(builtins, name, None) + if not ( + isinstance(exc, type) and _typesafe_issubclass(exc, BaseException) + ): + continue + for caught_name, caught_exc in caught: + # the same name caught twice is reported by B025 + if ( + name != caught_name or handler.type is None + ) and _typesafe_issubclass(exc, caught_exc): + self.add_error( + "B047", handler, name, caught_name, self.in_trystar + ) + break + current.append((name, exc)) + caught.extend(current) + @staticmethod def _is_infinite_iterator(node: ast.expr) -> bool: if not ( @@ -3399,6 +3434,13 @@ def __call__(self, lineno: int, col: int, vars: tuple[object, ...] = ()) -> erro "B044 `assert ` is always true. Did you forget `all()`?" ) ), + "B047": Error( + message=( + "B047 `{0}` is never caught by this handler, since the earlier " + "`except{2} {1}` already catches it. Reorder the handlers or remove " + "the unreachable one." + ) + ), # Warnings disabled by default. "B901": Error( message=( diff --git a/tests/eval_files/b047.py b/tests/eval_files/b047.py new file mode 100644 index 0000000..8f77d21 --- /dev/null +++ b/tests/eval_files/b047.py @@ -0,0 +1,85 @@ +import os + +try: + pass +except BaseException: + raise +except Exception: # B047: 0, "Exception", "BaseException", "" + pass + +try: + pass +except BaseException: + raise +except: # B001: 0 # B047: 0, "BaseException", "BaseException", "" + pass + +try: + pass +except Exception: + pass +except ValueError: # B047: 0, "ValueError", "Exception", "" + pass +except (KeyError, MyError): # B047: 0, "KeyError", "Exception", "" + pass +except KeyboardInterrupt: + pass + +try: + pass +except (TypeError, LookupError) as e: + pass +except IndexError as e: # B047: 0, "IndexError", "LookupError", "" + pass +except (KeyError, UnicodeDecodeError): # B047: 0, "KeyError", "LookupError", "" + pass + +# IOError is an alias of OSError +try: + pass +except OSError: + pass +except IOError: # B047: 0, "IOError", "OSError", "" + pass + +# good +try: + pass +except ValueError: + pass +except Exception: + pass +except BaseException: + raise + +try: + pass +except KeyError: + pass +except IndexError: + pass +except LookupError: + pass + +# not builtins, so the hierarchy is unknown +try: + pass +except MyError: + pass +except MySubError: + pass + +try: + pass +except os.error: + pass +except FileNotFoundError: + pass + +# the same exception caught twice is B025 +try: # B025: 0, "ValueError" + pass +except ValueError: + pass +except ValueError: + pass diff --git a/tests/eval_files/b047_py311.py b/tests/eval_files/b047_py311.py new file mode 100644 index 0000000..f86fc3f --- /dev/null +++ b/tests/eval_files/b047_py311.py @@ -0,0 +1,13 @@ +try: + pass +except* Exception: + pass +except* ValueError: # B047: 0, "ValueError", "Exception", "*" + pass + +try: + pass +except* (TypeError, OSError): + pass +except* (FileNotFoundError, ValueError): # B047: 0, "FileNotFoundError", "OSError", "*" + pass From 94e7bd313ca4cbff67295ebd1f9a2fd0c43a679a Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Fri, 9 Oct 2026 17:31:28 -0300 Subject: [PATCH 2/2] B047: expand starred tuples in except handlers --- bugbear.py | 10 +++++----- tests/eval_files/b047.py | 8 ++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/bugbear.py b/bugbear.py index ec822b8..640f7ac 100644 --- a/bugbear.py +++ b/bugbear.py @@ -2221,12 +2221,12 @@ def check_for_b047(self, node: ast.Try | ast.TryStar) -> None: if handler.type is None: # a bare `except:` catches the same as `except BaseException:` names = ["BaseException"] - elif isinstance(handler.type, ast.Tuple): - names = [e.id for e in handler.type.elts if isinstance(e, ast.Name)] - elif isinstance(handler.type, ast.Name): - names = [handler.type.id] else: - continue + names = [ + e.id + for e in _flatten_excepthandler(handler.type) + if isinstance(e, ast.Name) + ] current = [] for name in names: diff --git a/tests/eval_files/b047.py b/tests/eval_files/b047.py index 8f77d21..5666a7b 100644 --- a/tests/eval_files/b047.py +++ b/tests/eval_files/b047.py @@ -42,6 +42,14 @@ except IOError: # B047: 0, "IOError", "OSError", "" pass +# starred tuples are expanded +try: + pass +except Exception: + pass +except (*(ValueError,),): # B013: 0, "ValueError", "" # B047: 0, "ValueError", "Exception", "" + pass + # good try: pass