Skip to content

Assertion rewriting does not short-circuit chained comparisons #14819

Description

@RonnyPfannschmidt

Python evaluates a comparison chain lazily: in a < b < c, if a < b is false, c is never evaluated. The assertion rewriter evaluates every comparator unconditionally, so a rewritten assert can call things Python would not call, and can fail with an unrelated exception instead of AssertionError.

def test_raises_the_wrong_error():
    assert 1 < 0 < 1 / 0

def test_calls_what_it_should_not():
    calls = []
    def boom():
        calls.append("boom")
        return 5
    try:
        assert 1 < 0 < boom()
    except AssertionError:
        pass
    assert calls == []

Both pass under --assert=plain and on unrewritten Python. Rewritten, the first raises ZeroDivisionError and the second finds calls == ["boom"].

The cause is in AssertionRewriter.visit_Compare: it walks the comparators in a loop, appending @py_assertN = <left> <op> <next> for each, and only then combines them with ast.BoolOp(ast.And(), ...). By the time the and runs, everything has already been evaluated.

This is the same family as #57 ("New assertion logic no longer follows Python short circuit logic"), which was fixed for and/orvisit_BoolOp nests each subsequent operand inside an ast.If on the previous result. Comparison chains never got the same treatment.

Tested on main (f306da747), Python 3.12.

Found while sweeping the rewriter for evaluation-order divergences alongside #14445.

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