Skip to content

Commit 291699b

Browse files
danparizherntBre
andauthored
[refurb] FURB164 fix should validate arguments and should usually be marked unsafe (#19136)
## Summary Fixes #19076 An attempt at fixing #19076 where the rule could change program behavior by incorrectly converting from_float/from_decimal method calls to constructor calls. The fix implements argument validation using Ruff's existing type inference system (`ResolvedPythonType`, `typing::is_int`, `typing::is_float`) to determine when conversions are actually safe, adds logic to detect invalid method calls (wrong argument counts, incorrect keyword names) and suppress fixes for them, and changes the default fix applicability from `Safe` to `Unsafe` with safe fixes only offered when the argument type is known to be compatible and no problematic keywords are used. One uncertainty is whether the type inference catches all possible edge cases in complex codebases, but the new approach is significantly more conservative and safer than the previous implementation. ## Test Plan I updated the existing test fixtures with edge cases from the issue and manually verified behavior with temporary test files for valid/unsafe/invalid scenarios. --------- Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
1 parent 64ac7d7 commit 291699b

3 files changed

Lines changed: 338 additions & 56 deletions

File tree

crates/ruff_linter/resources/test/fixtures/refurb/FURB164.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,24 @@
2727
_ = Decimal.from_float(float(" InfinIty \n\t "))
2828
_ = Decimal.from_float(float("  -InfinIty\n \t"))
2929

30-
# OK
30+
# Cases with keyword arguments - should produce unsafe fixes
31+
_ = Fraction.from_decimal(dec=Decimal("4.2"))
32+
_ = Decimal.from_float(f=4.2)
33+
34+
# Cases with invalid argument counts - should not get fixes
35+
_ = Fraction.from_decimal(Decimal("4.2"), 1)
36+
_ = Decimal.from_float(4.2, None)
37+
38+
# Cases with wrong keyword arguments - should not get fixes
39+
_ = Fraction.from_decimal(numerator=Decimal("4.2"))
40+
_ = Decimal.from_float(value=4.2)
41+
42+
# Cases with type validation issues - should produce unsafe fixes
43+
_ = Decimal.from_float("4.2") # Invalid type for from_float
44+
_ = Fraction.from_decimal(4.2) # Invalid type for from_decimal
45+
_ = Fraction.from_float("4.2") # Invalid type for from_float
46+
47+
# OK - should not trigger the rule
3148
_ = Fraction(0.1)
3249
_ = Fraction(-0.5)
3350
_ = Fraction(5.0)

crates/ruff_linter/src/rules/refurb/rules/unnecessary_from_float.rs

Lines changed: 182 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use ruff_macros::{ViolationMetadata, derive_message_formats};
22
use ruff_python_ast::{self as ast, Expr, ExprCall};
3+
use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
4+
use ruff_python_semantic::analyze::typing;
35
use ruff_text_size::Ranged;
46

57
use crate::checkers::ast::Checker;
68
use crate::linter::float::as_non_finite_float_string_literal;
7-
use crate::{Edit, Fix, FixAvailability, Violation};
9+
use crate::{Applicability, Edit, Fix, FixAvailability, Violation};
810

911
/// ## What it does
1012
/// Checks for unnecessary `from_float` and `from_decimal` usages to construct
@@ -16,6 +18,12 @@ use crate::{Edit, Fix, FixAvailability, Violation};
1618
/// the use of `from_float` and `from_decimal` methods is unnecessary, and
1719
/// should be avoided in favor of the more concise constructor syntax.
1820
///
21+
/// However, there are important behavioral differences between the `from_*` methods
22+
/// and the constructors:
23+
/// - The `from_*` methods validate their argument types and raise `TypeError` for invalid types
24+
/// - The constructors accept broader argument types without validation
25+
/// - The `from_*` methods have different parameter names than the constructors
26+
///
1927
/// ## Example
2028
/// ```python
2129
/// Decimal.from_float(4.2)
@@ -32,6 +40,16 @@ use crate::{Edit, Fix, FixAvailability, Violation};
3240
/// Fraction(Decimal(4.2))
3341
/// ```
3442
///
43+
/// ## Fix safety
44+
/// This rule's fix is marked as unsafe by default because:
45+
/// - The `from_*` methods provide type validation that the constructors don't
46+
/// - Removing type validation can change program behavior
47+
/// - The parameter names are different between methods and constructors
48+
///
49+
/// The fix is marked as safe only when:
50+
/// - The argument type is known to be valid for the target constructor
51+
/// - No keyword arguments are used, or they match the constructor's parameters
52+
///
3553
/// ## References
3654
/// - [Python documentation: `decimal`](https://docs.python.org/3/library/decimal.html)
3755
/// - [Python documentation: `fractions`](https://docs.python.org/3/library/fractions.html)
@@ -101,62 +119,178 @@ pub(crate) fn unnecessary_from_float(checker: &Checker, call: &ExprCall) {
101119
call.range(),
102120
);
103121

104-
let edit = Edit::range_replacement(
105-
checker.locator().slice(&**value).to_string(),
106-
call.func.range(),
107-
);
122+
// Validate that the method call has correct arguments and get the argument value
123+
let Some(arg_value) = has_valid_method_arguments(call, method_name, constructor) else {
124+
// Don't suggest a fix for invalid calls
125+
return;
126+
};
108127

109-
// Short-circuit case for special values, such as: `Decimal.from_float(float("inf"))` to `Decimal("inf")`.
110-
'short_circuit: {
111-
if !matches!(constructor, Constructor::Decimal) {
112-
break 'short_circuit;
113-
}
114-
if !(method_name == MethodName::FromFloat) {
115-
break 'short_circuit;
116-
}
128+
let constructor_name = checker.locator().slice(&**value).to_string();
129+
130+
// Special case for non-finite float literals: Decimal.from_float(float("inf")) -> Decimal("inf")
131+
if let Some(replacement) = handle_non_finite_float_special_case(
132+
call,
133+
method_name,
134+
constructor,
135+
arg_value,
136+
&constructor_name,
137+
checker,
138+
) {
139+
diagnostic.set_fix(Fix::safe_edit(replacement));
140+
return;
141+
}
142+
143+
// Check if we should suppress the fix due to type validation concerns
144+
let is_type_safe = is_valid_argument_type(arg_value, method_name, constructor, checker);
145+
let has_keywords = !call.arguments.keywords.is_empty();
146+
147+
// Determine fix safety
148+
let applicability = if is_type_safe && !has_keywords {
149+
Applicability::Safe
150+
} else {
151+
Applicability::Unsafe
152+
};
153+
154+
// Build the replacement
155+
let arg_text = checker.locator().slice(arg_value);
156+
let replacement_text = format!("{constructor_name}({arg_text})");
157+
158+
let edit = Edit::range_replacement(replacement_text, call.range());
159+
160+
diagnostic.set_fix(Fix::applicable_edit(edit, applicability));
161+
}
162+
163+
/// Check if the argument would be valid for the target constructor
164+
fn is_valid_argument_type(
165+
arg_expr: &Expr,
166+
method_name: MethodName,
167+
constructor: Constructor,
168+
checker: &Checker,
169+
) -> bool {
170+
let semantic = checker.semantic();
171+
let resolved_type = ResolvedPythonType::from(arg_expr);
172+
173+
let (is_int, is_float) = if let ResolvedPythonType::Unknown = resolved_type {
174+
arg_expr
175+
.as_name_expr()
176+
.and_then(|name| semantic.only_binding(name).map(|id| semantic.binding(id)))
177+
.map(|binding| {
178+
(
179+
typing::is_int(binding, semantic),
180+
typing::is_float(binding, semantic),
181+
)
182+
})
183+
.unwrap_or_default()
184+
} else {
185+
(false, false)
186+
};
187+
188+
match (method_name, constructor) {
189+
// Decimal.from_float accepts int, bool, float
190+
(MethodName::FromFloat, Constructor::Decimal) => match resolved_type {
191+
ResolvedPythonType::Atom(PythonType::Number(
192+
NumberLike::Integer | NumberLike::Bool | NumberLike::Float,
193+
)) => true,
194+
ResolvedPythonType::Unknown => is_int || is_float,
195+
_ => false,
196+
},
197+
// Fraction.from_float accepts int, bool, float
198+
(MethodName::FromFloat, Constructor::Fraction) => match resolved_type {
199+
ResolvedPythonType::Atom(PythonType::Number(
200+
NumberLike::Integer | NumberLike::Bool | NumberLike::Float,
201+
)) => true,
202+
ResolvedPythonType::Unknown => is_int || is_float,
203+
_ => false,
204+
},
205+
// Fraction.from_decimal accepts int, bool, Decimal
206+
(MethodName::FromDecimal, Constructor::Fraction) => match resolved_type {
207+
ResolvedPythonType::Atom(PythonType::Number(
208+
NumberLike::Integer | NumberLike::Bool,
209+
)) => true,
210+
ResolvedPythonType::Unknown => is_int,
211+
_ => {
212+
// Check if it's a Decimal instance
213+
arg_expr
214+
.as_call_expr()
215+
.and_then(|call| semantic.resolve_qualified_name(&call.func))
216+
.is_some_and(|qualified_name| {
217+
matches!(qualified_name.segments(), ["decimal", "Decimal"])
218+
})
219+
}
220+
},
221+
_ => false,
222+
}
223+
}
224+
225+
/// Check if the call has valid arguments for the from_* method
226+
fn has_valid_method_arguments(
227+
call: &ExprCall,
228+
method_name: MethodName,
229+
constructor: Constructor,
230+
) -> Option<&Expr> {
231+
if call.arguments.len() != 1 {
232+
return None;
233+
}
117234

118-
let Some(value) = (match method_name {
119-
MethodName::FromFloat => call.arguments.find_argument_value("f", 0),
120-
MethodName::FromDecimal => call.arguments.find_argument_value("dec", 0),
121-
}) else {
122-
return;
123-
};
124-
125-
let Expr::Call(
126-
call @ ast::ExprCall {
127-
func, arguments, ..
128-
},
129-
) = value
130-
else {
131-
break 'short_circuit;
132-
};
133-
134-
// Must have exactly one argument, which is a string literal.
135-
if !arguments.keywords.is_empty() {
136-
break 'short_circuit;
235+
match method_name {
236+
MethodName::FromFloat => {
237+
// Decimal.from_float is positional-only; Fraction.from_float allows keyword 'f'.
238+
if constructor == Constructor::Decimal {
239+
// Only allow positional argument for Decimal.from_float
240+
call.arguments.find_positional(0)
241+
} else {
242+
// Fraction.from_float allows either positional or 'f' keyword
243+
call.arguments.find_argument_value("f", 0)
244+
}
137245
}
138-
let [float] = arguments.args.as_ref() else {
139-
break 'short_circuit;
140-
};
141-
if as_non_finite_float_string_literal(float).is_none() {
142-
break 'short_circuit;
246+
MethodName::FromDecimal => {
247+
// from_decimal(dec) - should have exactly one positional argument or 'dec' keyword
248+
call.arguments.find_argument_value("dec", 0)
143249
}
250+
}
251+
}
144252

145-
// Must be a call to the `float` builtin.
146-
if !semantic.match_builtin_expr(func, "float") {
147-
break 'short_circuit;
148-
}
253+
/// Handle the special case for non-finite float literals
254+
fn handle_non_finite_float_special_case(
255+
call: &ExprCall,
256+
method_name: MethodName,
257+
constructor: Constructor,
258+
arg_value: &Expr,
259+
constructor_name: &str,
260+
checker: &Checker,
261+
) -> Option<Edit> {
262+
// Only applies to Decimal.from_float
263+
if !matches!(
264+
(method_name, constructor),
265+
(MethodName::FromFloat, Constructor::Decimal)
266+
) {
267+
return None;
268+
}
149269

150-
let replacement = checker.locator().slice(float).to_string();
151-
diagnostic.set_fix(Fix::safe_edits(
152-
edit,
153-
[Edit::range_replacement(replacement, call.range())],
154-
));
270+
let Expr::Call(ast::ExprCall {
271+
func, arguments, ..
272+
}) = arg_value
273+
else {
274+
return None;
275+
};
155276

156-
return;
277+
// Must be a call to the `float` builtin.
278+
if !checker.semantic().match_builtin_expr(func, "float") {
279+
return None;
157280
}
158281

159-
diagnostic.set_fix(Fix::safe_edit(edit));
282+
// Must have exactly one argument, which is a string literal.
283+
if !arguments.keywords.is_empty() {
284+
return None;
285+
}
286+
let [float_arg] = arguments.args.as_ref() else {
287+
return None;
288+
};
289+
as_non_finite_float_string_literal(float_arg)?;
290+
291+
let replacement_arg = checker.locator().slice(float_arg).to_string();
292+
let replacement_text = format!("{constructor_name}({replacement_arg})");
293+
Some(Edit::range_replacement(replacement_text, call.range()))
160294
}
161295

162296
#[derive(Debug, Copy, Clone, PartialEq, Eq)]

0 commit comments

Comments
 (0)