diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 3899b4f0..187bf9fa 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -173,6 +173,8 @@ 'fasthtml.jupyter': { 'fasthtml.jupyter.HTMX': ('api/jupyter.html#htmx', 'fasthtml/jupyter.py'), 'fasthtml.jupyter.JupyUvi': ('api/jupyter.html#jupyuvi', 'fasthtml/jupyter.py'), 'fasthtml.jupyter.JupyUvi.__init__': ('api/jupyter.html#jupyuvi.__init__', 'fasthtml/jupyter.py'), + 'fasthtml.jupyter.JupyUvi._live_sse': ('api/jupyter.html#jupyuvi._live_sse', 'fasthtml/jupyter.py'), + 'fasthtml.jupyter.JupyUvi._setup_live': ('api/jupyter.html#jupyuvi._setup_live', 'fasthtml/jupyter.py'), 'fasthtml.jupyter.JupyUvi.start': ('api/jupyter.html#jupyuvi.start', 'fasthtml/jupyter.py'), 'fasthtml.jupyter.JupyUvi.start_async': ('api/jupyter.html#jupyuvi.start_async', 'fasthtml/jupyter.py'), 'fasthtml.jupyter.JupyUvi.stop': ('api/jupyter.html#jupyuvi.stop', 'fasthtml/jupyter.py'), diff --git a/fasthtml/jupyter.py b/fasthtml/jupyter.py index 5b9e180a..409f20ea 100644 --- a/fasthtml/jupyter.py +++ b/fasthtml/jupyter.py @@ -79,16 +79,18 @@ def htmx_config_port(port=8000): }); ''' % port)) -# %% ../nbs/api/06_jupyter.ipynb #29a834a5 +# %% ../nbs/api/06_jupyter.ipynb #79406618 class JupyUvi: "Start and stop a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`" - def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True, daemon=False, **kwargs): + def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True, live=False, live_rt='/_lr', daemon=False, **kwargs): self.kwargs = kwargs - store_attr(but='start') + store_attr(but='start,live') self.server = None + self._live_ver = 0 + if live: self._setup_live(app) if start: self.start() if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port) - + def start(self): self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port,daemon=self.daemon, **self.kwargs) @@ -99,6 +101,21 @@ def stop(self): self.server.should_exit = True wait_port_free(self.port) + def _setup_live(self, app): + rt = self.live_rt or '/_lr' + if not rt.startswith('/'): rt = f'/{rt}' + app.hdrs.append(Script(f"new EventSource({rt!r}).onmessage=e=>{{if(e.data==='reload')navigation.reload()}}")) + @app.get(rt) + async def _sse(): return EventStream(self._live_sse()) + get_ipython().events.register('post_run_cell', lambda _: setattr(self, '_live_ver', self._live_ver+1)) + + async def _live_sse(self): + ver = self._live_ver + while not self.server.should_exit: + await asyncio.sleep(0.1) + if ver != self._live_ver: + ver = self._live_ver + yield 'data: reload\n\n' # %% ../nbs/api/06_jupyter.ipynb #9134035e class JupyUviAsync(JupyUvi): diff --git a/nbs/api/06_jupyter.ipynb b/nbs/api/06_jupyter.ipynb index d8fcc86d..c24696e1 100644 --- a/nbs/api/06_jupyter.ipynb +++ b/nbs/api/06_jupyter.ipynb @@ -187,20 +187,22 @@ { "cell_type": "code", "execution_count": null, - "id": "29a834a5", + "id": "79406618", "metadata": {}, "outputs": [], "source": [ "#| export\n", "class JupyUvi:\n", " \"Start and stop a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`\"\n", - " def __init__(self, app, log_level=\"error\", host='0.0.0.0', port=8000, start=True, daemon=False, **kwargs):\n", + " def __init__(self, app, log_level=\"error\", host='0.0.0.0', port=8000, start=True, live=False, live_rt='/_lr', daemon=False, **kwargs):\n", " self.kwargs = kwargs\n", - " store_attr(but='start')\n", + " store_attr(but='start,live')\n", " self.server = None\n", + " self._live_ver = 0\n", + " if live: self._setup_live(app)\n", " if start: self.start()\n", " if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port)\n", - "\n", + " \n", " def start(self):\n", " self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port,daemon=self.daemon, **self.kwargs)\n", "\n", @@ -209,7 +211,54 @@ "\n", " def stop(self):\n", " self.server.should_exit = True\n", - " wait_port_free(self.port)\n" + " wait_port_free(self.port)\n", + "\n", + " def _setup_live(self, app):\n", + " rt = self.live_rt or '/_lr'\n", + " if not rt.startswith('/'): rt = f'/{rt}'\n", + " app.hdrs.append(Script(f\"new EventSource({rt!r}).onmessage=e=>{{if(e.data==='reload')navigation.reload()}}\"))\n", + " @app.get(rt)\n", + " async def _sse(): return EventStream(self._live_sse())\n", + " get_ipython().events.register('post_run_cell', lambda _: setattr(self, '_live_ver', self._live_ver+1))\n", + "\n", + " async def _live_sse(self):\n", + " ver = self._live_ver\n", + " while not self.server.should_exit:\n", + " await asyncio.sleep(0.1)\n", + " if ver != self._live_ver:\n", + " ver = self._live_ver\n", + " yield 'data: reload\\n\\n'" + ] + }, + { + "cell_type": "markdown", + "id": "db9364ca", + "metadata": {}, + "source": [ + "Live Browser Refresh:\n", + "---\n", + "\n", + "Since JupyUvi doesn't restart the uvicorn process, and `FastHTMLWithLiveReload` relies on the websocket connection dropping to trigger a reload, we use a custom SSE implementation to add live client reloads to JupyUvi, controlled via the `live` param." + ] + }, + { + "cell_type": "markdown", + "id": "f9e41531", + "metadata": {}, + "source": [ + "The logic flows as follows:\n", + "\n", + "- A version number is stored as a private attr on the instance: `self._live_ver = 0`\n", + "- `_setup_live()` then sets up the app to handle browser refreshes:\n", + " 1. The `live_rt = '/_lr'` controls what endpoint the SSE generator will be attached to on the app. This is normalised w/ a fallback to ensure the endpoint works correctly.\n", + " 2. A Script tag is appended to the global app headers to ensure all standard routes connect to the SSE endpoint when opened in a client. It connects to the path `self.live_rt` and listens for a `data: reload` event from the SSE endpoint. On receiving this, a browser reload is triggered via `navigation.reload()`.\n", + " 3. The route is registered on the app passed through to `JupyUvi`, and the generator (detailed below) is served from the `EventStream` response.\n", + " 4. A callback is attached to the Ipython event `'post_run_cell'`, which is triggered after a cell/ code has been executed in the kernel and returned. We incremement the `JupyUvi` version attribute each time a cell has been run to completion.\n", + "\n", + "- `_live_sse` is the generator served from our SSE endpoint, which does the following:\n", + " 1. Creates a reference to the global version number, since a generator is created fresh for each connected client.\n", + " 2. Create a `while` loop to poll the global version number -> generator's version to check for code changes. `self.server.should_exit` is set via `stop()`, so all generators exit immediately when `stop()` is called so open connections don't hang.\n", + " 3. If the client/generators version doesn't match the instance version (code has been run in the kernel since the last loop iteration), we send a `data: reload` event to the `EventSource` listener and trigger a browser reload for each non-matching client." ] }, { @@ -874,6 +923,14 @@ "display_name": "python3", "language": "python", "name": "python3" + }, + "solveit": { + "default_code": true, + "mode": "learning", + "use_fence": false, + "use_thinking": false, + "use_tools": true, + "ver": 2 } }, "nbformat": 4,