mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-19 12:16:43 +00:00
[pydoclint] Fix false positive on explicit exception re-raising (DOC501, DOC502) (#21011)
## Summary Fixes #20973 (`docstring-extraneous-exception`) false positive when exceptions mentioned in docstrings are caught and explicitly re-raised using `raise e` or `raise e from None`. ## Problem Analysis The DOC502 rule was incorrectly flagging exceptions mentioned in docstrings as "not explicitly raised" when they were actually being explicitly re-raised through exception variables bound in `except` clauses. **Root Cause**: The `BodyVisitor` in `check_docstring.rs` only checked for direct exception references (like `raise OSError()`) but didn't recognize when a variable bound to an exception in an `except` clause was being re-raised. **Example of the bug**: ```python def f(): """Do nothing. Raises ------ OSError If the OS errors. """ try: pass except OSError as e: raise e # This was incorrectly flagged as not explicitly raising OSError ``` The issue occurred because `resolve_qualified_name(e)` couldn't resolve the variable `e` to a qualified exception name, since `e` is just a variable binding, not a direct reference to an exception class. ## Approach Modified the `BodyVisitor` in `crates/ruff_linter/src/rules/pydoclint/rules/check_docstring.rs` to: 1. **Track exception variable bindings**: Added `exception_variables` field to map exception variable names to their exception types within `except` clauses 2. **Enhanced raise statement detection**: Updated `visit_stmt` to check if a `raise` statement uses a variable name that's bound to an exception in the current `except` clause 3. **Proper scope management**: Clear exception variable mappings when leaving `except` handlers to prevent cross-contamination **Key changes**: - Added `exception_variables: FxHashMap<&'a str, QualifiedName<'a>>` to track variable-to-exception mappings - Enhanced `visit_except_handler` to store exception variable bindings when entering `except` clauses - Modified `visit_stmt` to check for variable-based re-raising: `raise e` → lookup `e` in `exception_variables` - Clear mappings when exiting `except` handlers to maintain proper scope --------- Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
This commit is contained in:
parent
304ac22e74
commit
1ade9a5943
4 changed files with 218 additions and 16 deletions
|
|
@ -81,3 +81,55 @@ def calculate_speed(distance: float, time: float) -> float:
|
||||||
except TypeError:
|
except TypeError:
|
||||||
print("Not a number? Shame on you!")
|
print("Not a number? Shame on you!")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# This should NOT trigger DOC502 because OSError is explicitly re-raised
|
||||||
|
def f():
|
||||||
|
"""Do nothing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If the OS errors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pass
|
||||||
|
except OSError as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
# This should NOT trigger DOC502 because OSError is explicitly re-raised with from None
|
||||||
|
def g():
|
||||||
|
"""Do nothing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If the OS errors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pass
|
||||||
|
except OSError as e:
|
||||||
|
raise e from None
|
||||||
|
|
||||||
|
|
||||||
|
# This should NOT trigger DOC502 because ValueError is explicitly re-raised from tuple exception
|
||||||
|
def h():
|
||||||
|
"""Do nothing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If something goes wrong.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pass
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
# This should NOT trigger DOC502 because TypeError is explicitly re-raised from tuple exception
|
||||||
|
def i():
|
||||||
|
"""Do nothing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If something goes wrong.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pass
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise e
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use ruff_python_semantic::{Definition, SemanticModel};
|
||||||
use ruff_python_stdlib::identifiers::is_identifier;
|
use ruff_python_stdlib::identifiers::is_identifier;
|
||||||
use ruff_source_file::{LineRanges, NewlineWithTrailingNewline};
|
use ruff_source_file::{LineRanges, NewlineWithTrailingNewline};
|
||||||
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
|
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
|
|
||||||
use crate::Violation;
|
use crate::Violation;
|
||||||
use crate::checkers::ast::Checker;
|
use crate::checkers::ast::Checker;
|
||||||
|
|
@ -823,6 +824,8 @@ struct BodyVisitor<'a> {
|
||||||
currently_suspended_exceptions: Option<&'a ast::Expr>,
|
currently_suspended_exceptions: Option<&'a ast::Expr>,
|
||||||
raised_exceptions: Vec<ExceptionEntry<'a>>,
|
raised_exceptions: Vec<ExceptionEntry<'a>>,
|
||||||
semantic: &'a SemanticModel<'a>,
|
semantic: &'a SemanticModel<'a>,
|
||||||
|
/// Maps exception variable names to their exception expressions in the current except clause
|
||||||
|
exception_variables: FxHashMap<&'a str, &'a ast::Expr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> BodyVisitor<'a> {
|
impl<'a> BodyVisitor<'a> {
|
||||||
|
|
@ -833,6 +836,7 @@ impl<'a> BodyVisitor<'a> {
|
||||||
currently_suspended_exceptions: None,
|
currently_suspended_exceptions: None,
|
||||||
raised_exceptions: Vec::new(),
|
raised_exceptions: Vec::new(),
|
||||||
semantic,
|
semantic,
|
||||||
|
exception_variables: FxHashMap::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -864,20 +868,47 @@ impl<'a> BodyVisitor<'a> {
|
||||||
raised_exceptions,
|
raised_exceptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store `exception` if its qualified name does not correspond to one of the exempt types.
|
||||||
|
fn maybe_store_exception(&mut self, exception: &'a Expr, range: TextRange) {
|
||||||
|
let Some(qualified_name) = self.semantic.resolve_qualified_name(exception) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if is_exception_or_base_exception(&qualified_name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.raised_exceptions.push(ExceptionEntry {
|
||||||
|
qualified_name,
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Visitor<'a> for BodyVisitor<'a> {
|
impl<'a> Visitor<'a> for BodyVisitor<'a> {
|
||||||
fn visit_except_handler(&mut self, handler: &'a ast::ExceptHandler) {
|
fn visit_except_handler(&mut self, handler: &'a ast::ExceptHandler) {
|
||||||
let ast::ExceptHandler::ExceptHandler(handler_inner) = handler;
|
let ast::ExceptHandler::ExceptHandler(handler_inner) = handler;
|
||||||
self.currently_suspended_exceptions = handler_inner.type_.as_deref();
|
self.currently_suspended_exceptions = handler_inner.type_.as_deref();
|
||||||
|
|
||||||
|
// Track exception variable bindings
|
||||||
|
if let Some(name) = handler_inner.name.as_ref() {
|
||||||
|
if let Some(exceptions) = self.currently_suspended_exceptions {
|
||||||
|
// Store the exception expression(s) for later resolution
|
||||||
|
self.exception_variables
|
||||||
|
.insert(name.id.as_str(), exceptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
visitor::walk_except_handler(self, handler);
|
visitor::walk_except_handler(self, handler);
|
||||||
self.currently_suspended_exceptions = None;
|
self.currently_suspended_exceptions = None;
|
||||||
|
// Clear exception variables when leaving the except handler
|
||||||
|
self.exception_variables.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn visit_stmt(&mut self, stmt: &'a Stmt) {
|
fn visit_stmt(&mut self, stmt: &'a Stmt) {
|
||||||
match stmt {
|
match stmt {
|
||||||
Stmt::Raise(ast::StmtRaise { exc, .. }) => {
|
Stmt::Raise(ast::StmtRaise { exc, .. }) => {
|
||||||
if let Some(exc) = exc.as_ref() {
|
if let Some(exc) = exc.as_ref() {
|
||||||
|
// First try to resolve the exception directly
|
||||||
if let Some(qualified_name) =
|
if let Some(qualified_name) =
|
||||||
self.semantic.resolve_qualified_name(map_callable(exc))
|
self.semantic.resolve_qualified_name(map_callable(exc))
|
||||||
{
|
{
|
||||||
|
|
@ -885,28 +916,27 @@ impl<'a> Visitor<'a> for BodyVisitor<'a> {
|
||||||
qualified_name,
|
qualified_name,
|
||||||
range: exc.range(),
|
range: exc.range(),
|
||||||
});
|
});
|
||||||
|
} else if let ast::Expr::Name(name) = exc.as_ref() {
|
||||||
|
// If it's a variable name, check if it's bound to an exception in the
|
||||||
|
// current except clause
|
||||||
|
if let Some(exception_expr) = self.exception_variables.get(name.id.as_str())
|
||||||
|
{
|
||||||
|
if let ast::Expr::Tuple(tuple) = exception_expr {
|
||||||
|
for exception in tuple {
|
||||||
|
self.maybe_store_exception(exception, stmt.range());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.maybe_store_exception(exception_expr, stmt.range());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(exceptions) = self.currently_suspended_exceptions {
|
} else if let Some(exceptions) = self.currently_suspended_exceptions {
|
||||||
let mut maybe_store_exception = |exception| {
|
|
||||||
let Some(qualified_name) = self.semantic.resolve_qualified_name(exception)
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if is_exception_or_base_exception(&qualified_name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.raised_exceptions.push(ExceptionEntry {
|
|
||||||
qualified_name,
|
|
||||||
range: stmt.range(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if let ast::Expr::Tuple(tuple) = exceptions {
|
if let ast::Expr::Tuple(tuple) = exceptions {
|
||||||
for exception in tuple {
|
for exception in tuple {
|
||||||
maybe_store_exception(exception);
|
self.maybe_store_exception(exception, stmt.range());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
maybe_store_exception(exceptions);
|
self.maybe_store_exception(exceptions, stmt.range());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,66 @@ DOC501 Raised exception `FasterThanLightError` missing from docstring
|
||||||
|
|
|
|
||||||
help: Add `FasterThanLightError` to the docstring
|
help: Add `FasterThanLightError` to the docstring
|
||||||
|
|
||||||
|
DOC501 Raised exception `ZeroDivisionError` missing from docstring
|
||||||
|
--> DOC501_google.py:70:5
|
||||||
|
|
|
||||||
|
68 | # DOC501
|
||||||
|
69 | def calculate_speed(distance: float, time: float) -> float:
|
||||||
|
70 | / """Calculate speed as distance divided by time.
|
||||||
|
71 | |
|
||||||
|
72 | | Args:
|
||||||
|
73 | | distance: Distance traveled.
|
||||||
|
74 | | time: Time spent traveling.
|
||||||
|
75 | |
|
||||||
|
76 | | Returns:
|
||||||
|
77 | | Speed as distance divided by time.
|
||||||
|
78 | | """
|
||||||
|
| |_______^
|
||||||
|
79 | try:
|
||||||
|
80 | return distance / time
|
||||||
|
|
|
||||||
|
help: Add `ZeroDivisionError` to the docstring
|
||||||
|
|
||||||
|
DOC501 Raised exception `ValueError` missing from docstring
|
||||||
|
--> DOC501_google.py:88:5
|
||||||
|
|
|
||||||
|
86 | # DOC501
|
||||||
|
87 | def calculate_speed(distance: float, time: float) -> float:
|
||||||
|
88 | / """Calculate speed as distance divided by time.
|
||||||
|
89 | |
|
||||||
|
90 | | Args:
|
||||||
|
91 | | distance: Distance traveled.
|
||||||
|
92 | | time: Time spent traveling.
|
||||||
|
93 | |
|
||||||
|
94 | | Returns:
|
||||||
|
95 | | Speed as distance divided by time.
|
||||||
|
96 | | """
|
||||||
|
| |_______^
|
||||||
|
97 | try:
|
||||||
|
98 | return distance / time
|
||||||
|
|
|
||||||
|
help: Add `ValueError` to the docstring
|
||||||
|
|
||||||
|
DOC501 Raised exception `ZeroDivisionError` missing from docstring
|
||||||
|
--> DOC501_google.py:88:5
|
||||||
|
|
|
||||||
|
86 | # DOC501
|
||||||
|
87 | def calculate_speed(distance: float, time: float) -> float:
|
||||||
|
88 | / """Calculate speed as distance divided by time.
|
||||||
|
89 | |
|
||||||
|
90 | | Args:
|
||||||
|
91 | | distance: Distance traveled.
|
||||||
|
92 | | time: Time spent traveling.
|
||||||
|
93 | |
|
||||||
|
94 | | Returns:
|
||||||
|
95 | | Speed as distance divided by time.
|
||||||
|
96 | | """
|
||||||
|
| |_______^
|
||||||
|
97 | try:
|
||||||
|
98 | return distance / time
|
||||||
|
|
|
||||||
|
help: Add `ZeroDivisionError` to the docstring
|
||||||
|
|
||||||
DOC501 Raised exception `AnotherError` missing from docstring
|
DOC501 Raised exception `AnotherError` missing from docstring
|
||||||
--> DOC501_google.py:106:5
|
--> DOC501_google.py:106:5
|
||||||
|
|
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,66 @@ DOC501 Raised exception `FasterThanLightError` missing from docstring
|
||||||
|
|
|
|
||||||
help: Add `FasterThanLightError` to the docstring
|
help: Add `FasterThanLightError` to the docstring
|
||||||
|
|
||||||
|
DOC501 Raised exception `ZeroDivisionError` missing from docstring
|
||||||
|
--> DOC501_google.py:70:5
|
||||||
|
|
|
||||||
|
68 | # DOC501
|
||||||
|
69 | def calculate_speed(distance: float, time: float) -> float:
|
||||||
|
70 | / """Calculate speed as distance divided by time.
|
||||||
|
71 | |
|
||||||
|
72 | | Args:
|
||||||
|
73 | | distance: Distance traveled.
|
||||||
|
74 | | time: Time spent traveling.
|
||||||
|
75 | |
|
||||||
|
76 | | Returns:
|
||||||
|
77 | | Speed as distance divided by time.
|
||||||
|
78 | | """
|
||||||
|
| |_______^
|
||||||
|
79 | try:
|
||||||
|
80 | return distance / time
|
||||||
|
|
|
||||||
|
help: Add `ZeroDivisionError` to the docstring
|
||||||
|
|
||||||
|
DOC501 Raised exception `ValueError` missing from docstring
|
||||||
|
--> DOC501_google.py:88:5
|
||||||
|
|
|
||||||
|
86 | # DOC501
|
||||||
|
87 | def calculate_speed(distance: float, time: float) -> float:
|
||||||
|
88 | / """Calculate speed as distance divided by time.
|
||||||
|
89 | |
|
||||||
|
90 | | Args:
|
||||||
|
91 | | distance: Distance traveled.
|
||||||
|
92 | | time: Time spent traveling.
|
||||||
|
93 | |
|
||||||
|
94 | | Returns:
|
||||||
|
95 | | Speed as distance divided by time.
|
||||||
|
96 | | """
|
||||||
|
| |_______^
|
||||||
|
97 | try:
|
||||||
|
98 | return distance / time
|
||||||
|
|
|
||||||
|
help: Add `ValueError` to the docstring
|
||||||
|
|
||||||
|
DOC501 Raised exception `ZeroDivisionError` missing from docstring
|
||||||
|
--> DOC501_google.py:88:5
|
||||||
|
|
|
||||||
|
86 | # DOC501
|
||||||
|
87 | def calculate_speed(distance: float, time: float) -> float:
|
||||||
|
88 | / """Calculate speed as distance divided by time.
|
||||||
|
89 | |
|
||||||
|
90 | | Args:
|
||||||
|
91 | | distance: Distance traveled.
|
||||||
|
92 | | time: Time spent traveling.
|
||||||
|
93 | |
|
||||||
|
94 | | Returns:
|
||||||
|
95 | | Speed as distance divided by time.
|
||||||
|
96 | | """
|
||||||
|
| |_______^
|
||||||
|
97 | try:
|
||||||
|
98 | return distance / time
|
||||||
|
|
|
||||||
|
help: Add `ZeroDivisionError` to the docstring
|
||||||
|
|
||||||
DOC501 Raised exception `AnotherError` missing from docstring
|
DOC501 Raised exception `AnotherError` missing from docstring
|
||||||
--> DOC501_google.py:106:5
|
--> DOC501_google.py:106:5
|
||||||
|
|
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue