Skip to content

Commit a70cdcf

Browse files
authored
Codemod to fix math.isclose comparison to 0 (#475)
* codemod for math.isclose without abs_tol * add more test cases * document codemod * add sonar codemod for fix math.isclose * document sonar
1 parent 8e02cdd commit a70cdcf

File tree

13 files changed

+410
-2
lines changed

13 files changed

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

src/codemodder/codemods/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,27 @@ def is_assigned_to_True(original_node: cst.Assign):
192192
isinstance(original_node.value, cst.Name)
193193
and original_node.value.value == "True"
194194
)
195+
196+
197+
def is_zero(node: cst.CSTNode) -> bool:
198+
match node:
199+
case cst.Integer() | cst.Float():
200+
try:
201+
return float(node.value) == 0
202+
except (ValueError, TypeError):
203+
return False
204+
case cst.Call(func=cst.Name("int"), args=[]) | cst.Call(
205+
func=cst.Name("float"), args=[]
206+
):
207+
# int() or float() == 0
208+
return True
209+
case cst.Call(
210+
func=cst.Name("int"), args=[cst.Arg(value=cst.Name(value="False"))]
211+
) | cst.Call(
212+
func=cst.Name("float"), args=[cst.Arg(value=cst.Name(value="False"))]
213+
):
214+
# int(False) or float(False)
215+
return True
216+
case cst.Call(func=cst.Name("int")) | cst.Call(func=cst.Name("float")):
217+
return is_zero(node.args[0].value)
218+
return False

