diff --git a/docs/source/core-concepts.rst b/docs/source/core-concepts.rst index 5eb10b5e1..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}"], ) @@ -63,7 +67,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 @@ -79,16 +83,18 @@ 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}"]) - 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,57 +117,52 @@ 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.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 - 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/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: 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..fd03b861d 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,137 @@ 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 +_SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], "Future[None]"] +_SharedViewDispatcherCoro = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] - 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[_SharedViewDispatcherFuture]: + 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[None]] = [] - @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[None]: + 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[None], _SharedViewDispatcherCoro]: + dispatcher_future: Future[_SharedViewDispatcherCoro] = Future() + + async def dispatch_shared_view_forever() -> None: + 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: - ... - - @abc.abstractmethod - async def _incoming(self, layout: Layout, context: Any, message: Any) -> None: - ... - - -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. - """ - - __slots__ = "_current_model_as_json" - - def __init__(self, layout: Layout) -> None: - super().__init__(layout) - self._current_model_as_json = "" - - async def _outgoing(self, layout: Layout, context: Any) -> LayoutUpdate: - return await layout.render() - - async def _incoming(self, layout: Layout, context: Any, event: LayoutEvent) -> None: - await layout.dispatch(event) + 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) + + async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: + await (await dispatcher_future)(send, recv) + + return ensure_future(dispatch_shared_view_forever()), dispatch + + +async def _make_shared_view_dispatcher( + layout: Layout, +) -> Tuple[_SharedViewDispatcherCoro, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: + initial_update = await layout.render() + model_state = Ref(initial_update.apply_to({})) + + # 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 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[Any]]: + 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..5a6b3fbab 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", bound="Layout") - __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..e4dfb4f05 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -1,10 +1,10 @@ +import asyncio import json import logging import sys import time -import uuid from threading import Event, Thread -from typing import Any, Dict, Optional, Tuple, Type, Union, cast +from typing import Any, Dict, Optional, Tuple, Union from fastapi import APIRouter, FastAPI, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware @@ -19,11 +19,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 +44,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: @@ -164,42 +162,48 @@ def _run_application_in_thread( self._run_application(config, app, host, port, args, kwargs) async def _run_dispatcher( - self, - send: SendCoroutine, - recv: RecvCoroutine, - params: Dict[str, Any], + 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))) + raise NotImplementedError() class PerClientStateServer(FastApiRenderServer): """Each client view will have its own state.""" - _dispatcher_type = SingleViewDispatcher + async def _run_dispatcher( + self, + send: SendCoroutine, + recv: RecvCoroutine, + params: Dict[str, Any], + ) -> None: + 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 - def _setup_application(self, config: Config, app: FastAPI) -> None: app.on_event("startup")(self._activate_dispatcher) app.on_event("shutdown")(self._deactivate_dispatcher) 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 + 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, @@ -210,7 +214,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..9f543bf8c 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 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,33 @@ def _generic_run_application( ) self._wsgi_server.serve_forever() + def _run_dispatcher( + self, + query_params: Dict[str, Any], + send: Callable[[Any], None], + recv: Callable[[], Optional[LayoutEvent]], + ) -> None: + 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: Callable[[Any], None], + recv: Callable[[], Optional[LayoutEvent]], + ) -> None: + dispatch_single_view_in_thread( + self._root_component_constructor(**query_params), send, recv + ) -def run_dispatcher_in_thread( - make_dispatcher: Callable[[], AbstractDispatcher], +def dispatch_single_view_in_thread( + component: AbstractComponent, send: Callable[[Any], None], recv: Callable[[], Optional[LayoutEvent]], - context: Optional[Any], ) -> None: dispatch_thread_info_created = ThreadEvent() dispatch_thread_info_ref: idom.Ref[Optional[_DispatcherThreadInfo]] = idom.Ref(None) @@ -207,8 +215,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..5aa7546b7 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -1,8 +1,8 @@ import asyncio import json -import uuid +import logging from threading import Event -from typing import Any, Dict, Optional, Tuple, Type, Union, cast +from typing import Any, Dict, Optional, Tuple, Union from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response @@ -11,17 +11,19 @@ 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 from .base import AbstractRenderServer +logger = logging.getLogger(__name__) + + class Config(TypedDict, total=False): """Config for :class:`SanicRenderServer`""" @@ -35,7 +37,6 @@ class SanicRenderServer(AbstractRenderServer[Sanic, Config]): """Base ``sanic`` extension.""" _loop: asyncio.AbstractEventLoop - _dispatcher_type: Type[AbstractDispatcher] _did_stop: Event def stop(self) -> None: @@ -111,6 +112,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, @@ -165,31 +171,26 @@ 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 - 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") @@ -198,14 +199,19 @@ 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: + 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, @@ -216,4 +222,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..88a158d83 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -1,6 +1,9 @@ +from __future__ import annotations + 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 +15,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,9 +131,8 @@ 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 - _message_queue: "AsyncQueue[str]" + _dispatch_future: Future[None] + _message_queue: AsyncQueue[str] def initialize(self, component_constructor: ComponentConstructor) -> None: self._component_constructor = component_constructor @@ -138,9 +140,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 +147,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 +162,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(