Skip to content

Commit fb39848

Browse files
authored
add use debug value hook (#733)
* add use debug value hook * changelog entry * fix tests * export at top level * no cover * fix doctest
1 parent ce2e3ae commit fb39848

File tree

11 files changed

+173
-44
lines changed

11 files changed

+173
-44
lines changed

docs/source/about/changelog.rst

+7-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ more info, see the :ref:`Contributor Guide <Creating a Changelog Entry>`.
2323
Unreleased
2424
----------
2525

26-
No changes.
26+
**Added**
27+
28+
- :pull:`733` - ``use_debug_value`` hook
29+
30+
**Changed**
31+
32+
- :pull:`733` - renamed ``assert_idom_logged`` testing util to ``assert_idom_did_log``
2733

2834

2935
v0.38.0-a3

src/idom/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
create_context,
88
use_callback,
99
use_context,
10+
use_debug_value,
1011
use_effect,
1112
use_memo,
1213
use_reducer,
@@ -42,6 +43,7 @@
4243
"types",
4344
"use_callback",
4445
"use_context",
46+
"use_debug_value",
4547
"use_effect",
4648
"use_memo",
4749
"use_reducer",

src/idom/core/hooks.py

+50-8
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@
2424

2525
from typing_extensions import Protocol
2626

27+
from idom.config import IDOM_DEBUG_MODE
2728
from idom.utils import Ref
2829

2930
from ._thread_local import ThreadLocal
30-
from .types import Key, VdomDict
31+
from .types import ComponentType, Key, VdomDict
3132
from .vdom import vdom
3233

3334

@@ -204,6 +205,40 @@ def effect() -> None:
204205
return add_effect
205206

206207

208+
def use_debug_value(
209+
message: Any | Callable[[], Any],
210+
dependencies: Sequence[Any] | ellipsis | None = ...,
211+
) -> None:
212+
"""Log debug information when the given message changes.
213+
214+
.. note::
215+
This hook only logs if :data:`~idom.config.IDOM_DEBUG_MODE` is active.
216+
217+
Unlike other hooks, a message is considered to have changed if the old and new
218+
values are ``!=``. Because this comparison is performed on every render of the
219+
component, it may be worth considering the performance cost in some situations.
220+
221+
Parameters:
222+
message:
223+
The value to log or a memoized function for generating the value.
224+
dependencies:
225+
Dependencies for the memoized function. The message will only be recomputed
226+
if the identity of any value in the given sequence changes (i.e. their
227+
:func:`id` is different). By default these are inferred based on local
228+
variables that are referenced by the given function.
229+
"""
230+
if not IDOM_DEBUG_MODE.current:
231+
return # pragma: no cover
232+
233+
old: Ref[Any] = _use_const(lambda: Ref(object()))
234+
memo_func = message if callable(message) else lambda: message
235+
new = use_memo(memo_func, dependencies)
236+
237+
if old.current != new:
238+
old.current = new
239+
logger.debug(f"{current_hook().component} {new}")
240+
241+
207242
def create_context(
208243
default_value: _StateType, name: str | None = None
209244
) -> type[Context[_StateType]]:
@@ -576,7 +611,7 @@ class LifeCycleHook:
576611
577612
# --- start render cycle ---
578613
579-
hook.affect_component_will_render()
614+
hook.affect_component_will_render(...)
580615
581616
hook.set_current()
582617
@@ -614,16 +649,19 @@ class LifeCycleHook:
614649
"""
615650

616651
__slots__ = (
652+
"__weakref__",
653+
"_current_state_index",
654+
"_event_effects",
655+
"_is_rendering",
656+
"_rendered_atleast_once",
617657
"_schedule_render_callback",
618658
"_schedule_render_later",
619-
"_current_state_index",
620659
"_state",
621-
"_rendered_atleast_once",
622-
"_is_rendering",
623-
"_event_effects",
624-
"__weakref__",
660+
"component",
625661
)
626662

663+
component: ComponentType
664+
627665
def __init__(
628666
self,
629667
schedule_render: Callable[[], None],
@@ -662,13 +700,17 @@ def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> N
662700
"""Trigger a function on the occurance of the given effect type"""
663701
self._event_effects[effect_type].append(function)
664702

665-
def affect_component_will_render(self) -> None:
703+
def affect_component_will_render(self, component: ComponentType) -> None:
666704
"""The component is about to render"""
705+
self.component = component
706+
667707
self._is_rendering = True
668708
self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear()
669709

670710
def affect_component_did_render(self) -> None:
671711
"""The component completed a render"""
712+
del self.component
713+
672714
component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT]
673715
for effect in component_did_render_effects:
674716
try:

src/idom/core/layout.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def _render_component(
199199
new_state.model.current = old_state.model.current
200200
else:
201201
life_cycle_hook = life_cycle_state.hook
202-
life_cycle_hook.affect_component_will_render()
202+
life_cycle_hook.affect_component_will_render(component)
203203
try:
204204
life_cycle_hook.set_current()
205205
try:

src/idom/testing/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
from .display import DisplayFixture
33
from .logs import (
44
LogAssertionError,
5+
assert_idom_did_log,
56
assert_idom_did_not_log,
6-
assert_idom_logged,
77
capture_idom_logs,
88
)
99
from .server import ServerFixture
1010

1111

1212
__all__ = [
1313
"assert_idom_did_not_log",
14-
"assert_idom_logged",
14+
"assert_idom_did_log",
1515
"capture_idom_logs",
1616
"clear_idom_web_modules_dir",
1717
"DisplayFixture",

src/idom/testing/logs.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class LogAssertionError(AssertionError):
1414

1515

1616
@contextmanager
17-
def assert_idom_logged(
17+
def assert_idom_did_log(
1818
match_message: str = "",
1919
error_type: type[Exception] | None = None,
2020
match_error: str = "",
@@ -77,7 +77,7 @@ def assert_idom_did_not_log(
7777
) -> Iterator[None]:
7878
"""Assert the inverse of :func:`assert_idom_logged`"""
7979
try:
80-
with assert_idom_logged(match_message, error_type, match_error):
80+
with assert_idom_did_log(match_message, error_type, match_error):
8181
yield None
8282
except LogAssertionError:
8383
pass

tests/test_core/test_hooks.py

+85-6
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import idom
77
from idom import html
8+
from idom.config import IDOM_DEBUG_MODE
89
from idom.core.hooks import COMPONENT_DID_RENDER_EFFECT, LifeCycleHook, current_hook
910
from idom.core.layout import Layout
1011
from idom.core.serve import render_json_patch
11-
from idom.testing import DisplayFixture, HookCatcher, assert_idom_logged, poll
12+
from idom.testing import DisplayFixture, HookCatcher, assert_idom_did_log, poll
13+
from idom.testing.logs import assert_idom_did_not_log
1214
from idom.utils import Ref
1315
from tests.tooling.asserts import assert_same_items
1416

@@ -553,7 +555,7 @@ def bad_effect():
553555

554556
return idom.html.div()
555557

556-
with assert_idom_logged(match_message=r"Layout post-render effect .* failed"):
558+
with assert_idom_did_log(match_message=r"Layout post-render effect .* failed"):
557559
async with idom.Layout(ComponentWithEffect()) as layout:
558560
await layout.render() # no error
559561

@@ -574,7 +576,7 @@ def bad_cleanup():
574576

575577
return idom.html.div()
576578

577-
with assert_idom_logged(match_error=r"Layout post-render effect .* failed"):
579+
with assert_idom_did_log(match_error=r"Layout post-render effect .* failed"):
578580
async with idom.Layout(ComponentWithEffect()) as layout:
579581
await layout.render()
580582
component_hook.latest.schedule_render()
@@ -600,7 +602,7 @@ def bad_cleanup():
600602

601603
return idom.html.div()
602604

603-
with assert_idom_logged(
605+
with assert_idom_did_log(
604606
match_message=r"Pre-unmount effect .*? failed",
605607
error_type=ValueError,
606608
):
@@ -843,7 +845,7 @@ def test_bad_schedule_render_callback():
843845
def bad_callback():
844846
raise ValueError("something went wrong")
845847

846-
with assert_idom_logged(
848+
with assert_idom_did_log(
847849
match_message=f"Failed to schedule render via {bad_callback}"
848850
):
849851
LifeCycleHook(bad_callback).schedule_render()
@@ -1137,7 +1139,7 @@ def bad_effect():
11371139
hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect)
11381140
return idom.html.div()
11391141

1140-
with assert_idom_logged(
1142+
with assert_idom_did_log(
11411143
match_message="Component post-render effect .*? failed",
11421144
error_type=ValueError,
11431145
match_error="The error message",
@@ -1168,3 +1170,80 @@ def SetStateDuringRender():
11681170
# there should be no more renders to perform
11691171
with pytest.raises(asyncio.TimeoutError):
11701172
await asyncio.wait_for(layout.render(), timeout=0.1)
1173+
1174+
1175+
@pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="only logs in debug mode")
1176+
async def test_use_debug_mode():
1177+
set_message = idom.Ref()
1178+
component_hook = HookCatcher()
1179+
1180+
@idom.component
1181+
@component_hook.capture
1182+
def SomeComponent():
1183+
message, set_message.current = idom.use_state("hello")
1184+
idom.use_debug_value(f"message is {message!r}")
1185+
return idom.html.div()
1186+
1187+
async with idom.Layout(SomeComponent()) as layout:
1188+
1189+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'hello'"):
1190+
await layout.render()
1191+
1192+
set_message.current("bye")
1193+
1194+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'bye'"):
1195+
await layout.render()
1196+
1197+
component_hook.latest.schedule_render()
1198+
1199+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"):
1200+
await layout.render()
1201+
1202+
1203+
@pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="only logs in debug mode")
1204+
async def test_use_debug_mode_with_factory():
1205+
set_message = idom.Ref()
1206+
component_hook = HookCatcher()
1207+
1208+
@idom.component
1209+
@component_hook.capture
1210+
def SomeComponent():
1211+
message, set_message.current = idom.use_state("hello")
1212+
idom.use_debug_value(lambda: f"message is {message!r}")
1213+
return idom.html.div()
1214+
1215+
async with idom.Layout(SomeComponent()) as layout:
1216+
1217+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'hello'"):
1218+
await layout.render()
1219+
1220+
set_message.current("bye")
1221+
1222+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'bye'"):
1223+
await layout.render()
1224+
1225+
component_hook.latest.schedule_render()
1226+
1227+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"):
1228+
await layout.render()
1229+
1230+
1231+
@pytest.mark.skipif(IDOM_DEBUG_MODE.current, reason="logs in debug mode")
1232+
async def test_use_debug_mode_does_not_log_if_not_in_debug_mode():
1233+
set_message = idom.Ref()
1234+
1235+
@idom.component
1236+
def SomeComponent():
1237+
message, set_message.current = idom.use_state("hello")
1238+
idom.use_debug_value(lambda: f"message is {message!r}")
1239+
return idom.html.div()
1240+
1241+
async with idom.Layout(SomeComponent()) as layout:
1242+
1243+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'hello'"):
1244+
await layout.render()
1245+
1246+
set_message.current("bye")
1247+
1248+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"):
1249+
await layout.render()

tests/test_core/test_layout.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from idom.testing import (
1818
HookCatcher,
1919
StaticEventHandler,
20-
assert_idom_logged,
20+
assert_idom_did_log,
2121
capture_idom_logs,
2222
)
2323
from idom.utils import Ref
@@ -181,7 +181,7 @@ def OkChild():
181181
def BadChild():
182182
raise ValueError("error from bad child")
183183

184-
with assert_idom_logged(match_error="error from bad child"):
184+
with assert_idom_did_log(match_error="error from bad child"):
185185

186186
async with idom.Layout(Main()) as layout:
187187
patch = await render_json_patch(layout)
@@ -237,7 +237,7 @@ def OkChild():
237237
def BadChild():
238238
raise ValueError("error from bad child")
239239

240-
with assert_idom_logged(match_error="error from bad child"):
240+
with assert_idom_did_log(match_error="error from bad child"):
241241

242242
async with idom.Layout(Main()) as layout:
243243
patch = await render_json_patch(layout)
@@ -734,7 +734,7 @@ def ComponentReturnsDuplicateKeys():
734734
return idom.html.div()
735735

736736
async with idom.Layout(ComponentReturnsDuplicateKeys()) as layout:
737-
with assert_idom_logged(
737+
with assert_idom_did_log(
738738
error_type=ValueError,
739739
match_error=r"Duplicate keys \['duplicate'\] at '/children/0'",
740740
):
@@ -747,7 +747,7 @@ def ComponentReturnsDuplicateKeys():
747747

748748
should_error = True
749749
hook.latest.schedule_render()
750-
with assert_idom_logged(
750+
with assert_idom_did_log(
751751
error_type=ValueError,
752752
match_error=r"Duplicate keys \['duplicate'\] at '/children/0'",
753753
):
@@ -788,7 +788,7 @@ def raise_error():
788788

789789
return idom.html.button({"onClick": raise_error})
790790

791-
with assert_idom_logged(match_error="bad event handler"):
791+
with assert_idom_did_log(match_error="bad event handler"):
792792

793793
async with idom.Layout(ComponentWithBadEventHandler()) as layout:
794794
await layout.render()
@@ -812,7 +812,7 @@ def Child(state):
812812
idom.hooks.use_effect(lambda: lambda: print("unmount", state))
813813
return idom.html.div(state)
814814

815-
with assert_idom_logged(
815+
with assert_idom_did_log(
816816
r"Did not render component with model state ID .*? - component already unmounted",
817817
):
818818
async with idom.Layout(Parent()) as layout:
@@ -1218,7 +1218,7 @@ def bad_should_render(new):
12181218

12191219
return ComponentShouldRender(html.div(), should_render=bad_should_render)
12201220

1221-
with assert_idom_logged(
1221+
with assert_idom_did_log(
12221222
match_message=r".* component failed to check if .* should be rendered",
12231223
error_type=ValueError,
12241224
match_error="The error message",

0 commit comments

Comments
 (0)