11use ruff_macros:: { ViolationMetadata , derive_message_formats} ;
22use 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;
35use ruff_text_size:: Ranged ;
46
57use crate :: checkers:: ast:: Checker ;
68use 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