Skip to content

Commit 9c2836e

Browse files
New find-and-fix and sonar codemod for floating point equality (#450)
* new codemod to fix float equality * add testing edge cases * register and test float eq * document codemod * add sonar fix float equality codemod * fix small bug * Apply suggestions from code review Co-authored-by: Dan D'Avella <[email protected]> * Update src/core_codemods/fix_float_equality.py Co-authored-by: Dan D'Avella <[email protected]> --------- Co-authored-by: Dan D'Avella <[email protected]>
1 parent b084f2f commit 9c2836e

File tree

11 files changed

+404
-1
lines changed

11 files changed

+404
-1
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from codemodder.codemods.test import SonarIntegrationTest
2+
from core_codemods.fix_float_equality import FixFloatEqualityTransformer
3+
from core_codemods.sonar.sonar_fix_float_equality import SonarFixFloatEquality
4+
5+
6+
class TestSonarFixFloatEquality(SonarIntegrationTest):
7+
codemod = SonarFixFloatEquality
8+
code_path = "tests/samples/fix_float_equality.py"
9+
replacement_lines = [
10+
(1, "import math\n"),
11+
(2, "\n"),
12+
(3, "def foo(a, b):\n"),
13+
(4, " return math.isclose(a, b - 0.1, rel_tol=1e-09, abs_tol=0.0)\n"),
14+
]
15+
# fmt: off
16+
expected_diff = (
17+
"""--- \n"""
18+
"""+++ \n"""
19+
"""@@ -1,2 +1,4 @@\n"""
20+
"""+import math\n"""
21+
"""+\n"""
22+
""" def foo(a, b):\n"""
23+
"""- return a == b - 0.1\n"""
24+
"""+ return math.isclose(a, b - 0.1, rel_tol=1e-09, abs_tol=0.0)\n"""
25+
)
26+
# fmt: on
27+
expected_line_change = "2"
28+
change_description = FixFloatEqualityTransformer.change_description
29+
num_changes = 1
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from codemodder.codemods.test import BaseIntegrationTest
2+
from core_codemods.fix_float_equality import (
3+
FixFloatEquality,
4+
FixFloatEqualityTransformer,
5+
)
6+
7+
8+
class TestFixFloatEquality(BaseIntegrationTest):
9+
codemod = FixFloatEquality
10+
original_code = """
11+
def foo(a, b):
12+
return a == b - 0.1
13+
"""
14+
expected_new_code = """
15+
import math
16+
17+
def foo(a, b):
18+
return math.isclose(a, b - 0.1, rel_tol=1e-09, abs_tol=0.0)
19+
"""
20+
# fmt: off
21+
expected_diff = (
22+
"""--- \n"""
23+
"""+++ \n"""
24+
"""@@ -1,2 +1,4 @@\n"""
25+
"""+import math\n"""
26+
"""+\n"""
27+
""" def foo(a, b):\n"""
28+
"""- return a == b - 0.1\n"""
29+
"""+ return math.isclose(a, b - 0.1, rel_tol=1e-09, abs_tol=0.0)"""
30+
)
31+
# fmt: on
32+
expected_line_change = "2"
33+
change_description = FixFloatEqualityTransformer.change_description

src/codemodder/scripts/generate_docs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ class DocMetadata:
255255
importance="Medium",
256256
guidance_explained="This change is safe and will prevent errors when calling on these instance or class methods..",
257257
),
258+
"fix-float-equality": DocMetadata(
259+
importance="Medium",
260+
guidance_explained="This change makes your code more accurate but in some cases it may be necessary to adjust the `abs_tol` and `rel_tol` parameter values for your particular calculations.",
261+
),
258262
}
259263
DEFECTDOJO_CODEMODS = {
260264
"django-secure-set-cookie": DocMetadata(
@@ -284,6 +288,7 @@ class DocMetadata:
284288
"secure-random-S2245",
285289
"enable-jinja2-autoescape-S5247",
286290
"url-sandbox-S5144",
291+
"fix-float-equality-S1244",
287292
]
288293
SONAR_CODEMODS = {
289294
name: DocMetadata(

src/core_codemods/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .fix_deprecated_abstractproperty import FixDeprecatedAbstractproperty
2121
from .fix_deprecated_logging_warn import FixDeprecatedLoggingWarn
2222
from .fix_empty_sequence_comparison import FixEmptySequenceComparison
23+
from .fix_float_equality import FixFloatEquality
2324
from .fix_hasattr_call import TransformFixHasattrCall
2425
from .fix_missing_self_or_cls import FixMissingSelfOrCls
2526
from .fix_mutable_params import FixMutableParams
@@ -54,6 +55,7 @@
5455
from .sonar.sonar_enable_jinja2_autoescape import SonarEnableJinja2Autoescape
5556
from .sonar.sonar_exception_without_raise import SonarExceptionWithoutRaise
5657
from .sonar.sonar_fix_assert_tuple import SonarFixAssertTuple
58+
from .sonar.sonar_fix_float_equality import SonarFixFloatEquality
5759
from .sonar.sonar_fix_missing_self_or_cls import SonarFixMissingSelfOrCls
5860
from .sonar.sonar_flask_json_response_type import SonarFlaskJsonResponseType
5961
from .sonar.sonar_jwt_decode_verify import SonarJwtDecodeVerify
@@ -131,6 +133,7 @@
131133
FixEmptySequenceComparison,
132134
RemoveAssertionInPytestRaises,
133135
FixAssertTuple,
136+
FixFloatEquality,
134137
LazyLogging,
135138
StrConcatInSeqLiteral,
136139
FixAsyncTaskInstantiation,
@@ -158,6 +161,7 @@
158161
SonarSecureRandom,
159162
SonarEnableJinja2Autoescape,
160163
SonarUrlSandbox,
164+
SonarFixFloatEquality,
161165
],
162166
)
163167

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
In most programming languages, floating point arithmetic is imprecise due to the way floating point numbers are stored as binary representations. Moreover, the result of calculations with floats can vary based on when rounding happens. Using equality or inequality to compare floats or their operations will almost always be imprecise and lead to bugs.
2+
3+
For these reasons, this codemod changes any operations involving equality or inequality with floats to the recommended `math.isclose` function. This codemod uses the default parameter values `rel_tol=1e-09` and `abs_tol=0.0` but makes them explicit as a starting point for you to consider depending on your calculation needs.
4+
5+
Our changes look like the following:
6+
```diff
7+
+import math
8+
+
9+
def foo(a, b):
10+
- return a == b - 0.1
11+
+ return math.isclose(a, b - 0.1, rel_tol=1e-09, abs_tol=0.0)
12+
```
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import libcst as cst
2+
3+
from codemodder.codemods.libcst_transformer import (
4+
LibcstResultTransformer,
5+
LibcstTransformerPipeline,
6+
)
7+
from codemodder.codemods.utils_mixin import NameAndAncestorResolutionMixin
8+
from core_codemods.api import Metadata, Reference, ReviewGuidance
9+
from core_codemods.api.core_codemod import CoreCodemod
10+
11+
12+
class FixFloatEqualityTransformer(
13+
LibcstResultTransformer, NameAndAncestorResolutionMixin
14+
):
15+
change_description = "Replace `==` or `!=` with `math.isclose`"
16+
17+
def leave_Comparison(
18+
self, original_node: cst.Comparison, updated_node: cst.Comparison
19+
) -> cst.BaseExpression:
20+
if not self.node_is_selected(original_node):
21+
return updated_node
22+
23+
match original_node:
24+
case cst.Comparison(
25+
left=left, comparisons=[cst.ComparisonTarget() as target]
26+
):
27+
if isinstance(
28+
target.operator, cst.Equal | cst.NotEqual
29+
) and self.at_least_one_float(left, right := target.comparator):
30+
self.add_needed_import("math")
31+
isclose_call = self.make_isclose_call(left, right)
32+
self.report_change(original_node)
33+
return (
34+
isclose_call
35+
if isinstance(target.operator, cst.Equal)
36+
else cst.UnaryOperation(
37+
operator=cst.Not(),
38+
expression=isclose_call,
39+
)
40+
)
41+
return updated_node
42+
43+
def make_isclose_call(self, left, right):
44+
return cst.Call(
45+
func=cst.Attribute(
46+
value=cst.Name(value="math"), attr=cst.Name(value="isclose")
47+
),
48+
args=[
49+
cst.Arg(value=left),
50+
cst.Arg(value=right),
51+
cst.Arg(
52+
keyword=cst.Name(value="rel_tol"),
53+
value=cst.Float(value="1e-09"),
54+
equal=cst.AssignEqual(
55+
whitespace_before=cst.SimpleWhitespace(""),
56+
whitespace_after=cst.SimpleWhitespace(""),
57+
),
58+
),
59+
cst.Arg(
60+
keyword=cst.Name(value="abs_tol"),
61+
value=cst.Float(value="0.0"),
62+
equal=cst.AssignEqual(
63+
whitespace_before=cst.SimpleWhitespace(""),
64+
whitespace_after=cst.SimpleWhitespace(""),
65+
),
66+
),
67+
],
68+
)
69+
70+
def at_least_one_float(self, left, right) -> bool:
71+
left_type = self.resolve_expression(left)
72+
right_type = self.resolve_expression(right)
73+
74+
match (left_type, right_type):
75+
case (cst.Float(), _) | (_, cst.Float()):
76+
return True
77+
case (cst.BinaryOperation(), _):
78+
return self.at_least_one_float(left_type.left, left_type.right)
79+
case (_, cst.BinaryOperation()):
80+
return self.at_least_one_float(right_type.left, right_type.right)
81+
return False
82+
83+
84+
FixFloatEquality = CoreCodemod(
85+
metadata=Metadata(
86+
name="fix-float-equality",
87+
summary="Use `math.isclose` Instead of Direct Equality for Floats",
88+
review_guidance=ReviewGuidance.MERGE_AFTER_REVIEW,
89+
references=[
90+
Reference(
91+
url="https://docs.python.org/3/tutorial/floatingpoint.html#floating-point-arithmetic-issues-and-limitations"
92+
),
93+
Reference(url="https://docs.python.org/3/library/math.html#math.isclose"),
94+
],
95+
),
96+
transformer=LibcstTransformerPipeline(FixFloatEqualityTransformer),
97+
detector=None,
98+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from core_codemods.fix_float_equality import FixFloatEquality
2+
from core_codemods.sonar.api import SonarCodemod
3+
4+
SonarFixFloatEquality = SonarCodemod.from_core_codemod(
5+
name="fix-float-equality-S1244",
6+
other=FixFloatEquality,
7+
rule_id="python:S1244",
8+
rule_name="Floating point numbers should not be tested for equality",
9+
rule_url="https://rules.sonarsource.com/python/type/Bug/RSPEC-1244/",
10+
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import json
2+
3+
from codemodder.codemods.test import BaseSASTCodemodTest
4+
from core_codemods.sonar.sonar_fix_float_equality import SonarFixFloatEquality
5+
6+
7+
class TestSonarFixFloatEquality(BaseSASTCodemodTest):
8+
codemod = SonarFixFloatEquality
9+
tool = "sonar"
10+
11+
def test_name(self):
12+
assert self.codemod.name == "fix-float-equality-S1244"
13+
14+
def test_simple(self, tmpdir):
15+
input_code = """
16+
def foo(a, b):
17+
return a == b - 0.1
18+
"""
19+
expected_output = """
20+
import math
21+
22+
def foo(a, b):
23+
return math.isclose(a, b - 0.1, rel_tol=1e-09, abs_tol=0.0)
24+
"""
25+
issues = {
26+
"issues": [
27+
{
28+
"rule": "python:S1244",
29+
"status": "OPEN",
30+
"component": "code.py",
31+
"textRange": {
32+
"startLine": 3,
33+
"endLine": 3,
34+
"startOffset": 11,
35+
"endOffset": 23,
36+
},
37+
}
38+
]
39+
}
40+
self.run_and_assert(
41+
tmpdir,
42+
input_code,
43+
expected_output,
44+
results=json.dumps(issues),
45+
)

0 commit comments

Comments
 (0)