Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions changelog/14445.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
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).

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.
1 change: 1 addition & 0 deletions changelog/14814.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed assertion rewriting evaluating a walrus operator (``:=``) or a starred argument out of order when a later argument assigned to the same name, so ``assert collect(*items, identity(items := [9]))`` now passes the pre-assignment ``items``.
4 changes: 4 additions & 0 deletions changelog/14815.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Assertion failure messages now decompose subscript expressions, showing the container and the key that produced a value::

assert 1 == 99
+ where 1 = {'a': 1, 'b': 2}['a']
4 changes: 4 additions & 0 deletions changelog/14816.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Assertion failure messages now show the condition of a conditional expression::

assert 0 == 99
+ where 0 = (... if True else ...)
4 changes: 4 additions & 0 deletions changelog/14817.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Assertion failure messages now show a method call on a single line, without the bound method as a separate intermediate::

assert 42 == 100
+ where 42 = Obj().compute()
222 changes: 142 additions & 80 deletions src/_pytest/assertion/rewrite.py

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions testing/python/raises_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -1237,11 +1237,10 @@ def test_assert_matches() -> None:
match=wrap_escape(
"`ValueError()` is not an instance of `TypeError`\n"
"assert False\n"
" + where False = matches(ValueError())\n"
" + where matches = RaisesExc(TypeError).matches"
" + where False = RaisesExc(TypeError).matches(ValueError())"
),
):
# you'd need to do this arcane incantation
# binding the RaisesExc is still how you get at ``fail_reason``
assert (m := RaisesExc(TypeError)).matches(e), m.fail_reason

# but even if we add assert_matches, will people remember to use it?
Expand Down
244 changes: 63 additions & 181 deletions testing/test_assertrewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -1637,190 +1637,29 @@ def test_simple_failure():
result.stdout.fnmatch_lines(["*E*assert (1 + 1) == 3"])


class TestAssertionRewriteWalrusOperator:
"""See #10743"""

def test_assertion_walrus_operator(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def my_func(before, after):
return before == after

def change_value(value):
return value.lower()

def test_walrus_conversion():
a = "Hello"
assert not my_func(a, a := change_value(a))
assert a == "hello"
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_walrus_operator_dont_rewrite(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
'PYTEST_DONT_REWRITE'
def my_func(before, after):
return before == after

def change_value(value):
return value.lower()

def test_walrus_conversion_dont_rewrite():
a = "Hello"
assert not my_func(a, a := change_value(a))
assert a == "hello"
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_inline_walrus_operator(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def my_func(before, after):
return before == after

def test_walrus_conversion_inline():
a = "Hello"
assert not my_func(a, a := a.lower())
assert a == "hello"
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_inline_walrus_operator_reverse(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def my_func(before, after):
return before == after

def test_walrus_conversion_reverse():
a = "Hello"
assert my_func(a := a.lower(), a)
assert a == 'hello'
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_walrus_no_variable_name_conflict(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_walrus_conversion_no_conflict():
a = "Hello"
assert a == (b := a.lower())
"""
)
result = pytester.runpytest()
assert result.ret == 1
result.stdout.fnmatch_lines(["*AssertionError: assert 'Hello' == 'hello'"])

def test_assertion_walrus_operator_true_assertion_and_changes_variable_value(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_walrus_conversion_succeed():
a = "Hello"
assert a != (a := a.lower())
assert a == 'hello'
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_walrus_operator_fail_assertion(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
def test_walrus_conversion_fails():
a = "Hello"
assert a == (a := a.lower())
"""
)
result = pytester.runpytest()
assert result.ret == 1
result.stdout.fnmatch_lines(["*AssertionError: assert 'Hello' == 'hello'"])

def test_assertion_walrus_operator_boolean_composite(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_walrus_operator_change_boolean_value():
a = True
assert a and True and ((a := False) is False) and (a is False) and ((a := None) is None)
assert a is None
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_walrus_operator_compare_boolean_fails(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_walrus_operator_change_boolean_value():
a = True
assert not (a and ((a := False) is False))
"""
)
result = pytester.runpytest()
assert result.ret == 1
result.stdout.fnmatch_lines(["*assert not (True and False is False)"])
def test_walrus_rebinding_does_not_outlive_its_statement(
pytester: Pytester,
) -> None:
"""A walrus target must not be rebound by a later, unrelated assertion.

def test_assertion_walrus_operator_boolean_none_fails(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_walrus_operator_change_boolean_value():
a = True
assert not (a and ((a := None) is None))
The rest of #10743's suite moved to the coverage matrix, which runs
in-process. This one stays here because it needs two tests in one module
to say anything: the rewriter may keep no state that survives a statement,
let alone a test. The matrix cannot express that.
"""
pytester.makepyfile(
"""
)
result = pytester.runpytest()
assert result.ret == 1
result.stdout.fnmatch_lines(["*assert not (True and None is None)"])
def test_walrus_operator_change_value():
a = True
assert (a := None) is None

def test_assertion_walrus_operator_value_changes_cleared_after_each_test(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_walrus_operator_change_value():
a = True
assert (a := None) is None

def test_walrus_operator_not_override_value():
a = True
assert a is True
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_assertion_namedexpr_compare_left_overwrite(
self, pytester: Pytester
) -> None:
pytester.makepyfile(
"""
def test_namedexpr_compare_left_overwrite():
a = "Hello"
b = "World"
c = "Test"
assert (a := b) == c and (a := "Test") == "Test"
"""
)
result = pytester.runpytest()
assert result.ret == 1
result.stdout.fnmatch_lines(["*assert ('World' == 'Test'*"])
def test_walrus_operator_not_override_value():
a = True
assert a is True
"""
)
result = pytester.runpytest()
assert result.ret == 0


class TestIssue11028:
Expand Down Expand Up @@ -1931,6 +1770,49 @@ def test_2():
assert result.ret == 0


class TestIssue14445:
"""Regression tests for #14445: walrus operator double evaluation."""

def test_walrus_no_double_eval_basic(self, pytester: Pytester) -> None:
"""Walrus captures the value at assignment time, not re-evaluated later."""
pytester.makepyfile(
"""
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1

def test_walrus_in_assertion_basic():
c = Counter()
assert (before := c.value) == 0
c.increment()
assert before != (after := c.value)
"""
)
result = pytester.runpytest()
assert result.ret == 0

def test_walrus_no_double_eval_running_counter(self, pytester: Pytester) -> None:
"""Walrus increments fire exactly once per assert statement."""
pytester.makepyfile(
"""
def test_walrus_running_counter():
count = 0
items = []
items.append("a")
assert (count := count + 1) == len(items)
items.append("b")
assert (count := count + 1) == len(items)
items.append("c")
assert (count := count + 1) == len(items)
assert count == 3
"""
)
result = pytester.runpytest()
assert result.ret == 0


@pytest.mark.skipif(
sys.maxsize <= (2**31 - 1), reason="Causes OverflowError on 32bit systems"
)
Expand Down
Loading
Loading