Skip to content

Commit da0b766

Browse files
committed
add starlette server implementation
1 parent 0bf5877 commit da0b766

File tree

7 files changed

+326
-261
lines changed

7 files changed

+326
-261
lines changed

Diff for: requirements/pkg-extras.txt

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ sanic-cors
66
fastapi >=0.63.0
77
uvicorn[standard] >=0.13.4
88

9+
# extra=starlette
10+
fastapi >=0.16.0
11+
uvicorn[standard] >=0.13.4
12+
913
# extra=flask
1014
flask<2.0
1115
flask-cors

Diff for: src/idom/server/fastapi.py

+22-257
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,27 @@
11
from __future__ import annotations
22

3-
import asyncio
4-
import json
5-
import logging
6-
import sys
7-
from asyncio import Future
8-
from threading import Event, Thread, current_thread
9-
from typing import Any, Dict, Optional, Tuple, Union
3+
from typing import Optional
104

11-
from fastapi import APIRouter, FastAPI, Request, WebSocket
12-
from fastapi.middleware.cors import CORSMiddleware
13-
from fastapi.responses import RedirectResponse
14-
from fastapi.staticfiles import StaticFiles
15-
from mypy_extensions import TypedDict
16-
from starlette.websockets import WebSocketDisconnect
17-
from uvicorn.config import Config as UvicornConfig
18-
from uvicorn.server import Server as UvicornServer
19-
from uvicorn.supervisors.multiprocess import Multiprocess
20-
from uvicorn.supervisors.statreload import StatReload as ChangeReload
5+
from fastapi import FastAPI
216

22-
from idom.config import IDOM_WED_MODULES_DIR
23-
from idom.core.dispatcher import (
24-
RecvCoroutine,
25-
SendCoroutine,
26-
SharedViewDispatcher,
27-
VdomJsonPatch,
28-
dispatch_single_view,
29-
ensure_shared_view_dispatcher_future,
30-
)
31-
from idom.core.layout import Layout, LayoutEvent
327
from idom.core.proto import ComponentConstructor
338

34-
from .utils import CLIENT_BUILD_DIR, poll, threaded
35-
36-
37-
logger = logging.getLogger(__name__)
38-
39-
40-
class Config(TypedDict, total=False):
41-
"""Config for :class:`FastApiRenderServer`"""
42-
43-
cors: Union[bool, Dict[str, Any]]
44-
"""Enable or configure Cross Origin Resource Sharing (CORS)
45-
46-
For more information see docs for ``fastapi.middleware.cors.CORSMiddleware``
47-
"""
48-
49-
redirect_root_to_index: bool
50-
"""Whether to redirect the root URL (with prefix) to ``index.html``"""
51-
52-
serve_static_files: bool
53-
"""Whether or not to serve static files (i.e. web modules)"""
54-
55-
url_prefix: str
56-
"""The URL prefix where IDOM resources will be served from"""
9+
from .starlette import (
10+
Config,
11+
StarletteServer,
12+
_setup_common_routes,
13+
_setup_config_and_app,
14+
_setup_shared_view_dispatcher_route,
15+
_setup_single_view_dispatcher_route,
16+
)
5717

5818

