diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF067.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF067.py new file mode 100644 index 0000000000000..769f0e479ebc0 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF067.py @@ -0,0 +1,39 @@ +a = 1.0 +b = 2.0 +x = 99 + +# ok +if a is None: ... +if a == 1: ... +if a != b: ... +if a > 1.0: ... + +# errors +assert x == 0.0 +print(3.14 != a) +if x == 0.3: ... +if x == 0.42: ... + + +def foo(a, b): + return a == b - 0.1 + +print(x == 3.0 == 3.0) +print(1.0 == x == 2) + +assert x == float(1) +assert (a + b) == 1.0 +assert -x == 1.0 +assert -x == 1.0 +assert x**2 == 4.0 +assert x / 2 == 1.5 +assert (y := x + 1.0) == 2.0 + +[i for i in range(10) if i == 1.0] +{i for i in range(10) if i != 2.0} + +assert x / 2 == 1 +assert (x / 2) == (y / 3) + +# ok +assert Path(__file__).parent / ".txt" == 1 diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 53081e3681840..597adf7b79dba 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1632,6 +1632,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.is_rule_enabled(Rule::YodaConditions) { flake8_simplify::rules::yoda_conditions(checker, expr, left, ops, comparators); } + if checker.is_rule_enabled(Rule::FloatEqualityComparison) { + ruff::rules::float_equality_comparison(checker, compare); + } if checker.is_rule_enabled(Rule::PandasNuniqueConstantSeriesCheck) { pandas_vet::rules::nunique_constant_series_check( checker, diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 172841dc7c1f7..1f482a00ef7be 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1059,6 +1059,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Ruff, "064") => rules::ruff::rules::NonOctalPermissions, (Ruff, "065") => rules::ruff::rules::LoggingEagerConversion, + (Ruff, "067") => rules::ruff::rules::FloatEqualityComparison, + (Ruff, "100") => rules::ruff::rules::UnusedNOQA, (Ruff, "101") => rules::ruff::rules::RedirectedNOQA, (Ruff, "102") => rules::ruff::rules::InvalidRuleCode, diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 7cdc55784176e..0f4a88069aad6 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -565,6 +565,7 @@ mod tests { #[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_3.py"))] #[test_case(Rule::IndentedFormFeed, Path::new("RUF054.py"))] #[test_case(Rule::ImplicitClassVarInDataclass, Path::new("RUF045.py"))] + #[test_case(Rule::FloatEqualityComparison, Path::new("RUF067.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( "preview__{}_{}", diff --git a/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs b/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs new file mode 100644 index 0000000000000..ef40d66e2df50 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs @@ -0,0 +1,116 @@ +use itertools::Itertools; +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::{self as ast, CmpOp, Expr}; +use ruff_python_semantic::SemanticModel; +use ruff_text_size::{Ranged, TextRange}; + +use crate::checkers::ast::Checker; +use crate::{FixAvailability, Violation}; + +/// ## What it does +/// Checks for comparisons between floating-point values using `==` or `!=`. +/// +/// ## Why is this bad? +/// Directly comparing floats can produce unreliable results due to the +/// inherent imprecision of floating-point arithmetic. +/// +/// ## When to use `math.isclose()` vs `numpy.isclose()` +/// +/// **Use `math.isclose()` for scalar values:** +/// - Comparing individual float numbers +/// - Working with regular Python variables (not arrays) +/// - When you need a single `True`/`False` result +/// +/// **Use `numpy.isclose()` for array-like objects:** +/// - Comparing `pandas` Series, `numpy` arrays, or other vectorized objects +/// - When you need element-wise comparison of arrays +/// - Working in data science contexts with vectorized operations +/// +/// ## Example +/// ```python +/// assert 0.1 + 0.2 == 0.3 # AssertionError +/// ``` +/// Use instead: +/// ```python +/// import math +/// +/// # Scalar comparison +/// assert math.isclose(0.1 + 0.2, 0.3, abs_tol=1e-9) +/// ``` +/// ## References +/// - [Python documentation: `math.isclose`](https://docs.python.org/3/library/math.html#math.isclose) +/// - [NumPy documentation: `numpy.isclose`](https://numpy.org/doc/stable/reference/generated/numpy.isclose.html#numpy-isclose) +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "0.14.3")] +pub(crate) struct FloatEqualityComparison { + pub left: String, + pub right: String, + pub operand: String, +} + +impl Violation for FloatEqualityComparison { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + format!( + "Comparison `{} {} {}` should be replaced by `math.isclose()` or `numpy.isclose()`", + self.left, self.operand, self.right, + ) + } +} + +/// RUF067 +pub(crate) fn float_equality_comparison(checker: &Checker, compare: &ast::ExprCompare) { + let locator = checker.locator(); + let semantic = checker.semantic(); + + for (left, right, operand) in std::iter::once(&*compare.left) + .chain(&compare.comparators) + .tuple_windows() + .zip(&compare.ops) + .filter(|(_, op)| matches!(op, CmpOp::Eq | CmpOp::NotEq)) + .filter(|((left, right), _)| has_float(left, semantic) || has_float(right, semantic)) + .map(|((left, right), op)| (left, right, op)) + { + checker.report_diagnostic( + FloatEqualityComparison { + left: locator.slice(left.range()).to_string(), + right: locator.slice(right.range()).to_string(), + operand: operand.to_string(), + }, + TextRange::new(left.start(), right.end()), + ); + } +} + +fn has_float(expr: &Expr, semantic: &SemanticModel) -> bool { + match expr { + Expr::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => { + matches!(value, ast::Number::Float(_)) + } + Expr::BinOp(ast::ExprBinOp { + left, right, op, .. + }) => { + if matches!(op, ast::Operator::Div) { + is_numeric_expr(left) || is_numeric_expr(right) + } else { + has_float(left, semantic) || has_float(right, semantic) + } + } + Expr::UnaryOp(ast::ExprUnaryOp { operand, .. }) => has_float(operand, semantic), + Expr::Call(ast::ExprCall { func, .. }) => semantic.match_builtin_expr(func, "float"), + _ => false, + } +} + +fn is_numeric_expr(expr: &Expr) -> bool { + match expr { + Expr::NumberLiteral(_) => true, + Expr::BinOp(ast::ExprBinOp { left, right, .. }) => { + is_numeric_expr(left) || is_numeric_expr(right) + } + Expr::UnaryOp(ast::ExprUnaryOp { operand, .. }) => is_numeric_expr(operand), + _ => false, + } +} diff --git a/crates/ruff_linter/src/rules/ruff/rules/mod.rs b/crates/ruff_linter/src/rules/ruff/rules/mod.rs index 9d5ade77f3f46..5bdfe89423712 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mod.rs @@ -10,6 +10,7 @@ pub(crate) use decimal_from_float_literal::*; pub(crate) use default_factory_kwarg::*; pub(crate) use explicit_f_string_type_conversion::*; pub(crate) use falsy_dict_get_fallback::*; +pub(crate) use float_equality_comparison::*; pub(crate) use function_call_in_dataclass_default::*; pub(crate) use if_key_in_dict_del::*; pub(crate) use implicit_classvar_in_dataclass::*; @@ -74,6 +75,7 @@ mod decimal_from_float_literal; mod default_factory_kwarg; mod explicit_f_string_type_conversion; mod falsy_dict_get_fallback; +mod float_equality_comparison; mod function_call_in_dataclass_default; mod if_key_in_dict_del; mod implicit_classvar_in_dataclass; diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF067_RUF067.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF067_RUF067.py.snap new file mode 100644 index 0000000000000..d60e54c07ec3f --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__preview__RUF067_RUF067.py.snap @@ -0,0 +1,197 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF067 Comparison `x == 0.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:12:8 + | +11 | # errors +12 | assert x == 0.0 + | ^^^^^^^^ +13 | print(3.14 != a) +14 | if x == 0.3: ... + | + +RUF067 Comparison `3.14 != a` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:13:7 + | +11 | # errors +12 | assert x == 0.0 +13 | print(3.14 != a) + | ^^^^^^^^^ +14 | if x == 0.3: ... +15 | if x == 0.42: ... + | + +RUF067 Comparison `x == 0.3` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:14:4 + | +12 | assert x == 0.0 +13 | print(3.14 != a) +14 | if x == 0.3: ... + | ^^^^^^^^ +15 | if x == 0.42: ... + | + +RUF067 Comparison `x == 0.42` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:15:4 + | +13 | print(3.14 != a) +14 | if x == 0.3: ... +15 | if x == 0.42: ... + | ^^^^^^^^^^^^^^^ + | + +RUF067 Comparison `a == b - 0.1` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:19:12 + | +18 | def foo(a, b): +19 | return a == b - 0.1 + | ^^^^^^^^^^^^ +20 | +21 | print(x == 3.0 == 3.0) + | + +RUF067 Comparison `x == 3.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:21:7 + | +19 | return a == b - 0.1 +20 | +21 | print(x == 3.0 == 3.0) + | ^^^^^^^^ +22 | print(1.0 == x == 2) + | + +RUF067 Comparison `3.0 == 3.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:21:12 + | +19 | return a == b - 0.1 +20 | +21 | print(x == 3.0 == 3.0) + | ^^^^^^^^^^ +22 | print(1.0 == x == 2) + | + +RUF067 Comparison `1.0 == x` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:22:7 + | +21 | print(x == 3.0 == 3.0) +22 | print(1.0 == x == 2) + | ^^^^^^^^ +23 | +24 | assert x == float(1) + | + +RUF067 Comparison `x == float(1)` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:24:8 + | +22 | print(1.0 == x == 2) +23 | +24 | assert x == float(1) + | ^^^^^^^^^^^^^ +25 | assert (a + b) == 1.0 +26 | assert -x == 1.0 + | + +RUF067 Comparison `a + b == 1.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:25:9 + | +24 | assert x == float(1) +25 | assert (a + b) == 1.0 + | ^^^^^^^^^^^^^ +26 | assert -x == 1.0 +27 | assert -x == 1.0 + | + +RUF067 Comparison `-x == 1.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:26:8 + | +24 | assert x == float(1) +25 | assert (a + b) == 1.0 +26 | assert -x == 1.0 + | ^^^^^^^^^ +27 | assert -x == 1.0 +28 | assert x**2 == 4.0 + | + +RUF067 Comparison `-x == 1.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:27:8 + | +25 | assert (a + b) == 1.0 +26 | assert -x == 1.0 +27 | assert -x == 1.0 + | ^^^^^^^^^ +28 | assert x**2 == 4.0 +29 | assert x / 2 == 1.5 + | + +RUF067 Comparison `x**2 == 4.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:28:8 + | +26 | assert -x == 1.0 +27 | assert -x == 1.0 +28 | assert x**2 == 4.0 + | ^^^^^^^^^^^ +29 | assert x / 2 == 1.5 +30 | assert (y := x + 1.0) == 2.0 + | + +RUF067 Comparison `x / 2 == 1.5` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:29:8 + | +27 | assert -x == 1.0 +28 | assert x**2 == 4.0 +29 | assert x / 2 == 1.5 + | ^^^^^^^^^^^^ +30 | assert (y := x + 1.0) == 2.0 + | + +RUF067 Comparison `y := x + 1.0 == 2.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:30:9 + | +28 | assert x**2 == 4.0 +29 | assert x / 2 == 1.5 +30 | assert (y := x + 1.0) == 2.0 + | ^^^^^^^^^^^^^^^^^^^^ +31 | +32 | [i for i in range(10) if i == 1.0] + | + +RUF067 Comparison `i == 1.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:32:26 + | +30 | assert (y := x + 1.0) == 2.0 +31 | +32 | [i for i in range(10) if i == 1.0] + | ^^^^^^^^ +33 | {i for i in range(10) if i != 2.0} + | + +RUF067 Comparison `i != 2.0` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:33:26 + | +32 | [i for i in range(10) if i == 1.0] +33 | {i for i in range(10) if i != 2.0} + | ^^^^^^^^ +34 | +35 | assert x / 2 == 1 + | + +RUF067 Comparison `x / 2 == 1` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:35:8 + | +33 | {i for i in range(10) if i != 2.0} +34 | +35 | assert x / 2 == 1 + | ^^^^^^^^^^ +36 | assert (x / 2) == (y / 3) + | + +RUF067 Comparison `x / 2 == y / 3` should be replaced by `math.isclose()` or `numpy.isclose()` + --> RUF067.py:36:9 + | +35 | assert x / 2 == 1 +36 | assert (x / 2) == (y / 3) + | ^^^^^^^^^^^^^^^^ +37 | +38 | # ok + | diff --git a/ruff.schema.json b/ruff.schema.json index 04ef3fcc3df33..242c4b663f3c1 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -4037,6 +4037,7 @@ "RUF063", "RUF064", "RUF065", + "RUF067", "RUF1", "RUF10", "RUF100",