Skip to content

Commit eff27c1

Browse files
committed
use server protocol instea of inheritance
1 parent 4ac9322 commit eff27c1

File tree

13 files changed

+134
-98
lines changed

13 files changed

+134
-98
lines changed

docs/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ async def forward_to_index(request):
6767
"redirect_root_to_index": False,
6868
"url_prefix": IDOM_MODEL_SERVER_URL_PREFIX,
6969
},
70-
).register(app)
70+
app,
71+
)
7172

7273

7374
if __name__ == "__main__":

docs/source/core-concepts.rst

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,8 @@ Layout Server
170170

171171
The :ref:`Dispatcher <Layout Dispatcher>` allows you to animate the layout, but we still
172172
need to get the models on the screen. One of the last steps in that journey is to send
173-
them over the wire. To do that you need an
174-
:class:`~idom.server.base.AbstractRenderServer` implementation. Presently, IDOM comes
175-
with support for the following web servers:
173+
them over the wire. To do that you need a :class:`~idom.server.proto.ServerFactory`
174+
implementation. Presently, IDOM comes with support for the following web servers:
176175

177176
- :class:`sanic.app.Sanic` (``pip install idom[sanic]``)
178177

@@ -244,8 +243,7 @@ The implementation registers hooks into the application to serve the model once
244243
def View(self):
245244
return idom.html.h1(["Hello World"])
246245
247-
per_client_state = PerClientStateServer(View)
248-
per_client_state.register(app)
246+
per_client_state = PerClientStateServer(View, app=app)
249247
250248
app.run("localhost", 5000)
251249

scripts/live_docs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def wrap_builder(old_builder):
2626
# This is the bit that we're injecting to get the example components to reload too
2727
def new_builder():
2828
[s.stop() for s in _running_idom_servers]
29+
[s.wait_until_stopped() for s in _running_idom_servers]
2930

