Skip to content

Commit d89f1bc

Browse files
committed
make IDOM_DEBUG_MODE mutable + add Option.subscribe
subscribe() allows users to listen when a mutable option is changed
1 parent 97c0f42 commit d89f1bc

File tree

8 files changed

+103
-76
lines changed

8 files changed

+103
-76
lines changed

src/idom/_option.py

Lines changed: 24 additions & 3 deletions
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`

src/idom/config.py

Lines changed: 1 addition & 3 deletions
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

src/idom/core/layout.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -135,23 +135,12 @@ async def render(self) -> LayoutUpdate:
135135
f"{model_state_id!r} - component already unmounted"
136136
)
137137
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
138+
update = self._create_layout_update(model_state)
139+
if IDOM_CHECK_VDOM_SPEC.current:
140+
root_id = self._root_life_cycle_state_id
141+
root_model = self._model_states_by_life_cycle_state_id[root_id]
142+
validate_vdom_json(root_model.model.current)
143+
return update
155144

156145
def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate:
157146
new_state = _copy_component_model_state(old_state)

src/idom/core/vdom.py

Lines changed: 27 additions & 43 deletions
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 "..."

src/idom/logging.py

Lines changed: 8 additions & 6 deletions
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")

src/idom/web/module.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
def module_from_url(
3636
url: str,
3737
fallback: Optional[Any] = None,
38-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
38+
resolve_exports: bool | None = None,
3939
resolve_exports_depth: int = 5,
4040
unmount_before_update: bool = False,
4141
) -> WebModule:
@@ -64,7 +64,11 @@ def module_from_url(
6464
file=None,
6565
export_names=(
6666
resolve_module_exports_from_url(url, resolve_exports_depth)
67-
if resolve_exports
67+
if (
68+
resolve_exports
69+
if resolve_exports is not None
70+
else IDOM_DEBUG_MODE.current
71+
)
6872
else None
6973
),
7074
unmount_before_update=unmount_before_update,
@@ -79,7 +83,7 @@ def module_from_template(
7983
package: str,
8084
cdn: str = "https://esm.sh",
8185
fallback: Optional[Any] = None,
82-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
86+
resolve_exports: bool | None = None,
8387
resolve_exports_depth: int = 5,
8488
unmount_before_update: bool = False,
8589
) -> WebModule:
@@ -153,7 +157,7 @@ def module_from_file(
153157
name: str,
154158
file: Union[str, Path],
155159
fallback: Optional[Any] = None,
156-
resolve_exports: bool = IDOM_DEBUG_MODE.current,
160+
resolve_exports: bool | None = None,
157161
resolve_exports_depth: int = 5,
158162
unmount_before_update: bool = False,
159163
symlink: bool = False,

tests/test__option.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,25 @@ 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"]

tests/test_config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from idom.config import IDOM_DEBUG_MODE
2+
3+
4+
def test_idom_debug_mode_toggle():
5+
# just check that nothing breaks
6+
IDOM_DEBUG_MODE.current = True
7+
IDOM_DEBUG_MODE.current = False

0 commit comments

Comments
 (0)