Skip to content

Commit 5fe5987

Browse files
fix(rewrite): freeze operands a later walrus would clobber
The rewriter hoists each operand into its own statement, but a plain name is left as a bare load evaluated when the enclosing expression is assembled -- after the statements of the operands that follow it. A walrus operator in a later operand rebinds the name in between, so both the value used and the value reported were the post-walrus one, while Python evaluates the earlier operand first: assert value != identity(value := value.lower()) visit_BoolOp already guarded against this; extract its pre-scan as _walrus_targets() and add visit_operand() to apply the same freeze in visit_Compare, visit_Call and visit_BinOp. visit_Compare previously matched only a comparator that *was* a NamedExpr, missing walrus operators nested inside it; visit_Call did not guard at all, so an earlier argument saw a later argument's assignment. These cases predate the walrus rework -- they fail on main too. Closes the single-eval-walrus, order-compare-left, order-call-argument and order-binop-left groups in the coverage matrix. order-call-argument keeps one entry: a bare walrus argument is still substituted into a later one, which visit_operand does not yet see because the operand is a NamedExpr rather than a Name. Reported-by: Denis Scapin
1 parent d115f0e commit 5fe5987

3 files changed

Lines changed: 173 additions & 31 deletions

File tree

‎changelog/14445.bugfix.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
Fixed assertion rewriting evaluating walrus operator (``:=``) expressions multiple times, causing incorrect test results when the expression had side effects (e.g., incrementing a counter or calling a function).
2+
3+
Operands preceding a walrus operator are now evaluated -- and reported -- before it rebinds their name, so ``assert value != identity(value := value.lower())`` keeps Python's left-to-right evaluation order.

‎src/_pytest/assertion/rewrite.py‎

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,16 @@ def traverse_node(node: ast.AST) -> Iterator[ast.AST]:
530530
yield from traverse_node(child)
531531

532532

533+
def _walrus_targets(nodes: Iterable[ast.expr]) -> set[str]:
534+
"""Return the names any walrus operator in *nodes* rebinds."""
535+
return {
536+
sub.target.id
537+
for node in nodes
538+
for sub in ast.walk(node)
539+
if isinstance(sub, ast.NamedExpr)
540+
}
541+
542+
533543
@functools.lru_cache(maxsize=1)
534544
def _get_assertion_exprs(src: bytes) -> dict[int, str]:
535545
"""Return a mapping from {lineno: "assertion test expression"}."""
@@ -951,6 +961,27 @@ def visit_Name(self, name: ast.Name) -> tuple[ast.Name, str]:
951961
expr = ast.IfExp(test, self.display(name), ast.Constant(name.id))
952962
return name, self.explanation_param(expr)
953963

964+
def visit_operand(
965+
self, operand: ast.expr, later: Sequence[ast.expr]
966+
) -> tuple[ast.expr, str]:
967+
"""Visit an operand, freezing it against walrus operators in *later*.
968+
969+
Operands are rewritten into statements that run in source order, but
970+
a plain name is left as a bare load evaluated at the very end, when
971+
the enclosing expression is assembled. A walrus operator in a later
972+
operand rebinds that name in between, so both the value used and the
973+
value reported would be the post-walrus one -- Python evaluates the
974+
earlier operand first. Copy the value into a temporary instead.
975+
"""
976+
specifiers = set(self.explanation_specifiers)
977+
res, expl = self.visit(operand)
978+
if isinstance(res, ast.Name) and res.id in _walrus_targets(later):
979+
snapshot = self.assign(res)
980+
for key in set(self.explanation_specifiers) - specifiers:
981+
self.explanation_specifiers[key] = self.display(snapshot)
982+
res = snapshot
983+
return res, expl
984+
954985
def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
955986
res_var = self.variable()
956987
expl_list = self.assign(ast.List([], ast.Load()))
@@ -959,18 +990,10 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
959990
body = save = self.statements
960991
fail_save = self.expl_stmts
961992
levels = len(boolop.values) - 1
962-
# Pre-scan: for each operand position, collect the set of variable
963-
# names that a *later* operand's walrus operator will overwrite.
964-
# An operand needs a snapshot only when its value references a name
965-
# in this set (otherwise the explanation would show the post-walrus
966-
# value instead of the value at evaluation time).
967-
later_walrus_targets: list[set[str]] = [set() for _ in boolop.values]
968-
seen: set[str] = set()
969-
for idx in range(len(boolop.values) - 1, -1, -1):
970-
later_walrus_targets[idx] = set(seen)
971-
for node in ast.walk(boolop.values[idx]):
972-
if isinstance(node, ast.NamedExpr):
973-
seen.add(node.target.id)
993+
later_walrus_targets = [
994+
_walrus_targets(boolop.values[idx + 1 :])
995+
for idx in range(len(boolop.values))
996+
]
974997
self.push_format_context()
975998
# Process each operand, short-circuiting as needed.
976999
for i, v in enumerate(boolop.values):
@@ -1024,7 +1047,7 @@ def visit_UnaryOp(self, unary: ast.UnaryOp) -> tuple[ast.Name, str]:
10241047