3031
# we need to set this before `docs.main` does
3132
IDOM_CLIENT_IMPORT_SOURCE_URL.current = (

src/idom/server/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
from .base import AbstractRenderServer
21
from .prefab import hotswap_server, multiview_server, run
32

43

54
__all__ = [
6-
"default",
7-
"run",
8-
"multiview_server",
95
"hotswap_server",
10-
"AbstractRenderServer",
6+
"multiview_server",
7+
"run",
118
]

src/idom/server/base.py

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,79 @@
11
import abc
22
from threading import Event, Thread
3-
from typing import Any, Dict, Generic, Optional, Tuple, TypeVar
3+
from typing import Any, Dict, Optional, Tuple, TypeVar
44

55
from idom.core.component import ComponentConstructor
66

7+
from .proto import ServerFactory
8+
79

810
_App = TypeVar("_App", bound=Any)
911
_Config = TypeVar("_Config", bound=Any)
10-
_Self = TypeVar("_Self", bound="AbstractRenderServer[Any, Any]")
1112

1213

13-
class AbstractRenderServer(Generic[_App, _Config], abc.ABC):
14+
class AbstractRenderServer(ServerFactory[_App, _Config], abc.ABC):
1415
"""Base class for all IDOM server application and extension implementations.
1516
1617
It is assumed that IDOM will be used in conjuction with some async-enabled server
1718
library (e.g. ``sanic`` or ``tornado``) so these server implementations should work
1819
standalone and as an extension to an existing application.
1920
2021
Standalone usage:
21-
:meth:`~AbstractServerExtension.run` or :meth:`~AbstractServerExtension.run_in_thread`
22-
Register an extension:
23-
:meth:`~AbstractServerExtension.register`
22+
Construct the server then call ``:meth:`~AbstractRenderServer.run` or
23+
:meth:`~AbstractRenderServer.run_in_thread`
24+
Register as an extension:
25+
Simply construct the :meth:`~AbstractRenderServer` and pass it an ``app``
26+
instance.
2427
"""
2528

2629
def __init__(
2730
self,
2831
constructor: ComponentConstructor,
2932
config: Optional[_Config] = None,
33+
app: Optional[_App] = None,
3034
) -> None:
31-
self._app: Optional[_App] = None
3235
self._root_component_constructor = constructor
3336
self._daemon_thread: Optional[Thread] = None
3437
self._config = self._create_config(config)
3538
self._server_did_start = Event()
36-
37-
@property
38-
def application(self) -> _App:
39-
if self._app is None:
40-
raise RuntimeError("No application registered.")
41-
return self._app
39+
if app is None:
40+
self.app = self._default_application(self._config)
41+
self._setup_application(self._config, self.app)
42+
self._setup_application_did_start_event(
43+
self._config, self.app, self._server_did_start
44+
)
4245

4346
def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None:
4447
"""Run as a standalone application."""
45-
if self._app is None:
46-
app = self._default_application(self._config)
47-
self.register(app)
48-
else: # pragma: no cover
49-
app = self._app
5048
if self._daemon_thread is None: # pragma: no cover
51-
return self._run_application(self._config, app, host, port, args, kwargs)
49+
return self._run_application(
50+
self._config, self.app, host, port, args, kwargs
51+
)
5252
else:
5353
return self._run_application_in_thread(
54-
self._config, app, host, port, args, kwargs
54+
self._config, self.app, host, port, args, kwargs
5555
)
5656

57-
def run_in_thread(self, *args: Any, **kwargs: Any) -> Thread:
57+
def run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> Thread:
5858
"""Run the standalone application in a seperate thread."""
5959
self._daemon_thread = thread = Thread(
60-
target=lambda: self.run(*args, **kwargs), daemon=True
60+
target=lambda: self.run(host, port, *args, **kwargs), daemon=True
6161
)
6262

6363
thread.start()
64-
self.wait_until_server_start()
64+
self.wait_until_started()
6565

6666
return thread
6767

68-
def register(self: _Self, app: Optional[_App]) -> _Self:
69-
"""Register this as an extension."""
70-
if self._app is not None:
71-
raise RuntimeError(f"Already registered {self._app}")
72-
self._setup_application(self._config, app)
73-
self._setup_application_did_start_event(
74-
self._config, app, self._server_did_start
75-
)
76-
self._app = app
77-
return self
78-
79-
def wait_until_server_start(self, timeout: float = 3.0) -> None:
68+
def wait_until_started(self, timeout: Optional[float] = 3.0) -> None:
8069
"""Block until the underlying application has started"""
8170
if not self._server_did_start.wait(timeout=timeout):
8271
raise RuntimeError( # pragma: no cover
8372
f"Server did not start within {timeout} seconds"
8473
)
8574

8675
@abc.abstractmethod
87-
def stop(self) -> None:
76+
def stop(self, timeout: Optional[float] = None) -> None:
8877
"""Stop a currently running application"""
8978
raise NotImplementedError()
9079

@@ -135,3 +124,8 @@ def _run_application_in_thread(
135124
) -> None:
136125
"""This function has been called inside a daemon thread to run the application"""
137126
raise NotImplementedError()
127+
128+
def __repr__(self) -> str:
129+
cls = type(self)
130+
full_name = f"{cls.__module__}.{cls.__name__}"
131+
return f"{full_name}({self._config})"

src/idom/server/fastapi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class FastApiRenderServer(AbstractRenderServer[FastAPI, Config]):
5151

5252
_server: UvicornServer
5353

54-
def stop(self, timeout: float = 3) -> None:
54+
def stop(self, timeout: Optional[float] = 3.0) -> None:
5555
"""Stop the running application"""
5656
self._server.should_exit
5757
if self._daemon_thread is not None:

src/idom/server/prefab.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,33 @@
44
"""
55

66
import logging
7-
from typing import Any, Dict, Optional, Tuple, Type, TypeVar
7+
from typing import Any, Dict, Optional, Tuple, TypeVar
88

99
from idom.core.component import ComponentConstructor
1010
from idom.widgets.utils import MountFunc, MultiViewMount, hotswap, multiview
1111

12-
from .base import AbstractRenderServer
12+
from .proto import Server, ServerFactory
1313
from .utils import find_available_port, find_builtin_server_type
1414

1515

16+
DEFAULT_SERVER_FACTORY = find_builtin_server_type("PerClientStateServer")
17+
1618
logger = logging.getLogger(__name__)
17-
_S = TypeVar("_S", bound=AbstractRenderServer[Any, Any])
19+
20+
_App = TypeVar("_App")
21+
_Config = TypeVar("_Config")
1822

1923

2024
def run(
2125
component: ComponentConstructor,
22-
server_type: Type[_S] = find_builtin_server_type("PerClientStateServer"),
26+
server_type: ServerFactory[_App, _Config] = DEFAULT_SERVER_FACTORY,
2327
host: str = "127.0.0.1",
2428
port: Optional[int] = None,
2529
server_config: Optional[Any] = None,
2630
run_kwargs: Optional[Dict[str, Any]] = None,
2731
app: Optional[Any] = None,
2832
daemon: bool = False,
29-
) -> _S:
33+
) -> Server[_App]:
3034
"""A utility for quickly running a render server with minimal boilerplate
3135
3236
Parameters:
@@ -41,8 +45,8 @@ def run(
4145
server_config:
4246
Options passed to configure the server.
4347
run_kwargs:
44-
Keyword arguments passed to the :meth:`AbstractRenderServer.run`
45-
or :meth:`AbstractRenderServer.run_in_thread` methods of the server
48+
Keyword arguments passed to the :meth:`~idom.server.proto.Server.run`
49+
or :meth:`~idom.server.proto.Server.run_in_thread` methods of the server
4650
depending on whether ``daemon`` is set or not.
4751
app:
4852
Register the server to an existing application and run that.
@@ -58,12 +62,8 @@ def run(
5862
if port is None: # pragma: no cover
5963
port = find_available_port(host)
6064

61-
logger.info(f"Using {server_type.__module__}.{server_type.__name__}")
62-
63-
server = server_type(component, server_config)
64-
65-
if app is not None: # pragma: no cover
66-
server.register(app)
65+
server = server_type(component, server_config, app)
66+
logger.info(f"Using {server}")
6767

6868
run_server = server.run if not daemon else server.run_in_thread
6969
run_server(host, port, **(run_kwargs or {})) # type: ignore
@@ -72,13 +72,13 @@ def run(
7272

7373

7474
def multiview_server(
75-
server_type: Type[_S],
75+
server_type: ServerFactory[_App, _Config] = DEFAULT_SERVER_FACTORY,
7676
host: str = "127.0.0.1",
7777
port: Optional[int] = None,
78-
server_config: Optional[Any] = None,
78+
server_config: Optional[_Config] = None,
7979
run_kwargs: Optional[Dict[str, Any]] = None,
8080
app: Optional[Any] = None,
81-
) -> Tuple[MultiViewMount, _S]:
81+
) -> Tuple[MultiViewMount, Server[_App]]:
8282
"""Set up a server where views can be dynamically added.
8383
8484
In other words this allows the user to work with IDOM in an imperative manner.
@@ -89,8 +89,8 @@ def multiview_server(
8989
server: The server type to start up as a daemon
9090
host: The server hostname
9191
port: The server port number
92-
server_config: Value passed to :meth:`AbstractRenderServer.configure`
93-
run_kwargs: Keyword args passed to :meth:`AbstractRenderServer.run_in_thread`
92+
server_config: Value passed to :meth:`~idom.server.proto.ServerFactory`
93+
run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread`
9494
app: Optionally provide a prexisting application to register to
9595
9696
Returns:
@@ -114,14 +114,14 @@ def multiview_server(
114114

115115

116116
def hotswap_server(
117-
server_type: Type[_S],
117+
server_type: ServerFactory[_App, _Config] = DEFAULT_SERVER_FACTORY,
118118
host: str = "127.0.0.1",
119119
port: Optional[int] = None,
120-
server_config: Optional[Any] = None,
120+
server_config: Optional[_Config] = None,
121121
run_kwargs: Optional[Dict[str, Any]] = None,
122122
app: Optional[Any] = None,
123123
sync_views: bool = False,
124-
) -> Tuple[MountFunc, _S]:
124+
) -> Tuple[MountFunc, Server[_App]]:
125125
"""Set up a server where views can be dynamically swapped out.
126126
127127
In other words this allows the user to work with IDOM in an imperative manner.
@@ -132,8 +132,8 @@ def hotswap_server(
132132
server: The server type to start up as a daemon
133133
host: The server hostname
134134
port: The server port number
135-
server_config: Value passed to :meth:`AbstractRenderServer.configure`
136-
run_kwargs: Keyword args passed to :meth:`AbstractRenderServer.run_in_thread`
135+
server_config: Value passed to :meth:`~idom.server.proto.ServerFactory`
136+
run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread`
137137
app: Optionally provide a prexisting application to register to
138138
sync_views: Whether to update all displays with newly mounted components
139139

src/idom/server/proto.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional, TypeVar
4+
5+
from idom.core.component import ComponentConstructor
6+
7+
8+
try:
9+
from typing import Protocol
10+
except ImportError:
11+
from typing_extensions import Protocol # type: ignore
12+
13+
14+
_App = TypeVar("_App")
15+
_Config = TypeVar("_Config", contravariant=True)
16+
17+
18+
class ServerFactory(Protocol[_App, _Config]):
19+
"""Setup a :class:`Server`"""
20+
21+
def __call__(
22+
self,
23+
constructor: ComponentConstructor,
24+
config: Optional[_Config] = None,
25+
app: Optional[_App] = None,
26+
) -> Server[_App]:
27+
...
28+
29+
30+
class Server(Protocol[_App]):
31+
"""An object representing a server prepared to support IDOM's protocols"""
32+
33+
app: _App
34+
"""The server's underlying application"""
35+
36+
def run(self, host: str, port: int) -> None:
37+
"""Start running the server"""
38+
39+
def run_in_thread(self, host: str, port: int) -> None:
40+
"""Run the server in a thread"""
41+
42+
def wait_until_started(self, timeout: Optional[float] = None) -> None:
43+
"""Block until the server is able to receive requests"""
44+
45+
def stop(self, timeout: Optional[float] = None) -> None:
46+
"""Stop the running server"""

src/idom/server/sanic.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ class SanicRenderServer(AbstractRenderServer[Sanic, Config]):
4444
_loop: asyncio.AbstractEventLoop
4545
_did_stop: Event
4646

47-
def stop(self) -> None:
47+
def stop(self, timeout: Optional[float] = 5.0) -> None:
4848
"""Stop the running application"""
49-
self._loop.call_soon_threadsafe(self.application.stop)
50-
self._did_stop.wait(5)
49+
self._loop.call_soon_threadsafe(self.app.stop)
50+
self._did_stop.wait(timeout)
5151

5252
def _create_config(self, config: Optional[Config]) -> Config:
5353
new_config: Config = {

src/idom/server/tornado.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,22 @@ class TornadoRenderServer(AbstractRenderServer[Application, Config]):
4242

4343
_model_stream_handler_type: Type[WebSocketHandler]
4444

45-
def stop(self) -> None:
45+
def stop(self, timeout: Optional[float] = None) -> None:
4646
try:
4747
loop = self._loop
4848
except AttributeError: # pragma: no cover
4949
raise RuntimeError(
5050
f"Application is not running or was not started by {self}"
5151
)
5252
else:
53-
loop.call_soon_threadsafe(self._loop.stop)
53+
did_stop = ThreadEvent()
54+
55+
def stop() -> None:
56+
loop.stop()
57+
did_stop.set()
58+
59+
loop.call_soon_threadsafe(stop)
60+
did_stop.wait(timeout)
5461

5562
def _create_config(self, config: Optional[Config]) -> Config:
5663
new_config: Config = {

0 commit comments

Comments
 (0)