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
18 changes: 18 additions & 0 deletions src/mutmut/node_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,23 @@ def operator_assignment(
yield node.with_changes(value=mutated_value)


def _wrap_low_precedence_expression(expression: cst.BaseExpression) -> cst.BaseExpression:
"""Wrap low-precedence expressions before appending boolean operators."""
if expression.lpar or expression.rpar:
return expression

if isinstance(expression, cst.BooleanOperation | cst.IfExp | cst.Lambda | cst.NamedExpr):
return expression.with_changes(lpar=[cst.LeftParen()], rpar=[cst.RightParen()])

return expression


def operator_if_exp(node: cst.IfExp) -> Iterable[cst.IfExp]:
test = _wrap_low_precedence_expression(node.test)
yield node.with_changes(test=cst.BooleanOperation(left=test, operator=cst.And(), right=cst.Name("False")))
yield node.with_changes(test=cst.BooleanOperation(left=test, operator=cst.Or(), right=cst.Name("True")))


def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]:
"""Drop the case statements in a match."""
if len(node.cases) > 1:
Expand All @@ -263,6 +280,7 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]:
(cst.Call, operator_symmetric_string_methods_swap),
(cst.Call, operator_unsymmetrical_string_methods_swap),
(cst.Lambda, operator_lambda),
(cst.IfExp, operator_if_exp),
(cst.CSTNode, operator_keywords), # type: ignore[type-abstract]
(cst.CSTNode, operator_swap_op), # type: ignore[type-abstract]
(cst.Match, operator_match),
Expand Down
20 changes: 15 additions & 5 deletions tests/test_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ def mutated_module(source: str) -> str:
("foo is not foo", "foo is foo"),
("a or b", "a and b"),
("a and b", "a or b"),
("a if b else c", ["a if b and False else c", "a if b or True else c"]),
("not a", "a"),
("a < b", ["a <= b"]),
("a <= b", ["a < b"]),
Expand Down Expand Up @@ -325,6 +326,13 @@ def foo() -> int:
assert not mutants


def test_ternary_mutation_preserves_boolean_precedence():
mutants = mutants_for_source("a if b or c else d")

assert "a if (b or c) and False else d" in mutants
assert "a if (b or c) or True else d" in mutants
Copy link
Collaborator

@Otto-AA Otto-AA Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR!

It's an interesting question, but If I'm not mistaken, I don't think the precedence matters in these cases.

For both 1 if (b or c) and False else 2 and 1 if b or (c and False) else 2:

  • we always evaluate expression b first
  • if b is falsy, we always evaluate c
  • we always return False

In the general case, I think adding and False or or True won't effect which expressions we evaluate, regardless of precedence.

Are you aware of a case where it could matter? Then it would be nice to add it here as test case. Otherwise, I think we could simplify the operator_if_exp mutation to directly use node.test.



def test_do_not_mutate_specific_functions():
source = """
class A:
Expand Down Expand Up @@ -401,13 +409,15 @@ def test_function_with_annotation():
mutated_code = mutated_module(source)
print(mutated_code)

expected_defs = [
"def x_capitalize__mutmut_1(s : str):\n return s[0].title() - s[1:] if s else s",
"def x_capitalize__mutmut_2(s : str):\n return s[1].title() + s[1:] if s else s",
"def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s",
expected_snippets = [
"return s[0].title() + s[1:] if s and False else s",
"return s[0].title() + s[1:] if s or True else s",
"return s[0].title() - s[1:] if s else s",
"return s[1].title() + s[1:] if s else s",
"return s[0].title() + s[2:] if s else s",
]

for expected in expected_defs:
for expected in expected_snippets:
print(expected)
assert expected in mutated_code

Expand Down