pyctures 0.1.0__py3-none-any.whl

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.
pyctures/__init__.py ADDED
@@ -0,0 +1,890 @@
1
+ """Pyctures: Python 2D and 3D graphics, drawn in a web browser.
2
+
3
+ This module is the Python half of Pyctures. The same source runs in your own
4
+ CPython (``pyctures run``) and in the browser with Pyodide (``pyctures build``):
5
+ the browser half loads this very file as the module ``pyctures``.
6
+
7
+ NAMESPACE MAPPING. ``js``, ``canvas``, ``ctx``, ``THREE`` and ``addons`` are
8
+ proxies for objects that live in the browser page. Using one RECORDS a command;
9
+ nothing crosses to JavaScript until the frame ends (``draw()`` returns,
10
+ ``await frame()``, or ``await flush()``). So a whole frame is one message.
11
+
12
+ - ``a.b`` a lazy reference (nothing sent until it is used)
13
+ - ``a.b(...)`` a call with ``this = a``; the result gets a handle number at
14
+ once, on the Python side, so it can be used in the same frame
15
+ - ``A.B(...)`` a capitalised name is a constructor: ``new A.B(...)``
16
+ - ``a.b = v`` a property write; the value is also remembered here, so
17
+ reading it back (``a.b``) needs no round trip
18
+ - ``a.b += 1`` maths on a value that lives in the page is DEFERRED: it is
19
+ sent as an expression and worked out there
20
+ - ``await a.b`` a real read, answered at the end of the frame
21
+ - keyword args become one trailing options object (``antialias=True``)
22
+ - snake_case ``fill_rect``, ``fill_style`` reach ``fillRect``, ``fillStyle``
23
+ - functions passed as callbacks (addEventListener) come back as events
24
+
25
+ Command shape (the contract with ``web/pyctures.js``). Every command is
26
+ ``[kind, line, ...]``, ``line`` being the user's source line that made it::
27
+
28
+ ['get', line, newH, objH, name]
29
+ ['set', line, objH, name, value]
30
+ ['call', line, newH, objH, name, args]
31
+ ['new', line, newH, objH, name, args]
32
+ ['fcall',line, newH, fnH, args]
33
+ ['fnew', line, newH, fnH, args]
34
+ ['free', line, h]
35
+ ['read', line, readId, value, newH] (newH: where a promise's object lands)
36
+
37
+ Values: plain JSON, or {"$h": h}, {"$e": expr}, {"$cb": id}, {"$d": {...}}.
38
+
39
+ The host calls ``start(source, info_json)`` once, then ``tick(payload_json)``
40
+ once per frame, and ``stop()``. Each returns a JSON reply:
41
+ ``{"cmds", "out", "err", "running", "error", "errorLine"}``.
42
+ """
43
+ import sys, io, json, time, math, traceback, threading, types, linecache
44
+
45
+ __version__ = "0.1.0"
46
+
47
+ VERSION = "10"
48
+ USER_FILE = "main.py"
49
+
50
+ # Fixed handles the frame defines before anything runs.
51
+ H_WINDOW, H_CANVAS, H_THREE, H_CTX, H_ADDONS = 1, 2, 3, 4, 5
52
+
53
+
54
+ class _State:
55
+ def __init__(self):
56
+ self.cmds = []
57
+ self.next_read = 1
58
+ self.reads = {} # read id -> result once answered
59
+ self.callbacks = {} # callback id -> Python function
60
+ self.next_cb = 1
61
+ self.tasks = [] # live coroutines: [coro, waiting]
62
+ self.ns = None # the user's namespace
63
+ self.out = io.StringIO()
64
+ self.err = io.StringIO()
65
+ self.started = 0.0
66
+ self.frame_no = 0
67
+ self.thread = None
68
+ self.busy = False
69
+ self.stopped = False
70
+ self.pictures = {} # (text, size) -> a canvas with the text drawn once
71
+ self.measuring = False # measure mode: time each part of a frame
72
+ self.sections = {} # name -> ms this frame (measure mode)
73
+
74
+
75
+ # WHICH RUN A THREAD IS WORKING FOR. A host may run every frame as its own
76
+ # request on its own thread, and a frame from a run the user has just stopped
77
+ # can still be finishing while the next run starts. If both shared one state,
78
+ # the old frame's commands landed in the new run (objects the new preview never
79
+ # made). So each start()/tick() pins its run's state to its thread, and `S`
80
+ # reads through that pin; with no pin, it is the latest run.
81
+ _tls = threading.local()
82
+ _latest = [_State()]
83
+
84
+
85
+ class _Current:
86
+ __slots__ = ()
87
+
88
+ def __getattr__(self, name):
89
+ return getattr(getattr(_tls, "state", None) or _latest[0], name)
90
+
91
+ def __setattr__(self, name, value):
92
+ setattr(getattr(_tls, "state", None) or _latest[0], name, value)
93
+
94
+
95
+ S = _Current()
96
+
97
+ # Never reset between runs: a reference from the LAST run that is collected
98
+ # during this one sends a 'free' for its number, and that number must not
99
+ # belong to anything new.
100
+ _next_handle = [1000]
101
+
102
+
103
+ def _user_line():
104
+ f = sys._getframe(1)
105
+ while f is not None:
106
+ if f.f_code.co_filename == USER_FILE:
107
+ return f.f_lineno
108
+ f = f.f_back
109
+ return 0
110
+
111
+
112
+ def _emit(*cmd):
113
+ S.cmds.append([cmd[0], _user_line(), *cmd[1:]])
114
+
115
+
116
+ def _new_handle():
117
+ _next_handle[0] += 1
118
+ return _next_handle[0]
119
+
120
+
121
+ class PreviewValueError(TypeError):
122
+ pass
123
+
124
+
125
+ _READ_HINT = ("this value lives in the preview, not in Python — "
126
+ "read it with value = await <expression>")
127
+
128
+
129
+ class _Read:
130
+ """An awaitable for one value the frame has to send back.
131
+
132
+ A PROMISE is awaited in the page: what it resolves to comes back as data,
133
+ or, when it is an object (a GPU adapter, a device, a loaded font), as a
134
+ reference that can be used like any other. Its handle number is reserved
135
+ here, with the read, so the two sides never disagree on it.
136
+ """
137
+ __slots__ = ("rid",)
138
+
139
+ def __init__(self, encoded):
140
+ self.rid = S.next_read
141
+ S.next_read += 1
142
+ _emit("read", self.rid, encoded, _new_handle())
143
+
144
+ def __await__(self):
145
+ result = yield self
146
+ if isinstance(result, dict) and len(result) == 1:
147
+ if "$h" in result:
148
+ return JsRef(result["$h"], owned=True)
149
+ if "$error" in result:
150
+ raise RuntimeError(result["$error"])
151
+ return result
152
+
153
+
154
+ class _Frame:
155
+ """'await frame()' — park until the next frame (also sends this one)."""
156
+ __slots__ = ("until",)
157
+
158
+ def __init__(self, seconds=0.0):
159
+ self.until = time.monotonic() + seconds
160
+
161
+ def __await__(self):
162
+ yield self
163
+
164
+
165
+ def frame():
166
+ return _Frame()
167
+
168
+
169
+ def flush():
170
+ """Send what has been drawn so far, now, and carry on next frame."""
171
+ return _Frame()
172
+
173
+
174
+ def sleep(seconds):
175
+ return _Frame(seconds)
176
+
177
+
178
+ # ---------------------------------------------------------------- expressions
179
+
180
+ class _Expr:
181
+ __slots__ = ("tree",)
182
+
183
+ def __init__(self, tree):
184
+ object.__setattr__(self, "tree", tree)
185
+
186
+ def _enc(self):
187
+ return {"$e": self.tree}
188
+
189
+ def __await__(self):
190
+ return _Read(self._enc()).__await__()
191
+
192
+ def __bool__(self):
193
+ raise PreviewValueError(_READ_HINT)
194
+
195
+ def __repr__(self):
196
+ return "<preview expression>"
197
+
198
+
199
+ def _expr_of(v):
200
+ if isinstance(v, _Expr):
201
+ return v.tree
202
+ if isinstance(v, JsRef):
203
+ return ["ref", v._enc()]
204
+ return ["v", _encode(v)]
205
+
206
+
207
+ def _binop(op):
208
+ def fwd(self, other):
209
+ return _Expr([op, _expr_of(self), _expr_of(other)])
210
+
211
+ def rev(self, other):
212
+ return _Expr([op, _expr_of(other), _expr_of(self)])
213
+ return fwd, rev
214
+
215
+
216
+ for _name, _op in (("add", "+"), ("sub", "-"), ("mul", "*"), ("truediv", "/"),
217
+ ("mod", "%"), ("pow", "**"), ("floordiv", "//")):
218
+ _f, _r = _binop(_op)
219
+ setattr(_Expr, "__%s__" % _name, _f)
220
+ setattr(_Expr, "__r%s__" % _name, _r)
221
+ _Expr.__neg__ = lambda self: _Expr(["neg", _expr_of(self)])
222
+
223
+
224
+ # ---------------------------------------------------------------- references
225
+
226
+ class JsRef:
227
+ """A JavaScript object in the preview frame, seen from Python."""
228
+ __slots__ = ("_h", "_parent", "_name", "_shadow", "_kids", "_owned", "_cmd", "__weakref__")
229
+
230
+ def __init__(self, handle=None, parent=None, name=None, owned=False, cmd=None):
231
+ object.__setattr__(self, "_h", handle)
232
+ object.__setattr__(self, "_parent", parent)
233
+ object.__setattr__(self, "_name", name)
234
+ object.__setattr__(self, "_shadow", {})
235
+ object.__setattr__(self, "_kids", {})
236
+ object.__setattr__(self, "_owned", owned)
237
+ # The unsent command that made this value. While it is still unsent and
238
+ # the value is unused, a discarded result needs no handle at all.
239
+ object.__setattr__(self, "_cmd", cmd)
240
+
241
+ def _use(self):
242
+ object.__setattr__(self, "_cmd", None)
243
+
244
+ # A lazy member becomes a real handle the first time it is used as one.
245
+ def _handle(self):
246
+ self._use()
247
+ if self._h is None:
248
+ h = _new_handle()
249
+ _emit("get", h, self._parent._handle(), self._name)
250
+ object.__setattr__(self, "_h", h)
251
+ object.__setattr__(self, "_owned", True)
252
+ return self._h
253
+
254
+ def _enc(self):
255
+ return {"$h": self._handle()}
256
+
257
+ def __getattr__(self, name):
258
+ if name.startswith("__") and name.endswith("__"):
259
+ raise AttributeError(name)
260
+ self._use()
261
+ shadow = self._shadow
262
+ if name in shadow:
263
+ return shadow[name]
264
+ kid = self._kids.get(name)
265
+ if kid is None:
266
+ kid = JsRef(parent=self, name=name)
267
+ self._kids[name] = kid
268
+ return kid
269
+
270
+ def __setattr__(self, name, value):
271
+ self._use()
272
+ _emit("set", self._handle(), name, _encode(value))
273
+ if isinstance(value, (int, float, str, bool)) or value is None:
274
+ self._shadow[name] = value
275
+ else:
276
+ self._shadow.pop(name, None)
277
+ self._kids.pop(name, None)
278
+
279
+ def __getitem__(self, key):
280
+ return self.__getattr__(str(key))
281
+
282
+ def __setitem__(self, key, value):
283
+ self.__setattr__(str(key), value)
284
+
285
+ def __call__(self, *args, **kwargs):
286
+ enc = [_encode(a) for a in args]
287
+ if kwargs:
288
+ enc.append({"$d": {k: _encode(v) for k, v in kwargs.items()}})
289
+ self._use()
290
+ h = _new_handle()
291
+ if self._parent is not None and self._h is None:
292
+ kind = "new" if self._name[:1].isupper() else "call"
293
+ _emit(kind, h, self._parent._handle(), self._name, enc)
294
+ else:
295
+ _emit("fcall", h, self._handle(), enc)
296
+ return JsRef(h, owned=True, cmd=S.cmds[-1])
297
+
298
+ def new(self, *args, **kwargs):
299
+ enc = [_encode(a) for a in args]
300
+ if kwargs:
301
+ enc.append({"$d": {k: _encode(v) for k, v in kwargs.items()}})
302
+ h = _new_handle()
303
+ if self._parent is not None and self._h is None:
304
+ _emit("new", h, self._parent._handle(), self._name, enc)
305
+ else:
306
+ _emit("fnew", h, self._handle(), enc)
307
+ return JsRef(h, owned=True)
308
+
309
+ def __await__(self):
310
+ if self._h is None and self._parent is not None:
311
+ enc = {"$e": ["get", _expr_of(self._parent), self._name]}
312
+ else:
313
+ enc = self._enc()
314
+ return _Read(enc).__await__()
315
+
316
+ def __bool__(self):
317
+ return True
318
+
319
+ def __del__(self):
320
+ try:
321
+ cmd = self._cmd
322
+ if cmd is not None and S.cmds and any(c is cmd for c in S.cmds[-64:]):
323
+ cmd[2] = 0 # never used and not sent yet: do not keep it
324
+ elif self._owned and self._h is not None and not S.stopped:
325
+ S.cmds.append(["free", 0, self._h])
326
+ except Exception:
327
+ pass
328
+
329
+ def __repr__(self):
330
+ if self._parent is not None and self._h is None:
331
+ return "<preview %s>" % self._name
332
+ return "<preview object #%s>" % self._h
333
+
334
+
335
+ # Arithmetic on a reference to a value in the frame is deferred.
336
+ def _ref_expr(op, rev=False):
337
+ def f(self, other):
338
+ lhs = ["get", _expr_of(self._parent), self._name] if (self._h is None and self._parent is not None) else _expr_of(self)
339
+ rhs = _expr_of(other)
340
+ return _Expr([op, rhs, lhs] if rev else [op, lhs, rhs])
341
+ return f
342
+
343
+
344
+ for _name, _op in (("add", "+"), ("sub", "-"), ("mul", "*"), ("truediv", "/"),
345
+ ("mod", "%"), ("pow", "**"), ("floordiv", "//")):
346
+ setattr(JsRef, "__%s__" % _name, _ref_expr(_op))
347
+ setattr(JsRef, "__r%s__" % _name, _ref_expr(_op, rev=True))
348
+
349
+
350
+ def _cmp_error(self, other):
351
+ raise PreviewValueError(_READ_HINT)
352
+
353
+
354
+ for _name in ("lt", "le", "gt", "ge"):
355
+ setattr(JsRef, "__%s__" % _name, _cmp_error)
356
+ setattr(_Expr, "__%s__" % _name, _cmp_error)
357
+
358
+
359
+ # ---------------------------------------------------------------- values
360
+
361
+ def _encode(v):
362
+ if v is None or isinstance(v, (bool, int, float, str)):
363
+ if isinstance(v, float) and (v != v or v in (float("inf"), float("-inf"))):
364
+ return {"$e": ["num", "NaN" if v != v else ("Infinity" if v > 0 else "-Infinity")]}
365
+ return v
366
+ if isinstance(v, JsRef):
367
+ return v._enc()
368
+ if isinstance(v, _Expr):
369
+ return v._enc()
370
+ if isinstance(v, (list, tuple)):
371
+ return [_encode(x) for x in v]
372
+ if isinstance(v, dict):
373
+ return {"$d": {str(k): _encode(x) for k, x in v.items()}}
374
+ if callable(v):
375
+ cid = S.next_cb
376
+ S.next_cb += 1
377
+ S.callbacks[cid] = v
378
+ return {"$cb": cid}
379
+ tolist = getattr(v, "tolist", None) # numpy arrays and scalars
380
+ if tolist is not None:
381
+ return _encode(tolist())
382
+ return str(v)
383
+
384
+
385
+ class Event(dict):
386
+ """An event from the preview: event.key, event.x … (also event["key"])."""
387
+ def __getattr__(self, name):
388
+ try:
389
+ return self[name]
390
+ except KeyError:
391
+ raise AttributeError(name)
392
+
393
+
394
+ # ---------------------------------------------------------------- input state
395
+
396
+ keys = set()
397
+
398
+
399
+ class _Mouse:
400
+ def __init__(self):
401
+ self.x = 0.0
402
+ self.y = 0.0
403
+ self.down = False
404
+
405
+ def __repr__(self):
406
+ return "mouse(x=%g, y=%g, down=%s)" % (self.x, self.y, self.down)
407
+
408
+
409
+ mouse = _Mouse()
410
+
411
+
412
+ # ---------------------------------------------------------------- sprites
413
+
414
+ class Sprite:
415
+ """A picture (an image, or any emoji/text) that moves. Draws itself with ctx."""
416
+
417
+ def __init__(self, picture, x=0, y=0, size=48):
418
+ self.picture = picture
419
+ self.x, self.y = x, y
420
+ self.vx = self.vy = 0.0
421
+ self.angle = 0.0
422
+ self.size = size
423
+ self.visible = True
424
+
425
+ def update(self, dt):
426
+ self.x += self.vx * dt
427
+ self.y += self.vy * dt
428
+
429
+ def draw(self):
430
+ if not self.visible:
431
+ return
432
+ c = ctx
433
+ c.save()
434
+ c.translate(self.x, self.y)
435
+ if self.angle:
436
+ c.rotate(self.angle)
437
+ s = self.size
438
+ if isinstance(self.picture, str):
439
+ # Emoji/text is drawn ONCE into a small canvas and stamped from then
440
+ # on: redrawing text every frame is the slow path on phones.
441
+ box = s * 1.3
442
+ c.drawImage(_picture(self.picture, s), -box / 2, -box / 2, box, box)
443
+ else:
444
+ c.drawImage(self.picture, -s / 2, -s / 2, s, s)
445
+ c.restore()
446
+
447
+ def touches(self, other):
448
+ r = (self.size + other.size) * 0.4
449
+ return (self.x - other.x) ** 2 + (self.y - other.y) ** 2 < r * r
450
+
451
+
452
+ def _picture(text, size):
453
+ key = (text, size)
454
+ pic = S.pictures.get(key)
455
+ if pic is None:
456
+ ratio = (S.ns or {}).get("pixel_ratio", 1) or 1
457
+ px = max(1, int(size * 1.3 * ratio + 0.5))
458
+ pic = js.document.createElement("canvas")
459
+ pic.width = px
460
+ pic.height = px
461
+ g = pic.getContext("2d")
462
+ g.font = "%dpx system-ui, sans-serif" % int(size * ratio)
463
+ g.textAlign = "center"
464
+ g.textBaseline = "middle"
465
+ g.fillText(text, px / 2, px / 2 + size * ratio * 0.06)
466
+ S.pictures[key] = pic
467
+ return pic
468
+
469
+
470
+ def load_image(src):
471
+ """An image for drawImage or a Sprite. Draws once it has loaded."""
472
+ img = js.Image()
473
+ img.src = src
474
+ return img
475
+
476
+
477
+ # ---------------------------------------------------------------- the namespace
478
+
479
+ js = JsRef(H_WINDOW)
480
+ canvas = JsRef(H_CANVAS)
481
+ THREE = JsRef(H_THREE)
482
+ ctx = JsRef(H_CTX)
483
+ addons = JsRef(H_ADDONS)
484
+
485
+ class measure:
486
+ """Time a section of your frame: `with measure("physics"): ...`. Shown in measure mode."""
487
+ __slots__ = ("name", "t0")
488
+
489
+ def __init__(self, name):
490
+ self.name = str(name)
491
+
492
+ def __enter__(self):
493
+ self.t0 = time.perf_counter() if S.measuring else 0.0
494
+ return self
495
+
496
+ def __exit__(self, *exc):
497
+ if self.t0:
498
+ sec = S.sections
499
+ sec[self.name] = sec.get(self.name, 0.0) + (time.perf_counter() - self.t0) * 1000
500
+ return False
501
+
502
+
503
+ EXPORTS = ("js", "canvas", "ctx", "THREE", "addons", "frame", "flush", "sleep",
504
+ "keys", "mouse", "Sprite", "load_image", "Event", "measure")
505
+
506
+
507
+ # ---------------------------------------------------------------- breakpoints
508
+ #
509
+ # A pause HOLDS the thread running the frame, so only a host that answers
510
+ # other requests meanwhile (one thread per request) can pause. The
511
+ # ``pyctures`` command line does not use breakpoints; they are kept so the
512
+ # module stays the same everywhere it runs. The host asks debug_state() and answers with debug_resume()
513
+ # from those other requests. Only the user's file is traced.
514
+
515
+ class _Debug:
516
+ def __init__(self):
517
+ self.on = False
518
+ self.bps = set()
519
+ self.mode = None # 'over' | 'into' | 'out' after a step
520
+ self.depth = 0
521
+ self.paused = False
522
+ self.line = 0
523
+ self.vars = {}
524
+ self.action = None
525
+ self.seq = 0 # one number per pause, so the host answers each once
526
+
527
+
528
+ DBG = _Debug()
529
+ _STEP = {"stepOver": "over", "stepInto": "into", "stepOut": "out"}
530
+
531
+
532
+ def _user_depth(frame):
533
+ d = 0
534
+ while frame is not None:
535
+ if frame.f_code.co_filename == USER_FILE:
536
+ d += 1
537
+ frame = frame.f_back
538
+ return d
539
+
540
+
541
+ def _vars(frame):
542
+ out = {}
543
+ names = dict(frame.f_globals) if frame.f_globals is not None else {}
544
+ names.update(frame.f_locals)
545
+ for name, value in names.items():
546
+ if name.startswith("_") or name in EXPORTS:
547
+ continue
548
+ if isinstance(value, types.ModuleType) or (callable(value) and not isinstance(value, type)):
549
+ continue
550
+ try:
551
+ if isinstance(value, (int, float, str, bool, type(None))):
552
+ out[name] = {"value": value, "type": type(value).__name__}
553
+ elif isinstance(value, (list, tuple, dict, set)):
554
+ out[name] = {"value": repr(value)[:200], "type": type(value).__name__, "length": len(value)}
555
+ else:
556
+ out[name] = {"value": repr(value)[:200], "type": type(value).__name__}
557
+ except Exception:
558
+ out[name] = {"value": "<unreadable>", "type": type(value).__name__}
559
+ return out
560
+
561
+
562
+ def _local_trace(frame, event, arg):
563
+ if event == "line" and DBG.on and not S.stopped:
564
+ line = frame.f_lineno
565
+ mode = DBG.mode
566
+ d = _user_depth(frame) if mode else 0
567
+ if (line in DBG.bps or mode == "into"
568
+ or (mode == "over" and d <= DBG.depth)
569
+ or (mode == "out" and d < DBG.depth)):
570
+ _pause(frame, line)
571
+ return _local_trace
572
+
573
+
574
+ def _global_trace(frame, event, arg):
575
+ if frame.f_code.co_filename != USER_FILE:
576
+ return None
577
+ return _local_trace
578
+
579
+
580
+ def _pause(frame, line):
581
+ # This run's state, not whatever run is latest: a new run (or a reloaded
582
+ # page) must still release a pause left behind.
583
+ state = getattr(_tls, "state", None) or _latest[0]
584
+ DBG.mode = None
585
+ DBG.line = line
586
+ DBG.vars = _vars(frame)
587
+ DBG.action = None
588
+ DBG.seq += 1
589
+ DBG.paused = True
590
+ try:
591
+ while DBG.action is None and not state.stopped:
592
+ time.sleep(0.02)
593
+ finally:
594
+ DBG.paused = False
595
+ action = DBG.action
596
+ if action == "stop" or state.stopped:
597
+ raise KeyboardInterrupt("Stopped")
598
+ DBG.mode = _STEP.get(action)
599
+ DBG.depth = _user_depth(frame)
600
+
601
+
602
+ def debug_set(lines_json, on):
603
+ DBG.on = bool(on)
604
+ DBG.bps = set(json.loads(lines_json))
605
+ DBG.mode = None
606
+ return "ok"
607
+
608
+
609
+ def debug_state():
610
+ return json.dumps({"paused": DBG.paused, "seq": DBG.seq, "line": DBG.line,
611
+ "vars": DBG.vars if DBG.paused else {}}, default=str)
612
+
613
+
614
+ def debug_resume(action):
615
+ DBG.action = action
616
+ return "ok"
617
+
618
+
619
+ class _Traced:
620
+ """Trace the user's file for the length of one start() or tick()."""
621
+ def __enter__(self):
622
+ if DBG.on:
623
+ sys.settrace(_global_trace)
624
+
625
+ def __exit__(self, *exc):
626
+ if DBG.on:
627
+ sys.settrace(None)
628
+ return False
629
+
630
+
631
+ # ---------------------------------------------------------------- running
632
+
633
+ def _drive(task, value=None):
634
+ """Step one coroutine until it waits; returns False when it has finished."""
635
+ coro = task[0]
636
+ try:
637
+ waiting = coro.send(value)
638
+ except StopIteration:
639
+ return False
640
+ task[1] = waiting
641
+ return True
642
+
643
+
644
+ def _format_error(e):
645
+ tb = traceback.extract_tb(e.__traceback__)
646
+ user = [fr for fr in tb if fr.filename == USER_FILE]
647
+ lines = ["Traceback (most recent call last):"]
648
+ for fr in user:
649
+ lines.append(' line %d, in %s' % (fr.lineno, fr.name))
650
+ if fr.line:
651
+ lines.append(" " + fr.line.strip())
652
+ line = user[-1].lineno if user else 0
653
+ if isinstance(e, SyntaxError) and e.filename == USER_FILE and e.lineno:
654
+ # A mistake in how the file is written: it never ran, so there is no
655
+ # traceback into it, only the line the parser stopped at.
656
+ line = e.lineno
657
+ lines.append(" line %d" % line)
658
+ if e.text:
659
+ lines.append(" " + e.text.strip())
660
+ lines.append("%s: %s" % (type(e).__name__, e.msg))
661
+ return "\n".join(lines), line
662
+ lines.append("%s: %s" % (type(e).__name__, e))
663
+ return "\n".join(lines), line
664
+
665
+
666
+ def _reply(error=None, error_line=0):
667
+ cmds, S.cmds = S.cmds, []
668
+ out = S.out.getvalue()
669
+ err = S.err.getvalue()
670
+ S.out = io.StringIO()
671
+ S.err = io.StringIO()
672
+ running = S.ns is not None and not error and (
673
+ bool(S.tasks) or bool(S.callbacks)
674
+ or any(callable(S.ns.get(n)) for n in _LIVE_NAMES))
675
+ reply = {
676
+ "cmds": cmds, "out": out, "err": err, "running": running,
677
+ "error": error, "errorLine": error_line,
678
+ }
679
+ if S.measuring:
680
+ reply["t"] = S.sections
681
+ S.sections = {}
682
+ return json.dumps(reply, default=str)
683
+
684
+
685
+ _LIVE_NAMES = ("draw", "update", "on_key_down", "on_key_up",
686
+ "on_mouse_down", "on_mouse_move", "on_mouse_up")
687
+
688
+
689
+ def _apply_info(p):
690
+ ns = S.ns
691
+ if ns is None:
692
+ return
693
+ for k in ("width", "height", "pixel_ratio"):
694
+ if k in p:
695
+ ns[k] = p[k]
696
+
697
+
698
+ def _apply_events(p):
699
+ ns = S.ns
700
+ for ev in p.get("events", ()):
701
+ e = Event(ev.get("e") or {})
702
+ kind = ev.get("k")
703
+ if kind == "cb":
704
+ fn = S.callbacks.get(ev.get("id"))
705
+ if fn is not None:
706
+ r = fn(e)
707
+ if hasattr(r, "send"):
708
+ # Kept only while it is still waiting: a callback that
709
+ # finishes without awaiting must not be driven again.
710
+ task = [r, None]
711
+ if _drive(task):
712
+ S.tasks.append(task)
713
+ continue
714
+ t = e.get("type")
715
+ if t == "keydown":
716
+ keys.add(e.get("key"))
717
+ fn = ns.get("on_key_down")
718
+ if callable(fn):
719
+ fn(e.get("key"))
720
+ elif t == "keyup":
721
+ keys.discard(e.get("key"))
722
+ fn = ns.get("on_key_up")
723
+ if callable(fn):
724
+ fn(e.get("key"))
725
+ elif t in ("pointerdown", "pointermove", "pointerup"):
726
+ mouse.x, mouse.y = e.get("x", mouse.x), e.get("y", mouse.y)
727
+ if t == "pointerdown":
728
+ mouse.down = True
729
+ elif t == "pointerup":
730
+ mouse.down = False
731
+ fn = ns.get({"pointerdown": "on_mouse_down", "pointermove": "on_mouse_move",
732
+ "pointerup": "on_mouse_up"}[t])
733
+ if callable(fn):
734
+ fn(mouse.x, mouse.y)
735
+
736
+
737
+ def _step_tasks(answers):
738
+ now = time.monotonic()
739
+ alive = []
740
+ for task in S.tasks:
741
+ waiting = task[1]
742
+ if isinstance(waiting, _Read):
743
+ if waiting.rid not in answers:
744
+ alive.append(task)
745
+ continue
746
+ keep = _drive(task, answers[waiting.rid])
747
+ elif isinstance(waiting, _Frame):
748
+ if waiting.until > now:
749
+ alive.append(task)
750
+ continue
751
+ keep = _drive(task)
752
+ else:
753
+ keep = _drive(task)
754
+ if keep:
755
+ alive.append(task)
756
+ S.tasks = alive
757
+
758
+
759
+ def _call_frame_functions(dt):
760
+ ns = S.ns
761
+ for name, args in (("update", (dt,)), ("draw", ())):
762
+ fn = ns.get(name)
763
+ if not callable(fn):
764
+ continue
765
+ if any(t[2:] == [name] for t in S.tasks):
766
+ continue # last frame's async draw is still waiting on a read
767
+ takes = getattr(getattr(fn, "__code__", None), "co_argcount", 1)
768
+ t0 = time.perf_counter() if S.measuring else 0.0
769
+ r = fn(*args) if (name != "update" or takes) else fn()
770
+ if t0:
771
+ S.sections[name] = S.sections.get(name, 0.0) + (time.perf_counter() - t0) * 1000
772
+ if hasattr(r, "send"):
773
+ task = [r, None, name]
774
+ if _drive(task):
775
+ S.tasks.append(task)
776
+
777
+
778
+ def start(source, info_json):
779
+ """Run the file's top level. Returns the first frame's reply (JSON)."""
780
+ _latest[0].stopped = True
781
+ _latest[0] = _State()
782
+ _tls.state = _latest[0]
783
+ # FRESH ROOT NAMES EVERY RUN. A name like `js` remembers what was looked
784
+ # up through it (js.document, THREE.DoubleSide) as handles in the preview
785
+ # page; a new run has a new page, so reused names pointed at objects that
786
+ # no longer exist ("that object no longer exists in the preview").
787
+ global js, canvas, THREE, ctx, addons
788
+ js, canvas, THREE = JsRef(H_WINDOW), JsRef(H_CANVAS), JsRef(H_THREE)
789
+ ctx, addons = JsRef(H_CTX), JsRef(H_ADDONS)
790
+ keys.clear()
791
+ mouse.x = mouse.y = 0.0
792
+ mouse.down = False
793
+ S.thread = threading.get_ident()
794
+ S.busy = True
795
+ S.started = time.monotonic()
796
+ info = json.loads(info_json)
797
+ S.measuring = bool(info.get("measure"))
798
+ ns = {"__name__": "__main__", "__builtins__": __builtins__}
799
+ for name in EXPORTS:
800
+ ns[name] = globals()[name]
801
+ S.ns = ns
802
+ _apply_info(info)
803
+ old_out, old_err = sys.stdout, sys.stderr
804
+ sys.stdout, sys.stderr = S.out, S.err
805
+ tracer = _Traced()
806
+ try:
807
+ tracer.__enter__()
808
+ import ast
809
+ flags = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0)
810
+ # Tracebacks quote the line that failed: let them find the source.
811
+ linecache.cache[USER_FILE] = (len(source), None, source.splitlines(True), USER_FILE)
812
+ code = compile(source, USER_FILE, "exec", flags=flags)
813
+ result = eval(code, ns)
814
+ if hasattr(result, "send"):
815
+ task = [result, None]
816
+ if _drive(task):
817
+ S.tasks.append(task)
818
+ if callable(ns.get("setup")):
819
+ r = ns["setup"]()
820
+ if hasattr(r, "send"):
821
+ task = [r, None]
822
+ if _drive(task):
823
+ S.tasks.append(task)
824
+ _call_frame_functions(0.0)
825
+ except BaseException as e: # noqa: BLE001 — every failure is the user's to read
826
+ msg, line = _format_error(e)
827
+ S.ns = None
828
+ return _reply(msg, line)
829
+ finally:
830
+ tracer.__exit__()
831
+ sys.stdout, sys.stderr = old_out, old_err
832
+ S.busy = False
833
+ return _reply()
834
+
835
+
836
+ def tick(payload_json):
837
+ """One frame: events and read answers in, this frame's commands out (JSON)."""
838
+ _tls.state = _latest[0]
839
+ if S.ns is None:
840
+ return _reply("Nothing is running", 0)
841
+ S.thread = threading.get_ident()
842
+ S.busy = True
843
+ p = json.loads(payload_json)
844
+ S.frame_no += 1
845
+ S.measuring = bool(p.get("measure"))
846
+ t_tick = time.perf_counter() if S.measuring else 0.0
847
+ old_out, old_err = sys.stdout, sys.stderr
848
+ sys.stdout, sys.stderr = S.out, S.err
849
+ tracer = _Traced()
850
+ try:
851
+ tracer.__enter__()
852
+ if "bps" in p:
853
+ DBG.bps = set(p["bps"])
854
+ _apply_info(p)
855
+ answers = {int(k): v for k, v in (p.get("reads") or {}).items()}
856
+ _apply_events(p)
857
+ _step_tasks(answers)
858
+ _call_frame_functions(float(p.get("dt", 0.0)))
859
+ except BaseException as e: # noqa: BLE001
860
+ msg, line = _format_error(e)
861
+ S.ns = None
862
+ return _reply(msg, line)
863
+ finally:
864
+ tracer.__exit__()
865
+ sys.stdout, sys.stderr = old_out, old_err
866
+ S.busy = False
867
+ if t_tick:
868
+ S.sections["python"] = (time.perf_counter() - t_tick) * 1000
869
+ return _reply()
870
+
871
+
872
+ def stop():
873
+ """Stop: drop the program, and interrupt it if it is stuck in a loop."""
874
+ _tls.state = _latest[0]
875
+ S.stopped = True
876
+ S.ns = None
877
+ S.tasks = []
878
+ DBG.action = "stop"
879
+ tid = S.thread
880
+ # Only while a frame is actually running: a finished frame's thread id may
881
+ # already belong to another request.
882
+ if S.busy and tid is not None and tid != threading.get_ident():
883
+ try:
884
+ import ctypes
885
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), ctypes.py_object(KeyboardInterrupt))
886
+ except Exception:
887
+ pass
888
+ return "stopped"
889
+
890
+