From 3c0670431e3f33b9ba845001ab490dda71a8f0fb Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 03:21:03 -0400 Subject: [PATCH 01/16] build: allow a pure-Python wheel, and drop pkg_resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together let `vpython` be installed and imported under Pyodide/wasm. Both are opt-in or invisible to existing users: an ordinary `python -m build` still produces the same platform wheel it always did, and `install_requires` is untouched. 1. `VPYTHON_PURE_PYTHON=1` builds with no C extension, producing a `py3-none-any` wheel. micropip (and any other wasm installer) cannot install a platform wheel, and merely DECLARING `ext_modules` is what makes every wheel a platform wheel — so the key is now omitted entirely for that build rather than set to an empty list, which would not have changed the tag. Nothing is lost but speed: `_vector_import_helper` already falls back to the pure-Python `vector` when `cyvector` is unavailable. 2. `vpython/__init__.py` uses `importlib.metadata` instead of `pkg_resources`. pkg_resources is deprecated upstream and simply absent in Pyodide, so importing vpython there failed on the package's very first line. `python_requires` moves to >=3.8 accordingly (importlib.metadata is 3.8+; `notebook>=7` already required 3.8+, so this narrows nothing in practice). Verified in a real Pyodide 3.13.2 Web Worker, installing this branch's own pure wheel with `deps=False` and no setuptools present: OK vpython.vector OK vpython.vpython PACKAGE OK __version__ = 7.6.5 __gs_version__ = 3.2 `__gs_version__` resolving also confirms `gs_version.py` reads the bundled glow.min.js fine there, and that it is on the same 3.2 line trinket serves. Both builds were checked: with the flag, `vpython-7.6.5-py3-none-any.whl`; without it, `vpython-7.6.5-cp314-cp314-macosx_10_15_universal2.whl` as before. Execution now reaches the transport seam — `sphere()` stops at vpython.py:267 `from .no_notebook import _` (autobahn), which is the next piece of work, not a packaging problem. --- setup.py | 36 ++++++++++++++++++++++++++++-------- vpython/__init__.py | 3 ++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/setup.py b/setup.py index bcd9cc3..268acfa 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,28 @@ +import os + from setuptools import setup from distutils.extension import Extension -try: - from Cython.Build import cythonize - USE_CYTHON = True - extensions = cythonize('vpython/cyvector.pyx') -except ImportError: - extensions = [Extension('vpython.cyvector', ['vpython/cyvector.c'])] +# Set VPYTHON_PURE_PYTHON=1 to build with no C extension at all. The result is a +# `py3-none-any` wheel, which is what Pyodide/micropip and other wasm targets +# require — they cannot install a platform wheel, and declaring `ext_modules` is +# what makes every wheel a platform wheel. +# +# Nothing is lost but speed: `_vector_import_helper` already falls back to the +# pure-Python `vector` implementation when `cyvector` cannot be imported, so the +# same source runs either way. Ordinary builds are unchanged — this is opt-in. +PURE_PYTHON = os.environ.get('VPYTHON_PURE_PYTHON', '').strip() not in ('', '0', 'false', 'False') + +if PURE_PYTHON: + extensions = [] +else: + try: + from Cython.Build import cythonize + USE_CYTHON = True + extensions = cythonize('vpython/cyvector.pyx') + except ImportError: + extensions = [Extension('vpython.cyvector', ['vpython/cyvector.c'])] install_requires = ['jupyter', 'jupyter-server-proxy', 'jupyterlab-vpython>=3.1.8', 'notebook>=7.0.0', 'numpy', 'ipykernel', @@ -35,14 +50,19 @@ 'Topic :: Multimedia :: Graphics :: 3D Rendering', 'Topic :: Scientific/Engineering :: Visualization', ], - ext_modules=extensions, install_requires=install_requires, - python_requires=">=3.8", + python_requires=">=3.8", # importlib.metadata, used in vpython/__init__.py package_data={'vpython': ['vpython_data/*', 'vpython_libraries/*', 'vpython_libraries/images/*']}, ) +# `ext_modules` is added only for a normal build. Omitting the key entirely (as +# opposed to passing an empty list) is what makes setuptools tag the wheel +# `py3-none-any` rather than a platform wheel. +if not PURE_PYTHON: + setup_args['ext_modules'] = extensions + try: setup(**setup_args) except SystemExit as e: diff --git a/vpython/__init__.py b/vpython/__init__.py index e46c197..262d5e6 100644 --- a/vpython/__init__.py +++ b/vpython/__init__.py @@ -1,6 +1,7 @@ # importlib.metadata, not pkg_resources: fresh Python 3.12+ environments no # longer ship setuptools, so `import pkg_resources` raises ModuleNotFoundError -# the moment `import vpython` runs (caught by CI's macos-3.12 leg). +# the moment `import vpython` runs (caught by CI's macos-3.12 leg). The same +# import is simply absent on Pyodide/wasm, so this also unblocks wasm targets. from importlib.metadata import version as _dist_version, PackageNotFoundError from .gs_version import glowscript_version From b64d142bef22c9ef56a7c208a55dff6b2d8108fe Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 03:38:06 -0400 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20a=20wasm/Web-Worker=20transport?= =?UTF-8?q?=20=E2=80=94=20the=20host=20supplies=20the=20pipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third transport beside with_notebook (Jupyter Comm) and no_notebook (http.server + autobahn in threads). In a Web Worker none of that exists — no Jupyter, no servers, no threads — but the host JS environment has postMessage. So the contract is two functions on the JS global scope: * the host defines __trinket_vpython_send(jsonString) BEFORE importing vpython; every outbound update package (and the 'trigger' handshake) goes through it * this module sets __trinket_vpython_dispatch(jsonArrayOfEvents); the host calls it as browser events arrive, and every call — including a bare trigger — is answered with a flush, the same request/reply rhythm the websocket transport uses, paced by the browser's ~33 ms canvas_update timer Selection is sys.platform == 'emscripten' in baseObj.__init__, tried after _isnotebook so a notebook running under Pyodide (JupyterLite) still gets its Comm. Importing the module IS the setup, exactly like the other two: GlowWidget() first (it nulls the module-global sender outside a notebook), then install ours, then an initial trigger() so the scene canvas buffered during `import vpython` flushes and baseObj.sent unblocks appendcmd. Verified end-to-end in trinket's real #108 worker (Pyodide 3.13.2, this branch's pure wheel, deps=False), with send captured on the Python side: MSG 0: {"cmds": [{"cmd": "canvas", ...}, {"cmd": "distant_light", ...} x2]} MSG 1: {"cmds": [{"cmd": "sphere", "idx": 4, "color": [1,0,0], ...}], "attrs": ["a4a1,2,3"]} MSG 1's attrs entry is ball.pos = vector(1,2,3) in the protocol's compact per-attribute wire coding — constructors, attribute updates and the browser-driven flush all cross the seam. What remains is the OTHER half of the pipe: a browser-side consumer of this stream (glowcomm.js reads the identical format from a Comm), which lives in the embedding host, not here. --- vpython/trinket_worker.py | 79 +++++++++++++++++++++++++++++++++++++++ vpython/vpython.py | 4 ++ 2 files changed, 83 insertions(+) create mode 100644 vpython/trinket_worker.py diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py new file mode 100644 index 0000000..154fe40 --- /dev/null +++ b/vpython/trinket_worker.py @@ -0,0 +1,79 @@ +"""Transport for Pyodide/wasm hosts — e.g. trinket's #108 Web Worker. + +The notebook transport talks over a Jupyter Comm; the standalone transport +stands up an ``http.server`` plus autobahn websockets in threads. Neither +exists inside a Web Worker: there is no Jupyter, no server, and no threads. +What a worker does have is a host JS environment with ``postMessage`` — so +here the host supplies the pipe and this module supplies the seam. + +The contract with the host, kept deliberately tiny: + +* Before ``import vpython``, the host defines ``__trinket_vpython_send`` on + the JS global scope: a function of one argument, a JSON string. Every + outbound update package (and the bare ``"trigger"`` handshake) goes through + it. What the host does with the string — ``postMessage`` it to a page, hand + it to a renderer — is its business. + +* This module sets ``__trinket_vpython_dispatch`` on the JS global scope: a + function of one argument, a JSON **array** of browser events in glowcomm's + wire format. The host calls it whenever events arrive. Each call processes + the events and then flushes buffered updates back out through + ``__trinket_vpython_send`` — the same request/reply rhythm the websocket + transport uses, with the host (ultimately the browser's ~33 ms + ``canvas_update`` timer) setting the pace. + +Like the other transports, importing this module IS the setup; it ends by +binding the module-global ``sender`` and starting the update ping-pong. +""" + +import json + +import js +from pyodide.ffi import create_proxy + +from .vpython import GlowWidget, baseObj +from . import vpython as _protocol + + +def _send(msg): + """Ship one update package (or the 'trigger' handshake) to the host. + + ``trigger()`` hands us either the bare string ``'trigger'`` or the + ``{cmds, methods, attrs}`` dict from ``baseObj.package``. Encode + uniformly; the receiving side re-parses. + """ + js.__trinket_vpython_send(json.dumps(msg)) + + +def _dispatch(events_json): + """Process a JSON array of browser events, then flush updates back. + + Mirrors the websocket transports: events go one at a time to + ``handle_msg`` (bound-event handlers rely on that), and every inbound + message — including a bare trigger with no events — is answered with a + flush. ``_isnotebook`` is False here, so ``handle_msg`` does not trigger + by itself; the flush below is that reply. + """ + events = json.loads(events_json) if events_json else [] + for evt in events: + if isinstance(evt, dict) and 'trigger' in evt: + continue # pacing only; the flush below answers it + baseObj.glow.handle_msg({'content': {'data': [evt]}}) + baseObj.trigger() + + +# GlowWidget() records itself as baseObj.glow. Outside a notebook it sets the +# module-global sender to None, so ours is installed after it. +GlowWidget() +_protocol.sender = _send + +js.__trinket_vpython_dispatch = create_proxy(_dispatch) + +# Start the ping-pong exactly as with_notebook does: the first trigger() +# flushes anything already buffered (the scene canvas constructed during +# `import vpython` is sitting in the buffer at this point) and sets +# baseObj.sent, which appendcmd()/addmethod() spin on. +baseObj.trigger() + +# Dummy name to import, matching the other transports. +_ = None diff --git a/vpython/vpython.py b/vpython/vpython.py index 1fa951d..c8d02cb 100644 --- a/vpython/vpython.py +++ b/vpython/vpython.py @@ -263,6 +263,10 @@ def __init__(self, **kwargs): baseObj._canvas_constructing): if _isnotebook: from .with_notebook import _ + elif sys.platform == 'emscripten': + # Pyodide/wasm (e.g. a Web Worker): no Jupyter, no servers, no + # threads. The host JS environment supplies the pipe instead. + from .trinket_worker import _ else: from .no_notebook import _ baseObj._view_constructed = True From ae16fc760c1891bcef45d0af0c7fc2c4e52c0dd3 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 05:06:48 -0400 Subject: [PATCH 03/16] feat: host-agnostic browser front-end ported from glowcomm.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glowcomm.js is the Jupyter notebook front end: a Comm/WebSocket, nbextension font loading, a setTimeout pacing loop, and GlowScript constructors it expects to find on window. Underneath that is the actual wire protocol — turn a decoded {cmds, methods, attrs} package into GlowScript objects — which is host-neutral and is what any embedding (Jupyter, JupyterLab, a Pyodide Web Worker page) needs. glowcomm_host.js is that core, extracted as a factory: createGlowFrontend({container, send, glow}) -> {handle(ops), reset(), destroy()} Ported verbatim (normalized-diff clean against glowcomm.js): the attrs/attrsb/ methods/vecattrs/textattrs tables and patterns, decode, fix_location, o2vec3, handler (as handle), handle_cmds, handle_methods, handle_attrs. Not ported (host's job / later work): the Comm + WebSocket setup, fontloading/ checkloading, domessage/onmessage, send/msclock/update_canvas, send_to_server/ok, send_pick/send_compound, and process/process_pause/process_waitfor/ process_binding/control_handler. Deliberate departures from a byte-for-byte port: - Constructors come off the injected `glow` registry (defaults to globalThis), so `sphere(cfg)` is `glow.sphere(cfg)`. Same for vec/print_anchor. - Per-function "use strict" replaced by one file-level directive. - handle() decodes a shallow copy, and handle_cmds copies each cmd before deleting cmd/idx/method from it. glowcomm.js owned and discarded the Comm payload; here the package belongs to the caller and may be replayed. - The canvas case drops the JupyterLab '#glowscript' lookup and the jQuery contextmenu handler; the host's container is published on globalThis.__context instead. (A host that wants the right-click suppression can add it itself.) - `instanceof` on vec/curve/points goes through is_a(), which returns false instead of throwing when the injected registry is not constructible (unit test stubs). Identical result for real GlowScript classes. - glowcomm.js:889 reads `ifif (val == 'None')`, an upstream typo that makes the file unparseable; glowcomm.html has the same line correctly as `if`. Ported as `if`, with a comment. Deferred call sites — each now hits a local stub that console.warns "glowcomm_host: not wired yet" and forwards a glowcomm-shaped event array to opts.send. Wiring them to the host channel is a later task: send_compound <- handle_cmds 'compound' (non-clone), 'extrusion', 'text' (non-clone) send_pick <- handle_methods 'pick' process_binding<- handle_methods 'bind', 'unbind' process_waitfor<- handle_methods 'waitfor' process_pause <- handle_methods 'pause' control_handler<- handle_cmds 'winput', 'checkbox', 'radio', 'button', 'slider', 'menu' Exercised by the consumer's unit suite (5 tests) against captured transport packages and a stub glow registry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- vpython/vpython_libraries/glowcomm_host.js | 683 +++++++++++++++++++++ 1 file changed, 683 insertions(+) create mode 100644 vpython/vpython_libraries/glowcomm_host.js diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js new file mode 100644 index 0000000..d60b9db --- /dev/null +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -0,0 +1,683 @@ +// glowcomm_host.js — host-agnostic browser front-end for the VPython wire protocol. +// +// Ported from glowcomm.js, which is the Jupyter-notebook front end: it owns a +// Comm/WebSocket, loads fonts out of the nbextensions data directory, paces +// itself with a setTimeout loop, and calls GlowScript constructors that Jupyter +// happens to have put on `window`. Everything in that list is the *host's* job. +// +// What is left after removing it is the part that is actually the protocol: turn +// a decoded {cmds, methods, attrs} package into GlowScript objects. That is this +// file. It knows nothing about Jupyter, websockets, Pyodide, or any particular +// embedding page; the host supplies the GlowScript constructor registry, a +// container element, and a send() for the event channel. +// +// var fe = createGlowFrontend({container: el, send: fn, glow: window}) +// fe.handle(ops) // ops is the parsed {cmds, methods, attrs} package, or 'trigger' +// fe.reset() // forget every object (new scene generation) +// fe.destroy() // reset + ask the objects to remove themselves +// +// Event capture (mouse/key/widget events and canvas polling) is NOT ported yet: +// the call sites that need it are stubbed and warn — see "not wired yet" below. + +'use strict'; + +function createGlowFrontend(opts) { + opts = opts || {}; + + // The GlowScript constructor registry. glowcomm.js called `sphere(cfg)` and + // friends as bare globals; here they come off `glow` so a test (or a second + // scene) can supply its own. + var glow = opts.glow || globalThis; + // Where the host wants the canvas to appear (may be null; see 'canvas' below). + var container = opts.container || null; + // Host's outbound channel for events. Only the deferred stubs use it today. + var send = opts.send || null; + + // Every object Python has created, indexed by the idx Python assigned. + var glowObjs = []; + + // glowcomm.js could rely on `vec`/`curve`/`points` being real constructors on + // the page, so it used a bare `instanceof`. Here the registry is injected and + // may not be constructible (the unit tests pass plain functions), which makes + // `instanceof` throw. Same answer as `instanceof` for real GlowScript classes. + function is_a(value, Ctor) { + try { return typeof Ctor === 'function' && value instanceof Ctor; } + catch (e) { return false; } + } + + // glowcomm.js decoded and consumed the Comm payload in place (it owned that + // object and threw it away afterwards). Here the package belongs to the + // caller and may be replayed or inspected, so the two mutating paths — + // decode() rewriting data.attrs, and handle_cmds() deleting cmd/idx/method — + // work on shallow copies instead. + function shallow_copy(obj) { + var copy = {}, k; + for (k in obj) { + if (Object.prototype.hasOwnProperty.call(obj, k)) copy[k] = obj[k]; + } + return copy; + } + + // --------------------------------------------------------------------------- + // Deferred: the event/control channel back to Python. glowcomm.js implemented + // these by pushing onto an `events` list that a paced send() drained over the + // Comm. Wiring them to opts.send is a later task; until then they are loud. + // --------------------------------------------------------------------------- + + function not_wired(feature, events) { + if (typeof console !== 'undefined') console.warn('glowcomm_host: ' + feature + ' not wired yet'); + if (send) send(events); + } + + function send_pick(cvs, p, seg) { + not_wired('pick', [{event: 'pick', 'canvas': cvs, 'pick': p, 'segment': seg}]); + } + + function send_compound(cvs, pos, size, up) { + not_wired('compound/extrusion/text measurement', [{event: '_compound', 'canvas': cvs, + 'pos': [pos.x, pos.y, pos.z], + 'size': [size.x, size.y, size.z], 'up': [up.x, up.y, up.z]}]); + } + + function process_binding(event) { // event associated with a previous bind command + not_wired('event bindings', [{event: event && event.type, bind: true}]); + } + + function process_waitfor(event) { + not_wired('waitfor', [{event: event && event.type, bind: true}]); + } + + function process_pause() { + not_wired('pause', [{event: 'click'}]); + } + + function control_handler(obj) { // button, menu, slider, radio, checkbox, winput + not_wired('widgets', [{idx: obj && obj.idx, widget: obj && obj.objName}]); + } + + var waitfor_canvas = null; + var waitfor_options = null; + // possible event types to bind: + var binds = ['mousedown', 'mouseup', 'mousemove', 'click', 'mouseenter', 'mouseleave', + 'keydown', 'keyup', 'redraw', 'draw_complete', 'resize']; + + // --------------------------------------------------------------------------- + // The wire format. Ported verbatim from glowcomm.js. + // --------------------------------------------------------------------------- + + // attrs are X in {'a': '23X....'} available: none + var attrs = {'a':'pos', 'b':'up', 'c':'color', 'd':'trail_color', // don't use single and double quotes; available: comma, but maybe that would cause trouble + 'e':'ambient', 'f':'axis', 'g':'size', 'h':'origin', 'i':'textcolor', + 'j':'direction', 'k':'linecolor', 'l':'bumpaxis', 'm':'dot_color', + 'n':'foreground', 'o':'background', 'p':'ray', 'E':'center', '#':'forward', '+':'resizable', + + // scalar attributes + 'q':'graph', 'r':'canvas', 's':'trail_radius', + 't':'visible', 'u':'opacity', 'v':'shininess', 'w':'emissive', + 'x':'make_trail', 'y':'trail_type', 'z':'interval', 'A':'pps', 'B':'retain', + 'C':'red', 'D':'green', 'E':'ccw', 'F':'blue', 'G':'length', 'H':'width', 'I':'height', 'J':'radius', + 'K':'thickness', 'L':'shaftwidth', 'M':'headwidth', 'N':'headlength', 'O':'pickable', + 'P':'coils', 'Q':'xoffset', 'R':'yoffset', + 'S':'border', 'T':'line', 'U':'box', 'V':'space', 'W':'linewidth', + 'X':'xmin', 'Y':'xmax', 'Z':'ymin', '`':'ymax', + '~':'ctrl', '!':'shift', '@':'alt', + + // text attributes: + '$':'text', '%':'align', '^':'caption', + '-':'fast','&':'title', '*':'xtitle', '(':'ytitle', + + // Miscellany: + ')':'lights', '_':'objects', '=':'bind', + '[':'pixel_pos', ']':'texpos', + '{':'v0', '}':'v1', ';':'v2', ':':'v3', '<':'vs', '>':'type', + '?':'font', '/':'texture'}; + + // attrsb are X in {'b': '23X....'}; ran out of easily typable one-character codes + var attrsb = {'a':'userzoom', 'b':'userspin', 'c':'range', 'd':'autoscale', 'e':'fov', + 'f':'normal', 'g':'data', 'h':'checked', 'i':'disabled', 'j':'selected', + 'k':'vertical', 'l':'min', 'm':'max', 'n':'step', 'o':'value', + 'p':'left', 'q':'right', 'r':'top', 's':'bottom', 't':'_cloneid', + 'u':'logx', 'v':'logy', 'w':'dot', 'x':'dot_radius', + 'y':'markers', 'z':'legend', 'A':'label','B':'delta', 'C':'marker_color', + 'D':'size_units', 'E':'userpan', 'F':'scroll', 'G':'choices', 'H':'depth', 'I':'round', + 'J':'name', 'K':'offset', 'L':'attach_idx', 'M':'ccw' + }; + + // methods are X in {'m': '23X....'} + var methods = {'a':'select', 'b':'pos', 'c':'start', 'd':'stop', 'f':'clear', // unused eghijklmnopvxyzCDFAB + 'q':'plot', 's':'add_to_trail', + 't':'follow', 'u':'_attach_arrow', 'w':'clear_trail', + 'G':'bind', 'H':'unbind', 'I':'waitfor', 'J':'pause', 'K':'pick', + 'M':'delete', 'N':'capture'}; + + var vecattrs = ['pos', 'up', 'color', 'trail_color', 'axis', 'size', 'origin', '_attach_arrow', + 'direction', 'linecolor', 'bumpaxis', 'dot_color', 'ambient', 'add_to_trail', 'textcolor', + 'foreground', 'background', 'ray', 'ambient', 'center', 'forward', 'normal', + 'marker_color']; + + var textattrs = ['text', 'align', 'caption', 'title', 'title_align', 'xtitle', 'ytitle', 'selected', 'capture', + 'label', 'append_to_caption', 'append_to_title', 'bind', 'unbind', 'pause', 'choices']; + + // patt gets idx and attr code; vpatt gets x,y,z of a vector + var patt = /(\d+)(.)(.*)/; + var vpatt = /([^,]*),([^,]*),(.*)/; + var quadpatt = /([^,]*),([^,]*),(.*)/; + var plotpatt = /([^,]*),([^,]*)/; + + function decode(data) { + // data is {'cmds':list of constructors, 'attrs': list of attributes and (time-ordered) methods + // Attribute and method lists: [ 'XiK0.0,1.0,1.0', .....] X is a or b (attributes) or m (methods) + // i is object index, K is a key to an attribute or method in the dictionaries above + var output = [], s, m, idx, attr, val, datatype, out, i, as, ms; + var as = []; + var ms = []; + + if ('attrs' in data) { + var c = data['attrs']; + for (i=0; i -1) { + val = m[3].match(vpatt); + val = glow.vec(Number(val[1]), Number(val[2]), Number(val[3])); + } else if (attr == 'vs') { + var vs; + val = m[3].match(quadpatt); + if (val === null) { + val = m[3].match(vpatt); + vs = [Number(val[1]), Number(val[2]), Number(val[3])]; + } else { + vs = [Number(val[1]), Number(val[2]), Number(val[3]), Number(val[4])]; + } + } else if (textattrs.indexOf(attr) > -1) { + if (attr == 'choices') { // menu choices are wrapped in a list + val = m[3].slice(2, -2).split("', '"); // choices separated by ', ' + } else { + // '\n' doesn't survive JSON transmission, so in vpython.py we replace '\n' with '
' + val = m[3].replace(/
/g, "\n"); + } + } else if (attr == 'rotate') { // angle,x,y,z,x,y,z + var temp = m[3]; + val = []; + var first = temp.match(/([^,]*)/); + val.push(Number(first[1])); + var v1 = temp.slice(first[1].length+1); + m = v1.match(/([^,]*),([^,]*),([^,]*)/); + val.push(glow.vec(Number(m[1]), Number(m[2]), Number(m[3]))); + var v2 = temp.slice(first[1].length + 1 + m[0].length + 1); + m = v2.match(vpatt); + val.push(glow.vec(Number(m[1]), Number(m[2]), Number(m[3]))); + } else if (attr == 'plot' || attr == 'data') { + val = []; + var start = m[1].length+1; // start of arguments + while (true) { + m = s.slice(start).match(plotpatt); + val.push([ Number(m[1]), Number(m[2]) ]); + start += m[1].length+m[2].length+2; + if (start > s.length) break; + } + } else if (attr == 'waitfor' || attr == 'pause' || attr == 'delete') { + val = m[3]; + } else if (attr == 'follow') { + if (m[3] == 'None') val = null; + else val = Number(m[3]); + } else val = Number(m[3]); + out = {'idx':idx, 'attr':attr, 'val':val}; + if (datatype == 'attr') as.push(out); + else ms.push(out); + } + } + if (as.length > 0) data['attrs'] = as; + else data['attrs'] = []; + if (ms.length > 0) data['methods'] = ms; + return data; + } + + function fix_location(cfgx) { + if ('location' in cfgx) { + var loc = cfgx['location']; + var id = loc[0]; + if (id == -1) { + cfgx['pos'] = glow.print_anchor; // this doesn't work; throw an error in vpython.py + } else { + var cvs = glowObjs[id]; + var where = loc[1]; + if (where === 1) cfgx['pos'] = cvs.title_anchor; + else cfgx['pos'] = cvs.caption_anchor; + } + delete cfgx['location']; + } + return cfgx; + } + + function o2vec3(p) { + return glow.vec(p[0], p[1], p[2]); + } + + function handle_cmds(dcmds) { + //console.log('CMDS') + for (var icmds=0; icmds 0) { + for (var k = 0; k < len4; k++) { + objects[k] = glowObjs[val[k]]; + } + } + } else if (attr == "lights") { + if (val == 'empty_list') val = []; + cfg[attr] = val; + } else { + cfg[attr] = val; + } + } + if (!construct) { // commands such as "center" (for a canvas) + var parametric = ['splice', 'modify']; + var val = cfg[attr]; + if (attr == 'append_to_caption' || attr == 'append_to_title' ) glowObjs[idx][attr](val); + else if (method !== null) { + var npargs = 0; + var info; + if (parametric.indexOf(method) > -1) { + npargs = val.length - 1; + info = val[npargs]; // a list of dictionaries + } else { + info = val; + } + for (var j=0; j < info.length; j++) { + var dj = info[j]; + for (var a in dj) { + if (dj[a] instanceof Array) dj[a] = o2vec3(dj[a]); + } + } + if ( npargs === 0 ) { + glowObjs[idx][method](info); + } else if ( method === 'modify' ) { // 1 parameter + glowObjs[idx][method](val[0], info[0]); + } else if ( method === 'splice' ) { // 2 parameters + glowObjs[idx][method](val[0], val[1], info); + } else { + throw new Error('Too many parameters in '+method); + } + } else glowObjs[idx][attr] = val; + continue; + } + // creating the objects + cfg.idx = idx; // reinsert idx, having looped thru all other attributes + // triangle and quad objects should not have a canvas attribute; canvas is provided in the vertex objectsE + if ((obj == 'triangle' || obj == 'quad') && cfg.canvas !== undefined) delete cfg.canvas; + switch (obj) { + case 'box': {glowObjs[idx] = glow.box(cfg); break} + case 'sphere': {glowObjs[idx] = glow.sphere(cfg); break} + case 'simple_sphere': {glowObjs[idx] = glow.simple_sphere(cfg); break} + case 'arrow': {glowObjs[idx] = glow.arrow(cfg); break} + case 'cone': {glowObjs[idx] = glow.cone(cfg); break} + case 'cylinder': {glowObjs[idx] = glow.cylinder(cfg); break} + case 'helix': {glowObjs[idx] = glow.helix(cfg); break} + case 'pyramid': {glowObjs[idx] = glow.pyramid(cfg); break} + case 'ring': {glowObjs[idx] = glow.ring(cfg); break} + case 'curve': {glowObjs[idx] = glow.curve(cfg); break} + case 'points': {glowObjs[idx] = glow.points(cfg); break} + case 'vertex': {glowObjs[idx] = glow.vertex(cfg); break} + case 'triangle': {glowObjs[idx] = glow.triangle(cfg); break} + case 'quad': {glowObjs[idx] = glow.quad(cfg); break} + case 'label': {glowObjs[idx] = glow.label(cfg); break} + case 'ellipsoid': {glowObjs[idx] = glow.sphere(cfg); break} + case 'graph': { // currently graph gives an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.graph(cfg); + break + } + case 'gcurve': { // currently gcurve give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.gcurve(cfg); + break + } + case 'gdots': { // currently gdots give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.gdots(cfg); + break + } + case 'gvbars': { // currently gvbars give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.gvbars(cfg); + break + } + case 'ghbars': { // currently ghbars give an error for non-fundamental arguments + delete cfg.idx; + glowObjs[idx] = glow.ghbars(cfg); + break + } + case 'compound': { + if (cfg._cloneid !== undefined) { + var idoriginal = cfg._cloneid; + delete cfg._cloneid; + glowObjs[idx] = glowObjs[idoriginal].clone(cfg); + } else { + var obj = glowObjs[idx] = glow.compound(objects, cfg); + // Return computed compound pos and size to Python + send_compound(obj.canvas['idx'], obj.pos, obj.size, obj.up); + } + break + } + case 'extrusion': { + var obj = glowObjs[idx] = glow.extrusion(cfg); + // Return computed compound pos and size to Python + send_compound(obj.canvas['idx'], obj.pos, obj.size, obj.up); + break + } + case 'text': { + if (cfg._cloneid !== undefined) { + var idoriginal = cfg._cloneid; + delete cfg._cloneid; + glowObjs[idx] = glowObjs[idoriginal].clone(cfg); + } else { + // Return text parameters to Python + var obj = glowObjs[idx] = glow.text(cfg); + send_compound(obj.canvas['idx'], glow.vec(obj.length, obj.descender, 0), + obj.__comp.size, obj.up); + } + break + } + case 'local_light': {glowObjs[idx] = glow.local_light(cfg); break} + case 'distant_light': {glowObjs[idx] = glow.distant_light(cfg); break} + case 'canvas': { + // glowcomm.js looked up Jupyter's '#glowscript' div here. The + // host tells us where its scene goes instead; GlowScript reads + // the mount point off the global __context. + if (container !== null) { + globalThis.__context = { glowscript_container: container }; + } + glowObjs[idx] = glow.canvas(cfg); + glowObjs[idx]['idx'] = idx; + break + } + case 'attach_arrow': { + var attrs = ['pos', 'size', 'axis', 'up', 'color']; + var o = glowObjs[cfg['obj']]; + delete cfg['obj']; + var attr = cfg['attr']; + delete cfg['attr']; + var val = cfg['attrval']; + delete cfg['attrval']; + if (attrs.indexOf(attr) < 0) attr = '_attach_arrow'; + o.attr = val; + glowObjs[idx] = glow.attach_arrow( o, attr, cfg ); + break + } + case 'attach_trail': { + if ( typeof cfg['_obj'] === 'string' ) { + var o = cfg['_obj']; // the string '_func' + } else { + var o = glowObjs[cfg['_obj']]; + } + delete cfg['_obj']; + glowObjs[idx] = glow.attach_trail(o, cfg); + break + } + case 'wtext': { + cfg.objName = obj; + cfg = fix_location(cfg); + glowObjs[idx] = glow.wtext(cfg); + break + } + case 'winput': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.winput(cfg); + break + } + case 'checkbox': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.checkbox(cfg); + break + } + case 'radio': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.radio(cfg); + break + } + case 'button': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.button(cfg); + break + } + case 'slider': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.slider(cfg); + break + } + case 'menu': { + cfg.objName = obj; + cfg.bind = control_handler; + cfg = fix_location(cfg); + glowObjs[idx] = glow.menu(cfg); + if (cfg['selected'] === 'None') { + cfg['selected'] = null; + } + break + } + default: + console.log("Unable to create object"); + } + } // end of cmds (constructors and special data) + } + + async function handle_methods(dmeth) { + //console.log('METHODS') + for (var idmeth=0; idmeth 0) { + await obj.pause(val); + } else { + await obj.pause(); + } + process_pause(); + } else if (method === 'pick') { + var p = glowObjs[val].mouse.pick(); // wait for pick render; val is canvas + var seg = null; + if (p !== null) { + if (is_a(p, glow.curve)) seg = p.segment; + p = p.idx; + } + send_pick(val, p, seg); + } else obj[method](val); + } + } + + function handle_attrs(dattrs) { + //console.log('ATTRS') + for (var idattrs=0; idattrs 0) handle_cmds(data.cmds); + if (data.methods !== undefined && data.methods.length > 0) handle_methods(data.methods); + if (data.attrs !== undefined && data.attrs.length > 0) handle_attrs(data.attrs); + } + + // Forget every object: the next scene generation reuses idx 0, 1, 2, ... + function reset() { + glowObjs = []; + waitfor_canvas = null; + waitfor_options = null; + } + + // reset(), plus a best-effort ask for the objects to take themselves off the + // page. GlowScript objects use remove(); a canvas uses delete(). + function destroy() { + for (var i = 0; i < glowObjs.length; i++) { + var o = glowObjs[i]; + if (!o) continue; + try { + if (typeof o.remove === 'function') o.remove(); + else if (typeof o['delete'] === 'function') o['delete'](); + } catch (e) { /* already gone, or not removable */ } + } + reset(); + } + + // Test-only accessor: the internal glowObjs registry (idx -> GlowScript object). + // Not part of the host contract — do not use it from page code. + function _objs() { return glowObjs; } + + return { handle: handle, reset: reset, destroy: destroy, _objs: _objs }; +} + +var api = { createGlowFrontend: createGlowFrontend }; +if (typeof module !== 'undefined' && module.exports) module.exports = api; +if (typeof self !== 'undefined') self.createGlowFrontend = createGlowFrontend; From 58c22ff9e52153b963003014bb8a91776d007c0b Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 05:25:43 -0400 Subject: [PATCH 04/16] fix: don't clobber glow's __context, and drop (not forward) synthesized events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the port. 1. The canvas case handed glow a raw DOM element and replaced __context on every canvas cmd. __context is glow's own scratch space (canvas_selected, canvas_all, print_container), and upstream ran its container lookup at most once, so replacing it wipes state belonging to canvases already on the page. glow also stores the container as a jQuery object — the setter is `glowscript_container = $(value)` and the print path calls `canvas.container.css(...)`, which throws on a bare element. Now merges into any existing __context and wraps with glow.$ / $ / jQuery, warning if it cannot find one. 2. Four of the six deferred stubs forwarded a synthesized payload to opts.send. Those payloads are not protocol-valid: control_handler emitted {idx, widget} with idx/value missing, and process_binding/process_waitfor emitted {event: undefined}. vpython.py handle_msg (vpython.py:398-427) routes any event with a 'widget' key through object_registry[evt['idx']] and reads evt['value']/evt['text'] before calling the user's bind callback, and routes everything else through object_registry[evt['canvas']] into canvas.handle_event's `ev = evt['event']` (vpython.py:3288). A partial event raises inside the kernel's message loop, so once a live send is wired any widget click or bound event would crash the run. control_handler, process_binding, process_waitfor and process_pause are now warn-and-drop. send_pick and send_compound keep forwarding — their payloads are byte-faithful to glowcomm.js, and both are synchronous barriers Python blocks on. Pinned by the consumer's unit suite, which now fails against the previous commit on both points ("expected undefined to deeply equal [ 'pre-existing' ]" and "expected [ [ { idx: undefined, ... } ] ] to deeply equal []"). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- vpython/vpython_libraries/glowcomm_host.js | 59 ++++++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js index d60b9db..0376972 100644 --- a/vpython/vpython_libraries/glowcomm_host.js +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -64,35 +64,55 @@ function createGlowFrontend(opts) { // Comm. Wiring them to opts.send is a later task; until then they are loud. // --------------------------------------------------------------------------- - function not_wired(feature, events) { + function not_wired(feature) { if (typeof console !== 'undefined') console.warn('glowcomm_host: ' + feature + ' not wired yet'); - if (send) send(events); } + // These two CAN be forwarded: the payloads below are byte-faithful to + // glowcomm.js, so Python's handle_msg understands them as-is. Both are + // synchronous barriers on the Python side (it blocks for the answer), so a + // host that supplies send() gets working compound/text/extrusion and pick + // even before the full event channel is ported. + function send_pick(cvs, p, seg) { - not_wired('pick', [{event: 'pick', 'canvas': cvs, 'pick': p, 'segment': seg}]); + not_wired('pick'); + var evt = {event: 'pick', 'canvas': cvs, 'pick': p, 'segment':seg}; + if (send) send([evt]); } function send_compound(cvs, pos, size, up) { - not_wired('compound/extrusion/text measurement', [{event: '_compound', 'canvas': cvs, - 'pos': [pos.x, pos.y, pos.z], - 'size': [size.x, size.y, size.z], 'up': [up.x, up.y, up.z]}]); + not_wired('compound/extrusion/text measurement'); + var evt = {event: '_compound', 'canvas': cvs, 'pos': [pos.x, pos.y, pos.z], + 'size': [size.x, size.y, size.z], 'up': [up.x, up.y, up.z]}; + if (send) send([evt]); } + // These four must NOT forward anything. Their real payloads are built by the + // deleted process()/control_handler(), which read live mouse/key/widget state; + // anything this file could synthesize is a partial event, and a partial event + // does not fail politely on the Python side. vpython.py handle_msg() sends + // every event without a 'widget' key down `cvs = object_registry[evt['canvas']]` + // (vpython.py:425) and then canvas.handle_event()'s `ev = evt['event']` + // (vpython.py:3288); an event WITH a 'widget' key indexes object_registry by + // evt['idx'] and then reads evt['value']/evt['text'], and calls the user's + // bind callback. Either way a synthesized stub raises KeyError inside the + // kernel's message loop. Warning and dropping is the honest behaviour until + // Task 9 ports the real event capture. + function process_binding(event) { // event associated with a previous bind command - not_wired('event bindings', [{event: event && event.type, bind: true}]); + not_wired('event bindings'); } function process_waitfor(event) { - not_wired('waitfor', [{event: event && event.type, bind: true}]); + not_wired('waitfor'); } function process_pause() { - not_wired('pause', [{event: 'click'}]); + not_wired('pause'); } function control_handler(obj) { // button, menu, slider, radio, checkbox, winput - not_wired('widgets', [{idx: obj && obj.idx, widget: obj && obj.objName}]); + not_wired('widgets'); } var waitfor_canvas = null; @@ -443,8 +463,25 @@ function createGlowFrontend(opts) { // glowcomm.js looked up Jupyter's '#glowscript' div here. The // host tells us where its scene goes instead; GlowScript reads // the mount point off the global __context. + // + // Two things matter. (1) __context is GlowScript's own scratch + // space — canvas_selected, canvas_all, print_container — so + // merge into it; replacing it wipes state belonging to canvases + // already on the page (upstream did the lookup at most once, + // this runs on every canvas cmd). (2) glow stores a *jQuery* + // object (`glowscript_container` set does $(value), and the + // print-area path calls container.css(...)), so a raw element + // has to be wrapped or the first print() throws. if (container !== null) { - globalThis.__context = { glowscript_container: container }; + var jq = glow.$ || globalThis.$ || globalThis.jQuery; + var ctx = globalThis.__context || (globalThis.__context = {}); + if (jq) ctx.glowscript_container = jq(container); + else { + if (typeof console !== 'undefined') console.warn( + 'glowcomm_host: no jQuery found to wrap the container; ' + + 'GlowScript expects $(container) and print() will fail'); + ctx.glowscript_container = container; + } } glowObjs[idx] = glow.canvas(cfg); glowObjs[idx]['idx'] = idx; From 379672f5296e7191ee0823040bc4e3a693af55dd Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 06:44:12 -0400 Subject: [PATCH 05/16] feat(worker): eager boot + async rate/sleep + loud deferrals for wasm hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Web Worker gives vpython one thread, and that thread is the one the browser's replies arrive on — so every place vpython spins waiting for the front end is a deadlock, not a delay. Rewrite that surface at transport boot: * rate() and sleep() become coroutines that flush buffered updates and then await asyncio.sleep, so the async transform's inserted `await` yields to the host loop. rate is a module-level *instance* already bound into user namespaces, so the patch lands on _RateKeeper2.__call__ — the class — and reaches every binding. * scene.pause/scene.waitfor and the six widget constructors raise NotImplementedError naming themselves and the escape hatch, rather than hanging silently. The widgets share controls.setup (each subclass defines its own __init__ that delegates there), so one patch covers all six. The patches live in apply_worker_patches(), called by the bootstrap, and __init__.py now imports the transport eagerly under emscripten — before the star-imports bind rate/sleep into the package namespace, which is the only ordering where a student's `from vpython import *` sees the patched names. The lazy selection in baseObj.__init__ stays as the fallback; it now finds the module cached and no-ops. tests/test_trinket_worker.py stands the whole thing up on CPython with fake js/pyodide modules and sys.platform forced to emscripten, asserting through the package namespace exactly as a student's program would see it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- tests/conftest.py | 10 +++ tests/test_trinket_worker.py | 132 +++++++++++++++++++++++++++++++++++ vpython/__init__.py | 7 ++ vpython/trinket_worker.py | 53 +++++++++++++- 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_trinket_worker.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..32e0f66 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Make the in-tree ``vpython`` package importable regardless of cwd. + +The repo root is prepended (not appended) so these tests always exercise the +working tree rather than any copy of vpython installed in site-packages. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py new file mode 100644 index 0000000..0ded512 --- /dev/null +++ b/tests/test_trinket_worker.py @@ -0,0 +1,132 @@ +"""CPython tests for the wasm-host transport patches. + +A Web Worker gives vpython a JS host and a single thread: no Jupyter, no +servers, no ``time.sleep`` that anyone can afford. ``trinket_worker`` supplies +the pipe and rewrites vpython's blocking surface to match. These tests stand up +that world on plain CPython -- ``js``/``pyodide.ffi`` are fakes and +``sys.platform`` is forced to ``'emscripten'`` -- so importing the package boots +the *trinket* transport rather than ``no_notebook``'s threads and sockets. + +Assertions deliberately run against the package namespace (``vpython.rate``, +``vpython.sleep``), i.e. exactly the names ``from vpython import *`` binds into +a student's program. That also pins the eager-boot ordering in ``__init__.py``: +if the transport booted after the star-imports, ``vpython.sleep`` would still be +the busy-spinning original and these tests would fail. +""" + +import asyncio +import sys +import types + +import pytest + + +DEFERRAL_SUFFIX = (" is not supported in the worker runtime yet — " + "run without the workerVPython flag to use it.") + + +@pytest.fixture() +def worker_env(monkeypatch): + """Import vpython as it comes up inside the worker, with a fake host. + + Yields ``(vpython_module, sent)`` where ``sent`` collects every JSON string + the transport handed to the host -- the boot flush lands there. + """ + sent = [] + + js = types.ModuleType('js') + setattr(js, '__trinket_vpython_send', lambda s: sent.append(s)) + + ffi = types.ModuleType('pyodide.ffi') + ffi.create_proxy = lambda f: f + pyodide_mod = types.ModuleType('pyodide') + pyodide_mod.ffi = ffi + + monkeypatch.setitem(sys.modules, 'js', js) + monkeypatch.setitem(sys.modules, 'pyodide', pyodide_mod) + monkeypatch.setitem(sys.modules, 'pyodide.ffi', ffi) + monkeypatch.setattr(sys, 'platform', 'emscripten') + + # Force a clean import so the eager boot in __init__.py actually runs; + # monkeypatch restores whatever was in sys.modules afterwards. + for name in [m for m in list(sys.modules) if m == 'vpython' or m.startswith('vpython.')]: + monkeypatch.delitem(sys.modules, name) + + import vpython + + return vpython, sent + + +def test_transport_booted_and_sent_the_handshake(worker_env): + """The eager boot must still stand up the transport, not just the patches.""" + vp, sent = worker_env + assert sent, 'transport sent nothing to the host during boot' + assert vp.baseObj.glow is not None + + +def test_rate_returns_a_coroutine(worker_env): + vp, _ = worker_env + c = vp.rate(30) + assert asyncio.iscoroutine(c) + asyncio.run(c) + + +def test_rate_flushes_updates_to_the_host(worker_env): + """rate() is the pacing beat: awaiting one must push buffered work out.""" + vp, sent = worker_env + before = len(sent) + asyncio.run(vp.rate(60)) + assert len(sent) > before + + +def test_sleep_returns_a_coroutine(worker_env): + vp, _ = worker_env + c = vp.sleep(0.01) + assert asyncio.iscoroutine(c) + asyncio.run(c) + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_pause_raises_with_the_message(worker_env): + vp, _ = worker_env + cv = object.__new__(vp.canvas) # no full construction needed + with pytest.raises(NotImplementedError, match="scene.pause"): + vp.canvas.pause(cv) + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_waitfor_raises_with_the_documented_text(worker_env): + """Exact wording -- the Task 11 browser assertion matches on it.""" + vp, _ = worker_env + cv = object.__new__(vp.canvas) + with pytest.raises(NotImplementedError) as exc: + vp.canvas.waitfor(cv, 'draw_complete') + assert str(exc.value) == 'scene.waitfor' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_widgets_raise(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError, match="widgets"): + vp.button(text='go', bind=lambda: None) + + +# menu reads its own `choices` before delegating to controls.setup, so each +# widget gets the minimum kwargs that reach the shared code path. +WIDGETS = [ + ('button', {'text': 'go'}), + ('checkbox', {'text': 'on'}), + ('radio', {'text': 'pick'}), + ('winput', {}), + ('menu', {'choices': ['a', 'b']}), + ('slider', {}), +] + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +@pytest.mark.parametrize('name,kwargs', WIDGETS, ids=[w[0] for w in WIDGETS]) +def test_every_widget_class_defers(worker_env, name, kwargs): + """All six share controls.setup, so one patch has to cover all six.""" + vp, _ = worker_env + with pytest.raises(NotImplementedError, match="widgets"): + getattr(vp, name)(bind=lambda: None, **kwargs) diff --git a/vpython/__init__.py b/vpython/__init__.py index 262d5e6..5bdfe1b 100644 --- a/vpython/__init__.py +++ b/vpython/__init__.py @@ -25,6 +25,13 @@ from .vpython import canvas +import sys as _sys +if _sys.platform == 'emscripten': + # Boot the wasm transport EAGERLY: its patches must land before the + # star-imports below bind rate/sleep into the package namespace. + from . import trinket_worker as _tw +del _sys + # Need to initialize canvas before user does anything and before scene = canvas() diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py index 154fe40..b2d4ed2 100644 --- a/vpython/trinket_worker.py +++ b/vpython/trinket_worker.py @@ -23,7 +23,8 @@ ``canvas_update`` timer) setting the pace. Like the other transports, importing this module IS the setup; it ends by -binding the module-global ``sender`` and starting the update ping-pong. +binding the module-global ``sender``, patching vpython's blocking surface (see +``apply_worker_patches``) and starting the update ping-pong. """ import json @@ -62,6 +63,52 @@ def _dispatch(events_json): baseObj.trigger() +_DEFER = ("{name} is not supported in the worker runtime yet — " + "run without the workerVPython flag to use it.") + + +def apply_worker_patches(): + """Make vpython's blocking surface cooperative (or loudly absent) for a + single-threaded wasm host. Called from the transport bootstrap; separated so + plain-CPython tests can exercise the patches without a live transport. + + Every construct patched here spins the one and only thread while waiting on + the browser — which, in a worker, is the thread the browser's replies have + to arrive on. Waiting is therefore a deadlock, so each is either made + awaitable (``rate``, ``sleep``: the async transform inserts the ``await``) + or made to fail loudly rather than hang. + """ + import asyncio + from . import rate_control + from . import vpython as _vp + + async def _async_rate(maxRate): + baseObj.trigger() # flush buffered updates + await asyncio.sleep(1.0 / max(float(maxRate), 1.0)) + + # rate is a module-level INSTANCE bound into user namespaces at import time + # (rate_control.py: `rate = _RateKeeper2(...)`); patching the class __call__ + # changes the already-bound object everywhere. + rate_control._RateKeeper2.__call__ = lambda self, maxRate=100: _async_rate(maxRate) + + async def _async_sleep(dt): + baseObj.trigger() + await asyncio.sleep(dt) + _vp.sleep = _async_sleep # BEFORE __init__'s star-import binds it + + def _deferred(name): + def _raise(*args, **kwargs): + raise NotImplementedError(_DEFER.format(name=name)) + return _raise + + _vp.canvas.pause = _deferred('scene.pause') + _vp.canvas.waitfor = _deferred('scene.waitfor') + # button/checkbox/radio/winput/menu/slider all subclass `controls`, but each + # defines its own __init__ that calls `controls.setup` — setup, not + # __init__, is the one shared entry point, so that is what gets patched. + _vp.controls.setup = _deferred('widgets (button/slider/menu/checkbox/radio/winput)') + + # GlowWidget() records itself as baseObj.glow. Outside a notebook it sets the # module-global sender to None, so ours is installed after it. GlowWidget() @@ -69,6 +116,10 @@ def _dispatch(events_json): js.__trinket_vpython_dispatch = create_proxy(_dispatch) +# Patch before the first flush — and, via __init__'s eager boot, before the +# star-imports bind rate/sleep into the package namespace. +apply_worker_patches() + # Start the ping-pong exactly as with_notebook does: the first trigger() # flushes anything already buffered (the scene canvas constructed during # `import vpython` is sitting in the buffer at this point) and sets From 37720140051b33e490650792abde16edf319ad5a Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 07:12:17 -0400 Subject: [PATCH 06/16] fix(worker): render cap, rate(0) parity, fixture isolation, stale comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the transport patches: * _async_rate flushed on every call, so a rate(1000) loop pushed 1000 update packages/second at the page. Upstream RateKeeper caps renders at MAX_RENDERS/second regardless of how often rate() is called; do the same behind a monotonic-clock gate, reading the constant from rate_control. Pacing is unchanged — the full 1/maxRate interval is still awaited — and a skipped flush loses nothing, since the updates stay buffered for the next. * rate(0) clamped to 1 Hz where upstream raises. Validate synchronously in a wrapper, before the coroutine is built, so the error lands at the call site as it does upstream rather than on await (or never, if nothing awaits). * The test fixture left its emscripten-booted package in sys.modules, handing async sleep and raising widgets to every later `import vpython` in the session. Snapshot and restore the vpython* keys around it, with a guard test that the cache comes back clean. * The closing comment still described the pre-eager-boot world, where the canvas was already buffered when the transport booted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- tests/test_trinket_worker.py | 76 ++++++++++++++++++++++++++++++++---- vpython/trinket_worker.py | 39 ++++++++++++++---- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py index 0ded512..f98f2f7 100644 --- a/tests/test_trinket_worker.py +++ b/tests/test_trinket_worker.py @@ -16,6 +16,7 @@ import asyncio import sys +import time import types import pytest @@ -47,14 +48,24 @@ def worker_env(monkeypatch): monkeypatch.setitem(sys.modules, 'pyodide.ffi', ffi) monkeypatch.setattr(sys, 'platform', 'emscripten') - # Force a clean import so the eager boot in __init__.py actually runs; - # monkeypatch restores whatever was in sys.modules afterwards. - for name in [m for m in list(sys.modules) if m == 'vpython' or m.startswith('vpython.')]: - monkeypatch.delitem(sys.modules, name) - - import vpython - - return vpython, sent + # Force a clean import so the eager boot in __init__.py actually runs -- and + # put sys.modules back exactly as found afterwards. The package that comes + # up in here is emscripten-flavoured (async sleep, raising widgets); leaving + # it cached would hand it to every later `import vpython` in the session, + # including whatever else the suite runs on darwin. + saved = {k: v for k, v in sys.modules.items() + if k == 'vpython' or k.startswith('vpython.')} + for name in saved: + del sys.modules[name] + + try: + import vpython + yield vpython, sent + finally: + for name in [m for m in list(sys.modules) + if m == 'vpython' or m.startswith('vpython.')]: + del sys.modules[name] + sys.modules.update(saved) def test_transport_booted_and_sent_the_handshake(worker_env): @@ -79,6 +90,40 @@ def test_rate_flushes_updates_to_the_host(worker_env): assert len(sent) > before +def test_rate_honours_the_render_cap(worker_env): + """rate(1000) must pace at 1000 Hz but still render at most MAX_RENDERS/s. + + Upstream RateKeeper decouples the two (rate_control.py:153); without the + same cap a tight rate(1000) loop floods the page with update packages. + """ + vp, sent = worker_env + from vpython import rate_control + + calls = 40 + before = len(sent) + started = time.monotonic() + + async def burst(): + for _ in range(calls): + await vp.rate(1000) + + asyncio.run(burst()) + elapsed = time.monotonic() - started + flushes = len(sent) - before + + assert flushes < calls, 'no render cap: every rate() call flushed' + # +1 for the flush on the very first call, +1 for scheduling slop. + assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 + + +@pytest.mark.parametrize('bad', [0, -1]) +def test_rate_rejects_values_below_one(worker_env, bad): + """Parity with _RateKeeper2.__call__ -- rate(0) raises, it does not clamp.""" + vp, _ = worker_env + with pytest.raises(ValueError, match='greater than or equal to 1'): + vp.rate(bad) + + def test_sleep_returns_a_coroutine(worker_env): vp, _ = worker_env c = vp.sleep(0.01) @@ -130,3 +175,18 @@ def test_every_widget_class_defers(worker_env, name, kwargs): vp, _ = worker_env with pytest.raises(NotImplementedError, match="widgets"): getattr(vp, name)(bind=lambda: None, **kwargs) + + +def test_the_fixture_leaves_no_emscripten_build_cached(): + """Runs last on purpose: every test above used worker_env. + + If the emscripten-booted package were still in sys.modules, the next + `import vpython` anywhere in the session -- on darwin, in some other test + file -- would silently get async sleep, raising widgets and a transport + talking to a fake `js`. Asserting the cache is clean is both the check and + the guarantee that a later import rebuilds the real thing; actually + importing vpython here is not an option, since on a desktop platform that + starts the no_notebook http server and opens a browser tab. + """ + leaked = sorted(m for m in sys.modules if m == 'vpython' or m.startswith('vpython.')) + assert leaked == [], 'worker_env leaked modules into sys.modules: %s' % leaked diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py index b2d4ed2..8c85c10 100644 --- a/vpython/trinket_worker.py +++ b/vpython/trinket_worker.py @@ -79,17 +79,39 @@ def apply_worker_patches(): or made to fail loudly rather than hang. """ import asyncio + import time from . import rate_control from . import vpython as _vp + # Upstream RateKeeper decouples the rate() call frequency from the render + # frequency: however often the loop asks, at most MAX_RENDERS renders go out + # per second (rate_control.py:153). Keep that contract — rate(1000) must + # still pace at 1000 Hz, but it must not flush 1000 packages/second at the + # page. Flushes we skip are not lost: the updates stay buffered and go out + # with the next one. + _render_period = 1.0 / rate_control.MAX_RENDERS + _last_flush = [float('-inf')] + async def _async_rate(maxRate): - baseObj.trigger() # flush buffered updates - await asyncio.sleep(1.0 / max(float(maxRate), 1.0)) + now = time.monotonic() + if now - _last_flush[0] >= _render_period: + _last_flush[0] = now + baseObj.trigger() # flush buffered updates + await asyncio.sleep(1.0 / float(maxRate)) + + def _rate(self, maxRate=100): + # Validate SYNCHRONOUSLY, before building the coroutine: parity with + # _RateKeeper2.__call__ (rate_control.py:265), where rate(0) raises at + # the call site. Inside the coroutine the error would surface only on + # await — and not at all if the program never awaits. + if maxRate < 1: + raise ValueError("rate value must be greater than or equal to 1") + return _async_rate(maxRate) # rate is a module-level INSTANCE bound into user namespaces at import time # (rate_control.py: `rate = _RateKeeper2(...)`); patching the class __call__ # changes the already-bound object everywhere. - rate_control._RateKeeper2.__call__ = lambda self, maxRate=100: _async_rate(maxRate) + rate_control._RateKeeper2.__call__ = _rate async def _async_sleep(dt): baseObj.trigger() @@ -120,10 +142,13 @@ def _raise(*args, **kwargs): # star-imports bind rate/sleep into the package namespace. apply_worker_patches() -# Start the ping-pong exactly as with_notebook does: the first trigger() -# flushes anything already buffered (the scene canvas constructed during -# `import vpython` is sitting in the buffer at this point) and sets -# baseObj.sent, which appendcmd()/addmethod() spin on. +# Start the ping-pong exactly as with_notebook does. Because __init__.py boots +# this transport EAGERLY — before `scene = canvas()` — the buffer is empty here, +# so this first trigger() is the bare 'trigger' handshake rather than a package; +# the scene canvas and its lights are constructed just afterwards and flush on +# the next trigger (the first rate() call, or the host's next dispatch ping). +# What matters either way is that it sets baseObj.sent, which appendcmd() and +# addmethod() spin on. baseObj.trigger() # Dummy name to import, matching the other transports. From 502d6f868fb61dc9fd9ab6c08edf1ce7c038a328 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 10:10:41 -0400 Subject: [PATCH 07/16] =?UTF-8?q?feat(worker):=20port=20glowcomm's=20event?= =?UTF-8?q?=20capture=20=E2=80=94=20mouse,=20keys,=20camera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The front-end has been one-way since Task 1: Python builds objects, the page draws them, and every path back was a warn-and-drop stub. This ports the other half of glowcomm.js — update_canvas (:193-275), process (:311-346), process_binding (:373-380) and send (:150-170) — so a scene.bind('click', f) handler actually fires. The event objects are byte-faithful to glowcomm.js on purpose: their field set IS the wire contract that vpython.py's handle_msg (:394-425) and canvas.handle_event (:3287) destructure, and a missing key is a KeyError inside the kernel's message loop rather than a polite failure. Two things are deliberately NOT verbatim. send() becomes tick(). Upstream's send() owned both halves of pacing: what goes in a tick, and when the next one happens (it re-armed its own setTimeout). The when belongs to the host — it is the same clock that drives rendering, and in trinket it belongs to the RUN — so this file exports the what and the host calls it. Events flush themselves when no clock is running. Upstream could queue and wait because its timer never stopped; a host whose clock is scoped to the run has no clock at all by the time the student clicks, and `scene.bind('click', f)` followed by the end of the program is exactly what an interactive example looks like. So queue() sends immediately unless a tick happened in the last 100 ms, which keeps glowcomm's 33 ms coalescing for the case it was written for — a bound mousemove must not cost one worker round trip per mouse pixel — without deadlocking the case it was not. pick and _compound never wait at all: Python is blocked in _wait() for both. Also: canvas.hasmouse is a CLASS static and outlives a scene, so update_canvas ignores a canvas this front-end no longer owns — otherwise the first tick of a new generation reports an idx that means something else in Python's (persistent) object_registry. reset() now clears the queue and re-seeds the camera diff for the same reason. waitfor, pause and widgets stay warn-and-drop: they are deferred by design (spec V5, NotImplementedError on the Python side), and the comment now says so rather than "not ported yet". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- vpython/vpython_libraries/glowcomm_host.js | 282 ++++++++++++++++++--- 1 file changed, 253 insertions(+), 29 deletions(-) diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js index 0376972..bff925f 100644 --- a/vpython/vpython_libraries/glowcomm_host.js +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -13,11 +13,13 @@ // // var fe = createGlowFrontend({container: el, send: fn, glow: window}) // fe.handle(ops) // ops is the parsed {cmds, methods, attrs} package, or 'trigger' +// fe.tick() // one pacing tick: sample the canvas, drain queued events // fe.reset() // forget every object (new scene generation) // fe.destroy() // reset + ask the objects to remove themselves // -// Event capture (mouse/key/widget events and canvas polling) is NOT ported yet: -// the call sites that need it are stubbed and warn — see "not wired yet" below. +// Mouse/key event capture IS ported (see "the event channel" below). Widgets, +// pause and waitfor are not: those are deferred by design (spec V5, they raise +// NotImplementedError on the Python side) and their call sites warn and drop. 'use strict'; @@ -59,48 +61,123 @@ function createGlowFrontend(opts) { } // --------------------------------------------------------------------------- - // Deferred: the event/control channel back to Python. glowcomm.js implemented - // these by pushing onto an `events` list that a paced send() drained over the - // Comm. Wiring them to opts.send is a later task; until then they are loud. + // The event channel back to Python. + // + // glowcomm.js pushed events onto a module-global list and drained it from a + // free-running setTimeout loop that also owned the render pacing. Here the + // HOST owns when a tick happens (it calls tick() off its own clock) and this + // file owns what goes in it. The event objects themselves are byte-faithful + // to glowcomm.js, because those shapes ARE the wire contract that + // vpython.py's handle_msg (:394-425) and canvas.handle_event (:3287) read. // --------------------------------------------------------------------------- - function not_wired(feature) { - if (typeof console !== 'undefined') console.warn('glowcomm_host: ' + feature + ' not wired yet'); + var events = []; // queued outbound events, drained by tick()/flush() + var last_tick = -Infinity; // when the host last called tick() + + // How long after a tick() we keep assuming the host's clock is running. + // Hosts pace at glowcomm.js's ~33 ms `interval`, so this is three misses. + var PACING_GRACE_MS = 100; + + function now_ms() { + if (typeof performance !== 'undefined' && performance.now) return performance.now(); + return Date.now(); + } + + function flush() { + if (events.length === 0) return; + var out = events; + events = []; + if (send) send(out); + } + + // Queue one event for Python. While the host is ticking, tick() drains this + // within a frame and the events coalesce — that batching is the only reason + // glowcomm.js has a queue at all. When the host is NOT ticking, nothing will + // ever drain the queue, so the event goes out on its own. + // + // That second case is not a corner: a host whose pacing clock belongs to the + // RUN (trinket's does) has no clock at all by the time the user clicks, and + // `scene.bind('click', f)` followed by the end of the program is exactly + // what an interactive VPython example looks like. The click has to carry + // itself. + function queue(evt) { + events.push(evt); + if (now_ms() - last_tick > PACING_GRACE_MS) flush(); } - // These two CAN be forwarded: the payloads below are byte-faithful to - // glowcomm.js, so Python's handle_msg understands them as-is. Both are - // synchronous barriers on the Python side (it blocks for the answer), so a - // host that supplies send() gets working compound/text/extrusion and pick - // even before the full event channel is ported. + // pick and compound/text/extrusion are synchronous barriers: Python is + // blocked inside _wait() until the answer comes back. They never wait for a + // tick, even when one is due — but they go out with anything already queued, + // so ordering is preserved. function send_pick(cvs, p, seg) { - not_wired('pick'); var evt = {event: 'pick', 'canvas': cvs, 'pick': p, 'segment':seg}; - if (send) send([evt]); + events.push(evt); + flush(); } function send_compound(cvs, pos, size, up) { - not_wired('compound/extrusion/text measurement'); var evt = {event: '_compound', 'canvas': cvs, 'pos': [pos.x, pos.y, pos.z], 'size': [size.x, size.y, size.z], 'up': [up.x, up.y, up.z]}; - if (send) send([evt]); + events.push(evt); + flush(); } - // These four must NOT forward anything. Their real payloads are built by the - // deleted process()/control_handler(), which read live mouse/key/widget state; - // anything this file could synthesize is a partial event, and a partial event - // does not fail politely on the Python side. vpython.py handle_msg() sends - // every event without a 'widget' key down `cvs = object_registry[evt['canvas']]` - // (vpython.py:425) and then canvas.handle_event()'s `ev = evt['event']` - // (vpython.py:3288); an event WITH a 'widget' key indexes object_registry by - // evt['idx'] and then reads evt['value']/evt['text'], and calls the user's - // bind callback. Either way a synthesized stub raises KeyError inside the - // kernel's message loop. Warning and dropping is the honest behaviour until - // Task 9 ports the real event capture. + // glowcomm.js process() (:311-346): one browser event, in the shape + // canvas.handle_event() destructures. `event` is GlowScript's event object. + function process(event) { + // mouse events: mouseup, mousedown, mousemove, mouseenter, mouseleave, click + // key events: keydown, keyup + // other: resize + var etype = event.type; + var evt = {event:etype}; + var idx = event.canvas['idx']; + evt.canvas = idx; + if (etype != 'resize') { + if (etype.slice(0,3) == 'key') { + evt.key = event.key; + evt.which = event.which; + evt.alt = event.alt; + evt.ctrl = event.ctrl; + evt.shift = event.shift; + } else { + var pos = event.pos; + evt.pos = [pos.x, pos.y, pos.z]; + evt.press = event.press; + evt.release = event.release; + evt.which = event.which; + var ray = event.canvas.mouse.ray; + evt.ray = [ ray.x, ray.y, ray.z ]; + evt.alt = event.canvas.mouse.alt; + evt.ctrl = event.canvas.mouse.ctrl; + evt.shift = event.canvas.mouse.shift; + } + } else { + evt.width = event.canvas.width; + evt.height = event.canvas.height; + } + if ('bind' in event) evt.bind = true; + queue(evt); + } function process_binding(event) { // event associated with a previous bind command - not_wired('event bindings'); + event.bind = true; + process(event); + } + + // These three must NOT forward anything. pause, waitfor and widgets are + // deferred by design (spec V5): the Python side raises NotImplementedError + // before any of them can reach the browser, so these handlers are reachable + // only if that deferral is lifted without porting them. Anything this file + // could synthesize would be a PARTIAL event, and a partial event does not + // fail politely: handle_msg indexes object_registry by evt['idx'] and reads + // evt['value']/evt['text'] for widgets (vpython.py:400-415), and + // handle_event reads evt['alt']/['shift']/['ctrl'] for a mouse event + // (vpython.py:3335) — a stub raises KeyError inside the kernel's message + // loop. Warning and dropping is the honest behaviour. + + function not_wired(feature) { + if (typeof console !== 'undefined') console.warn('glowcomm_host: ' + feature + ' not wired yet'); } function process_waitfor(event) { @@ -121,6 +198,147 @@ function createGlowFrontend(opts) { var binds = ['mousedown', 'mouseup', 'mousemove', 'click', 'mouseenter', 'mouseleave', 'keydown', 'keyup', 'redraw', 'draw_complete', 'resize']; + // --------------------------------------------------------------------------- + // Canvas polling: the half of the state only the BROWSER knows. + // --------------------------------------------------------------------------- + + // The previous sample, so a still scene sends nothing. glowcomm.js kept these + // as module globals seeded with vec(0,0,0); here they are per-front-end and + // re-seeded by reset(), because a new scene generation starts from scratch. + var lastpos, lastray, lastforward, lastup, lastcenter; + var lastrange, lastautoscale, lastsliders, lastkeysdown; + // glowcomm.js's control_handler() samples slider values in here so a drag + // reports once per render instead of once per pixel. Widgets are deferred + // (see control_handler above) so it stays empty, but update_canvas' half of + // that mechanism is ported whole rather than left as a hole to re-derive. + var sliders; + + function vzero() { return (typeof glow.vec === 'function') ? glow.vec(0, 0, 0) : null; } + + function reset_canvas_state() { + lastpos = vzero(); lastray = vzero(); lastforward = vzero(); + lastup = vzero(); lastcenter = vzero(); + lastrange = 1; + lastautoscale = true; + lastsliders = {}; + lastkeysdown = []; + sliders = {}; + } + reset_canvas_state(); + + // glowcomm.js update_canvas() (:193-275). Mouse position and camera state + // for the canvas the mouse is over, diffed against the last sample; returns + // an array of events, or null when nothing changed. + function update_canvas() { + var dosend = false; + var evt = null; + // `canvas.hasmouse` is a static on GlowScript's canvas class — the only + // way it changes is with the mouse. + var cvs = glow.canvas ? glow.canvas.hasmouse : null; + // ...and being a class static, it OUTLIVES a scene: after reset() it can + // still point at a canvas from a torn-down generation, whose idx now + // means something else (or nothing) in Python's object_registry. Only + // report a canvas this front-end still owns. + if (cvs && glowObjs[cvs.idx] !== cvs) cvs = null; + + if (cvs !== null && cvs !== undefined) { + evt = {event:'update_canvas'}; + var idx = cvs.idx; + evt.canvas = idx; + var pos = cvs.mouse.pos; + if (!lastpos || !pos.equals(lastpos)) {evt.pos = [pos.x,pos.y,pos.z]; dosend=true;} + lastpos = pos; + var ray = cvs.mouse.ray; + if (!lastray || !ray.equals(lastray)) {evt.ray = [ray.x,ray.y,ray.z]; dosend=true;} + lastray = ray; + + // glowcomm.js calls the bare global keysdown(); a host that supplied + // its own registry may not have it, in which case report no change. + var k = (typeof glow.keysdown === 'function') ? glow.keysdown() : lastkeysdown; + var test = true; // assume keysdown() is same as lastkeysdown + if (k.length !== lastkeysdown.length) test = false; + else { + for (var i=0; i 0) { + if (dosend) evt = evt.concat(output_sliders); + else evt = output_sliders; + dosend = true; + } + if (dosend) return evt; + else return null; + } + + // glowcomm.js send() (:150-170) — the WHAT of one pacing tick. Upstream's + // send() also owned the WHEN (it re-armed its own setTimeout); that half is + // the host's, which is why this is a method it calls rather than a loop. + // + // Note the fallback. An empty tick still sends {event:'update_canvas', + // trigger:1}: the transport treats a 'trigger' entry as pacing and processes + // nothing, but the MESSAGE is the request half of a request/reply — it is + // what makes the kernel flush the updates it has buffered. A tick that sent + // nothing would stall a program that never calls rate(). + function tick() { + last_tick = now_ms(); + var update = update_canvas(); + var out = events; + events = []; + if (update !== null) out = out.concat(update); + if (out.length === 0) out = [{event:'update_canvas', 'trigger':1}]; + if (send) send(out); + return out; + } + // --------------------------------------------------------------------------- // The wire format. Ported verbatim from glowcomm.js. // --------------------------------------------------------------------------- @@ -688,10 +906,16 @@ function createGlowFrontend(opts) { } // Forget every object: the next scene generation reuses idx 0, 1, 2, ... + // Queued events go with them — they name idxs that no longer mean anything — + // and the canvas diff starts over, so the new scene's first tick reports its + // own state rather than a delta against the old one. function reset() { glowObjs = []; waitfor_canvas = null; waitfor_options = null; + events = []; + last_tick = -Infinity; + reset_canvas_state(); } // reset(), plus a best-effort ask for the objects to take themselves off the @@ -712,7 +936,7 @@ function createGlowFrontend(opts) { // Not part of the host contract — do not use it from page code. function _objs() { return glowObjs; } - return { handle: handle, reset: reset, destroy: destroy, _objs: _objs }; + return { handle: handle, tick: tick, reset: reset, destroy: destroy, _objs: _objs }; } var api = { createGlowFrontend: createGlowFrontend }; From 0a6ddbe8f6b53d23dd927ca040c481c3d00f1995 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 10:28:23 -0400 Subject: [PATCH 08/16] fix(worker): tell the front-end when the clock stops; poll the canvas on every flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the Task 9 port found two holes, both in the same place: what happens to an event when no clock is running. I1. PACING_GRACE_MS was doing a job it cannot do. Inferring "the host is ticking" from "a tick happened <100 ms ago" is right until the tick that happens to be the LAST one — after which an event arriving inside that window is queued for a tick that will never come, and sits there until some later event happens to flush it. For a program that has ended, there is no later event. Add pacingStopped(): the host says so, because only the host knows. The grace window stays as a backstop for a host that forgets, or one whose clock dies without warning. I2. glowcomm.js polled the canvas on every message out, because send() was the only way out. The event-driven flush added here was a second way out and it carried no update_canvas — so with no clock running, a bound handler saw scene.forward/up/center/range/autoscale and keysdown() frozen at whatever they were when the clock stopped. flush() and tick() now share a drain() that appends update_canvas() in upstream's events.concat(update) order. flush() still returns early on an empty queue: it never polls or sends unprompted, which is what keeps a stopped scene silent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- vpython/vpython_libraries/glowcomm_host.js | 45 +++++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js index bff925f..5b70860 100644 --- a/vpython/vpython_libraries/glowcomm_host.js +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -14,6 +14,7 @@ // var fe = createGlowFrontend({container: el, send: fn, glow: window}) // fe.handle(ops) // ops is the parsed {cmds, methods, attrs} package, or 'trigger' // fe.tick() // one pacing tick: sample the canvas, drain queued events +// fe.pacingStopped() // the host's clock has stopped; flush and stay flushing // fe.reset() // forget every object (new scene generation) // fe.destroy() // reset + ask the objects to remove themselves // @@ -74,8 +75,10 @@ function createGlowFrontend(opts) { var events = []; // queued outbound events, drained by tick()/flush() var last_tick = -Infinity; // when the host last called tick() - // How long after a tick() we keep assuming the host's clock is running. - // Hosts pace at glowcomm.js's ~33 ms `interval`, so this is three misses. + // Backstop only. The host is expected to SAY when its clock stops + // (pacingStopped(), below); this catches a host that forgets to, or one whose + // clock dies without warning. Hosts pace at glowcomm.js's ~33 ms `interval`, + // so three misses. var PACING_GRACE_MS = 100; function now_ms() { @@ -83,11 +86,24 @@ function createGlowFrontend(opts) { return Date.now(); } - function flush() { - if (events.length === 0) return; + // The contents of one outbound message: everything queued, plus whatever the + // canvas poll has to say. glowcomm.js's send() built exactly this list, in + // this order, and it built it for EVERY message — there was only one path + // out. Keeping the poll on both paths is what makes an event-driven flush + // carry current camera/keys state instead of whatever was true when the + // host's clock last ran. + function drain() { + var update = update_canvas(); var out = events; events = []; - if (send) send(out); + if (update !== null) out = out.concat(update); + return out; + } + + function flush() { + if (events.length === 0) return; // nothing to say; don't poll, don't send + var out = drain(); + if (out.length > 0 && send) send(out); } // Queue one event for Python. While the host is ticking, tick() drains this @@ -105,6 +121,17 @@ function createGlowFrontend(opts) { if (now_ms() - last_tick > PACING_GRACE_MS) flush(); } + // The host's clock has stopped. Called BY the host, because only the host + // knows: inferring it from PACING_GRACE_MS leaves a window — an event + // arriving in the ~100 ms after the final tick looks like it has a tick + // coming, so it is queued for one that never arrives and sits there until + // some later event happens to flush it. Anything queued goes out now, and + // every subsequent event flushes itself. + function pacing_stopped() { + last_tick = -Infinity; + flush(); + } + // pick and compound/text/extrusion are synchronous barriers: Python is // blocked inside _wait() until the answer comes back. They never wait for a // tick, even when one is due — but they go out with anything already queued, @@ -330,10 +357,7 @@ function createGlowFrontend(opts) { // nothing would stall a program that never calls rate(). function tick() { last_tick = now_ms(); - var update = update_canvas(); - var out = events; - events = []; - if (update !== null) out = out.concat(update); + var out = drain(); if (out.length === 0) out = [{event:'update_canvas', 'trigger':1}]; if (send) send(out); return out; @@ -936,7 +960,8 @@ function createGlowFrontend(opts) { // Not part of the host contract — do not use it from page code. function _objs() { return glowObjs; } - return { handle: handle, tick: tick, reset: reset, destroy: destroy, _objs: _objs }; + return { handle: handle, tick: tick, pacingStopped: pacing_stopped, + reset: reset, destroy: destroy, _objs: _objs }; } var api = { createGlowFrontend: createGlowFrontend }; From 393c1d6bc69be8cd63d50694ac01e2b6b3e226bd Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 12:00:22 -0400 Subject: [PATCH 09/16] =?UTF-8?q?feat(worker):=20poll()=20=E2=80=94=20a=20?= =?UTF-8?q?pacing=20tick=20that=20stays=20quiet=20while=20rate()=20drives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things can make the worker flush a scene: the host's ~33 ms pacer, and rate() inside the student's own loop, which triggers a render up to rate_control.MAX_RENDERS a second. While an animation is running the second is doing the whole job and the pacer's handshake is pure packet overhead — measured from trinket at 91 host messages against 254 packages over three seconds, a third of the traffic on the hottest path in the system, buying nothing. poll() is the tick a host uses in that state. It does the half only the browser can do — queued events, and the camera/mouse state update_canvas() samples — and skips the bare {event:'update_canvas',trigger:1} the program no longer needs. It still stamps last_tick, because the host's clock IS running: events must keep batching into the next tick rather than each paying its own round trip. Also: a note for whoever wires control_handler. Upstream reports a slider ONLY through update_canvas(), which was safe in a notebook where the clock never stops. On a host whose clock belongs to the run, flush() deliberately returns early on an empty queue, so after a program ends a slider drag would sit in `sliders` until some unrelated event flushed it. The fix at that point is to queue() a widget event, as upstream already does for every other widget. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- vpython/vpython_libraries/glowcomm_host.js | 35 +++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js index 5b70860..c5849d0 100644 --- a/vpython/vpython_libraries/glowcomm_host.js +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -14,6 +14,7 @@ // var fe = createGlowFrontend({container: el, send: fn, glow: window}) // fe.handle(ops) // ops is the parsed {cmds, methods, attrs} package, or 'trigger' // fe.tick() // one pacing tick: sample the canvas, drain queued events +// fe.poll() // like tick(), but silent when there is nothing to say // fe.pacingStopped() // the host's clock has stopped; flush and stay flushing // fe.reset() // forget every object (new scene generation) // fe.destroy() // reset + ask the objects to remove themselves @@ -238,6 +239,19 @@ function createGlowFrontend(opts) { // reports once per render instead of once per pixel. Widgets are deferred // (see control_handler above) so it stays empty, but update_canvas' half of // that mechanism is ported whole rather than left as a hole to re-derive. + // + // WHOEVER WIRES control_handler, READ THIS. Upstream reports a slider ONLY + // through update_canvas(), i.e. only when something else causes a message to + // go out. That was safe in the notebook, where the clock never stops. Here + // the host's clock belongs to the RUN (trinket's does), and flush() — + // deliberately — returns early on an empty queue rather than polling the + // canvas, so once a program has ended a slider drag would sit in this object + // until some unrelated mouse event happened to flush it. The fix at that + // point is for control_handler to queue() a widget event for the slider (the + // shape vpython.py handle_msg reads: {'idx':…,'value':…,'widget':'slider'}) + // instead of relying on this poll — which is what upstream's control_handler + // already does for every OTHER widget: only the slider branch returns early + // instead of ending in events.push(evt). var sliders; function vzero() { return (typeof glow.vec === 'function') ? glow.vec(0, 0, 0) : null; } @@ -363,6 +377,25 @@ function createGlowFrontend(opts) { return out; } + // One tick of the host's clock that is NOT the request half of a + // request/reply. When the PROGRAM is already flushing on its own — vpython's + // rate() triggers a render at up to MAX_RENDERS a second from inside the + // animation loop — the handshake above buys nothing and costs one message per + // tick on the hottest path in the system (measured: ~30 host messages a + // second on top of the ~85 the loop was already sending). The half of a tick + // that is still needed is the browser's own half: queued events, and the + // camera/mouse state only update_canvas() knows. So: drain, send if there is + // anything, and otherwise stay quiet. + // + // last_tick is still stamped, because the host's clock IS running: events + // must keep batching into the next tick rather than each paying a round trip. + function poll() { + last_tick = now_ms(); + var out = drain(); + if (out.length > 0 && send) send(out); + return out; + } + // --------------------------------------------------------------------------- // The wire format. Ported verbatim from glowcomm.js. // --------------------------------------------------------------------------- @@ -960,7 +993,7 @@ function createGlowFrontend(opts) { // Not part of the host contract — do not use it from page code. function _objs() { return glowObjs; } - return { handle: handle, tick: tick, pacingStopped: pacing_stopped, + return { handle: handle, tick: tick, poll: poll, pacingStopped: pacing_stopped, reset: reset, destroy: destroy, _objs: _objs }; } From 47875afaef3b5e78fd9ea58ece49271993d3fe31 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 14:23:21 -0400 Subject: [PATCH 10/16] fix(worker): the loud-deferral list was five constructs short, and sleep flushed uncapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review C1 and I2 (trinket .superpowers/sdd/2026-08-10-worker-vpython). apply_worker_patches() made pause, waitfor and widgets raise. It missed every OTHER synchronous barrier in the package, and each one spins the worker's only thread waiting for a browser reply that can only be delivered BY that thread: compound(...) `while not baseObj.sent: time.sleep(0.001)` — always hangs text(...) _wait(canvas) — always hangs extrusion(...) _wait(canvas) — always hangs scene.mouse.pick _wait(canvas) — always hangs obj.clone() `while not baseObj.empty(): rate(60)` — intermittent This branch made it worse rather than better: _wait() polls with rate(30), and rate is now a coroutine factory, so called from synchronous library code it builds a coroutine and discards it without ever sleeping. The hang became a 100% CPU spin with no yield. What a student sees is nothing at all — no output, no error, no traceback, and a Stop button that works. That is precisely the silent no-op the design's Errors section forbids ("a pause that doesn't pause corrupts program meaning"), and compound and text are not exotic in the M&I corpus a trial would be run against. So all five now raise the existing _DEFER message naming themselves, and _wait itself is patched as a backstop: those four are every caller in the package today, but a future one should say so rather than hang. Mouse.pick is a property, so it is replaced as one — the deferral has to fire on attribute access, not on a call — keeping the original setter, which already refuses. Separately, and the same defect class as the render cap rate() already had: _async_sleep flushed on EVERY call, so `while True: sleep(0.001)` floods the page with ~1000 update packages a second, each a postMessage plus a handle() on the main thread. Both now share one _flush_if_due() gate, so a loop mixing them obeys one cap rather than one each. glowcomm_host.js gains GLOWCOMM_HOST_VERSION (exposed as createGlowFrontend.version). The design's two-repo mitigation was "wheel filename carries the version; host shim logs both at boot" — the front-end had no version to log, so trinket's shim could not hold up its end. Now it can, and trinket's sync script refuses to copy a front-end whose version disagrees with the wheel's. Tests: 26 passed (up from 17) — six deferral cases asserting the exact message text, three for the shared flush gate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- tests/test_trinket_worker.py | 125 +++++++++++++++++++++ vpython/trinket_worker.py | 48 +++++++- vpython/vpython_libraries/glowcomm_host.js | 15 ++- 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py index f98f2f7..d474eb6 100644 --- a/tests/test_trinket_worker.py +++ b/tests/test_trinket_worker.py @@ -131,6 +131,62 @@ def test_sleep_returns_a_coroutine(worker_env): asyncio.run(c) +def test_sleep_flushes_updates_to_the_host(worker_env): + """sleep() is a pacing call too: the first one must push buffered work out.""" + vp, sent = worker_env + before = len(sent) + asyncio.run(vp.sleep(0.001)) + assert len(sent) > before + + +def test_sleep_honours_the_same_render_cap_as_rate(worker_env): + """`while True: sleep(0.001)` must not flood the page. + + rate() was capped; sleep() sat next to it flushing on every call, which is + ~1000 packages/second for a shape a beginner reaches by accident. Both now + share one gate, so this asserts the same property as + test_rate_honours_the_render_cap. + """ + vp, sent = worker_env + from vpython import rate_control + + calls = 40 + before = len(sent) + started = time.monotonic() + + async def burst(): + for _ in range(calls): + await vp.sleep(0.001) + + asyncio.run(burst()) + elapsed = time.monotonic() - started + flushes = len(sent) - before + + assert flushes < calls, 'no render cap: every sleep() flushed' + assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 + + +def test_rate_and_sleep_share_one_flush_gate(worker_env): + """One cap for the pair, not one each — a loop mixing them still obeys it.""" + vp, sent = worker_env + from vpython import rate_control + + calls = 40 + before = len(sent) + started = time.monotonic() + + async def burst(): + for _ in range(calls): + await vp.rate(1000) + await vp.sleep(0.001) + + asyncio.run(burst()) + elapsed = time.monotonic() - started + flushes = len(sent) - before + + assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 + + @pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') def test_pause_raises_with_the_message(worker_env): vp, _ = worker_env @@ -177,6 +233,75 @@ def test_every_widget_class_defers(worker_env, name, kwargs): getattr(vp, name)(bind=lambda: None, **kwargs) +# --- the synchronous barriers that used to deadlock silently ---------------- +# +# Each of these waits for a browser reply that, in a worker, can only be +# delivered by the very thread doing the waiting. Before they were patched they +# hung — at 100% CPU, because `_wait` polls with `rate(30)` and rate is now a +# coroutine factory that never sleeps when called from synchronous library code. +# Decision V5: a deferral must be LOUD. These assert the noise. + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_compound_defers(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.compound([]) + assert str(exc.value) == 'compound' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_text_defers(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.text(text='hello') + assert str(exc.value) == 'text' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_extrusion_defers(worker_env): + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.extrusion(path=[vp.vec(0, 0, 0), vp.vec(0, 0, -1)], + shape=vp.shapes.circle(radius=1)) + assert str(exc.value) == 'extrusion' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_clone_defers(worker_env): + """Patched on standardAttributes, so every drawable object is covered. + + clone() is the intermittent one: it spins only `while not baseObj.empty()`, + so whether it hangs depends on where the last flush fell. Raising always is + the point — a construct that deadlocks one run in three is harder to + diagnose than one that deadlocks every time. + """ + vp, _ = worker_env + from vpython import vpython as _vp + ball = object.__new__(vp.sphere) # no wire traffic needed + with pytest.raises(NotImplementedError) as exc: + _vp.standardAttributes.clone(ball) + assert str(exc.value) == 'obj.clone' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_mouse_pick_defers(worker_env): + """A property, so the deferral has to fire on ATTRIBUTE ACCESS, not a call.""" + vp, _ = worker_env + with pytest.raises(NotImplementedError) as exc: + vp.scene.mouse.pick + assert str(exc.value) == 'scene.mouse.pick' + DEFERRAL_SUFFIX + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnraisableExceptionWarning') +def test_the_module_level_waiter_defers_as_a_backstop(worker_env): + """text/extrusion/pick are every caller today; a future one must not hang.""" + vp, _ = worker_env + from vpython import vpython as _vp + with pytest.raises(NotImplementedError, match='waiting for a scene event'): + _vp._wait(None) + + def test_the_fixture_leaves_no_emscripten_build_cached(): """Runs last on purpose: every test above used worker_env. diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py index 8c85c10..db7482a 100644 --- a/vpython/trinket_worker.py +++ b/vpython/trinket_worker.py @@ -92,11 +92,22 @@ def apply_worker_patches(): _render_period = 1.0 / rate_control.MAX_RENDERS _last_flush = [float('-inf')] - async def _async_rate(maxRate): + def _flush_if_due(): + """Flush buffered updates, at most MAX_RENDERS times a second. + + Shared by rate() and sleep(): both are pacing calls in a student's loop, + and both must therefore obey the same render cap. Without it, + ``while True: sleep(0.001)`` — a shape a beginner reaches by accident — + floods the page with ~1000 update packages a second, each one a + postMessage plus a handle() on the main thread. + """ now = time.monotonic() if now - _last_flush[0] >= _render_period: _last_flush[0] = now baseObj.trigger() # flush buffered updates + + async def _async_rate(maxRate): + _flush_if_due() await asyncio.sleep(1.0 / float(maxRate)) def _rate(self, maxRate=100): @@ -114,7 +125,7 @@ def _rate(self, maxRate=100): rate_control._RateKeeper2.__call__ = _rate async def _async_sleep(dt): - baseObj.trigger() + _flush_if_due() await asyncio.sleep(dt) _vp.sleep = _async_sleep # BEFORE __init__'s star-import binds it @@ -130,6 +141,39 @@ def _raise(*args, **kwargs): # __init__, is the one shared entry point, so that is what gets patched. _vp.controls.setup = _deferred('widgets (button/slider/menu/checkbox/radio/winput)') + # The OTHER synchronous barriers — the ones that look like ordinary drawing + # rather than like waiting, which is exactly why they have to be loud. + # + # Each of these spins the one and only thread until the browser answers, and + # in a worker the browser's answer arrives *on that thread* (via + # __trinket_vpython_dispatch). So the wait can never end: they deadlock. + # Worse, they do it at 100% CPU — `_wait()` polls with `rate(30)`, and rate + # is now a coroutine factory, so called from synchronous library code it + # builds a coroutine and throws it away without ever sleeping. + # + # compound(...) vpython.py: `while not baseObj.sent: time.sleep(.001)` + # text(...) vpython.py: _wait(canvas) — measures the glyph run + # extrusion(...) vpython.py: _wait(canvas) — measures the swept shape + # scene.mouse.pick vpython.py: _wait(canvas) — waits for setpick + # obj.clone() vpython.py: `while not baseObj.empty(): rate(60)` + # + # A student who hits one of these gets no scene, no error and a Stop button + # that works — the silent no-op decision V5 exists to forbid. Making them + # raise costs the feature and keeps the diagnosis. (`clone` is the one that + # only *sometimes* hangs, depending on whether the buffer happens to be + # empty; a construct that deadlocks intermittently is worse than one that + # always does, not better.) + _vp.compound.__init__ = _deferred('compound') + _vp.text.__init__ = _deferred('text') + _vp.extrusion.__init__ = _deferred('extrusion') + _vp.standardAttributes.clone = _deferred('obj.clone') + # pick is a property; keep the original setter, which already refuses. + _vp.Mouse.pick = property(_deferred('scene.mouse.pick'), _vp.Mouse.pick.fset) + # Backstop for any other caller of the module-level waiter: the four above + # are every one in the package today, but a future one would otherwise hang + # silently rather than say so. + _vp._wait = _deferred('waiting for a scene event') + # GlowWidget() records itself as baseObj.glow. Outside a notebook it sets the # module-global sender to None, so ours is installed after it. diff --git a/vpython/vpython_libraries/glowcomm_host.js b/vpython/vpython_libraries/glowcomm_host.js index c5849d0..58ffc43 100644 --- a/vpython/vpython_libraries/glowcomm_host.js +++ b/vpython/vpython_libraries/glowcomm_host.js @@ -19,12 +19,23 @@ // fe.reset() // forget every object (new scene generation) // fe.destroy() // reset + ask the objects to remove themselves // +// createGlowFrontend.version // vpython-jupyter version this file ships with +// // Mouse/key event capture IS ported (see "the event channel" below). Widgets, // pause and waitfor are not: those are deferred by design (spec V5, they raise // NotImplementedError on the Python side) and their call sites warn and drop. 'use strict'; +// The half of the pair that is NOT the wheel. This file and the wheel are built +// from the same checkout and copied into a host by hand +// (trinket: scripts/sync-vpython-worker.sh), so the one question a running +// deploy has to be able to answer is "are these two the same vintage?". +// Carrying the vpython-jupyter version here lets a host log it next to the wheel +// filename and see a mismatch instead of debugging one. Keep it in step with +// SETUPTOOLS_SCM_PRETEND_VERSION when the wheel is rebuilt. +var GLOWCOMM_HOST_VERSION = '7.6.5'; + function createGlowFrontend(opts) { opts = opts || {}; @@ -997,6 +1008,8 @@ function createGlowFrontend(opts) { reset: reset, destroy: destroy, _objs: _objs }; } -var api = { createGlowFrontend: createGlowFrontend }; +createGlowFrontend.version = GLOWCOMM_HOST_VERSION; + +var api = { createGlowFrontend: createGlowFrontend, version: GLOWCOMM_HOST_VERSION }; if (typeof module !== 'undefined' && module.exports) module.exports = api; if (typeof self !== 'undefined') self.createGlowFrontend = createGlowFrontend; From ec6d59dbd3bb41de8d2049e01f4560dfc97858cd Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 14:59:12 -0400 Subject: [PATCH 11/16] fix(worker): rate(N) slept a full period ON TOP of the student's loop body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review caveat 5 (trinket .superpowers/sdd/2026-08-10-worker-vpython). rate(N) means "N iterations per second", not "sleep 1/N between them". The worker's _async_rate did the latter: await asyncio.sleep(1.0 / float(maxRate)) flat, regardless of what the loop body had just cost. So rate(60) — a 16.7 ms period — with 10 ms of physics per iteration paced at 37 Hz, not 60. Every program with real work in it ran measurably slower under the worker than the same program on the main-thread bridge, which contradicts the spec's success criterion ("renders and animates identically to the main path"). Measured here, before and after, at 100+ iterations per row: rate(50), 10 ms work 33.3 Hz -> 46.1 Hz (target 50) rate(60), 10 ms work 37.5 Hz -> 55.6 Hz (target 60) rate(100), 5 ms work 66.7 Hz -> 91.7 Hz (target 100) rate(30), 25 ms work 17.1 Hz -> 28.5 Hz (target 30) Upstream's _RateKeeper2.__call__ measures the time spent in user code between rate() returns (`userTime`, rate_control.py:173) and subtracts it from the delay. We already handle the OTHER half of what upstream does — decoupling render frequency from call frequency — via _flush_if_due()'s MAX_RENDERS cap, so only the timing half was missing. This is that half, as a fixed timestep: remember when the last call RETURNED (when the user's iteration began) and sleep only the remainder of the period. The residual ~6% shortfall in the numbers above is asyncio.sleep overshoot, not compensation error: it is equally present with no user code at all (rate(50) with an empty body measures 47.2 Hz), and closing it would mean carrying debt forward, which is precisely what must not happen. Four properties the implementation is pinned to, all tested: * The remainder is clamped at max(0, ...), and the next deadline is anchored on the ACTUAL return rather than on last+period. A loop that falls behind stays behind; it does not bank debt and then burst through a batch of zero-sleep iterations catching up. * It ALWAYS awaits, including at zero. When user code overruns the period there is nothing left to wait for, but a bare `return` would never yield to the event loop — and a worker loop that stops yielding dispatches no scene events and cannot be stopped. asyncio.sleep(0) yields. This is the safety property that matters more than the pacing one. * The period is recomputed each call, so rate(10) after a rate(1000) loop slows down on THAT call rather than one call later. * The first call has no previous return to measure a remainder from, so it yields and returns — as upstream's `count == 1` branch does after callInteract(). It costs at most one period, once, per program, and gets the flush that call just issued onto the page without an added wait. sleep(dt) is deliberately untouched: "sleep dt seconds" is not "pace at a target rate", and compensating it would make sleep(1) mean something other than one second. It keeps its _flush_if_due(). rate(0)/rate(-1) still raise ValueError synchronously at the call site, and the render cap is unchanged. Tests: 31 passed (up from 26) — five pacing cases, wall-clock assertions kept loose enough for noisy CI (direction and rough magnitude, never equality), with user code simulated by a busy spin rather than an await so the only yield point under test is rate() itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- tests/test_trinket_worker.py | 142 +++++++++++++++++++++++++++++++++++ vpython/trinket_worker.py | 31 +++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py index d474eb6..2d71aa3 100644 --- a/tests/test_trinket_worker.py +++ b/tests/test_trinket_worker.py @@ -116,6 +116,148 @@ async def burst(): assert flushes <= elapsed * rate_control.MAX_RENDERS + 2 +# --- pacing: rate(N) must deliver N iterations/second, work included --------- +# +# A flat ``asyncio.sleep(1/maxRate)`` sleeps the whole period on top of whatever +# the student's loop body cost, so rate(60) with 10 ms of physics runs at ~37 Hz. +# Upstream subtracts the measured user-code time (_RateKeeper2.__call__'s +# ``userTime``) from the delay; these assert the same property on the worker's +# fixed-timestep version of it. +# +# Wall-clock assertions are deliberately loose: they pin the DIRECTION and rough +# magnitude, because CI timing is noisy and asyncio.sleep only ever overshoots. + + +def _spin(seconds): + """Burn CPU without yielding — synchronous user code, as a student writes it. + + ``asyncio.sleep`` would be a yield point, which is exactly what these tests + must not hand the loop for free. + """ + end = time.monotonic() + seconds + while time.monotonic() < end: + pass + + +def test_rate_paces_at_the_target_with_negligible_user_code(worker_env): + """No regression: an empty loop at rate(N) still takes ~iterations/N.""" + vp, _ = worker_env + iterations, target = 10, 50 + ideal = iterations / target # 0.20 s + + async def loop(): + for _ in range(iterations): + await vp.rate(target) + + started = time.monotonic() + asyncio.run(loop()) + elapsed = time.monotonic() - started + + # Lower bound is one period short of ideal: the first call has no previous + # return to measure a remainder from and so only yields (see _async_rate). + assert elapsed > ideal * 0.7, 'rate(%d) did not pace at all' % target + assert elapsed < ideal * 1.6, 'rate(%d) paced far slower than target' % target + + +def test_rate_subtracts_user_code_time_from_the_period(worker_env): + """THE FIX: work inside the period must not be added on top of it. + + rate(50) is a 20 ms period; with 10 ms of user code per iteration the loop + must still take ~20 ms per iteration, not 30. Ten iterations: 0.2 s, not 0.3. + """ + vp, _ = worker_env + iterations, target, work = 10, 50, 0.010 + ideal = iterations / target # 0.20 s — work absorbed + flat = iterations * (1.0 / target + work) # 0.30 s — work added on + + async def loop(): + for _ in range(iterations): + await vp.rate(target) + _spin(work) + + started = time.monotonic() + asyncio.run(loop()) + elapsed = time.monotonic() - started + + assert elapsed < (ideal + flat) / 2, ( + 'rate(%d) with %.0f ms of user code took %.3f s for %d iterations; ' + 'flat-sleep behaviour is ~%.3f s, compensated is ~%.3f s' + % (target, work * 1000, elapsed, iterations, flat, ideal)) + + +def test_rate_still_yields_when_user_code_overruns_the_period(worker_env): + """Safety: over-period work must not turn rate() into a non-yielding return. + + If it stops yielding, the worker's event loop never runs — no scene events + get dispatched, and the Stop button dies. That matters more than the pacing. + """ + vp, _ = worker_env + calls = 5 + bystander_runs = [] + + async def bystander(): + while True: + bystander_runs.append(time.monotonic()) + await asyncio.sleep(0) + + async def loop(): + task = asyncio.ensure_future(bystander()) + await asyncio.sleep(0) # let it reach its first await + before = len(bystander_runs) + for _ in range(calls): + await vp.rate(1000) # 1 ms period... + _spin(0.005) # ...and 5 ms of user code + gained = len(bystander_runs) - before + task.cancel() + return gained + + gained = asyncio.run(loop()) + assert gained >= calls, ( + 'rate() yielded %d times in %d over-period calls — a loop that stops ' + 'yielding starves the worker (no events, no Stop)' % (gained, calls)) + + +def test_rate_does_not_accumulate_debt_after_an_overrun(worker_env): + """No catch-up burst: falling behind must not buy zero-sleep iterations. + + A deadline advanced by += period would owe five periods after a 100 ms + stall and then rush the next five calls through instantly. + """ + vp, _ = worker_env + target, catchup = 50, 5 + period = 1.0 / target + + async def loop(): + await vp.rate(target) # arm the timestep + _spin(period * 5) # fall five periods behind + await vp.rate(target) # already late: no sleep owed + started = time.monotonic() + for _ in range(catchup): + await vp.rate(target) + return time.monotonic() - started + + elapsed = asyncio.run(loop()) + assert elapsed > period * catchup * 0.7, ( + '%d calls to rate(%d) after an overrun took %.3f s — the loop burst ' + 'through them repaying debt' % (catchup, target, elapsed)) + + +def test_rate_recomputes_the_period_when_maxrate_changes(worker_env): + """maxRate may differ call to call; the period in force is the current one.""" + vp, _ = worker_env + + async def loop(): + await vp.rate(1000) # arm with a 1 ms period + started = time.monotonic() + await vp.rate(10) # 100 ms period, effective NOW + return time.monotonic() - started + + elapsed = asyncio.run(loop()) + assert elapsed > 0.05, ( + 'rate(10) after rate(1000) slept %.3f s — it reused the old period' + % elapsed) + + @pytest.mark.parametrize('bad', [0, -1]) def test_rate_rejects_values_below_one(worker_env, bad): """Parity with _RateKeeper2.__call__ -- rate(0) raises, it does not clamp.""" diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py index db7482a..1e0d404 100644 --- a/vpython/trinket_worker.py +++ b/vpython/trinket_worker.py @@ -106,9 +106,38 @@ def _flush_if_due(): _last_flush[0] = now baseObj.trigger() # flush buffered updates + # rate(N) means "N iterations per second", not "sleep 1/N between them". + # Upstream measures the time spent in user code between rate() returns + # (_RateKeeper2.__call__'s `userTime`, rate_control.py:173) and subtracts it + # from the delay. A flat sleep does not: rate(60) with 10 ms of physics per + # iteration runs at ~37 Hz, visibly slower than the same program elsewhere. + # So remember when the last call RETURNED (i.e. when the user's iteration + # began) and sleep only the remainder of the period. + _last_return = [None] + async def _async_rate(maxRate): _flush_if_due() - await asyncio.sleep(1.0 / float(maxRate)) + # Recomputed every call: maxRate may change between calls, and the + # period in force is the current one — rate(10) after a rate(1000) loop + # must slow down on THIS call, not the next. + period = 1.0 / float(maxRate) + last = _last_return[0] + # FIRST call: nothing has been timed yet, so no part of a period is + # owed; yield and return, as upstream's `count == 1` branch does after + # callInteract(). It costs one period once per program and gets the + # flush we just issued onto the page without an added wait. + remaining = 0.0 if last is None else (last + period) - time.monotonic() + # Always await, even at zero. When user code overruns the period the + # remainder is negative and there is nothing to wait for — but a bare + # return would never yield to the event loop, and a worker whose loop + # never yields dispatches no scene events and cannot be stopped. + # asyncio.sleep(0) yields; clamping at 0 also means a loop that falls + # behind simply stays behind rather than banking debt and then bursting + # through a batch of zero-sleep iterations to catch up. + await asyncio.sleep(remaining if remaining > 0.0 else 0.0) + # Anchor on the ACTUAL return, not on `last + period`: that is what makes + # the clamp above debt-free. + _last_return[0] = time.monotonic() def _rate(self, maxRate=100): # Validate SYNCHRONOUSLY, before building the coroutine: parity with From 68eff8c455e62600d8cf79229dc354f53a4459c4 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 15:21:50 -0400 Subject: [PATCH 12/16] fix(worker): the await is right, the reason given for it was wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 1 (correctness of the JUSTIFICATION, not of the code) plus finding 3. No behaviour change: comments, a docstring, an assert message, and one added assertion. e086450 said an unconditional await was needed because a rate() that stops yielding "cannot be stopped". That is false on this host. Stop is worker.terminate() on the page side — unconditional, needing nothing from the worker thread — and trinket's worker-client.js says so in its opening comment. A wrong reason attached to a right decision is a liability: the next reader checks it, finds Stop works fine without the await, and deletes the await. The true reason is narrower and worse. In a worker the host delivers browser events by CALLING __trinket_vpython_dispatch, and that call only gets a turn when the running coroutine gives one up. A rate() that returns without awaiting therefore dispatches no events and flushes no updates: the scene FREEZES while the program runs flat out. Stop surviving that is what makes it subtle rather than mild — a frozen picture from a program that is still running reads to a student as "vpython is broken", not as a hang, and Stop clearing it up is consistent with both stories. Corrected in all three places it appeared here; the report's copy goes with the trinket commit. Finding 3: test_rate_subtracts_user_code_time_from_the_period asserted only an upper bound, so "sleep zero whenever the body did any work" — a plausible mis-implementation of exactly this fix, and a much worse one than the flat sleep it replaced — would have passed it. It now also asserts elapsed > ideal * 0.7: compensating for the body's cost must not become free-running. Tests: 31 passed (unchanged count; the new bound rides on the existing case). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- tests/test_trinket_worker.py | 18 +++++++++++++++--- vpython/trinket_worker.py | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py index 2d71aa3..6ef5cc7 100644 --- a/tests/test_trinket_worker.py +++ b/tests/test_trinket_worker.py @@ -183,13 +183,23 @@ async def loop(): 'rate(%d) with %.0f ms of user code took %.3f s for %d iterations; ' 'flat-sleep behaviour is ~%.3f s, compensated is ~%.3f s' % (target, work * 1000, elapsed, iterations, flat, ideal)) + # The other side of it, or "compensation" that just stopped sleeping whenever + # the body did any work at all would pass the assertion above. Subtracting + # the body's cost must not turn rate(N) into a free-running loop. + assert elapsed > ideal * 0.7, ( + 'rate(%d) with user code ran at %.1f Hz — it stopped pacing rather than ' + 'compensating' % (target, iterations / elapsed)) def test_rate_still_yields_when_user_code_overruns_the_period(worker_env): """Safety: over-period work must not turn rate() into a non-yielding return. - If it stops yielding, the worker's event loop never runs — no scene events - get dispatched, and the Stop button dies. That matters more than the pacing. + In a worker the host delivers browser events by CALLING the transport's + dispatch, which only gets a turn when the running coroutine gives one up. A + rate() that returns without awaiting therefore freezes the scene — no events + dispatched, no flush — while the program runs flat out. (Stop survives it: + that is worker.terminate() on the page side and needs nothing from this + thread. Which makes the symptom subtler, not milder.) """ vp, _ = worker_env calls = 5 @@ -214,7 +224,9 @@ async def loop(): gained = asyncio.run(loop()) assert gained >= calls, ( 'rate() yielded %d times in %d over-period calls — a loop that stops ' - 'yielding starves the worker (no events, no Stop)' % (gained, calls)) + 'yielding starves the event loop, so no browser event is dispatched and ' + 'no update is flushed: the scene freezes while the program runs on' + % (gained, calls)) def test_rate_does_not_accumulate_debt_after_an_overrun(worker_env): diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py index 1e0d404..9ae680a 100644 --- a/vpython/trinket_worker.py +++ b/vpython/trinket_worker.py @@ -128,10 +128,21 @@ async def _async_rate(maxRate): # flush we just issued onto the page without an added wait. remaining = 0.0 if last is None else (last + period) - time.monotonic() # Always await, even at zero. When user code overruns the period the - # remainder is negative and there is nothing to wait for — but a bare - # return would never yield to the event loop, and a worker whose loop - # never yields dispatches no scene events and cannot be stopped. - # asyncio.sleep(0) yields; clamping at 0 also means a loop that falls + # remainder is negative and there is nothing left to wait for — but a + # bare return would never yield to the event loop, and in a worker run + # nothing else does either: the host delivers browser events by CALLING + # __trinket_vpython_dispatch, and that call only gets a turn when the + # running coroutine gives one up. A rate() that stops yielding therefore + # freezes the scene — no events dispatched, no flush, a program running + # flat out with a picture that never changes. + # + # It does NOT break Stop: Stop is worker.terminate() on the page side, + # which is unconditional and needs no cooperation from this thread + # (trinket's worker-client.js says so at the top). That makes the + # symptom subtler, not milder — a frozen animation from a program that + # is still running reads as "vpython is broken" rather than as a hang. + # + # asyncio.sleep(0) yields. Clamping at 0 also means a loop that falls # behind simply stays behind rather than banking debt and then bursting # through a batch of zero-sleep iterations to catch up. await asyncio.sleep(remaining if remaining > 0.0 else 0.0) From 2504893f15d3f9b152cf09843c7cee48bd932a10 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Mon, 10 Aug 2026 15:44:21 -0400 Subject: [PATCH 13/16] docs(worker): the mechanism was corrected everywhere EXCEPT the code it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of finding 1. Comments only; no behaviour change. pytest 31 passed. b403c71 replaced a false reason for the unconditional await ("a loop that stops yielding cannot be stopped") with a more careful one. Then the browser experiment for finding 6 disproved the replacement too, and I updated the report and trinket's spec — but not these three, which b403c71 had written hours earlier and which still said the scene "freezes", with "no events dispatched, no flush". So the same claim was wrong here twice in a row, the second time in the one place a reader of _async_rate actually looks. What the experiment showed — build with `if remaining <= 0.0: return`, sync, run — is narrower and much harder to notice: * the scene KEEPS ANIMATING; _flush_if_due() is synchronous, so outbound updates need no yield at all; * Stop KEEPS WORKING; terminate() needs nothing from this thread; * INBOUND events silently stop arriving — the host delivers them by calling __trinket_vpython_dispatch, which only gets a turn when the coroutine gives one up. Inbound dispatch is the only half that needs the yield. The failure mode is therefore a scene that animates, a Stop button that works, and mouse events that quietly do nothing — which is exactly why the first browser test written for it passed against the broken build. All three sites now say that, and each says the browser is where it was established rather than presenting it as deduction. The docstring also notes why the CPython test asserts a bystander coroutine instead: yielding is the property, and a co-scheduled task is its only visible consequence here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- tests/test_trinket_worker.py | 25 +++++++++++++++++-------- vpython/trinket_worker.py | 31 +++++++++++++++++++------------ 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/tests/test_trinket_worker.py b/tests/test_trinket_worker.py index 6ef5cc7..17c6576 100644 --- a/tests/test_trinket_worker.py +++ b/tests/test_trinket_worker.py @@ -194,12 +194,20 @@ async def loop(): def test_rate_still_yields_when_user_code_overruns_the_period(worker_env): """Safety: over-period work must not turn rate() into a non-yielding return. - In a worker the host delivers browser events by CALLING the transport's - dispatch, which only gets a turn when the running coroutine gives one up. A - rate() that returns without awaiting therefore freezes the scene — no events - dispatched, no flush — while the program runs flat out. (Stop survives it: - that is worker.terminate() on the page side and needs nothing from this - thread. Which makes the symptom subtler, not milder.) + What that costs was measured in a browser, not reasoned about, and it is + narrower than it sounds. The scene keeps animating: the flush above is + synchronous, so outbound updates go out without any yield. Stop keeps + working: that is worker.terminate() on the page side, needing nothing from + this thread. The one thing that breaks is INBOUND — the host delivers + browser events by CALLING the transport's dispatch, and that call only gets + a turn when the running coroutine gives one up, so scene.bind handlers and + mouse picks silently never fire. + + That is why this test asserts a bystander coroutine gets to run rather than + anything about output: yielding is the property, and in CPython a + co-scheduled task is the only visible consequence of it. The browser half + (a click handler firing during an over-period loop) lives in trinket's + worker-vpython.spec.js; both fail on the same mutation. """ vp, _ = worker_env calls = 5 @@ -224,8 +232,9 @@ async def loop(): gained = asyncio.run(loop()) assert gained >= calls, ( 'rate() yielded %d times in %d over-period calls — a loop that stops ' - 'yielding starves the event loop, so no browser event is dispatched and ' - 'no update is flushed: the scene freezes while the program runs on' + 'yielding starves the event loop, so the host never gets to deliver a ' + 'browser event: the scene still animates and Stop still works, but ' + 'scene.bind handlers and mouse picks silently never fire' % (gained, calls)) diff --git a/vpython/trinket_worker.py b/vpython/trinket_worker.py index 9ae680a..336df54 100644 --- a/vpython/trinket_worker.py +++ b/vpython/trinket_worker.py @@ -128,19 +128,26 @@ async def _async_rate(maxRate): # flush we just issued onto the page without an added wait. remaining = 0.0 if last is None else (last + period) - time.monotonic() # Always await, even at zero. When user code overruns the period the - # remainder is negative and there is nothing left to wait for — but a - # bare return would never yield to the event loop, and in a worker run - # nothing else does either: the host delivers browser events by CALLING - # __trinket_vpython_dispatch, and that call only gets a turn when the - # running coroutine gives one up. A rate() that stops yielding therefore - # freezes the scene — no events dispatched, no flush, a program running - # flat out with a picture that never changes. + # remainder is negative and there is nothing left to wait for, which + # makes an early `return` here look free. It is not, and what it costs + # was established by building that version and running it in a browser + # (see trinket's worker-vpython.spec.js) rather than by reasoning: # - # It does NOT break Stop: Stop is worker.terminate() on the page side, - # which is unconditional and needs no cooperation from this thread - # (trinket's worker-client.js says so at the top). That makes the - # symptom subtler, not milder — a frozen animation from a program that - # is still running reads as "vpython is broken" rather than as a hang. + # * The scene KEEPS ANIMATING. _flush_if_due() above is synchronous — + # it postMessages without yielding — so outbound updates go out + # exactly as before and the picture moves normally. + # * Stop KEEPS WORKING. That is worker.terminate() on the page side, + # unconditional, needing nothing from this thread (trinket's + # worker-client.js says so at the top). + # * INBOUND EVENTS SILENTLY STOP ARRIVING. The host delivers them by + # CALLING __trinket_vpython_dispatch, and that call only gets a turn + # when the running coroutine gives one up. Nothing else in a worker + # run does. So scene.bind handlers, mouse picks and the rest simply + # never fire. + # + # Inbound dispatch is the ONLY half that needs this yield, which is what + # makes losing it so hard to spot: a scene that animates, a Stop button + # that works, and mouse events that quietly do nothing. # # asyncio.sleep(0) yields. Clamping at 0 also means a loop that falls # behind simply stays behind rather than banking debt and then bursting From c8fd10ae6412a71ac17b104fc111e6c431b0fc87 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Sat, 15 Aug 2026 18:04:28 -0400 Subject: [PATCH 14/16] ci: build the pure-Python (py3-none-any) wheel as a pinned-artifact for wasm consumers VPYTHON_PURE_PYTHON=1 wheel built on every push to master/pyodide-packaging, verified pure + importable, sha256-summed, uploaded as an actions artifact, and attached to GitHub releases. Downstream (trinket) pins by URL + sha256. Also gitignore local virtualenvs. --- .github/workflows/pure-wheel.yml | 58 ++++++++++++++++++++++++++++++++ .gitignore | 4 +++ 2 files changed, 62 insertions(+) create mode 100644 .github/workflows/pure-wheel.yml diff --git a/.github/workflows/pure-wheel.yml b/.github/workflows/pure-wheel.yml new file mode 100644 index 0000000..31cf99e --- /dev/null +++ b/.github/workflows/pure-wheel.yml @@ -0,0 +1,58 @@ +name: Pure-Python wheel (wasm/Pyodide) + +# Builds the VPYTHON_PURE_PYTHON=1 wheel (py3-none-any) that Pyodide/micropip +# and other wasm targets can install — they cannot use platform wheels. +# Consumers (e.g. trinket) pin the artifact by URL + sha256; a GitHub release +# gets the wheel attached automatically so pins survive artifact expiry. + +on: + workflow_dispatch: + push: + branches: [master, pyodide-packaging] + release: + types: [published] + +jobs: + pure-wheel: + runs-on: ubuntu-latest + permissions: + contents: write # needed only for the release-asset upload step + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # setuptools_scm-style dev versions need history + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Build pure wheel + run: | + pip install build + VPYTHON_PURE_PYTHON=1 python -m build --wheel --outdir dist + ls -l dist + + - name: Verify wheel is py3-none-any and imports + run: | + python - <<'EOF' + import glob, sys + w = glob.glob('dist/*.whl') + assert w and w[0].endswith('py3-none-any.whl'), f"not a pure wheel: {w}" + print("pure wheel:", w[0]) + EOF + pip install dist/*.whl + python -c "import vpython; print('import ok', vpython.__version__)" + + - name: Checksum + run: shasum -a 256 dist/*.whl | tee dist/SHA256SUMS + + - uses: actions/upload-artifact@v4 + with: + name: vpython-pure-wheel + path: dist/ + + - name: Attach wheel to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist/*.whl dist/SHA256SUMS --clobber diff --git a/.gitignore b/.gitignore index 7dfb4c1..9ac7803 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ docs/_build/ # PyBuilder target/ + +# local virtualenvs +.venv/ +venv/ From 77521bb165ea248d509426e2511a1e54c43dcd52 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Sat, 15 Aug 2026 19:06:16 -0400 Subject: [PATCH 15/16] ci: never publish pre-releases to PyPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release tags host pinned wasm artifacts (pure-wheel.yml); the PyPI upload must only run for real releases. Guard is per-job because uploads happen per-job — a pre-release partially published 7.6.6.dev0 before its linux legs failed. --- .github/workflows/upload_pypi.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/upload_pypi.yml b/.github/workflows/upload_pypi.yml index 7f31eb4..231bcd2 100644 --- a/.github/workflows/upload_pypi.yml +++ b/.github/workflows/upload_pypi.yml @@ -2,10 +2,17 @@ name: Upload Python wheel to PyPI on: release: + if: ${{ !github.event.release.prerelease }} types: [created] +# Pre-releases exist to host pinned artifacts (see pure-wheel.yml) — they must +# NEVER publish to PyPI. Guarded per-job because uploads happen per-job: on +# 2026-08-15 a pre-release tag partially published 7.6.6.dev0 (sdist + mac/win +# wheels) before the linux legs failed. + jobs: wheels: + if: ${{ !github.event.release.prerelease }} strategy: max-parallel: 4 @@ -40,6 +47,7 @@ jobs: twine upload dist/*.whl linux_wheels: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -71,6 +79,7 @@ jobs: twine upload dist/vpython-*-manylinux*.whl linux_aarch64_wheels: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -100,6 +109,7 @@ jobs: twine upload dist/vpython-*-manylinux*.whl sdist: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From 6c357e6c1a773ca614cc981bf6efea80ec8c46b2 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Sat, 15 Aug 2026 19:23:52 -0400 Subject: [PATCH 16/16] =?UTF-8?q?ci:=20fix=20invalid=20if=20under=20on.rel?= =?UTF-8?q?ease=20=E2=80=94=20the=20guard=20belongs=20on=20jobs=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/upload_pypi.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/upload_pypi.yml b/.github/workflows/upload_pypi.yml index 231bcd2..c96271a 100644 --- a/.github/workflows/upload_pypi.yml +++ b/.github/workflows/upload_pypi.yml @@ -2,7 +2,6 @@ name: Upload Python wheel to PyPI on: release: - if: ${{ !github.event.release.prerelease }} types: [created] # Pre-releases exist to host pinned artifacts (see pure-wheel.yml) — they must