Skip to content

Commit 5a4e666

Browse files
committed
finish up flask conversion
1 parent 51b105d commit 5a4e666

File tree

5 files changed

+85
-158
lines changed

5 files changed

+85
-158
lines changed

src/idom/server/any.py

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44
import warnings
55
import webbrowser
66
from importlib import import_module
7-
from typing import Any, Awaitable, Iterator, TypeVar, runtime_checkable
8-
9-
from typing_extensions import Protocol
7+
from typing import Any, Awaitable, Iterator
108

119
from idom.types import ComponentConstructor
1210

11+
from .types import ServerImplementation
1312
from .utils import find_available_port
1413

1514

@@ -36,23 +35,16 @@ def run(
3635
stacklevel=2,
3736
)
3837

39-
try:
40-
implementation = next(all_implementations())
41-
except StopIteration:
42-
raise RuntimeError( # pragma: no cover
43-
f"Found no built-in server implementation installed {SUPPORTED_PACKAGES}"
44-
)
45-
46-
app = implementation.create_development_app()
47-
implementation.configure(app, component)
38+
app = create_development_app()
39+
configure(app, component)
4840

4941
coros: list[Awaitable] = []
5042

5143
host = host
5244
port = port or find_available_port(host)
5345
started = asyncio.Event()
5446

55-
coros.append(implementation.serve_development_app(app, host, port, started))
47+
coros.append(serve_development_app(app, host, port, started))
5648

5749
if open_browser:
5850

@@ -66,55 +58,51 @@ async def _open_browser_after_server() -> None:
6658

6759

6860
def configure(app: Any, component: ComponentConstructor) -> None:
61+
"""Configure the given app instance to display the given component"""
6962
return get_implementation().configure(app, component)
7063

7164

7265
def create_development_app() -> Any:
66+
"""Create an application instance for development purposes"""
7367
return get_implementation().create_development_app()
7468

7569

7670
async def serve_development_app(
7771
app: Any, host: str, port: int, started: asyncio.Event
7872
) -> None:
73+
"""Run an application using a development server"""
7974
return await get_implementation().serve_development_app(app, host, port, started)
8075

8176

82-
def get_implementation() -> Implementation:
77+
def get_implementation() -> ServerImplementation:
8378
"""Get the first available server implementation"""
79+
if _DEFAULT_IMPLEMENTATION is not None:
80+
return _DEFAULT_IMPLEMENTATION
81+
8482
try:
85-
return next(all_implementations())
83+
implementation = next(all_implementations())
8684
except StopIteration:
8785
raise RuntimeError("No built-in server implementation installed.")
86+
else:
87+
global _DEFAULT_IMPLEMENTATION
88+
_DEFAULT_IMPLEMENTATION = implementation
89+
return implementation
90+
8891

92+
_DEFAULT_IMPLEMENTATION: ServerImplementation | None = None
8993

90-
def all_implementations() -> Iterator[Implementation]:
94+
95+
def all_implementations() -> Iterator[ServerImplementation]:
9196
"""Yield all available server implementations"""
9297
for name in SUPPORTED_PACKAGES:
9398
try:
9499
module = import_module(f"idom.server.{name}")
95100
except ImportError: # pragma: no cover
96101
continue
97102

98-
if not isinstance(module, Implementation):
99-
raise TypeError(f"{module.__name__!r} is an invalid implementation")
103+
if not isinstance(module, ServerImplementation):
104+
raise TypeError( # pragma: no cover
105+
f"{module.__name__!r} is an invalid implementation"
106+
)
100107

101108
yield module
102-
103-
104-
_App = TypeVar("_App")
105-
106-
107-
@runtime_checkable
108-
class Implementation(Protocol):
109-
"""Common interface for IDOM's builti-in server implementations"""
110-
111-
def configure(self, app: _App, component: ComponentConstructor) -> None:
112-
"""Configure the given app instance to display the given component"""
113-
114-
def create_development_app(self) -> _App:
115-
"""Create an application instance for development purposes"""
116-
117-
async def serve_development_app(
118-
self, app: _App, host: str, port: int, started: asyncio.Event
119-
) -> None:
120-
"""Run an application using a development server"""

src/idom/server/flask.py

