Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF067.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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__{}_{}",
Expand Down
116 changes: 116 additions & 0 deletions crates/ruff_linter/src/rules/ruff/rules/float_equality_comparison.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/ruff/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
|
Loading