Skip to content

Commit 3c9c062

Browse files
committed
fix render tests and others
1 parent 86df2b8 commit 3c9c062

File tree

8 files changed

+170
-75
lines changed

8 files changed

+170
-75
lines changed

idom/core/render.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import abc
22
import asyncio
3+
from functools import wraps
34
from typing import Callable, Awaitable, Dict, Any, AsyncIterator
45

56
from anyio import create_task_group, TaskGroup # type: ignore
7+
from jsonpatch import make_patch, apply_patch
8+
from loguru import logger
69

710
from .layout import (
811
LayoutEvent,
@@ -23,6 +26,8 @@ class StopRendering(Exception):
2326
class AbstractRenderer(HasAsyncResources, abc.ABC):
2427
"""A base class for implementing :class:`~idom.core.layout.Layout` renderers."""
2528

29+
__slots__ = "_layout"
30+
2631
def __init__(self, layout: Layout) -> None:
2732
super().__init__()
2833
self._layout = layout
@@ -43,8 +48,12 @@ async def run(self, send: SendCoroutine, recv: RecvCoroutine, context: Any) -> N
4348
This will call :meth:`AbstractLayouTaskGroupTaskGroupt.render` and :meth:`Layout.dispatch`
4449
to render new models and execute events respectively.
4550
"""
46-
await self.task_group.spawn(self._outgoing_loop, send, context)
47-
await self.task_group.spawn(self._incoming_loop, recv, context)
51+
await self.task_group.spawn(
52+
_async_log_exceptions(self._outgoing_loop), send, context
53+
)
54+
await self.task_group.spawn(
55+
_async_log_exceptions(self._incoming_loop), recv, context
56+
)
4857
return None
4958

5059
async def _outgoing_loop(self, send: SendCoroutine, context: Any) -> None:
@@ -72,6 +81,8 @@ class SingleStateRenderer(AbstractRenderer):
7281
be ``None`` since it's not used.
7382
"""
7483

84+
__slots__ = "_current_model_as_json"
85+
7586
def __init__(self, layout: Layout) -> None:
7687
super().__init__(layout)
7788
self._current_model_as_json = ""
@@ -91,33 +102,41 @@ class SharedStateRenderer(SingleStateRenderer):
91102
:meth:`SharedStateRenderer.run`
92103
"""
93104

105+
__slots__ = "_update_queues", "_model_state"
106+
94107
def __init__(self, layout: Layout) -> None:
95108
super().__init__(layout)
109+
self._model_state = {}
96110
self._update_queues: Dict[str, asyncio.Queue[LayoutUpdate]] = {}
97111

98112
@async_resource
99113
async def task_group(self) -> AsyncIterator[TaskGroup]:
100114
async with create_task_group() as group:
101-
await group.spawn(self._render_loop)
115+
await group.spawn(_async_log_exceptions(self._render_loop))
102116
yield group
103117

104118
async def run(
105119
self, send: SendCoroutine, recv: RecvCoroutine, context: str, join: bool = False
106120
) -> None:
107-
self._updates[context] = asyncio.Queue()
108121
await super().run(send, recv, context)
109122
if join:
110123
await self._join_event.wait()
111124

112125
async def _render_loop(self) -> None:
113126
while True:
114127
update = await super()._outgoing(self.layout, None)
128+
self._model_state = _apply_layout_update(self._model_state, update)
115129
# append updates to all other contexts
116130
for queue in self._update_queues.values():
117131
await queue.put(update)
118132

133+
async def _outgoing_loop(self, send: SendCoroutine, context: Any) -> None:
134+
self._update_queues[context] = asyncio.Queue()
135+
await send(LayoutUpdate("", make_patch({}, self._model_state).patch))
136+
await super()._outgoing_loop(send, context)
137+
119138
async def _outgoing(self, layout: Layout, context: str) -> LayoutUpdate:
120-
return await self._updates[context].get()
139+
return await self._update_queues[context].get()
121140

122141
@async_resource
123142
async def _join_event(self) -> AsyncIterator[asyncio.Event]:
@@ -126,3 +145,23 @@ async def _join_event(self) -> AsyncIterator[asyncio.Event]:
126145
yield event
127146
finally:
128147
event.set()
148+
149+
150+
def _apply_layout_update(doc: Dict[str, Any], update: LayoutUpdate) -> Dict[str, Any]:
151+
return apply_patch(
152+
doc, [{**c, "path": update.path + c["path"]} for c in update.changes]
153+
)
154+
155+
156+
def _async_log_exceptions(function):
157+
# BUG: https://github.com/agronholm/anyio/issues/155
158+
159+
@wraps(function)
160+
async def wrapper(*args, **kwargs):
161+
try:
162+
return await function(*args, **kwargs)
163+
except Exception:
164+
logger.exception(f"Failure in {function}")
165+
raise
166+
167+
return wrapper

idom/widgets/utils.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,12 @@ def DivTwo(self):
171171
172172
# displaying the output now will show DivTwo
173173
"""
174-
constructor_and_arguments: Ref[_FuncArgsKwargs] = Ref(lambda: {"tagName": "div"})
174+
constructor_and_arguments: Ref[_FuncArgsKwargs] = Ref(
175+
(lambda: {"tagName": "div"}, (), {})
176+
)
175177

176178
if shared:
177-
set_state_callbacks: Set[Callable[[_FuncArgsKwargs], None]] = Set()
179+
set_state_callbacks: Set[Callable[[_FuncArgsKwargs], None]] = set()
178180

179181
@element
180182
async def HotSwap() -> Any:
@@ -183,17 +185,17 @@ async def HotSwap() -> Any:
183185

184186
def add_callback():
185187
set_state_callbacks.add(set_state)
186-
return lambda: set_state_callbacks.remove(swap)
188+
return lambda: set_state_callbacks.remove(set_state)
187189

188190
hooks.use_effect(add_callback)
189191

190192
return f(*a, **kw)
191193

192194
def swap(_func_: ElementConstructor, *args: Any, **kwargs: Any) -> None:
193-
constructor_and_arguments.current = (_func_, args, kwargs)
195+
f_a_kw = constructor_and_arguments.current = (_func_, args, kwargs)
194196

195197
for set_state in set_state_callbacks:
196-
set_state(constructor_and_arguments.current)
198+
set_state(f_a_kw)
197199

198200
return None
199201

@@ -288,3 +290,36 @@ def _add_view(
288290
kwargs: Dict[str, Any],
289291
) -> None:
290292
self._views[view_id] = lambda: constructor(*args, **kwargs)
293+
294+
295+
{
296+
"tagName": "div",
297+
"children": [
298+
{
299+
"tagName": "div",
300+
"children": [
301+
{
302+
"tagName": "button",
303+
"attributes": {"id": "incr-button"},
304+
"children": ["click to increment"],
305+
"eventHandlers": {
306+
"onClick": {
307+
"target": "139775953392160",
308+
"preventDefault": False,
309+
"stopPropagation": False,
310+
}
311+
},
312+
},
313+
{
314+
"tagName": "div",
315+
"attributes": {"id": "count-is-1"},
316+
"children": ["1"],
317+
},
318+
],
319+
}
320+
],
321+
}
322+
[
323+
{"path": "/children/1/attributes/id", "op": "replace", "value": "count-is-1"},
324+
{"path": "/children/1/children/0", "op": "replace", "value": "1"},
325+
]

tests/test_core/test_hooks.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ async def SimpleStatefulElement():
3434
patch_1.changes,
3535
[
3636
{"op": "add", "path": "/children", "value": ["0"]},
37-
{"op": "add", "path": "/eventHandlers", "value": {}},
3837
{"op": "add", "path": "/tagName", "value": "div"},
3938
],
4039
)

