Skip to content

Commit

Permalink
use server protocol instea of inheritance
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorshea committed May 16, 2021
1 parent 4ac9322 commit abe0fde
Show file tree
Hide file tree
Showing 13 changed files with 133 additions and 98 deletions.
3 changes: 2 additions & 1 deletion docs/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ async def forward_to_index(request):
"redirect_root_to_index": False,
"url_prefix": IDOM_MODEL_SERVER_URL_PREFIX,
},
).register(app)
app,
)


if __name__ == "__main__":
Expand Down
8 changes: 3 additions & 5 deletions docs/source/core-concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,8 @@ Layout Server

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

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

Expand Down Expand Up @@ -244,8 +243,7 @@ The implementation registers hooks into the application to serve the model once
def View(self):
return idom.html.h1(["Hello World"])
per_client_state = PerClientStateServer(View)
per_client_state.register(app)
per_client_state = PerClientStateServer(View, app=app)
app.run("localhost", 5000)
Expand Down
1 change: 1 addition & 0 deletions scripts/live_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def wrap_builder(old_builder):
# This is the bit that we're injecting to get the example components to reload too
def new_builder():
[s.stop() for s in _running_idom_servers]
[s.wait_until_stopped() for s in _running_idom_servers]

# we need to set this before `docs.main` does
IDOM_CLIENT_IMPORT_SOURCE_URL.current = (
Expand Down
7 changes: 2 additions & 5 deletions src/idom/server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
from .base import AbstractRenderServer
from .prefab import hotswap_server, multiview_server, run


__all__ = [
"default",
"run",
"multiview_server",
"hotswap_server",
"AbstractRenderServer",
"multiview_server",
"run",
]
65 changes: 29 additions & 36 deletions src/idom/server/base.py
Original file line number Diff line number Diff line change
@@ -1,90 +1,78 @@
import abc
from threading import Event, Thread
from typing import Any, Dict, Generic, Optional, Tuple, TypeVar
from typing import Any, Dict, Optional, Tuple, TypeVar

from idom.core.component import ComponentConstructor

from .proto import ServerFactory


_App = TypeVar("_App", bound=Any)
_Config = TypeVar("_Config", bound=Any)
_Self = TypeVar("_Self", bound="AbstractRenderServer[Any, Any]")


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

def __init__(
self,
constructor: ComponentConstructor,
config: Optional[_Config] = None,
app: Optional[_App] = None,
) -> None:
self._app: Optional[_App] = None
self._root_component_constructor = constructor
self._daemon_thread: Optional[Thread] = None
self._config = self._create_config(config)
self._server_did_start = Event()

@property
def application(self) -> _App:
if self._app is None:
raise RuntimeError("No application registered.")
return self._app
self.app = app or self._default_application(self._config)
self._setup_application(self._config, self.app)
self._setup_application_did_start_event(
self._config, self.app, self._server_did_start
)

def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None:
"""Run as a standalone application."""
if self._app is None:
app = self._default_application(self._config)
self.register(app)
else: # pragma: no cover
app = self._app
if self._daemon_thread is None: # pragma: no cover
return self._run_application(self._config, app, host, port, args, kwargs)
return self._run_application(
self._config, self.app, host, port, args, kwargs
)
else:
return self._run_application_in_thread(
self._config, app, host, port, args, kwargs
self._config, self.app, host, port, args, kwargs
)

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

thread.start()
self.wait_until_server_start()
self.wait_until_started()

return thread

def register(self: _Self, app: Optional[_App]) -> _Self:
"""Register this as an extension."""
if self._app is not None:
raise RuntimeError(f"Already registered {self._app}")
self._setup_application(self._config, app)
self._setup_application_did_start_event(
self._config, app, self._server_did_start
)
self._app = app
return self

def wait_until_server_start(self, timeout: float = 3.0) -> None:
def wait_until_started(self, timeout: Optional[float] = 3.0) -> None:
"""Block until the underlying application has started"""
if not self._server_did_start.wait(timeout=timeout):
raise RuntimeError( # pragma: no cover
f"Server did not start within {timeout} seconds"
)

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

Expand Down Expand Up @@ -135,3 +123,8 @@ def _run_application_in_thread(
) -> None:
"""This function has been called inside a daemon thread to run the application"""
raise NotImplementedError()

def __repr__(self) -> str:
cls = type(self)
full_name = f"{cls.__module__}.{cls.__name__}"
return f"{full_name}({self._config})"
2 changes: 1 addition & 1 deletion src/idom/server/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class FastApiRenderServer(AbstractRenderServer[FastAPI, Config]):

_server: UvicornServer

def stop(self, timeout: float = 3) -> None:
def stop(self, timeout: Optional[float] = 3.0) -> None:
"""Stop the running application"""
self._server.should_exit
if self._daemon_thread is not None:
Expand Down
46 changes: 23 additions & 23 deletions src/idom/server/prefab.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,33 @@
"""

import logging
from typing import Any, Dict, Optional, Tuple, Type, TypeVar
from typing import Any, Dict, Optional, Tuple, TypeVar

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

from .base import AbstractRenderServer
from .proto import Server, ServerFactory
from .utils import find_available_port, find_builtin_server_type


DEFAULT_SERVER_FACTORY = find_builtin_server_type("PerClientStateServer")

logger = logging.getLogger(__name__)
_S = TypeVar("_S", bound=AbstractRenderServer[Any, Any])

_App = TypeVar("_App")
_Config = TypeVar("_Config")


def run(
component: ComponentConstructor,
server_type: Type[_S] = find_builtin_server_type("PerClientStateServer"),
server_type: ServerFactory[_App, _Config] = DEFAULT_SERVER_FACTORY,
host: str = "127.0.0.1",
port: Optional[int] = None,
server_config: Optional[Any] = None,
run_kwargs: Optional[Dict[str, Any]] = None,
app: Optional[Any] = None,
daemon: bool = False,
) -> _S:
) -> Server[_App]:
"""A utility for quickly running a render server with minimal boilerplate
Parameters:
Expand All @@ -41,8 +45,8 @@ def run(
server_config:
Options passed to configure the server.
run_kwargs:
Keyword arguments passed to the :meth:`AbstractRenderServer.run`
or :meth:`AbstractRenderServer.run_in_thread` methods of the server
Keyword arguments passed to the :meth:`~idom.server.proto.Server.run`
or :meth:`~idom.server.proto.Server.run_in_thread` methods of the server
depending on whether ``daemon`` is set or not.
app:
Register the server to an existing application and run that.
Expand All @@ -58,12 +62,8 @@ def run(
if port is None: # pragma: no cover
port = find_available_port(host)

logger.info(f"Using {server_type.__module__}.{server_type.__name__}")

server = server_type(component, server_config)

if app is not None: # pragma: no cover
server.register(app)
server = server_type(component, server_config, app)
logger.info(f"Using {server}")

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


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


def hotswap_server(
server_type: Type[_S],
server_type: ServerFactory[_App, _Config] = DEFAULT_SERVER_FACTORY,
host: str = "127.0.0.1",
port: Optional[int] = None,
server_config: Optional[Any] = None,
server_config: Optional[_Config] = None,
run_kwargs: Optional[Dict[str, Any]] = None,
app: Optional[Any] = None,
sync_views: bool = False,
) -> Tuple[MountFunc, _S]:
) -> Tuple[MountFunc, Server[_App]]:
"""Set up a server where views can be dynamically swapped out.
In other words this allows the user to work with IDOM in an imperative manner.
Expand All @@ -132,8 +132,8 @@ def hotswap_server(
server: The server type to start up as a daemon
host: The server hostname
port: The server port number
server_config: Value passed to :meth:`AbstractRenderServer.configure`
run_kwargs: Keyword args passed to :meth:`AbstractRenderServer.run_in_thread`
server_config: Value passed to :meth:`~idom.server.proto.ServerFactory`
run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread`
app: Optionally provide a prexisting application to register to
sync_views: Whether to update all displays with newly mounted components
Expand Down
46 changes: 46 additions & 0 deletions src/idom/server/proto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

from typing import Optional, TypeVar

from idom.core.component import ComponentConstructor


try:
from typing import Protocol
except ImportError:
from typing_extensions import Protocol # type: ignore


_App = TypeVar("_App")
_Config = TypeVar("_Config", contravariant=True)


class ServerFactory(Protocol[_App, _Config]):
"""Setup a :class:`Server`"""

def __call__(
self,
constructor: ComponentConstructor,
config: Optional[_Config] = None,
app: Optional[_App] = None,
) -> Server[_App]:
...


class Server(Protocol[_App]):
"""An object representing a server prepared to support IDOM's protocols"""

app: _App
"""The server's underlying application"""

def run(self, host: str, port: int) -> None:
"""Start running the server"""

def run_in_thread(self, host: str, port: int) -> None:
"""Run the server in a thread"""

def wait_until_started(self, timeout: Optional[float] = None) -> None:
"""Block until the server is able to receive requests"""

def stop(self, timeout: Optional[float] = None) -> None:
"""Stop the running server"""
6 changes: 3 additions & 3 deletions src/idom/server/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ class SanicRenderServer(AbstractRenderServer[Sanic, Config]):
_loop: asyncio.AbstractEventLoop
_did_stop: Event

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

def _create_config(self, config: Optional[Config]) -> Config:
new_config: Config = {
Expand Down
11 changes: 9 additions & 2 deletions src/idom/server/tornado.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,22 @@ class TornadoRenderServer(AbstractRenderServer[Application, Config]):

_model_stream_handler_type: Type[WebSocketHandler]

def stop(self) -> None:
def stop(self, timeout: Optional[float] = None) -> None:
try:
loop = self._loop
except AttributeError: # pragma: no cover
raise RuntimeError(
f"Application is not running or was not started by {self}"
)
else:
loop.call_soon_threadsafe(self._loop.stop)
did_stop = ThreadEvent()

def stop() -> None:
loop.stop()
did_stop.set()

loop.call_soon_threadsafe(stop)
did_stop.wait(timeout)

def _create_config(self, config: Optional[Config]) -> Config:
new_config: Config = {
Expand Down
Loading

0 comments on commit abe0fde

Please sign in to comment.