10251048
def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]:
10261049
symbol = BINOP_MAP[binop.op.__class__]
1027-
left_expr, left_expl = self.visit(binop.left)
1050+
left_expr, left_expl = self.visit_operand(binop.left, [binop.right])
10281051
right_expr, right_expl = self.visit(binop.right)
10291052
explanation = f"({left_expl} {symbol} {right_expl})"
10301053
res = self.assign(
@@ -1033,16 +1056,19 @@ def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]:
10331056
return res, explanation
10341057

10351058
def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]:
1036-
new_func, func_expl = self.visit(call.func)
1059+
# The callee and every argument are evaluated left to right, so each of
1060+
# them has to be frozen against walrus operators in what follows.
1061+
operands = [*call.args, *(keyword.value for keyword in call.keywords)]
1062+
new_func, func_expl = self.visit_operand(call.func, operands)
10371063
arg_expls = []
10381064
new_args = []
10391065
new_kwargs = []
1040-
for arg in call.args:
1041-
res, expl = self.visit(arg)
1066+
for i, arg in enumerate(call.args):
1067+
res, expl = self.visit_operand(arg, operands[i + 1 :])
10421068
arg_expls.append(expl)
10431069
new_args.append(res)
1044-
for keyword in call.keywords:
1045-
res, expl = self.visit(keyword.value)
1070+
for i, keyword in enumerate(call.keywords, start=len(call.args)):
1071+
res, expl = self.visit_operand(keyword.value, operands[i + 1 :])
10461072
new_kwargs.append(ast.keyword(keyword.arg, res))
10471073
if keyword.arg:
10481074
arg_expls.append(keyword.arg + "=" + expl)
@@ -1076,7 +1102,7 @@ def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]:
10761102

10771103
def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
10781104
self.push_format_context()
1079-
left_res, left_expl = self.visit(comp.left)
1105+
left_res, left_expl = self.visit_operand(comp.left, comp.comparators)
10801106
if isinstance(comp.left, ast.Compare | ast.BoolOp):
10811107
left_expl = f"({left_expl})"
10821108
if isinstance(left_res, ast.NamedExpr):
@@ -1089,18 +1115,9 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
10891115
syms: list[ast.expr] = []
10901116
results = [left_res]
10911117
for i, op, next_operand in it:
1092-
# If the next operand is a walrus that assigns to the same name as
1093-
# the current left_res, we must freeze left_res's value before the
1094-
# walrus modifies it.
1095-
match (next_operand, left_res):
1096-
case (
1097-
ast.NamedExpr(target=ast.Name(id=target_id)),
1098-
ast.Name(id=name_id),
1099-
) if target_id == name_id:
1100-
left_res = self.assign(left_res)
1101-
results[-1] = left_res
1102-
1103-
next_res, next_expl = self.visit(next_operand)
1118+
next_res, next_expl = self.visit_operand(
1119+
next_operand, comp.comparators[i + 1 :]
1120+
)
11041121
if isinstance(next_operand, ast.Compare | ast.BoolOp):
11051122
next_expl = f"({next_expl})"
11061123
if isinstance(next_res, ast.NamedExpr):

