Skip to content

Commit 119e7d5

Browse files
fix(rewrite): freeze operands against any later side effect (#14820)
visit_operand() froze a name only when a walrus operator in a later operand targeted it. A call can rebind just as well, through global or nonlocal, and then the name -- still unhoisted, still read when the enclosing expression is assembled -- sees the new binding: count = 0 def bump(): global count count = 99 return 0 assert count == bump() # Python compares 0 == 0 and passes There is no way to tell from the assert which names a call might rebind, so _walrus_targets() becomes _can_rebind(): a name is frozen whenever anything that follows it can execute code at all. That sounds expensive and is not. An operand that was already hoisted needs no freeze, so `assert len(items) == expected` rewrites unchanged; only the bare-name-then-call shape gains one assignment. visit_BoolOp used the same pre-scan and is generalized with it, which leaves one rule in one place instead of two spellings of half of it. Closes the order-name-rebound-by-call group in the coverage matrix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 01d8827 commit 119e7d5

3 files changed

Lines changed: 84 additions & 24 deletions

File tree

‎changelog/14821.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed assertion rewriting reading an operand after a later call had rebound its name, so ``assert count == bump()`` compares the ``count`` Python would have compared even when ``bump()`` assigns to it via ``global`` or ``nonlocal``.

‎src/_pytest/assertion/rewrite.py‎

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -568,14 +568,19 @@ def traverse_node(node: ast.AST) -> Iterator[ast.AST]:
568568
yield from traverse_node(child)
569569

570570

571-
def _walrus_targets(nodes: Iterable[ast.expr]) -> set[str]:
572-
"""Return the names any walrus operator in *nodes* rebinds."""
573-
return {
574-
sub.target.id
571+
def _can_rebind(nodes: Iterable[ast.expr]) -> bool:
572+
"""Return whether evaluating *nodes* could rebind a name read before them.
573+
574+
A walrus operator rebinds its target outright. A call -- including the
575+
implicit one behind ``await`` -- can rebind anything it declares ``global``
576+
or ``nonlocal``, and there is no way to tell from here which names those
577+
are, so assume the worst.
578+
"""
579+
return any(
580+
isinstance(sub, ast.NamedExpr | ast.Call | ast.Await)
575581
for node in nodes
576582
for sub in ast.walk(node)
577-
if isinstance(sub, ast.NamedExpr)
578-
}
583+
)
579584

580585

581586
@functools.lru_cache(maxsize=1)
@@ -1002,34 +1007,32 @@ def visit_Name(self, name: ast.Name) -> tuple[ast.Name, str]:
10021007
def visit_operand(
10031008
self, operand: ast.expr, later: Sequence[ast.expr]
10041009
) -> tuple[ast.expr, str]:
1005-
"""Visit an operand, freezing it against walrus operators in *later*.
1010+
"""Visit an operand, freezing it against side effects in *later*.
10061011
10071012
Operands are rewritten into statements that run in source order, but
10081013
two of them stay unhoisted and are evaluated at the very end, when the
10091014
enclosing expression is assembled -- after everything that follows
10101015
them:
10111016
1012-
* a plain name, which a walrus operator in a later operand rebinds in
1013-
between, so the value used and the value reported would be the
1014-
post-walrus one;
1017+
* a plain name, whose binding anything in *later* may have changed by
1018+
then, so the value used and the value reported would be the new one;
10151019
* a walrus operator itself, which would then assign in the wrong
10161020
order, and be visible to the operands that were meant to precede it.
10171021
10181022
Either way Python evaluates the earlier operand first, so copy it into
10191023
a temporary here. A starred argument is unwrapped and rewrapped, its
10201024
value being subject to the same problem.
1025+
1026+
Only a name or a walrus can reach the check below: the visit_* methods
1027+
hoist what they build into a temporary and generic_visit assigns
1028+
whatever is left -- a literal included -- so anything else is already
1029+
ordered by the time it arrives here.
10211030
"""
10221031
specifiers = set(self.explanation_specifiers)
10231032
res, expl = self.visit(operand)
10241033
value = res.value if isinstance(res, ast.Starred) else res
1025-
if isinstance(value, ast.NamedExpr):
1026-
needs_freeze = bool(later)
1027-
else:
1028-
# Every other operand arrives as a temporary: the visit_* methods
1029-
# hoist what they build, and generic_visit assigns whatever is left
1030-
# -- a literal included -- so a name is all that can reach here.
1031-
assert isinstance(value, ast.Name)
1032-
needs_freeze = value.id in _walrus_targets(later)
1034+
assert isinstance(value, ast.NamedExpr | ast.Name)
1035+
needs_freeze = _can_rebind(later)
10331036
if needs_freeze:
10341037
snapshot = self.assign(value)
10351038
for key in set(self.explanation_specifiers) - specifiers:
@@ -1049,9 +1052,8 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
10491052
body = save = self.statements
10501053
fail_save = self.expl_stmts
10511054
levels = len(boolop.values) - 1
1052-
later_walrus_targets = [
1053-
_walrus_targets(boolop.values[idx + 1 :])
1054-
for idx in range(len(boolop.values))
1055+
later_can_rebind = [
1056+
_can_rebind(boolop.values[idx + 1 :]) for idx in range(len(boolop.values))
10551057
]
10561058
self.push_format_context()
10571059
# Process each operand, short-circuiting as needed.
@@ -1068,10 +1070,10 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
10681070
# as a condition or explanation reference:
10691071
# - NamedExpr (non-last): reusing the node re-evaluates the
10701072
# walrus expression including any side effects.
1071-
# - Name whose variable a later walrus overwrites: the
1072-
# explanation would show the post-walrus value.
1073+
# - Name whose binding a later operand may change: the
1074+
# explanation would show the value it ended up with.
10731075
needs_snapshot = (isinstance(v, ast.NamedExpr) and i < levels) or (
1074-
isinstance(v, ast.Name) and v.id in later_walrus_targets[i]
1076+
isinstance(v, ast.Name) and later_can_rebind[i]
10751077
)
10761078
if needs_snapshot:
10771079
snapshot = self.assign(ast.Name(res_var, ast.Load()))
@@ -1205,6 +1207,11 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
12051207
left_res, left_expl = self.visit_operand(comp.left, comp.comparators)
12061208
if isinstance(comp.left, ast.Compare | ast.BoolOp):
12071209
left_expl = f"({left_expl})"
1210+
if isinstance(left_res, ast.NamedExpr):
1211+
# visit_operand only freezes an operand something later can rebind,
1212+
# so a walrus with nothing but literals after it arrives raw -- and
1213+
# the comparison below would evaluate it a second time.
1214+
left_res = self.assign(left_res)
12081215
res_variables = [self.variable() for i in range(len(comp.ops))]
12091216
load_names: list[ast.expr] = [ast.Name(v, ast.Load()) for v in res_variables]
12101217
store_names = [ast.Name(v, ast.Store()) for v in res_variables]

‎testing/test_assertrewrite_coverage.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,6 +1201,58 @@ def collect(**kwargs):
12011201
return "passed", mapping
12021202
""")
12031203