5919
def PerClientStateServer(
6020
constructor: ComponentConstructor,
6121
config: Optional[Config] = None,
6222
app: Optional[FastAPI] = None,
63-
) -> FastApiServer:
64-
"""Return a :class:`FastApiServer` where each client has its own state.
23+
) -> StarletteServer:
24+
"""Return a :class:`StarletteServer` where each client has its own state.
6525
6626
Implements the :class:`~idom.server.proto.ServerFactory` protocol
6727
@@ -70,20 +30,18 @@ def PerClientStateServer(
7030
config: Options for configuring server behavior
7131
app: An application instance (otherwise a default instance is created)
7232
"""
73-
config, app = _setup_config_and_app(config, app)
74-
router = APIRouter(prefix=config["url_prefix"])
75-
_setup_common_routes(app, router, config)
76-
_setup_single_view_dispatcher_route(router, constructor)
77-
app.include_router(router)
78-
return FastApiServer(app)
33+
config, app = _setup_config_and_app(config, app, FastAPI)
34+
_setup_common_routes(config, app)
35+
_setup_single_view_dispatcher_route(config["url_prefix"], app, constructor)
36+
return StarletteServer(app)
7937

8038

8139
def SharedClientStateServer(
8240
constructor: ComponentConstructor,
8341
config: Optional[Config] = None,
8442
app: Optional[FastAPI] = None,
85-
) -> FastApiServer:
86-
"""Return a :class:`FastApiServer` where each client shares state.
43+
) -> StarletteServer:
44+
"""Return a :class:`StarletteServer` where each client shares state.
8745
8846
Implements the :class:`~idom.server.proto.ServerFactory` protocol
8947
@@ -92,200 +50,7 @@ def SharedClientStateServer(
9250
config: Options for configuring server behavior
9351
app: An application instance (otherwise a default instance is created)
9452
"""
95-
config, app = _setup_config_and_app(config, app)
96-
router = APIRouter(prefix=config["url_prefix"])
97-
_setup_common_routes(app, router, config)
98-
_setup_shared_view_dispatcher_route(app, router, constructor)
99-
app.include_router(router)
100-
return FastApiServer(app)
101-
102-
103-
class FastApiServer:
104-
"""A thin wrapper for running a FastAPI application
105-
106-
See :class:`idom.server.proto.Server` for more info
107-
"""
108-
109-
_server: UvicornServer
110-
_current_thread: Thread
111-
112-
def __init__(self, app: FastAPI) -> None:
113-
self.app = app
114-
self._did_stop = Event()
115-
app.on_event("shutdown")(self._server_did_stop)
116-
117-
def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None:
118-
self._current_thread = current_thread()
119-
120-
self._server = server = UvicornServer(
121-
UvicornConfig(
122-
self.app, host=host, port=port, loop="asyncio", *args, **kwargs
123-
)
124-
)
125-
126-
# The following was copied from the uvicorn source with minimal modification. We
127-
# shouldn't need to do this, but unfortunately there's no easy way to gain access to
128-
# the server instance so you can stop it.
129-
# BUG: https://github.com/encode/uvicorn/issues/742
130-
config = server.config
131-
132-
if (config.reload or config.workers > 1) and not isinstance(
133-
server.config.app, str
134-
): # pragma: no cover
135-
logger = logging.getLogger("uvicorn.error")
136-
logger.warning(
137-
"You must pass the application as an import string to enable 'reload' or "
138-
"'workers'."
139-
)
140-
sys.exit(1)
141-
142-
if config.should_reload: # pragma: no cover
143-
sock = config.bind_socket()
144-
supervisor = ChangeReload(config, target=server.run, sockets=[sock])
145-
supervisor.run()
146-
elif config.workers > 1: # pragma: no cover
147-
sock = config.bind_socket()
148-
supervisor = Multiprocess(config, target=server.run, sockets=[sock])
149-
supervisor.run()
150-
else:
151-
import asyncio
152-
153-
asyncio.set_event_loop(asyncio.new_event_loop())
154-
server.run()
155-
156-
run_in_thread = threaded(run)
157-
158-
def wait_until_started(self, timeout: Optional[float] = 3.0) -> None:
159-
poll(
160-
f"start {self.app}",
161-
0.01,
162-
timeout,
163-
lambda: hasattr(self, "_server") and self._server.started,
164-
)
165-
166-
def stop(self, timeout: Optional[float] = 3.0) -> None:
167-
self._server.should_exit = True
168-
self._did_stop.wait(timeout)
169-
170-
async def _server_did_stop(self) -> None:
171-
self._did_stop.set()
172-
173-
174-
def _setup_config_and_app(
175-
config: Optional[Config],
176-
app: Optional[FastAPI],
177-
) -> Tuple[Config, FastAPI]:
178-
return (
179-
{
180-
"cors": False,
181-
"url_prefix": "",
182-
"serve_static_files": True,
183-
"redirect_root_to_index": True,
184-
**(config or {}), # type: ignore
185-
},
186-
app or FastAPI(),
187-
)
188-
189-
190-
def _setup_common_routes(app: FastAPI, router: APIRouter, config: Config) -> None:
191-
cors_config = config["cors"]
192-
if cors_config: # pragma: no cover
193-
cors_params = (
194-
cors_config if isinstance(cors_config, dict) else {"allow_origins": ["*"]}
195-
)
196-
app.add_middleware(CORSMiddleware, **cors_params)
197-
198-
# This really should be added to the APIRouter, but there's a bug in FastAPI
199-
# BUG: https://github.com/tiangolo/fastapi/issues/1469
200-
url_prefix = config["url_prefix"]
201-
if config["serve_static_files"]:
202-
app.mount(
203-
f"{url_prefix}/client",
204-
StaticFiles(
205-
directory=str(CLIENT_BUILD_DIR),
206-
html=True,
207-
check_dir=True,
208-
),
209-
name="idom_static_files",
210-
)
211-
app.mount(
212-
f"{url_prefix}/modules",
213-
StaticFiles(
214-
directory=str(IDOM_WED_MODULES_DIR.current),
215-
html=True,
216-
check_dir=True,
217-
),
218-
name="idom_static_files",
219-
)
220-
221-
if config["redirect_root_to_index"]:
222-
223-
@app.route(f"{url_prefix}/")
224-
def redirect_to_index(request: Request) -> RedirectResponse:
225-
return RedirectResponse(
226-
f"{url_prefix}/client/index.html?{request.query_params}"
227-
)
228-
229-
230-
def _setup_single_view_dispatcher_route(
231-
router: APIRouter, constructor: ComponentConstructor
232-
) -> None:
233-
@router.websocket("/stream")
234-
async def model_stream(socket: WebSocket) -> None:
235-
await socket.accept()
236-
send, recv = _make_send_recv_callbacks(socket)
237-
try:
238-
await dispatch_single_view(
239-
Layout(constructor(**dict(socket.query_params))), send, recv
240-
)
241-
except WebSocketDisconnect as error:
242-
logger.info(f"WebSocket disconnect: {error.code}")
243-
244-
245-
def _setup_shared_view_dispatcher_route(
246-
app: FastAPI, router: APIRouter, constructor: ComponentConstructor
247-
) -> None:
248-
dispatcher_future: Future[None]
249-
dispatch_coroutine: SharedViewDispatcher
250-
251-
@app.on_event("startup")
252-
async def activate_dispatcher() -> None:
253-
nonlocal dispatcher_future
254-
nonlocal dispatch_coroutine
255-
dispatcher_future, dispatch_coroutine = ensure_shared_view_dispatcher_future(
256-
Layout(constructor())
257-
)
258-
259-
@app.on_event("shutdown")
260-
async def deactivate_dispatcher() -> None:
261-
logger.debug("Stopping dispatcher - server is shutting down")
262-
dispatcher_future.cancel()
263-
await asyncio.wait([dispatcher_future])
264-
265-
@router.websocket("/stream")
266-
async def model_stream(socket: WebSocket) -> None:
267-
await socket.accept()
268-
269-
if socket.query_params:
270-
raise ValueError(
271-
"SharedClientState server does not support per-client view parameters"
272-
)
273-
274-
send, recv = _make_send_recv_callbacks(socket)
275-
276-
try:
277-
await dispatch_coroutine(send, recv)
278-
except WebSocketDisconnect as error:
279-
logger.info(f"WebSocket disconnect: {error.code}")
280-
281-
282-
def _make_send_recv_callbacks(
283-
socket: WebSocket,
284-
) -> Tuple[SendCoroutine, RecvCoroutine]:
285-
async def sock_send(value: VdomJsonPatch) -> None:
286-
await socket.send_text(json.dumps(value))
287-
288-
async def sock_recv() -> LayoutEvent:
289-
return LayoutEvent(**json.loads(await socket.receive_text()))
290-
291-
return sock_send, sock_recv
53+
config, app = _setup_config_and_app(config, app, FastAPI)
54+
_setup_common_routes(config, app)
55+
_setup_shared_view_dispatcher_route(config["url_prefix"], app, constructor)
56+
return StarletteServer(app)

0 commit comments

Comments
 (0)