Skip to content

Assertion rewriting reorders an operand when a later call rebinds its name #14820

Description

@RonnyPfannschmidt

The rewriter hoists sub-expressions into statements, but leaves a plain name as a bare load evaluated when the enclosing expression is assembled — that is, after the statements belonging to the operands that follow it. If one of those rebinds the name, the earlier operand sees a value Python would never have given it.

count = 0

def bump():
    global count
    count = 99
    return 0

def test_left_operand_is_read_too_late():
    assert count == bump()

Python evaluates the left operand first, so this compares 0 == 0 and passes. Rewritten, it compares 99 == 0 and fails. Same with nonlocal, and in argument position:

value = "a"

def bump():
    global value
    value = "b"
    return "x"

def test_earlier_argument_is_read_too_late():
    assert (value, bump()) == ("a", "x")

Both pass under --assert=plain.

#14447 fixes this class for the walrus operator, via a visit_operand() helper that freezes an operand into a temporary when a later operand rebinds its name. The detection is _walrus_targets(), which scans for ast.NamedExpr — so a function that rebinds through global/nonlocal is invisible to it.

The conservative fix is to freeze a bare name whenever anything later can execute arbitrary code (Call, Await, NamedExpr) rather than only when a walrus targets it. It is cheaper than it sounds: an operand that is already hoisted needs no freeze, so assert len(items) == expected is unchanged; only the bare-name-followed-by-a-call shape gains one assignment.

Tested on main (f306da747), Python 3.12. Related: #14445, #14819.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions