Skip to content

Commit 1abfd76

Browse files
committed
make HookCatcher/StaticEventHandlers testing utils
also fixes mypy typing issues
1 parent 9678622 commit 1abfd76

File tree

6 files changed

+162
-118
lines changed

6 files changed

+162
-118
lines changed

src/idom/core/layout.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,9 @@ class _ModelState:
379379
)
380380

381381
model: _ModelVdom
382+
life_cycle_hook: LifeCycleHook
383+
patch_path: str
384+
component: AbstractComponent
382385

383386
def __init__(
384387
self,
@@ -418,12 +421,15 @@ def new(
418421
) -> _ModelState:
419422
if new_parent is None:
420423
new_parent = getattr(self, "parent", None)
424+
425+
life_cycle_hook: Optional[LifeCycleHook]
421426
if hasattr(self, "life_cycle_hook"):
422427
assert component is not None
423428
life_cycle_hook = self.life_cycle_hook
424429
life_cycle_hook.component = component
425430
else:
426431
life_cycle_hook = None
432+
427433
return _ModelState(new_parent, self.index, self.key, life_cycle_hook)
428434

429435
def iter_children(self, include_self: bool = True) -> Iterator[_ModelState]:

src/idom/testing.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import re
3+
from functools import wraps
34
from types import TracebackType
45
from typing import (
56
Any,
@@ -12,12 +13,18 @@
1213
Type,
1314
TypeVar,
1415
Union,
16+
overload,
1517
)
1618
from urllib.parse import urlencode, urlunparse
19+
from weakref import ref
1720

1821
from selenium.webdriver import Chrome
1922
from selenium.webdriver.remote.webdriver import WebDriver
23+
from typing_extensions import Literal
2024

25+
from idom.core.events import EventHandler
26+
from idom.core.hooks import LifeCycleHook, current_hook
27+
from idom.core.utils import hex_id
2128
from idom.server.base import AbstractRenderServer
2229
from idom.server.prefab import hotswap_server
2330
from idom.server.utils import find_available_port, find_builtin_server_type
@@ -167,3 +174,107 @@ def __init__(self) -> None:
167174

168175
def handle(self, record: logging.LogRecord) -> None:
169176
self.records.append(record)
177+
178+
179+
class HookCatcher:
180+
"""Utility for capturing a LifeCycleHook from a component
181+
182+
Example:
183+
.. code-block::
184+
185+
hooks = HookCatcher(index_by_kwarg="key")
186+
187+
@idom.component
188+
@hooks.capture
189+
def MyComponent(key):
190+
...
191+
192+
... # render the component
193+
194+
# grab the last render of where MyComponent(key='some_key')
195+
hooks.index["some_key"]
196+
# or grab the hook from the component's last render
197+
hooks.latest
198+
199+
After the first render of ``MyComponent`` the ``HookCatcher`` will have
200+
captured the component's ``LifeCycleHook``.
201+
"""
202+
203+
latest: LifeCycleHook
204+
205+
def __init__(self, index_by_kwarg: Optional[str] = None):
206+
self.index_by_kwarg = index_by_kwarg
207+
self.index: Dict[Any, LifeCycleHook] = {}
208+
209+
def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]:
210+
"""Decorator for capturing a ``LifeCycleHook`` on each render of a component"""
211+
212+
# The render function holds a reference to `self` and, via the `LifeCycleHook`,
213+
# the component. Some tests check whether components are garbage collected, thus
214+
# we must use a `ref` here to ensure these checks pass once the catcher itself
215+
# has been collected.
216+
self_ref = ref(self)
217+
218+
@wraps(render_function)
219+
def wrapper(*args: Any, **kwargs: Any) -> Any:
220+
self = self_ref()
221+
assert self is not None, "Hook catcher has been garbage collected"
222+
223+
hook = current_hook()
224+
if self.index_by_kwarg is not None:
225+
self.index[kwargs[self.index_by_kwarg]] = hook
226+
self.latest = hook
227+
return render_function(*args, **kwargs)
228+
229+
return wrapper
230+
231+
232+
class StaticEventHandlers:
233+
"""Utility for capturing the target of a static set of event handlers
234+
235+
Example:
236+
.. code-block::
237+
238+
static_handlers = StaticEventHandlers("first", "second")
239+
240+
@idom.component
241+
def MyComponent(key):
242+
state, set_state = idom.hooks.use_state(0)
243+
handler = static_handlers.use(key, lambda event: set_state(state + 1))
244+
return idom.html.button({"onClick": handler}, "Click me!")
245+
246+
# gives the target ID for onClick where MyComponent(key="first")
247+
first_target = static_handlers.targets["first"]
248+
"""
249+
250+
def __init__(self, *index: Any) -> None:
251+
if not index:
252+
raise ValueError("Static set of index keys are required")
253+
self._handlers: Dict[Any, EventHandler] = {i: EventHandler() for i in index}
254+
self.targets: Dict[Any, str] = {i: hex_id(h) for i, h in self._handlers.items()}
255+
256+
@overload
257+
def use(
258+
self,
259+
index: Any,
260+
function: Literal[None] = ...,
261+
) -> Callable[[Callable[..., Any]], EventHandler]:
262+
...
263+
264+
@overload
265+
def use(self, index: Any, function: Callable[..., Any]) -> EventHandler:
266+
...
267+
268+
def use(self, index: Any, function: Optional[Callable[..., Any]] = None) -> Any:
269+
"""Decorator for capturing an event handler function"""
270+
271+
def setup(function: Callable[..., Any]) -> EventHandler:
272+
handler = self._handlers[index]
273+
handler.clear()
274+
handler.add(function)
275+
return handler
276+
277+
if function is not None:
278+
return setup(function)
279+
else:
280+
return setup

