You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
# 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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,8 @@
10
10
- Demonstrates the `mcpyrate``Dialect.transform_source` hook (full-module source-to-source transformer), which the other dialects in this package do not use.
11
11
- 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.
12
12
-`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.
Copy file name to clipboardExpand all lines: doc/macros.md
+41-4Lines changed: 41 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1750,7 +1750,7 @@ with continuations:
1750
1750
assert [x for x in mi] == [1, 2, 3]
1751
1751
```
1752
1752
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:
1754
1754
1755
1755
```python
1756
1756
import copy
@@ -1774,14 +1774,51 @@ Unlike standard generators, multi-shot generators support `copy.copy()`. The for
1774
1774
1775
1775
`copy.deepcopy(mi)` raises `TypeError`. The continuation closes over caller state we can't meaningfully deep-copy; use `copy.copy()` to fork.
1776
1776
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
+
definner():
1793
+
myield[1]
1794
+
myield[2]
1795
+
return99# surfaces as StopIteration(99) at the boundary
1796
+
1797
+
@multishot
1798
+
defouter():
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
+
1777
1814
##### Differences from standard Python generators
1778
1815
1779
1816
Beyond what's already mentioned above:
1780
1817
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.
1782
1819
-**`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).
1785
1822
-**No async form.** No `__aiter__`, `asend`, etc.; multi-shot is sync-only.
0 commit comments