Skip to content

Commit 8cb7a4a

Browse files
fix(rewrite): short-circuit chained comparisons (pytest-dev#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 edc350c commit 8cb7a4a

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
@@ -1193,7 +1193,22 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
11931193
expls: list[ast.expr] = []
11941194
syms: list[ast.expr] = []
11951195
results = [left_res]
1196+
# A chain short-circuits: ``a < b < c`` leaves c unevaluated when a < b
1197+
# is false. Everything past the first link therefore goes inside an
1198+
# ``if`` on the link before it, and the temporaries it would have
1199+
# produced are set to None up front -- the failure path builds a tuple
1200+
# of all of them, and the ones that never ran must still be readable.
1201+
# None is also falsey, so _call_reprcompare still stops at the link
1202+
# that actually failed.
1203+
body = self.statements
1204+
deferred_at = deferred_from = None
11961205
for i, op, next_operand in it:
1206+
if i:
1207+
if deferred_at is None:
1208+
deferred_at, deferred_from = len(body), len(self.variables)
1209+
inner: list[ast.stmt] = []
1210+
self.statements.append(ast.If(load_names[i - 1], inner, []))
1211+
self.statements = inner
11971212
next_res, next_expl = self.visit_operand(
11981213
next_operand, comp.comparators[i + 1 :]
11991214
)
@@ -1209,6 +1224,17 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
12091224
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
12101225
self.statements.append(ast.Assign([store_names[i]], res_expr))
12111226
left_res, left_expl = next_res, next_expl
1227+
self.statements = body
1228+
if deferred_at is not None:
1229+
assert deferred_from is not None
1230+
deferred = [*res_variables[1:], *self.variables[deferred_from:]]
1231+
body.insert(
1232+
deferred_at,
1233+
ast.Assign(
1234+
[ast.Name(name, ast.Store()) for name in deferred],
1235+
ast.Constant(None),
1236+
),
1237+
)
12121238
# Use pytest.assertion.util._reprcompare if that's available.
12131239
expl_call = self.helper(
12141240
"_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
@@ -1335,10 +1334,6 @@ def check():
13351334
return "passed", value
13361335
""")
13371336

1338-
@pytest.mark.xfail(
1339-
strict=True,
1340-
reason="order-chained-compare-lazy: #14819, every comparator is evaluated",
1341-
)
13421337
def test_chained_compare_stops_at_the_first_false(self) -> None:
13431338
assert_evaluation_order("""
13441339
def check():
@@ -1353,10 +1348,6 @@ def rec(label, value):
13531348
return "passed", trace
13541349
""")
13551350

1356-
@pytest.mark.xfail(
1357-
strict=True,
1358-
reason="order-chained-compare-lazy: #14819, an unreached comparator still raises",
1359-
)
13601351
def test_chained_compare_unreached_operand_does_not_raise(self) -> None:
13611352
assert_evaluation_order("""
13621353
def check():

0 commit comments

Comments
 (0)