src/codemodder/scripts/generate_docs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ class DocMetadata:
259259
importance="Medium",
260260
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.",
261261
),
262+
"fix-math-isclose": DocMetadata(
263+
importance="Medium",
264+
guidance_explained="This change makes your code more accurate but in some cases it may be necessary to adjust the `abs_tol` parameter value for your particular calculations.",
265+
),
262266
}
263267
DEFECTDOJO_CODEMODS = {
264268
"django-secure-set-cookie": DocMetadata(
@@ -289,6 +293,7 @@ class DocMetadata:
289293
"enable-jinja2-autoescape-S5247",
290294
"url-sandbox-S5144",
291295
"fix-float-equality-S1244",
296+
"fix-math-isclose-S6727",
292297
]
293298
SONAR_CODEMODS = {
294299
name: DocMetadata(

src/core_codemods/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .fix_empty_sequence_comparison import FixEmptySequenceComparison
2323
from .fix_float_equality import FixFloatEquality
2424
from .fix_hasattr_call import TransformFixHasattrCall
25+
from .fix_math_isclose import FixMathIsClose
2526
from .fix_missing_self_or_cls import FixMissingSelfOrCls
2627
from .fix_mutable_params import FixMutableParams
2728
from .flask_enable_csrf_protection import FlaskEnableCSRFProtection
@@ -56,6 +57,7 @@
5657
from .sonar.sonar_exception_without_raise import SonarExceptionWithoutRaise
5758
from .sonar.sonar_fix_assert_tuple import SonarFixAssertTuple
5859
from .sonar.sonar_fix_float_equality import SonarFixFloatEquality
60+
from .sonar.sonar_fix_math_isclose import SonarFixMathIsClose
5961
from .sonar.sonar_fix_missing_self_or_cls import SonarFixMissingSelfOrCls
6062
from .sonar.sonar_flask_json_response_type import SonarFlaskJsonResponseType
6163
from .sonar.sonar_jwt_decode_verify import SonarJwtDecodeVerify
@@ -141,6 +143,7 @@
141143
TransformFixHasattrCall,
142144
FixDataclassDefaults,
143145
FixMissingSelfOrCls,
146+
FixMathIsClose,
144147
],
145148
)
146149

@@ -162,6 +165,7 @@
162165
SonarEnableJinja2Autoescape,
163166
SonarUrlSandbox,
164167
SonarFixFloatEquality,
168+
SonarFixMathIsClose,
165169
],
166170
)
167171

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
The default value for the `abs_tol` argument to a `math.isclose` call is `0`. Using this default when comparing a value against `0`, such as in `math.isclose(a, 0)` is equivalent to a strict equality check to `0`, which is not the intended use of the `math.isclose` function.
2+
3+
This codemod adds `abs_tol=1e-09` to any call to `math.isclose` with one of of the first arguments evaluating to `0` if `abs_tol` is not already specified. `1e-09` is 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):
10+
- return math.isclose(a, 0)
11+
+ return math.isclose(a, 0, abs_tol=1e-09)
12+
```
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import libcst as cst
2+
3+
from codemodder.codemods.libcst_transformer import (
4+
LibcstResultTransformer,
5+
LibcstTransformerPipeline,
6+
NewArg,
7+
)
8+
from codemodder.codemods.utils import is_zero
9+
from codemodder.codemods.utils_mixin import NameAndAncestorResolutionMixin
10+
from core_codemods.api import Metadata, Reference, ReviewGuidance
11+
from core_codemods.api.core_codemod import CoreCodemod
12+
13+
14+
class FixMathIsCloseTransformer(
15+
LibcstResultTransformer,
16+
NameAndAncestorResolutionMixin,
17+
):
18+
change_description = "Add `abs_tol` to `math.isclose` call"
19+
20+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call):
21+
if (
22+
not self.node_is_selected(original_node)
23+
or self.find_base_name(original_node.func) != "math.isclose"
24+
or len(original_node.args) < 2
25+
):
26+
return updated_node
27+
28+
if self.at_least_one_zero_arg(original_node.args):
29+
for arg in original_node.args[2:]:
30+
match arg:
31+
case cst.Arg(keyword=cst.Name(value="abs_tol")) as matched_arg:
32+
# A `abs_tol` kwarg set to not 0 is acceptable if comparing to 0
33+
if not is_zero(matched_arg.value):
34+
return updated_node
35+
36+
new_args = self.replace_args(
37+
original_node,
38+
[NewArg(name="abs_tol", value="1e-09", add_if_missing=True)],
39+
)
40+
41+
self.report_change(original_node)
42+
return self.update_arg_target(updated_node, new_args)
43+
44+
return updated_node
45+
46+
def at_least_one_zero_arg(self, original_args: list[cst.Arg]):
47+
first_arg = self.resolve_expression(original_args[0].value)
48+
second_arg = self.resolve_expression(original_args[1].value)
49+
return is_zero(first_arg) or is_zero(second_arg)
50+
51+
52+
FixMathIsClose = CoreCodemod(
53+
metadata=Metadata(
54+
name="fix-math-isclose",
55+
summary="Add `abs_tol` to `math.isclose` Call",
56+
review_guidance=ReviewGuidance.MERGE_AFTER_REVIEW,
57+
references=[
58+
Reference(url="https://docs.python.org/3/library/math.html#math.isclose"),
59+
],
60+
),
61+
transformer=LibcstTransformerPipeline(FixMathIsCloseTransformer),
62+
detector=None,
63+
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import libcst as cst
2+
3+
from codemodder.codemods.libcst_transformer import LibcstTransformerPipeline
4+
from codemodder.result import fuzzy_column_match, same_line
5+
from core_codemods.fix_math_isclose import FixMathIsClose, FixMathIsCloseTransformer
6+
from core_codemods.sonar.api import SonarCodemod
7+
8+
9+
class FixMathIsCloseSonarTransformer(FixMathIsCloseTransformer):
10+
def filter_by_result(self, node) -> bool:
11+
"""
12+
Special case result-matching for this rule because the sonar
13+
results returned match only the `math.isclose` call without `(...args...)`
14+
"""
15+
match node:
16+
case cst.Call():
17+
pos_to_match = self.node_position(node)
18+
return any(
19+
self.match_location(pos_to_match, result)
20+
for result in self.results or []
21+
)
22+
return False
23+
24+
def match_location(self, pos, result):
25+
return any(
26+
same_line(pos, location) and fuzzy_column_match(pos, location)
27+
for location in result.locations
28+
)
29+
30+
31+
SonarFixMathIsClose = SonarCodemod.from_core_codemod(
32+
name="fix-math-isclose-S6727",
33+
other=FixMathIsClose,
34+
rule_id="python:S6727",
35+
rule_name="The abs_tol parameter should be provided when using math.isclose to compare values to 0",
36+
rule_url="https://rules.sonarsource.com/python/RSPEC-6727/",
37+
transformer=LibcstTransformerPipeline(FixMathIsCloseSonarTransformer),
38+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import json
2+
3+
from codemodder.codemods.test import BaseSASTCodemodTest
4+
from core_codemods.sonar.sonar_fix_math_isclose import SonarFixMathIsClose
5+
6+
7+
class TestSonarFixMathIsClose(BaseSASTCodemodTest):
8+
codemod = SonarFixMathIsClose
9+
tool = "sonar"
10+
11+
def test_name(self):
12+
assert self.codemod.name == "fix-math-isclose-S6727"
13+
14+
def test_simple(self, tmpdir):
15+
input_code = """
16+
import math
17+
18+
def foo(a):
19+
return math.isclose(a, 0)
20+
"""
21+
expected_output = """
22+
import math
23+
24+
def foo(a):
25+
return math.isclose(a, 0, abs_tol=1e-09)
26+
"""
27+
hotspots = {
28+
"issues": [
29+
{
30+
"rule": "python:S6727",
31+
"status": "OPEN",
32+
"component": "code.py",
33+
"textRange": {
34+
"startLine": 5,
35+
"endLine": 5,
36+
"startOffset": 11,
37+
"endOffset": 23,
38+
},
39+
}
40+
]
41+
}
42+
self.run_and_assert(
43+
tmpdir,
44+
input_code,
45+
expected_output,
46+
results=json.dumps(hotspots),
47+
num_changes=1,
48+
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from codemodder.codemods.test import BaseCodemodTest
2+
from core_codemods.fix_math_isclose import FixMathIsClose
3+
4+
5+
class TestFixMathIsClose(BaseCodemodTest):
6+
codemod = FixMathIsClose
7+
8+
def test_name(self):
9+
assert self.codemod.name == "fix-math-isclose"
10+
11+
def test_change(self, tmpdir):
12+
input_code = """
13+
import math
14+
15+
math.isclose(a, 0, abs_tol=0.0)
16+
math.isclose(a, 0, abs_tol=0)
17+
math.isclose(0, 20)
18+
19+
def foo(a):
20+
return math.isclose(a, 0)
21+
22+
"""
23+
expected = """
24+
import math
25+
26+
math.isclose(a, 0, abs_tol=1e-09)
27+
math.isclose(a, 0, abs_tol=1e-09)
28+
math.isclose(0, 20, abs_tol=1e-09)
29+
30+
def foo(a):
31+
return math.isclose(a, 0, abs_tol=1e-09)
32+
33+
"""
34+
self.run_and_assert(tmpdir, input_code, expected, num_changes=4)
35+
36+
def test_change_resolves_to_zero(self, tmpdir):
37+
input_code = """
38+
import math
39+
40+
a = 0
41+
math.isclose(a, b)
42+
43+
c = float(0.0)
44+
math.isclose(c, d)
45+
math.isclose(1, int(0.0))
46+
"""
47+
expected = """
48+
import math
49+
50+
a = 0
51+
math.isclose(a, b, abs_tol=1e-09)
52+
53+
c = float(0.0)
54+
math.isclose(c, d, abs_tol=1e-09)
55+
math.isclose(1, int(0.0), abs_tol=1e-09)
56+
"""
57+
self.run_and_assert(tmpdir, input_code, expected, num_changes=3)
58+
59+
def test_no_change(self, tmpdir):
60+
input_code = """
61+
import math
62+
63+
math.isclose(a, b)
64+
math.isclose(5, 29)
65+
math.isclose(a, 0, abs_tol=0.1)
66+
67+
def bar(a, b):
68+
return math.isclose(a, b)
69+
70+
math.isclose()
71+
math.isclose(2)
72+
"""
73+
self.run_and_assert(tmpdir, input_code, input_code)
74+
75+
def test_exclude_line(self, tmpdir):
76+
input_code = (
77+
expected
78+
) = """
79+
import math
80+
math.isclose(20, 0)
81+
"""
82+
lines_to_exclude = [3]
83+
self.run_and_assert(
84+
tmpdir,
85+
input_code,
86+
expected,
87+
lines_to_exclude=lines_to_exclude,
88+
)

0 commit comments

Comments
 (0)