From f31b5a57f4d56e2cc892a0072ab244888f1e4d76 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 23 Apr 2021 19:09:33 -0700 Subject: [PATCH 1/6] refactor dispatchers Simplify them greatly by making them normal functions. This avoids a lot of indirection and complexity caused by inheritance. In addition this fixes some impropper usages of TaskGroups which was causing problems in the SharedClientStateServer implementations. This refactor also allowed us to get rid of the clunky HasAsyncResources util class. In the end it just added confusion. --- docs/source/core-concepts.rst | 32 +-- requirements/pkg-deps.txt | 1 - src/idom/core/__init__.py | 23 -- src/idom/core/dispatcher.py | 233 ++++++++-------- src/idom/core/events.py | 2 +- src/idom/core/hooks.py | 2 +- src/idom/core/layout.py | 75 ++--- src/idom/core/utils.py | 121 +------- src/idom/server/fastapi.py | 50 ++-- src/idom/server/flask.py | 39 ++- src/idom/server/sanic.py | 49 ++-- src/idom/server/tornado.py | 26 +- src/idom/testing.py | 2 +- tests/test_core/test_dispatcher.py | 260 +++++++----------- tests/test_core/test_hooks.py | 46 ++-- tests/test_core/test_layout.py | 43 +-- tests/test_core/test_utils.py | 44 --- .../test_common/test_per_client_state.py | 6 +- .../test_common/test_shared_state_client.py | 36 ++- 19 files changed, 445 insertions(+), 645 deletions(-) delete mode 100644 tests/test_core/test_utils.py diff --git a/docs/source/core-concepts.rst b/docs/source/core-concepts.rst index 5eb10b5e1..1efaa3605 100644 --- a/docs/source/core-concepts.rst +++ b/docs/source/core-concepts.rst @@ -63,7 +63,7 @@ ever be removed from the model. Then you'll just need to call and await a .. testcode:: - async with idom.Layout(ClickCount()) as layout: + with idom.Layout(ClickCount()) as layout: patch = await layout.render() The layout also handles the triggering of event handlers. Normally these are @@ -88,7 +88,7 @@ which we can re-render and see what changed: return idom.html.button({"onClick": handler}, [f"Click count: {count}"]) - async with idom.Layout(ClickCount()) as layout: + with idom.Layout(ClickCount()) as layout: patch_1 = await layout.render() fake_event = LayoutEvent(target=static_handler.target, data=[{}]) @@ -111,20 +111,20 @@ which we can re-render and see what changed: Layout Dispatcher ----------------- -An :class:`~idom.core.dispatcher.AbstractDispatcher` implementation is a relatively thin layer -of logic around a :class:`~idom.core.layout.Layout` which drives the triggering of -events and layout updates by scheduling an asynchronous loop that will run forever - -effectively animating the model. To execute the loop, the dispatcher's -:meth:`~idom.core.dispatcher.AbstractDispatcher.run` method accepts two callbacks. One is a -"send" callback to which the dispatcher passes updates, while the other is "receive" -callback that's called by the dispatcher to events it should execute. +A "dispatcher" implementation is a relatively thin layer of logic around a +:class:`~idom.core.layout.Layout` which drives the triggering of events and updates by +scheduling an asynchronous loop that will run forever - effectively animating the model. +The simplest dispatcher is :func:`~idom.core.dispatcher.dispatch_single_view` which +accepts three arguments. The first is a :class:`~idom.core.layout.Layout`, the second is +a "send" callback to which the dispatcher passes updates, and the third is a "receive" +callback that's called by the dispatcher to collect events it should execute. .. testcode:: import asyncio - from idom.core import SingleViewDispatcher, EventHandler from idom.core.layout import LayoutEvent + from idom.core.dispatch import dispatch_single_view sent_patches = [] @@ -148,20 +148,14 @@ callback that's called by the dispatcher to events it should execute. return event - async with SingleViewDispatcher(idom.Layout(ClickCount())) as dispatcher: - context = None # see note below - await dispatcher.run(send, recv, context) - + await dispatch_single_view(idom.Layout(ClickCount()), send, recv) assert len(sent_patches) == 5 .. note:: - ``context`` is information that's specific to the - :class:`~idom.core.dispatcher.AbstractDispatcher` implementation. In the case of - the :class:`~idom.core.dispatcher.SingleViewDispatcher` it doesn't require any - context. On the other hand the :class:`~idom.core.dispatcher.SharedViewDispatcher` - requires a client ID as its piece of contextual information. + The :func:`~idom.core.dispatcher.create_shared_view_dispatcher`, while more complex + in its usage, allows multiple clients to share one synchronized view. Layout Server diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index dcfb650bc..0fe1c5974 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,7 +1,6 @@ typing-extensions >=3.7.4 mypy-extensions >=0.4.3 anyio >=2.0 -async_generator >=1.10; python_version<"3.7" async_exit_stack >=1.0.1; python_version<"3.7" jsonpatch >=1.26 typer >=0.3.2 diff --git a/src/idom/core/__init__.py b/src/idom/core/__init__.py index 269a4e7b1..e69de29bb 100644 --- a/src/idom/core/__init__.py +++ b/src/idom/core/__init__.py @@ -1,23 +0,0 @@ -from .component import AbstractComponent, Component, ComponentConstructor, component -from .dispatcher import AbstractDispatcher, SharedViewDispatcher, SingleViewDispatcher -from .events import EventHandler, Events, event -from .layout import Layout -from .vdom import vdom - - -__all__ = [ - "AbstractComponent", - "Layout", - "AbstractDispatcher", - "component", - "Component", - "EventHandler", - "ComponentConstructor", - "event", - "Events", - "hooks", - "Layout", - "vdom", - "SharedViewDispatcher", - "SingleViewDispatcher", -] diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py index d53251590..1704eb288 100644 --- a/src/idom/core/dispatcher.py +++ b/src/idom/core/dispatcher.py @@ -1,13 +1,23 @@ -import abc -import asyncio +from __future__ import annotations + +import sys +from asyncio import Future, Queue +from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait from logging import getLogger -from typing import Any, AsyncIterator, Awaitable, Callable, Dict +from typing import Any, AsyncIterator, Awaitable, Callable, List, Sequence, Tuple +from weakref import WeakSet from anyio import create_task_group -from anyio.abc import TaskGroup + +from idom.utils import Ref from .layout import Layout, LayoutEvent, LayoutUpdate -from .utils import HasAsyncResources, async_resource + + +if sys.version_info >= (3, 7): # pragma: no cover + from contextlib import asynccontextmanager # noqa +else: # pragma: no cover + from async_generator import asynccontextmanager logger = getLogger(__name__) @@ -16,136 +26,139 @@ RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] -class AbstractDispatcher(HasAsyncResources, abc.ABC): - """A base class for implementing :class:`~idom.core.layout.Layout` dispatchers.""" +async def dispatch_single_view( + layout: Layout, + send: SendCoroutine, + recv: RecvCoroutine, +) -> None: + with layout: + async with create_task_group() as task_group: + task_group.start_soon(_single_outgoing_loop, layout, send) + task_group.start_soon(_single_incoming_loop, layout, recv) - __slots__ = "_layout" - def __init__(self, layout: Layout) -> None: - super().__init__() - self._layout = layout +_SharedDispatchFuture = Callable[[SendCoroutine, RecvCoroutine], Future] - async def start(self) -> None: - await self.__aenter__() - async def stop(self) -> None: - await self.task_group.cancel_scope.cancel() - await self.__aexit__(None, None, None) +@asynccontextmanager +async def create_shared_view_dispatcher( + layout: Layout, run_forever: bool = False +) -> AsyncIterator[_SharedDispatchFuture]: + with layout: + ( + dispatch_shared_view, + model_state, + all_update_queues, + ) = await _make_shared_view_dispatcher(layout) - @async_resource - async def layout(self) -> AsyncIterator[Layout]: - async with self._layout as layout: - yield layout + dispatch_tasks: List[Future] = [] - @async_resource - async def task_group(self) -> AsyncIterator[TaskGroup]: - async with create_task_group() as group: - yield group + def dispatch_shared_view_soon( + send: SendCoroutine, recv: RecvCoroutine + ) -> Future: + future = ensure_future(dispatch_shared_view(send, recv)) + dispatch_tasks.append(future) + return future - async def run(self, send: SendCoroutine, recv: RecvCoroutine, context: Any) -> None: - """Start an unending loop which will drive the layout. + yield dispatch_shared_view_soon - This will call :meth:`AbstractLayout.render` and :meth:`Layout.dispatch` - to render new models and execute events respectively. - """ - await self.task_group.spawn(self._outgoing_loop, send, context) - await self.task_group.spawn(self._incoming_loop, recv, context) - return None + gathered_dispatch_tasks = gather(*dispatch_tasks, return_exceptions=True) - async def _outgoing_loop(self, send: SendCoroutine, context: Any) -> None: - try: - while True: - await send(await self._outgoing(self.layout, context)) - except Exception: - logger.info("Failed to send outgoing update", exc_info=True) - raise + while True: + ( + update_future, + dispatchers_completed_future, + ) = await _wait_until_first_complete( + layout.render(), + gathered_dispatch_tasks, + ) + + if dispatchers_completed_future.done(): + update_future.cancel() + break + else: + update: LayoutUpdate = update_future.result() + + model_state.current = update.apply_to(model_state.current) + # push updates to all dispatcher callbacks + for queue in all_update_queues: + queue.put_nowait(update) + + +def ensure_shared_view_dispatcher_future( + layout: Layout, +) -> Tuple[Future, _SharedDispatchFuture]: + dispatcher_future = Future() + + async def dispatch_shared_view_forever(): + with layout: + ( + dispatch_shared_view, + model_state, + all_update_queues, + ) = await _make_shared_view_dispatcher(layout) + + dispatcher_future.set_result(dispatch_shared_view) - async def _incoming_loop(self, recv: RecvCoroutine, context: Any) -> None: - try: while True: - await self._incoming(self.layout, context, await recv()) - except Exception: - logger.info("Failed to receive incoming event", exc_info=True) - raise - - @abc.abstractmethod - async def _outgoing(self, layout: Layout, context: Any) -> Any: - ... + update = await layout.render() + model_state.current = update.apply_to(model_state.current) + # push updates to all dispatcher callbacks + for queue in all_update_queues: + queue.put_nowait(update) - @abc.abstractmethod - async def _incoming(self, layout: Layout, context: Any, message: Any) -> None: - ... + async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: + await (await dispatcher_future)(send, recv) + return ensure_future(dispatch_shared_view_forever()), dispatch -class SingleViewDispatcher(AbstractDispatcher): - """Each client of the dispatcher will get its own model. - ..note:: - The ``context`` parameter of :meth:`SingleViewDispatcher.run` should just - be ``None`` since it's not used. - """ +_SharedDispatchCoroutine = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] - __slots__ = "_current_model_as_json" - def __init__(self, layout: Layout) -> None: - super().__init__(layout) - self._current_model_as_json = "" +async def _make_shared_view_dispatcher( + layout: Layout, +) -> Tuple[_SharedDispatchCoroutine, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: + initial_update = await layout.render() + model_state = Ref(initial_update.apply_to({})) - async def _outgoing(self, layout: Layout, context: Any) -> LayoutUpdate: - return await layout.render() + # We push updates to queues instead of pushing directly to send() callbacks in + # order to isolate the render loop from any errors dispatch callbacks might + # raise. + all_update_queues: WeakSet[Queue[LayoutUpdate]] = WeakSet() - async def _incoming(self, layout: Layout, context: Any, event: LayoutEvent) -> None: - await layout.dispatch(event) + async def dispatch_shared_view(send: SendCoroutine, recv: RecvCoroutine) -> None: + update_queue: Queue[LayoutUpdate] = Queue() + async with create_task_group() as inner_task_group: + all_update_queues.add(update_queue) + await send(LayoutUpdate.create_from({}, model_state.current)) + inner_task_group.start_soon(_single_incoming_loop, layout, recv) + inner_task_group.start_soon(_shared_outgoing_loop, send, update_queue) return None + return dispatch_shared_view, model_state, all_update_queues -class SharedViewDispatcher(SingleViewDispatcher): - """Each client of the dispatcher shares the same model. - The client's ID is indicated by the ``context`` argument of - :meth:`SharedViewDispatcher.run` - """ +async def _single_outgoing_loop(layout: Layout, send: SendCoroutine) -> None: + while True: + await send(await layout.render()) - __slots__ = "_update_queues", "_model_state" - def __init__(self, layout: Layout) -> None: - super().__init__(layout) - self._model_state: Any = {} - self._update_queues: Dict[str, asyncio.Queue[LayoutUpdate]] = {} +async def _single_incoming_loop(layout: Layout, recv: RecvCoroutine) -> None: + while True: + await layout.dispatch(await recv()) - @async_resource - async def task_group(self) -> AsyncIterator[TaskGroup]: - async with create_task_group() as group: - await group.spawn(self._render_loop) - yield group - async def run( - self, send: SendCoroutine, recv: RecvCoroutine, context: str, join: bool = False - ) -> None: - await super().run(send, recv, context) - if join: - await self._join_event.wait() +async def _shared_outgoing_loop( + send: SendCoroutine, queue: Queue[LayoutUpdate] +) -> None: + while True: + await send(await queue.get()) - async def _render_loop(self) -> None: - while True: - update = await super()._outgoing(self.layout, None) - self._model_state = update.apply_to(self._model_state) - # append updates to all other contexts - for queue in self._update_queues.values(): - await queue.put(update) - - async def _outgoing_loop(self, send: SendCoroutine, context: Any) -> None: - self._update_queues[context] = asyncio.Queue() - await send(LayoutUpdate.create_from({}, self._model_state)) - await super()._outgoing_loop(send, context) - - async def _outgoing(self, layout: Layout, context: str) -> LayoutUpdate: - return await self._update_queues[context].get() - - @async_resource - async def _join_event(self) -> AsyncIterator[asyncio.Event]: - event = asyncio.Event() - try: - yield event - finally: - event.set() + +async def _wait_until_first_complete( + *tasks: Awaitable[Any], +) -> Sequence[Future]: + futures = [ensure_future(t) for t in tasks] + await wait(futures, return_when=FIRST_COMPLETED) + return futures diff --git a/src/idom/core/events.py b/src/idom/core/events.py index c596bedbd..753a511e4 100644 --- a/src/idom/core/events.py +++ b/src/idom/core/events.py @@ -192,7 +192,7 @@ async def __call__(self, data: List[Any]) -> Any: if self._coro_handlers: async with create_task_group() as group: for handler in self._coro_handlers: - await group.spawn(handler, *data) + group.start_soon(handler, *data) for handler in self._func_handlers: handler(*data) diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 168f69ea4..781a82520 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -387,8 +387,8 @@ class LifeCycleHook: def __init__( self, - component: AbstractComponent, layout: idom.core.layout.Layout, + component: AbstractComponent, ) -> None: self.component = component self._layout = weakref.ref(layout) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index 4873d6796..c7e4169af 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -5,17 +5,7 @@ from collections import Counter from functools import wraps from logging import getLogger -from typing import ( - Any, - AsyncIterator, - Dict, - Iterator, - List, - NamedTuple, - Optional, - Set, - Tuple, -) +from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple, TypeVar from weakref import ref from jsonpatch import apply_patch, make_patch @@ -26,7 +16,7 @@ from .component import AbstractComponent from .events import EventHandler from .hooks import LifeCycleHook -from .utils import CannotAccessResource, HasAsyncResources, async_resource, hex_id +from .utils import hex_id from .vdom import validate_vdom @@ -57,9 +47,17 @@ class LayoutEvent(NamedTuple): """A list of event data passed to the event handler.""" -class Layout(HasAsyncResources): +_Self = TypeVar("_Self") - __slots__ = ["root", "_event_handlers"] + +class Layout: + + __slots__ = [ + "root", + "_event_handlers", + "_rendering_queue", + "_model_state_by_component_id", + ] if not hasattr(abc.ABC, "__weakref__"): # pragma: no cover __slots__.append("__weakref__") @@ -69,13 +67,31 @@ def __init__(self, root: "AbstractComponent") -> None: if not isinstance(root, AbstractComponent): raise TypeError("Expected an AbstractComponent, not %r" % root) self.root = root + + def __enter__(self: _Self) -> _Self: + # create attributes here to avoid access before entering context manager self._event_handlers: Dict[str, EventHandler] = {} + self._rendering_queue = _ComponentQueue() + self._model_state_by_component_id: Dict[int, _ModelState] = { + id(self.root): _ModelState(None, -1, "", LifeCycleHook(self, self.root)) + } + self._rendering_queue.put(self.root) + return self + + def __exit__(self, *exc: Any) -> None: + root_state = self._model_state_by_component_id[id(self.root)] + self._unmount_model_states([root_state]) + + # delete attributes here to avoid access after exiting context manager + del self._event_handlers + del self._rendering_queue + del self._model_state_by_component_id + + return None def update(self, component: "AbstractComponent") -> None: - try: - self._rendering_queue.put(component) - except CannotAccessResource: - logger.info(f"Did not update {component} - resources of {self} are closed") + self._rendering_queue.put(component) + return None async def dispatch(self, event: LayoutEvent) -> None: # It is possible for an element in the frontend to produce an event @@ -83,6 +99,7 @@ async def dispatch(self, event: LayoutEvent) -> None: # events if the element and the handler exist in the backend. Otherwise # we just ignore the event. handler = self._event_handlers.get(event.target) + if handler is not None: await handler(event.data) else: @@ -92,9 +109,7 @@ async def dispatch(self, event: LayoutEvent) -> None: async def render(self) -> LayoutUpdate: while True: - component = await self._rendering_queue.get() - if id(component) in self._model_state_by_component_id: - return self._create_layout_update(component) + return self._create_layout_update(await self._rendering_queue.get()) if IDOM_DEBUG_MODE.get(): # If in debug mode inject a function that ensures all returned updates @@ -110,20 +125,6 @@ async def render(self) -> LayoutUpdate: validate_vdom(self._model_state_by_component_id[id(self.root)].model) return result - @async_resource - async def _rendering_queue(self) -> AsyncIterator[_ComponentQueue]: - queue = _ComponentQueue() - queue.put(self.root) - yield queue - - @async_resource - async def _model_state_by_component_id( - self, - ) -> AsyncIterator[Dict[int, _ModelState]]: - root_state = _ModelState(None, -1, "", LifeCycleHook(self.root, self)) - yield {id(self.root): root_state} - self._unmount_model_states([root_state]) - def _create_layout_update(self, component: AbstractComponent) -> LayoutUpdate: old_state = self._model_state_by_component_id[id(component)] new_state = old_state.new(None, component) @@ -312,7 +313,7 @@ def _render_model_children( if old_child_state is not None: new_child_state = old_child_state.new(new_state, child) else: - hook = LifeCycleHook(child, self) + hook = LifeCycleHook(self, child) new_child_state = _ModelState(new_state, index, key, hook) self._render_component(old_child_state, new_child_state, child) else: @@ -331,7 +332,7 @@ def _render_model_children_without_old_state( new_children.append(child_state.model) new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: - life_cycle_hook = LifeCycleHook(child, self) + life_cycle_hook = LifeCycleHook(self, child) child_state = _ModelState(new_state, index, key, life_cycle_hook) self._render_component(None, child_state, child) else: diff --git a/src/idom/core/utils.py b/src/idom/core/utils.py index 09e05c5c6..49530cc25 100644 --- a/src/idom/core/utils.py +++ b/src/idom/core/utils.py @@ -1,124 +1,5 @@ -import sys -from typing import ( - Any, - AsyncIterator, - Callable, - Dict, - Generic, - Optional, - Tuple, - Type, - TypeVar, - Union, - cast, - overload, -) - - -if sys.version_info >= (3, 7): # pragma: no cover - from contextlib import AsyncExitStack, asynccontextmanager # noqa -else: # pragma: no cover - from async_exit_stack import AsyncExitStack - from async_generator import asynccontextmanager +from typing import Any def hex_id(obj: Any) -> str: return format(id(obj), "x") - - -_Rsrc = TypeVar("_Rsrc") -_Self = TypeVar("_Self", bound="HasAsyncResources") - - -def async_resource( - method: Callable[[Any], AsyncIterator[_Rsrc]] -) -> "AsyncResource[_Rsrc]": - """A decorator for creating an :class:`AsyncResource`""" - return AsyncResource(method) - - -class CannotAccessResource(RuntimeError): - """When a resource of :class:`HasAsyncResources` object is incorrectly accessed""" - - -class HasAsyncResources: - - _async_resource_names: Tuple[str, ...] = () - __slots__ = "_async_resource_state", "_async_exit_stack" - - def __init__(self) -> None: - self._async_resource_state: Dict[str, Any] = {} - self._async_exit_stack: Optional[AsyncExitStack] = None - - def __init_subclass__(cls: Type["HasAsyncResources"]) -> None: - for k, v in list(cls.__dict__.items()): - if isinstance(v, AsyncResource) and k not in cls._async_resource_names: - cls._async_resource_names += (k,) - return None - - async def __aenter__(self: _Self) -> _Self: - if self._async_exit_stack is not None: - raise CannotAccessResource(f"{self} is already open") - - self._async_exit_stack = await AsyncExitStack().__aenter__() - - for rsrc_name in self._async_resource_names: - rsrc: AsyncResource[Any] = getattr(type(self), rsrc_name) - await self._async_exit_stack.enter_async_context(rsrc.context(self)) - - return self - - async def __aexit__(self, *exc: Any) -> bool: - if self._async_exit_stack is None: - raise CannotAccessResource(f"{self} is not open") - - result = await self._async_exit_stack.__aexit__(*exc) - self._async_exit_stack = None - return result - - -class AsyncResource(Generic[_Rsrc]): - - __slots__ = "_context_manager", "_name" - - def __init__( - self, - method: Callable[[Any], AsyncIterator[_Rsrc]], - ) -> None: - self._context_manager = asynccontextmanager(method) - - @asynccontextmanager - async def context(self, obj: HasAsyncResources) -> AsyncIterator[None]: - try: - async with self._context_manager(obj) as value: - obj._async_resource_state[self._name] = value - yield None - finally: - if self._name in obj._async_resource_state: - del obj._async_resource_state[self._name] - - def __set_name__(self, cls: Type[HasAsyncResources], name: str) -> None: - self._name = name - - @overload - def __get__( - self, obj: None, cls: Type[HasAsyncResources] - ) -> "AsyncResource[_Rsrc]": - ... - - @overload - def __get__(self, obj: HasAsyncResources, cls: Type[HasAsyncResources]) -> _Rsrc: - ... - - def __get__( - self, obj: Optional[HasAsyncResources], cls: Type[HasAsyncResources] - ) -> Union[_Rsrc, "AsyncResource[_Rsrc]"]: - if obj is None: - return self - else: - try: - return cast(_Rsrc, obj._async_resource_state[self._name]) - except KeyError: - raise CannotAccessResource( - f"Resource {self._name!r} of {obj} is not open" - ) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 5f6cba343..c7548576d 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -1,10 +1,11 @@ +import asyncio import json import logging import sys import time -import uuid +from asyncio.futures import Future from threading import Event, Thread -from typing import Any, Dict, Optional, Tuple, Type, Union, cast +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union from fastapi import APIRouter, FastAPI, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware @@ -19,11 +20,10 @@ from idom.config import IDOM_CLIENT_BUILD_DIR from idom.core.dispatcher import ( - AbstractDispatcher, RecvCoroutine, SendCoroutine, - SharedViewDispatcher, - SingleViewDispatcher, + dispatch_single_view, + ensure_shared_view_dispatcher_future, ) from idom.core.layout import Layout, LayoutEvent, LayoutUpdate @@ -45,7 +45,6 @@ class Config(TypedDict, total=False): class FastApiRenderServer(AbstractRenderServer[FastAPI, Config]): """Base ``sanic`` extension.""" - _dispatcher_type: Type[AbstractDispatcher] _server: UvicornServer def stop(self, timeout: float = 3) -> None: @@ -163,30 +162,28 @@ def _run_application_in_thread( # uvicorn does the event loop setup for us self._run_application(config, app, host, port, args, kwargs) + +class PerClientStateServer(FastApiRenderServer): + """Each client view will have its own state.""" + async def _run_dispatcher( self, send: SendCoroutine, recv: RecvCoroutine, params: Dict[str, Any], ) -> None: - async with self._make_dispatcher(params) as dispatcher: - await dispatcher.run(send, recv, None) - - def _make_dispatcher(self, params: Dict[str, Any]) -> AbstractDispatcher: - return self._dispatcher_type(Layout(self._root_component_constructor(**params))) - - -class PerClientStateServer(FastApiRenderServer): - """Each client view will have its own state.""" - - _dispatcher_type = SingleViewDispatcher + await dispatch_single_view( + Layout(self._root_component_constructor(**params)), + send, + recv, + ) class SharedClientStateServer(FastApiRenderServer): """All connected client views will have shared state.""" - _dispatcher_type = SharedViewDispatcher - _dispatcher: SharedViewDispatcher + _dispatch_daemon_future: Future + _dispatch_coroutine: Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] def _setup_application(self, config: Config, app: FastAPI) -> None: app.on_event("startup")(self._activate_dispatcher) @@ -194,12 +191,17 @@ def _setup_application(self, config: Config, app: FastAPI) -> None: super()._setup_application(config, app) async def _activate_dispatcher(self) -> None: - self._dispatcher = cast(SharedViewDispatcher, self._make_dispatcher({})) - await self._dispatcher.start() + ( + self._dispatch_daemon_future, + self._dispatch_coroutine, + ) = ensure_shared_view_dispatcher_future( + Layout(self._root_component_constructor()) + ) async def _deactivate_dispatcher(self) -> None: # pragma: no cover - # this doesn't seem to get triggered during testing for some reason - await self._dispatcher.stop() + # for some reason this isn't getting run during testing + self._dispatch_daemon_future.cancel(f"{self} is shutting down") + await asyncio.wait([self._dispatch_daemon_future]) async def _run_dispatcher( self, @@ -210,7 +212,7 @@ async def _run_dispatcher( if params: msg = f"SharedClientState server does not support per-client view parameters {params}" raise ValueError(msg) - await self._dispatcher.run(send, recv, uuid.uuid4().hex, join=True) + await self._dispatch_coroutine(send, recv) def _run_uvicorn_server(server: UvicornServer) -> None: diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index b90e277ca..75c54f7b3 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -5,7 +5,7 @@ from queue import Queue as ThreadQueue from threading import Event as ThreadEvent from threading import Thread -from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Type, Union, cast +from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Union, cast from urllib.parse import parse_qs as parse_query_string from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for @@ -18,8 +18,9 @@ import idom from idom.config import IDOM_CLIENT_BUILD_DIR -from idom.core.dispatcher import AbstractDispatcher, SingleViewDispatcher -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.component import AbstractComponent +from idom.core.dispatcher import RecvCoroutine, SendCoroutine, dispatch_single_view +from idom.core.layout import LayoutEvent, LayoutUpdate from .base import AbstractRenderServer @@ -40,7 +41,6 @@ class Config(TypedDict, total=False): class FlaskRenderServer(AbstractRenderServer[Flask, Config]): """Base class for render servers which use Flask""" - _dispatcher_type: Type[AbstractDispatcher] _wsgi_server: pywsgi.WSGIServer def stop(self, timeout: Optional[float] = None) -> None: @@ -98,14 +98,7 @@ def recv() -> Optional[LayoutEvent]: for k, v in parse_query_string(ws.environ["QUERY_STRING"]).items() } - run_dispatcher_in_thread( - lambda: self._dispatcher_type( - Layout(self._root_component_constructor(**query_params)) - ), - send, - recv, - None, - ) + self._run_dispatcher(query_params, send, recv) def _setup_blueprint_routes(self, config: Config, blueprint: Blueprint) -> None: if config["serve_static_files"]: @@ -177,18 +170,25 @@ def _generic_run_application( ) self._wsgi_server.serve_forever() + def _run_dispatcher( + self, query_params: Dict[str, Any], send: SendCoroutine, recv: RecvCoroutine + ): + raise NotImplementedError() + class PerClientStateServer(FlaskRenderServer): """Each client view will have its own state.""" - _dispatcher_type = SingleViewDispatcher + def _run_dispatcher( + self, query_params: Dict[str, Any], send: SendCoroutine, recv: RecvCoroutine + ): + dispatch_single_view_in_thread( + self._root_component_constructor(**query_params), send, recv + ) -def run_dispatcher_in_thread( - make_dispatcher: Callable[[], AbstractDispatcher], - send: Callable[[Any], None], - recv: Callable[[], Optional[LayoutEvent]], - context: Optional[Any], +def dispatch_single_view_in_thread( + component: AbstractComponent, send: SendCoroutine, recv: RecvCoroutine ) -> None: dispatch_thread_info_created = ThreadEvent() dispatch_thread_info_ref: idom.Ref[Optional[_DispatcherThreadInfo]] = idom.Ref(None) @@ -207,8 +207,7 @@ async def recv_coro() -> Any: return await async_recv_queue.get() async def main() -> None: - async with make_dispatcher() as dispatcher: - await dispatcher.run(send_coro, recv_coro, context) + await dispatch_single_view(idom.Layout(component), send_coro, recv_coro) main_future = asyncio.ensure_future(main()) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index bb5337744..fb799c076 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -1,8 +1,7 @@ import asyncio import json -import uuid from threading import Event -from typing import Any, Dict, Optional, Tuple, Type, Union, cast +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response @@ -11,11 +10,10 @@ from idom.config import IDOM_CLIENT_BUILD_DIR from idom.core.dispatcher import ( - AbstractDispatcher, RecvCoroutine, SendCoroutine, - SharedViewDispatcher, - SingleViewDispatcher, + dispatch_single_view, + ensure_shared_view_dispatcher_future, ) from idom.core.layout import Layout, LayoutEvent, LayoutUpdate @@ -35,7 +33,6 @@ class SanicRenderServer(AbstractRenderServer[Sanic, Config]): """Base ``sanic`` extension.""" _loop: asyncio.AbstractEventLoop - _dispatcher_type: Type[AbstractDispatcher] _did_stop: Event def stop(self) -> None: @@ -165,30 +162,28 @@ def _run_application_in_thread( connection.close_if_idle() server.after_stop() + +class PerClientStateServer(SanicRenderServer): + """Each client view will have its own state.""" + async def _run_dispatcher( self, send: SendCoroutine, recv: RecvCoroutine, params: Dict[str, Any], ) -> None: - async with self._make_dispatcher(params) as dispatcher: - await dispatcher.run(send, recv, None) - - def _make_dispatcher(self, params: Dict[str, Any]) -> AbstractDispatcher: - return self._dispatcher_type(Layout(self._root_component_constructor(**params))) - - -class PerClientStateServer(SanicRenderServer): - """Each client view will have its own state.""" - - _dispatcher_type = SingleViewDispatcher + await dispatch_single_view( + Layout(self._root_component_constructor(**params)), + send, + recv, + ) class SharedClientStateServer(SanicRenderServer): """All connected client views will have shared state.""" - _dispatcher_type = SharedViewDispatcher - _dispatcher: SharedViewDispatcher + _dispatch_daemon_future: asyncio.Future + _dispatch_coroutine: Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] def _setup_application(self, config: Config, app: Sanic) -> None: app.register_listener(self._activate_dispatcher, "before_server_start") @@ -198,14 +193,18 @@ def _setup_application(self, config: Config, app: Sanic) -> None: async def _activate_dispatcher( self, app: Sanic, loop: asyncio.AbstractEventLoop ) -> None: - self._dispatcher = cast(SharedViewDispatcher, self._make_dispatcher({})) - await self._dispatcher.start() + ( + self._dispatch_daemon_future, + self._dispatch_coroutine, + ) = ensure_shared_view_dispatcher_future( + Layout(self._root_component_constructor()) + ) async def _deactivate_dispatcher( self, app: Sanic, loop: asyncio.AbstractEventLoop - ) -> None: # pragma: no cover - # this doesn't seem to get triggered during testing for some reason - await self._dispatcher.stop() + ) -> None: + self._dispatch_daemon_future.cancel(f"{self} is shutting down") + await asyncio.wait([self._dispatch_daemon_future]) async def _run_dispatcher( self, @@ -216,4 +215,4 @@ async def _run_dispatcher( if params: msg = f"SharedClientState server does not support per-client view parameters {params}" raise ValueError(msg) - await self._dispatcher.run(send, recv, uuid.uuid4().hex, join=True) + await self._dispatch_coroutine(send, recv) diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 652fe34aa..d630de4f4 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -1,6 +1,7 @@ import asyncio import json from asyncio import Queue as AsyncQueue +from asyncio.futures import Future from threading import Event as ThreadEvent from typing import Any, Dict, List, Optional, Tuple, Type, Union from urllib.parse import urljoin @@ -12,7 +13,7 @@ from idom.config import IDOM_CLIENT_BUILD_DIR from idom.core.component import ComponentConstructor -from idom.core.dispatcher import AbstractDispatcher, SingleViewDispatcher +from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent, LayoutUpdate from .base import AbstractRenderServer @@ -128,8 +129,7 @@ def _run_application_in_thread( class PerClientStateModelStreamHandler(WebSocketHandler): """A web-socket handler that serves up a new model stream to each new client""" - _dispatcher_type: Type[AbstractDispatcher] = SingleViewDispatcher - _dispatcher_inst: AbstractDispatcher + _dispatch_future: Future _message_queue: "AsyncQueue[str]" def initialize(self, component_constructor: ComponentConstructor) -> None: @@ -138,9 +138,6 @@ def initialize(self, component_constructor: ComponentConstructor) -> None: async def open(self, *args: str, **kwargs: str) -> None: message_queue: "AsyncQueue[str]" = AsyncQueue() query_params = {k: v[0].decode() for k, v in self.request.arguments.items()} - dispatcher = self._dispatcher_type( - Layout(self._component_constructor(**query_params)) - ) async def send(value: LayoutUpdate) -> None: await self.write_message(json.dumps(value)) @@ -148,14 +145,14 @@ async def send(value: LayoutUpdate) -> None: async def recv() -> LayoutEvent: return LayoutEvent(**json.loads(await message_queue.get())) - async def run() -> None: - await dispatcher.__aenter__() - await dispatcher.run(send, recv, None) - - asyncio.ensure_future(run()) - - self._dispatcher_inst = dispatcher self._message_queue = message_queue + self._dispatch_future = asyncio.ensure_future( + dispatch_single_view( + Layout(self._component_constructor(**query_params)), + send, + recv, + ) + ) async def on_message(self, message: Union[str, bytes]) -> None: await self._message_queue.put( @@ -163,7 +160,8 @@ async def on_message(self, message: Union[str, bytes]) -> None: ) def on_close(self) -> None: - asyncio.ensure_future(self._dispatcher_inst.__aexit__(None, None, None)) + if not self._dispatch_future.done(): + self._dispatch_future.cancel() class PerClientStateServer(TornadoRenderServer): diff --git a/src/idom/testing.py b/src/idom/testing.py index c800f560c..7aac98f80 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -132,7 +132,7 @@ def list_logged_exceptions( found: List[BaseException] = [] compiled_pattern = re.compile(pattern) for index, record in enumerate(self.log_records): - if record.levelno >= log_level and record.exc_info is not None: + if record.levelno >= log_level and record.exc_info: error = record.exc_info[1] if ( error is not None diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index 3858336d4..c76cd3120 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -1,172 +1,124 @@ import asyncio +from typing import Any, Sequence import pytest -from anyio import ExceptionGroup import idom from idom.core.dispatcher import ( - AbstractDispatcher, - SharedViewDispatcher, - SingleViewDispatcher, + create_shared_view_dispatcher, + dispatch_single_view, + ensure_shared_view_dispatcher_future, ) -from idom.core.layout import Layout, LayoutEvent +from idom.core.layout import Layout, LayoutEvent, LayoutUpdate from idom.testing import StaticEventHandler -from tests.general_utils import assert_same_items -async def test_shared_state_dispatcher(): - done = asyncio.Event() - changes_1 = [] - changes_2 = [] +EVENT_NAME = "onEvent" +EVENT_HANDLER = StaticEventHandler() - event_name = "onEvent" - event_handler = StaticEventHandler() - events_to_inject = [LayoutEvent(event_handler.target, [])] * 4 +def make_send_recv_callbacks(events_to_inject): + changes = [] - async def send_1(patch): - changes_1.append(patch.changes) - - async def recv_1(): - # Need this to yield control back to event loop otherwise we block indefinitely - # for some reason. Realistically this await would be on some client event, so - # this isn't too contrived. - await asyncio.sleep(0) - try: - return events_to_inject.pop(0) - except IndexError: - done.set() - raise asyncio.CancelledError() - - async def send_2(patch): - changes_2.append(patch.changes) - - async def recv_2(): - await done.wait() - raise asyncio.CancelledError() - - @idom.component - def Clickable(): - count, set_count = idom.hooks.use_state(0) - handler = event_handler.use(lambda: set_count(count + 1)) - return idom.html.div({event_name: handler, "count": count}) - - async with SharedViewDispatcher(Layout(Clickable())) as dispatcher: - await dispatcher.run(send_1, recv_1, "1") - await dispatcher.run(send_2, recv_2, "2") - - expected_changes = [ - [ - { - "op": "add", - "path": "/eventHandlers", - "value": { - event_name: { - "target": event_handler.target, - "preventDefault": False, - "stopPropagation": False, - } - }, - }, - {"op": "add", "path": "/attributes", "value": {"count": 0}}, - {"op": "add", "path": "/tagName", "value": "div"}, - ], - [{"op": "replace", "path": "/attributes/count", "value": 1}], - [{"op": "replace", "path": "/attributes/count", "value": 2}], - [{"op": "replace", "path": "/attributes/count", "value": 3}], - ] - - for c_2, expected_c in zip(changes_2, expected_changes): - assert_same_items(c_2, expected_c) - - assert changes_1 == changes_2 - - -async def test_dispatcher_run_does_not_supress_non_cancel_errors(): - class DispatcherWithBug(AbstractDispatcher): - async def _outgoing(self, layout, context): - raise ValueError("this is a bug") - - async def _incoming(self, layout, context, message): - raise ValueError("this is a bug") - - @idom.component - def AnyComponent(): - return idom.html.div() - - async def send(data): - pass - - async def recv(): - return {} - - with pytest.raises(ExceptionGroup, match="this is a bug"): - async with DispatcherWithBug(idom.Layout(AnyComponent())) as dispatcher: - await dispatcher.run(send, recv, None) - - -async def test_dispatcher_run_does_not_supress_errors(): - class DispatcherWithBug(AbstractDispatcher): - async def _outgoing(self, layout, context): - raise ValueError("this is a bug") - - async def _incoming(self, layout, context, message): - raise ValueError("this is a bug") - - @idom.component - def AnyComponent(): - return idom.html.div() - - async def send(data): - pass - - async def recv(): - return {} - - with pytest.raises(ExceptionGroup, match="this is a bug"): - async with DispatcherWithBug(idom.Layout(AnyComponent())) as dispatcher: - await dispatcher.run(send, recv, None) - - -async def test_dispatcher_start_stop(): - cancelled_recv = False - cancelled_send = False + # We need a semaphor here to simulate recieving an event after each update is sent. + # The effect is that the send() and recv() callbacks trade off control. If we did + # not do this, it would easy to determine when to halt because, while we might have + # received all the events, they might not have been sent since the two callbacks are + # executed in separate loops. + sem = asyncio.Semaphore(0) async def send(patch): - nonlocal cancelled_send - try: - await asyncio.sleep(100) - except asyncio.CancelledError: - cancelled_send = True - raise - else: - assert False, "this should never be reached" + changes.append(patch) + sem.release() + if not events_to_inject: + raise asyncio.CancelledError() async def recv(): - nonlocal cancelled_recv + await sem.acquire() try: - await asyncio.sleep(100) - except asyncio.CancelledError: - cancelled_recv = True - raise - else: - assert False, "this should never be reached" - - @idom.component - def AnyComponent(): - return idom.html.div() - - dispatcher = SingleViewDispatcher(Layout(AnyComponent())) - - await dispatcher.start() - - await dispatcher.run(send, recv, None) - - # let it run until it hits the sleeping recv/send calls - for i in range(10): - await asyncio.sleep(0) - - await dispatcher.stop() - - assert cancelled_recv - assert cancelled_send + return events_to_inject.pop(0) + except IndexError: + # wait forever + await asyncio.Event().wait() + + return changes, send, recv + + +def make_events_and_expected_model(): + events = [LayoutEvent(EVENT_HANDLER.target, [])] * 4 + expected_model = { + "tagName": "div", + "attributes": {"count": 4}, + "eventHandlers": { + EVENT_NAME: { + "target": EVENT_HANDLER.target, + "preventDefault": False, + "stopPropagation": False, + } + }, + } + return events, expected_model + + +def assert_changes_produce_expected_model( + changes: Sequence[LayoutUpdate], + expected_model: Any, +) -> None: + model_from_changes = {} + for update in changes: + model_from_changes = update.apply_to(model_from_changes) + assert model_from_changes == expected_model + + +@idom.component +def Counter(): + count, change_count = idom.hooks.use_reducer( + (lambda old_count, diff: old_count + diff), + initial_value=0, + ) + handler = EVENT_HANDLER.use(lambda: change_count(1)) + return idom.html.div({EVENT_NAME: handler, "count": count}) + + +async def test_dispatch_single_view(): + events, expected_model = make_events_and_expected_model() + changes, send, recv = make_send_recv_callbacks(events) + await dispatch_single_view(Layout(Counter()), send, recv) + assert_changes_produce_expected_model(changes, expected_model) + + +async def test_create_shared_state_dispatcher(): + events, model = make_events_and_expected_model() + changes_1, send_1, recv_1 = make_send_recv_callbacks(events) + changes_2, send_2, recv_2 = make_send_recv_callbacks(events) + + async with create_shared_view_dispatcher(Layout(Counter())) as dispatcher: + dispatcher(send_1, recv_1) + dispatcher(send_2, recv_2) + + assert_changes_produce_expected_model(changes_1, model) + assert_changes_produce_expected_model(changes_2, model) + + +async def test_ensure_shared_view_dispatcher_future(): + events, model = make_events_and_expected_model() + changes_1, send_1, recv_1 = make_send_recv_callbacks(events) + changes_2, send_2, recv_2 = make_send_recv_callbacks(events) + + dispatch_future, dispatch = ensure_shared_view_dispatcher_future(Layout(Counter())) + + await asyncio.gather( + dispatch(send_1, recv_1), + dispatch(send_2, recv_2), + return_exceptions=True, + ) + + # the dispatch future should run forever, until cancelled + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(dispatch_future, timeout=1) + + dispatch_future.cancel() + await asyncio.gather(dispatch_future, return_exceptions=True) + + assert_changes_produce_expected_model(changes_1, model) + assert_changes_produce_expected_model(changes_2, model) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index bcce896ef..2bbd4aacf 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -17,7 +17,7 @@ def SimpleComponentWithHook(): with pytest.raises(RuntimeError, match="No life cycle hook is active"): await SimpleComponentWithHook().render() - async with idom.Layout(SimpleComponentWithHook()) as layout: + with idom.Layout(SimpleComponentWithHook()) as layout: await layout.render() @@ -30,7 +30,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() - async with idom.Layout(sse) as layout: + with idom.Layout(sse) as layout: patch_1 = await layout.render() assert patch_1.path == "" assert_same_items( @@ -66,7 +66,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() - async with idom.Layout(sse) as layout: + with idom.Layout(sse) as layout: await layout.render() await layout.render() await layout.render() @@ -313,7 +313,7 @@ def CheckNoEffectYet(): effect_triggers_after_final_render.current = not effect_triggered.current return idom.html.div() - async with idom.Layout(OuterComponent()) as layout: + with idom.Layout(OuterComponent()) as layout: await layout.render() assert effect_triggered.current @@ -341,7 +341,7 @@ def cleanup(): return idom.html.div() - async with idom.Layout(ComponentWithEffect()) as layout: + with idom.Layout(ComponentWithEffect()) as layout: await layout.render() assert not cleanup_triggered.current @@ -380,7 +380,7 @@ def cleanup(): return idom.html.div() - async with idom.Layout(OuterComponent()) as layout: + with idom.Layout(OuterComponent()) as layout: await layout.render() assert not cleanup_triggered.current @@ -411,7 +411,7 @@ def effect(): return idom.html.div() - async with idom.Layout(ComponentWithMemoizedEffect()) as layout: + with idom.Layout(ComponentWithMemoizedEffect()) as layout: await layout.render() assert effect_run_count.current == 1 @@ -454,7 +454,7 @@ def cleanup(): return idom.html.div() - async with idom.Layout(ComponentWithEffect()) as layout: + with idom.Layout(ComponentWithEffect()) as layout: await layout.render() assert cleanup_trigger_count.current == 0 @@ -481,7 +481,7 @@ async def effect(): return idom.html.div() - async with idom.Layout(ComponentWithAsyncEffect()) as layout: + with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() await effect_ran.wait() @@ -501,7 +501,7 @@ async def effect(): return idom.html.div() - async with idom.Layout(ComponentWithAsyncEffect()) as layout: + with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() await effect_ran.wait() @@ -533,7 +533,7 @@ async def effect(): return idom.html.div() - async with idom.Layout(ComponentWithLongWaitingEffect()) as layout: + with idom.Layout(ComponentWithLongWaitingEffect()) as layout: await layout.render() await effect_ran.wait() @@ -559,7 +559,7 @@ def bad_effect(): return idom.html.div() - async with idom.Layout(ComponentWithEffect()) as layout: + with idom.Layout(ComponentWithEffect()) as layout: await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] @@ -582,7 +582,7 @@ def bad_cleanup(): return idom.html.div() - async with idom.Layout(ComponentWithEffect()) as layout: + with idom.Layout(ComponentWithEffect()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() # no error @@ -610,7 +610,7 @@ def bad_cleanup(): return idom.html.div() - async with idom.Layout(OuterComponent()) as layout: + with idom.Layout(OuterComponent()) as layout: await layout.render() outer_component_hook.latest.schedule_render() await layout.render() # no error @@ -638,7 +638,7 @@ def Counter(initial_count): ) return idom.html.div() - async with idom.Layout(Counter(0)) as layout: + with idom.Layout(Counter(0)) as layout: await layout.render() assert saved_count.current == 0 @@ -668,7 +668,7 @@ def ComponentWithUseReduce(): saved_dispatchers.append(idom.hooks.use_reducer(reducer, 0)[1]) return idom.html.div() - async with idom.Layout(ComponentWithUseReduce()) as layout: + with idom.Layout(ComponentWithUseReduce()) as layout: for _ in range(3): await layout.render() saved_dispatchers[-1]("increment") @@ -688,7 +688,7 @@ def ComponentWithRef(): used_callbacks.append(idom.hooks.use_callback(lambda: None)) return idom.html.div() - async with idom.Layout(ComponentWithRef()) as layout: + with idom.Layout(ComponentWithRef()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -714,7 +714,7 @@ def cb(): used_callbacks.append(cb) return idom.html.div() - async with idom.Layout(ComponentWithRef()) as layout: + with idom.Layout(ComponentWithRef()) as layout: await layout.render() set_state_hook.current(1) await layout.render() @@ -742,7 +742,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - async with idom.Layout(ComponentWithMemo()) as layout: + with idom.Layout(ComponentWithMemo()) as layout: await layout.render() set_state_hook.current(1) await layout.render() @@ -767,7 +767,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - async with idom.Layout(ComponentWithMemo()) as layout: + with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -794,7 +794,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - async with idom.Layout(ComponentWithMemo()) as layout: + with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() args_used_in_memo.current = None @@ -819,7 +819,7 @@ def ComponentWithMemo(): used_values.append(value) return idom.html.div() - async with idom.Layout(ComponentWithMemo()) as layout: + with idom.Layout(ComponentWithMemo()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() @@ -839,7 +839,7 @@ def ComponentWithRef(): used_refs.append(idom.hooks.use_ref(1)) return idom.html.div() - async with idom.Layout(ComponentWithRef()) as layout: + with idom.Layout(ComponentWithRef()) as layout: await layout.render() component_hook.latest.schedule_render() await layout.render() diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index d61152756..e50104fef 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -33,19 +33,22 @@ def test_layout_expects_abstract_component(): idom.Layout(idom.html.div()) -def test_not_open_layout_update_logs_error(caplog): +async def test_layout_cannot_be_used_outside_context_manager(caplog): @idom.component def Component(): ... component = Component() layout = idom.Layout(component) - layout.update(component) - assert re.match( - "Did not update .*? - resources of .*? are closed", - next(iter(caplog.records)).msg, - ) + with pytest.raises(Exception): + await layout.dispatch(LayoutEvent("something", [])) + + with pytest.raises(Exception): + layout.update(component) + + with pytest.raises(Exception): + await layout.render() async def test_simple_layout(): @@ -56,7 +59,7 @@ def SimpleComponent(): tag, set_state_hook.current = idom.hooks.use_state("div") return idom.vdom(tag) - async with idom.Layout(SimpleComponent()) as layout: + with idom.Layout(SimpleComponent()) as layout: path, changes = await layout.render() assert path == "" @@ -83,7 +86,7 @@ def Child(key): state, child_set_state.current = idom.hooks.use_state(0) return idom.html.div(state) - async with idom.Layout(Parent()) as layout: + with idom.Layout(Parent()) as layout: path, changes = await layout.render() @@ -126,7 +129,7 @@ def OkChild(): def BadChild(): raise ValueError("Something went wrong :(") - async with idom.Layout(Main()) as layout: + with idom.Layout(Main()) as layout: patch = await layout.render() assert_same_items( patch.changes, @@ -157,7 +160,7 @@ def Main(): def Child(): return {"tagName": "div", "children": {"tagName": "h1"}} - async with idom.Layout(Main()) as layout: + with idom.Layout(Main()) as layout: patch = await layout.render() assert_same_items( patch.changes, @@ -191,7 +194,7 @@ def Inner(): finalize(component, live_components.discard, id(component)) return idom.html.div() - async with idom.Layout(Outer()) as layout: + with idom.Layout(Outer()) as layout: await layout.render() assert len(live_components) == 2 @@ -225,7 +228,7 @@ def AnyComponent(): run_count.current += 1 return idom.html.div() - async with idom.Layout(AnyComponent()) as layout: + with idom.Layout(AnyComponent()) as layout: await layout.render() assert run_count.current == 1 @@ -257,7 +260,7 @@ def Parent(): def Child(): return idom.html.div() - async with idom.Layout(Parent()) as layout: + with idom.Layout(Parent()) as layout: await layout.render() hook.latest.schedule_render() @@ -271,7 +274,7 @@ async def test_log_on_dispatch_to_missing_event_handler(caplog): def SomeComponent(): return idom.html.div() - async with idom.Layout(SomeComponent()) as layout: + with idom.Layout(SomeComponent()) as layout: await layout.dispatch(LayoutEvent(target="missing", data=[])) assert re.match( @@ -315,7 +318,7 @@ def bad_trigger(): return idom.html.div(children) - async with idom.Layout(MyComponent()) as layout: + with idom.Layout(MyComponent()) as layout: await layout.render() for i in range(3): event = LayoutEvent(good_handler.target, []) @@ -361,7 +364,7 @@ def callback(): return idom.html.button({"onClick": callback, "id": "good"}, "good") - async with idom.Layout(RootComponent()) as layout: + with idom.Layout(RootComponent()) as layout: await layout.render() for _ in range(3): event = LayoutEvent(good_handler.target, []) @@ -383,7 +386,7 @@ def Outer(): def Inner(): return idom.html.div("hello") - async with idom.Layout(Outer()) as layout: + with idom.Layout(Outer()) as layout: update = await layout.render() assert_same_items( update.changes, @@ -417,7 +420,7 @@ def Inner(key): registered_finalizers.add(key) return idom.html.div(key) - async with idom.Layout(Outer()) as layout: + with idom.Layout(Outer()) as layout: await layout.render() pop_item.current() @@ -440,7 +443,7 @@ def ComponentReturnsDuplicateKeys(): idom.html.div(key="duplicate"), idom.html.div(key="duplicate") ) - async with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: + with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: await layout.render() with pytest.raises(ValueError, match=r"Duplicate keys \['duplicate'\] at '/'"): @@ -461,7 +464,7 @@ def Outer(): def Inner(key): return idom.html.div(key) - async with idom.Layout(Outer()) as layout: + with idom.Layout(Outer()) as layout: await layout.render() old_inner_hook = inner_hook.latest diff --git a/tests/test_core/test_utils.py b/tests/test_core/test_utils.py deleted file mode 100644 index c25316a2c..000000000 --- a/tests/test_core/test_utils.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest - -from idom.core.utils import HasAsyncResources, async_resource - - -async def test_simple_async_resource(): - class MyResources(HasAsyncResources): - - before = False - after = False - - @async_resource - async def x(self): - self.before = True - yield 1 - self.after = True - - my_resources = MyResources() - - with pytest.raises(RuntimeError, match="is not open"): - my_resources.x - - with pytest.raises(RuntimeError, match="is not open"): - await my_resources.__aexit__(None, None, None) - - assert not my_resources.before - async with my_resources: - assert my_resources.before - assert my_resources.x == 1 - assert not my_resources.after - assert my_resources.after - - with pytest.raises(RuntimeError, match="is not open"): - my_resources.x - - -async def test_resource_opens_only_once(): - class MyResources(HasAsyncResources): - pass - - with pytest.raises(RuntimeError, match="is already open"): - async with MyResources() as rsrc: - async with rsrc: - pass diff --git a/tests/test_server/test_common/test_per_client_state.py b/tests/test_server/test_common/test_per_client_state.py index 4f19763c4..197768de5 100644 --- a/tests/test_server/test_common/test_per_client_state.py +++ b/tests/test_server/test_common/test_per_client_state.py @@ -39,7 +39,7 @@ def Hello(): assert driver.find_element_by_id("hello") -def test_display_simple_click_counter(driver, display): +def test_display_simple_click_counter(driver, driver_wait, display): def increment(count): return count + 1 @@ -59,7 +59,9 @@ def Counter(): client_counter = driver.find_element_by_id("counter") for i in range(3): - assert client_counter.get_attribute("innerHTML") == f"Count: {i}" + driver_wait.until( + lambda driver: client_counter.get_attribute("innerHTML") == f"Count: {i}" + ) client_counter.click() diff --git a/tests/test_server/test_common/test_shared_state_client.py b/tests/test_server/test_common/test_shared_state_client.py index c752c5cc8..a883d1802 100644 --- a/tests/test_server/test_common/test_shared_state_client.py +++ b/tests/test_server/test_common/test_shared_state_client.py @@ -24,16 +24,13 @@ def server_mount_point(request): def test_shared_client_state(create_driver, server_mount_point): - driver_1 = create_driver() - driver_2 = create_driver() was_garbage_collected = Event() @idom.component - def IncrCounter(count=0): - count, set_count = idom.hooks.use_state(count) + def IncrCounter(): + count, set_count = idom.hooks.use_state(0) - @idom.event - async def incr_on_click(event): + def incr_on_click(event): set_count(count + 1) button = idom.html.button( @@ -50,6 +47,9 @@ def Counter(count): server_mount_point.mount(IncrCounter) + driver_1 = create_driver() + driver_2 = create_driver() + driver_1.get(server_mount_point.url()) driver_2.get(server_mount_point.url()) @@ -70,6 +70,30 @@ def Counter(count): driver_2.find_element_by_id("count-is-2") assert was_garbage_collected.wait(1) + was_garbage_collected.clear() + + # Ensure this continues working after a refresh. In the past dispatchers failed to + # exit when the connections closed. This was due to an expected error that is raised + # when the web socket closes. + driver_1.refresh() + driver_2.refresh() + + client_1_button = driver_1.find_element_by_id("incr-button") + client_2_button = driver_2.find_element_by_id("incr-button") + + client_1_button.click() + + driver_1.find_element_by_id("count-is-3") + driver_2.find_element_by_id("count-is-3") + + client_1_button.click() + + driver_1.find_element_by_id("count-is-4") + driver_2.find_element_by_id("count-is-4") + + client_2_button.click() + + assert was_garbage_collected.wait(1) def test_shared_client_state_server_does_not_support_per_client_parameters( From 6e50c2eabfeda09d3df79933627ea5f470ffa2da Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 25 Apr 2021 15:37:02 -0700 Subject: [PATCH 2/6] fix type annotations --- src/idom/core/dispatcher.py | 22 ++++++++++------------ src/idom/core/layout.py | 2 +- src/idom/server/fastapi.py | 11 ++++++----- src/idom/server/flask.py | 20 ++++++++++++++------ src/idom/server/sanic.py | 10 ++++++---- src/idom/server/tornado.py | 4 ++-- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py index 1704eb288..57f01e946 100644 --- a/src/idom/core/dispatcher.py +++ b/src/idom/core/dispatcher.py @@ -37,13 +37,14 @@ async def dispatch_single_view( task_group.start_soon(_single_incoming_loop, layout, recv) -_SharedDispatchFuture = Callable[[SendCoroutine, RecvCoroutine], Future] +_SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], Future[None]] +_SharedViewDispatcherCoro = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] @asynccontextmanager async def create_shared_view_dispatcher( layout: Layout, run_forever: bool = False -) -> AsyncIterator[_SharedDispatchFuture]: +) -> AsyncIterator[_SharedViewDispatcherFuture]: with layout: ( dispatch_shared_view, @@ -51,11 +52,11 @@ async def create_shared_view_dispatcher( all_update_queues, ) = await _make_shared_view_dispatcher(layout) - dispatch_tasks: List[Future] = [] + dispatch_tasks: List[Future[None]] = [] def dispatch_shared_view_soon( send: SendCoroutine, recv: RecvCoroutine - ) -> Future: + ) -> Future[None]: future = ensure_future(dispatch_shared_view(send, recv)) dispatch_tasks.append(future) return future @@ -87,10 +88,10 @@ def dispatch_shared_view_soon( def ensure_shared_view_dispatcher_future( layout: Layout, -) -> Tuple[Future, _SharedDispatchFuture]: - dispatcher_future = Future() +) -> Tuple[Future[None], _SharedViewDispatcherCoro]: + dispatcher_future: Future[_SharedViewDispatcherCoro] = Future() - async def dispatch_shared_view_forever(): + async def dispatch_shared_view_forever() -> None: with layout: ( dispatch_shared_view, @@ -113,12 +114,9 @@ async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: return ensure_future(dispatch_shared_view_forever()), dispatch -_SharedDispatchCoroutine = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] - - async def _make_shared_view_dispatcher( layout: Layout, -) -> Tuple[_SharedDispatchCoroutine, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: +) -> Tuple[_SharedViewDispatcherCoro, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: initial_update = await layout.render() model_state = Ref(initial_update.apply_to({})) @@ -158,7 +156,7 @@ async def _shared_outgoing_loop( async def _wait_until_first_complete( *tasks: Awaitable[Any], -) -> Sequence[Future]: +) -> Sequence[Future[Any]]: futures = [ensure_future(t) for t in tasks] await wait(futures, return_when=FIRST_COMPLETED) return futures diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index c7e4169af..5a6b3fbab 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -47,7 +47,7 @@ class LayoutEvent(NamedTuple): """A list of event data passed to the event handler.""" -_Self = TypeVar("_Self") +_Self = TypeVar("_Self", bound="Layout") class Layout: diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index c7548576d..ed1b63ef5 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -3,9 +3,8 @@ import logging import sys import time -from asyncio.futures import Future from threading import Event, Thread -from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union from fastapi import APIRouter, FastAPI, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware @@ -162,6 +161,11 @@ def _run_application_in_thread( # uvicorn does the event loop setup for us self._run_application(config, app, host, port, args, kwargs) + async def _run_dispatcher( + self, send: SendCoroutine, recv: RecvCoroutine, params: Dict[str, Any] + ) -> None: + raise NotImplementedError() + class PerClientStateServer(FastApiRenderServer): """Each client view will have its own state.""" @@ -182,9 +186,6 @@ async def _run_dispatcher( class SharedClientStateServer(FastApiRenderServer): """All connected client views will have shared state.""" - _dispatch_daemon_future: Future - _dispatch_coroutine: Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] - def _setup_application(self, config: Config, app: FastAPI) -> None: app.on_event("startup")(self._activate_dispatcher) app.on_event("shutdown")(self._deactivate_dispatcher) diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 75c54f7b3..9f543bf8c 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -19,7 +19,7 @@ import idom from idom.config import IDOM_CLIENT_BUILD_DIR from idom.core.component import AbstractComponent -from idom.core.dispatcher import RecvCoroutine, SendCoroutine, dispatch_single_view +from idom.core.dispatcher import dispatch_single_view from idom.core.layout import LayoutEvent, LayoutUpdate from .base import AbstractRenderServer @@ -171,8 +171,11 @@ def _generic_run_application( self._wsgi_server.serve_forever() def _run_dispatcher( - self, query_params: Dict[str, Any], send: SendCoroutine, recv: RecvCoroutine - ): + self, + query_params: Dict[str, Any], + send: Callable[[Any], None], + recv: Callable[[], Optional[LayoutEvent]], + ) -> None: raise NotImplementedError() @@ -180,15 +183,20 @@ class PerClientStateServer(FlaskRenderServer): """Each client view will have its own state.""" def _run_dispatcher( - self, query_params: Dict[str, Any], send: SendCoroutine, recv: RecvCoroutine - ): + self, + query_params: Dict[str, Any], + send: Callable[[Any], None], + recv: Callable[[], Optional[LayoutEvent]], + ) -> None: dispatch_single_view_in_thread( self._root_component_constructor(**query_params), send, recv ) def dispatch_single_view_in_thread( - component: AbstractComponent, send: SendCoroutine, recv: RecvCoroutine + component: AbstractComponent, + send: Callable[[Any], None], + recv: Callable[[], Optional[LayoutEvent]], ) -> None: dispatch_thread_info_created = ThreadEvent() dispatch_thread_info_ref: idom.Ref[Optional[_DispatcherThreadInfo]] = idom.Ref(None) diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index fb799c076..539fe24e0 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -1,7 +1,7 @@ import asyncio import json from threading import Event -from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response @@ -108,6 +108,11 @@ def redirect_to_index( f"{blueprint.url_prefix}/client/index.html?{request.query_string}" ) + async def _run_dispatcher( + self, send: SendCoroutine, recv: RecvCoroutine, params: Dict[str, Any] + ) -> None: + raise NotImplementedError() + def _run_application( self, config: Config, @@ -182,9 +187,6 @@ async def _run_dispatcher( class SharedClientStateServer(SanicRenderServer): """All connected client views will have shared state.""" - _dispatch_daemon_future: asyncio.Future - _dispatch_coroutine: Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] - 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") diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index d630de4f4..ba5dd7695 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -129,8 +129,8 @@ def _run_application_in_thread( class PerClientStateModelStreamHandler(WebSocketHandler): """A web-socket handler that serves up a new model stream to each new client""" - _dispatch_future: Future - _message_queue: "AsyncQueue[str]" + _dispatch_future: Future[None] + _message_queue: AsyncQueue[str] def initialize(self, component_constructor: ComponentConstructor) -> None: self._component_constructor = component_constructor From 6b5cf1eeece96d6257152776b89d0e856b525393 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 25 Apr 2021 15:52:18 -0700 Subject: [PATCH 3/6] fix doc examples --- docs/source/core-concepts.rst | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/source/core-concepts.rst b/docs/source/core-concepts.rst index 1efaa3605..22a2bcd12 100644 --- a/docs/source/core-concepts.rst +++ b/docs/source/core-concepts.rst @@ -40,12 +40,16 @@ whose body contains a hook usage. We'll demonstrate that with a simple import idom - @idom.component - def ClickCount(): + def use_counter(): count, set_count = idom.hooks.use_state(0) + return count, lambda: set_count(lambda old_count: old_count + 1) + + @idom.component + def ClickCount(): + count, increment_count = use_counter() return idom.html.button( - {"onClick": lambda event: set_count(count + 1)}, + {"onClick": lambda event: increment_count()}, [f"Click count: {count}"], ) @@ -79,15 +83,17 @@ which we can re-render and see what changed: static_handler = StaticEventHandler() + @idom.component def ClickCount(): - count, set_count = idom.hooks.use_state(0) + count, increment_count = use_counter() # we do this in order to capture the event handler's target ID - handler = static_handler.use(lambda event: set_count(count + 1)) + handler = static_handler.use(lambda event: increment_count()) return idom.html.button({"onClick": handler}, [f"Click count: {count}"]) + with idom.Layout(ClickCount()) as layout: patch_1 = await layout.render() @@ -124,27 +130,28 @@ callback that's called by the dispatcher to collect events it should execute. import asyncio from idom.core.layout import LayoutEvent - from idom.core.dispatch import dispatch_single_view + from idom.core.dispatcher import dispatch_single_view sent_patches = [] + # We need this to simulate a scenario in which events ariving *after* each update + # has been sent to the client. Otherwise the events would all arive at once and we + # would observe one large update rather than many discrete updates. + sempahore = asyncio.Semaphore(0) + async def send(patch): sent_patches.append(patch) + sempahore.release() if len(sent_patches) == 5: # if we didn't cancel the dispatcher would continue forever raise asyncio.CancelledError() async def recv(): + await sempahore.acquire() event = LayoutEvent(target=static_handler.target, data=[{}]) - - # We need this so we don't flood the render loop with events. - # In practice this is never an issue since events won't arrive - # as quickly as in this example. - await asyncio.sleep(0) - return event From d2513e30da7733c5dc7d279c40df9e2b2bca1065 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 25 Apr 2021 16:07:07 -0700 Subject: [PATCH 4/6] compat Python<3.9 --- src/idom/core/dispatcher.py | 2 +- src/idom/server/tornado.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py index 57f01e946..fd03b861d 100644 --- a/src/idom/core/dispatcher.py +++ b/src/idom/core/dispatcher.py @@ -37,7 +37,7 @@ async def dispatch_single_view( task_group.start_soon(_single_incoming_loop, layout, recv) -_SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], Future[None]] +_SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], "Future[None]"] _SharedViewDispatcherCoro = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index ba5dd7695..88a158d83 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import json from asyncio import Queue as AsyncQueue From 039e9b1227531c07bf19858e0ae53779cbcb8452 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 25 Apr 2021 16:24:19 -0700 Subject: [PATCH 5/6] add format session to noxfile --- noxfile.py | 69 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/noxfile.py b/noxfile.py index 223084281..6479d6b9f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,27 +14,11 @@ POSARGS_PATTERN = re.compile(r"^(\w+)\[(.+)\]$") -def get_posargs(name: str, session: Session) -> List[str]: - """Find named positional arguments - - Positional args of the form `name[arg1,arg2]` will be parsed as ['arg1', 'arg2'] if - the given `name` matches. Any args not matching that pattern will be added to the - list of args as well. Thus the following: - - --param session_1[arg1,arg2] session_2[arg3,arg4] - - where `name` is session_1 would produce ['--param', 'arg1', 'arg2'] - """ - collected_args: List[str] = [] - for arg in session.posargs: - match = POSARGS_PATTERN.match(arg) - if match is not None: - found_name, found_args = match.groups() - if name == found_name: - collected_args.extend(map(str.strip, found_args.split(","))) - else: - collected_args.append(arg) - return collected_args +@nox.session(reuse_venv=True) +def format(session: Session) -> None: + install_requirements_file(session, "check-style") + session.run("black", ".") + session.run("isort", ".") @nox.session(reuse_venv=True) @@ -54,7 +38,7 @@ def example(session: Session) -> None: @nox.session(reuse_venv=True) def docs(session: Session) -> None: """Build and display documentation in the browser (automatically reloads on change)""" - session.install("-r", "requirements/build-docs.txt") + install_requirements_file(session, "build-docs") install_idom_dev(session, extras="all") session.run( "python", @@ -103,7 +87,7 @@ def test(session: Session) -> None: def test_python(session: Session) -> None: """Run the Python-based test suite""" session.env["IDOM_DEBUG_MODE"] = "1" - session.install("-r", "requirements/test-env.txt") + install_requirements_file(session, "test-env") pytest_args = get_posargs("pytest", session) if "--no-cov" in pytest_args: @@ -118,16 +102,16 @@ def test_python(session: Session) -> None: @nox.session def test_types(session: Session) -> None: """Perform a static type analysis of the codebase""" - session.install("-r", "requirements/check-types.txt") - session.install("-r", "requirements/pkg-deps.txt") - session.install("-r", "requirements/pkg-extras.txt") + install_requirements_file(session, "check-types") + install_requirements_file(session, "pkg-deps") + install_requirements_file(session, "pkg-extras") session.run("mypy", "--strict", "src/idom") @nox.session def test_style(session: Session) -> None: """Check that style guidelines are being followed""" - session.install("-r", "requirements/check-style.txt") + install_requirements_file(session, "check-style") session.run("flake8", "src/idom", "tests", "docs") black_default_exclude = r"\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist" session.run( @@ -143,7 +127,7 @@ def test_style(session: Session) -> None: @nox.session def test_docs(session: Session) -> None: """Verify that the docs build and that doctests pass""" - session.install("-r", "requirements/build-docs.txt") + install_requirements_file(session, "build-docs") install_idom_dev(session, extras="all") session.run("sphinx-build", "-b", "html", "docs/source", "docs/build") session.run("sphinx-build", "-b", "doctest", "docs/source", "docs/build") @@ -181,6 +165,35 @@ def parse_commit_reference(commit_ref: str) -> Tuple[str, str, str]: print(f"- {msg} - {sha_repr}") +def get_posargs(name: str, session: Session) -> List[str]: + """Find named positional arguments + + Positional args of the form `name[arg1,arg2]` will be parsed as ['arg1', 'arg2'] if + the given `name` matches. Any args not matching that pattern will be added to the + list of args as well. Thus the following: + + --param session_1[arg1,arg2] session_2[arg3,arg4] + + where `name` is session_1 would produce ['--param', 'arg1', 'arg2'] + """ + collected_args: List[str] = [] + for arg in session.posargs: + match = POSARGS_PATTERN.match(arg) + if match is not None: + found_name, found_args = match.groups() + if name == found_name: + collected_args.extend(map(str.strip, found_args.split(","))) + else: + collected_args.append(arg) + return collected_args + + +def install_requirements_file(session: Session, name: str) -> None: + file_path = HERE / "requirements" / (name + ".txt") + assert file_path.exists(), f"requirements file {file_path} does not exist" + session.install("-r", str(file_path)) + + def install_idom_dev(session: Session, extras: str = "stable") -> None: session.install("-e", f".[{extras}]") if "--no-restore" not in session.posargs: From 7a6b45dfedba2f969b283c994c290bff58dce6f7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 25 Apr 2021 16:37:49 -0700 Subject: [PATCH 6/6] more Python<3.9 compat --- src/idom/server/fastapi.py | 3 ++- src/idom/server/sanic.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index ed1b63ef5..e4dfb4f05 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -201,7 +201,8 @@ async def _activate_dispatcher(self) -> None: async def _deactivate_dispatcher(self) -> None: # pragma: no cover # for some reason this isn't getting run during testing - self._dispatch_daemon_future.cancel(f"{self} is shutting down") + logger.debug("Stopping dispatcher - server is shutting down") + self._dispatch_daemon_future.cancel() await asyncio.wait([self._dispatch_daemon_future]) async def _run_dispatcher( diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 539fe24e0..5aa7546b7 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from threading import Event from typing import Any, Dict, Optional, Tuple, Union @@ -20,6 +21,9 @@ from .base import AbstractRenderServer +logger = logging.getLogger(__name__) + + class Config(TypedDict, total=False): """Config for :class:`SanicRenderServer`""" @@ -205,7 +209,8 @@ async def _activate_dispatcher( async def _deactivate_dispatcher( self, app: Sanic, loop: asyncio.AbstractEventLoop ) -> None: - self._dispatch_daemon_future.cancel(f"{self} is shutting down") + logger.debug("Stopping dispatcher - server is shutting down") + self._dispatch_daemon_future.cancel() await asyncio.wait([self._dispatch_daemon_future]) async def _run_dispatcher(