From a1f400682b7f2aaea9018fdf3e958657a6d72a64 Mon Sep 17 00:00:00 2001 From: Masahiro Wada Date: Mon, 10 Oct 2022 15:33:16 +0000 Subject: [PATCH] feat: use async func as Effect --- README.md | 2 +- alfort/app.py | 7 ++-- examples/simple_counter/main.py | 17 ++++---- tests/test_app.py | 74 +++++++++++++++++++++++---------- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3c52d58..73dce4a 100755 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ If you need more exmplaes, please check the [examples](https://github.com/ar90n/ ## Concept Alfort is inspired by TEA(The Elm Architecture). So Alfort makes you create an interactive application with `View`, `Model` and `Update`. If you need more specification about TEA, please see this [documentation](https://guide.elm-lang.org/architecture/). -Therefore, Alfort doesn't support Command. So Alfort uses functions whose type is `Callable[[Callable[[Msg], None]], None]` to achieve side effect. +Therefore, Alfort doesn't support Command. So Alfort uses functions whose type is `Callable[[Callable[[Msg], None]], Coroutine[None, None, Any]]` to achieve side effect. You can run some tasks which have side effects in this function. And, if you need, you can pass the result of side effect as Message to `dicpatch` which is given as an argument. This idea is inspired by [hyperapp](https://github.com/jorgebucaran/hyperapp). diff --git a/alfort/app.py b/alfort/app.py index be9f51d..07d0bac 100644 --- a/alfort/app.py +++ b/alfort/app.py @@ -1,7 +1,8 @@ +import asyncio from abc import abstractmethod from dataclasses import dataclass, replace from itertools import zip_longest -from typing import Callable, Generic, TypeAlias, TypeVar +from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar from alfort.sub import Context, Subscriptions from alfort.vdom import ( @@ -23,7 +24,7 @@ M = TypeVar("M") Dispatch: TypeAlias = Callable[[M], None] -Effect: TypeAlias = Callable[[Dispatch[M]], None] +Effect: TypeAlias = Callable[[Dispatch[M]], Coroutine[None, None, Any]] Init: TypeAlias = Callable[[], tuple[S, list[Effect[M]]]] View: TypeAlias = Callable[[S], VDom] Update: TypeAlias = Callable[[M, S], tuple[S, list[Effect[M]]]] @@ -62,7 +63,7 @@ class Alfort(Generic[S, M, N]): @classmethod def _run_effects(cls, dispatch: Dispatch[M], effects: list[Effect[M]]) -> None: for e in effects: - e(dispatch) + asyncio.create_task(e(dispatch)) @classmethod def _diff_props(cls, node_props: Props, vdom_props: Props) -> PatchProps: diff --git a/examples/simple_counter/main.py b/examples/simple_counter/main.py index fe39770..a540621 100644 --- a/examples/simple_counter/main.py +++ b/examples/simple_counter/main.py @@ -1,3 +1,4 @@ +import asyncio import curses from enum import Enum, auto from typing import Any, Callable @@ -52,10 +53,16 @@ def create_element( ) -> TextNode: raise ValueError("create_element should not be called") - def main( + async def main( self, ) -> None: self._main() + while True: + c = chr(stdscr.getch()) + if c == "q": + break + if handle := handlers.get(c): + handle() def main(stdscr: Any) -> None: @@ -87,13 +94,7 @@ def on_keydown(dispatch: Dispatch[Msg]) -> UnSubscription: app = AlfortSimpleCounter( init=init, view=view, update=update, subscriptions=subscriptions ) - app.main() - while True: - c = chr(stdscr.getch()) - if c == "q": - break - if handle := handlers.get(c): - handle() + asyncio.run(app.main()) if __name__ == "__main__": diff --git a/tests/test_app.py b/tests/test_app.py index b73d9d7..c1824b6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,4 @@ +import asyncio from dataclasses import dataclass from typing import Any, Callable, Generic, TypeVar @@ -21,6 +22,10 @@ T = TypeVar("T", bound=Node) +def get_other_tasks() -> set[asyncio.Task[Any]]: + return asyncio.all_tasks() - {asyncio.current_task()} + + def to_vnode(node_dom: NodeDom) -> VDom: match node_dom: case NodeDomText(): @@ -240,7 +245,10 @@ def view(state: dict[str, int]) -> VDom: return str(state["count"]) def init() -> tuple[dict[str, int], list[Effect[Msg]]]: - return ({"count": 0}, [lambda dispatch: dispatch(CountUp(3))]) + async def _e(dispatch: Dispatch[Msg]) -> None: + dispatch(CountUp(3)) + + return ({"count": 0}, [_e]) def update( msg: Msg, state: dict[str, int] @@ -252,14 +260,18 @@ def update( return ({"count": state["count"] - value}, []) app = AlfortText(init=init, view=view, update=update) - app.main(root) - assert root.child is not None - assert root.child.text == "3" - root.child.dispatch(CountUp()) - assert root.child.text == "4" - root.child.dispatch(CountDown()) - assert root.child.text == "3" + async def main_loop() -> None: + app.main(root) + await asyncio.gather(*get_other_tasks()) + assert root.child is not None + assert root.child.text == "3" + root.child.dispatch(CountUp()) + assert root.child.text == "4" + root.child.dispatch(CountDown()) + assert root.child.text == "3" + + asyncio.run(main_loop()) def test_enqueue() -> None: @@ -287,7 +299,13 @@ def view(state: dict[str, int]) -> VDom: return str(state["count"]) def init() -> tuple[dict[str, int], list[Effect[Msg]]]: - return ({"count": 5}, [capture, lambda d: enqueue(lambda: capture(d))]) + async def _e0(d: Dispatch[Msg]) -> None: + capture(d) + + async def _e1(d: Dispatch[Msg]) -> None: + enqueue(lambda: capture(d)) + + return ({"count": 5}, [_e0, _e1]) def update( msg: Msg, state: dict[str, int] @@ -295,11 +313,16 @@ def update( return (state, []) app = AlfortText(init=init, view=view, update=update, enqueue=enqueue) - app.main(root) - assert view_values == [None] - render() - assert view_values == [None, "5"] + async def main_loop() -> None: + app.main(root) + await asyncio.gather(*get_other_tasks()) + + assert view_values == [None] + render() + assert view_values == [None, "5"] + + asyncio.run(main_loop()) def test_subscriptions() -> None: @@ -341,13 +364,18 @@ def _unsubscribe(): return [on_countup] if state["count"] < 2 else [] app = AlfortText(init=init, view=view, update=update, subscriptions=subscriptions) - app.main(root) - - assert root.child is not None - assert root.child.text == "0" - assert countup is not None - countup() - assert root.child.text == "1" - countup() - assert root.child.text == "2" - assert countup is None + + async def main_loop() -> None: + app.main(root) + await asyncio.gather(*get_other_tasks()) + + assert root.child is not None + assert root.child.text == "0" + assert countup is not None + countup() + assert root.child.text == "1" + countup() + assert root.child.text == "2" + assert countup is None + + asyncio.run(main_loop())