Skip to content

Commit f18a34b

Browse files
committed
example of multishot generators using pattern k = call_cc[get_cc()]
See #80. This still needs to be moved into its own module.
1 parent efd7d51 commit f18a34b

File tree

1 file changed

+242
-3
lines changed

1 file changed

+242
-3
lines changed

unpythonic/syntax/tests/test_conts_gen.py

+242-3
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,224 @@
1818
https://github.com/Technologicat/python-3-scicomp-intro/blob/master/examples/beyond_python/generator.rkt
1919
"""
2020

21-
from ...syntax import macros, test, test_raises # noqa: F401
21+
from mcpyrate.multiphase import macros, phase
22+
23+
from ...syntax import macros, test, test_raises # noqa: F401, F811
2224
from ...test.fixtures import session, testset
2325

2426
from ...syntax import macros, continuations, call_cc, dlet, abbrev, let_syntax, block # noqa: F401, F811
2527

2628
from ...fploop import looped
2729
from ...fun import identity
2830

29-
#from mcpyrate.debug import macros, step_expansion # noqa: F811, F401
31+
from mcpyrate.debug import macros, step_expansion # noqa: F811, F401
32+
33+
# TODO: pretty long, move into its own module
34+
# Multishot generators can also be implemented using the pattern `k = call_cc[get_cc()]`.
35+
#
36+
# Because `with continuations` is a two-pass macro, it will first expand any
37+
# `@multishot` inside the block before performing its own processing, which is
38+
# exactly what we want.
39+
#
40+
# We could force the ordering with the metatool `mcpyrate.metatools.expand_first`
41+
# added in `mcpyrate` 3.6.0, but we don't need to do that.
42+
#
43+
# To make these multi-shot generators support the most basic parts
44+
# of the API of Python's native generators, make a wrapper object:
45+
#
46+
# - `__iter__` on the original function should create the wrapper object
47+
# and initialize it. Maybe always inject a bare `myield` at the beginning
48+
# of a multishot function before other processing, and run the function
49+
# until it returns the initial continuation? This continuation can then
50+
# be stashed just like with any resume point.
51+
# - `__next__` needs a stash for the most recent continuation
52+
# per activation of the multi-shot generator. It should run
53+
# the most recent continuation (with no arguments) until the next `myield`,
54+
# stash the new continuation, and return the yielded value, if any.
55+
# - `send` should send a value into the most recent continuation
56+
# (thus resuming).
57+
# - When the function returns normally, without returning any further continuation,
58+
# the wrapper should `raise StopIteration`, providing the return value as argument
59+
# to the exception.
60+
#
61+
# Note that a full implementation of the generator API requires much
62+
# more. We should at least support `close` and `throw`, and think hard
63+
# about how to handle exceptions. Particularly, a `yield` inside a
64+
# `finally` is a classic catch. This sketch also has no support for
65+
# `yield from`; we would likely need our own `myield_from`.
66+
with phase[1]:
67+
# TODO: relative imports
68+
# TODO: mcpyrate does not recognize current package in phases higher than 0? (parent missing)
69+
70+
import ast
71+
from functools import partial
72+
import sys
73+
74+
from mcpyrate.quotes import macros, q, a, h # noqa: F811
75+
from unpythonic.syntax import macros, call_cc # noqa: F811
76+
77+
from mcpyrate import namemacro, gensym
78+
from mcpyrate.quotes import is_captured_value
79+
from mcpyrate.utils import extract_bindings
80+
from mcpyrate.walkers import ASTTransformer
81+
82+
from unpythonic.syntax import get_cc, iscontinuation
83+
84+
def myield_function(tree, syntax, **kw):
85+
if syntax not in ("name", "expr"):
86+
raise SyntaxError("myield is a name and expr macro only")
87+
88+
# Accept `myield` in any non-load context, so that we can below define the macro `it`.
89+
#
90+
# This is only an issue, because this example uses multi-phase compilation.
91+
# The phase-1 `myield` is in the macro expander - preventing us from referring to
92+
# the name `myield` - when the lifted phase-0 definition is being run. During phase 0,
93+
# that makes the line `myield = namemacro(...)` below into a macro-expansion-time
94+
# syntax error, because that `myield` is not inside a `@multishot`.
95+
#
96+
# We hack around it, by allowing `myield` anywhere as long as the context is not a `Load`.
97+
if hasattr(tree, "ctx") and type(tree.ctx) is not ast.Load:
98+
return tree
99+
100+
raise SyntaxError("myield may only appear inside a multishot function")
101+
myield = namemacro(myield_function)
102+
103+
def multishot(tree, syntax, expander, **kw):
104+
"""[syntax, block] Multi-shot generators based on the pattern `k = call_cc[get_cc()]`."""
105+
if syntax != "decorator":
106+
raise SyntaxError("multishot is a decorator macro only") # pragma: no cover
107+
if type(tree) is not ast.FunctionDef:
108+
raise SyntaxError("@multishot supports `def` only")
109+
110+
# Detect the name(s) of `myield` at the use site (this accounts for as-imports)
111+
macro_bindings = extract_bindings(expander.bindings, myield_function)
112+
if not macro_bindings:
113+
raise SyntaxError("The use site of `multishot` must macro-import `myield`, too.")
114+
names_of_myield = list(macro_bindings.keys())
115+
116+
def is_myield_name(node):
117+
return type(node) is ast.Name and node.id in names_of_myield
118+
def is_myield_expr(node):
119+
return type(node) is ast.Subscript and is_myield_name(node.value)
120+
def getslice(subscript_node):
121+
if sys.version_info >= (3, 9, 0): # Python 3.9+: no ast.Index wrapper
122+
return subscript_node.slice
123+
return subscript_node.slice.value
124+
# We can work with variations of the pattern
125+
#
126+
# k = call_cc[get_cc()]
127+
# if iscontinuation(k):
128+
# return k
129+
# # here `k` is the data sent in via the continuation
130+
#
131+
# to create a multi-shot resume point. The details will depend on whether our
132+
# user wants each particular resume point to return and/or take in a value.
133+
#
134+
# Note that `myield`, beside optionally yielding a value, always returns the
135+
# continuation that resumes execution just after that `myield`. The caller
136+
# is free to stash the continuations and invoke earlier ones again, as needed.
137+
class MultishotYieldTransformer(ASTTransformer):
138+
def transform(self, tree):
139+
if is_captured_value(tree): # do not recurse into hygienic captures
140+
return tree
141+
# respect scope boundaries
142+
if type(tree) in (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
143+
ast.ListComp, ast.DictComp, ast.SetComp, ast.GeneratorExp):
144+
return tree
145+
146+
# `k = myield[value]`
147+
if type(tree) is ast.Assign and is_myield_expr(tree.value):
148+
if len(tree.targets) != 1:
149+
raise SyntaxError("expected exactly one assignment target in k = myield[expr]")
150+
var = tree.targets[0]
151+
value = getslice(tree.value)
152+
with q as quoted:
153+
a[var] = h[call_cc][h[get_cc]()]
154+
if h[iscontinuation](a[var]):
155+
return a[var], a[value]
156+
return quoted
157+
158+
# `k = myield`
159+
elif type(tree) is ast.Assign and is_myield_name(tree.value):
160+
if len(tree.targets) != 1:
161+
raise SyntaxError("expected exactly one assignment target in k = myield[expr]")
162+
var = tree.targets[0]
163+
with q as quoted:
164+
a[var] = h[call_cc][h[get_cc]()]
165+
if h[iscontinuation](a[var]):
166+
return a[var]
167+
return quoted
168+
169+
# `myield[value]`
170+
elif type(tree) is ast.Expr and is_myield_expr(tree.value):
171+
var = ast.Name(id=gensym("myield_cont"))
172+
value = getslice(tree.value)
173+
with q as quoted:
174+
a[var] = h[call_cc][h[get_cc]()]
175+
if h[iscontinuation](a[var]):
176+
return h[partial](a[var], None), a[value]
177+
return quoted
178+
179+
# `myield`
180+
elif type(tree) is ast.Expr and is_myield_name(tree.value):
181+
var = ast.Name(id=gensym("myield_cont"))
182+
with q as quoted:
183+
a[var] = h[call_cc][h[get_cc]()]
184+
if h[iscontinuation](a[var]):
185+
return h[partial](a[var], None)
186+
return quoted
187+
188+
return self.generic_visit(tree)
189+
190+
class ReturnToStopIterationTransformer(ASTTransformer):
191+
def transform(self, tree):
192+
if is_captured_value(tree): # do not recurse into hygienic captures
193+
return tree
194+
# respect scope boundaries
195+
if type(tree) in (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
196+
ast.ListComp, ast.DictComp, ast.SetComp, ast.GeneratorExp):
197+
return tree
198+
199+
if type(tree) is ast.Return:
200+
# `return`
201+
if tree.value is None:
202+
with q as quoted:
203+
raise h[StopIteration]
204+
return quoted
205+
# `return value`
206+
with q as quoted:
207+
raise h[StopIteration](a[tree.value])
208+
return quoted
209+
210+
return self.generic_visit(tree)
211+
212+
# ------------------------------------------------------------
213+
# main processing logic
214+
215+
# Make the multishot generator raise `StopIteration` when it finishes
216+
# via any `return`. First make the implicit bare `return` explicit.
217+
#
218+
# We must do this before we transform the `myield` statements,
219+
# to avoid breaking tail-calling the continuations.
220+
if type(tree.body[-1]) is not ast.Return:
221+
with q as quoted:
222+
return
223+
tree.body.extend(quoted)
224+
tree.body = ReturnToStopIterationTransformer().visit(tree.body)
225+
226+
# Inject a bare `myield` resume point at the beginning of the function body.
227+
# This makes the resulting function work somewhat like a Python generator.
228+
# When initially called, the arguments are bound, and you get a continuation;
229+
# then resuming that continuation starts the actual computation.
230+
tree.body.insert(0, ast.Expr(value=ast.Name(id=names_of_myield[0])))
231+
232+
# Transform multishot yields (`myield`) into `call_cc`.
233+
tree.body = MultishotYieldTransformer().visit(tree.body)
234+
235+
return tree
236+
237+
from __self__ import macros, multishot, myield # noqa: F811, F401
238+
30239

31240
def runtests():
32241
with testset("a basic generator"):
@@ -178,7 +387,7 @@ def result(loop, i=0):
178387
x = g2() # noqa: F821
179388
test[out == list(range(10))]
180389

181-
with testset("multi-shot generators"):
390+
with testset("multi-shot generators with call_cc[]"):
182391
with continuations:
183392
with let_syntax:
184393
with block[value] as my_yield: # noqa: F821
@@ -242,6 +451,36 @@ def my_yieldf(value=None, *, cc):
242451
# outside any make_generator are caught at compile time. The actual template the
243452
# make_generator macro needs to splice in is already here in the final example.)
244453

454+
with testset("multi-shot generators with the pattern call_cc[get_cc()]"):
455+
with continuations:
456+
@multishot
457+
def g():
458+
myield[1]
459+
myield[2]
460+
myield[3]
461+
462+
try:
463+
out = []
464+
k = g() # instantiate the multishot generator
465+
while True:
466+
k, x = k()
467+
out.append(x)
468+
except StopIteration:
469+
pass
470+
test[out == [1, 2, 3]]
471+
472+
k0 = g() # instantiate the multishot generator
473+
k1, x1 = k0()
474+
k2, x2 = k1()
475+
k3, x3 = k2()
476+
k, x = k1() # multi-shot generator can resume from an earlier point
477+
test[x1 == 1]
478+
test[x2 == x == 2]
479+
test[x3 == 3]
480+
test[k.func.__qualname__ == k2.func.__qualname__] # same bookmarked position...
481+
test[k.func is not k2.func] # ...but different function object instance
482+
test_raises[StopIteration, k3()]
483+
245484
if __name__ == '__main__': # pragma: no cover
246485
with session(__file__):
247486
runtests()

0 commit comments

Comments
 (0)