From 68ef42a24666118cb316bbf859a953429bc51588 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Wed, 14 Feb 2024 08:33:21 +0100 Subject: [PATCH] fix: fastapi lifespan management with multiple apps at once * introduce streamsync.serve.lifespan to gather lifespan of different streamsync application --- docs/docs/custom-server.md | 2 +- src/streamsync/serve.py | 62 ++++++++- tests/__init__.py | 1 + tests/test_serve.py | 39 +++++- tests/testmultiapp/app1/__init__.py | 0 tests/testmultiapp/app1/main.py | 40 ++++++ tests/testmultiapp/app1/static/favicon.png | Bin 0 -> 3780 bytes tests/testmultiapp/app1/ui.json | 149 +++++++++++++++++++++ tests/testmultiapp/app2/__init__.py | 0 tests/testmultiapp/app2/main.py | 40 ++++++ tests/testmultiapp/app2/static/favicon.png | Bin 0 -> 3780 bytes tests/testmultiapp/app2/ui.json | 149 +++++++++++++++++++++ 12 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 tests/testmultiapp/app1/__init__.py create mode 100644 tests/testmultiapp/app1/main.py create mode 100644 tests/testmultiapp/app1/static/favicon.png create mode 100644 tests/testmultiapp/app1/ui.json create mode 100644 tests/testmultiapp/app2/__init__.py create mode 100644 tests/testmultiapp/app2/main.py create mode 100644 tests/testmultiapp/app2/static/favicon.png create mode 100644 tests/testmultiapp/app2/ui.json diff --git a/docs/docs/custom-server.md b/docs/docs/custom-server.md index 3163c915b..a5f7549fa 100644 --- a/docs/docs/custom-server.md +++ b/docs/docs/custom-server.md @@ -39,7 +39,7 @@ import uvicorn import streamsync.serve from fastapi import FastAPI, Response -root_asgi_app = FastAPI() +root_asgi_app = FastAPI(lifespan=streamsync.serve.lifespan) sub_asgi_app_1 = streamsync.serve.get_asgi_app("../app1", "run") sub_asgi_app_2 = streamsync.serve.get_asgi_app("../app2", "run") diff --git a/src/streamsync/serve.py b/src/streamsync/serve.py index 0bedcd9e4..e9a19c016 100644 --- a/src/streamsync/serve.py +++ b/src/streamsync/serve.py @@ -1,4 +1,5 @@ import asyncio +import dataclasses import mimetypes from contextlib import asynccontextmanager import sys @@ -8,6 +9,7 @@ from fastapi import FastAPI, Request, HTTPException from fastapi.staticfiles import StaticFiles from pydantic import ValidationError +from fastapi.routing import Mount from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState from streamsync.ss_types import (AppProcessServerResponse, ComponentUpdateRequestPayload, EventResponsePayload, InitRequestBody, InitResponseBodyEdit, InitResponseBodyRun, InitSessionRequestPayload, InitSessionResponsePayload, ServeMode, StateEnquiryResponsePayload, StreamsyncEvent, StreamsyncWebsocketIncoming, StreamsyncWebsocketOutgoing) @@ -22,7 +24,6 @@ MAX_WEBSOCKET_MESSAGE_SIZE = 201*1024*1024 logging.getLogger().setLevel(logging.INFO) - def get_asgi_app( user_app_path: str, serve_mode: ServeMode, @@ -57,6 +58,11 @@ async def lifespan(app: FastAPI): on_shutdown() asgi_app = FastAPI(lifespan=lifespan) + """ + Reuse the same pattern to give variable to FastAPI application + than `asgi_app.state.is_server_static_mounted` already use in streamsync. + """ + asgi_app.state.streamsync_app = True def _get_extension_paths() -> List[str]: extensions_path = pathlib.Path(user_app_path) / "extensions" @@ -421,6 +427,60 @@ def on_load(): uvicorn.run(asgi_app, host=host, port=port, log_level=log_level, ws_max_size=MAX_WEBSOCKET_MESSAGE_SIZE) +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + This feature supports launching multiple streamsync applications simultaneously. + + >>> import uvicorn + >>> import streamsync.serve + >>> from fastapi import FastAPI, Response + >>> + >>> root_asgi_app = FastAPI(lifespan=streamsync.serve.lifespan) + >>> + >>> sub_asgi_app_1 = streamsync.serve.get_asgi_app("../app1", "run") + >>> sub_asgi_app_2 = streamsync.serve.get_asgi_app("../app2", "run") + >>> + >>> uvicorn.run(root_asgi_app, ws_max_size=streamsync.serve.MAX_WEBSOCKET_MESSAGE_SIZE) + + Streamsync uses lifespan to start an application server (app_runner) per + application. + """ + streamsync_lifespans = [] + for route in app.routes: + if isinstance(route, Mount) and isinstance(route.app, FastAPI): + if hasattr(route.app.state, "streamsync_app"): + ctx = route.app.router.lifespan_context + streamsync_lifespans.append(ctx) + + async with _lifespan_invoke(streamsync_lifespans, app): + yield + + +@asynccontextmanager +async def _lifespan_invoke(context: list, app: FastAPI): + """ + Helper to run multiple lifespans in cascade. + + Running + + >>> _lifespan_invoke([app1.router.lifespan_context, app2.router.lifespan_context], app) + + is equivalent to + + >>> @asynccontextmanager + >>> async def lifespan_context(app: FastAPI): + >>> async with app1.router.lifespan_context(app): + >>> async with app2.router.lifespan_context(app): + >>> yield + """ + ctx = context.pop(0) + async with ctx(app): + if len(context) > 0: + async with _lifespan_invoke(context, app): + yield + else: + yield def _fix_mimetype(): """ diff --git a/tests/__init__.py b/tests/__init__.py index 3892a47f8..942382f1c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,4 @@ from pathlib import Path test_app_dir = Path(__file__).resolve().parent / 'testapp' +test_multiapp_dir = Path(__file__).resolve().parent / 'testmultiapp' diff --git a/tests/test_serve.py b/tests/test_serve.py index 9665590ff..9f79f0e76 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1,12 +1,13 @@ -import json import mimetypes import fastapi +from fastapi import FastAPI + import streamsync.serve import fastapi.testclient import pytest -from tests import test_app_dir +from tests import test_app_dir, test_multiapp_dir class TestServe: @@ -112,3 +113,37 @@ def test_serve_javascript_file_with_a_valid_content_type(self) -> None: # Assert assert res.status_code == 200 assert res.headers["Content-Type"].startswith("text/javascript") + + def test_multiapp_should_run_the_lifespan_of_all_streamsync_app(self): + """ + This test check that multiple streamsync applications embedded + in FastAPI start completely and answer websocket request. + """ + asgi_app: fastapi.FastAPI = FastAPI(lifespan=streamsync.serve.lifespan) + asgi_app.mount("/app1", streamsync.serve.get_asgi_app(test_multiapp_dir / 'app1', "run")) + asgi_app.mount("/app2", streamsync.serve.get_asgi_app(test_multiapp_dir / 'app2', "run")) + + with fastapi.testclient.TestClient(asgi_app) as client: + # test websocket connection on app1 + with client.websocket_connect("/app1/api/stream") as websocket: + websocket.send_json({ + "type": "streamInit", + "trackingId": 0, + "payload": { + "sessionId": "bad_session" + } + }) + with pytest.raises(fastapi.WebSocketDisconnect): + websocket.receive_json() + + # test websocket connection on app2 + with client.websocket_connect("/app2/api/stream") as websocket: + websocket.send_json({ + "type": "streamInit", + "trackingId": 0, + "payload": { + "sessionId": "bad_session" + } + }) + with pytest.raises(fastapi.WebSocketDisconnect): + websocket.receive_json() \ No newline at end of file diff --git a/tests/testmultiapp/app1/__init__.py b/tests/testmultiapp/app1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/testmultiapp/app1/main.py b/tests/testmultiapp/app1/main.py new file mode 100644 index 000000000..de9b57482 --- /dev/null +++ b/tests/testmultiapp/app1/main.py @@ -0,0 +1,40 @@ +import streamsync as ss + +# This is a placeholder to get you started or refresh your memory. +# Delete it or adapt it as necessary. +# Documentation is available at https://streamsync.cloud + +# Shows in the log when the app starts +print("Hello world!") + +# Its name starts with _, so this function won't be exposed +def _update_message(state): + is_even = state["counter"] % 2 == 0 + message = ("+Even" if is_even else "-Odd") + state["message"] = message + +def decrement(state): + state["counter"] -= 1 + _update_message(state) + +def increment(state): + state["counter"] += 1 + # Shows in the log when the event handler is run + print(f"The counter has been incremented.") + _update_message(state) + +# Initialise the state + +# "_my_private_element" won't be serialised or sent to the frontend, +# because it starts with an underscore + +initial_state = ss.init_state({ + "my_app": { + "title": "My App 1" + }, + "_my_private_element": 1337, + "message": None, + "counter": 26, +}) + +_update_message(initial_state) \ No newline at end of file diff --git a/tests/testmultiapp/app1/static/favicon.png b/tests/testmultiapp/app1/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..33692a2ffc26513a747317cda3ba66498d85de0c GIT binary patch literal 3780 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 R|JJ7ym>F9d6&eu3{|DiQ{1*TK literal 0 HcmV?d00001 diff --git a/tests/testmultiapp/app1/ui.json b/tests/testmultiapp/app1/ui.json new file mode 100644 index 000000000..603fc715a --- /dev/null +++ b/tests/testmultiapp/app1/ui.json @@ -0,0 +1,149 @@ +{ + "metadata": { + "streamsync_version": "0.1.4" + }, + "components": { + "root": { + "id": "root", + "type": "root", + "content": { + "appName": "My App 1" + }, + "parentId": null, + "position": 0, + "handlers": {}, + "visible": true + }, + "c0f99a9e-5004-4e75-a6c6-36f17490b134": { + "id": "c0f99a9e-5004-4e75-a6c6-36f17490b134", + "type": "page", + "content": { + "pageMode": "compact", + "emptinessColor": "#e9eef1" + }, + "parentId": "root", + "position": 0, + "handlers": {}, + "visible": true + }, + "bebc5fe9-63a7-46a7-b0fa-62303555cfaf": { + "id": "bebc5fe9-63a7-46a7-b0fa-62303555cfaf", + "type": "header", + "content": { + "text": "@{my_app.title}" + }, + "parentId": "c0f99a9e-5004-4e75-a6c6-36f17490b134", + "position": 0, + "handlers": {}, + "visible": true + }, + "28d3885b-0fb8-4d41-97c6-978540015431": { + "id": "28d3885b-0fb8-4d41-97c6-978540015431", + "type": "section", + "content": { + "title": "", + "snapMode": "no", + "containerShadow": "0px 4px 11px -12px #000000" + }, + "parentId": "c0f99a9e-5004-4e75-a6c6-36f17490b134", + "position": 1, + "handlers": {}, + "visible": true + }, + "9556c0e3-8584-4ac9-903f-908a775a33ec": { + "id": "9556c0e3-8584-4ac9-903f-908a775a33ec", + "type": "button", + "content": { + "text": " Increment", + "icon": "arrow-up" + }, + "parentId": "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117", + "position": 1, + "handlers": { + "click": "increment" + }, + "visible": true + }, + "51d1554e-1b88-461c-9353-1419cba0053a": { + "id": "51d1554e-1b88-461c-9353-1419cba0053a", + "type": "button", + "content": { + "text": "Decrement", + "icon": "arrow-down" + }, + "parentId": "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117", + "position": 0, + "handlers": { + "click": "decrement" + }, + "visible": true + }, + "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117": { + "id": "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117", + "type": "horizontalstack", + "content": { + "alignment": "left" + }, + "parentId": "f3777e75-3659-4d44-8ef7-aeec0d06855b", + "position": 0, + "handlers": {}, + "visible": true + }, + "172a14df-f73a-44fa-8fb1-e8648e7d32d2": { + "id": "172a14df-f73a-44fa-8fb1-e8648e7d32d2", + "type": "metric", + "content": { + "metricValue": "@{counter}", + "note": "@{message}", + "name": "Counter" + }, + "parentId": "c2519671-9ce7-44e7-ba4e-b0efda9cb20e", + "position": 0, + "handlers": {}, + "visible": true + }, + "d4a5e62c-c6fe-49c4-80d4-33862af8727d": { + "id": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "type": "columns", + "content": {}, + "parentId": "28d3885b-0fb8-4d41-97c6-978540015431", + "position": 0, + "handlers": {}, + "visible": true + }, + "f3777e75-3659-4d44-8ef7-aeec0d06855b": { + "id": "f3777e75-3659-4d44-8ef7-aeec0d06855b", + "type": "column", + "content": { + "title": "", + "width": "1", + "verticalAlignment": "center", + "horizontalAlignment": "center" + }, + "parentId": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "position": 2, + "handlers": {}, + "visible": true + }, + "c2519671-9ce7-44e7-ba4e-b0efda9cb20e": { + "id": "c2519671-9ce7-44e7-ba4e-b0efda9cb20e", + "type": "column", + "content": { + "width": "1" + }, + "parentId": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "position": 0, + "handlers": {}, + "visible": true + }, + "d4a71819-7444-4083-a1c7-7995452a7abf": { + "id": "d4a71819-7444-4083-a1c7-7995452a7abf", + "type": "separator", + "content": {}, + "parentId": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "position": 1, + "handlers": {}, + "visible": true + } + } +} \ No newline at end of file diff --git a/tests/testmultiapp/app2/__init__.py b/tests/testmultiapp/app2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/testmultiapp/app2/main.py b/tests/testmultiapp/app2/main.py new file mode 100644 index 000000000..2f3de74ed --- /dev/null +++ b/tests/testmultiapp/app2/main.py @@ -0,0 +1,40 @@ +import streamsync as ss + +# This is a placeholder to get you started or refresh your memory. +# Delete it or adapt it as necessary. +# Documentation is available at https://streamsync.cloud + +# Shows in the log when the app starts +print("Hello world!") + +# Its name starts with _, so this function won't be exposed +def _update_message(state): + is_even = state["counter"] % 2 == 0 + message = ("+Even" if is_even else "-Odd") + state["message"] = message + +def decrement(state): + state["counter"] -= 1 + _update_message(state) + +def increment(state): + state["counter"] += 1 + # Shows in the log when the event handler is run + print(f"The counter has been incremented.") + _update_message(state) + +# Initialise the state + +# "_my_private_element" won't be serialised or sent to the frontend, +# because it starts with an underscore + +initial_state = ss.init_state({ + "my_app": { + "title": "My App 2" + }, + "_my_private_element": 1337, + "message": None, + "counter": 26, +}) + +_update_message(initial_state) \ No newline at end of file diff --git a/tests/testmultiapp/app2/static/favicon.png b/tests/testmultiapp/app2/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..33692a2ffc26513a747317cda3ba66498d85de0c GIT binary patch literal 3780 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 R|JJ7ym>F9d6&eu3{|DiQ{1*TK literal 0 HcmV?d00001 diff --git a/tests/testmultiapp/app2/ui.json b/tests/testmultiapp/app2/ui.json new file mode 100644 index 000000000..029cbf2d2 --- /dev/null +++ b/tests/testmultiapp/app2/ui.json @@ -0,0 +1,149 @@ +{ + "metadata": { + "streamsync_version": "0.1.4" + }, + "components": { + "root": { + "id": "root", + "type": "root", + "content": { + "appName": "My App 2" + }, + "parentId": null, + "position": 0, + "handlers": {}, + "visible": true + }, + "c0f99a9e-5004-4e75-a6c6-36f17490b134": { + "id": "c0f99a9e-5004-4e75-a6c6-36f17490b134", + "type": "page", + "content": { + "pageMode": "compact", + "emptinessColor": "#e9eef1" + }, + "parentId": "root", + "position": 0, + "handlers": {}, + "visible": true + }, + "bebc5fe9-63a7-46a7-b0fa-62303555cfaf": { + "id": "bebc5fe9-63a7-46a7-b0fa-62303555cfaf", + "type": "header", + "content": { + "text": "@{my_app.title}" + }, + "parentId": "c0f99a9e-5004-4e75-a6c6-36f17490b134", + "position": 0, + "handlers": {}, + "visible": true + }, + "28d3885b-0fb8-4d41-97c6-978540015431": { + "id": "28d3885b-0fb8-4d41-97c6-978540015431", + "type": "section", + "content": { + "title": "", + "snapMode": "no", + "containerShadow": "0px 4px 11px -12px #000000" + }, + "parentId": "c0f99a9e-5004-4e75-a6c6-36f17490b134", + "position": 1, + "handlers": {}, + "visible": true + }, + "9556c0e3-8584-4ac9-903f-908a775a33ec": { + "id": "9556c0e3-8584-4ac9-903f-908a775a33ec", + "type": "button", + "content": { + "text": " Increment", + "icon": "arrow-up" + }, + "parentId": "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117", + "position": 1, + "handlers": { + "click": "increment" + }, + "visible": true + }, + "51d1554e-1b88-461c-9353-1419cba0053a": { + "id": "51d1554e-1b88-461c-9353-1419cba0053a", + "type": "button", + "content": { + "text": "Decrement", + "icon": "arrow-down" + }, + "parentId": "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117", + "position": 0, + "handlers": { + "click": "decrement" + }, + "visible": true + }, + "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117": { + "id": "0d05bc9f-1655-4d0b-bc9b-c2f4c71a5117", + "type": "horizontalstack", + "content": { + "alignment": "left" + }, + "parentId": "f3777e75-3659-4d44-8ef7-aeec0d06855b", + "position": 0, + "handlers": {}, + "visible": true + }, + "172a14df-f73a-44fa-8fb1-e8648e7d32d2": { + "id": "172a14df-f73a-44fa-8fb1-e8648e7d32d2", + "type": "metric", + "content": { + "metricValue": "@{counter}", + "note": "@{message}", + "name": "Counter" + }, + "parentId": "c2519671-9ce7-44e7-ba4e-b0efda9cb20e", + "position": 0, + "handlers": {}, + "visible": true + }, + "d4a5e62c-c6fe-49c4-80d4-33862af8727d": { + "id": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "type": "columns", + "content": {}, + "parentId": "28d3885b-0fb8-4d41-97c6-978540015431", + "position": 0, + "handlers": {}, + "visible": true + }, + "f3777e75-3659-4d44-8ef7-aeec0d06855b": { + "id": "f3777e75-3659-4d44-8ef7-aeec0d06855b", + "type": "column", + "content": { + "title": "", + "width": "1", + "verticalAlignment": "center", + "horizontalAlignment": "center" + }, + "parentId": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "position": 2, + "handlers": {}, + "visible": true + }, + "c2519671-9ce7-44e7-ba4e-b0efda9cb20e": { + "id": "c2519671-9ce7-44e7-ba4e-b0efda9cb20e", + "type": "column", + "content": { + "width": "1" + }, + "parentId": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "position": 0, + "handlers": {}, + "visible": true + }, + "d4a71819-7444-4083-a1c7-7995452a7abf": { + "id": "d4a71819-7444-4083-a1c7-7995452a7abf", + "type": "separator", + "content": {}, + "parentId": "d4a5e62c-c6fe-49c4-80d4-33862af8727d", + "position": 1, + "handlers": {}, + "visible": true + } + } +} \ No newline at end of file