diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF032.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF032.py new file mode 100644 index 0000000000..4b2146aace --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF032.py @@ -0,0 +1,120 @@ +import decimal + +# Tests with fully qualified import +decimal.Decimal(0) + +decimal.Decimal(0.0) # Should error + +decimal.Decimal("0.0") + +decimal.Decimal(10) + +decimal.Decimal(10.0) # Should error + +decimal.Decimal("10.0") + +decimal.Decimal(-10) + +decimal.Decimal(-10.0) # Should error + +decimal.Decimal("-10.0") + +a = 10.0 + +decimal.Decimal(a) + + +# Tests with relative import +from decimal import Decimal + + +val = Decimal(0) + +val = Decimal(0.0) # Should error + +val = Decimal("0.0") + +val = Decimal(10) + +val = Decimal(10.0) # Should error + +val = Decimal("10.0") + +val = Decimal(-10) + +val = Decimal(-10.0) # Should error + +val = Decimal("-10.0") + +a = 10.0 + +val = Decimal(a) + + +# Tests with shadowed name +class Decimal(): + value: float | int | str + + def __init__(self, value: float | int | str) -> None: + self.value = value + + +val = Decimal(0.0) + +val = Decimal("0.0") + +val = Decimal(10.0) + +val = Decimal("10.0") + +val = Decimal(-10.0) + +val = Decimal("-10.0") + +a = 10.0 + +val = Decimal(a) + + +# Retest with fully qualified import + +val = decimal.Decimal(0.0) # Should error + +val = decimal.Decimal("0.0") + +val = decimal.Decimal(10.0) # Should error + +val = decimal.Decimal("10.0") + +val = decimal.Decimal(-10.0) # Should error + +val = decimal.Decimal("-10.0") + +a = 10.0 + +val = decimal.Decimal(a) + + +class decimal(): + class Decimal(): + value: float | int | str + + def __init__(self, value: float | int | str) -> None: + self.value = value + + +val = decimal.Decimal(0.0) + +val = decimal.Decimal("0.0") + +val = decimal.Decimal(10.0) + +val = decimal.Decimal("10.0") + +val = decimal.Decimal(-10.0) + +val = decimal.Decimal("-10.0") + +a = 10.0 + +val = decimal.Decimal(a) diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index a47f94723e..4f2f46661f 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1011,6 +1011,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { if checker.enabled(Rule::UnnecessaryIterableAllocationForFirstElement) { ruff::rules::unnecessary_iterable_allocation_for_first_element(checker, expr); } + if checker.enabled(Rule::DecimalFromFloatLiteral) { + ruff::rules::decimal_from_float_literal_syntax(checker, call); + } if checker.enabled(Rule::IntOnSlicedStr) { refurb::rules::int_on_sliced_str(checker, call); } diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index eaa37b3e6b..0171944422 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -959,6 +959,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "029") => (RuleGroup::Preview, rules::ruff::rules::UnusedAsync), (Ruff, "030") => (RuleGroup::Preview, rules::ruff::rules::AssertWithPrintMessage), (Ruff, "031") => (RuleGroup::Preview, rules::ruff::rules::IncorrectlyParenthesizedTupleInSubscript), + (Ruff, "032") => (RuleGroup::Preview, rules::ruff::rules::DecimalFromFloatLiteral), (Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA), (Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA), diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 9d63fa3069..d02c9f7f0e 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -57,6 +57,7 @@ mod tests { #[test_case(Rule::UnusedAsync, Path::new("RUF029.py"))] #[test_case(Rule::AssertWithPrintMessage, Path::new("RUF030.py"))] #[test_case(Rule::IncorrectlyParenthesizedTupleInSubscript, Path::new("RUF031.py"))] + #[test_case(Rule::DecimalFromFloatLiteral, Path::new("RUF032.py"))] #[test_case(Rule::RedirectedNOQA, Path::new("RUF101.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); diff --git a/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs b/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs new file mode 100644 index 0000000000..3f7120d923 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/decimal_from_float_literal.rs @@ -0,0 +1,85 @@ +use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{self as ast}; +use ruff_python_codegen::Stylist; +use ruff_text_size::{Ranged, TextRange}; + +use crate::checkers::ast::Checker; + +/// ## What it does +/// Checks for `Decimal` calls passing a float literal. +/// +/// ## Why is this bad? +/// Float literals have limited precision that can lead to unexpected results. +/// The `Decimal` class is designed to handle numbers with fixed-point precision, +/// so a string literal should be used instead. +/// +/// ## Example +/// +/// ```python +/// num = Decimal(1.2345) +/// ``` +/// +/// Use instead: +/// ```python +/// num = Decimal("1.2345") +/// ``` +/// +/// ## Fix Safety +/// This rule's fix is marked as unsafe because it changes the underlying value +/// of the `Decimal` instance that is constructed. This can lead to unexpected +/// behavior if your program relies on the previous value (whether deliberately or not). +#[violation] +pub struct DecimalFromFloatLiteral; + +impl AlwaysFixableViolation for DecimalFromFloatLiteral { + #[derive_message_formats] + fn message(&self) -> String { + format!("`Decimal()` called with float literal argument") + } + + fn fix_title(&self) -> String { + "Use a string literal instead".to_string() + } +} + +/// RUF032: `Decimal()` called with float literal argument +pub(crate) fn decimal_from_float_literal_syntax(checker: &mut Checker, call: &ast::ExprCall) { + let Some(arg) = call.arguments.args.first() else { + return; + }; + + if !is_arg_float_literal(arg) { + return; + } + + if checker + .semantic() + .resolve_qualified_name(call.func.as_ref()) + .is_some_and(|qualified_name| matches!(qualified_name.segments(), ["decimal", "Decimal"])) + { + let diagnostic = + Diagnostic::new(DecimalFromFloatLiteral, arg.range()).with_fix(fix_float_literal( + arg.range(), + &checker.generator().expr(arg), + checker.stylist(), + )); + checker.diagnostics.push(diagnostic); + } +} + +fn is_arg_float_literal(arg: &ast::Expr) -> bool { + match arg { + ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Float(_), + .. + }) => true, + ast::Expr::UnaryOp(ast::ExprUnaryOp { operand, .. }) => is_arg_float_literal(operand), + _ => false, + } +} + +fn fix_float_literal(range: TextRange, float_literal: &str, stylist: &Stylist) -> Fix { + let content = format!("{quote}{float_literal}{quote}", quote = stylist.quote()); + Fix::unsafe_edit(Edit::range_replacement(content, range)) +} diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 83f3515201..4d34f7cff8 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -3,6 +3,7 @@ pub(crate) use assert_with_print_message::*; pub(crate) use assignment_in_assert::*; pub(crate) use asyncio_dangling_task::*; pub(crate) use collection_literal_concatenation::*; +pub(crate) use decimal_from_float_literal::*; pub(crate) use default_factory_kwarg::*; pub(crate) use explicit_f_string_type_conversion::*; pub(crate) use function_call_in_dataclass_default::*; @@ -36,6 +37,7 @@ mod assignment_in_assert; mod asyncio_dangling_task; mod collection_literal_concatenation; mod confusables; +mod decimal_from_float_literal; mod default_factory_kwarg; mod explicit_f_string_type_conversion; mod function_call_in_dataclass_default; diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF032_RUF032.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF032_RUF032.py.snap new file mode 100644 index 0000000000..c21499b3f4 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF032_RUF032.py.snap @@ -0,0 +1,191 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF032.py:6:17: RUF032 [*] `Decimal()` called with float literal argument + | +4 | decimal.Decimal(0) +5 | +6 | decimal.Decimal(0.0) # Should error + | ^^^ RUF032 +7 | +8 | decimal.Decimal("0.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +3 3 | # Tests with fully qualified import +4 4 | decimal.Decimal(0) +5 5 | +6 |-decimal.Decimal(0.0) # Should error + 6 |+decimal.Decimal("0.0") # Should error +7 7 | +8 8 | decimal.Decimal("0.0") +9 9 | + +RUF032.py:12:17: RUF032 [*] `Decimal()` called with float literal argument + | +10 | decimal.Decimal(10) +11 | +12 | decimal.Decimal(10.0) # Should error + | ^^^^ RUF032 +13 | +14 | decimal.Decimal("10.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +9 9 | +10 10 | decimal.Decimal(10) +11 11 | +12 |-decimal.Decimal(10.0) # Should error + 12 |+decimal.Decimal("10.0") # Should error +13 13 | +14 14 | decimal.Decimal("10.0") +15 15 | + +RUF032.py:18:17: RUF032 [*] `Decimal()` called with float literal argument + | +16 | decimal.Decimal(-10) +17 | +18 | decimal.Decimal(-10.0) # Should error + | ^^^^^ RUF032 +19 | +20 | decimal.Decimal("-10.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +15 15 | +16 16 | decimal.Decimal(-10) +17 17 | +18 |-decimal.Decimal(-10.0) # Should error + 18 |+decimal.Decimal("-10.0") # Should error +19 19 | +20 20 | decimal.Decimal("-10.0") +21 21 | + +RUF032.py:33:15: RUF032 [*] `Decimal()` called with float literal argument + | +31 | val = Decimal(0) +32 | +33 | val = Decimal(0.0) # Should error + | ^^^ RUF032 +34 | +35 | val = Decimal("0.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +30 30 | +31 31 | val = Decimal(0) +32 32 | +33 |-val = Decimal(0.0) # Should error + 33 |+val = Decimal("0.0") # Should error +34 34 | +35 35 | val = Decimal("0.0") +36 36 | + +RUF032.py:39:15: RUF032 [*] `Decimal()` called with float literal argument + | +37 | val = Decimal(10) +38 | +39 | val = Decimal(10.0) # Should error + | ^^^^ RUF032 +40 | +41 | val = Decimal("10.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +36 36 | +37 37 | val = Decimal(10) +38 38 | +39 |-val = Decimal(10.0) # Should error + 39 |+val = Decimal("10.0") # Should error +40 40 | +41 41 | val = Decimal("10.0") +42 42 | + +RUF032.py:45:15: RUF032 [*] `Decimal()` called with float literal argument + | +43 | val = Decimal(-10) +44 | +45 | val = Decimal(-10.0) # Should error + | ^^^^^ RUF032 +46 | +47 | val = Decimal("-10.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +42 42 | +43 43 | val = Decimal(-10) +44 44 | +45 |-val = Decimal(-10.0) # Should error + 45 |+val = Decimal("-10.0") # Should error +46 46 | +47 47 | val = Decimal("-10.0") +48 48 | + +RUF032.py:81:23: RUF032 [*] `Decimal()` called with float literal argument + | +79 | # Retest with fully qualified import +80 | +81 | val = decimal.Decimal(0.0) # Should error + | ^^^ RUF032 +82 | +83 | val = decimal.Decimal("0.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +78 78 | +79 79 | # Retest with fully qualified import +80 80 | +81 |-val = decimal.Decimal(0.0) # Should error + 81 |+val = decimal.Decimal("0.0") # Should error +82 82 | +83 83 | val = decimal.Decimal("0.0") +84 84 | + +RUF032.py:85:23: RUF032 [*] `Decimal()` called with float literal argument + | +83 | val = decimal.Decimal("0.0") +84 | +85 | val = decimal.Decimal(10.0) # Should error + | ^^^^ RUF032 +86 | +87 | val = decimal.Decimal("10.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +82 82 | +83 83 | val = decimal.Decimal("0.0") +84 84 | +85 |-val = decimal.Decimal(10.0) # Should error + 85 |+val = decimal.Decimal("10.0") # Should error +86 86 | +87 87 | val = decimal.Decimal("10.0") +88 88 | + +RUF032.py:89:23: RUF032 [*] `Decimal()` called with float literal argument + | +87 | val = decimal.Decimal("10.0") +88 | +89 | val = decimal.Decimal(-10.0) # Should error + | ^^^^^ RUF032 +90 | +91 | val = decimal.Decimal("-10.0") + | + = help: Use a string literal instead + +ℹ Unsafe fix +86 86 | +87 87 | val = decimal.Decimal("10.0") +88 88 | +89 |-val = decimal.Decimal(-10.0) # Should error + 89 |+val = decimal.Decimal("-10.0") # Should error +90 90 | +91 91 | val = decimal.Decimal("-10.0") +92 92 | diff --git a/ruff.schema.json b/ruff.schema.json index 0f605a121a..94b5e69be4 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3738,6 +3738,7 @@ "RUF03", "RUF030", "RUF031", + "RUF032", "RUF1", "RUF10", "RUF100",