tests/test_core/test_render.py

Lines changed: 73 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
import asyncio
2+
from tests.general_utils import assert_unordered_equal
3+
4+
import pytest
5+
from anyio.exceptions import ExceptionGroup
6+
7+
import idom
8+
from idom.core.layout import Layout, LayoutEvent
9+
from idom.core.render import SharedStateRenderer, AbstractRenderer
10+
11+
12+
import asyncio
13+
from asyncio.exceptions import CancelledError
214

315
import pytest
416
from anyio.exceptions import ExceptionGroup
@@ -10,37 +22,35 @@
1022

1123
async def test_shared_state_renderer():
1224
done = asyncio.Event()
13-
data_sent_1 = asyncio.Queue()
14-
data_sent_2 = []
25+
changes_1 = []
26+
changes_2 = []
27+
target_id = "an-event"
1528

16-
async def send_1(data):
17-
await data_sent_1.put(data)
29+
events_to_inject = [LayoutEvent(target=target_id, data=[])] * 4
1830

19-
async def recv_1():
20-
sent = await data_sent_1.get()
31+
async def send_1(patch):
32+
changes_1.append(patch.changes)
2133

22-
element_id = sent["root"]
23-
element_data = sent["new"][element_id]
24-
if element_data["attributes"]["count"] == 4:
34+
async def recv_1():
35+
await asyncio.sleep(0)
36+
try:
37+
return events_to_inject.pop(0)
38+
except IndexError:
2539
done.set()
26-
raise asyncio.CancelledError()
27-
28-
return LayoutEvent(target="an-event", data=[])
40+
raise CancelledError()
2941

30-
async def send_2(data):
31-
element_id = data["root"]
32-
element_data = data["new"][element_id]
33-
data_sent_2.append(element_data["attributes"]["count"])
42+
async def send_2(patch):
43+
changes_2.append(patch.changes)
3444

3545
async def recv_2():
3646
await done.wait()
37-
raise asyncio.CancelledError()
47+
raise CancelledError()
3848

