Skip to content

Commit cdc4b90

Browse files
fix(rewrite): short-circuit chained comparisons (#14819)
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 and only combined the results with `and` afterwards, by which time everything had already run: assert 1 < 0 < 1 / 0 # ZeroDivisionError, not AssertionError Each link past the first now goes inside an `if` on the link before it, the same shape visit_BoolOp has used since #57 was fixed for and/or. The failure path builds a tuple of every link's result and every operand, so the temporaries belonging to links that never ran are set to None ahead of the chain rather than left unbound. None is falsey, which is also what _call_reprcompare wants: it stops at the first falsey result, and that is the link that actually failed. Closes the order-chained-compare-lazy group in the coverage matrix.
1 parent 20d0b1c commit cdc4b90

3 files changed

Lines changed: 27 additions & 9 deletions

File tree

changelog/14822.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Chained comparisons in an ``assert`` now short-circuit the way Python does: in ``assert a < b < c``, ``c`` is no longer evaluated when ``a < b`` is false. Previously ``assert 1 < 0 < 1 / 0`` raised ``ZeroDivisionError`` instead of ``AssertionError``, and a call in an unreached position ran anyway.

src/_pytest/assertion/rewrite.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,7 +1191,22 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
11911191
expls: list[ast.expr] = []
11921192
syms: list[ast.expr] = []
11931193
results = [left_res]
1194+
# A chain short-circuits: ``a < b < c`` leaves c unevaluated when a < b
1195+
# is false. Everything past the first link therefore goes inside an
1196+
# ``if`` on the link before it, and the temporaries it would have
1197+
# produced are set to None up front -- the failure path builds a tuple
1198+
# of all of them, and the ones that never ran must still be readable.
1199+
# None is also falsey, so _call_reprcompare still stops at the link
1200+
# that actually failed.
1201+
body = self.statements
1202+
deferred_at = deferred_from = None
11941203
for i, op, next_operand in it:
1204+
if i:
1205+
if deferred_at is None:
1206+
deferred_at, deferred_from = len(body), len(self.variables)
1207+
inner: list[ast.stmt] = []
1208+
self.statements.append(ast.If(load_names[i - 1], inner, []))
1209+
self.statements = inner
11951210
next_res, next_expl = self.visit_operand(
11961211
next_operand, comp.comparators[i + 1 :]
11971212
)
@@ -1207,6 +1222,17 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
12071222
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
12081223
self.statements.append(ast.Assign([store_names[i]], res_expr))
12091224
left_res, left_expl = next_res, next_expl
1225+
self.statements = body
1226+
if deferred_at is not None:
1227+
assert deferred_from is not None
1228+
deferred = [*res_variables[1:], *self.variables[deferred_from:]]
1229+
body.insert(
1230+
deferred_at,
1231+
ast.Assign(
1232+
[ast.Name(name, ast.Store()) for name in deferred],
1233+
ast.Constant(None),
1234+
),
1235+
)
12101236
# Use pytest.assertion.util._reprcompare if that's available.
12111237
expl_call = self.helper(
12121238
"_call_reprcompare",

testing/test_assertrewrite_coverage.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
order-call-argument an earlier argument is read too late
2222
order-binop-left a binary operator's left operand is read too late
2323
order-starred-argument a starred argument is read too late
24-
order-chained-compare-lazy a chained comparison evaluates every comparator
2524
2625
The xfails are strict on purpose: fixing the behaviour without flipping the
2726
marker fails the suite. A change may remove entries from that list, never add
@@ -1297,10 +1296,6 @@ def check():
12971296
return "passed", value
12981297
""")
12991298

1300-
@pytest.mark.xfail(
1301-
strict=True,
1302-
reason="order-chained-compare-lazy: #14819, every comparator is evaluated",
1303-
)
13041299
def test_chained_compare_stops_at_the_first_false(self) -> None:
13051300
assert_evaluation_order("""
13061301
def check():
@@ -1315,10 +1310,6 @@ def rec(label, value):
13151310
return "passed", trace
13161311
""")
13171312

1318-
@pytest.mark.xfail(
1319-
strict=True,
1320-
reason="order-chained-compare-lazy: #14819, an unreached comparator still raises",
1321-
)
13221313
def test_chained_compare_unreached_operand_does_not_raise(self) -> None:
13231314
assert_evaluation_order("""
13241315
def check():

0 commit comments

Comments
 (0)