Short-circuit comparison chains in rewritten asserts - #14918
Conversation
Python evaluates a comparison chain lazily: in a < b < c, c is never evaluated when a < b is false. visit_Compare walked the comparators in a loop, assigning every result, and only combined them with an and afterwards. By then everything had already run, so a rewritten assert could call what Python would not, and could fail with an unrelated exception instead of AssertionError. Nest each comparison after the first inside an if on the previous result, which is what visit_BoolOp already does for and/or since pytest-dev#57. The explanation builds its arguments eagerly, so a skipped operand would leave an unbound name behind. Everything a skipped operand would have bound is set to None before the chain. Nothing reads those values: _call_reprcompare stops at the first false result, and a chain only short-circuits after one. Closes pytest-dev#14819. Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
An operand can contribute statements that run only when the assertion fails: a nested boolop appends to an explanation list built in the main statement body. Nesting only the main statements left those appends running unconditionally, against a list the skipped operand never bound, so assert 1 < 0 < (a or b) raised AttributeError on None. Nest expl_stmts on the same condition. Most operands add nothing there, and an If with an empty body is not valid ast, so the empty ones are dropped afterwards, innermost first: pruning a child can empty its parent. Collect the names to pre-bind by walking the conditional blocks for Store targets, rather than tracking self.variables and format_variables by index. That picks up @py_format names too, which pop_format_context only records when the assertion_pass hook is enabled, and drops a branch that no test could reach. Co-authored-by: Claude <noreply@anthropic.com>
|
I moved this to draft for a few hours because I found a case my own change broke, and I would rather flag it than have a reviewer find it. Nesting only the main statement body was not enough. An operand can also contribute statements that run only when the assertion fails — a nested boolop builds its explanation by appending to a list that is created in the main body. With the list creation skipped and the appends left running unconditionally: def test_boolop_in_a_chain():
a = b = 0
assert 1 < 0 < (a or b)That is worse than the bug being fixed. The full suite did not catch it — there was no test for a boolop inside a comparison chain — so I found it by reading the generated code with Fixed by nesting
|
|
please crosscheck if this is already part of my stack of rewrite bugfixes/enhancements |
|
Sorry, I just checked and duplicated the result; I had some leftover GPT tokens and wanted to help out with computing power—I'm auditing and helping more effectively now. My apologies; you're doing great work, brother. |
|
Sorry, I just checked and duplicated the result; I had some leftover GPT tokens and wanted to help out with computing power—I'm auditing and helping more effectively now. My apologies; you're doing great work, brother. |
|
Cross-checked as you asked: #14822 already does this, from three weeks ago, and reaches the same shape. Closing in favour of your stack. One thing that came out of the comparison — I left the detail on #14822 — is that a boolop in a skipped comparator raises assert 1 < 0 < (a or b) # AttributeError: 'NoneType' object has no attribute 'append'
Sorry for the duplicate. I filtered issues on "no assignee, no comments" and #14819 is both, being your own — I should have looked for a PR referencing it before starting. |
Short-circuiting the chain nested the statements that evaluate a link
past the first, but not the statements that explain it, so the failure
branch ran a skipped link's explanation against temporaries the link
never assigned:
assert 1 < 0 < (a or b)
raised ``AttributeError: 'NoneType' object has no attribute 'append'``
instead of failing. visit_BoolOp builds its explanation by creating a
list in the main body and appending to it from expl_stmts; nesting only
the body left the list None while the appends ran unconditionally.
SirHegel found this against the branch and named the fix -- nest
expl_stmts on the same condition, the way visit_BoolOp nests both -- in
pytest-dev#14822 (comment),
having reduced it from his own pytest-dev#14918. What follows is his idea; two
details are worth recording.
Most links explain themselves in the format context alone and contribute
no statements, and an ``if`` with an empty body is not valid syntax, so
the guards are attached innermost first and the empty ones dropped --
attaching a child fills its parent, so a parent is only known to be empty
once its child has been placed.
The names to pre-bind now include the @py_format ones created inside
those guarded blocks, which the outer format context reads. Collecting
them by walking the blocks is what makes them reachable at all, but the
walk must not take everything it finds: a walrus target inside a skipped
link belongs to the user, and Python leaves it unbound. Binding it to
None to keep the explanation readable would be visible after the
assertion, so _rewriter_temporaries() takes only names the rewriter
itself makes.
That last point is a second failure mode, which the report did not
cover: the explanation of a walrus reads its target to decide how to show
it, and a skipped link never bound it.
assert 1 < 0 < (w := 1) # UnboundLocalError: 'w'
assert 1 < 0 < identity(w := 1) # likewise
Nesting does not reach it -- the read sits in the compare's own format
dict, which is built eagerly and belongs to no link -- and ``'w' in
locals()`` does not guard it, because the fallback hands the value to
_should_repr_global_name(). visit_NamedExpr now asks whether the target
is a global before reading it, and shows the bare name when it is
neither. An undefined name inside a skipped operand failed the same way
with NameError, and is fixed by the nesting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Short-circuiting the chain nested the statements that evaluate a link
past the first, but not the statements that explain it, so the failure
branch ran a skipped link's explanation against temporaries the link
never assigned:
assert 1 < 0 < (a or b)
raised ``AttributeError: 'NoneType' object has no attribute 'append'``
instead of failing. visit_BoolOp builds its explanation by creating a
list in the main body and appending to it from expl_stmts; nesting only
the body left the list None while the appends ran unconditionally.
SirHegel found this against the branch and named the fix -- nest
expl_stmts on the same condition, the way visit_BoolOp nests both -- in
pytest-dev#14822 (comment),
having reduced it from his own pytest-dev#14918. What follows is his idea; two
details are worth recording.
Most links explain themselves in the format context alone and contribute
no statements, and an ``if`` with an empty body is not valid syntax, so
the guards are attached innermost first and the empty ones dropped --
attaching a child fills its parent, so a parent is only known to be empty
once its child has been placed.
The names to pre-bind now include the @py_format ones created inside
those guarded blocks, which the outer format context reads. Collecting
them by walking the blocks is what makes them reachable at all, but the
walk must not take everything it finds: a walrus target inside a skipped
link belongs to the user, and Python leaves it unbound. Binding it to
None to keep the explanation readable would be visible after the
assertion, so _rewriter_temporaries() takes only names the rewriter
itself makes.
That last point is a second failure mode, which the report did not
cover: the explanation of a walrus reads its target to decide how to show
it, and a skipped link never bound it.
assert 1 < 0 < (w := 1) # UnboundLocalError: 'w'
assert 1 < 0 < identity(w := 1) # likewise
Nesting does not reach it -- the read sits in the compare's own format
dict, which is built eagerly and belongs to no link -- and ``'w' in
locals()`` does not guard it, because the fallback hands the value to
_should_repr_global_name(). visit_NamedExpr now asks whether the target
is a global before reading it, and shows the bare name when it is
neither. An undefined name inside a skipped operand failed the same way
with NameError, and is fixed by the nesting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #14819.
The problem
Python evaluates a comparison chain lazily: in
a < b < c,cis never evaluated whena < bis false.visit_Comparewalked the comparators in a loop, assigning every result, and only combined them with anandafterwards — by which point everything had already run.Both pass under
--assert=plainand fail when rewritten.The change
Each comparison after the first is nested inside an
ifon the previous result — whatvisit_BoolOphas done forand/orsince #57. Forassert 1 < 0 < boom():The
andin the guard already short-circuits, soresis safe. The explanation is not: it builds its arguments eagerly, and a skipped operand would leave an unbound name for_safereprto read. Everything a skipped operand would have bound is set toNonebefore the chain.Nothing ever reads those
Nones._call_reprcomparebreaks at the first falsy result and reports only that pair — which is exactly where the chain stopped, since a chain only short-circuits after a false comparison. That is also why the messages do not change:assert 1 < 3 < 5 <= 4 < 7still reportsassert 5 <= 4.Notes for review
Noneassignment is inserted aftercomp.left's own statements, so a compound left operand is still evaluated exactly once and in place. Withassert len(v) < g() < h()the generated code keeps@py_assert2 = len(v)first and only callsh()inside the guard.format_variablesonly exists when thepytest_assertion_passhook is enabled, so it is read throughgetattr.Tests
test_comparison_chain_short_circuitscovers the wrong-exception case, the unwanted-call case, and that a chain which fails partway still reports the failing pair.test_comparisonsandtest_custom_reprcomparepin the existing messages and are unchanged.Full suite: 4359 passed, 97 skipped, 14 xfailed.
Checklist
Co-authored-bycommit trailers.changelogdirectory.AUTHORSin alphabetical order.Claude (Opus 5) was used; it is credited in the
Co-authored-bytrailers. I read the generated code withast.unparsebefore and after, and I can answer questions on any part of it.