Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 4fd5cde

Browse filesBrowse 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
Copy full SHA for 4fd5cde

File tree

10 files changed

+148
-90
lines changed
Filter options

10 files changed

+148
-90
lines changed

‎docs/source/about/changelog.rst

Copy file name to clipboardExpand all lines: docs/source/about/changelog.rst
+5Lines changed: 5 additions & 0 deletions
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

‎src/idom/_option.py

Copy file name to clipboardExpand all lines: src/idom/_option.py
+28-3Lines changed: 28 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`
@@ -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})"

‎src/idom/config.py

Copy file name to clipboardExpand all lines: src/idom/config.py
+1-3Lines 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

Copy file name to clipboardExpand all lines: src/idom/core/layout.py
+6-18Lines changed: 6 additions & 18 deletions
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)

‎src/idom/core/vdom.py

Copy file name to clipboardExpand all lines: src/idom/core/vdom.py
+27-43Lines 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

Copy file name to clipboardExpand all lines: src/idom/logging.py
+8-6Lines 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

Copy file name to clipboardExpand all lines: src/idom/web/module.py
+19-7Lines changed: 19 additions & 7 deletions
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,

‎tests/test__option.py

Copy file name to clipboardExpand all lines: tests/test__option.py
+25Lines changed: 25 additions & 0 deletions
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"]

‎tests/test_config.py

Copy file name to clipboard
+29Lines changed: 29 additions & 0 deletions
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

‎tests/test_core/test_vdom.py

Copy file name to clipboardExpand all lines: tests/test_core/test_vdom.py
-10Lines changed: 0 additions & 10 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.