tests/general_utils.py

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
# dialect=pytest
22

33
from contextlib import contextmanager
4-
from functools import wraps
5-
from weakref import ref
6-
7-
import idom
8-
from idom.core.events import EventHandler
9-
from idom.core.utils import hex_id
104

115

126
@contextmanager
@@ -21,74 +15,6 @@ def patch_slots_object(obj, attr, new_value):
2115
setattr(obj, attr, old_value)
2216

2317

24-
class EventCatcher:
25-
"""Utility for capturing the target of an event handler
26-
27-
Example:
28-
.. code-block::
29-
30-
event_catcher = EventCatcher()
31-
32-
@idom.component
33-
def MyComponent():
34-
state, set_state = idom.hooks.use_state(0)
35-
handler = event_catcher.capture(lambda event: set_state(state + 1))
36-
return idom.html.button({"onClick": handler}, "Click me!")
37-
"""
38-
39-
def __init__(self):
40-
self._event_handler = EventHandler()
41-
42-
@property
43-
def target(self) -> str:
44-
return hex_id(self._event_handler)
45-
46-
def capture(self, function) -> EventHandler:
47-
"""Called within the body of a component to create a captured event handler"""
48-
self._event_handler.clear()
49-
self._event_handler.add(function)
50-
return self._event_handler
51-
52-
53-
class HookCatcher:
54-
"""Utility for capturing a LifeCycleHook from a component
55-
56-
Example:
57-
.. code-block::
58-
59-
component_hook = HookCatcher()
60-
61-
@idom.component
62-
@component_hook.capture
63-
def MyComponent():
64-
...
65-
66-
After the first render of ``MyComponent`` the ``HookCatcher`` will have
67-
captured the component's ``LifeCycleHook``.
68-
"""
69-
70-
current: idom.hooks.LifeCycleHook
71-
72-
def capture(self, render_function):
73-
"""Decorator for capturing a ``LifeCycleHook`` on the first render of a component"""
74-
75-
# The render function holds a reference to `self` and, via the `LifeCycleHook`,
76-
# the component. Some tests check whether components are garbage collected, thus we
77-
# must use a `ref` here to ensure these checks pass.
78-
self_ref = ref(self)
79-
80-
@wraps(render_function)
81-
def wrapper(*args, **kwargs):
82-
self_ref().current = idom.hooks.current_hook()
83-
return render_function(*args, **kwargs)
84-
85-
return wrapper
86-
87-
def schedule_render(self) -> None:
88-
"""Useful alias of ``HookCatcher.current.schedule_render``"""
89-
self.current.schedule_render()
90-
91-
9218
def assert_same_items(left, right):
9319
"""Check that two unordered sequences are equal (only works if reprs are equal)"""
9420
sorted_left = list(sorted(left, key=repr))

tests/test_core/test_dispatcher.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
SingleViewDispatcher,
1111
)
1212
from idom.core.layout import Layout, LayoutEvent
13-
from tests.general_utils import EventCatcher, assert_same_items
13+
from idom.testing import StaticEventHandlers
14+
from tests.general_utils import assert_same_items
1415

1516

1617
async def test_shared_state_dispatcher():
@@ -19,9 +20,9 @@ async def test_shared_state_dispatcher():
1920
changes_2 = []
2021

2122
event_name = "onEvent"
22-
event_catcher = EventCatcher()
23+
event_handlers = StaticEventHandlers(event_name)
2324

24-
events_to_inject = [LayoutEvent(target=event_catcher.target, data=[])] * 4
25+
events_to_inject = [LayoutEvent(event_handlers.targets[event_name], [])] * 4
2526

2627
async def send_1(patch):
2728
changes_1.append(patch.changes)
@@ -47,7 +48,7 @@ async def recv_2():
4748
@idom.component
4849
def Clickable():
4950
count, set_count = idom.hooks.use_state(0)
50-
handler = event_catcher.capture(lambda: set_count(count + 1))
51+
handler = event_handlers.use(event_name, lambda: set_count(count + 1))
5152
return idom.html.div({event_name: handler, "count": count})
5253

5354
async with SharedViewDispatcher(Layout(Clickable())) as dispatcher:
@@ -61,7 +62,7 @@ def Clickable():
6162
"path": "/eventHandlers",
6263
"value": {
6364
event_name: {
64-
"target": event_catcher.target,
65+
"target": event_handlers.targets[event_name],
6566
"preventDefault": False,
6667
"stopPropagation": False,
6768
}

0 commit comments

Comments
 (0)