marimo-team / marimo

A reactive notebook for Python — run reproducible experiments, execute as a script, deploy as an app, and version with git.
https://marimo.io
Apache License 2.0
7.99k stars 284 forks source link

Autoreload in mounted apps #2910

Open liquidcarbon opened 5 days ago

liquidcarbon commented 5 days ago

Describe the bug

I'm having a lot of fun building dashboards for a data app using Marimo mounted on top of FastAPI.

One minor nuisance is that when I change the notebook that drives the app, it takes a couple of refreshes to pick up the changes.

The left screen is the running marimo notebook (via marimo edit), the right is the FastAPI app served by uvicorn with autoreload.

Image

Is this something that's easy to fix?

I'm one step away from app dev heaven :)

Environment

{
  "marimo": "0.9.20",
  "OS": "Linux",
  "OS Version": "5.4.17-2136.331.7.el8uek.x86_64",
  "Processor": "x86_64",
  "Python Version": "3.12.7",
  "Binaries": {
    "Browser": "--",
    "Node": "--"
  },
  "Dependencies": {
    "click": "8.1.7",
    "docutils": "0.21.2",
    "itsdangerous": "2.2.0",
    "jedi": "0.19.2",
    "markdown": "3.7",
    "narwhals": "1.13.5",
    "packaging": "24.1",
    "psutil": "6.1.0",
    "pygments": "2.18.0",
    "pymdown-extensions": "10.12",
    "pyyaml": "6.0.2",
    "ruff": "0.7.4",
    "starlette": "0.41.2",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.12.2",
    "uvicorn": "0.32.0",
    "websockets": "12.0"
  },
  "Optional Dependencies": {
    "altair": "5.4.1",
    "duckdb": "1.1.3",
    "pandas": "2.2.3",
    "pyarrow": "18.0.0"
  }
}

Code to reproduce

FastAPI logs during the GIF recording - that 401 Unauthorized might be the culprit?

241120 @ 10:57:37.573 WARNING:  StatReload detected changes in 'tdw/api/home.py'. Reloading...
241120 @ 10:57:37.669 INFO:     Shutting down
241120 @ 10:57:37.671 INFO:     connection closed
241120 @ 10:57:37.772 INFO:     Waiting for application shutdown.
241120 @ 10:57:37.772 INFO:     Application shutdown complete.
241120 @ 10:57:37.772 INFO:     Finished server process [3331868]
241120 @ 10:57:39.191 INFO:     Started server process [3331963]
241120 @ 10:57:39.191 INFO:     Waiting for application startup.
241120 @ 10:57:39.192 INFO:     Application startup complete.
241120 @ 10:57:40.184 INFO:     ('10.125.136.48', 56092) - "WebSocket /home/ws?session_id=s_z76eu1" [accepted]
241120 @ 10:57:40.278 INFO:     connection open
241120 @ 10:57:40.610 INFO:     10.125.136.48:56093 - "POST /home/api/kernel/instantiate HTTP/1.1" 401 Unauthorized
241120 @ 10:57:41.001 INFO:     connection closed
241120 @ 10:57:44.418 INFO:     ('10.125.136.48', 56094) - "WebSocket /home/ws?session_id=s_z76eu1" [accepted]
241120 @ 10:57:44.419 INFO:     connection open
241120 @ 10:57:48.005 INFO:     10.125.136.48:56095 - "GET /home/ HTTP/1.1" 200 OK
241120 @ 10:57:48.231 INFO:     connection closed
241120 @ 10:57:48.423 INFO:     ('10.125.136.48', 56097) - "WebSocket /home/ws?session_id=s_vdzy2p" [accepted]
241120 @ 10:57:48.442 INFO:     connection open
241120 @ 10:57:48.648 INFO:     10.125.136.48:56095 - "POST /home/api/kernel/instantiate HTTP/1.1" 200 OK

app.py - via .venv/bin/python -m api.app

from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from .marimo_server import marimo_server

app = FastAPI()

@app.get("/", response_class=RedirectResponse)
def home():
    return "/home"

app.mount("/", marimo_server.build())

