Skip to content

Commit a3d79aa

Browse files
authored
report better error for attrs that cannot be serialized (#1008)
* 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 * add more tests * no need to be clever - just inherit * fix tests * ignore missing cov * add test for non-json serializable attrs * add changelog entry * fix tests * try node 16? https://github.com/nodesource/distributions#using-ubuntu-3 * install npm explicitey * try install in different order * update first * explicitely install npm i guess...
1 parent 678afe0 commit a3d79aa

File tree

11 files changed

+185
-75
lines changed

11 files changed

+185
-75
lines changed

Diff for: docs/Dockerfile

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ WORKDIR /app/
44

55
# Install NodeJS
66
# --------------
7-
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash -
8-
RUN apt-get install -yq nodejs build-essential
7+
RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
8+
RUN apt-get install -y build-essential nodejs npm
99
RUN npm install -g [email protected]
1010

1111
# Install Poetry

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

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ more info, see the :ref:`Contributor Guide <Creating a Changelog Entry>`.
2323
Unreleased
2424
----------
2525

26-
No changes.
26+
**Fixed**
27+
28+
- :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`)
29+
- :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`)
2730

2831

2932
v1.0.0

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

+36-15
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
_O = TypeVar("_O")
1010
logger = getLogger(__name__)
11+
UNDEFINED = cast(Any, object())
1112

1213

1314
class Option(Generic[_O]):
@@ -16,8 +17,9 @@ class Option(Generic[_O]):
1617
def __init__(
1718
self,
1819
name: str,
19-
default: _O | Option[_O],
20+
default: _O = UNDEFINED,
2021
mutable: bool = True,
22+
parent: Option[_O] | None = None,
2123
validator: Callable[[Any], _O] = lambda x: cast(_O, x),
2224
) -> None:
2325
self._name = name
@@ -28,12 +30,15 @@ def __init__(
2830
if name in os.environ:
2931
self._current = validator(os.environ[name])
3032

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:
33+
if parent is not None:
34+
if not (parent.mutable and self.mutable):
35+
raise TypeError("Parent and child options must be mutable")
36+
self._default = parent.default
37+
parent.subscribe(self.set_current)
38+
elif default is not UNDEFINED:
3639
self._default = default
40+
else:
41+
raise TypeError("Must specify either a default or a parent option")
3742

3843
logger.debug(f"{self._name}={self.current}")
3944

@@ -81,11 +86,19 @@ def set_current(self, new: Any) -> None:
8186
8287
Raises a ``TypeError`` if this option is not :attr:`Option.mutable`.
8388
"""
89+
old = self.current
90+
if new is old:
91+
return None
92+
8493
if not self._mutable:
8594
msg = f"{self} cannot be modified after initial load"
8695
raise TypeError(msg)
87-
old = self.current
88-
new = self._current = self._validator(new)
96+
97+
try:
98+
new = self._current = self._validator(new)
99+
except ValueError as error:
100+
raise ValueError(f"Invalid value for {self._name}: {new!r}") from error
101+
89102
logger.debug(f"{self._name}={self._current}")
90103
if new != old:
91104
for sub_func in self._subscribers:
@@ -119,15 +132,23 @@ def __repr__(self) -> str:
119132
return f"Option({self._name}={self.current!r})"
120133

121134

122-
class DeprecatedOption(Option[_O]): # nocov
123-
def __init__(self, message: str, *args: Any, **kwargs: Any) -> None:
124-
self._deprecation_message = message
135+
class DeprecatedOption(Option[_O]):
136+
"""An option that will warn when it is accessed"""
137+
138+
def __init__(self, *args: Any, message: str, **kwargs: Any) -> None:
125139
super().__init__(*args, **kwargs)
140+
self._deprecation_message = message
126141

127142
@Option.current.getter # type: ignore
128143
def current(self) -> _O:
129-
warn(
130-
self._deprecation_message,
131-
DeprecationWarning,
132-
)
144+
try:
145+
# we access the current value during init to debug log it
146+
# no need to warn unless it's actually used. since this attr
147+
# is only set after super().__init__ is called, we can check
148+
# for it to determine if it's being accessed by a user.
149+
msg = self._deprecation_message
150+
except AttributeError:
151+
pass
152+
else:
153+
warn(msg, DeprecationWarning)
133154
return super().current

Diff for: src/py/reactpy/reactpy/backend/_common.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str
114114
head = cast(VdomDict, {**head, "tagName": ""})
115115
return vdom_to_html(head)
116116
else:
117-
return vdom_to_html(html._(head))
117+
return vdom_to_html(html._(*head))
118118

119119

120120
@dataclass

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

+44-23
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,65 @@
33
variables or, for those which allow it, a programmatic interface.
44
"""
55

6+
from __future__ import annotations
7+
68
from pathlib import Path
79
from tempfile import TemporaryDirectory
810

9-
from reactpy._option import Option as _Option
11+
from reactpy._option import Option
1012

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
13+
TRUE_VALUES = {"true", "1"}
14+
FALSE_VALUES = {"false", "0"}
1715

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

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-
"""
17+
def boolean(value: str | bool | int) -> bool:
18+
if isinstance(value, bool):
19+
return value
20+
elif isinstance(value, int):
21+
return bool(value)
22+
elif not isinstance(value, str):
23+
raise TypeError(f"Expected str or bool, got {type(value).__name__}")
24+
25+
if value.lower() in TRUE_VALUES:
26+
return True
27+
elif value.lower() in FALSE_VALUES:
28+
return False
29+
else:
30+
raise ValueError(
31+
f"Invalid boolean value {value!r} - expected "
32+
f"one of {list(TRUE_VALUES | FALSE_VALUES)}"
33+
)
34+
2435

25-
REACTPY_CHECK_VDOM_SPEC = _Option(
26-
"REACTPY_CHECK_VDOM_SPEC",
27-
default=REACTPY_DEBUG_MODE,
28-
validator=lambda x: bool(int(x)),
36+
REACTPY_DEBUG_MODE = Option(
37+
"REACTPY_DEBUG_MODE", default=False, validator=boolean, mutable=True
2938
)
30-
"""This immutable option turns on/off checks which ensure VDOM is rendered to spec
39+
"""Get extra logs and validation checks at the cost of performance.
3140
32-
The string values ``1`` and ``0`` are mapped to ``True`` and ``False`` respectively.
41+
This will enable the following:
3342
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.
43+
- :data:`REACTPY_CHECK_VDOM_SPEC`
44+
- :data:`REACTPY_CHECK_JSON_ATTRS`
45+
"""
46+
47+
REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG_MODE)
48+
"""Checks which ensure VDOM is rendered to spec
3649
3750
For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema`
3851
"""
3952

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

