Skip to content

Commit 4fd5cde

Browse files
authored
Make IDOM_DEBUG_MODE mutable + add Option.subscribe method (#843)
* make IDOM_DEBUG_MODE mutable + add Option.subscribe subscribe() allows users to listen when a mutable option is changed * update changelog * remove check for children key in attrs * fix tests * fix resolve_exports default * remove unused import
1 parent 0d4def4 commit 4fd5cde

File tree

10 files changed

+148
-90
lines changed

10 files changed

+148
-90
lines changed

Diff for: docs/source/about/changelog.rst

+5
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ Unreleased
3232

3333
- :pull:`835` - ability to customize the ``<head>`` element of IDOM's built-in client.
3434
- :pull:`835` - ``vdom_to_html`` utility function.
35+
- :pull:`843` - Ability to subscribe to changes that are made to mutable options.
36+
37+
**Fixed**
38+
39+
- :issue:`582` - ``IDOM_DEBUG_MODE`` is now mutable and can be changed at runtime
3540

3641

3742
v0.41.0

Diff for: src/idom/_option.py

+28-3
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,25 @@ class Option(Generic[_O]):
1616
def __init__(
1717
self,
1818
name: str,
19-
default: _O,
19+
default: _O | Option[_O],
2020
mutable: bool = True,
2121
validator: Callable[[Any], _O] = lambda x: cast(_O, x),
2222
) -> None:
2323
self._name = name
24-
self._default = default
2524
self._mutable = mutable
2625
self._validator = validator
26+
self._subscribers: list[Callable[[_O], None]] = []
27+
2728
if name in os.environ:
2829
self._current = validator(os.environ[name])
30+
31+
self._default: _O
32+
if isinstance(default, Option):
33+
self._default = default.default
34+
default.subscribe(lambda value: setattr(self, "_default", value))
35+
else:
36+
self._default = default
37+
2938
logger.debug(f"{self._name}={self.current}")
3039

3140
@property
@@ -55,6 +64,14 @@ def current(self, new: _O) -> None:
5564
self.set_current(new)
5665
return None
5766

67+
def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]:
68+
"""Register a callback that will be triggered when this option changes"""
69+
if not self.mutable:
70+
raise TypeError("Immutable options cannot be subscribed to.")
71+
self._subscribers.append(handler)
72+
handler(self.current)
73+
return handler
74+
5875
def is_set(self) -> bool:
5976
"""Whether this option has a value other than its default."""
6077
return hasattr(self, "_current")
@@ -66,8 +83,12 @@ def set_current(self, new: Any) -> None:
6683
"""
6784
if not self._mutable:
6885
raise TypeError(f"{self} cannot be modified after initial load")
69-
self._current = self._validator(new)
86+
old = self.current
87+
new = self._current = self._validator(new)
7088
logger.debug(f"{self._name}={self._current}")
89+
if new != old:
90+
for sub_func in self._subscribers:
91+
sub_func(new)
7192

7293
def set_default(self, new: _O) -> _O:
7394
"""Set the value of this option if not :meth:`Option.is_set`
@@ -86,7 +107,11 @@ def unset(self) -> None:
86107
"""Remove the current value, the default will be used until it is set again."""
87108
if not self._mutable:
88109
raise TypeError(f"{self} cannot be modified after initial load")
110+
old = self.current
89111
delattr(self, "_current")
112+
if self.current != old:
113+
for sub_func in self._subscribers:
114+
sub_func(self.current)
90115

91116
def __repr__(self) -> str:
92117
return f"Option({self._name}={self.current!r})"

Diff for: src/idom/config.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
IDOM_DEBUG_MODE = _Option(
1414
"IDOM_DEBUG_MODE",
1515
default=False,
16-
mutable=False,
1716
validator=lambda x: bool(int(x)),
1817
)
1918
"""This immutable option turns on/off debug mode
@@ -27,8 +26,7 @@
2726

2827
IDOM_CHECK_VDOM_SPEC = _Option(
2928
"IDOM_CHECK_VDOM_SPEC",
30-
default=IDOM_DEBUG_MODE.current,
31-
mutable=False,
29+
default=IDOM_DEBUG_MODE,
3230
validator=lambda x: bool(int(x)),
3331
)
3432
"""This immutable option turns on/off checks which ensure VDOM is rendered to spec

Diff for: src/idom/core/layout.py

+6-18
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import asyncio
55
from collections import Counter
66
from contextlib import ExitStack
7-
from functools import wraps
87
from logging import getLogger
98
from typing import (
109
Any,
@@ -135,23 +134,12 @@ async def render(self) -> LayoutUpdate:
135134
f"{model_state_id!r} - component already unmounted"
136135
)
137136
else:
138-
return self._create_layout_update(model_state)
139-
140-
if IDOM_CHECK_VDOM_SPEC.current:
141-
# If in debug mode inject a function that ensures all returned updates
142-
# contain valid VDOM models. We only do this in debug mode or when this check
143-
# is explicitely turned in order to avoid unnecessarily impacting performance.
144-
145-
_debug_render = render
146-
147-
@wraps(_debug_render)
148-
async def render(self) -> LayoutUpdate:
149-
result = await self._debug_render()
150-
# Ensure that the model is valid VDOM on each render
151-
root_id = self._root_life_cycle_state_id
152-
root_model = self._model_states_by_life_cycle_state_id[root_id]
153-
validate_vdom_json(root_model.model.current)
154-
return result
137+
update = self._create_layout_update(model_state)
138+
if IDOM_CHECK_VDOM_SPEC.current:
139+
root_id = self._root_life_cycle_state_id
140+
root_model = self._model_states_by_life_cycle_state_id[root_id]
141+
validate_vdom_json(root_model.model.current)
142+
return update
155143