‎testing/test_assertrewrite_coverage.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,33 @@ def __getitem__(self, key):
763763
assert d["a"] == 100
764764
""")
765765

766+
def test_walrus_in_compare_evaluated_once(self) -> None:
767+
assert_single_evaluation("""
768+
def check():
769+
def side_effect():
770+
counter[0] += 1
771+
return 42
772+
assert (x := side_effect()) == 100
773+
""")
774+
775+
def test_walrus_in_boolean_evaluated_once(self) -> None:
776+
assert_single_evaluation("""
777+
def check():
778+
def side_effect():
779+
counter[0] += 1
780+
return 42
781+
assert (x := side_effect()) and False
782+
""")
783+
784+
def test_walrus_in_chained_compare_evaluated_once(self) -> None:
785+
assert_single_evaluation("""
786+
def check():
787+
def side_effect():
788+
counter[0] += 1
789+
return 5
790+
assert 1 < (x := side_effect()) < 3
791+
""")
792+
766793
def test_method_call_evaluated_once(self) -> None:
767794
assert_single_evaluation("""
768795
def check():
@@ -836,6 +863,72 @@ class TestEvaluationOrder:
836863
given it.
837864
"""
838865

866+
def test_compare_left_operand_precedes_walrus(self) -> None:
867+
assert_evaluation_order("""
868+
def check():
869+
def identity(v):
870+
return v
871+
value = "Hello"
872+
try:
873+
assert value != identity(value := value.lower())
874+
except AssertionError:
875+
return "raised", value
876+
return "passed", value
877+
""")
878+
879+
def test_compare_reports_left_operand(self) -> None:
880+
assert_introspects(
881+
"""
882+
def check():
883+
def identity(v):
884+
return v
885+
value = 2
886+
assert value == identity(value := 3)
887+
""",
888+
must_contain=["assert 2 == 3"],
889+
)
890+
891+
def test_call_earlier_argument_precedes_walrus(self) -> None:
892+
assert_evaluation_order("""
893+
def check():
894+
def identity(v):
895+
return v
896+
def collect(*values):
897+
return values
898+
value = "Hello"
899+
try:
900+
assert collect(value, identity(value := value.lower())) == ("Hello", "hello")
901+
except AssertionError:
902+
return "raised", value
903+
return "passed", value
904+
""")
905+
906+
def test_binop_left_operand_precedes_walrus(self) -> None:
907+
assert_evaluation_order("""
908+
def check():
909+
def identity(v):
910+
return v
911+
value = 1
912+
try:
913+
assert value + identity(value := 5) == 6
914+
except AssertionError:
915+
return "raised", value
916+
return "passed", value
917+
""")
918+
919+
def test_chained_compare_operands_in_order(self) -> None:
920+
assert_evaluation_order("""
921+
def check():
922+
def identity(v):
923+
return v
924+
value = 1
925+
try:
926+
assert value < identity(value := 5) < 9
927+
except AssertionError:
928+
return "raised", value
929+
return "passed", value
930+
""")
931+
839932
def test_container_literal_operand_in_order(self) -> None:
840933
"""Guard: ``generic_visit`` hoists container literals into a temporary."""
841934
assert_evaluation_order("""
@@ -914,6 +1007,36 @@ def take(self, value):
9141007
return "passed", obj
9151008
""")
9161009

1010+
def test_keyword_argument_precedes_walrus(self) -> None:
1011+
assert_evaluation_order("""
1012+
def check():
1013+
def identity(v):
1014+
return v
1015+
def collect(**kwargs):
1016+
return kwargs
1017+
value = 1
1018+
try:
1019+
assert collect(a=value, b=identity(value := 2)) == {"a": 1, "b": 2}
1020+
except AssertionError:
1021+
return "raised", value
1022+
return "passed", value
1023+
""")
1024+
1025+
def test_double_star_argument_precedes_walrus(self) -> None:
1026+
assert_evaluation_order("""
1027+
def check():
1028+
def identity(v):
1029+
return v
1030+
def collect(**kwargs):
1031+
return kwargs
1032+
mapping = {"a": 1}
1033+
try:
1034+
assert collect(**mapping, b=identity(mapping := {"a": 9})) == {"a": 1, "b": {"a": 9}}
1035+
except AssertionError:
1036+
return "raised", mapping
1037+
return "passed", mapping
1038+
""")
1039+
9171040
def test_ifexp_branches_in_order(self) -> None:
9181041
"""Guard: the condition is evaluated before the selected branch."""
9191042
assert_evaluation_order("""

0 commit comments

Comments
 (0)