Skip to content

Commit 839953b

Browse files
Technologicatclaude
andcommitted
multishot: ship myield_from + _step helper / throw capture-and-update
# myield_from (multi-shot yield-from) The multi-shot analog of `yield from`. Inside a `@multishot` body, `myield_from[inner_call()]` drives a second `@multishot` to exhaustion, re-yielding each value to outer's caller; the assignment form `var = myield_from[...]` captures inner's `StopIteration` value. Forwards `send` and `throw` into the inner; tracks the inner iterator via `outer_mi.gi_yieldfrom` (mirrors the standard generator API). Multi-shot-to-multi-shot only — cross-delegation with standard generators is wontfix (semantic mismatch). The expansion captures "rest of outer" via `_rest = call_cc[get_cc()]` (the multi-shot analog of Racket's `(let/cc return ...)`), tail-calls a small driver from outer's top level, and uses the cut-the-tail trick (`cc = identity`) inside a yield helper to deliver each `(captured_cc, value)` pair straight to the user's `mi._k()` trampoline. When inner exhausts, the driver invokes the captured rest-cc to resume outer's body just past the `myield_from` statement. Subscript syntax (`myield_from[expr]`, not `myield_from(expr)`) for consistency with `myield[expr]` and the other unpythonic macros. # Shared `_step` helper, send-bug fix, throw capture-and-update Refactor: `MultishotIterator._advance` and `.throw` now route through a single `_step(k, mode, value)` helper that detects the partial vs. raw shape of the underlying continuation. Side-effect: fixes a latent bug where `mi.send(value)` against a bare-myield continuation (partial-wrapped) raised `TypeError`. Standard-generator parity: `gen.send(value)` against a bare `yield` discards the value silently; we now match. `MultishotIterator.throw` now captures the new yielded value and updates `self._k`, mirroring the standard generator protocol. Required for `myield_from`'s throw-forwarding into the inner. # Terminology and documentation Sweep: "real generators" → "standard (Python) generators" throughout. Made the across-myield `with`/`try`/`finally`/`with handlers` gotcha load-bearingly explicit in `doc/macros.md` (including the workaround: install `with handlers(...)` outside the `@multishot` body) and fixed a misleading "the with re-enters from the top whenever the continuation is invoked" claim — it doesn't; resumption picks up mid-body without re-running `__enter__`. New "How does `copy.copy(mi)` differ from `k = mi.k`?" subsection in `doc/macros.md` — they're nearly equivalent, with `copy.copy` being the iterator-shaped convenience. Tests: 27 new passing in `test_multishot.py` (multishot total 72, full suite 3810/3810 green; was 3783). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1ddb616 commit 839953b

4 files changed

Lines changed: 406 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
- Demonstrates the `mcpyrate` `Dialect.transform_source` hook (full-module source-to-source transformer), which the other dialects in this package do not use.
1111
- Uses the new `mcpyrate.dialects.split_at_dialectimport` helper (requires `mcpyrate >= 4.1.0`), which correctly handles the case where the bf dialect-import shares a `from X import dialects, A, B` line with other dialects.
1212
- `unpythonic.llist.FrozenAttributeError`: compatibility shim that multiply-inherits from `TypeError` (legacy unpythonic <= 2.x) and `dataclasses.FrozenInstanceError` (Python 3.7+ stdlib convention). Raised by `cons` on attribute write/delete attempts. Either `except TypeError` or `except FrozenInstanceError` catches it. The `TypeError` base will be dropped in 3.0.0; new code should catch `FrozenInstanceError` (or `AttributeError`).
13-
- `unpythonic.syntax.multishot`: `@multishot` decorator macro and `myield` name/expr macro for multi-shot generators, plus `MultishotIterator` adapter. A `@multishot` function is generator-shaped, but at every `myield` the execution state is captured *as a continuation*, so it can be resumed from any earlier `myield` arbitrarily many times. Only meaningful inside `with continuations:`. `MultishotIterator` exposes a subset of the standard generator protocol (`iter`, `next`, `send`, `throw`, `close`, plus `gi_*` introspection) and one method real generators don't have: `copy.copy(mi)` forks the iterator at the current continuation, with the two iterators advancing into independent timelines. Closes #80. See `doc/macros.md`.
13+
- `unpythonic.syntax.multishot`: `@multishot` decorator macro and `myield` name/expr macro for multi-shot generators, plus `MultishotIterator` adapter. A `@multishot` function is generator-shaped, but at every `myield` the execution state is captured *as a continuation*, so it can be resumed from any earlier `myield` arbitrarily many times. Only meaningful inside `with continuations:`. `MultishotIterator` exposes a subset of the standard generator protocol (`iter`, `next`, `send`, `throw`, `close`, plus `gi_*` introspection) and one method standard generators don't have: `copy.copy(mi)` forks the iterator at the current continuation, with the two iterators advancing into independent timelines. Closes #80. See `doc/macros.md`.
14+
- `myield_from` macro: the multi-shot analog of `yield from`. Inside a `@multishot` body, `myield_from[inner_call()]` drives a second `@multishot` to exhaustion, re-yielding each value to outer's caller; the assignment form `var = myield_from[...]` captures inner's `StopIteration` value. Forwards `send` and `throw` into the inner, and tracks the inner iterator via `outer_mi.gi_yieldfrom`. Multi-shot-to-multi-shot only — cross-delegation with standard generators is wontfix.
1415