Lines changed: 33 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,25 @@
1313
from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for
1414
from flask_cors import CORS
1515
from flask_sockets import Sockets
16-
from gevent import pywsgi
17-
from geventwebsocket.handler import WebSocketHandler
1816
from geventwebsocket.websocket import WebSocket
1917
from typing_extensions import TypedDict
18+
from werkzeug.serving import ThreadedWSGIServer
2019

2120
import idom
2221
from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR
2322
from idom.core.dispatcher import dispatch_single_view
2423
from idom.core.layout import LayoutEvent, LayoutUpdate
2524
from idom.core.types import ComponentConstructor, ComponentType
2625

27-
from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event
26+
from .utils import CLIENT_BUILD_DIR
2827

2928

3029
logger = logging.getLogger(__name__)
3130

3231

3332
def configure(
3433
app: Flask, component: ComponentConstructor, options: Options | None = None
35-
) -> FlaskServer:
34+
) -> None:
3635
"""Return a :class:`FlaskServer` where each client has its own state.
3736
3837
Implements the :class:`~idom.server.proto.ServerFactory` protocol
@@ -47,17 +46,45 @@ def configure(
4746
_setup_common_routes(blueprint, options)
4847
_setup_single_view_dispatcher_route(app, options, component)
4948
app.register_blueprint(blueprint)
50-
return FlaskServer(app)
5149

5250

5351
def create_development_app() -> Flask:
52+
"""Create an application instance for development purposes"""
5453
return Flask(__name__)
5554

5655

5756
async def serve_development_app(
5857
app: Flask, host: str, port: int, started: asyncio.Event
5958
) -> None:
60-
...
59+
"""Run an application using a development server"""
60+
loop = asyncio.get_event_loop()
61+
62+
@app.before_first_request
63+
def set_started():
64+
loop.call_soon_threadsafe(started.set)
65+
66+
server = ThreadedWSGIServer(host, port, app)
67+
68+
stopped = asyncio.Event()
69+
70+
def run_server():
71+
try:
72+
server.serve_forever()
73+
finally:
74+
loop.call_soon_threadsafe(stopped.set)
75+
76+
thread = Thread(target=run_server, daemon=True)
77+
78+
try:
79+
await stopped.wait()
80+
finally:
81+
# we may have exitted because this task was cancelled
82+
server.shutdown()
83+
# the thread should eventually join
84+
thread.join(timeout=3)
85+
# just double check it happened
86+
if thread.is_alive():
87+
raise RuntimeError("Failed to shutdown server.")
6188

6289

6390
class Options(TypedDict, total=False):
@@ -85,52 +112,6 @@ class Options(TypedDict, total=False):
85112
"""The URL prefix where IDOM resources will be served from"""
86113

87114

88-
class FlaskServer:
89-
"""A thin wrapper for running a Flask application
90-
91-
See :class:`idom.server.proto.Server` for more info
92-
"""
93-
94-
_wsgi_server: pywsgi.WSGIServer
95-
96-
def __init__(self, app: Flask) -> None:
97-
self.app = app
98-
self._did_start = ThreadEvent()
99-
100-
@app.before_first_request
101-
def server_did_start() -> None:
102-
self._did_start.set()
103-
104-
def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None:
105-
if IDOM_DEBUG_MODE.current:
106-
logging.basicOptions(level=logging.DEBUG) # pragma: no cover
107-
logger.info(f"Running at http://{host}:{port}")
108-
self._wsgi_server = _StartCallbackWSGIServer(
109-
self._did_start.set,
110-
(host, port),
111-
self.app,
112-
*args,
113-
handler_class=WebSocketHandler,
114-
**kwargs,
115-
)
116-
self._wsgi_server.serve_forever()
117-
118-
run_in_thread = threaded(run)
119-
120-
def wait_until_started(self, timeout: Optional[float] = 3.0) -> None:
121-
wait_on_event(f"start {self.app}", self._did_start, timeout)
122-
123-
def stop(self, timeout: Optional[float] = 3.0) -> None:
124-
try:
125-
server = self._wsgi_server
126-
except AttributeError: # pragma: no cover
127-
raise RuntimeError(
128-
f"Application is not running or was not started by {self}"
129-
)
130-
else:
131-
server.stop(timeout)
132-
133-
134115
def _setup_options(options: Options | None) -> Options:
135116
return {
136117
"url_prefix": "",
@@ -273,25 +254,6 @@ class _DispatcherThreadInfo(NamedTuple):
273254
async_recv_queue: "AsyncQueue[LayoutEvent]"
274255

275256

276-
class _StartCallbackWSGIServer(pywsgi.WSGIServer): # type: ignore
277-
def __init__(
278-
self, before_first_request: Callable[[], None], *args: Any, **kwargs: Any
279-
) -> None:
280-
self._before_first_request_callback = before_first_request
281-
super().__init__(*args, **kwargs)
282-
283-
def update_environ(self) -> None:
284-
"""
285-
Called before the first request is handled to fill in WSGI environment values.
286-
287-
This includes getting the correct server name and port.
288-
"""
289-
super().update_environ()
290-
# BUG: https://github.com/nedbat/coveragepy/issues/1012
291-
# Coverage isn't able to support concurrency coverage for both threading and gevent
292-
self._before_first_request_callback() # pragma: no cover
293-
294-
295257
def _join_url_paths(*args: str) -> str:
296258
# urllib.parse.urljoin performs more logic than is needed. Thus we need a util func
297259
# to join paths as if they were POSIX paths.

src/idom/server/types.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import asyncio
2+
from typing import Callable, TypeVar
3+
4+
from typing_extensions import Protocol, runtime_checkable
5+
6+
from idom.core.types import ComponentType
7+
8+
9+
_App = TypeVar("_App")
10+
11+
12+
@runtime_checkable
13+
class ServerImplementation(Protocol):
14+
"""Common interface for IDOM's builti-in server implementations"""
15+
16+
def configure(self, app: _App, component: Callable[[], ComponentType]) -> None:
17+
"""Configure the given app instance to display the given component"""
18+
19+
def create_development_app(self) -> _App:
20+
"""Create an application instance for development purposes"""
21+
22+
async def serve_development_app(
23+
self, app: _App, host: str, port: int, started: asyncio.Event
24+
) -> None:
25+
"""Run an application using a development server"""