43-
REACTPY_WEB_MODULES_DIR = _Option(
64+
REACTPY_WEB_MODULES_DIR = Option(
4465
"REACTPY_WEB_MODULES_DIR",
4566
default=Path(_DEFAULT_WEB_MODULES_DIR.name),
4667
validator=Path,
@@ -52,7 +73,7 @@
5273
set of publicly available APIs for working with the client.
5374
"""
5475

55-
REACTPY_TESTING_DEFAULT_TIMEOUT = _Option(
76+
REACTPY_TESTING_DEFAULT_TIMEOUT = Option(
5677
"REACTPY_TESTING_DEFAULT_TIMEOUT",
5778
5.0,
5879
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: # nocov
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 setting "
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

+7-8
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from __future__ import annotations
22

3-
import logging
3+
import json
44
from collections.abc import Mapping, Sequence
55
from functools import wraps
66
from typing import Any, Protocol, cast, overload
77

88
from fastjsonschema import compile as compile_json_schema
99

1010
from reactpy._warnings import warn
11-
from reactpy.config import REACTPY_DEBUG_MODE
11+
from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG_MODE
1212
from reactpy.core._f_back import f_module_name
1313
from reactpy.core.events import EventHandler, to_event_handler_function
1414
from reactpy.core.types import (
@@ -25,9 +25,6 @@
2525
VdomJson,
2626
)
2727

28-
logger = logging.getLogger()
29-
30-
3128
VDOM_JSON_SCHEMA = {
3229
"$schema": "http://json-schema.org/draft-07/schema",
3330
"$ref": "#/definitions/element",
@@ -199,6 +196,8 @@ def vdom(
199196
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
200197

201198
if attributes:
199+
if REACTPY_CHECK_JSON_ATTRS.current:
200+
json.dumps(attributes)
202201
model["attributes"] = attributes
203202

204203
if children:
@@ -325,18 +324,18 @@ def _is_single_child(value: Any) -> bool:
325324

326325
def _validate_child_key_integrity(value: Any) -> None:
327326
if hasattr(value, "__iter__") and not hasattr(value, "__len__"):
328-
logger.error(
327+
warn(
329328
f"Did not verify key-path integrity of children in generator {value} "
330329
"- pass a sequence (i.e. list of finite length) in order to verify"
331330
)
332331
else:
333332
for child in value:
334333
if isinstance(child, ComponentType) and child.key is None:
335-
logger.error(f"Key not specified for child in list {child}")
334+
warn(f"Key not specified for child in list {child}", UserWarning)
336335
elif isinstance(child, Mapping) and "key" not in child:
337336
# remove 'children' to reduce log spam
338337
child_copy = {**child, "children": _EllipsisRepr()}
339-
logger.error(f"Key not specified for child in list {child_copy}")
338+
warn(f"Key not specified for child in list {child_copy}", UserWarning)
340339

341340

342341
class _CustomVdomDictConstructor(Protocol):

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

+28-2
Original file line numberDiff line numberDiff line change
@@ -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,36 @@ def test_option_subscribe():
102102

103103

104104
def test_deprecated_option():
105-
opt = DeprecatedOption("is deprecated!", "A_FAKE_OPTION", None)
105+
opt = DeprecatedOption("A_FAKE_OPTION", None, message="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)
131+
132+
133+
def test_no_default_or_parent():
134+
with pytest.raises(
135+
TypeError, match="Must specify either a default or a parent option"
136+
):
137+
Option("A_FAKE_OPTION")

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

+24
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,27 @@ def test_reactpy_debug_mode_toggle():
2727
# just check that nothing breaks
2828
config.REACTPY_DEBUG_MODE.current = True
2929
config.REACTPY_DEBUG_MODE.current = False
30+
31+
32+
def test_boolean():
33+
assert config.boolean(True) is True
34+
assert config.boolean(False) is False
35+
assert config.boolean(1) is True
36+
assert config.boolean(0) is False
37+
assert config.boolean("true") is True
38+
assert config.boolean("false") is False
39+
assert config.boolean("True") is True
40+
assert config.boolean("False") is False
41+
assert config.boolean("TRUE") is True
42+
assert config.boolean("FALSE") is False
43+
assert config.boolean("1") is True
44+
assert config.boolean("0") is False
45+
46+
with pytest.raises(ValueError):
47+
config.boolean("2")
48+
49+
with pytest.raises(ValueError):
50+
config.boolean("")
51+
52+
with pytest.raises(TypeError):
53+
config.boolean(None)

0 commit comments

Comments
 (0)