1204+
def test_global_rebound_by_call_precedes_compare(self) -> None:
1205+
assert_evaluation_order("""
1206+
count = 0
1207+
1208+
def bump():
1209+
global count
1210+
count = 99
1211+
return 0
1212+
1213+
def check():
1214+
try:
1215+
assert count == bump()
1216+
except AssertionError:
1217+
return "raised", count
1218+
return "passed", count
1219+
""")
1220+
1221+
def test_nonlocal_rebound_by_call_precedes_compare(self) -> None:
1222+
assert_evaluation_order("""
1223+
def check():
1224+
value = 1
1225+
def bump():
1226+
nonlocal value
1227+
value = 99
1228+
return 1
1229+
try:
1230+
assert value == bump()
1231+
except AssertionError:
1232+
return "raised", value
1233+
return "passed", value
1234+
""")
1235+
1236+
def test_global_rebound_by_call_precedes_argument(self) -> None:
1237+
assert_evaluation_order("""
1238+
value = "a"
1239+
1240+
def bump():
1241+
global value
1242+
value = "b"
1243+
return "x"
1244+
1245+
def collect(*args):
1246+
return args
1247+
1248+
def check():
1249+
try:
1250+
assert collect(value, bump()) == ("a", "x")
1251+
except AssertionError:
1252+
return "raised", value
1253+
return "passed", value
1254+
""")
1255+
12041256
def test_method_lookup_precedes_arguments(self) -> None:
12051257
"""Guard: the bound method is looked up before the arguments run."""
12061258
assert_evaluation_order("""

0 commit comments

Comments
 (0)