156144
def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate:
157145
new_state = _copy_component_model_state(old_state)

Diff for: src/idom/core/vdom.py

+27-43
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
to_event_handler_function,
1414
)
1515
from idom.core.types import (
16+
ComponentType,
1617
EventHandlerDict,
1718
EventHandlerMapping,
1819
EventHandlerType,
@@ -295,47 +296,30 @@ def _is_attributes(value: Any) -> bool:
295296
return isinstance(value, Mapping) and "tagName" not in value
296297

297298

298-
if IDOM_DEBUG_MODE.current:
299-
300-
_debug_is_attributes = _is_attributes
301-
302-
def _is_attributes(value: Any) -> bool:
303-
result = _debug_is_attributes(value)
304-
if result and "children" in value:
305-
logger.error(f"Reserved key 'children' found in attributes {value}")
306-
return result
307-
308-
309299
def _is_single_child(value: Any) -> bool:
310-
return isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__")
311-
312-
313-
if IDOM_DEBUG_MODE.current:
314-
315-
_debug_is_single_child = _is_single_child
316-
317-
def _is_single_child(value: Any) -> bool:
318-
if _debug_is_single_child(value):
319-
return True
320-
321-
from .types import ComponentType
322-
323-
if hasattr(value, "__iter__") and not hasattr(value, "__len__"):
324-
logger.error(
325-
f"Did not verify key-path integrity of children in generator {value} "
326-
"- pass a sequence (i.e. list of finite length) in order to verify"
327-
)
328-
else:
329-
for child in value:
330-
if isinstance(child, ComponentType) and child.key is None:
331-
logger.error(f"Key not specified for child in list {child}")
332-
elif isinstance(child, Mapping) and "key" not in child:
333-
# remove 'children' to reduce log spam
334-
child_copy = {**child, "children": _EllipsisRepr()}
335-
logger.error(f"Key not specified for child in list {child_copy}")
336-
337-
return False
338-
339-
class _EllipsisRepr:
340-
def __repr__(self) -> str:
341-
return "..."
300+
if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"):
301+
return True
302+
if IDOM_DEBUG_MODE.current:
303+
_validate_child_key_integrity(value)
304+
return False
305+
306+
307+
def _validate_child_key_integrity(value: Any) -> None:
308+
if hasattr(value, "__iter__") and not hasattr(value, "__len__"):
309+
logger.error(
310+
f"Did not verify key-path integrity of children in generator {value} "
311+
"- pass a sequence (i.e. list of finite length) in order to verify"
312+
)
313+
else:
314+
for child in value:
315+
if isinstance(child, ComponentType) and child.key is None:
316+
logger.error(f"Key not specified for child in list {child}")
317+
elif isinstance(child, Mapping) and "key" not in child:
318+
# remove 'children' to reduce log spam
319+
child_copy = {**child, "children": _EllipsisRepr()}
320+
logger.error(f"Key not specified for child in list {child_copy}")
321+
322+
323+
class _EllipsisRepr:
324+
def __repr__(self) -> str:
325+
return "..."

Diff for: src/idom/logging.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@
1010
"version": 1,
1111
"disable_existing_loggers": False,
1212
"loggers": {
13-
"idom": {
14-
"level": "DEBUG" if IDOM_DEBUG_MODE.current else "INFO",
15-
"handlers": ["console"],
16-
},
13+
"idom": {"handlers": ["console"]},
1714
},
1815
"handlers": {
1916
"console": {
@@ -37,5 +34,10 @@
3734
"""IDOM's root logger instance"""
3835

3936

40-
if IDOM_DEBUG_MODE.current:
41-
ROOT_LOGGER.debug("IDOM is in debug mode")
37+
@IDOM_DEBUG_MODE.subscribe
38+
def _set_debug_level(debug: bool) -> None:
39+
if debug:
40+
ROOT_LOGGER.setLevel("DEBUG")
41+
ROOT_LOGGER.debug("IDOM is in debug mode")
42+
else:
43+
ROOT_LOGGER.setLevel("INFO")

Diff for: src/idom/web/module.py

+19-7
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
def module_from_url(
3737
url: str,
3838
fallback: Optional[Any] = None,
39-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
39+
resolve_exports: bool | None = None,
4040
resolve_exports_depth: int = 5,
4141
unmount_before_update: bool = False,
4242
) -> WebModule:
@@ -65,7 +65,11 @@ def module_from_url(
6565
file=None,
6666
export_names=(
6767
resolve_module_exports_from_url(url, resolve_exports_depth)
68-
if resolve_exports
68+
if (
69+
resolve_exports
70+
if resolve_exports is not None
71+
else IDOM_DEBUG_MODE.current
72+
)
6973
else None
7074
),
7175
unmount_before_update=unmount_before_update,
@@ -80,7 +84,7 @@ def module_from_template(
8084
package: str,
8185
cdn: str = "https://esm.sh",
8286
fallback: Optional[Any] = None,
83-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
87+
resolve_exports: bool | None = None,
8488
resolve_exports_depth: int = 5,
8589
unmount_before_update: bool = False,
8690
) -> WebModule:
@@ -159,7 +163,7 @@ def module_from_file(
159163
name: str,
160164
file: Union[str, Path],
161165
fallback: Optional[Any] = None,
162-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
166+
resolve_exports: bool | None = None,
163167
resolve_exports_depth: int = 5,
164168
unmount_before_update: bool = False,
165169
symlink: bool = False,
@@ -209,7 +213,11 @@ def module_from_file(
209213
file=target_file,
210214
export_names=(
211215
resolve_module_exports_from_file(source_file, resolve_exports_depth)
212-
if resolve_exports
216+
if (
217+
resolve_exports
218+
if resolve_exports is not None
219+
else IDOM_DEBUG_MODE.current
220+
)
213221
else None
214222
),
215223
unmount_before_update=unmount_before_update,
@@ -236,7 +244,7 @@ def module_from_string(
236244
name: str,
237245
content: str,
238246
fallback: Optional[Any] = None,
239-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
247+
resolve_exports: bool | None = None,
240248
resolve_exports_depth: int = 5,
241249
unmount_before_update: bool = False,
242250
) -> WebModule:
@@ -280,7 +288,11 @@ def module_from_string(
280288
file=target_file,
281289
export_names=(
282290
resolve_module_exports_from_file(target_file, resolve_exports_depth)
283-
if resolve_exports
291+
if (
292+
resolve_exports
293+
if resolve_exports is not None
294+
else IDOM_DEBUG_MODE.current
295+
)
284296
else None
285297
),
286298
unmount_before_update=unmount_before_update,

Diff for: tests/test__option.py

+25
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,28 @@ def test_option_set_default():
7474
assert not opt.is_set()
7575
assert opt.set_default("new-value") == "new-value"
7676
assert opt.is_set()
77+
78+
79+
def test_cannot_subscribe_immutable_option():
80+
opt = Option("A_FAKE_OPTION", "default", mutable=False)
81+
with pytest.raises(TypeError, match="Immutable options cannot be subscribed to"):
82+
opt.subscribe(lambda value: None)
83+
84+
85+
def test_option_subscribe():
86+
opt = Option("A_FAKE_OPTION", "default")
87+
88+
calls = []
89+
opt.subscribe(calls.append)
90+
assert calls == ["default"]
91+
92+
opt.current = "default"
93+
# value did not change, so no trigger
94+
assert calls == ["default"]
95+
96+
opt.current = "new-1"
97+
opt.current = "new-2"
98+
assert calls == ["default", "new-1", "new-2"]
99+
100+
opt.unset()
101+
assert calls == ["default", "new-1", "new-2", "default"]

Diff for: tests/test_config.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import pytest
2+
3+
from idom import config
4+
from idom._option import Option
5+
6+
7+
@pytest.fixture(autouse=True)
8+
def reset_options():
9+
options = [value for value in config.__dict__.values() if isinstance(value, Option)]
10+
11+
should_unset = object()
12+
original_values = []
13+
for opt in options:
14+
original_values.append(opt.current if opt.is_set() else should_unset)
15+
16+
yield
17+
18+
for opt, val in zip(options, original_values):
19+
if val is should_unset:
20+
if opt.is_set():
21+
opt.unset()
22+
else:
23+
opt.current = val
24+
25+
26+
def test_idom_debug_mode_toggle():
27+
# just check that nothing breaks
28+
config.IDOM_DEBUG_MODE.current = True
29+
config.IDOM_DEBUG_MODE.current = False

Diff for: tests/test_core/test_vdom.py

-10
Original file line numberDiff line numberDiff line change
@@ -317,16 +317,6 @@ def test_invalid_vdom(value, error_message_pattern):
317317
validate_vdom_json(value)
318318

319319

320-
@pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="Only logs in debug mode")
321-
def test_debug_log_if_children_in_attributes(caplog):
322-
idom.vdom("div", {"children": ["hello"]})
323-
assert len(caplog.records) == 1
324-
assert caplog.records[0].message.startswith(
325-
"Reserved key 'children' found in attributes"
326-
)
327-
caplog.records.clear()
328-
329-
330320
@pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="Only logs in debug mode")
331321
def test_debug_log_cannot_verify_keypath_for_genereators(caplog):
332322
idom.vdom("div", (1 for i in range(10)))

0 commit comments

Comments
 (0)