Skip to content

Commit a03554b

Browse files
committed
implement the use_debug_value hook
1 parent aad455a commit a03554b

File tree

7 files changed

+230
-35
lines changed

7 files changed

+230
-35
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ repos:
44
hooks:
55
- id: black
66
- repo: https://github.com/PyCQA/flake8
7-
rev: 3.7.9
7+
rev: 4.0.1
88
hooks:
99
- id: flake8
1010
- repo: https://github.com/pycqa/isort

src/idom/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .core.events import EventHandler, event
66
from .core.hooks import (
77
use_callback,
8+
use_debug_value,
89
use_effect,
910
use_memo,
1011
use_reducer,
@@ -42,6 +43,7 @@
4243
"run",
4344
"Stop",
4445
"use_callback",
46+
"use_debug_value",
4547
"use_effect",
4648
"use_memo",
4749
"use_reducer",

src/idom/core/hooks.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +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

30+
from .proto import ComponentType
31+
2932

3033
if not TYPE_CHECKING:
3134
# make flake8 think that this variable exists
@@ -392,6 +395,54 @@ def use_ref(initial_value: _StateType) -> Ref[_StateType]:
392395
return _use_const(lambda: Ref(initial_value))
393396

394397

398+
if IDOM_DEBUG_MODE.current:
399+
400+
def use_debug_value(
401+
message: Any | Callable[[], Any],
402+
dependencies: Sequence[Any] | ellipsis | None = ...,
403+
) -> None:
404+
"""Log debug information when the given message changes.
405+
406+
Differently from other hooks, a message is considered to have changed if the
407+
old and new values are ``!=``. Because this comparison is performed on every
408+
render of the component, it may be worth considering the performance cost in
409+
some situations.
410+
411+
Parameters:
412+
message:
413+
The value to log or a memoized function for generating the value.
414+
dependencies:
415+
Dependencies for the memoized function. The message will only be
416+
recomputed if the identity of any value in the given sequence changes
417+
(i.e. their :func:`id` is different). By default these are inferred
418+
based on local variables that are referenced by the given function.
419+
420+
.. note::
421+
422+
This hook only logs if :data:`~idom.config.IDOM_DEBUG_MODE` is active.
423+
"""
424+
old_ref = _use_const(Ref)
425+
memo_func = message if callable(message) else lambda: message
426+
new = use_memo(memo_func, dependencies)
427+
428+
try:
429+
old = old_ref.current
430+
except AttributeError:
431+
old = object()
432+
433+
if old != new:
434+
old_ref.current = new
435+
logger.debug(f"{current_hook().component} {new}")
436+
437+
else: # pragma: no cover
438+
439+
def use_debug_value(
440+
message: Any | Callable[[], Any],
441+
dependencies: Sequence[Any] | ellipsis | None = ...,
442+
) -> None:
443+
"""This hook does nothing because :data:`~idom.config.IDOM_DEBUG_MODE` is off"""
444+
445+
395446
def _use_const(function: Callable[[], _StateType]) -> _StateType:
396447
return current_hook().use_state(function)
397448

@@ -507,6 +558,11 @@ class LifeCycleHook:
507558
"__weakref__",
508559
)
509560

