← board

An extended slice cannot be assigned

Found 2026-08-16 by a tools/pydiff.py sweep.

l = [1, 2, 3]
l[::2] = [7, 8]      # CPython [7, 2, 8]
                     # pascal26: error (parse)
l[1:3] = [9]         # works
print(l[::2])        # works — the READ side is complete

Both halves it needs already exist: pylist_slice_step reads a strided slice and pylist_setslice writes a contiguous one. The missing piece is the lvalue-side parse of a three-part subscript plus a pylist_setslice_step that walks the same index sequence the reader does — and, as CPython does, raises when the RHS length does not match the number of selected slots (that check is what makes the strided form different from the contiguous one, which resizes).

Low priority: strided assignment is rare in real code, and it fails LOUDLY at compile time, so nothing can be silently wrong.

Gate

A .npy diffed against CPython: forward and negative steps, a length mismatch raising ValueError, and the contiguous forms unchanged.

Log

Resolved 2026-09-12 — implemented, CPython-differential green

Three pylib entry points, each walking the positions its matching READ selects via PySliceBoundsStep rather than re-derived arithmetic:

pylist_setslice_step l[lo:hi:step] = src
pybytes_setslice_step b[lo:hi:step] = src
pyvar_setslice_step target with no static type; unboxes and dispatches on the runtime type

PyRewriteSliceAssign routes all three; the refusal is gone.

The part that would have shipped broken

The refusal this ticket describes was only reachable for a target with a static list or bytes type. The "is this a slice-assign target" predicate named pylist_slice_step and pybytes_slice_step and not pyvar_slice_step, so a target with no static type never entered the store path at all — it fell through to expression-statement parsing and died on expected newline after statement at the =.

That is not a corner. A name first bound inside an if or try body has no static type, and neither does a container element — d["k"][::2] = ..., which had been separately (and wrongly) read as a parse gap, is the same cause.

Every top-level spelling compiled: l[::2], l[0::2], l[::-1], l[0:4:2], l[1::2], l[:4:2], l[0:], l[::] — all eight probed. It surfaced only because the fixture's try/except rows rebind their target inside the block. A fixture written the ordinary way would have certified the feature as complete with two thirds of it missing, so the suite now holds all three routes on purpose: static target, variant NAME, variant RECEIVER.

The diagnostic also misreported the location, naming line 14 — the first top-level occurrence of a similar statement — while the failing line was 60.

What the gate asserts

test/test_nilpy_extended_slice_assign.npy, 15 rows, .expected generated by python3. Forward and negative steps, the empty selection, both refusal messages verbatim, tuple immutability, and app.py:2358's own shape (bytearray, explicit start, omitted stop).

Positive control MEASURED: the pinned compiler answers pascal26:14: error: expected expression on this fixture, so the row is red pre-fix.

The self-alias rows (x[::-1] = x, legal Python) are load-bearing and that was measured, not assumed: with both snapshot guards disabled, those two rows alone go wrong — [1, 2, 3, 2, 1] and b'abcba' — while the other thirteen stay green.

Two residuals, deliberately not done

  1. A variant source into a statically-typed target (b[::2] = d["k"]) is refused by name. The setters take a TPyBytes/TPyList and a raw variant handed to one is read as that object's HEADER — a plausible wrong length with no diagnostic — so refusing is the honest answer, but it leaves an asymmetry: a variant TARGET accepts a variant source and a static one does not. The fix to try is routing that case through pyvar_setslice_step and letting the target box; the thing to verify is that the boxing passes a REFERENCE, because a copy would silently not mutate. One CPython differential answers it.
  2. b[::2] = [88, 89, 90] — CPython accepts a list of ints into a bytearray extended slice. Not implemented; unrelated to this ticket's shape.

Inert for other seats until a pin: these are compiler/builtin/** entry points, so a $(PXX_STABLE) consumer cannot see them — the pinned binary resolves its own snapshot builtin.