Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions fasthtml/_modidx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
25 changes: 21 additions & 4 deletions fasthtml/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,18 @@ def htmx_config_port(port=8000):
});
</script>''' % 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):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The live_rt param is the path of the SSE route that get's registered in the app if live is set to True, to send reload events to connected clients

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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since multiple clients can be connected to our app at a given time, using a version over a boolean flag ensures all client's reload (since each get's their own version number once connected to the generator endpoint)

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)

Expand All @@ -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()}}"))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registers a simple EventSource in the client browsers by adding it to the global app headers. Logic is as follows:

  1. Clients connect to an SSE endpoint at self.live_rt (which falls back to /_lr if overridden)
  2. When the browser receives a data: reload event from the server, it triggers a browser-side reload to fetch the latest version of whatever page is loaded

@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))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This registers a global callback in the running IPykernel. Each time a cell has been run/ executed in the kernel, it triggers the callback & increments the self._live_ver variable


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'
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator which sends the reload events to connected clients.

Each client that connects receives it's own new generator on the server-side. Each generator stores an initial reference to the JupyUvi _live_ver variable.

If the client's version doesn't match the server version state, it sends a data: reload event to that client, triggering a browser refresh.

The self.server.should_exit loop conditional ensures generators are stopped & cleaned up before the uvicorn thread/ process exits so it doesn't cause connection hangs. It seems to work well in notebook environments since stop() sets the server attribute first


# %% ../nbs/api/06_jupyter.ipynb #9134035e
class JupyUviAsync(JupyUvi):
Expand Down
36 changes: 31 additions & 5 deletions nbs/api/06_jupyter.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -209,7 +211,23 @@
"\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'"
]
},
{
Expand Down Expand Up @@ -874,6 +892,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,
Expand Down
Loading