@tinyvolt/pie 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/LICENSE +21 -0
- package/README.md +56 -2
- package/functions/midpoint.ts +29 -0
- package/functions/square.ts +20 -0
- package/init/index.js +87 -0
- package/init/site-agents.md +253 -0
- package/init/site-gitignore +28 -0
- package/init/site-tsconfig.json +20 -0
- package/init/site-vite.config.ts +6 -0
- package/layouts/header.tsx +16 -0
- package/layouts/index.tsx +10 -0
- package/nodes/square.tsx +36 -0
- package/package.json +59 -4
- package/pages/example.md +7 -0
- package/pages/python-editor.md +29 -0
- package/public/favicon.ico +0 -0
- package/public/logo.svg +5 -0
- package/site.yml +1 -0
- package/src/app.css +343 -0
- package/src/app.tsx +30 -0
- package/src/authorStyles.ts +3 -0
- package/src/components/Header.tsx +16 -0
- package/src/components/PageTitle.tsx +10 -0
- package/src/components/SplitPane.tsx +170 -0
- package/src/components/icons.tsx +22 -0
- package/src/entry-client.tsx +4 -0
- package/src/entry-server.tsx +21 -0
- package/src/global.d.ts +7 -0
- package/src/lib/appDir.ts +4 -0
- package/src/lib/autodiff/index.ts +49 -0
- package/src/lib/layouts/layout.ts +20 -0
- package/src/lib/layouts/vitePlugin.ts +39 -0
- package/src/lib/markdown/components/CanvasPage.tsx +25 -0
- package/src/lib/markdown/components/MarkdownBody.tsx +41 -0
- package/src/lib/markdown/components/PythonEditor.tsx +166 -0
- package/src/lib/markdown/frontmatter.ts +81 -0
- package/src/lib/markdown/pages.ts +23 -0
- package/src/lib/markdown/render.ts +54 -0
- package/src/lib/markdown/vitePlugin.ts +94 -0
- package/src/lib/nodes/components/CenteredGrid.tsx +59 -0
- package/src/lib/nodes/components/NodeCanvas.tsx +58 -0
- package/src/lib/nodes/execute.ts +166 -0
- package/src/lib/nodes/functions/Node2NodeFunc.ts +307 -0
- package/src/lib/nodes/functions/array.ts +36 -0
- package/src/lib/nodes/functions/autodiff.ts +64 -0
- package/src/lib/nodes/functions/basic.ts +298 -0
- package/src/lib/nodes/functions/registry.ts +46 -0
- package/src/lib/nodes/functions/sideEffects.ts +193 -0
- package/src/lib/nodes/functions/vitePlugin.ts +40 -0
- package/src/lib/nodes/hooks/useCanvasResize.ts +38 -0
- package/src/lib/nodes/jax.ts +191 -0
- package/src/lib/nodes/render/renderLogic.tsx +306 -0
- package/src/lib/nodes/state/NameGenerator.ts +31 -0
- package/src/lib/nodes/state/Node.ts +211 -0
- package/src/lib/nodes/state/NodeStore.ts +110 -0
- package/src/lib/nodes/tensors/broadcast.ts +101 -0
- package/src/lib/nodes/tensors/tensor.ts +42 -0
- package/src/lib/python/lift.ts +105 -0
- package/src/lib/python/py/__pycache__/pie.cpython-314.pyc +0 -0
- package/src/lib/python/py/pie.py +231 -0
- package/src/lib/python/py/pie_mpl.py +15 -0
- package/src/lib/python/py/show_figures.py +5 -0
- package/src/lib/python/runner.ts +202 -0
- package/src/lib/python/worker.ts +109 -0
- package/src/lib/site/vitePlugin.ts +43 -0
- package/src/routes/[...slug].tsx +38 -0
- package/src/routes/about.tsx +10 -0
- package/src/routes/index.tsx +14 -0
- package/src/routes/pages/index.tsx +20 -0
- package/styles/theme.css +27 -0
- package/vite.ts +61 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import json, numbers, sys, types
|
|
2
|
+
from pyodide.ffi import run_sync, to_js
|
|
3
|
+
from js import Object
|
|
4
|
+
import bridge
|
|
5
|
+
|
|
6
|
+
class Node:
|
|
7
|
+
def __init__(self, id, type):
|
|
8
|
+
self.id = id
|
|
9
|
+
self.type = type
|
|
10
|
+
|
|
11
|
+
def __repr__(self):
|
|
12
|
+
return f"{self.type}({self.id})"
|
|
13
|
+
|
|
14
|
+
class Scalar(Node):
|
|
15
|
+
def __init__(self, id):
|
|
16
|
+
super().__init__(id, "Scalar")
|
|
17
|
+
|
|
18
|
+
class Point(Node):
|
|
19
|
+
def __init__(self, id):
|
|
20
|
+
super().__init__(id, "Point")
|
|
21
|
+
|
|
22
|
+
class Text(Node):
|
|
23
|
+
def __init__(self, id):
|
|
24
|
+
super().__init__(id, "Text")
|
|
25
|
+
|
|
26
|
+
class Array(Node):
|
|
27
|
+
# `pie.Array[pie.Point]`, a type for `pie.lift`.
|
|
28
|
+
def __class_getitem__(cls, item):
|
|
29
|
+
return types.GenericAlias(cls, item)
|
|
30
|
+
|
|
31
|
+
def __init__(self, id, shape, item_type):
|
|
32
|
+
super().__init__(id, "Array")
|
|
33
|
+
self.shape = shape
|
|
34
|
+
self.item_type = item_type
|
|
35
|
+
|
|
36
|
+
def __len__(self):
|
|
37
|
+
return self.shape[0]
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return f"Array[{self.item_type}]({self.id}, shape={self.shape})"
|
|
41
|
+
|
|
42
|
+
def __getitem__(self, key):
|
|
43
|
+
key = key if isinstance(key, tuple) else (key,)
|
|
44
|
+
if len(key) > len(self.shape):
|
|
45
|
+
raise IndexError(f"too many indices: the array has {len(self.shape)} dimensions, got {len(key)}")
|
|
46
|
+
entries = [_index_entry(k, n) for k, n in zip(key, self.shape)]
|
|
47
|
+
# One int per dimension picks an existing item: no new node.
|
|
48
|
+
if len(entries) == len(self.shape) and all(isinstance(e, int) for e in entries):
|
|
49
|
+
position = 0
|
|
50
|
+
for e, n in zip(entries, self.shape):
|
|
51
|
+
position = position * n + e
|
|
52
|
+
_check_bridge()
|
|
53
|
+
return _node(run_sync(bridge.item(self.id, position)))
|
|
54
|
+
return _call("_index", [self, json.dumps(entries)])
|
|
55
|
+
|
|
56
|
+
def _index_entry(k, n):
|
|
57
|
+
"""One dimension of a key, for a dimension of size `n`: an int, or the list of ints a slice picks."""
|
|
58
|
+
if isinstance(k, slice):
|
|
59
|
+
return list(range(*k.indices(n)))
|
|
60
|
+
if isinstance(k, int):
|
|
61
|
+
if not -n <= k < n:
|
|
62
|
+
raise IndexError(f"index {k} is out of range for size {n}")
|
|
63
|
+
return k % n
|
|
64
|
+
raise TypeError(f"array indices must be ints or slices, got {type(k).__name__}")
|
|
65
|
+
|
|
66
|
+
def _node(result):
|
|
67
|
+
if result.type == "Array":
|
|
68
|
+
return Array(result.id, tuple(result.shape), result.itemType)
|
|
69
|
+
if result.type == "Scalar":
|
|
70
|
+
return Scalar(result.id)
|
|
71
|
+
if result.type == "Point":
|
|
72
|
+
return Point(result.id)
|
|
73
|
+
if result.type == "Text":
|
|
74
|
+
return Text(result.id)
|
|
75
|
+
return Node(result.id, result.type)
|
|
76
|
+
|
|
77
|
+
def _arg(a):
|
|
78
|
+
if isinstance(a, Node):
|
|
79
|
+
return {"id": a.id}
|
|
80
|
+
if isinstance(a, (list, tuple)):
|
|
81
|
+
return [_arg(x) for x in a]
|
|
82
|
+
return a
|
|
83
|
+
|
|
84
|
+
def _call(name, args, **options):
|
|
85
|
+
"""`options`: keyword arguments like `out` and `fill`. Ones that are None are left out."""
|
|
86
|
+
_check_bridge()
|
|
87
|
+
js_args = to_js([_arg(a) for a in args], dict_converter=Object.fromEntries)
|
|
88
|
+
js_options = to_js({k: v for k, v in options.items() if v is not None}, dict_converter=Object.fromEntries)
|
|
89
|
+
return _node(run_sync(bridge.call(name, js_args, js_options)))
|
|
90
|
+
|
|
91
|
+
# Above 0 while a function passed to `pie.lift` runs. It can't reach the page: a rerun runs outside any block.
|
|
92
|
+
_lift_depth = 0
|
|
93
|
+
|
|
94
|
+
def _check_bridge():
|
|
95
|
+
if _lift_depth:
|
|
96
|
+
raise RuntimeError("pie.* calls and array indexing aren't allowed inside a function passed to pie.lift")
|
|
97
|
+
|
|
98
|
+
# Type passed to `pie.lift` -> (kind, item type).
|
|
99
|
+
_RETURNS = [
|
|
100
|
+
(Scalar, ("Scalar", None)),
|
|
101
|
+
(Point, ("Point", None)),
|
|
102
|
+
(Text, ("Text", None)),
|
|
103
|
+
(str, ("Text", None)),
|
|
104
|
+
(Array[Scalar], ("Array", "Scalar")),
|
|
105
|
+
(Array[Point], ("Array", "Point")),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
class _Slot:
|
|
109
|
+
"""Where node argument `index` goes in a stored call."""
|
|
110
|
+
def __init__(self, index):
|
|
111
|
+
self.index = index
|
|
112
|
+
|
|
113
|
+
def lift(fn, out_type):
|
|
114
|
+
"""
|
|
115
|
+
A regular Python function `fn` can be _lifted_ to accept nodes (Scalar, Point, Text and Array)
|
|
116
|
+
g = pie.lift(fn, out_type)
|
|
117
|
+
- If `fn` returns a num, `g` returns `Scalar`, out_type = `Scalar`
|
|
118
|
+
- If `fn` returns a tuple or array of len 2, `g` returns `Point`, out_type = `Point`
|
|
119
|
+
- If `fn` returns a str, `g` returns `Text`, out_type = `Text` or `str`
|
|
120
|
+
- If `fn` returns a numpy array, `g` can return `Array[Point]` or `Array[Scalar]`
|
|
121
|
+
"""
|
|
122
|
+
returns = next((r for t, r in _RETURNS if out_type == t), None)
|
|
123
|
+
if returns is None:
|
|
124
|
+
raise TypeError(f"pie.lift: out_type must be pie.Scalar, pie.Point, pie.Text (or str), pie.Array[pie.Scalar] or pie.Array[pie.Point], got {out_type!r}")
|
|
125
|
+
# The page's globals: `lift` is called from a block.
|
|
126
|
+
scope = sys._getframe(1).f_globals
|
|
127
|
+
name = getattr(fn, "__name__", None)
|
|
128
|
+
# A function defined in a block is found by name, so a later `def f` is used. Anything else, e.g. np.linspace, by reference.
|
|
129
|
+
target = name if name is not None and scope.get(name) is fn else fn
|
|
130
|
+
label = name or repr(fn)
|
|
131
|
+
|
|
132
|
+
def lifted(*args, out=None, hidden=False, fill=None, stroke=None, **kwargs):
|
|
133
|
+
return _lifted_node(scope, target, label, returns, args, kwargs, out, hidden, fill, stroke)
|
|
134
|
+
lifted.__name__ = lifted.__qualname__ = f"lift({label})"
|
|
135
|
+
return lifted
|
|
136
|
+
|
|
137
|
+
def _lifted_node(scope, target, label, returns, args, kwargs, out, hidden, fill, stroke):
|
|
138
|
+
_check_bridge()
|
|
139
|
+
ids = []
|
|
140
|
+
def slot(a):
|
|
141
|
+
if not isinstance(a, Node):
|
|
142
|
+
return a
|
|
143
|
+
ids.append(a.id)
|
|
144
|
+
return _Slot(len(ids) - 1)
|
|
145
|
+
# The call, with node arguments as slots. Reruns fill the slots with new values; other arguments stay as they are.
|
|
146
|
+
call = (target, label, [slot(a) for a in args], {k: slot(v) for k, v in kwargs.items()}, returns)
|
|
147
|
+
kind, item = returns
|
|
148
|
+
reply = run_sync(bridge.lift(label, to_js(ids), kind, out, fill, stroke))
|
|
149
|
+
shape, values = _evaluate(scope, call, reply.values.to_py())
|
|
150
|
+
save = {"id": reply.id, "name": label, "inputIds": ids, "kind": kind, "itemType": item, "shape": shape, "values": values, "hidden": hidden, "fill": fill, "stroke": stroke}
|
|
151
|
+
bridge.lift_save(to_js(save, dict_converter=Object.fromEntries))
|
|
152
|
+
scope.setdefault("__pie_lift__", {})[reply.id] = call
|
|
153
|
+
if kind == "Array":
|
|
154
|
+
return Array(reply.id, tuple(shape), item)
|
|
155
|
+
return {"Scalar": Scalar, "Point": Point, "Text": Text}[kind](reply.id)
|
|
156
|
+
|
|
157
|
+
def _rerun(scope, node_id, values):
|
|
158
|
+
"""Called by the worker when an input of node `node_id` changes. `values`: the input values, in slot order."""
|
|
159
|
+
call = scope.get("__pie_lift__", {}).get(node_id)
|
|
160
|
+
if call is None:
|
|
161
|
+
raise RuntimeError("Python was restarted, so this node can't rerun. Run the block that made it again.")
|
|
162
|
+
shape, values = _evaluate(scope, call, values)
|
|
163
|
+
return {"shape": shape, "values": values}
|
|
164
|
+
|
|
165
|
+
def _evaluate(scope, call, values):
|
|
166
|
+
"""Calls the function: by name, the one now named so in `scope`; otherwise the one kept."""
|
|
167
|
+
global _lift_depth
|
|
168
|
+
target, label, args, kwargs, returns = call
|
|
169
|
+
if isinstance(target, str):
|
|
170
|
+
if target not in scope:
|
|
171
|
+
raise NameError(f"name '{target}' is not defined")
|
|
172
|
+
fn = scope[target]
|
|
173
|
+
else:
|
|
174
|
+
fn = target
|
|
175
|
+
# A Scalar's value arrives as a number, a Point's as [x, y], a Text's as a str, an Array's as {shape, values}.
|
|
176
|
+
def fill(a):
|
|
177
|
+
if not isinstance(a, _Slot):
|
|
178
|
+
return a
|
|
179
|
+
v = values[a.index]
|
|
180
|
+
if isinstance(v, str):
|
|
181
|
+
return v
|
|
182
|
+
if isinstance(v, dict):
|
|
183
|
+
import numpy as np
|
|
184
|
+
return np.array(v["values"]).reshape(v["shape"])
|
|
185
|
+
return tuple(float(c) for c in v) if isinstance(v, list) else float(v)
|
|
186
|
+
_lift_depth += 1
|
|
187
|
+
try:
|
|
188
|
+
result = fn(*map(fill, args), **{k: fill(v) for k, v in kwargs.items()})
|
|
189
|
+
finally:
|
|
190
|
+
_lift_depth -= 1
|
|
191
|
+
return _check_out_type_and_flatten(label, returns, result)
|
|
192
|
+
|
|
193
|
+
def _is_number(v):
|
|
194
|
+
return isinstance(v, numbers.Real)
|
|
195
|
+
|
|
196
|
+
def _check_out_type_and_flatten(name, returns, result):
|
|
197
|
+
"""`result` as (shape, values): every number in row-major order, a Point as x, y. A Text's values: [the string]."""
|
|
198
|
+
kind, item = returns
|
|
199
|
+
if kind == "Text":
|
|
200
|
+
if not isinstance(result, str):
|
|
201
|
+
raise TypeError(f"{name} is lifted to pie.Text, so it must return a str, got {type(result).__name__}")
|
|
202
|
+
return [], [result]
|
|
203
|
+
if kind == "Scalar":
|
|
204
|
+
if not _is_number(result):
|
|
205
|
+
raise TypeError(f"{name} is lifted to pie.Scalar, so it must return a number, got {type(result).__name__}")
|
|
206
|
+
return [], [float(result)]
|
|
207
|
+
if kind == "Point":
|
|
208
|
+
if not (isinstance(result, (tuple, list)) and len(result) == 2 and all(map(_is_number, result))):
|
|
209
|
+
raise TypeError(f"{name} is lifted to pie.Point, so it must return a tuple or list of 2 numbers, got {type(result).__name__}")
|
|
210
|
+
return [], [float(c) for c in result]
|
|
211
|
+
lifted_to = f"lifted to pie.Array[pie.{item}]"
|
|
212
|
+
np = sys.modules.get("numpy")
|
|
213
|
+
if np is None or not isinstance(result, np.ndarray):
|
|
214
|
+
raise TypeError(f"{name} is {lifted_to}, so it must return a numpy array, got {type(result).__name__}")
|
|
215
|
+
if result.dtype.kind not in "biuf":
|
|
216
|
+
raise TypeError(f"{name} is {lifted_to}, so its array must hold numbers, got dtype {result.dtype}")
|
|
217
|
+
if item == "Point" and (result.ndim < 2 or result.shape[-1] != 2):
|
|
218
|
+
raise ValueError(f"{name} is {lifted_to}, so its array's shape must end in 2, with at least 2 dimensions, got {result.shape}")
|
|
219
|
+
if result.ndim < 1:
|
|
220
|
+
raise ValueError(f"{name} is {lifted_to}, so its array needs at least 1 dimension, got shape {result.shape}")
|
|
221
|
+
if result.size == 0:
|
|
222
|
+
raise ValueError(f"{name} returned an empty array, shape {result.shape}")
|
|
223
|
+
shape = result.shape[:-1] if item == "Point" else result.shape
|
|
224
|
+
return list(shape), result.astype(np.float64).ravel().tolist()
|
|
225
|
+
|
|
226
|
+
def __getattr__(name):
|
|
227
|
+
if name.startswith("_"):
|
|
228
|
+
raise AttributeError(name)
|
|
229
|
+
def fn(*args, out=None, hidden=None, fill=None, stroke=None, animate=None):
|
|
230
|
+
return _call(name, args, out=out, hidden=hidden, fill=fill, stroke=stroke, animate=animate)
|
|
231
|
+
return fn
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Matplotlib backend: `plt.show()` sends each open figure to the editor as a PNG, then closes it.
|
|
2
|
+
import base64, io, sys
|
|
3
|
+
import bridge
|
|
4
|
+
from matplotlib._pylab_helpers import Gcf
|
|
5
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
|
|
6
|
+
|
|
7
|
+
def show(*args, **kwargs):
|
|
8
|
+
# Text printed before the plot must reach the editor first.
|
|
9
|
+
sys.stdout.flush()
|
|
10
|
+
sys.stderr.flush()
|
|
11
|
+
for manager in Gcf.get_all_fig_managers():
|
|
12
|
+
buf = io.BytesIO()
|
|
13
|
+
manager.canvas.figure.savefig(buf, format="png")
|
|
14
|
+
bridge.plot(base64.b64encode(buf.getvalue()).decode())
|
|
15
|
+
Gcf.destroy_all()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { NodeStore } from '~/lib/nodes/state/NodeStore';
|
|
2
|
+
import { execute } from '~/lib/nodes/execute';
|
|
3
|
+
import { getItem } from '~/lib/nodes/tensors/tensor';
|
|
4
|
+
import { saveLift, startLift, type InputValue, type PyResult } from './lift';
|
|
5
|
+
|
|
6
|
+
export type RunStatus = 'waiting' | 'loading' | 'running' | 'finished' | 'stopped' | 'killed';
|
|
7
|
+
|
|
8
|
+
interface Run {
|
|
9
|
+
scope: PythonScope;
|
|
10
|
+
code: string;
|
|
11
|
+
onStatus: (status: RunStatus, error?: string) => void;
|
|
12
|
+
onOutput: (text: string) => void;
|
|
13
|
+
/** `png`: a matplotlib figure as a base64 PNG. */
|
|
14
|
+
onPlot: (png: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let worker: Worker | undefined;
|
|
18
|
+
let loaded = false;
|
|
19
|
+
let active: Run | undefined;
|
|
20
|
+
const queue: Run[] = [];
|
|
21
|
+
let nextScopeId = 0;
|
|
22
|
+
|
|
23
|
+
/** A request to rerun the `pie.lift`ed function of node `nodeId` with new input values. */
|
|
24
|
+
interface Rerun {
|
|
25
|
+
scope: PythonScope;
|
|
26
|
+
nodeId: string;
|
|
27
|
+
values: InputValue[];
|
|
28
|
+
done: (result?: PyResult, error?: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Reruns waiting for the running block to end, by scope and node id. A newer request replaces a waiting one.
|
|
32
|
+
const waitingReruns = new Map<string, Rerun>();
|
|
33
|
+
// Reruns sent to the worker, by request id.
|
|
34
|
+
const sentReruns = new Map<number, Rerun>();
|
|
35
|
+
let nextRerunId = 0;
|
|
36
|
+
|
|
37
|
+
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
|
38
|
+
|
|
39
|
+
function onMessage(this: Worker, { data }: MessageEvent) {
|
|
40
|
+
const run = active;
|
|
41
|
+
if (data.type === 'loaded') {
|
|
42
|
+
loaded = true;
|
|
43
|
+
run?.onStatus('running');
|
|
44
|
+
}
|
|
45
|
+
if (data.type === 'call' || data.type === 'item' || data.type === 'lift') {
|
|
46
|
+
// A reply from before a kill must not reach the new worker.
|
|
47
|
+
const reply = (msg: { result?: unknown; error?: string }) => {
|
|
48
|
+
if (worker === this) this.postMessage({ type: 'reply', reqId: data.reqId, ...msg });
|
|
49
|
+
};
|
|
50
|
+
const store = run?.scope.store;
|
|
51
|
+
if (!store) {
|
|
52
|
+
reply({ error: 'this page has no canvas' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (data.type === 'item') {
|
|
56
|
+
try {
|
|
57
|
+
reply({ result: getItem(store, data.arrayId, data.position) });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
reply({ error: message(err) });
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (data.type === 'lift') {
|
|
64
|
+
try {
|
|
65
|
+
reply({ result: startLift(store, data.name, data.inputIds, data.kind, data.out, data.fill, data.stroke) });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
reply({ error: message(err) });
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
execute(store, data.keyword, data.args, data.options).then(
|
|
72
|
+
(result) => reply({ result }),
|
|
73
|
+
(err) => reply({ error: message(err) }),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// No reply: Python doesn't wait for it.
|
|
77
|
+
if (data.type === 'lift_save') {
|
|
78
|
+
const scope = run?.scope;
|
|
79
|
+
try {
|
|
80
|
+
if (scope?.store) saveLift(scope.store, data, (nodeId, values, done) => scope.rerun(nodeId, values, done));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`${data.name}: ${message(err)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (data.type === 'rerun_done') {
|
|
86
|
+
const rerun = sentReruns.get(data.reqId);
|
|
87
|
+
sentReruns.delete(data.reqId);
|
|
88
|
+
rerun?.done(data.result, data.error);
|
|
89
|
+
}
|
|
90
|
+
if (data.type === 'output') {
|
|
91
|
+
run?.onOutput(data.text);
|
|
92
|
+
}
|
|
93
|
+
if (data.type === 'plot') {
|
|
94
|
+
run?.onPlot(data.png);
|
|
95
|
+
}
|
|
96
|
+
if (data.type === 'done') {
|
|
97
|
+
finish(data.error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function startNext() {
|
|
102
|
+
if (active || queue.length === 0) return;
|
|
103
|
+
active = queue.shift()!;
|
|
104
|
+
if (!worker) {
|
|
105
|
+
worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
|
106
|
+
worker.onmessage = onMessage;
|
|
107
|
+
worker.onerror = (e) => finish(e.message || 'Python worker failed to start');
|
|
108
|
+
}
|
|
109
|
+
active.onStatus(loaded ? 'running' : 'loading');
|
|
110
|
+
worker.postMessage({ type: 'run', scope: active.scope.id, code: active.code });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function finish(error?: string) {
|
|
114
|
+
const run = active;
|
|
115
|
+
if (!run) return;
|
|
116
|
+
active = undefined;
|
|
117
|
+
// Pyodide failed to load: start a fresh worker next time instead of reusing the broken one.
|
|
118
|
+
if (!loaded) killWorker();
|
|
119
|
+
if (run.scope.disposed) worker?.postMessage({ type: 'dispose', scope: run.scope.id });
|
|
120
|
+
run.onStatus('finished', error);
|
|
121
|
+
sendReruns();
|
|
122
|
+
startNext();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sends the waiting reruns, unless a block is running. After a kill they wait for the next worker,
|
|
127
|
+
* which answers that Python was restarted.
|
|
128
|
+
*/
|
|
129
|
+
function sendReruns() {
|
|
130
|
+
if (active || !worker) return;
|
|
131
|
+
for (const rerun of waitingReruns.values()) {
|
|
132
|
+
const reqId = nextRerunId++;
|
|
133
|
+
sentReruns.set(reqId, rerun);
|
|
134
|
+
worker.postMessage({ type: 'rerun', reqId, scope: rerun.scope.id, nodeId: rerun.nodeId, values: rerun.values });
|
|
135
|
+
}
|
|
136
|
+
waitingReruns.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function killWorker() {
|
|
140
|
+
worker?.terminate();
|
|
141
|
+
worker = undefined;
|
|
142
|
+
loaded = false;
|
|
143
|
+
// Their replies will never come.
|
|
144
|
+
sentReruns.clear();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Kills Python. The running run and every queued run end as killed. */
|
|
148
|
+
function killPython() {
|
|
149
|
+
killWorker();
|
|
150
|
+
const ended = [...(active ? [active] : []), ...queue];
|
|
151
|
+
active = undefined;
|
|
152
|
+
queue.length = 0;
|
|
153
|
+
for (const run of ended) run.onStatus('killed');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Runs that share Python variables, and the store their `pie.*` calls draw on. */
|
|
157
|
+
export class PythonScope {
|
|
158
|
+
readonly id = String(nextScopeId++);
|
|
159
|
+
disposed = false;
|
|
160
|
+
|
|
161
|
+
/** `store`: where `pie.*` calls draw. Without it they fail with "this page has no canvas". */
|
|
162
|
+
constructor(readonly store?: NodeStore) {}
|
|
163
|
+
|
|
164
|
+
/** Queues `code`. Returns a stop function. `error` comes only with `finished`. */
|
|
165
|
+
run(code: string, onStatus: Run['onStatus'], onOutput: Run['onOutput'], onPlot: Run['onPlot']): () => void {
|
|
166
|
+
const run: Run = { scope: this, code, onStatus, onOutput, onPlot };
|
|
167
|
+
queue.push(run);
|
|
168
|
+
onStatus('waiting');
|
|
169
|
+
startNext();
|
|
170
|
+
|
|
171
|
+
// The stop function (the editor's ■ button): drops the run if queued, kills Python if it's running.
|
|
172
|
+
return () => {
|
|
173
|
+
const at = queue.indexOf(run);
|
|
174
|
+
if (at !== -1) {
|
|
175
|
+
queue.splice(at, 1);
|
|
176
|
+
onStatus('stopped');
|
|
177
|
+
} else if (active === run) {
|
|
178
|
+
killPython();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Queues a rerun of node `nodeId`'s `pie.lift`ed function. It waits while a block runs. */
|
|
184
|
+
rerun(nodeId: string, values: InputValue[], done: Rerun['done']) {
|
|
185
|
+
if (this.disposed) return;
|
|
186
|
+
waitingReruns.set(`${this.id}/${nodeId}`, { scope: this, nodeId, values, done });
|
|
187
|
+
sendReruns();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Frees the variables. Call when the page goes away. */
|
|
191
|
+
dispose() {
|
|
192
|
+
this.disposed = true;
|
|
193
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
194
|
+
if (queue[i].scope === this) queue.splice(i, 1);
|
|
195
|
+
}
|
|
196
|
+
for (const [key, rerun] of waitingReruns) {
|
|
197
|
+
if (rerun.scope === this) waitingReruns.delete(key);
|
|
198
|
+
}
|
|
199
|
+
// A running run still uses the variables; `finish` frees them.
|
|
200
|
+
if (active?.scope !== this) worker?.postMessage({ type: 'dispose', scope: this.id });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import PIE_PY from './py/pie.py?raw';
|
|
2
|
+
import PIE_MPL_PY from './py/pie_mpl.py?raw';
|
|
3
|
+
import SHOW_FIGURES from './py/show_figures.py?raw';
|
|
4
|
+
import type { CallOptions } from '~/lib/nodes/execute';
|
|
5
|
+
|
|
6
|
+
const BASE = 'https://cdn.jsdelivr.net/pyodide/v0.28.2/full/';
|
|
7
|
+
|
|
8
|
+
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
9
|
+
let next = 0;
|
|
10
|
+
|
|
11
|
+
/** Posts `message` to the page and waits for its reply. */
|
|
12
|
+
const request = (message: object) =>
|
|
13
|
+
new Promise((resolve, reject) => {
|
|
14
|
+
const reqId = next++;
|
|
15
|
+
pending.set(reqId, { resolve, reject });
|
|
16
|
+
postMessage({ ...message, reqId });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const call = (keyword: string, args: unknown[], options: CallOptions) =>
|
|
20
|
+
request({ type: 'call', keyword, args, options });
|
|
21
|
+
|
|
22
|
+
/** The node at `position` of an array's items. Creates nothing. */
|
|
23
|
+
const item = (arrayId: string, position: number) => request({ type: 'item', arrayId, position });
|
|
24
|
+
|
|
25
|
+
/** First half of `g(a)` for `g = pie.lift(f, ...)`: the input values and the output id. */
|
|
26
|
+
const lift = (name: string, inputIds: string[], kind: string, out?: string, fill?: string, stroke?: string) =>
|
|
27
|
+
request({ type: 'lift', name, inputIds, kind, out, fill, stroke });
|
|
28
|
+
|
|
29
|
+
/** Second half of `g(a)`: the first result. Python doesn't wait for a reply. */
|
|
30
|
+
const lift_save = (save: object) => postMessage({ type: 'lift_save', ...save });
|
|
31
|
+
|
|
32
|
+
const ready = (async () => {
|
|
33
|
+
const { loadPyodide } = await import(/* @vite-ignore */ `${BASE}pyodide.mjs`);
|
|
34
|
+
const py = await loadPyodide({ indexURL: BASE, env: { MPLBACKEND: 'module://pie_mpl' } });
|
|
35
|
+
py.setStdout({ batched: (text: string) => postMessage({ type: 'output', text: text + '\n' }) });
|
|
36
|
+
py.setStderr({ batched: (text: string) => postMessage({ type: 'output', text: text + '\n' }) });
|
|
37
|
+
py.registerJsModule('bridge', {
|
|
38
|
+
call, item, lift, lift_save, plot: (png: string) => postMessage({ type: 'plot', png }),
|
|
39
|
+
});
|
|
40
|
+
py.FS.writeFile('/home/pyodide/pie.py', PIE_PY);
|
|
41
|
+
py.FS.writeFile('/home/pyodide/pie_mpl.py', PIE_MPL_PY);
|
|
42
|
+
postMessage({ type: 'loaded' });
|
|
43
|
+
return py;
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
// Scope id -> the Python globals its runs share. One scope per page.
|
|
47
|
+
const scopes = new Map<string, any>();
|
|
48
|
+
|
|
49
|
+
const message = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
50
|
+
|
|
51
|
+
self.onmessage = async ({ data }) => {
|
|
52
|
+
if (data.type === 'reply') {
|
|
53
|
+
const { resolve, reject } = pending.get(data.reqId)!;
|
|
54
|
+
pending.delete(data.reqId);
|
|
55
|
+
if (data.error !== undefined) reject(new Error(data.error));
|
|
56
|
+
else resolve(data.result);
|
|
57
|
+
}
|
|
58
|
+
if (data.type === 'run') {
|
|
59
|
+
let error: string | undefined;
|
|
60
|
+
try {
|
|
61
|
+
const py = await ready;
|
|
62
|
+
let globals = scopes.get(data.scope);
|
|
63
|
+
if (!globals) {
|
|
64
|
+
globals = py.globals.get('dict')();
|
|
65
|
+
py.runPython('import pie', { globals });
|
|
66
|
+
scopes.set(data.scope, globals);
|
|
67
|
+
}
|
|
68
|
+
// Downloads packages the code imports (numpy, scipy, matplotlib, ...) on first use.
|
|
69
|
+
await py.loadPackagesFromImports(data.code);
|
|
70
|
+
try {
|
|
71
|
+
await py.runPythonAsync(data.code, { globals }); // Async entry is required for run_sync
|
|
72
|
+
} catch (e) {
|
|
73
|
+
error = message(e);
|
|
74
|
+
}
|
|
75
|
+
// Runs after an error too, so figures drawn before it still show.
|
|
76
|
+
py.runPython(SHOW_FIGURES);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
error ??= message(e);
|
|
79
|
+
}
|
|
80
|
+
postMessage({ type: 'done', error });
|
|
81
|
+
}
|
|
82
|
+
// The page sends reruns only between runs, so this never interleaves with a run.
|
|
83
|
+
if (data.type === 'rerun') {
|
|
84
|
+
let result: unknown;
|
|
85
|
+
let error: string | undefined;
|
|
86
|
+
try {
|
|
87
|
+
const py = await ready;
|
|
88
|
+
const globals = scopes.get(data.scope);
|
|
89
|
+
if (!globals) throw new Error("Python was restarted, so this node can't rerun. Run the block that made it again.");
|
|
90
|
+
const pie = py.pyimport('pie');
|
|
91
|
+
const values = py.toPy(data.values);
|
|
92
|
+
try {
|
|
93
|
+
const out = pie._rerun(globals, data.nodeId, values);
|
|
94
|
+
result = out.toJs({ dict_converter: Object.fromEntries });
|
|
95
|
+
out.destroy();
|
|
96
|
+
} finally {
|
|
97
|
+
values.destroy();
|
|
98
|
+
pie.destroy();
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
error = message(e);
|
|
102
|
+
}
|
|
103
|
+
postMessage({ type: 'rerun_done', reqId: data.reqId, result, error });
|
|
104
|
+
}
|
|
105
|
+
if (data.type === 'dispose') {
|
|
106
|
+
scopes.get(data.scope)?.destroy();
|
|
107
|
+
scopes.delete(data.scope);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { Plugin } from 'vite';
|
|
3
|
+
import { parseKeyValues, readFields, text, type Values } from '../markdown/frontmatter';
|
|
4
|
+
|
|
5
|
+
/** Every field `site.yml` can set. Keys not listed here are ignored. */
|
|
6
|
+
const FIELDS = {
|
|
7
|
+
/** Shown in the header and in every browser tab title. */
|
|
8
|
+
title: text(''),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type Site = Values<typeof FIELDS>;
|
|
12
|
+
|
|
13
|
+
/** Reads `site.yml`. Throws, naming the file, on a bad line or a missing `title`. */
|
|
14
|
+
function parseSite(file: string, yml: string): Site {
|
|
15
|
+
try {
|
|
16
|
+
const site = readFields(FIELDS, parseKeyValues(yml));
|
|
17
|
+
if (!site.title) throw new Error('`title` is required');
|
|
18
|
+
return site;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
throw new Error(`${file}: ${(e as Error).message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turns the root `site.yml` into a module whose default export is a `Site`, at build time.
|
|
26
|
+
* Bad settings fail the build; in dev it shows Vite's error screen.
|
|
27
|
+
*/
|
|
28
|
+
export function siteConfig(): Plugin {
|
|
29
|
+
let root = '';
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: 'site-config',
|
|
33
|
+
enforce: 'pre',
|
|
34
|
+
configResolved(config) {
|
|
35
|
+
root = config.root;
|
|
36
|
+
},
|
|
37
|
+
transform(yml, id) {
|
|
38
|
+
const file = path.relative(root, id.split('?')[0]);
|
|
39
|
+
if (file !== 'site.yml') return;
|
|
40
|
+
return { code: `export default ${JSON.stringify(parseSite(file, yml))};`, map: null };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createAsync, useParams } from '@solidjs/router';
|
|
2
|
+
import { HttpStatusCode } from '@solidjs/start';
|
|
3
|
+
import { Match, Switch } from 'solid-js';
|
|
4
|
+
import 'katex/dist/katex.min.css';
|
|
5
|
+
import { PageTitle } from '~/components/PageTitle';
|
|
6
|
+
import { CanvasPage } from '~/lib/markdown/components/CanvasPage';
|
|
7
|
+
import { MarkdownBody } from '~/lib/markdown/components/MarkdownBody';
|
|
8
|
+
import { loadPage } from '~/lib/markdown/pages';
|
|
9
|
+
|
|
10
|
+
export default function Page() {
|
|
11
|
+
const params = useParams();
|
|
12
|
+
const page = createAsync(() => loadPage(params.slug ?? ''));
|
|
13
|
+
|
|
14
|
+
// `undefined` while loading, `null` when there is no such page.
|
|
15
|
+
// Keyed: a new page gets a fresh canvas and fresh Python variables.
|
|
16
|
+
return (
|
|
17
|
+
<Switch>
|
|
18
|
+
<Match when={page() === null}>
|
|
19
|
+
<main>
|
|
20
|
+
<PageTitle>Page Not Found</PageTitle>
|
|
21
|
+
<HttpStatusCode code={404} />
|
|
22
|
+
<h1>Page Not Found</h1>
|
|
23
|
+
</main>
|
|
24
|
+
</Match>
|
|
25
|
+
<Match when={page()} keyed>
|
|
26
|
+
{(page) => {
|
|
27
|
+
const canvas = page.frontmatter.canvas;
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<PageTitle>{page.frontmatter.title}</PageTitle>
|
|
31
|
+
{canvas === 'none' ? <MarkdownBody html={page.html} /> : <CanvasPage side={canvas} html={page.html} />}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}}
|
|
35
|
+
</Match>
|
|
36
|
+
</Switch>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { layout, type LayoutModules } from "~/lib/layouts/layout";
|
|
2
|
+
|
|
3
|
+
// Shown when the site has no `layouts/index.tsx`.
|
|
4
|
+
function Home() {
|
|
5
|
+
return (
|
|
6
|
+
<main>
|
|
7
|
+
<h1>pie-ssg</h1>
|
|
8
|
+
<h2>A Markdown-based SSG for interactive science explainers</h2>
|
|
9
|
+
<a href="/pages">Pages</a>
|
|
10
|
+
</main>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default layout(import.meta.glob<LayoutModules[string]>("/layouts/index.tsx", { eager: true }), Home);
|