diff --git a/docs/main.py b/docs/main.py index f2b0790e8..30162a5ca 100644 --- a/docs/main.py +++ b/docs/main.py @@ -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__": diff --git a/docs/source/core-concepts.rst b/docs/source/core-concepts.rst index fb31c091d..dacb48252 100644 --- a/docs/source/core-concepts.rst +++ b/docs/source/core-concepts.rst @@ -170,9 +170,8 @@ Layout Server The :ref:`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]``) @@ -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) diff --git a/noxfile.py b/noxfile.py index 43d972d0a..d33e9af7e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -120,7 +120,7 @@ def test_style(session: Session) -> None: ".", "--check", "--exclude", - rf"/({black_default_exclude}|node_modules)/", + rf"/({black_default_exclude}|venv|node_modules)/", ) session.run("isort", ".", "--check-only") diff --git a/scripts/live_docs.py b/scripts/live_docs.py index 5a4478e51..4c1f18f9d 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -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 = ( @@ -39,6 +40,7 @@ def new_builder(): server = PerClientStateServer(main.component, {"cors": True}) _running_idom_servers.append(server) server.run_in_thread("127.0.0.1", 5555, debug=True) + server.wait_until_started() old_builder() return new_builder diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py index 4b276060f..9f6f8ade2 100644 --- a/src/idom/core/dispatcher.py +++ b/src/idom/core/dispatcher.py @@ -42,8 +42,8 @@ async def dispatch_single_view( task_group.start_soon(_single_incoming_loop, layout, recv) +SharedViewDispatcher = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] _SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], "Future[None]"] -_SharedViewDispatcherCoro = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] @asynccontextmanager @@ -93,8 +93,8 @@ def dispatch_shared_view_soon( def ensure_shared_view_dispatcher_future( layout: Layout, -) -> Tuple[Future[None], _SharedViewDispatcherCoro]: - dispatcher_future: Future[_SharedViewDispatcherCoro] = Future() +) -> Tuple[Future[None], SharedViewDispatcher]: + dispatcher_future: Future[SharedViewDispatcher] = Future() async def dispatch_shared_view_forever() -> None: with layout: @@ -121,7 +121,7 @@ async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: async def _make_shared_view_dispatcher( layout: Layout, -) -> Tuple[_SharedViewDispatcherCoro, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: +) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: initial_update = await layout.render() model_state = Ref(initial_update.apply_to({})) diff --git a/src/idom/server/__init__.py b/src/idom/server/__init__.py index 35c95dd2d..0dfd40ace 100644 --- a/src/idom/server/__init__.py +++ b/src/idom/server/__init__.py @@ -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", ] diff --git a/src/idom/server/base.py b/src/idom/server/base.py index fa9017326..541daf37a 100644 --- a/src/idom/server/base.py +++ b/src/idom/server/base.py @@ -1,16 +1,17 @@ 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 @@ -18,65 +19,52 @@ class AbstractRenderServer(Generic[_App, _Config], abc.ABC): 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 @@ -84,7 +72,7 @@ def wait_until_server_start(self, timeout: float = 3.0) -> None: ) @abc.abstractmethod - def stop(self) -> None: + def stop(self, timeout: Optional[float] = None) -> None: """Stop a currently running application""" raise NotImplementedError() @@ -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})" diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 368b6efbd..6e1f97cc0 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -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: diff --git a/src/idom/server/prefab.py b/src/idom/server/prefab.py index 8b4530294..a9daf623e 100644 --- a/src/idom/server/prefab.py +++ b/src/idom/server/prefab.py @@ -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: @@ -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. @@ -58,27 +62,24 @@ 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 + server.wait_until_started() return server 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. @@ -89,8 +90,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: @@ -114,14 +115,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. @@ -132,8 +133,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 diff --git a/src/idom/server/proto.py b/src/idom/server/proto.py new file mode 100644 index 000000000..8d75f76c4 --- /dev/null +++ b/src/idom/server/proto.py @@ -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: # pragma: no cover + 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""" diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index e6939147f..c986afb74 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -2,11 +2,14 @@ Sanic Servers ============= """ +from __future__ import annotations import asyncio import json import logging -from threading import Event +from asyncio import Future +from asyncio.events import AbstractEventLoop +from threading import Event, Thread from typing import Any, Dict, Optional, Tuple, Union from mypy_extensions import TypedDict @@ -15,16 +18,14 @@ from websockets import WebSocketCommonProtocol from idom.config import IDOM_CLIENT_BUILD_DIR +from idom.core.component import ComponentConstructor from idom.core.dispatcher import ( - RecvCoroutine, - SendCoroutine, + SharedViewDispatcher, dispatch_single_view, ensure_shared_view_dispatcher_future, ) from idom.core.layout import Layout, LayoutEvent, LayoutUpdate -from .base import AbstractRenderServer - logger = logging.getLogger(__name__) @@ -38,111 +39,60 @@ class Config(TypedDict, total=False): redirect_root_to_index: bool -class SanicRenderServer(AbstractRenderServer[Sanic, Config]): - """Base ``sanic`` extension.""" - - _loop: asyncio.AbstractEventLoop - _did_stop: Event - - def stop(self) -> None: - """Stop the running application""" - self._loop.call_soon_threadsafe(self.application.stop) - self._did_stop.wait(5) - - def _create_config(self, config: Optional[Config]) -> Config: - new_config: Config = { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - } - return new_config - - def _default_application(self, config: Config) -> Sanic: - return Sanic() - - def _setup_application(self, config: Config, app: Sanic) -> None: - bp = Blueprint(f"idom_dispatcher_{id(self)}", url_prefix=config["url_prefix"]) - - self._setup_blueprint_routes(config, bp) - - self._did_stop = did_stop = Event() - - @app.listener("before_server_stop") # type: ignore - async def server_did_stop(app: Sanic, loop: asyncio.AbstractEventLoop) -> None: - did_stop.set() - - cors_config = config["cors"] - if cors_config: # pragma: no cover - cors_params = cors_config if isinstance(cors_config, dict) else {} - CORS(bp, **cors_params) - - app.blueprint(bp) - - def _setup_application_did_start_event( - self, config: Config, app: Sanic, event: Event - ) -> None: - async def server_did_start(app: Sanic, loop: asyncio.AbstractEventLoop) -> None: - event.set() - - app.register_listener(server_did_start, "after_server_start") - - def _setup_blueprint_routes(self, config: Config, blueprint: Blueprint) -> None: - """Add routes to the application blueprint""" - - @blueprint.websocket("/stream") # type: ignore - async def model_stream( - request: request.Request, socket: WebSocketCommonProtocol - ) -> None: - async def sock_send(value: LayoutUpdate) -> None: - await socket.send(json.dumps(value)) - - async def sock_recv() -> LayoutEvent: - return LayoutEvent(**json.loads(await socket.recv())) - - component_params = {k: request.args.get(k) for k in request.args} - await self._run_dispatcher(sock_send, sock_recv, component_params) - - if config["serve_static_files"]: - blueprint.static("/client", str(IDOM_CLIENT_BUILD_DIR.current)) +def PerClientStateServer( + constructor: ComponentConstructor, + config: Optional[Config] = None, + app: Optional[Sanic] = None, +) -> SanicServer: + config, app = _setup_config_and_app(config, app) + blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=config["url_prefix"]) + _setup_common_routes(blueprint, config) + _setup_single_view_dispatcher_route(blueprint, constructor) + app.blueprint(blueprint) + return SanicServer(app) + + +def SharedClientStateServer( + constructor: ComponentConstructor, + config: Optional[Config] = None, + app: Optional[Sanic] = None, +) -> SanicServer: + config, app = _setup_config_and_app(config, app) + blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=config["url_prefix"]) + _setup_common_routes(blueprint, config) + _setup_shared_view_dispatcher_route(app, blueprint, constructor) + app.blueprint(blueprint) + return SanicServer(app) + + +class SanicServer: + + _loop: AbstractEventLoop + + def __init__(self, app: Sanic) -> None: + self.app = app + self._did_start = Event() + self._did_stop = Event() + app.register_listener(self._server_did_start, "after_server_start") + app.register_listener(self._server_did_stop, "after_server_stop") + + def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: + self.app.run(host, port, *args, **kwargs) # pragma: no cover + + def run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: + thread = Thread( + target=lambda: self._run_in_thread(host, port, *args, *kwargs), daemon=True + ) + thread.start() - if config["redirect_root_to_index"]: + def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: + self._did_start.wait(timeout) - @blueprint.route("/") # type: ignore - def redirect_to_index( - request: request.Request, - ) -> response.HTTPResponse: - return response.redirect( - f"{blueprint.url_prefix}/client/index.html?{request.query_string}" - ) + def stop(self, timeout: Optional[float] = 3.0) -> None: + self._loop.call_soon_threadsafe(self.app.stop) + self._did_stop.wait(timeout) - async def _run_dispatcher( - self, send: SendCoroutine, recv: RecvCoroutine, params: Dict[str, Any] - ) -> None: - raise NotImplementedError() - - def _run_application( - self, - config: Config, - app: Sanic, - host: str, - port: int, - args: Tuple[Any, ...], - kwargs: Dict[str, Any], - ) -> None: - self._loop = asyncio.get_event_loop() - app.run(host, port, *args, **kwargs) - - def _run_application_in_thread( - self, - config: Config, - app: Sanic, - host: str, - port: int, - args: Tuple[Any, ...], - kwargs: Dict[str, Any], - ) -> None: + def _run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: try: loop = asyncio.get_event_loop() except RuntimeError: @@ -154,7 +104,7 @@ def _run_application_in_thread( # what follows was copied from: # https://github.com/sanic-org/sanic/blob/7028eae083b0da72d09111b9892ddcc00bce7df4/examples/run_async_advanced.py - serv_coro = app.create_server( + serv_coro = self.app.create_server( host, port, *args, **kwargs, return_asyncio_server=True ) serv_task = asyncio.ensure_future(serv_coro, loop=loop) @@ -162,7 +112,7 @@ def _run_application_in_thread( server.after_start() try: loop.run_forever() - except KeyboardInterrupt: + except KeyboardInterrupt: # pragma: no cover loop.stop() finally: server.before_stop() @@ -176,55 +126,105 @@ def _run_application_in_thread( connection.close_if_idle() server.after_stop() + async def _server_did_start(self, app: Sanic, loop: AbstractEventLoop) -> None: + self._loop = loop + self._did_start.set() + + async def _server_did_stop(self, app: Sanic, loop: AbstractEventLoop) -> None: + self._did_stop.set() + + +def _setup_config_and_app( + config: Optional[Config], + app: Optional[Sanic], +) -> Tuple[Config, Sanic]: + return ( + { + "cors": False, + "url_prefix": "", + "serve_static_files": True, + "redirect_root_to_index": True, + **(config or {}), # type: ignore + }, + app or Sanic(), + ) + + +def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: + cors_config = config["cors"] + if cors_config: # pragma: no cover + cors_params = cors_config if isinstance(cors_config, dict) else {} + CORS(blueprint, **cors_params) + + if config["serve_static_files"]: + blueprint.static("/client", str(IDOM_CLIENT_BUILD_DIR.current)) -class PerClientStateServer(SanicRenderServer): - """Each client view will have its own state.""" + if config["redirect_root_to_index"]: - async def _run_dispatcher( - self, - send: SendCoroutine, - recv: RecvCoroutine, - params: Dict[str, Any], + @blueprint.route("/") # type: ignore + def redirect_to_index( + request: request.Request, + ) -> response.HTTPResponse: + return response.redirect( + f"{blueprint.url_prefix}/client/index.html?{request.query_string}" + ) + + +def _setup_single_view_dispatcher_route( + blueprint: Blueprint, constructor: ComponentConstructor +) -> None: + @blueprint.websocket("/stream") # type: ignore + async def model_stream( + request: request.Request, socket: WebSocketCommonProtocol ) -> None: + async def sock_send(value: LayoutUpdate) -> None: + await socket.send(json.dumps(value)) + + async def sock_recv() -> LayoutEvent: + return LayoutEvent(**json.loads(await socket.recv())) + + component_params = {k: request.args.get(k) for k in request.args} await dispatch_single_view( - Layout(self._root_component_constructor(**params)), - send, - recv, + Layout(constructor(**component_params)), + sock_send, + sock_recv, ) -class SharedClientStateServer(SanicRenderServer): - """All connected client views will have shared state.""" - - def _setup_application(self, config: Config, app: Sanic) -> None: - app.register_listener(self._activate_dispatcher, "before_server_start") - app.register_listener(self._deactivate_dispatcher, "before_server_stop") - super()._setup_application(config, app) +def _setup_shared_view_dispatcher_route( + app: Sanic, blueprint: Blueprint, constructor: ComponentConstructor +) -> None: + dispatcher_future: Future[None] + dispatch_coroutine: SharedViewDispatcher - async def _activate_dispatcher( - self, app: Sanic, loop: asyncio.AbstractEventLoop - ) -> None: - ( - self._dispatch_daemon_future, - self._dispatch_coroutine, - ) = ensure_shared_view_dispatcher_future( - Layout(self._root_component_constructor()) + async def activate_dispatcher(app: Sanic, loop: AbstractEventLoop) -> None: + nonlocal dispatcher_future + nonlocal dispatch_coroutine + dispatcher_future, dispatch_coroutine = ensure_shared_view_dispatcher_future( + Layout(constructor()) ) - async def _deactivate_dispatcher( - self, app: Sanic, loop: asyncio.AbstractEventLoop - ) -> None: + async def deactivate_dispatcher(app: Sanic, loop: AbstractEventLoop) -> None: logger.debug("Stopping dispatcher - server is shutting down") - self._dispatch_daemon_future.cancel() - await asyncio.wait([self._dispatch_daemon_future]) - - async def _run_dispatcher( - self, - send: SendCoroutine, - recv: RecvCoroutine, - params: Dict[str, Any], + dispatcher_future.cancel() + await asyncio.wait([dispatcher_future]) + + app.register_listener(activate_dispatcher, "before_server_start") + app.register_listener(deactivate_dispatcher, "before_server_stop") + + @blueprint.websocket("/stream") # type: ignore + async def model_stream( + request: request.Request, socket: WebSocketCommonProtocol ) -> None: - if params: - msg = f"SharedClientState server does not support per-client view parameters {params}" - raise ValueError(msg) - await self._dispatch_coroutine(send, recv) + if request.args: + raise ValueError( + "SharedClientState server does not support per-client view parameters" + ) + + async def sock_send(value: LayoutUpdate) -> None: + await socket.send(json.dumps(value)) + + async def sock_recv() -> LayoutEvent: + return LayoutEvent(**json.loads(await socket.recv())) + + await dispatch_coroutine(sock_send, sock_recv) diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 87f9ac0ea..942bcaedd 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -42,7 +42,7 @@ 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 @@ -50,7 +50,14 @@ def stop(self) -> None: 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 = { diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index e377b754a..b7241026f 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -1,7 +1,9 @@ from contextlib import closing from importlib import import_module from socket import socket -from typing import Any, List, Type +from typing import Any, List + +from .proto import ServerFactory _SUPPORTED_PACKAGES = [ @@ -12,7 +14,7 @@ ] -def find_builtin_server_type(type_name: str) -> Type[Any]: +def find_builtin_server_type(type_name: str) -> ServerFactory[Any, Any]: """Find first installed server implementation""" installed_builtins: List[str] = [] for name in _SUPPORTED_PACKAGES: diff --git a/src/idom/testing.py b/src/idom/testing.py index fde01d983..1838787ca 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -28,9 +28,9 @@ from idom.core.events import EventHandler from idom.core.hooks import LifeCycleHook, current_hook from idom.core.utils import hex_id -from idom.server.base import AbstractRenderServer -from idom.server.prefab import hotswap_server -from idom.server.utils import find_available_port, find_builtin_server_type +from idom.server.prefab import DEFAULT_SERVER_FACTORY, hotswap_server +from idom.server.proto import Server, ServerFactory +from idom.server.utils import find_available_port __all__ = [ @@ -40,9 +40,6 @@ ] -AnyRenderServer = AbstractRenderServer[Any, Any] - - def create_simple_selenium_web_driver( driver_type: Type[WebDriver] = Chrome, driver_options: Optional[Any] = None, @@ -61,7 +58,9 @@ def create_simple_selenium_web_driver( _Self = TypeVar("_Self", bound="ServerMountPoint[Any, Any]") _Mount = TypeVar("_Mount") -_Server = TypeVar("_Server", bound=AnyRenderServer) +_Server = TypeVar("_Server", bound=Server[Any]) +_App = TypeVar("_App") +_Config = TypeVar("_Config") class ServerMountPoint(Generic[_Mount, _Server]): @@ -74,13 +73,13 @@ class ServerMountPoint(Generic[_Mount, _Server]): def __init__( self, - server_type: Type[_Server] = 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, + server_config: Optional[_Config] = None, run_kwargs: Optional[Dict[str, Any]] = None, mount_and_server_constructor: "Callable[..., Tuple[_Mount, _Server]]" = hotswap_server, # type: ignore - app: Optional[Any] = None, + app: Optional[_App] = None, **other_options: Any, ) -> None: self.host = host diff --git a/tests/test_server/test_base.py b/tests/test_server/test_base.py deleted file mode 100644 index 0b404657e..000000000 --- a/tests/test_server/test_base.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -import sanic - -import idom -from idom.server.sanic import PerClientStateServer -from idom.server.utils import find_builtin_server_type - - -@idom.component -def AnyComponent(): - pass - - -def test_no_application_until_running(): - server = find_builtin_server_type("PerClientStateServer")(AnyComponent) - with pytest.raises(RuntimeError, match="No application"): - server.application - - -def test_cannot_register_app_twice(): - server = PerClientStateServer(AnyComponent) - server.register(sanic.Sanic()) - with pytest.raises(RuntimeError, match="Already registered"): - server.register(sanic.Sanic())