diff --git a/solara/server/app.py b/solara/server/app.py index 934435e05..742e171aa 100644 --- a/solara/server/app.py +++ b/solara/server/app.py @@ -31,6 +31,8 @@ state_directory = Path(".") / "states" state_directory.mkdir(exist_ok=True) +reload.reloader.start() + class AppType(str, Enum): SCRIPT = "script" @@ -91,6 +93,7 @@ def __init__(self, name, default_app_name="Page"): warn_is_widget() def _execute(self): + logger.info("Executing %s", self.name) app = None local_scope = { "display": display, @@ -270,7 +273,13 @@ def reload(self): with context: # we save the state for when the app reruns, so we stay in the same state. # (e.g. button clicks, chosen options etc) - context.state = render_context.state_get() + # for instance a dataframe, needs to be pickled, because after the pandas + # module is reloaded, it's a different class type + logger.info("pickling state: %s", render_context.state_get()) + try: + context.state = pickle.dumps(render_context.state_get()) + except Exception as e: + logger.warning("Could not pickle state, next render the state will be lost: %s", e) # clear/cleanup the render_context, so during reload we start # from scratch context.app_object = None @@ -417,6 +426,9 @@ def _run_app( # app.signal_hook_install() main_object = app_script.run() + app_state = pickle.loads(app_state) if app_state is not None else None + if app_state: + logger.info("Restoring state: %r", app_state) context = get_current_context() container = context.container @@ -464,35 +476,25 @@ def load_app_widget(app_state, app_script: AppScript, pathname: str): try: render_context = context.app_object with context: - with reload.reloader.watch(): - while True: - # reloading might take in extra dependencies, so the reload happens first - if reload.reloader.requires_reload: - reload.reloader.reload() - # reload before starting app, because we may load state using pickle - # if we do that before reloading, the classes are not compatible: - app_state = app_state_initial - # e.g.: _pickle.PicklingError: Can't pickle : it's not the same object as testapp.Clicks - try: - widget, render_context = _run_app( - app_state, - app_script, - pathname, - render_context=render_context, - ) - if render_context is None: - assert context.container is not None - context.container.children = [widget] - except Exception: - if settings.main.use_pdb: - logger.exception("Exception, will be handled by debugger") - pdb.post_mortem() - raise - - if render_context: - context.app_object = render_context - if not reload.reloader.requires_reload: - break + app_state = app_state_initial + try: + widget, render_context = _run_app( + app_state, + app_script, + pathname, + render_context=render_context, + ) + if render_context is None: + assert context.container is not None + context.container.children = [widget] + except Exception: + if settings.main.use_pdb: + logger.exception("Exception, will be handled by debugger") + pdb.post_mortem() + raise + + if render_context: + context.app_object = render_context except BaseException as e: error = "" diff --git a/solara/server/reload.py b/solara/server/reload.py index b111a4b13..01dad3d28 100644 --- a/solara/server/reload.py +++ b/solara/server/reload.py @@ -156,17 +156,20 @@ def reload(self): # before we did this: # # don't reload modules like solara.server and react # # that may cause issues (like 2 Element classes existing) - reload_modules = {k for k in set(sys.modules) - set(self.ignore_modules) if not (k.startswith("solara.server") or k.startswith("anyio"))} + # not sure why, but if we reload pandas, the integration/reload_test.py fails + reload_modules = { + k for k in set(sys.modules) - set(self.ignore_modules) if not (k.startswith("solara.server") or k.startswith("anyio") or k.startswith("pandas")) + } # which picks up import that are done in threads etc, but it will also reload starlette, httptools etc # which causes issues with exceptions and isinstance checks. # reload_modules = self.watched_modules logger.info("Reloading modules... %s", reload_modules) # not sure if this is needed importlib.invalidate_caches() - for mod in reload_modules: + for mod in sorted(reload_modules): # don't reload modules like solara.server and react # that may cause issues (like 2 Element classes existing) - logger.info("Reloading module %s", mod) + logger.debug("Reloading module %s", mod) sys.modules.pop(mod, None) # if all succesfull... self.requires_reload = False diff --git a/solara/server/starlette.py b/solara/server/starlette.py index 5f9465939..8cb2c665a 100644 --- a/solara/server/starlette.py +++ b/solara/server/starlette.py @@ -19,7 +19,6 @@ from starlette.staticfiles import StaticFiles import solara -from solara.server import reload from solara.server.threaded import ServerBase from . import app as appmod @@ -267,7 +266,6 @@ def lookup_path(self, path: str) -> typing.Tuple[str, typing.Optional[os.stat_re def on_startup(): # TODO: configure and set max number of threads # see https://github.com/encode/starlette/issues/1724 - reload.reloader.start() telemetry.server_start() diff --git a/tests/integration/reload_test.py b/tests/integration/reload_test.py index 00b3775ac..b72663098 100644 --- a/tests/integration/reload_test.py +++ b/tests/integration/reload_test.py @@ -1,48 +1,102 @@ -# import contextlib -# import logging -# from pathlib import Path +import contextlib +import logging + +# import subprocess +from pathlib import Path # import playwright.sync_api -# from solara.server import reload +# import solara.server.server -# app_path = Path(__file__).parent / "testapp.py" +# from . import conftest -# logger = logging.getLogger("solara-test.integration.reload_test") +app_path = Path(__file__).parent / "testapp.py" +logger = logging.getLogger("solara-test.integration.reload_test") -# @contextlib.contextmanager -# def append(text): -# with app_path.open() as f: -# content = f.read() -# try: -# with app_path.open("w") as f: -# f.write(content) -# f.write(text) -# yield -# finally: -# with app_path.open("w") as f: -# f.write(content) - - -# @contextlib.contextmanager -# def replace(path, text): -# with path.open() as f: -# content = f.read() -# try: -# with path.open("w") as f: -# f.write(text) -# yield -# finally: -# with path.open("w") as f: -# f.write(content) + +HERE = Path(__file__).parent + + +@contextlib.contextmanager +def append(text): + with app_path.open() as f: + content = f.read() + try: + with app_path.open("w") as f: + f.write(content) + f.write(text) + yield + finally: + with app_path.open("w") as f: + f.write(content) + + +@contextlib.contextmanager +def replace(path, text): + with path.open() as f: + content = f.read() + try: + with path.open("w") as f: + f.write(text) + yield + finally: + with path.open("w") as f: + f.write(content) + + +# button_code = """ +# @solara.component +# def ButtonClick(): +# clicks, set_clicks = solara.use_state(Clicks(0)) +# return rw.Button(description=f"!!! {clicks.value} times", on_click=lambda: set_clicks(Clicks(clicks.value + 1))) + + +# app = ButtonClick() +# """ +# def test_reload_with_pickle(page_session: playwright.sync_api.Page): +# port = conftest.TEST_PORT + 1000 +# conftest.TEST_PORT += 1 +# args = ["solara", "run", f"--port={port}", "--no-open", "tests.integration.testapp:app", "--log-level=debug"] +# popen = subprocess.Popen(args, shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) +# host = "localhost" +# try: +# solara.server.server.wait_ready(f"http://{host}:{port}", timeout=10) +# page_session.goto(f"http://localhost:{port}") +# page_session.locator("text=Clicked 0 times").click(timeout=5000) +# page_session.locator("text=Clicked 1 times").click(timeout=5000) +# page_session.locator("text=Clicked 2 times").wait_for(timeout=5000) +# page_session.wait_for_timeout(1000) +# with append(button_code): +# page_session.locator("text=!!! 0 times").click(timeout=5000) +# page_session.locator("text=!!! 1 times").click(timeout=5000) +# popen.kill() +# except Exception as e: +# try: +# popen.kill() +# except: # noqa +# pass +# outs, errs = popen.communicate(timeout=5) +# if errs: +# print("STDERR:") # noqa +# print(errs.decode("utf-8")) # noqa +# if outs: +# print("STDOUT:") # noqa +# print(outs.decode("utf-8")) # noqa +# if errs: +# raise ValueError("Expected no errors in solara server output") from e +# raise + + +# the following tests are flakey on CI + # def test_reload_syntax_error(page_session: playwright.sync_api.Page, solara_server, solara_app, extra_include_path): # with extra_include_path(app_path.parent), solara_app("testapp:ButtonClick"): # # use as module, otherwise pickle wil not work # page_session.goto(solara_server.base_url) -# assert page_session.title() == "Hello from Solara ☀️" +# assert page_session.title() == "Solara ☀️" # page_session.locator("text=Clicked 0 times").click() # page_session.locator("text=Clicked 1 times").click() @@ -55,12 +109,25 @@ # page_session.locator("text=Clicked 3 times").wait_for() +# def test_reload_change(page_session: playwright.sync_api.Page, solara_server, solara_app, extra_include_path): +# with extra_include_path(app_path.parent), solara_app("testapp:app"): +# logger.info("test_reload_many:run app") +# page_session.goto(solara_server.base_url) +# # assert page_session.title() == "Solara ☀️" +# page_session.locator("text=Clicked 0 times").click() +# page_session.locator("text=Clicked 1 times").click() +# with append(button_code): +# reload.reloader.reload_event_next.wait() +# # page_session.locator("text=Clicked 2 times").click() +# # page_session.locator("text=SyntaxError").wait_for() +# page_session.locator("text=!!! 0 times").click() + + # def test_reload_many(page_session: playwright.sync_api.Page, solara_server, solara_app, extra_include_path): # with extra_include_path(app_path.parent), solara_app("testapp:app"): # logger.info("test_reload_many:run app") # # use as module, otherwise pickle wil not work # page_session.goto(solara_server.base_url) -# assert page_session.title() == "Hello from Solara ☀️" # page_session.locator("text=Clicked 0 times").click() # page_session.locator("text=Clicked 1 times").click() @@ -80,7 +147,6 @@ # def test_reload_vue(page_session: playwright.sync_api.Page, solara_server, solara_app, extra_include_path): # with extra_include_path(app_path.parent), solara_app("testapp:VueTestApp"): # page_session.goto(solara_server.base_url) -# assert page_session.title() == "Hello from Solara ☀️" # page_session.locator("text=foobar").wait_for() # vuecode = """ diff --git a/tests/integration/testapp.py b/tests/integration/testapp.py index 6678a8d8f..8d6279383 100644 --- a/tests/integration/testapp.py +++ b/tests/integration/testapp.py @@ -2,8 +2,9 @@ import os import ipyvue -import solara import traitlets + +import solara from solara.alias import rw diff --git a/tests/unit/app_test.py b/tests/unit/app_test.py index 3d04238b8..98ad10fd7 100644 --- a/tests/unit/app_test.py +++ b/tests/unit/app_test.py @@ -4,6 +4,8 @@ from pathlib import Path import ipywidgets + +# import pytest import reacton.core # import solara.server.app @@ -56,7 +58,8 @@ def test_notebook_widget(app_context, no_app_context): app.close() -# def test_watch_module_reload(tmpdir, app_context, extra_include_path): +# these make other test fail on CI (vaex is used, which causes a blake3 reload, which fails) +# def test_watch_module_reload(tmpdir, app_context, extra_include_path, no_app_context): # import ipyvuetify as v # with extra_include_path(str(tmpdir)): @@ -76,6 +79,7 @@ def test_notebook_widget(app_context, no_app_context): # result = app.run() # assert "somemod" in sys.modules # assert "somemod" in reload.reloader.watched_modules +# somemod1 = sys.modules["somemod"] # assert result().component.widget == v.Btn # # change depending module @@ -88,6 +92,8 @@ def test_notebook_widget(app_context, no_app_context): # result = app.run() # assert "somemod" in sys.modules # assert result().component.widget == v.Card +# somemod2 = sys.modules["somemod"] +# assert somemod1 is not somemod2 # finally: # app.close() # if "somemod" in sys.modules: @@ -95,7 +101,33 @@ def test_notebook_widget(app_context, no_app_context): # reload.reloader.watched_modules.remove("somemod") -# def test_watch_module_import_error(tmpdir, app_context, extra_include_path): +# def test_script_reload_component(tmpdir, app_context, extra_include_path, no_app_context): +# import ipyvuetify as v + +# with extra_include_path(str(tmpdir)): +# py_file = tmpdir / "test.py" + +# logger.info("writing files") +# with open(py_file, "w") as f: +# f.write("import reacton.ipyvuetify as v; Page = v.Btn\n") + +# app = AppScript(f"{py_file}") +# try: +# result = app.run() +# assert result().component.widget == v.Btn +# with open(py_file, "w") as f: +# f.write("import reacton.ipyvuetify as v; Page = v.Slider\n") +# # wait for the event to trigger +# reload.reloader.reload_event_next.wait() +# # assert "somemod" not in sys.modules +# # breakpoint() +# result = app.run() +# assert result().component.widget == v.Slider +# finally: +# app.close() + + +# def test_watch_module_import_error(tmpdir, app_context, extra_include_path, no_app_context): # import ipyvuetify as v # with extra_include_path(str(tmpdir)): @@ -112,9 +144,6 @@ def test_notebook_widget(app_context, no_app_context): # app = AppScript(f"{py_file}") # try: -# # import pdb - -# # pdb.set_trace() # result = app.run() # assert "somemod2" in sys.modules # assert "somemod2" in reload.reloader.watched_modules @@ -124,7 +153,6 @@ def test_notebook_widget(app_context, no_app_context): # with open(py_mod_file, "w") as f: # f.write("import ipyvuetify as v; App !%#$@= v.Card.element\n") # reload.reloader.reload_event_next.wait() -# # # assert "somemod" not in sys.modules # with pytest.raises(SyntaxError): # result = app.run()