fix: accept subscript and attribute targets in unpacking, for-loop, and comprehension contexts#473
Conversation
There was a problem hiding this comment.
1 issue found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/monty/test_cases/iter__for_loop_unpacking.py">
<violation number="1" location="crates/monty/test_cases/iter__for_loop_unpacking.py:79">
P2: Test for subscript index re-evaluation is non-discriminating and cannot detect incorrect caching</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| # Subscript target with computed index — index is re-evaluated each iteration | ||
| a = [0, 0] | ||
| i = 0 | ||
| for a[i] in (10, 20): |
There was a problem hiding this comment.
P2: Test for subscript index re-evaluation is non-discriminating and cannot detect incorrect caching
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty/test_cases/iter__for_loop_unpacking.py, line 79:
<comment>Test for subscript index re-evaluation is non-discriminating and cannot detect incorrect caching</comment>
<file context>
@@ -64,3 +64,33 @@
+# Subscript target with computed index — index is re-evaluated each iteration
+a = [0, 0]
+i = 0
+for a[i] in (10, 20):
+ # i never changes, so both iterations write the same slot
+ pass
</file context>
Merging this PR will not alter performance
Comparing Footnotes
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
e566b4f to
3de7147
Compare
|
Thanks @cubic-dev-ai — valid catch. The Fixed in 3de7147 by mutating The comprehension counterpart in Force-pushed: also trimmed verbose comments/docstrings throughout the patch per separate maintainer feedback. |
@mbeschastn0v Looks good — the discriminating test is clean. First iteration: The comprehension side makes sense too — no body means no place to mutate And the comment/docstring trim fits what you added to Tip: get faster answers by chatting with cubic’s review copilot in the review UI |
|
@samuelcolvin pub enum LeafAssignTarget {
Name(Identifier),
Subscript { target: ExprLoc, index: ExprLoc, target_position: CodeRange },
Attribute { object: ExprLoc, attr: EitherStr, target_position: CodeRange },
}
pub enum AssignTarget {
Leaf(LeafAssignTarget),
Unpack { targets: Vec<UnpackTarget>, targets_position: CodeRange },
}
pub enum UnpackTarget {
Leaf(LeafAssignTarget),
Tuple { targets: Vec<Self>, position: CodeRange },
Starred(Identifier),
}Wins: store-dispatch centralizes (one emit_leaf_store instead of three parallel match arms in compile_assign_target / compile_unpack_target / process_unpack_sim); collector helpers shrink similarly. The grammar split you encoded — Starred exclusive to unpack, Unpack exclusive to top-level assign — stays intact. |
CPython allows subscript (`x[i]`) and attribute (`o.a`) expressions as
targets inside tuple unpacking, `for`-loop variables, and comprehension
`for` clauses. Monty's `parse_unpack_target_impl` only accepted
`Name`/`Tuple`/`Starred`/`List` and rejected everything else with
`SyntaxError: invalid unpacking target: <kind>`, breaking common
patterns like `x[0], x[1] = x[1], x[0]`, `o.a, o.b = 1, 2`,
`for box[0] in iter:`, and `[... for box[0] in iter]`.
The `UnpackTarget` enum gains `Subscript { target, index, target_position }`
and `Attribute { object, attr, target_position }` leaves, mirroring the
existing `AssignTarget::Subscript`/`Attr` shape. The two-enum split
between `AssignTarget` and `UnpackTarget` is preserved — `Starred`
remains exclusive to unpack and `Unpack` remains exclusive to top-level
assignment, encoding Python's grammar in the type system.
Implementation:
- Parser: two new arms in `parse_unpack_target_impl` mirror the
matching arms in `parse_assign_target`.
- Prepare: `prepare_unpack_target` and `prepare_unpack_target_for_comprehension`
recurse into the sub-expressions via `prepare_expression`. The new
variants intentionally do NOT allocate comp-var slots — they mutate
existing state rather than introducing a binding, so sub-expression
names resolve against the surrounding scope.
- Scope analysis: `collect_names_from_unpack_target` walks
sub-expressions only to pick up walrus targets (no leaf binding).
Two new helpers `collect_cell_vars_from_unpack_target` and
`collect_referenced_names_from_unpack_target` mirror the existing
`*_from_assign_target` helpers. Both are wired into `Node::For` and
`Node::With` arms, which previously ignored the target entirely
(correct then, since targets had no sub-expressions — a silent
correctness bug after this change without the new walks).
- Compiler: `compile_unpack_target` and `process_unpack_sim` delegate
to `emit_subscript_store`/`emit_attr_store`. Each variant's net
operand-stack effect is -1, identical to `Name`/`Starred`, so the
existing per-target loop and `compile_comp_target_unpack`'s leaf
enumeration work unchanged. For the comprehension path the
sim <-> operand-stack 1:1 invariant is preserved by shrinking sim
in lock-step (no placeholder pushed back).
CPython evaluation order is preserved: the RHS is unpacked first,
then each target's sub-expressions are evaluated at store time. The
new test `unpack__ops.py::side-effecting index` locks this in.
Tests:
- `crates/monty/test_cases/unpack__ops.py` — subscript-target assignment
unpack (single target, two targets, swap, nested, starred siblings,
walrus in index, side-effecting index).
- `crates/monty/test_cases/iter__for_loop_unpacking.py` — `for x[i] in iter:`
with last-value semantics, computed indices, body visibility, nested
tuple targets.
- `crates/monty/test_cases/comprehension__all.py` — comprehension
`for` clause with subscript targets, nested forms, leak-out semantics.
- `crates/monty/test_cases/unpack__attr.py` — full attribute-target
coverage (assignment, swap, nested, starred, for-loop, comprehension)
via the existing `make_mutable_point()` external fixture.
- `crates/monty/tests/parse_errors.rs` — replaced the
`for_loop_attribute_target_has_clean_message` snapshot (locked
the previous rejection) with `unpack_target_constant_rejected` and
`unpack_target_call_rejected` which lock the upstream ruff rejection
for genuinely-invalid LHS shapes.
Closes pydantic#408.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests added to `unpack__ops.py` that drive the cell-var and referenced-name walks introduced for `Node::For` / `Node::With` targets: - `_for_target_lambda` puts a closure-capturing lambda inside the index of a for-loop subscript target. Without the walker added in `collect_cell_vars_from_unpack_target` / `collect_referenced_names_from_unpack_target`, the captured outer local would be invisible to scope analysis when nothing else in the enclosing function references it. - `_for_target_tuple_lambda` wraps the same pattern in a nested tuple target, exercising the `Tuple` recursion arm in both walkers. Together these close the codecov gap on the defense-in-depth scope walks that fire only when a subscript/attribute target's sub-expression contains a closure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409384c to
51a2d41
Compare
Closes #408.
Summary
CPython allows subscript (
x[i]) and attribute (o.a) expressions as targets inside tuple unpacking,for-loop variables, and comprehensionforclauses. Monty'sparse_unpack_target_implonly acceptedName/Tuple/Starred/Listand rejected everything else withSyntaxError: invalid unpacking target: <kind>. That breaks:x[0], x[1] = x[1], x[0](the reported case)x[0], = (1,)o.a, o.b = 1, 2(attribute targets — same parse arm, wider than the issue title)for box[0] in iter:/for o.a in iter:[... for box[0] in iter]and the same shape in comprehensionsThis PR fixes all five contexts in a single parse-site change plus the matching prepare/compile wiring.
Design
UnpackTargetgains two new leaf variantsSubscript { target, index, target_position }andAttribute { object, attr, target_position }, mirroringAssignTarget::Subscript/Attrexactly. The two-enum split betweenAssignTarget(top-level assignment LHS) andUnpackTarget(inside-an-unpack pattern) is intentional grammar typing —Starredis only valid inside unpack,Unpackis only valid at the top — and the fix preserves that. The leaves themselves are duplicated between the two enums (matching howNameis duplicated today); a follow-up could extract a sharedLeafAssignTargetif a third enum ever needs the same leaves, but YAGNI for now.Implementation notes
Compiler stack invariant:
compile_unpack_targetandprocess_unpack_simdelegate toemit_subscript_store/emit_attr_store. Each variant's net operand-stack effect is −1, identical toName/Starred, so the existing per-target loop andcompile_comp_target_unpack's leaf enumeration work unchanged. In the comprehension simulator the sim ↔ operand-stack 1:1 invariant is preserved by shrinking sim in lock-step (thePendingis popped and nothing pushed back — no sentinel needed). Traced throughTuple { sub_0=Subscript, sub_1, sub_2 }cases to confirm.CPython evaluation order: RHS unpacks first, then each target's sub-expressions evaluate at store time. Locked in by the
side-effecting indextest inunpack__ops.py.Silent-correctness fix:
collect_cell_vars_from_nodeandcollect_referenced_names_from_nodepreviously ignoredNode::For/Node::Withtargets — correct then, sinceUnpackTargethad no sub-expressions. After this change the targets can carry expressions that reference enclosing variables, so two new helperscollect_{cell_vars,referenced_names}_from_unpack_targetare wired into bothForandWitharms.Comp-var slot allocation:
prepare_unpack_target_for_comprehension's new arms intentionally do NOT allocate comp-var slots. Subscript/attribute targets mutate existing state, they don't introduce a binding — their sub-expressions resolve against the surrounding scope via the normalprepare_expressionpath.Walrus inside subscript index: a walrus
(i := 0)inside the index expression of a subscript target correctly binds in the outer scope —collect_names_from_unpack_targetwalks the sub-expressions viacollect_assigned_names_from_exprto pick it up. Locked in by thewalrus in subscript indextest.Test plan
cargo test -p monty --features memory-model-checks(all rust tests pass, 968 cases)cargo test -p monty --test parse_errors --features memory-model-checks(63 cases, including new negative snapshots)cargo run -p monty-datatest --features memory-model-checks unpack__/iter__for_loop/comprehension__(all green)make test-py(1091 Python binding tests pass)make format-rs && make lint-rs && make lint-pycleanTests added
crates/monty/test_cases/unpack__ops.pycrates/monty/test_cases/iter__for_loop_unpacking.pyfor x[i] in iter:last-value, computed index re-evaluated each iteration, body visibility, nested tuplecrates/monty/test_cases/comprehension__all.pyforclause with subscript targets, nested tuple subscript, leak-out semanticscrates/monty/test_cases/unpack__attr.py(new)make_mutable_point()crates/monty/tests/parse_errors.rsfor_loop_attribute_target_has_clean_message(the snapshot that locked the now-fixed rejection) withunpack_target_constant_rejected/unpack_target_call_rejectedto lock the upstream ruff rejection for genuinely-invalid LHS shapesOut of scope
AssignTargetandUnpackTargetinto a sharedLeafAssignTarget— preserved the existing architecture; happy to split that out if reviewers prefer.x[0] += 1) — separate code path viaAugAssign, unchanged.Summary by cubic
Allow subscript (
x[i]) and attribute (obj.a) targets in unpacking, for-loops, and comprehensions, matching CPython. Fixes #408 and unblocks swaps, nested patterns, and comprehension targets.UnpackTarget::SubscriptandUnpackTarget::Attribute(withtarget_position); parse arms mirror assign-target shapes.emit_subscript_store/emit_attr_store; net stack effect −1; preserve CPython eval order; comprehension sim shrinks in lock-step.For/Withtargets for cell-var and referenced-name analysis; walrus inside indices binds in outer scope.fortargets and nested tuple targets; update parse-error snapshots to lock upstream rejections for constant/call LHS shapes.Written for commit 51a2d41. Summary will update on new commits.
Review in cubic