Skip to content

Commit 5fdbb31

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

File tree

7 files changed

+328
-263
lines changed

7 files changed

+328
-263
lines changed

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

src/idom/server/fastapi.py

+22-259
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,25 @@
1-
from __future__ import annotations
1+
from typing import Optional
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 fastapi import FastAPI
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
21-
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
325
from idom.core.proto import ComponentConstructor
336

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"""
7+
from .starlette import (
8+
Config,
9+
StarletteServer,
10+
_setup_common_routes,
11+
_setup_config_and_app,
12+
_setup_shared_view_dispatcher_route,
13+
_setup_single_view_dispatcher_route,
14+
)
5715

5816

5917
def PerClientStateServer(
6018
constructor: ComponentConstructor,
6119
config: Optional[Config] = None,
6220
app: Optional[FastAPI] = None,
63-
) -> FastApiServer:
64-
"""Return a :class:`FastApiServer` where each client has its own state.
21+
) -> StarletteServer:
22+
"""Return a :class:`StarletteServer` where each client has its own state.
6523
6624
Implements the :class:`~idom.server.proto.ServerFactory` protocol
6725
@@ -70,20 +28,18 @@ def PerClientStateServer(
7028
config: Options for configuring server behavior
7129
app: An application instance (otherwise a default instance is created)
7230
"""
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)
31+
config, app = _setup_config_and_app(config, app, FastAPI)
32+
_setup_common_routes(config, app)
33+
_setup_single_view_dispatcher_route(config["url_prefix"], app, constructor)
34+
return StarletteServer(app)
7935

8036

8137
def SharedClientStateServer(
8238
constructor: ComponentConstructor,
8339
config: Optional[Config] = None,
8440
app: Optional[FastAPI] = None,
85-
) -> FastApiServer:
86-
"""Return a :class:`FastApiServer` where each client shares state.
41+
) -> StarletteServer:
42+
"""Return a :class:`StarletteServer` where each client shares state.
8743
8844
Implements the :class:`~idom.server.proto.ServerFactory` protocol
8945
@@ -92,200 +48,7 @@ def SharedClientStateServer(
9248
config: Options for configuring server behavior
9349
app: An application instance (otherwise a default instance is created)
9450
"""
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
51+
config, app = _setup_config_and_app(config, app, FastAPI)
52+
_setup_common_routes(config, app)
53+
_setup_shared_view_dispatcher_route(config["url_prefix"], app, constructor)
54+
return StarletteServer(app)

0 commit comments

Comments
 (0)