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,