if __name__ == "__main__":
    import uvicorn
    app_path = "api.app:app"
    uvicorn.run(
        app_path, host="0.0.0.0", port=7892, access_log=True, use_colors=True, reload=1
    )

marimo_server.py

import marimo as mo
from pathlib import Path
from typing import Dict

def find_marimo_files() -> Dict[str, Path]:
    """Discover marimo files for mounting as FastAPI endpoints."""
    mo_os = mo._server.files.os_file_system.OSFileSystem()
    py_files = Path(__file__).parent.rglob("*.py")
    return {
        f.stem: f
        for f in py_files
        if mo_os._is_marimo_file(f.as_posix())
    }

def build_nav_menu(marimo_files: Dict[str, Path]) -> Dict[str, str]:
    """Build nav_menu that will go into each marimo notebook."""
    nav_menu = {
        "/" + name: name.capitalize()
        for name in marimo_files
    }
    return nav_menu

def create_server():
    """Marimo ASGI server mounted on top of FastAPI app."""
    server = mo.create_asgi_app(include_code=True)
    for name, filepath in marimo_files.items():
        server = server.with_app(
            path="/"+name, root=filepath.as_posix()
        )
    return server

marimo_files = find_marimo_files()
nav_menu = build_nav_menu(marimo_files)
marimo_server = create_server()

class UI:
    TITLE = "# One Notebook To Rule Them All"
    mo_nav_menu = mo.nav_menu(nav_menu).style(font_size="20px")
    footer = mo.Html(
        """
        Footer
        """
    ).style(
        position="fixed", bottom=0, left="1%", width="98%", padding="8px",
        background_color="#f7d5cc", border_top="1px solid #ccc",
    )
    home_img = mo.image(
        "https://leelinesourcing.com/wp-content/uploads/2018/07/mat-288.jpg",
        width=480,
        alt="Outliers"
    ).left()
mscolnick commented 5 days ago

@liquidcarbon, marimo apps are stateful so when fastapi autoreload, it actually tears down all the state, including your active session, so the 401 makes sense to me since you no longer have the session you are connecting to.

maybe we can do something so that the page auto-refreshes too (creating a new session)

liquidcarbon commented 5 days ago

That would be cool! Could you point me at where in the code I can poke around?

I think the timing of refresh also matters. It wouldn't matter in my example because I'm just changing markdown, but if I started altering code, autosave may capture the notebook in a broken state. Maybe it's better to disable autosave when working in that way.

liquidcarbon commented 5 days ago

Also: #2891

liquidcarbon commented 5 days ago

What's the reason you don't use the watcher available in marimo run in the ASGI apps?

https://github.com/marimo-team/marimo/blob/main/marimo/_server/start.py#L116 vs https://github.com/marimo-team/marimo/blob/main/marimo/_server/asgi.py#L150

mscolnick commented 4 days ago

good question! idk, i originally made the ASGI apps for deployment and didn't think about using it for development. but i can see why you'd want to. does adding the watcher work for you? if you can add the watcher but turn off fastapis built-in watcher, that might work

liquidcarbon commented 4 days ago

Seems like your lifespans need to be in the parent app itself, something like app = FastAPI(lifespan=lifespans.Lifespans([lifespans.watcher])) - but at this point I really don't know what I'm doing anymore. :)

Without that, tried modifying asgi.py:

   146              app = create_starlette_app(
   147                  base_url="",
   148                  lifespan=lifespans.Lifespans(
   149                      [
   150                          # Not all lifespans are needed for run mode
   151                          lifespans.watcher,  # <- here
   152                          lifespans.etc,
   153                          lifespans.signal_handler,
   154                      ]
   155                  ),
   156                  enable_auth=not AuthToken.is_empty(auth_token),
   157                  allow_origins=("*",),
   158              )
   159              app.state.watch = True  # <- and here
   160              app.state.session_manager = session_manager
   161              app.state.base_url = path
   162              app.state.config_manager = user_config_mgr

No noticeable changes. Tried with and without uvicorn's autoreload, with and without watchdog. In marimo run --watch everything works smoothly.