Skip to content

Commit 65effc6

Browse files
authored
Add pyupgrade UP041 to replace TimeoutError aliases (#8476)
## Summary Add UP041 to replace `TimeoutError` aliases: * Python 3.10+: `socket.timeout` * Python 3.11+: `asyncio.TimeoutError` Re: * https://github.com/asottile/pyupgrade#timeouterror-aliases * https://docs.python.org/3/library/asyncio-exceptions.html#asyncio.TimeoutError * https://docs.python.org/3/library/socket.html#socket.timeout Based on `os_error_alias.rs`. ## Test Plan <!-- How was it tested? --> By running: ``` cargo clippy --workspace --all-targets --all-features -- -D warnings # Rust linting RUFF_UPDATE_SCHEMA=1 cargo test # Rust testing and updating ruff.schema.json pre-commit run --all-files --show-diff-on-failure # Rust and Python formatting, Markdown and Python linting, etc. cargo insta review ``` And also running with different `--target-version` values: ```sh cargo run -p ruff_cli -- check crates/ruff_linter/resources/test/fixtures/pyupgrade/UP041.py --no-cache --select UP041 --target-version py37 --diff cargo run -p ruff_cli -- check crates/ruff_linter/resources/test/fixtures/pyupgrade/UP041.py --no-cache --select UP041 --target-version py310 --diff cargo run -p ruff_cli -- check crates/ruff_linter/resources/test/fixtures/pyupgrade/UP041.py --no-cache --select UP041 --target-version py311 --diff ```
1 parent 4982694 commit 65effc6

File tree

9 files changed

+386
-0
lines changed

9 files changed

+386
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import asyncio, socket
2+
# These should be fixed
3+
try:
4+
pass
5+
except asyncio.TimeoutError:
6+
pass
7+
8+
try:
9+
pass
10+
except socket.timeout:
11+
pass
12+
13+
# Should NOT be in parentheses when replaced
14+
15+
try:
16+
pass
17+
except (asyncio.TimeoutError,):
18+
pass
19+
20+
try:
21+
pass
22+
except (socket.timeout,):
23+
pass
24+
25+
try:
26+
pass
27+
except (asyncio.TimeoutError, socket.timeout,):
28+
pass
29+
30+
# Should be kept in parentheses (because multiple)
31+
32+
try:
33+
pass
34+
except (asyncio.TimeoutError, socket.timeout, KeyError, TimeoutError):
35+
pass
36+
37+
# First should change, second should not
38+
39+
from .mmap import error
40+
try:
41+
pass
42+
except (asyncio.TimeoutError, error):
43+
pass
44+
45+
# These should not change
46+
47+
from foo import error
48+
49+
try:
50+
pass
51+
except (TimeoutError, error):
52+
pass
53+
54+
try:
55+
pass
56+
except:
57+
pass
58+
59+
try:
60+
pass
61+
except TimeoutError:
62+
pass
63+
64+
65+
try:
66+
pass
67+
except (TimeoutError, KeyError):
68+
pass

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

+5
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,11 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
466466
if checker.enabled(Rule::OSErrorAlias) {
467467
pyupgrade::rules::os_error_alias_call(checker, func);
468468
}
469+
if checker.enabled(Rule::TimeoutErrorAlias) {
470+
if checker.settings.target_version >= PythonVersion::Py310 {
471+
pyupgrade::rules::timeout_error_alias_call(checker, func);
472+
}
473+
}
469474
if checker.enabled(Rule::NonPEP604Isinstance) {
470475
if checker.settings.target_version >= PythonVersion::Py310 {
471476
pyupgrade::rules::use_pep604_isinstance(checker, expr, func, args);

crates/ruff_linter/src/checkers/ast/analyze/statement.rs

+12
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,13 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
10061006
pyupgrade::rules::os_error_alias_raise(checker, item);
10071007
}
10081008
}
1009+
if checker.enabled(Rule::TimeoutErrorAlias) {
1010+
if checker.settings.target_version >= PythonVersion::Py310 {
1011+
if let Some(item) = exc {
1012+
pyupgrade::rules::timeout_error_alias_raise(checker, item);
1013+
}
1014+
}
1015+
}
10091016
if checker.enabled(Rule::RaiseVanillaClass) {
10101017
if let Some(expr) = exc {
10111018
tryceratops::rules::raise_vanilla_class(checker, expr);
@@ -1304,6 +1311,11 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
13041311
if checker.enabled(Rule::OSErrorAlias) {
13051312
pyupgrade::rules::os_error_alias_handlers(checker, handlers);
13061313
}
1314+
if checker.enabled(Rule::TimeoutErrorAlias) {
1315+
if checker.settings.target_version >= PythonVersion::Py310 {
1316+
pyupgrade::rules::timeout_error_alias_handlers(checker, handlers);
1317+
}
1318+
}
13071319
if checker.enabled(Rule::PytestAssertInExcept) {
13081320
flake8_pytest_style::rules::assert_in_exception_handler(checker, handlers);
13091321
}

crates/ruff_linter/src/codes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
500500
(Pyupgrade, "038") => (RuleGroup::Stable, rules::pyupgrade::rules::NonPEP604Isinstance),
501501
(Pyupgrade, "039") => (RuleGroup::Stable, rules::pyupgrade::rules::UnnecessaryClassParentheses),
502502
(Pyupgrade, "040") => (RuleGroup::Stable, rules::pyupgrade::rules::NonPEP695TypeAlias),
503+
(Pyupgrade, "041") => (RuleGroup::Preview, rules::pyupgrade::rules::TimeoutErrorAlias),
503504

504505
// pydocstyle
505506
(Pydocstyle, "100") => (RuleGroup::Stable, rules::pydocstyle::rules::UndocumentedPublicModule),

crates/ruff_linter/src/rules/pyupgrade/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ mod tests {
6060
#[test_case(Rule::ReplaceStdoutStderr, Path::new("UP022.py"))]
6161
#[test_case(Rule::ReplaceUniversalNewlines, Path::new("UP021.py"))]
6262
#[test_case(Rule::SuperCallWithParameters, Path::new("UP008.py"))]
63+
#[test_case(Rule::TimeoutErrorAlias, Path::new("UP041.py"))]
6364
#[test_case(Rule::TypeOfPrimitive, Path::new("UP003.py"))]
6465
#[test_case(Rule::TypingTextStrAlias, Path::new("UP019.py"))]
6566
#[test_case(Rule::UTF8EncodingDeclaration, Path::new("UP009_0.py"))]

crates/ruff_linter/src/rules/pyupgrade/rules/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub(crate) use redundant_open_modes::*;
2020
pub(crate) use replace_stdout_stderr::*;
2121
pub(crate) use replace_universal_newlines::*;
2222
pub(crate) use super_call_with_parameters::*;
23+
pub(crate) use timeout_error_alias::*;
2324
pub(crate) use type_of_primitive::*;
2425
pub(crate) use typing_text_str_alias::*;
2526
pub(crate) use unicode_kind_prefix::*;
@@ -59,6 +60,7 @@ mod redundant_open_modes;
5960
mod replace_stdout_stderr;
6061
mod replace_universal_newlines;
6162
mod super_call_with_parameters;
63+
mod timeout_error_alias;
6264
mod type_of_primitive;
6365
mod typing_text_str_alias;
6466
mod unicode_kind_prefix;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
use ruff_python_ast::{self as ast, ExceptHandler, Expr, ExprContext};
2+
use ruff_text_size::{Ranged, TextRange};
3+
4+
use crate::fix::edits::pad;
5+
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
6+
use ruff_macros::{derive_message_formats, violation};
7+
use ruff_python_ast::call_path::compose_call_path;
8+
use ruff_python_semantic::SemanticModel;
9+
10+
use crate::checkers::ast::Checker;
11+
use crate::settings::types::PythonVersion;
12+
13+
/// ## What it does
14+
/// Checks for uses of exceptions that alias `TimeoutError`.
15+
///
16+
/// ## Why is this bad?
17+
/// `TimeoutError` is the builtin error type used for exceptions when a system
18+
/// function timed out at the system level.
19+
///
20+
/// In Python 3.10, `socket.timeout` was aliased to `TimeoutError`. In Python
21+
/// 3.11, `asyncio.TimeoutError` was aliased to `TimeoutError`.
22+
///
23+
/// These aliases remain in place for compatibility with older versions of
24+
/// Python, but may be removed in future versions.
25+
///
26+
/// Prefer using `TimeoutError` directly, as it is more idiomatic and future-proof.
27+
///
28+
/// ## Example
29+
/// ```python
30+
/// raise asyncio.TimeoutError
31+
/// ```
32+
///
33+
/// Use instead:
34+
/// ```python
35+
/// raise TimeoutError
36+
/// ```
37+
///
38+
/// ## References
39+
/// - [Python documentation: `TimeoutError`](https://docs.python.org/3/library/exceptions.html#TimeoutError)
40+
#[violation]
41+
pub struct TimeoutErrorAlias {
42+
name: Option<String>,
43+
}
44+
45+
impl AlwaysFixableViolation for TimeoutErrorAlias {
46+
#[derive_message_formats]
47+
fn message(&self) -> String {
48+
format!("Replace aliased errors with `TimeoutError`")
49+
}
50+
51+
fn fix_title(&self) -> String {
52+
let TimeoutErrorAlias { name } = self;
53+
match name {
54+
None => "Replace with builtin `TimeoutError`".to_string(),
55+
Some(name) => format!("Replace `{name}` with builtin `TimeoutError`"),
56+
}
57+
}
58+
}
59+
60+
/// Return `true` if an [`Expr`] is an alias of `TimeoutError`.
61+
fn is_alias(expr: &Expr, semantic: &SemanticModel, target_version: PythonVersion) -> bool {
62+
semantic.resolve_call_path(expr).is_some_and(|call_path| {
63+
if target_version >= PythonVersion::Py311 {
64+
matches!(call_path.as_slice(), [""] | ["asyncio", "TimeoutError"])
65+
} else {
66+
matches!(
67+
call_path.as_slice(),
68+
[""] | ["asyncio", "TimeoutError"] | ["socket", "timeout"]
69+
)
70+
}
71+
})
72+
}
73+
74+
/// Return `true` if an [`Expr`] is `TimeoutError`.
75+
fn is_timeout_error(expr: &Expr, semantic: &SemanticModel) -> bool {
76+
semantic
77+
.resolve_call_path(expr)
78+
.is_some_and(|call_path| matches!(call_path.as_slice(), ["", "TimeoutError"]))
79+
}
80+
81+
/// Create a [`Diagnostic`] for a single target, like an [`Expr::Name`].
82+
fn atom_diagnostic(checker: &mut Checker, target: &Expr) {
83+
let mut diagnostic = Diagnostic::new(
84+
TimeoutErrorAlias {
85+
name: compose_call_path(target),
86+
},
87+
target.range(),
88+
);
89+
if checker.semantic().is_builtin("TimeoutError") {
90+
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
91+
"TimeoutError".to_string(),
92+
target.range(),
93+
)));
94+
}
95+
checker.diagnostics.push(diagnostic);
96+
}
97+
98+
/// Create a [`Diagnostic`] for a tuple of expressions.
99+
fn tuple_diagnostic(checker: &mut Checker, tuple: &ast::ExprTuple, aliases: &[&Expr]) {
100+
let mut diagnostic = Diagnostic::new(TimeoutErrorAlias { name: None }, tuple.range());
101+
if checker.semantic().is_builtin("TimeoutError") {
102+
// Filter out any `TimeoutErrors` aliases.
103+
let mut remaining: Vec<Expr> = tuple
104+
.elts
105+
.iter()
106+
.filter_map(|elt| {
107+
if aliases.contains(&elt) {
108+
None
109+
} else {
110+
Some(elt.clone())
111+
}
112+
})
113+
.collect();
114+
115+
// If `TimeoutError` itself isn't already in the tuple, add it.
116+
if tuple
117+
.elts
118+
.iter()
119+
.all(|elt| !is_timeout_error(elt, checker.semantic()))
120+
{
121+
let node = ast::ExprName {
122+
id: "TimeoutError".into(),
123+
ctx: ExprContext::Load,
124+
range: TextRange::default(),
125+
};
126+
remaining.insert(0, node.into());
127+
}
128+
129+
let content = if remaining.len() == 1 {
130+
"TimeoutError".to_string()
131+
} else {
132+
let node = ast::ExprTuple {
133+
elts: remaining,
134+
ctx: ExprContext::Load,
135+
range: TextRange::default(),
136+
};
137+
format!("({})", checker.generator().expr(&node.into()))
138+
};
139+
140+
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
141+
pad(content, tuple.range(), checker.locator()),
142+
tuple.range(),
143+
)));
144+
}
145+
checker.diagnostics.push(diagnostic);
146+
}
147+
148+
/// UP041
149+
pub(crate) fn timeout_error_alias_handlers(checker: &mut Checker, handlers: &[ExceptHandler]) {
150+
for handler in handlers {
151+
let ExceptHandler::ExceptHandler(ast::ExceptHandlerExceptHandler { type_, .. }) = handler;
152+
let Some(expr) = type_.as_ref() else {
153+
continue;
154+
};
155+
match expr.as_ref() {
156+
Expr::Name(_) | Expr::Attribute(_) => {
157+
if is_alias(expr, checker.semantic(), checker.settings.target_version) {
158+
atom_diagnostic(checker, expr);
159+
}
160+
}
161+
Expr::Tuple(tuple) => {
162+
// List of aliases to replace with `TimeoutError`.
163+
let mut aliases: Vec<&Expr> = vec![];
164+
for elt in &tuple.elts {
165+
if is_alias(elt, checker.semantic(), checker.settings.target_version) {
166+
aliases.push(elt);
167+
}
168+
}
169+
if !aliases.is_empty() {
170+
tuple_diagnostic(checker, tuple, &aliases);
171+
}
172+
}
173+
_ => {}
174+
}
175+
}
176+
}
177+
178+
/// UP041
179+
pub(crate) fn timeout_error_alias_call(checker: &mut Checker, func: &Expr) {
180+
if is_alias(func, checker.semantic(), checker.settings.target_version) {
181+
atom_diagnostic(checker, func);
182+
}
183+
}
184+
185+
/// UP041
186+
pub(crate) fn timeout_error_alias_raise(checker: &mut Checker, expr: &Expr) {
187+
if matches!(expr, Expr::Name(_) | Expr::Attribute(_)) {
188+
if is_alias(expr, checker.semantic(), checker.settings.target_version) {
189+
atom_diagnostic(checker, expr);
190+
}
191+
}
192+
}

0 commit comments

Comments
 (0)