Skip to content

Commit bd69dff

Browse files
fix(rewrite): guard a chained comparison's explanation too
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>
1 parent 86a550b commit bd69dff

2 files changed

Lines changed: 137 additions & 11 deletions

File tree

src/_pytest/assertion/rewrite.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,25 @@ def _can_rebind(nodes: Iterable[ast.expr]) -> bool:
545545
)
546546

547547

548+
def _rewriter_temporaries(nodes: Iterable[ast.AST]) -> list[str]:
549+
"""Return the names the rewriter binds inside *nodes*, in creation order.
550+
551+
Only its own: a walrus target inside a conditional block belongs to the
552+
user, and Python leaves it unbound when the block does not run. Binding it
553+
to None to make it readable would be visible after the assertion.
554+
"""
555+
names: dict[str, None] = {}
556+
for node in nodes:
557+
for sub in ast.walk(node):
558+
if (
559+
isinstance(sub, ast.Name)
560+
and isinstance(sub.ctx, ast.Store)
561+
and sub.id.startswith("@py")
562+
):
563+
names[sub.id] = None
564+
return list(names)
565+
566+
548567
@functools.lru_cache(maxsize=1)
549568
def _get_assertion_exprs(src: bytes) -> dict[int, str]:
550569
"""Return a mapping from {lineno: "assertion test expression"}."""
@@ -951,7 +970,20 @@ def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.NamedExpr, str]:
951970
target_id = name.target.id
952971
target_name = ast.Name(target_id, ast.Load())
953972
inlocs = ast.Compare(ast.Constant(target_id), [ast.In()], [locs])
954-
dorepr = self.helper("_should_repr_global_name", target_name)
973+
# Unlike visit_Name, the target need not be bound when this runs: the
974+
# walrus may sit in a branch that short-circuited away, and then the
975+
# explanation is formatted for an assignment that never happened.
976+
# Reading it to decide how to show it would raise instead -- so ask
977+
# whether it exists as a global before passing it to a helper.
978+
inglobals = ast.Compare(
979+
ast.Constant(target_id),
980+
[ast.In()],
981+
[ast.Call(self.builtin("globals"), [], [])],
982+
)
983+
dorepr = ast.BoolOp(
984+
ast.And(),
985+
[inglobals, self.helper("_should_repr_global_name", target_name)],
986+
)
955987
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
956988
expr = ast.IfExp(test, self.display(target_name), ast.Constant(target_id))
957989
return name, self.explanation_param(expr)
@@ -1183,20 +1215,32 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
11831215
results = [left_res]
11841216
# A chain short-circuits: ``a < b < c`` leaves c unevaluated when a < b
11851217
# is false. Everything past the first link therefore goes inside an
1186-
# ``if`` on the link before it, and the temporaries it would have
1187-
# produced are set to None up front -- the failure path builds a tuple
1188-
# of all of them, and the ones that never ran must still be readable.
1189-
# None is also falsey, so _call_reprcompare still stops at the link
1190-
# that actually failed.
1218+
# ``if`` on the link before it -- both what evaluates the link and what
1219+
# explains it, because the explanation reads what the evaluation bound.
1220+
# The temporaries a skipped link would have produced are set to None up
1221+
# front, so the failure path can still read them: it builds a tuple of
1222+
# every link's result, and formats every link's explanation. None is
1223+
# also falsey, so _call_reprcompare still stops at the link that
1224+
# actually failed, and the entries behind it are never rendered.
11911225
body = self.statements
1192-
deferred_at = deferred_from = None
1226+
fail_save = self.expl_stmts
1227+
deferred_at = None
1228+
deferred_stmt_ifs: list[ast.If] = []
1229+
deferred_expl_ifs: list[tuple[list[ast.stmt], ast.If]] = []
11931230
for i, op, next_operand in it:
11941231
if i:
11951232
if deferred_at is None:
1196-
deferred_at, deferred_from = len(body), len(self.variables)
1233+
deferred_at = len(body)
11971234
inner: list[ast.stmt] = []
1198-
self.statements.append(ast.If(load_names[i - 1], inner, []))
1235+
stmt_if = ast.If(load_names[i - 1], inner, [])
1236+
deferred_stmt_ifs.append(stmt_if)
1237+
self.statements.append(stmt_if)
11991238
self.statements = inner
1239+
fail_inner: list[ast.stmt] = []
1240+
deferred_expl_ifs.append(
1241+
(self.expl_stmts, ast.If(load_names[i - 1], fail_inner, []))
1242+
)
1243+
self.expl_stmts = fail_inner
12001244
next_res, next_expl = self.visit_operand(
12011245
next_operand, comp.comparators[i + 1 :]
12021246
)
@@ -1213,9 +1257,24 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
12131257
self.statements.append(ast.Assign([store_names[i]], res_expr))
12141258
left_res, left_expl = next_res, next_expl
12151259
self.statements = body
1260+
self.expl_stmts = fail_save
1261+
# Attach the explanation guards innermost first, dropping the ones that
1262+
# stayed empty -- most links explain themselves in the format context
1263+
# alone and contribute no statements, and an ``if`` with an empty body
1264+
# is not valid syntax. Attaching a child fills its parent, so the
1265+
# parent is only known to be empty once the child has been placed.
1266+
for parent, fail_if in reversed(deferred_expl_ifs):
1267+
if fail_if.body:
1268+
parent.append(fail_if)
12161269
if deferred_at is not None:
1217-
assert deferred_from is not None
1218-
deferred = [*res_variables[1:], *self.variables[deferred_from:]]
1270+
deferred = dict.fromkeys(
1271+
[
1272+
*res_variables[1:],
1273+
*_rewriter_temporaries(
1274+
[*deferred_stmt_ifs, *(f for _, f in deferred_expl_ifs)]
1275+
),
1276+
]
1277+
)
12191278
body.insert(
12201279
deferred_at,
12211280
ast.Assign(

testing/test_assertrewrite_coverage.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,6 +1277,73 @@ def check():
12771277
return "passed", None
12781278
""")
12791279

1280+
def test_chained_compare_skipped_boolop_operand(self) -> None:
1281+
"""A boolop in a skipped link builds its explanation in that link."""
1282+
assert_evaluation_order("""
1283+
def check():
1284+
a = b = 0
1285+
try:
1286+
assert 1 < 0 < (a or b)
1287+
except AssertionError:
1288+
return "raised", None
1289+
return "passed", None
1290+
""")
1291+
1292+
def test_chained_compare_skipped_operand_reads_no_name(self) -> None:
1293+
"""Nor may it read the names that operand would have read."""
1294+
assert_evaluation_order("""
1295+
def check():
1296+
try:
1297+
assert 1 < 0 < (missing or 0)
1298+
except AssertionError:
1299+
return "raised", None
1300+
return "passed", None
1301+
""")
1302+
1303+
def test_chained_compare_skipped_walrus_stays_unbound(self) -> None:
1304+
"""A skipped walrus assigns nothing -- the explanation may not either."""
1305+
assert_evaluation_order("""
1306+
def check():
1307+
try:
1308+
assert 1 < 0 < (w := 1)
1309+
except AssertionError:
1310+
return "raised", "w" in locals()
1311+
return "passed", "w" in locals()
1312+
""")
1313+
1314+
def test_chained_compare_skipped_walrus_inside_call(self) -> None:
1315+
assert_evaluation_order("""
1316+
def check():
1317+
def identity(v):
1318+
return v
1319+
try:
1320+
assert 1 < 0 < identity(w := 1)
1321+
except AssertionError:
1322+
return "raised", "w" in locals()
1323+
return "passed", "w" in locals()
1324+
""")
1325+
1326+
def test_chained_compare_reports_the_link_that_failed(self) -> None:
1327+
"""The skipped link contributes nothing to the message either."""
1328+
assert_introspects(
1329+
"""
1330+
def check():
1331+
a = b = 0
1332+
assert 1 < 0 < (a or b)
1333+
""",
1334+
must_contain=["assert 1 < 0"],
1335+
must_not_contain=["None"],
1336+
)
1337+
1338+
def test_chained_compare_reports_a_taken_walrus_link(self) -> None:
1339+
assert_introspects(
1340+
"""
1341+
def check():
1342+
assert 0 < 1 < (w := 5) < 2
1343+
""",
1344+
must_contain=["5"],
1345+
)
1346+
12801347
def test_method_lookup_precedes_arguments(self) -> None:
12811348
"""Guard: the bound method is looked up before the arguments run."""
12821349
assert_evaluation_order("""

0 commit comments

Comments
 (0)