src/idom/server/utils.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,13 @@
1-
import asyncio
21
import socket
3-
import time
42
from contextlib import closing
5-
from functools import wraps
63
from pathlib import Path
7-
from threading import Event, Thread
8-
from typing import Any, Callable, Optional
9-
10-
from typing_extensions import ParamSpec
114

125
import idom
136

147

158
CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client"
169

1710

18-
_FuncParams = ParamSpec("_FuncParams")
19-
20-
21-
def threaded(function: Callable[_FuncParams, None]) -> Callable[_FuncParams, Thread]:
22-
@wraps(function)
23-
def wrapper(*args: Any, **kwargs: Any) -> Thread:
24-
def target() -> None:
25-
asyncio.set_event_loop(asyncio.new_event_loop())
26-
function(*args, **kwargs)
27-
28-
thread = Thread(target=target, daemon=True)
29-
thread.start()
30-
31-
return thread
32-
33-
return wrapper
34-
35-
36-
def wait_on_event(description: str, event: Event, timeout: Optional[float]) -> None:
37-
if not event.wait(timeout):
38-
raise TimeoutError(f"Did not {description} within {timeout} seconds")
39-
40-
41-
def poll(
42-
description: str,
43-
frequency: float,
44-
timeout: Optional[float],
45-
function: Callable[[], bool],
46-
) -> None:
47-
if timeout is not None:
48-
expiry = time.time() + timeout
49-
while not function():
50-
if time.time() > expiry:
51-
raise TimeoutError(f"Did not {description} within {timeout} seconds")
52-
time.sleep(frequency)
53-
else:
54-
while not function():
55-
time.sleep(frequency)
56-
57-
5811
def find_available_port(
5912
host: str,
6013
port_min: int = 8000,

src/idom/types.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
VdomDict,
2222
VdomJson,
2323
)
24-
from .server.types import ServerFactory, ServerType
24+
from .server.types import ServerImplementation
2525

2626

2727
__all__ = [
@@ -40,6 +40,5 @@
4040
"VdomChildren",
4141
"VdomDict",
4242
"VdomJson",
43-
"ServerFactory",
44-
"ServerType",
43+
"ServerImplementation",
4544
]

0 commit comments

Comments
 (0)