Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/source/auto/api-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ API Reference
.. automodule:: idom.core.layout
:members:

.. automodule:: idom.core.proto
:members:

.. automodule:: idom.core.vdom
:members:

Expand Down
43 changes: 23 additions & 20 deletions docs/source/core-abstractions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,30 @@ whose body contains a hook usage. We'll demonstrate that with a simple
Component Layout
----------------

Displaying components requires you to turn them into :ref:`VDOM <VDOM Mimetype>` -
this is done using a :class:`~idom.core.layout.Layout`. Layouts are responsible for
rendering components (turning them into VDOM) and scheduling their re-renders when they
:meth:`~idom.core.layout.Layout.update`. To create a layout, you'll need a
:class:`~idom.core.component.Component` instance, which will become its root, and won't
ever be removed from the model. Then you'll just need to call and await a
:meth:`~idom.core.layout.Layout.render` which will return a :ref:`JSON Patch`:
Displaying components requires you to turn them into :ref:`VDOM <VDOM Mimetype>`. This
transformation, known as "rendering a component", is done by a
:class:`~idom.core.proto.LayoutType`. Layouts are responsible for rendering components
and scheduling their re-renders when they change. IDOM's concrete
:class:`~idom.core.layout.Layout` implementation renders
:class:`~idom.core.component.Component` instances into
:class:`~idom.core.layout.LayoutUpdate` and responds to
:class:`~idom.core.layout.LayoutEvent` objects respectively.

To create a layout, you'll need a :class:`~idom.core.component.Component` instance, that
will become its root. This component won't ever be removed from the model. Then, you'll
just need to call and await a :meth:`~idom.core.layout.Layout.render` which will return
a :ref:`JSON Patch`:


.. testcode::

with idom.Layout(ClickCount()) as layout:
patch = await layout.render()
update = await layout.render()

The layout also handles the triggering of event handlers. Normally these are
automatically sent to a :ref:`Dispatcher <Layout Dispatcher>`, but for now we'll do it
manually. To do this we need to pass a fake event with its "target" (event handler
identifier), to the layout's :meth:`~idom.core.layout.Layout.dispatch` method, after
The layout also handles the deliver of events to their handlers. Normally these are sent
through a :ref:`Dispatcher <Layout Dispatcher>` first, but for now we'll do it manually.
To accomplish this we need to pass a fake event with its "target" (event handler
identifier), to the layout's :meth:`~idom.core.layout.Layout.deliver` method, after
which we can re-render and see what changed:

.. testcode::
Expand All @@ -92,17 +99,13 @@ which we can re-render and see what changed:


with idom.Layout(ClickCount()) as layout:
patch_1 = await layout.render()
update_1 = await layout.render()

fake_event = LayoutEvent(target=static_handler.target, data=[{}])
await layout.dispatch(fake_event)
patch_2 = await layout.render()

for change in patch_2.changes:
if change["path"] == "/children/0":
count_did_increment = change["value"] == "Click count: 1"
await layout.deliver(fake_event)

assert count_did_increment
update_2 = await layout.render()
assert update_2.new["children"][0] == "Click count: 1"

.. note::

Expand Down
34 changes: 8 additions & 26 deletions src/idom/core/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,18 @@

from __future__ import annotations

import abc
import inspect
import warnings
from functools import wraps
from typing import Any, Callable, Dict, Optional, Tuple, Union
from uuid import uuid4

from typing_extensions import Protocol, runtime_checkable

from .proto import ComponentType
from .vdom import VdomDict


ComponentConstructor = Callable[..., "ComponentType"]
ComponentRenderFunction = Callable[..., Union["ComponentType", VdomDict]]


