Skip to content

Commit ce115b2

Browse files
committed
report better error for attrs that cannot be serialized
also misc changes to config options to make this eaiser in particular ability to specify options as parents
1 parent e950966 commit ce115b2

File tree

6 files changed

+108
-49
lines changed

6 files changed

+108
-49
lines changed

Diff for: src/py/reactpy/reactpy/_option.py

+29-17
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88

99
_O = TypeVar("_O")
1010
logger = getLogger(__name__)
11+
UNDEFINED = cast(Any, object())
12+
13+
14+
def deprecate(option: Option[_O], message: str) -> Option[_O]:
15+
option.__class__ = _DeprecatedOption
16+
option._deprecation_message = message
17+
return option
1118

1219

1320
class Option(Generic[_O]):
@@ -16,8 +23,9 @@ class Option(Generic[_O]):
1623
def __init__(
1724
self,
1825
name: str,
19-
default: _O | Option[_O],
26+
default: _O = UNDEFINED,
2027
mutable: bool = True,
28+
parent: Option[_O] | None = None,
2129
validator: Callable[[Any], _O] = lambda x: cast(_O, x),
2230
) -> None:
2331
self._name = name
@@ -28,12 +36,15 @@ def __init__(
2836
if name in os.environ:
2937
self._current = validator(os.environ[name])
3038

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:
39+
if parent is not None:
40+
if not (parent.mutable and self.mutable):
41+
raise TypeError("Parent and child options must be mutable")
42+
self._default = parent.default
43+
parent.subscribe(self.set_current)
44+
elif default is not UNDEFINED:
3645
self._default = default
46+
else:
47+
raise TypeError("Must specify either a default or a parent option")
3748

3849
logger.debug(f"{self._name}={self.current}")
3950

@@ -81,11 +92,19 @@ def set_current(self, new: Any) -> None:
8192
8293
Raises a ``TypeError`` if this option is not :attr:`Option.mutable`.
8394
"""
95+
old = self.current
96+
if new is old:
97+
return None
98+
8499
if not self._mutable:
85100
msg = f"{self} cannot be modified after initial load"
86101
raise TypeError(msg)
87-
old = self.current
88-
new = self._current = self._validator(new)
102+
103+
try:
104+
new = self._current = self._validator(new)
105+
except ValueError as error:
106+
raise ValueError(f"Invalid value for {self._name}: {new!r}") from error
107+
89108
logger.debug(f"{self._name}={self._current}")
90109
if new != old:
91110
for sub_func in self._subscribers:
@@ -119,15 +138,8 @@ def __repr__(self) -> str:
119138
return f"Option({self._name}={self.current!r})"
120139

121140

122-
class DeprecatedOption(Option[_O]): # nocov
123-
def __init__(self, message: str, *args: Any, **kwargs: Any) -> None:
124-
self._deprecation_message = message
125-
super().__init__(*args, **kwargs)
126-
141+
class _DeprecatedOption(Option[_O]): # nocov
127142
@Option.current.getter # type: ignore
128143
def current(self) -> _O:
129-
warn(
130-
self._deprecation_message,
131-
DeprecationWarning,
132-
)
144+
warn(self._deprecation_message, DeprecationWarning)
133145
return super().current

Diff for: src/py/reactpy/reactpy/config.py

+40-23
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,58 @@
66
from pathlib import Path
77
from tempfile import TemporaryDirectory
88

9-
from reactpy._option import Option as _Option
9+
from reactpy._option import Option
1010

11-
REACTPY_DEBUG_MODE = _Option(
12-
"REACTPY_DEBUG_MODE",
13-
default=False,
14-
validator=lambda x: bool(int(x)),
15-
)
16-
"""This immutable option turns on/off debug mode
11+
TRUE_VALUES = {"true", "1"}
12+
FALSE_VALUES = {"false", "0"}
1713

18-
The string values ``1`` and ``0`` are mapped to ``True`` and ``False`` respectively.
1914

20-
When debug is on, extra validation measures are applied that negatively impact
21-
performance but can be used to catch bugs during development. Additionally, the default
22-
log level for ReactPy is set to ``DEBUG``.
23-
"""
15+
def boolean(value: str | bool) -> bool:
16+
if isinstance(value, bool):
17+
return value
18+
elif not isinstance(value, str):
19+
raise TypeError(f"Expected str or bool, got {type(value).__name__}")
20+
21+
if value.lower() in TRUE_VALUES:
22+
return True
23+
elif value.lower() in FALSE_VALUES:
24+
return False
25+
else:
26+
raise ValueError(
27+
f"Invalid boolean value {value!r} - expected "
28+
f"one of {list(TRUE_VALUES | FALSE_VALUES)}"
29+
)
2430

25-
REACTPY_CHECK_VDOM_SPEC = _Option(
26-
"REACTPY_CHECK_VDOM_SPEC",
27-
default=REACTPY_DEBUG_MODE,
28-
validator=lambda x: bool(int(x)),
31+
32+
REACTPY_DEBUG_MODE = Option(
33+
"REACTPY_DEBUG_MODE", default=False, validator=boolean, mutable=True
2934
)
30-
"""This immutable option turns on/off checks which ensure VDOM is rendered to spec
35+
"""Get extra logs and validation checks at the cost of performance.
3136
32-
The string values ``1`` and ``0`` are mapped to ``True`` and ``False`` respectively.
37+
This will enable the following:
3338
34-
By default this check is off. When ``REACTPY_DEBUG_MODE=1`` this will be turned on but can
35-
be manually disablled by setting ``REACTPY_CHECK_VDOM_SPEC=0`` in addition.
39+
- :data:`REACTPY_CHECK_VDOM_SPEC`
40+
- :data:`REACTPY_CHECK_JSON_ATTRS`
41+
"""
42+
43+
REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG_MODE)
44+
"""Checks which ensure VDOM is rendered to spec
3645
3746
For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema`
3847
"""
3948