561+
if IDOM_DEBUG_MODE.current:
562+
__slots__ += ("component",)
563+
component: ComponentType
564+
"""Only exists if in :data:`~idom.config.IDOM_DEBUG_MODE` is active."""
565+
510566
def __init__(
511567
self,
512568
schedule_render: Callable[[], None],

src/idom/core/layout.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,15 @@ class _LifeCycleState(NamedTuple):
644644
"""The current component instance"""
645645

646646

647+
if IDOM_DEBUG_MODE.current:
648+
# When in debug mode we bind a hook's associated component
649+
# to it so we can have more information when logging.
650+
651+
class _LifeCycleState(_LifeCycleState):
652+
def __init__(self, *args: Any, **kwargs: Any) -> None:
653+
self.hook.component = self.component
654+
655+
647656
_Type = TypeVar("_Type")
648657

649658

src/idom/testing.py

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Generic,
1515
Iterator,
1616
List,
17+
NoReturn,
1718
Optional,
1819
Tuple,
1920
Type,
@@ -173,8 +174,12 @@ def __exit__(
173174
return None
174175

175176

177+
class LogAssertionError(AssertionError):
178+
"""An assertion error raised in relation to log messages."""
179+
180+
176181
@contextmanager
177-
def assert_idom_logged(
182+
def assert_idom_did_log(
178183
match_message: str = "",
179184
error_type: type[Exception] | None = None,
180185
match_error: str = "",
@@ -192,11 +197,13 @@ def assert_idom_logged(
192197
error_pattern = re.compile(match_error)
193198

194199
try:
195-
with capture_idom_logs() as handler:
200+
with capture_idom_logs(use_existing=clear_matched_records) as log_records:
196201
yield None
197-
finally:
202+
except Exception:
203+
raise
204+
else:
198205
found = False
199-
for record in list(handler.records):
206+
for record in list(log_records):
200207
if (
201208
# record message matches
202209
message_pattern.findall(record.getMessage())
@@ -222,36 +229,79 @@ def assert_idom_logged(
222229
):
223230
found = True
224231
if clear_matched_records:
225-
handler.records.remove(record)
232+
log_records.remove(record)
226233

227234
if not found: # pragma: no cover
228-
conditions = []
229-
if match_message:
230-
conditions.append(f"log message pattern {match_message!r}")
231-
if error_type:
232-
conditions.append(f"exception type {error_type}")
233-
if match_error:
234-
conditions.append(f"error message pattern {match_error!r}")
235-
raise AssertionError(
236-
"Could not find a log record matching the given "
237-
+ " and ".join(conditions)
235+
_raise_log_message_error(
236+
"Could not find a log record matching the given",
237+
match_message,
238+
error_type,
239+
match_error,
238240
)
239241

240242

241243
@contextmanager
242-
def capture_idom_logs() -> Iterator[_LogRecordCaptor]:
243-
"""Capture logs from IDOM"""
244-
if _LOG_RECORD_CAPTOR_SINGLTON in ROOT_LOGGER.handlers:
245-
# this is being handled by an outer capture context
246-
yield _LOG_RECORD_CAPTOR_SINGLTON
247-
return None
244+
def assert_idom_did_not_log(
245+
match_message: str = "",
246+
error_type: type[Exception] | None = None,
247+
match_error: str = "",
248+
clear_matched_records: bool = False,
249+
) -> Iterator[None]:
250+
"""Assert the inverse of :func:`assert_idom_did_log`"""
251+
try:
252+
with assert_idom_did_log(
253+
match_message, error_type, match_error, clear_matched_records
254+
):
255+
yield None
256+
except LogAssertionError:
257+
pass
258+
else:
259+
_raise_log_message_error(
260+
"Did find a log record matching the given",
261+
match_message,
262+
error_type,
263+
match_error,
264+
)
265+
266+
267+
def _raise_log_message_error(
268+
prefix: str,
269+
match_message: str = "",
270+
error_type: type[Exception] | None = None,
271+
match_error: str = "",
272+
) -> NoReturn:
273+
conditions = []
274+
if match_message:
275+
conditions.append(f"log message pattern {match_message!r}")
276+
if error_type:
277+
conditions.append(f"exception type {error_type}")
278+
if match_error:
279+
conditions.append(f"error message pattern {match_error!r}")
280+
raise LogAssertionError(prefix + " " + " and ".join(conditions))
281+
248282

249-
ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR_SINGLTON)
283+
@contextmanager
284+
def capture_idom_logs(use_existing: bool = False) -> Iterator[list[logging.LogRecord]]:
285+
"""Capture logs from IDOM
286+
287+
Parameters:
288+
use_existing:
289+
If already inside an existing capture context yield the same list of logs.
290+
This is useful if you need to mutate the list of logs to affect behavior in
291+
the outer context.
292+
"""
293+
if use_existing:
294+
for handler in reversed(ROOT_LOGGER.handlers):
295+
if isinstance(handler, _LogRecordCaptor):
296+
yield handler.records
297+
return None
298+
299+
handler = _LogRecordCaptor()
300+
ROOT_LOGGER.addHandler(handler)
250301
try:
251-
yield _LOG_RECORD_CAPTOR_SINGLTON
302+
yield handler.records
252303
finally:
253-
ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR_SINGLTON)
254-
_LOG_RECORD_CAPTOR_SINGLTON.records = []
304+
ROOT_LOGGER.removeHandler(handler)
255305

256306

257307
class _LogRecordCaptor(logging.NullHandler):

tests/test_core/test_hooks.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import pytest
55

66
import idom
7+
from idom.config import IDOM_DEBUG_MODE
78
from idom.core.dispatcher import render_json_patch
89
from idom.core.hooks import LifeCycleHook
9-
from idom.testing import HookCatcher
10+
from idom.testing import HookCatcher, assert_idom_did_log, assert_idom_did_not_log
1011
from tests.general_utils import assert_same_items
1112

1213

@@ -915,3 +916,80 @@ def some_memo_func_that_uses_count():
915916
await layout.render()
916917
await did_memo.wait()
917918
did_memo.clear()
919+
920+
921+
@pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="only logs in debug mode")
922+
async def test_use_debug_mode():
923+
set_message = idom.Ref()
924+
component_hook = HookCatcher()
925+
926+
@idom.component
927+
@component_hook.capture
928+
def SomeComponent():
929+
message, set_message.current = idom.use_state("hello")
930+
idom.use_debug_value(f"message is {message!r}")
931+
return idom.html.div()
932+
933+
with idom.Layout(SomeComponent()) as layout:
934+
935+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'hello'"):
936+
await layout.render()
937+
938+
set_message.current("bye")
939+
940+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'bye'"):
941+
await layout.render()
942+
943+
component_hook.latest.schedule_render()
944+
945+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"):
946+
await layout.render()
947+
948+
949+
@pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="only logs in debug mode")
950+
async def test_use_debug_mode_with_factory():
951+
set_message = idom.Ref()
952+
component_hook = HookCatcher()
953+
954+
@idom.component
955+
@component_hook.capture
956+
def SomeComponent():
957+
message, set_message.current = idom.use_state("hello")
958+
idom.use_debug_value(lambda: f"message is {message!r}")
959+
return idom.html.div()
960+
961+
with idom.Layout(SomeComponent()) as layout:
962+
963+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'hello'"):
964+
await layout.render()
965+
966+
set_message.current("bye")
967+
968+
with assert_idom_did_log(r"SomeComponent\(.*?\) message is 'bye'"):
969+
await layout.render()
970+
971+
component_hook.latest.schedule_render()
972+
973+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"):
974+
await layout.render()
975+
976+
977+
@pytest.mark.skipif(IDOM_DEBUG_MODE.current, reason="logs in debug mode")
978+
async def test_use_debug_mode_does_not_log_if_not_in_debug_mode():
979+
set_message = idom.Ref()
980+
981+
@idom.component
982+
def SomeComponent():
983+
message, set_message.current = idom.use_state("hello")
984+
idom.use_debug_value(lambda: f"message is {message!r}")
985+
return idom.html.div()
986+
987+
with idom.Layout(SomeComponent()) as layout:
988+
989+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'hello'"):
990+
await layout.render()
991+
992+
set_message.current("bye")
993+
994+
with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"):
995+
await layout.render()

0 commit comments

Comments
 (0)