3949
@idom.element
40-
async def Clickable(count=0):
41-
count, set_count = idom.hooks.use_state(count)
50+
async def Clickable():
51+
count, set_count = idom.hooks.use_state(0)
4252

43-
@idom.event(target_id="an-event")
53+
@idom.event(target_id=target_id)
4454
async def an_event():
4555
set_count(count + 1)
4656

@@ -50,10 +60,34 @@ async def an_event():
5060
await renderer.run(send_1, recv_1, "1")
5161
await renderer.run(send_2, recv_2, "2")
5262

53-
assert data_sent_2 == [0, 1, 2, 3, 4]
54-
55-
56-
async def test_renderer_run_does_not_supress_non_stop_rendering_errors():
63+
expected_changes = [
64+
[
65+
{
66+
"op": "add",
67+
"path": "/eventHandlers",
68+
"value": {
69+
"anEvent": {
70+
"target": "an-event",
71+
"preventDefault": False,
72+
"stopPropagation": False,
73+
}
74+
},
75+
},
76+
{"op": "add", "path": "/attributes", "value": {"count": 0}},
77+
{"op": "add", "path": "/tagName", "value": "div"},
78+
],
79+
[{"op": "replace", "path": "/attributes/count", "value": 1}],
80+
[{"op": "replace", "path": "/attributes/count", "value": 2}],
81+
[{"op": "replace", "path": "/attributes/count", "value": 3}],
82+
]
83+
84+
for c_2, expected_c in zip(changes_2, expected_changes):
85+
assert_unordered_equal(c_2, expected_c)
86+
87+
assert changes_1 == changes_2
88+
89+
90+
async def test_renderer_run_does_not_supress_non_cancel_errors():
5791
class RendererWithBug(AbstractRenderer):
5892
async def _outgoing(self, layout, context):
5993
raise ValueError("this is a bug")
@@ -76,40 +110,24 @@ async def recv():
76110
await renderer.run(send, recv, None)
77111

78112

79-
async def test_shared_state_renderer_deletes_old_elements():
80-
sent = []
81-
target_id = "some-id"
82-
83-
async def send(data):
84-
if len(sent) == 2:
85-
raise asyncio.CancelledError()
86-
sent.append(data)
87-
88-
async def recv():
89-
# If we don't sleep here recv callback will clog the event loop.
90-
# In practice this isn't a problem because you'll usually be awaiting
91-
# something here that would take the place of sleep()
92-
await asyncio.sleep(0)
93-
return LayoutEvent(target_id, [])
94-
95-
@idom.element
96-
async def Outer():
97-
hook = idom.hooks.current_hook()
98-
99-
@idom.event(target_id=target_id)
100-
async def an_event():
101-
hook.schedule_render()
113+
async def test_renderer_run_does_not_supress_non_stop_rendering_errors():
114+
class RendererWithBug(AbstractRenderer):
115+
async def _outgoing(self, layout, context):
116+
raise ValueError("this is a bug")
102117

103-
return idom.html.div({"onEvent": an_event}, Inner())
118+
async def _incoming(self, layout, context, message):
119+
raise ValueError("this is a bug")
104120

105121
@idom.element
106-
async def Inner():
122+
async def AnyElement():
107123
return idom.html.div()
108124

109-
layout = Layout(Outer())
110-
async with SharedStateRenderer(layout) as renderer:
111-
await renderer.run(send, recv, "1")
125+
async def send(data):
126+
pass
127+
128+
async def recv():
129+
return {}
112130

113-
root = sent[0]["new"][layout.root]
114-
first_inner_id = root["children"][0]["data"]
115-
assert sent[1]["old"] == [first_inner_id]
131+
with pytest.raises(ExceptionGroup, match="this is a bug"):
132+
async with RendererWithBug(idom.Layout(AnyElement())) as renderer:
133+
await renderer.run(send, recv, None)

tests/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def test_install_with_exports(capsys):
4646
@pytest.mark.slow
4747
def test_restore(capsys):
4848
main("restore")
49-
assert client.installed() == ["htm", "react", "react-dom"]
49+
assert client.installed() == ["fast-json-patch", "htm", "react", "react-dom"]
5050

5151

5252
@pytest.mark.parametrize(

tests/test_server/test_sanic/test_shared_state_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def test_shared_client_state_server_does_not_support_per_client_parameters(
9393
):
9494
driver_get("per_client_param=1")
9595

96-
error = last_server_error.get()
96+
error = last_server_error.current
9797

9898
assert error is not None
9999

tests/test_utils.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55

66

77
def test_basic_ref_behavior():
8-
r = idom.Ref(None)
9-
assert r.current == "new_1"
10-
r.current = "new_2"
11-
assert r.current == "new_2"
8+
r = idom.Ref(1)
9+
r.current = 2
10+
assert r.current == 2
1211

1312

1413
def test_ref_equivalence():

0 commit comments

Comments
 (0)