def component(function: ComponentRenderFunction) -> Callable[..., "Component"]:
def component(
function: Callable[..., Union[ComponentType, VdomDict]]
) -> Callable[..., "Component"]:
"""A decorator for defining an :class:`Component`.

Parameters:
Expand All @@ -48,34 +43,21 @@ def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Compone
return constructor


@runtime_checkable
class ComponentType(Protocol):
"""The expected interface for all component-like objects"""

id: str
key: Optional[Any]

@abc.abstractmethod
def render(self) -> VdomDict:
"""Render the component's :class:`VdomDict`."""


class Component:
"""An object for rending component models."""

__slots__ = "__weakref__", "_func", "_args", "_kwargs", "id", "key"
__slots__ = "__weakref__", "_func", "_args", "_kwargs", "key"

def __init__(
self,
function: ComponentRenderFunction,
function: Callable[..., Union[ComponentType, VdomDict]],
key: Optional[Any],
args: Tuple[Any, ...],
kwargs: Dict[str, Any],
) -> None:
self._args = args
self._func = function
self._kwargs = kwargs
self.id = uuid4().hex
self.key = key

def render(self) -> VdomDict:
Expand All @@ -93,6 +75,6 @@ def __repr__(self) -> str:
else:
items = ", ".join(f"{k}={v!r}" for k, v in args.items())
if items:
return f"{self._func.__name__}({self.id}, {items})"
return f"{self._func.__name__}({id(self)}, {items})"
else:
return f"{self._func.__name__}({self.id})"
return f"{self._func.__name__}({id(self)})"
119 changes: 81 additions & 38 deletions src/idom/core/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,43 @@

from __future__ import annotations

import sys
from asyncio import Future, Queue
from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait
from contextlib import asynccontextmanager
from logging import getLogger
from typing import Any, AsyncIterator, Awaitable, Callable, List, Sequence, Tuple
from typing import (
Any,
AsyncIterator,
Awaitable,
Callable,
Dict,
List,
NamedTuple,
Sequence,
Tuple,
cast,
)
from weakref import WeakSet

from anyio import create_task_group
from jsonpatch import apply_patch, make_patch

from idom.core.vdom import VdomJson
from idom.utils import Ref

from .layout import Layout, LayoutEvent, LayoutUpdate


if sys.version_info >= (3, 7): # pragma: no cover
from contextlib import asynccontextmanager # noqa
else: # pragma: no cover
from async_generator import asynccontextmanager
from .layout import LayoutEvent, LayoutUpdate
from .proto import LayoutType


logger = getLogger(__name__)

SendCoroutine = Callable[[Any], Awaitable[None]]

SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]]
RecvCoroutine = Callable[[], Awaitable[LayoutEvent]]


