Skip to content

fix(rewrite): prevent walrus operator double evaluation in assertions#14447

Open
RonnyPfannschmidt wants to merge 5 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:ronny/fix-14445-assert-walrus
Open

fix(rewrite): prevent walrus operator double evaluation in assertions#14447
RonnyPfannschmidt wants to merge 5 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:ronny/fix-14445-assert-walrus

Conversation

@RonnyPfannschmidt
Copy link
Copy Markdown
Member

Summary

Fixes #14445 — assertion rewriting evaluated walrus operator (:=) expressions multiple times, causing incorrect test results when the expression had side effects.

Root cause: The variables_overwrite mechanism stored NamedExpr AST nodes and re-evaluated them in subsequent assertions, in _call_reprcompare's results tuple, and in explanation formatting.

Fix: Remove variables_overwrite entirely and instead:

  • Keep walrus expressions in their natural evaluation position (preserving left-to-right order)
  • Reference the target variable in explanations instead of re-evaluating
  • Freeze conflicting left operands via assign() when a comparator walrus targets the same name
  • Capture BoolOp conditions in stable temps for the explanation path

Test plan

  • Issue reproducer passes (both test_walrus_in_assertion_basic and test_walrus_running_counter)
  • All 19 existing walrus/namedexpr tests pass
  • Full test_assertrewrite.py suite passes (118 tests; only pre-existing subprocess env failures excluded)
  • Added 3 regression tests in TestIssue14445
  • Pre-commit hooks pass (ruff, mypy, codespell)

Made with Cursor

Fixes pytest-dev#14445 - assertion rewriting evaluated NamedExpr (:=) expressions
multiple times, causing side effects to fire repeatedly.

The root cause was the `variables_overwrite` mechanism which stored and
re-evaluated NamedExpr AST nodes in subsequent assertions, in
`_call_reprcompare`'s results tuple, and in explanation formatting.

The fix:
- visit_NamedExpr: reference the target variable in explanations instead
  of re-evaluating the full expression
- visit_Compare: assign left-side NamedExpr to a temp before right-side
  hoisting; freeze left_res when a comparator walrus targets the same
  name; replace NamedExpr entries in `results` with target variables
- visit_BoolOp: capture short-circuit condition in a stable temp for the
  explanation path; remove walrus target rename logic
- visit_Call: remove variables_overwrite substitution (walrus now properly
  assigns to user variables in its natural evaluation position)