1516
**Fixed**:
1617

doc/macros.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,7 +1750,7 @@ with continuations:
17501750
assert [x for x in mi] == [1, 2, 3]
17511751
```
17521752

1753-
`MultishotIterator` supports a subset of the generator protocol: `iter`, `next`, `send`, `throw`, `close`, `gi_code`, `gi_frame`, `gi_running`, `gi_yieldfrom`. Plus one method that real generators don't have:
1753+
`MultishotIterator` supports a subset of the generator protocol: `iter`, `next`, `send`, `throw`, `close`, `gi_code`, `gi_frame`, `gi_running`, `gi_yieldfrom`. Plus one method that standard generators don't have:
17541754

17551755
```python
17561756
import copy
@@ -1774,14 +1774,51 @@ Unlike standard generators, multi-shot generators support `copy.copy()`. The for
17741774

17751775
`copy.deepcopy(mi)` raises `TypeError`. The continuation closes over caller state we can't meaningfully deep-copy; use `copy.copy()` to fork.
17761776

1777+
##### How does `copy.copy(mi)` differ from `k = mi.k`?
1778+
1779+
It almost doesn't. `copy.copy(mi)` is essentially `MultishotIterator(mi.k)` — plus preservation of the closed-flag, plus the convenience of doing the snapshot in one operation (since `mi.k` is overwritten on each `next`/`send`/`throw`, you'd otherwise have to remember to grab `k` *before* the next advance). The fork is iterator-shaped from day one, so it composes with `for x in fork`, `next(fork)`, and `fork.gi_yieldfrom`; a bare `k` would have to be re-wrapped in a `MultishotIterator` to do the same.
1780+
1781+
Use `k = mi.k` (or capture `k` from a destructuring like `k1, x1 = mi.k(...)`) when you want the raw continuation — for instance, to drive it from a custom orchestrator. Use `copy.copy(mi)` when you want a second consumer-shaped iterator and idiomatic stdlib-style code at the call site.
1782+
1783+
(With standard generators, neither path is available: `copy.copy(real_gen)` raises `TypeError`, and `gen.gi_frame` isn't a continuation you could wrap and re-invoke. Multi-shot offers both.)
1784+
1785+
##### Delegating to another multi-shot: `myield_from`
1786+
1787+
The multi-shot analog of `yield from`. Inside an outer `@multishot` body, `myield_from[inner_call()]` drives a second `@multishot` to exhaustion, re-yielding each of its values to outer's caller. On inner's `StopIteration`, execution continues in outer's body. Two forms (subscript syntax — same convention as `myield[expr]`):
1788+
1789+
```python
1790+
with continuations:
1791+
@multishot
1792+
def inner():
1793+
myield[1]
1794+
myield[2]
1795+
return 99 # surfaces as StopIteration(99) at the boundary
1796+
1797+
@multishot
1798+
def outer():
1799+
myield[0]
1800+
result = myield_from[inner()] # binds inner's StopIteration value
1801+
myield[result] # → 99
1802+
```
1803+
1804+
The statement form `myield_from[inner_call()]` discards inner's `StopIteration` value; the assignment form `var = myield_from[inner_call()]` binds it.
1805+
1806+
`send` and `throw` from the outer's caller are forwarded into the inner. While delegating, `outer_mi.gi_yieldfrom` returns the inner `MultishotIterator` (mirroring the standard generator's `gi_yieldfrom`); it returns `None` again once inner is exhausted.
1807+
1808+
`myield_from` is statement-only and may only appear at the top level of a `@multishot` body — same placement constraint as `myield`. Inside lambdas, comprehensions, or nested `def`s it is rejected at macro-expansion time.
1809+
1810+
**Architecture note for the curious.** The expansion captures "rest of outer" via `_rest = call_cc[get_cc()]` (the multi-shot analog of Racket's `(let/cc return ...)`), tail-calls a small driver, and uses the *cut-the-tail* trick (`cc = identity`) inside a yield helper to deliver each `(captured_cc, value)` pair straight to the user's `mi._k()` trampoline. When inner exhausts, the driver invokes the captured rest-cc to resume outer's body just past the `myield_from` statement.
1811+
1812+
**Limitation: cross-form delegation is wontfix.** `myield_from` is multi-shot-to-multi-shot only; you cannot `myield_from` a standard generator (the semantic mismatch is the same as for `yield from` in the other direction).
1813+
17771814
##### Differences from standard Python generators
17781815

17791816
Beyond what's already mentioned above:
17801817

1781-
- **`gi_frame` is always `None`.** A multi-shot generator has no paused frame — every `myield` terminated its frame and returned a continuation closure. State lives in the closure cells of the continuation, not in any frame. The real-generator idiom `gen.gi_frame is None ↔ exhausted` does **not** apply; use `mi.gi_code is None` as the liveness signal instead.
1818+
- **`gi_frame` is always `None`.** A multi-shot generator has no paused frame — every `myield` terminated its frame and returned a continuation closure. State lives in the closure cells of the continuation, not in any frame. The standard-generator idiom `gen.gi_frame is None ↔ exhausted` does **not** apply; use `mi.gi_code is None` as the liveness signal instead.
17821819
- **`gi_running` is always `False`.** Nothing is ever paused.
1783-
- **`yield from` across a real generator and a multi-shot generator is not supported.** Real generators have paused state, multi-shots don't; the semantic mismatch can't be papered over. Multi-shot-to-multi-shot delegation (`myield_from`) is on the roadmap; until then, `gi_yieldfrom` is always `None`.
1784-
- **`with` and `try`/`finally` across `myield` boundaries do not behave as in real generators.** A `with` block "exits" as soon as the multi-shot `myield`s the continuation (because, technically, the function returned). It re-enters from the top whenever the continuation is invoked. For an example of what the world's serious `call/cc`-having languages do here, see Racket's [`dynamic-wind`](https://docs.racket-lang.org/reference/cont.html#%28def._%28%28quote._~23~25kernel%29._dynamic-wind%29%29).
1820+
- **`yield from` across a standard generator and a multi-shot generator is not supported.** Standard generators have paused state, multi-shots don't; the semantic mismatch can't be papered over. Multi-shot-to-multi-shot delegation is supported via `myield_from`; see the dedicated subsection above.
1821+
- **⚠ `with`, `try`/`finally`, and `with handlers` across `myield` boundaries do not behave as in standard generators — load-bearing gotcha.** When a `myield` is reached, the `@multishot` function technically *returns* (the `myield` macro expansion compiles into a `return` of a continuation). All `with` `__exit__` and `try`/`finally` clauses lexically containing the `myield` therefore fire *at the `myield`*, not at the end of the multi-shot. Resuming the continuation jumps back into mid-body — the `with`/`try` is *not* re-entered (no second `__enter__` call), so a `with open(...) as f: myield[1]; myield[2]` will see `f` already closed at `myield[2]`. Same for unpythonic conditions: a `with handlers(...)` lexically containing a `myield` uninstalls the handler at the `myield` and the handler is *not* re-installed on resume. **Workaround for cleanup:** do it explicitly — call `f.close()` after the consumer is done, or use a `try/except StopIteration` in the caller. **Workaround for handlers:** install `with handlers(...)` *outside* the `@multishot` body, in the calling code that consumes the iterator. Conditions raised at any point during multi-shot consumption then propagate to that outer handler normally. For an example of what the world's serious `call/cc`-having languages do for this kind of thing, see Racket's [`dynamic-wind`](https://docs.racket-lang.org/reference/cont.html#%28def._%28%28quote._~23~25kernel%29._dynamic-wind%29%29).
17851822
- **No async form.** No `__aiter__`, `asend`, etc.; multi-shot is sync-only.
17861823
- **No pickling.** Continuations are closures.
17871824

0 commit comments

Comments
 (0)