async def dispatch_single_view(
layout: Layout,
layout: LayoutType[LayoutUpdate, LayoutEvent],
send: SendCoroutine,
recv: RecvCoroutine,
) -> None:
Expand All @@ -49,14 +58,14 @@ async def dispatch_single_view(

@asynccontextmanager
async def create_shared_view_dispatcher(
layout: Layout, run_forever: bool = False
layout: LayoutType[LayoutUpdate, LayoutEvent],
) -> AsyncIterator[_SharedViewDispatcherFuture]:
"""Enter a dispatch context where all subsequent view instances share the same state"""
with layout:
(
dispatch_shared_view,
model_state,
all_update_queues,
all_patch_queues,
) = await _make_shared_view_dispatcher(layout)

dispatch_tasks: List[Future[None]] = []
Expand Down Expand Up @@ -85,16 +94,16 @@ def dispatch_shared_view_soon(
update_future.cancel()
break
else:
update: LayoutUpdate = update_future.result()
patch = VdomJsonPatch.create_from(update_future.result())

model_state.current = update.apply_to(model_state.current)
model_state.current = patch.apply_to(model_state.current)
# push updates to all dispatcher callbacks
for queue in all_update_queues:
queue.put_nowait(update)
for queue in all_patch_queues:
queue.put_nowait(patch)


def ensure_shared_view_dispatcher_future(
layout: Layout,
layout: LayoutType[LayoutUpdate, LayoutEvent],
) -> Tuple[Future[None], SharedViewDispatcher]:
"""Ensure the future of a dispatcher created by :func:`create_shared_view_dispatcher`"""
dispatcher_future: Future[SharedViewDispatcher] = Future()
Expand All @@ -104,59 +113,93 @@ async def dispatch_shared_view_forever() -> None:
(
dispatch_shared_view,
model_state,
all_update_queues,
all_patch_queues,
) = await _make_shared_view_dispatcher(layout)

dispatcher_future.set_result(dispatch_shared_view)

while True:
update = await layout.render()
model_state.current = update.apply_to(model_state.current)
patch = await render_json_patch(layout)
model_state.current = patch.apply_to(model_state.current)
# push updates to all dispatcher callbacks
for queue in all_update_queues:
queue.put_nowait(update)
for queue in all_patch_queues:
queue.put_nowait(patch)

async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None:
await (await dispatcher_future)(send, recv)

return ensure_future(dispatch_shared_view_forever()), dispatch


async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch:
"""Render a class:`VdomJsonPatch` from a layout"""
return VdomJsonPatch.create_from(await layout.render())


class VdomJsonPatch(NamedTuple):
"""An object describing an update to a :class:`Layout` in the form of a JSON patch"""

path: str
"""The path where changes should be applied"""

changes: List[Dict[str, Any]]
"""A list of JSON patches to apply at the given path"""

def apply_to(self, model: VdomJson) -> VdomJson:
"""Return the model resulting from the changes in this update"""
return cast(
VdomJson,
apply_patch(
model, [{**c, "path": self.path + c["path"]} for c in self.changes]
),
)

@classmethod
def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch:
"""Return a patch given an layout update"""
return cls(update.path, make_patch(update.old or {}, update.new).patch)


async def _make_shared_view_dispatcher(
layout: Layout,
) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[LayoutUpdate]]]:
initial_update = await layout.render()
model_state = Ref(initial_update.apply_to({}))
layout: LayoutType[LayoutUpdate, LayoutEvent],
) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[VdomJsonPatch]]]:
update = await layout.render()
model_state = Ref(update.new)

# 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()
all_patch_queues: WeakSet[Queue[VdomJsonPatch]] = WeakSet()

async def dispatch_shared_view(send: SendCoroutine, recv: RecvCoroutine) -> None:
update_queue: Queue[LayoutUpdate] = Queue()
patch_queue: Queue[VdomJsonPatch] = Queue()
async with create_task_group() as inner_task_group:
all_update_queues.add(update_queue)
await send(LayoutUpdate.create_from({}, model_state.current))
all_patch_queues.add(patch_queue)
effective_update = LayoutUpdate("", None, model_state.current)
await send(VdomJsonPatch.create_from(effective_update))
inner_task_group.start_soon(_single_incoming_loop, layout, recv)
inner_task_group.start_soon(_shared_outgoing_loop, send, update_queue)
inner_task_group.start_soon(_shared_outgoing_loop, send, patch_queue)
return None

return dispatch_shared_view, model_state, all_update_queues
return dispatch_shared_view, model_state, all_patch_queues


async def _single_outgoing_loop(layout: Layout, send: SendCoroutine) -> None:
async def _single_outgoing_loop(
layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine
) -> None:
while True:
await send(await layout.render())
await send(await render_json_patch(layout))


async def _single_incoming_loop(layout: Layout, recv: RecvCoroutine) -> None:
async def _single_incoming_loop(
layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine
) -> None:
while True:
await layout.dispatch(await recv())
await layout.deliver(await recv())


async def _shared_outgoing_loop(
send: SendCoroutine, queue: Queue[LayoutUpdate]
send: SendCoroutine, queue: Queue[VdomJsonPatch]
) -> None:
while True:
await send(await queue.get())
Expand Down
Loading