- Remove variables_overwrite, scope tracking, Sentinel class

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Sonnet 4 <claude@anthropic.com>
Copilot AI review requested due to automatic review settings May 8, 2026 08:38
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes assertion rewriting so walrus (:=) expressions are not evaluated multiple times, preventing side effects from running twice and producing incorrect rewritten-assert behavior (per #14445).

Changes:

  • Removes the prior variables_overwrite/scope-tracking mechanism and adjusts NamedExpr handling to avoid re-evaluation in explanations.
  • Updates BoolOp/Compare rewriting to stabilize conditions/operands for explanation formatting.
  • Adds new regression tests for walrus side-effect/double-evaluation cases and updates existing expected assertion output.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/_pytest/assertion/rewrite.py Refactors assertion-rewrite AST generation around NamedExpr, BoolOp, and Compare to avoid walrus re-evaluation.
testing/test_assertrewrite.py Updates expected assertion output and adds regression tests for #14445 scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/_pytest/assertion/rewrite.py Outdated
Comment thread src/_pytest/assertion/rewrite.py Outdated
Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Sonnet 4 <claude@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label May 8, 2026
RonnyPfannschmidt and others added 3 commits May 8, 2026 13:04
Add tests for two remaining walrus double-evaluation scenarios:
- Bare NamedExpr as BoolOp operand evaluated twice via condition check
- Same walrus target in chained comparison evaluated multiple times

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Sonnet 4 <claude@anthropic.com>
Use the already-assigned res_var to build the short-circuit condition
instead of the raw visitor result, preventing bare NamedExpr operands
from being evaluated a second time when checking truthiness.

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Sonnet 4 <claude@anthropic.com>
In a chained comparison like `(x := f()) < (x := g()) < (x := h())`,
each NamedExpr comparator is now assigned to a temp variable so it
evaluates exactly once. Previously the raw NamedExpr node would be
reused as left_res in the next iteration, causing double evaluation.

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Sonnet 4 <claude@anthropic.com>
@RonnyPfannschmidt RonnyPfannschmidt added the backport 9.0.x apply to PRs at any point; backports the changes to the 9.0.x branch label May 8, 2026
@RonnyPfannschmidt RonnyPfannschmidt mentioned this pull request May 9, 2026
7 tasks
Copy link
Copy Markdown
Member

@Pierre-Sassoulas Pierre-Sassoulas left a comment

Choose a reason for hiding this comment

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

LGTM, but we probably want another person to look at it

Copy link
Copy Markdown
Member

@bluetech bluetech left a comment

Choose a reason for hiding this comment

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

I haven't reviewed the code yet, before that I dumped the rewritten AST before/after this PR, on the following example:

def side_effect():
    return True

def test_walrus_boolop():
    assert (x := side_effect())
Before
Module(
    body=[
        Import(
            names=[
                alias(name='builtins', asname='@py_builtins')]),
        Import(
            names=[
                alias(name='_pytest.assertion.rewrite', asname='@pytest_ar')]),
        FunctionDef(
            name='side_effect',
            args=arguments(),
            body=[
                Return(
                    value=Constant(value=True))]),
        FunctionDef(
            name='test_walrus_boolop',
            args=arguments(),
            body=[
                If(
                    test=UnaryOp(
                        op=Not(),
                        operand=NamedExpr(
                            target=Name(id='x', ctx=Store()),
                            value=Call(
                                func=Name(id='side_effect', ctx=Load())))),
                    body=[
                        Assign(
                            targets=[
                                Name(id='@py_format1', ctx=Store())],
                            value=BinOp(
                                left=BinOp(
                                    left=Constant(value=''),
                                    op=Add(),
                                    right=Constant(value='assert %(py0)s')),
                                op=Mod(),
                                right=Dict(
                                    keys=[
                                        Constant(value='py0')],
                                    values=[
                                        IfExp(
                                            test=BoolOp(
                                                op=Or(),
                                                values=[
                                                    Compare(
                                                        left=Constant(value='x'),
                                                        ops=[
                                                            In()],
                                                        comparators=[
                                                            Call(
                                                                func=Attribute(
                                                                    value=Name(id='@py_builtins', ctx=Load()),
                                                                    attr='locals',
                                                                    ctx=Load()))]),
                                                    Call(
                                                        func=Attribute(
                                                            value=Name(id='@pytest_ar', ctx=Load()),
                                                            attr='_should_repr_global_name',
                                                            ctx=Load()),
                                                        args=[
                                                            NamedExpr(
                                                                target=Name(id='x', ctx=Store()),
                                                                value=Call(
                                                                    func=Name(id='side_effect', ctx=Load())))])]),
                                            body=Call(
                                                func=Attribute(
                                                    value=Name(id='@pytest_ar', ctx=Load()),
                                                    attr='_saferepr',
                                                    ctx=Load()),
                                                args=[
                                                    NamedExpr(
                                                        target=Name(id='x', ctx=Store()),
                                                        value=Call(
                                                            func=Name(id='side_effect', ctx=Load())))]),
                                            orelse=Constant(value='x'))]))),
                        Raise(
                            exc=Call(
                                func=Name(id='AssertionError', ctx=Load()),
                                args=[
                                    Call(
                                        func=Attribute(
                                            value=Name(id='@pytest_ar', ctx=Load()),
                                            attr='_format_explanation',
                                            ctx=Load()),
                                        args=[
                                            Name(id='@py_format1', ctx=Load())])]))])])])
After
Module(
    body=[
        Import(
            names=[
                alias(name='builtins', asname='@py_builtins')]),
        Import(
            names=[
                alias(name='_pytest.assertion.rewrite', asname='@pytest_ar')]),
        FunctionDef(
            name='side_effect',
            args=arguments(),
            body=[
                Return(
                    value=Constant(value=True))]),
        FunctionDef(
            name='test_walrus_boolop',
            args=arguments(),
            body=[
                If(
                    test=UnaryOp(
                        op=Not(),
                        operand=NamedExpr(
                            target=Name(id='x', ctx=Store()),
                            value=Call(
                                func=Name(id='side_effect', ctx=Load())))),
                    body=[
                        Assign(
                            targets=[
                                Name(id='@py_format1', ctx=Store())],
                            value=BinOp(
                                left=BinOp(
                                    left=Constant(value=''),
                                    op=Add(),
                                    right=Constant(value='assert %(py0)s')),
                                op=Mod(),
                                right=Dict(
                                    keys=[
                                        Constant(value='py0')],
                                    values=[
                                        IfExp(
                                            test=BoolOp(
                                                op=Or(),
                                                values=[
                                                    Compare(
                                                        left=Constant(value='x'),
                                                        ops=[
                                                            In()],
                                                        comparators=[
                                                            Call(
                                                                func=Attribute(
                                                                    value=Name(id='@py_builtins', ctx=Load()),
                                                                    attr='locals',
                                                                    ctx=Load()))]),
                                                    Call(
                                                        func=Attribute(
                                                            value=Name(id='@pytest_ar', ctx=Load()),
                                                            attr='_should_repr_global_name',
                                                            ctx=Load()),
                                                        args=[
                                                            Name(id='x', ctx=Load())])]),
                                            body=Call(
                                                func=Attribute(
                                                    value=Name(id='@pytest_ar', ctx=Load()),
                                                    attr='_saferepr',
                                                    ctx=Load()),
                                                args=[
                                                    Name(id='x', ctx=Load())]),
                                            orelse=Constant(value='x'))]))),
                        Raise(
                            exc=Call(
                                func=Name(id='AssertionError', ctx=Load()),
                                args=[
                                    Call(
                                        func=Attribute(
                                            value=Name(id='@pytest_ar', ctx=Load()),
                                            attr='_format_explanation',
                                            ctx=Load()),
                                        args=[
                                            Name(id='@py_format1', ctx=Load())])]))])])])

The diff is:

@@ -57,20 +57,14 @@
                                                             attr='_should_repr_global_name',
                                                             ctx=Load()),
                                                         args=[
-                                                            NamedExpr(
-                                                                target=Name(id='x', ctx=Store()),
-                                                                value=Call(
-                                                                    func=Name(id='side_effect', ctx=Load())))])]),
+                                                            Name(id='x', ctx=Load())])]),
                                             body=Call(
                                                 func=Attribute(
                                                     value=Name(id='@pytest_ar', ctx=Load()),
                                                     attr='_saferepr',
                                                     ctx=Load()),
                                                 args=[
-                                                    NamedExpr(
-                                                        target=Name(id='x', ctx=Store()),
-                                                        value=Call(
-                                                            func=Name(id='side_effect', ctx=Load())))]),
+                                                    Name(id='x', ctx=Load())]),
                                             orelse=Constant(value='x'))]))),
                         Raise(
                             exc=Call(

This looks good for the issue, since now we no longer run side_effect twice three times.

However if I tweak in this way:

def side_effect():
    return True

def test_walrus_boolop():
    assert (x := side_effect()) and (x := False)

the assertion is

x.py:5: in test_walrus_boolop
    assert (x := side_effect()) and (x := False)
E   assert (False and False)

which is incorrect (should be assert (True and False)). That said, this also happens in main.

Let me know if you want to tackle this problem in this PR as well, in which I'll wait before reviewing, or if I should open a separate issue for that and review this PR as is.

@RonnyPfannschmidt
Copy link
Copy Markdown
Member Author

good find, i'll address it in here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport 9.0.x apply to PRs at any point; backports the changes to the 9.0.x branch bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Walrus expression duplicate evaluation failures with rewrite

4 participants