40-
# Because these web modules will be linked dynamically at runtime this can be temporary
49+
REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG_MODE)
50+
"""Checks that all VDOM attributes are JSON serializable
51+
52+
The VDOM spec is not able to enforce this on its own since attributes could anything.
53+
"""
54+
55+
# Because these web modules will be linked dynamically at runtime this can be temporary.
56+
# Assigning to a variable here ensures that the directory is not deleted until the end
57+
# of the program.
4158
_DEFAULT_WEB_MODULES_DIR = TemporaryDirectory()
4259

43-
REACTPY_WEB_MODULES_DIR = _Option(
60+
REACTPY_WEB_MODULES_DIR = Option(
4461
"REACTPY_WEB_MODULES_DIR",
4562
default=Path(_DEFAULT_WEB_MODULES_DIR.name),
4663
validator=Path,
@@ -52,7 +69,7 @@
5269
set of publically available APIs for working with the client.
5370
"""
5471

55-
REACTPY_TESTING_DEFAULT_TIMEOUT = _Option(
72+
REACTPY_TESTING_DEFAULT_TIMEOUT = Option(
5673
"REACTPY_TESTING_DEFAULT_TIMEOUT",
5774
5.0,
5875
mutable=False,

Diff for: src/py/reactpy/reactpy/core/serve.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from anyio import create_task_group
88
from anyio.abc import TaskGroup
99

10+
from reactpy.config import REACTPY_DEBUG_MODE
1011
from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage
1112

1213
logger = getLogger(__name__)
@@ -49,7 +50,18 @@ async def _single_outgoing_loop(
4950
layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine
5051
) -> None:
5152
while True:
52-
await send(await layout.render())
53+
update = await layout.render()
54+
try:
55+
await send(update)
56+
except Exception:
57+
if not REACTPY_DEBUG_MODE.current:
58+
msg = (
59+
"Failed to send update. More info may be available "
60+
"if you enabling debug mode by settings "
61+
"`reactpy.config.REACTPY_DEBUG_MODE.current = True`."
62+
)
63+
logger.error(msg)
64+
raise
5365

5466

5567
async def _single_incoming_loop(

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

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
from collections.abc import Mapping, Sequence
56
from functools import wraps
@@ -8,7 +9,7 @@
89
from fastjsonschema import compile as compile_json_schema
910

1011
from reactpy._warnings import warn
11-
from reactpy.config import REACTPY_DEBUG_MODE
12+
from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG_MODE
1213
from reactpy.core._f_back import f_module_name
1314
from reactpy.core.events import EventHandler, to_event_handler_function
1415
from reactpy.core.types import (
@@ -199,6 +200,8 @@ def vdom(
199200
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
200201

201202
if attributes:
203+
if REACTPY_CHECK_JSON_ATTRS.current:
204+
json.dumps(attributes)
202205
model["attributes"] = attributes
203206

204207
if children:

Diff for: src/py/reactpy/tests/test__option.py

+22-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55

6-
from reactpy._option import DeprecatedOption, Option
6+
from reactpy._option import Option, deprecate
77

88

99
def test_option_repr():
@@ -33,7 +33,7 @@ def test_option_validator():
3333
opt.current = "0"
3434
assert opt.current is False
3535

36-
with pytest.raises(ValueError, match="invalid literal for int"):
36+
with pytest.raises(ValueError, match="Invalid value"):
3737
opt.current = "not-an-int"
3838

3939

@@ -102,10 +102,29 @@ def test_option_subscribe():
102102

103103

104104
def test_deprecated_option():
105-
opt = DeprecatedOption("is deprecated!", "A_FAKE_OPTION", None)
105+
opt = deprecate(Option("A_FAKE_OPTION", None), "is deprecated!")
106106

107107
with pytest.warns(DeprecationWarning, match="is deprecated!"):
108108
assert opt.current is None
109109

110110
with pytest.warns(DeprecationWarning, match="is deprecated!"):
111111
opt.current = "something"
112+
113+
114+
def test_option_parent():
115+
parent_opt = Option("A_FAKE_OPTION", "default-value", mutable=True)
116+
child_opt = Option("A_FAKE_OPTION", parent=parent_opt)
117+
assert child_opt.mutable
118+
assert child_opt.current == "default-value"
119+
120+
parent_opt.current = "new-value"
121+
assert child_opt.current == "new-value"
122+
123+
124+
def test_option_parent_child_must_be_mutable():
125+
mut_parent_opt = Option("A_FAKE_OPTION", "default-value", mutable=True)
126+
immu_parent_opt = Option("A_FAKE_OPTION", "default-value", mutable=False)
127+
with pytest.raises(TypeError, match="must be mutable"):
128+
Option("A_FAKE_OPTION", parent=mut_parent_opt, mutable=False)
129+
with pytest.raises(TypeError, match="must be mutable"):
130+
Option("A_FAKE_OPTION", parent=immu_parent_opt, mutable=None)

Diff for: src/py/reactpy/tests/test_utils.py

-4
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,6 @@ def test_del_html_body_transform():
204204
html.div(SOME_OBJECT),
205205
f"<div>{html_escape(str(SOME_OBJECT))}</div>",
206206
),
207-
(
208-
html.div({"someAttribute": SOME_OBJECT}),
209-
f'<div someattribute="{html_escape(str(SOME_OBJECT))}"></div>',
210-
),
211207
(
212208
html.div(
213209
"hello", html.a({"href": "https://example.com"}, "example"), "world"

0 commit comments

Comments
 (0)