From b418e22e4e4e971be1a7edfe31eb8cbeff6202da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20Corgi=C3=A9?= Date: Mon, 29 Dec 2025 12:46:07 +0100 Subject: [PATCH 1/5] Add 'redundant-exception-message' check (W0720) Add a new check that detects redundant exception messages when using 'raise ... from'. When exception chaining is used, including the original error message (via str(err), f-string interpolation, or concatenation) is redundant since Python preserves it via __cause__. Example of detected pattern: raise ConfigError(f"Failed: {err}") from err Recommended: raise ConfigError("Failed to save config") from err --- .../r/redundant-exception-message/bad.py | 4 + .../r/redundant-exception-message/good.py | 4 + .../r/redundant-exception-message/related.rst | 2 + doc/whatsnew/fragments/10792.new_check | 3 + pylint/checkers/exceptions.py | 90 ++++++++++++++++++ .../r/redundant_exception_message.py | 93 +++++++++++++++++++ .../r/redundant_exception_message.txt | 6 ++ 7 files changed, 202 insertions(+) create mode 100644 doc/data/messages/r/redundant-exception-message/bad.py create mode 100644 doc/data/messages/r/redundant-exception-message/good.py create mode 100644 doc/data/messages/r/redundant-exception-message/related.rst create mode 100644 doc/whatsnew/fragments/10792.new_check create mode 100644 tests/functional/r/redundant_exception_message.py create mode 100644 tests/functional/r/redundant_exception_message.txt diff --git a/doc/data/messages/r/redundant-exception-message/bad.py b/doc/data/messages/r/redundant-exception-message/bad.py new file mode 100644 index 0000000000..eea353c4a1 --- /dev/null +++ b/doc/data/messages/r/redundant-exception-message/bad.py @@ -0,0 +1,4 @@ +try: + save_config(data) +except OSError as err: + raise ConfigError(f"Failed to save config: {err}") from err # [redundant-exception-message] \ No newline at end of file diff --git a/doc/data/messages/r/redundant-exception-message/good.py b/doc/data/messages/r/redundant-exception-message/good.py new file mode 100644 index 0000000000..2e5fb1af9a --- /dev/null +++ b/doc/data/messages/r/redundant-exception-message/good.py @@ -0,0 +1,4 @@ +try: + save_config(data) +except OSError as err: + raise ConfigError("Failed to save configuration") from err \ No newline at end of file diff --git a/doc/data/messages/r/redundant-exception-message/related.rst b/doc/data/messages/r/redundant-exception-message/related.rst new file mode 100644 index 0000000000..006cd08bdb --- /dev/null +++ b/doc/data/messages/r/redundant-exception-message/related.rst @@ -0,0 +1,2 @@ +- `PEP 3134 - Exception Chaining `_ +- `raise-missing-from `_ \ No newline at end of file diff --git a/doc/whatsnew/fragments/10792.new_check b/doc/whatsnew/fragments/10792.new_check new file mode 100644 index 0000000000..2c05576d1e --- /dev/null +++ b/doc/whatsnew/fragments/10792.new_check @@ -0,0 +1,3 @@ +Add ``redundant-exception-message`` check (W0720) that detects when the original exception is included in the message of a re-raised exception using ``raise ... from``. This is redundant since Python's exception chaining preserves the original exception via ``__cause__``. + +Closes #10792 \ No newline at end of file diff --git a/pylint/checkers/exceptions.py b/pylint/checkers/exceptions.py index b8897a17f0..a50b446323 100644 --- a/pylint/checkers/exceptions.py +++ b/pylint/checkers/exceptions.py @@ -177,6 +177,15 @@ def _is_raising(body: list[nodes.NodeNG]) -> bool: "you expect to catch. This can hide bugs or make it harder to debug programs " "when unrelated errors are hidden.", ), + "W0720": ( + "Redundant exception message: '%s' included in message with 'raise ... from %s'", + "redundant-exception-message", + "When using 'raise ... from', the original exception is automatically " + "chained and its message is preserved via __cause__. Including the " + "original error message (via str(err), f-string, or concatenation) " + "results in duplicate information in logs. Use a descriptive message " + "without the original error, or extract specific context like file paths.", + ), } @@ -311,6 +320,7 @@ def open(self) -> None: "raising-format-tuple", "raise-missing-from", "broad-exception-raised", + "redundant-exception-message", ) def visit_raise(self, node: nodes.Raise) -> None: if node.exc is None: @@ -321,6 +331,7 @@ def visit_raise(self, node: nodes.Raise) -> None: self._check_raise_missing_from(node) else: self._check_bad_exception_cause(node) + self._check_redundant_exception_message(node) expr = node.exc ExceptionRaiseRefVisitor(self, node).visit(expr) @@ -414,6 +425,85 @@ def _check_raise_missing_from(self, node: nodes.Raise) -> None: confidence=HIGH, ) + def _check_redundant_exception_message(self, node: nodes.Raise) -> None: + """Check for redundant exception message when using 'raise ... from'. + + Detects patterns like: + raise SomeError(f"message: {err}") from err + raise SomeError(f"message: {str(err)}") from err + raise SomeError("message: " + str(err)) from err + """ + if node.cause is None or not isinstance(node.exc, nodes.Call): + return + + # Get the name of the chained exception variable + cause_name: str | None = None + if isinstance(node.cause, nodes.Name): + cause_name = node.cause.name + else: + return # Can't analyze complex cause expressions + + # Check the arguments of the raised exception + for arg in node.exc.args: + if self._contains_exception_in_message(arg, cause_name): + self.add_message( + "redundant-exception-message", + node=node, + args=(cause_name, cause_name), + confidence=HIGH, + ) + return + + def _contains_exception_in_message( + self, node: nodes.NodeNG, exc_name: str + ) -> bool: + """Check if the node contains a reference to the exception variable. + + Detects: + - f"...{err}..." or f"...{err!s}..." or f"...{err!r}..." + - f"...{str(err)}..." + - "..." + str(err) + - str(err) in concatenation + """ + if isinstance(node, nodes.JoinedStr): + # f-string: check for {err} or {str(err)} + for value in node.values: + if isinstance(value, nodes.FormattedValue): + inner = value.value + # Direct reference: f"{err}" + if isinstance(inner, nodes.Name) and inner.name == exc_name: + return True + # str(err) call: f"{str(err)}" + if ( + isinstance(inner, nodes.Call) + and isinstance(inner.func, nodes.Name) + and inner.func.name == "str" + and inner.args + and isinstance(inner.args[0], nodes.Name) + and inner.args[0].name == exc_name + ): + return True + elif isinstance(node, nodes.BinOp) and node.op == "+": + # String concatenation: "message: " + str(err) + return self._contains_exception_in_message( + node.left, exc_name + ) or self._contains_exception_in_message(node.right, exc_name) + elif isinstance(node, nodes.Call): + # str(err) directly as argument + if ( + isinstance(node.func, nodes.Name) + and node.func.name == "str" + and node.args + and isinstance(node.args[0], nodes.Name) + and node.args[0].name == exc_name + ): + return True + elif isinstance(node, nodes.Name) and node.name == exc_name: + # Direct reference as argument (rare but possible) + return True + + return False + def _check_catching_non_exception( self, handler: nodes.ExceptHandler, diff --git a/tests/functional/r/redundant_exception_message.py b/tests/functional/r/redundant_exception_message.py new file mode 100644 index 0000000000..dc1f0097ae --- /dev/null +++ b/tests/functional/r/redundant_exception_message.py @@ -0,0 +1,93 @@ +# pylint: disable=missing-docstring, broad-exception-caught, undefined-variable +# pylint: disable=raise-missing-from, line-too-long, invalid-name + + +class ConfigError(Exception): + """Custom exception for configuration errors.""" + + +# ===== BAD CASES (should trigger redundant-exception-message) ===== + +# f-string with direct exception reference +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError(f"Failed to save config: {err}") from err # [redundant-exception-message] + +# f-string with str(err) +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError(f"Failed to save config: {str(err)}") from err # [redundant-exception-message] + +# String concatenation with str(err) +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError("Failed to save config: " + str(err)) from err # [redundant-exception-message] + +# str(err) as sole argument +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError(str(err)) from err # [redundant-exception-message] + +# Direct exception reference as argument +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError(err) from err # [redundant-exception-message] + +# Nested concatenation +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError("Error: " + "details: " + str(err)) from err # [redundant-exception-message] + + +# ===== GOOD CASES (should NOT trigger redundant-exception-message) ===== + +# Simple message without exception reference +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError("Failed to save configuration") from err + +# Message with specific context (not the exception itself) +try: + 1 / 0 +except ZeroDivisionError as err: + path = "/path/to/config" + raise ConfigError(f"Config file not found: {path}") from err + +# f-string with different variable +try: + 1 / 0 +except ZeroDivisionError as err: + context = "user settings" + raise ConfigError(f"Failed to save {context}") from err + +# raise without from (separate rule: raise-missing-from) +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError(f"Failed: {err}") + +# raise from None (explicit suppression of context) +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError("Failed to save config") from None + +# No arguments to exception +try: + 1 / 0 +except ZeroDivisionError as err: + raise ConfigError from err + +# Different exception variable in from clause +try: + 1 / 0 +except ZeroDivisionError as err: + other_err = ValueError("other") + raise ConfigError(f"Error: {err}") from other_err diff --git a/tests/functional/r/redundant_exception_message.txt b/tests/functional/r/redundant_exception_message.txt new file mode 100644 index 0000000000..613c7606bf --- /dev/null +++ b/tests/functional/r/redundant_exception_message.txt @@ -0,0 +1,6 @@ +redundant-exception-message:15:4:15:63::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH +redundant-exception-message:21:4:21:68::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH +redundant-exception-message:27:4:27:68::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH +redundant-exception-message:33:4:33:40::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH +redundant-exception-message:39:4:39:35::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH +redundant-exception-message:45:4:45:66::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH \ No newline at end of file From b7c94c1c8ef1fc9b97f2f2f46df922fb863a5ddd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 11:57:57 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/data/messages/r/redundant-exception-message/bad.py | 4 +++- doc/data/messages/r/redundant-exception-message/good.py | 2 +- doc/data/messages/r/redundant-exception-message/related.rst | 2 +- doc/whatsnew/fragments/10792.new_check | 2 +- pylint/checkers/exceptions.py | 4 +--- tests/functional/r/redundant_exception_message.txt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/data/messages/r/redundant-exception-message/bad.py b/doc/data/messages/r/redundant-exception-message/bad.py index eea353c4a1..9bfc968eee 100644 --- a/doc/data/messages/r/redundant-exception-message/bad.py +++ b/doc/data/messages/r/redundant-exception-message/bad.py @@ -1,4 +1,6 @@ try: save_config(data) except OSError as err: - raise ConfigError(f"Failed to save config: {err}") from err # [redundant-exception-message] \ No newline at end of file + raise ConfigError( + f"Failed to save config: {err}" + ) from err # [redundant-exception-message] diff --git a/doc/data/messages/r/redundant-exception-message/good.py b/doc/data/messages/r/redundant-exception-message/good.py index 2e5fb1af9a..4dc0de18c1 100644 --- a/doc/data/messages/r/redundant-exception-message/good.py +++ b/doc/data/messages/r/redundant-exception-message/good.py @@ -1,4 +1,4 @@ try: save_config(data) except OSError as err: - raise ConfigError("Failed to save configuration") from err \ No newline at end of file + raise ConfigError("Failed to save configuration") from err diff --git a/doc/data/messages/r/redundant-exception-message/related.rst b/doc/data/messages/r/redundant-exception-message/related.rst index 006cd08bdb..5dfb0d03ca 100644 --- a/doc/data/messages/r/redundant-exception-message/related.rst +++ b/doc/data/messages/r/redundant-exception-message/related.rst @@ -1,2 +1,2 @@ - `PEP 3134 - Exception Chaining `_ -- `raise-missing-from `_ \ No newline at end of file +- `raise-missing-from `_ diff --git a/doc/whatsnew/fragments/10792.new_check b/doc/whatsnew/fragments/10792.new_check index 2c05576d1e..945774b01e 100644 --- a/doc/whatsnew/fragments/10792.new_check +++ b/doc/whatsnew/fragments/10792.new_check @@ -1,3 +1,3 @@ Add ``redundant-exception-message`` check (W0720) that detects when the original exception is included in the message of a re-raised exception using ``raise ... from``. This is redundant since Python's exception chaining preserves the original exception via ``__cause__``. -Closes #10792 \ No newline at end of file +Closes #10792 diff --git a/pylint/checkers/exceptions.py b/pylint/checkers/exceptions.py index a50b446323..2991501192 100644 --- a/pylint/checkers/exceptions.py +++ b/pylint/checkers/exceptions.py @@ -454,9 +454,7 @@ def _check_redundant_exception_message(self, node: nodes.Raise) -> None: ) return - def _contains_exception_in_message( - self, node: nodes.NodeNG, exc_name: str - ) -> bool: + def _contains_exception_in_message(self, node: nodes.NodeNG, exc_name: str) -> bool: """Check if the node contains a reference to the exception variable. Detects: diff --git a/tests/functional/r/redundant_exception_message.txt b/tests/functional/r/redundant_exception_message.txt index 613c7606bf..bbd7d5308f 100644 --- a/tests/functional/r/redundant_exception_message.txt +++ b/tests/functional/r/redundant_exception_message.txt @@ -3,4 +3,4 @@ redundant-exception-message:21:4:21:68::"Redundant exception message: 'err' incl redundant-exception-message:27:4:27:68::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH redundant-exception-message:33:4:33:40::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH redundant-exception-message:39:4:39:35::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH -redundant-exception-message:45:4:45:66::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH \ No newline at end of file +redundant-exception-message:45:4:45:66::"Redundant exception message: 'err' included in message with 'raise ... from err'":HIGH From f70863f3caf21167e416b47d1dc8d7da18d625e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20Corgi=C3=A9?= Date: Tue, 30 Dec 2025 09:55:00 +0100 Subject: [PATCH 3/5] to squash: fix tests --- doc/data/messages/r/redundant-exception-message/bad.py | 4 +--- tests/checkers/unittest_unicode/unittest_bad_chars.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/doc/data/messages/r/redundant-exception-message/bad.py b/doc/data/messages/r/redundant-exception-message/bad.py index 9bfc968eee..d8606ded9c 100644 --- a/doc/data/messages/r/redundant-exception-message/bad.py +++ b/doc/data/messages/r/redundant-exception-message/bad.py @@ -1,6 +1,4 @@ try: save_config(data) except OSError as err: - raise ConfigError( - f"Failed to save config: {err}" - ) from err # [redundant-exception-message] + raise ConfigError(f"Error: {err}") from err # [redundant-exception-message] diff --git a/tests/checkers/unittest_unicode/unittest_bad_chars.py b/tests/checkers/unittest_unicode/unittest_bad_chars.py index 293a4acd60..5e56bd7591 100644 --- a/tests/checkers/unittest_unicode/unittest_bad_chars.py +++ b/tests/checkers/unittest_unicode/unittest_bad_chars.py @@ -72,7 +72,7 @@ def _bad_char_file_generator( byte_line.decode(codec, "strict") except UnicodeDecodeError as e: raise ValueError( - f"Line {lineno} did raise unexpected error: {byte_line!r}\n{e}" + f"Line {lineno} did raise unexpected error: {byte_line!r}" ) from e else: try: From e5f056d04e3832e1be515a25d2c9ba44ea2d5b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20Corgi=C3=A9?= Date: Fri, 2 Jan 2026 13:24:11 +0100 Subject: [PATCH 4/5] to squash: add example of stacktrace duplication --- .../r/redundant-exception-message/details.rst | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 doc/data/messages/r/redundant-exception-message/details.rst diff --git a/doc/data/messages/r/redundant-exception-message/details.rst b/doc/data/messages/r/redundant-exception-message/details.rst new file mode 100644 index 0000000000..ad39511fde --- /dev/null +++ b/doc/data/messages/r/redundant-exception-message/details.rst @@ -0,0 +1,53 @@ +When using ``raise ... from original_exception``, Python automatically displays +the original exception in the traceback with the message "The above exception +was the direct cause of the following exception". Including the original +exception in the new message is therefore redundant. + +**With redundant message (bad):** + +.. code-block:: python + + try: + raise ValueError("Invalid format in config.yaml") + except ValueError as e: + raise RuntimeError(f"Failed to load config: {e}") from e + +.. code-block:: text + + Traceback (most recent call last): + File "example.py", line 2, in load_config + raise ValueError("Invalid format in config.yaml") + ValueError: Invalid format in config.yaml + + The above exception was the direct cause of the following exception: + + Traceback (most recent call last): + File "example.py", line 4, in load_config + raise RuntimeError(f"Failed to load config: {e}") from e + RuntimeError: Failed to load config: Invalid format in config.yaml + +**Without redundant message (good):** + +.. code-block:: python + + try: + raise ValueError("Invalid format in config.yaml") + except ValueError as e: + raise RuntimeError("Failed to load config") from e + +.. code-block:: text + + Traceback (most recent call last): + File "example.py", line 2, in load_config + raise ValueError("Invalid format in config.yaml") + ValueError: Invalid format in config.yaml + + The above exception was the direct cause of the following exception: + + Traceback (most recent call last): + File "example.py", line 4, in load_config + raise RuntimeError("Failed to load config") from e + RuntimeError: Failed to load config + +The exception chaining mechanism ensures all context is preserved without +message duplication. From 261c1a67dfe30a13fe827d37de3eef5647a6c485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corentin=20Corgi=C3=A9?= Date: Fri, 2 Jan 2026 13:38:01 +0100 Subject: [PATCH 5/5] to squash: make new check optional --- pylint/checkers/exceptions.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/pylint/checkers/exceptions.py b/pylint/checkers/exceptions.py index 2991501192..af5ff606ee 100644 --- a/pylint/checkers/exceptions.py +++ b/pylint/checkers/exceptions.py @@ -185,6 +185,7 @@ def _is_raising(body: list[nodes.NodeNG]) -> bool: "original error message (via str(err), f-string, or concatenation) " "results in duplicate information in logs. Use a descriptive message " "without the original error, or extract specific context like file paths.", + {"default_enabled": False}, ), } @@ -454,6 +455,17 @@ def _check_redundant_exception_message(self, node: nodes.Raise) -> None: ) return + def _is_str_call_of(self, node: nodes.NodeNG, name: str) -> bool: + """Check if node is a str(name) call.""" + return ( + isinstance(node, nodes.Call) + and isinstance(node.func, nodes.Name) + and node.func.name == "str" + and node.args + and isinstance(node.args[0], nodes.Name) + and node.args[0].name == name + ) + def _contains_exception_in_message(self, node: nodes.NodeNG, exc_name: str) -> bool: """Check if the node contains a reference to the exception variable. @@ -472,30 +484,16 @@ def _contains_exception_in_message(self, node: nodes.NodeNG, exc_name: str) -> b if isinstance(inner, nodes.Name) and inner.name == exc_name: return True # str(err) call: f"{str(err)}" - if ( - isinstance(inner, nodes.Call) - and isinstance(inner.func, nodes.Name) - and inner.func.name == "str" - and inner.args - and isinstance(inner.args[0], nodes.Name) - and inner.args[0].name == exc_name - ): + if self._is_str_call_of(inner, exc_name): return True elif isinstance(node, nodes.BinOp) and node.op == "+": # String concatenation: "message: " + str(err) return self._contains_exception_in_message( node.left, exc_name ) or self._contains_exception_in_message(node.right, exc_name) - elif isinstance(node, nodes.Call): + elif self._is_str_call_of(node, exc_name): # str(err) directly as argument - if ( - isinstance(node.func, nodes.Name) - and node.func.name == "str" - and node.args - and isinstance(node.args[0], nodes.Name) - and node.args[0].name == exc_name - ): - return True + return True elif isinstance(node, nodes.Name) and node.name == exc_name: # Direct reference as argument (rare but possible) return True