Skip to content

Commit 29c0e85

Browse files
committed
Change VdomDict into a dict subclass
1 parent ef21aeb commit 29c0e85

File tree

8 files changed

+104
-57
lines changed

8 files changed

+104
-57
lines changed

src/reactpy/_html.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
from __future__ import annotations
22

33
from collections.abc import Sequence
4-
from typing import TYPE_CHECKING, ClassVar, overload
4+
from typing import ClassVar, overload
55

66
from reactpy.core.vdom import Vdom
7-
8-
if TYPE_CHECKING:
9-
from reactpy.types import (
10-
EventHandlerDict,
11-
Key,
12-
VdomAttributes,
13-
VdomChild,
14-
VdomChildren,
15-
VdomDict,
16-
VdomDictConstructor,
17-
)
7+
from reactpy.types import (
8+
EventHandlerDict,
9+
Key,
10+
VdomAttributes,
11+
VdomChild,
12+
VdomChildren,
13+
VdomDict,
14+
VdomDictConstructor,
15+
)
1816

1917
__all__ = ["html"]
2018

@@ -109,7 +107,7 @@ def _fragment(
109107
if attributes or event_handlers:
110108
msg = "Fragments cannot have attributes besides 'key'"
111109
raise TypeError(msg)
112-
model: VdomDict = {"tagName": ""}
110+
model = VdomDict(tagName="")
113111

114112
if children:
115113
model["children"] = children
@@ -143,7 +141,7 @@ def _script(
143141
Doing so may allow for malicious code injection
144142
(`XSS <https://en.wikipedia.org/wiki/Cross-site_scripting>`__`).
145143
"""
146-
model: VdomDict = {"tagName": "script"}
144+
model = VdomDict(tagName="script")
147145

148146
if event_handlers:
149147
msg = "'script' elements do not support event handlers"

src/reactpy/core/hooks.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@
2020

2121
from reactpy.config import REACTPY_DEBUG
2222
from reactpy.core._life_cycle_hook import HOOK_STACK
23-
from reactpy.types import Connection, Context, Key, Location, State, VdomDict
23+
from reactpy.types import (
24+
Connection,
25+
Context,
26+
Key,
27+
Location,
28+
State,
29+
VdomDict,
30+
)
2431
from reactpy.utils import Ref
2532

2633
if not TYPE_CHECKING:
@@ -362,7 +369,7 @@ def __init__(
362369

363370
def render(self) -> VdomDict:
364371
HOOK_STACK.current_hook().set_context_provider(self)
365-
return {"tagName": "", "children": self.children}
372+
return VdomDict(tagName="ContextProvider", children=self.children)
366373

367374
def __repr__(self) -> str:
368375
return f"ContextProvider({self.type})"

src/reactpy/core/layout.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ async def _render_component(
196196
# wrap the model in a fragment (i.e. tagName="") to ensure components have
197197
# a separate node in the model state tree. This could be removed if this
198198
# components are given a node in the tree some other way
199-
wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]}
199+
wrapper_model = VdomDict(tagName="", children=[raw_model])
200200
await self._render_model(exit_stack, old_state, new_state, wrapper_model)
201201
except Exception as error:
202202
logger.exception(f"Failed to render {component}")

src/reactpy/core/vdom.py

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
EventHandlerType,
2727
VdomAttributes,
2828
VdomChildren,
29+
VdomDict,
2930
VdomJson,
30-
_VdomDict,
31+
VdomTypeDict,
3132
)
3233

3334
VDOM_JSON_SCHEMA = {
@@ -107,26 +108,8 @@ def validate_vdom_json(value: Any) -> VdomJson:
107108

108109

109110
def is_vdom(value: Any) -> bool:
110-
"""Return whether a value is a :class:`VdomDict`
111-
112-
This employs a very simple heuristic - something is VDOM if:
113-
114-
1. It is a ``dict`` instance
115-
2. It contains the key ``"tagName"``
116-
3. The value of the key ``"tagName"`` is a string
117-
118-
.. note::
119-
120-
Performing an ``isinstance(value, VdomDict)`` check is too restrictive since the
121-
user would be forced to import ``VdomDict`` every time they needed to declare a
122-
VDOM element. Giving the user more flexibility, at the cost of this check's
123-
accuracy, is worth it.
124-
"""
125-
return (
126-
isinstance(value, dict)
127-
and "tagName" in value
128-
and isinstance(value["tagName"], str)
129-
)
111+
"""Return whether a value is a :class:`VdomDict`"""
112+
return isinstance(value, VdomDict)
130113

131114

132115
class Vdom:
@@ -137,7 +120,7 @@ def __init__(
137120
/,
138121
allow_children: bool = True,
139122
custom_constructor: CustomVdomConstructor | None = None,
140-
**kwargs: Unpack[_VdomDict],
123+
**kwargs: Unpack[VdomTypeDict],
141124
) -> None:
142125
"""This init method is used to declare the VDOM dictionary default values, as well as configurable properties
143126
related to the construction of VDOM dictionaries."""
@@ -159,14 +142,14 @@ def __init__(
159142
@overload
160143
def __call__(
161144
self, attributes: VdomAttributes, /, *children: VdomChildren
162-
) -> _VdomDict: ...
145+
) -> VdomDict: ...
163146

164147
@overload
165-
def __call__(self, *children: VdomChildren) -> _VdomDict: ...
148+
def __call__(self, *children: VdomChildren) -> VdomDict: ...
166149

167150
def __call__(
168151
self, *attributes_and_children: VdomAttributes | VdomChildren
169-
) -> _VdomDict:
152+
) -> VdomDict:
170153
"""The entry point for the VDOM API, for example reactpy.html(<WE_ARE_HERE>)."""
171154
attributes, children = separate_attributes_and_children(attributes_and_children)
172155
key = attributes.pop("key", None)
@@ -198,7 +181,7 @@ def __call__(
198181
if REACTPY_DEBUG.current:
199182
self._validate_keys(result.keys())
200183

201-
return cast(_VdomDict, self.default_values | result)
184+
return VdomDict(**(self.default_values | result)) # type: ignore
202185

203186
@staticmethod
204187
def _validate_keys(keys: Sequence[str] | Iterable[str]) -> None:

src/reactpy/types.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Literal,
1212
Protocol,
1313
TypeVar,
14+
Unpack,
1415
overload,
1516
runtime_checkable,
1617
)
@@ -780,8 +781,8 @@ class DangerouslySetInnerHTML(TypedDict):
780781
}
781782

782783

783-
class _VdomDict(TypedDict):
784-
"""Dictionary representation of what the `Vdom` class eventually resolves into."""
784+
class VdomTypeDict(TypedDict):
785+
"""TypedDict representation of what the `VdomDict` should look like."""
785786

786787
tagName: str
787788
key: NotRequired[Key | None]
@@ -791,7 +792,58 @@ class _VdomDict(TypedDict):
791792
importSource: NotRequired[ImportSourceDict]
792793

793794

794-
VdomDict = _VdomDict | dict[str, Any]
795+
class VdomDict(dict):
796+
"""A dictionary representing a virtual DOM element."""
797+
798+
def __init__(self, **kwargs: Unpack[VdomTypeDict]) -> None:
799+
invalid_keys = set(kwargs) - ALLOWED_VDOM_KEYS
800+
if invalid_keys:
801+
msg = f"Invalid keys: {invalid_keys}."
802+
raise ValueError(msg)
803+
804+
super().__init__(**kwargs)
805+
806+
@overload
807+
def __getitem__(self, key: Literal["tagName"]) -> str: ...
808+
@overload
809+
def __getitem__(self, key: Literal["key"]) -> Key | None: ...
810+
@overload
811+
def __getitem__(
812+
self, key: Literal["children"]
813+
) -> Sequence[ComponentType | VdomChild]: ...
814+
@overload
815+
def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ...
816+
@overload
817+
def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ...
818+
@overload
819+
def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ...
820+
def __getitem__(self, key: VdomDictKeys) -> Any:
821+
return super().__getitem__(key)
822+
823+
@overload
824+
def __setitem__(self, key: Literal["tagName"], value: str) -> None: ...
825+
@overload
826+
def __setitem__(self, key: Literal["key"], value: Key | None) -> None: ...
827+
@overload
828+
def __setitem__(
829+
self, key: Literal["children"], value: Sequence[ComponentType | VdomChild]
830+
) -> None: ...
831+
@overload
832+
def __setitem__(
833+
self, key: Literal["attributes"], value: VdomAttributes
834+
) -> None: ...
835+
@overload
836+
def __setitem__(
837+
self, key: Literal["eventHandlers"], value: EventHandlerDict
838+
) -> None: ...
839+
@overload
840+
def __setitem__(
841+
self, key: Literal["importSource"], value: ImportSourceDict
842+
) -> None: ...
843+
def __setitem__(self, key: VdomDictKeys, value: Any) -> None:
844+
if key not in ALLOWED_VDOM_KEYS:
845+
raise KeyError(f"Invalid key: {key}")
846+
super().__setitem__(key, value)
795847

796848

797849
VdomChild: TypeAlias = ComponentType | VdomDict | str | None | Any
@@ -875,14 +927,14 @@ class VdomDictConstructor(Protocol):
875927
@overload
876928
def __call__(
877929
self, attributes: VdomAttributes, /, *children: VdomChildren
878-
) -> VdomDict | dict[str, Any]: ...
930+
) -> VdomDict: ...
879931

880932
@overload
881-
def __call__(self, *children: VdomChildren) -> VdomDict | dict[str, Any]: ...
933+
def __call__(self, *children: VdomChildren) -> VdomDict: ...
882934

883935
def __call__(
884936
self, *attributes_and_children: VdomAttributes | VdomChildren
885-
) -> VdomDict | dict[str, Any]: ...
937+
) -> VdomDict: ...
886938

887939

888940
class LayoutUpdateMessage(TypedDict):

src/reactpy/widgets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def image(
3030
base64_value = b64encode(bytes_value).decode()
3131
src = f"data:image/{format};base64,{base64_value}"
3232

33-
return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}}
33+
return VdomDict(tagName="img", attributes={"src": src, **(attributes or {})})
3434

3535

3636
_Value = TypeVar("_Value")

tests/sample.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
from reactpy import html
44
from reactpy.core.component import component
5-
from reactpy.types import VdomDict
65

76

87
@component
9-
def SampleApp() -> VdomDict:
8+
def SampleApp():
109
return html.div(
1110
{"id": "sample", "style": {"padding": "15px"}},
1211
html.h1("Sample Application"),

tests/test_core/test_vdom.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from reactpy.config import REACTPY_DEBUG
88
from reactpy.core.events import EventHandler
99
from reactpy.core.vdom import Vdom, is_vdom, validate_vdom_json
10-
from reactpy.types import _VdomDict
10+
from reactpy.types import VdomDict, VdomTypeDict
1111

1212
FAKE_EVENT_HANDLER = EventHandler(lambda data: None)
1313
FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER}
@@ -18,13 +18,15 @@
1818
[
1919
(False, {}),
2020
(False, {"tagName": None}),
21-
(False, _VdomDict()),
22-
(True, {"tagName": ""}),
23-
(True, _VdomDict(tagName="")),
21+
(False, {"tagName": ""}),
22+
(False, VdomTypeDict()),
23+
(False, VdomDict()),
24+
(True, VdomDict(tagName="")),
25+
(True, VdomDict(tagName="div")),
2426
],
2527
)
2628
def test_is_vdom(result, value):
27-
assert is_vdom(value) == result
29+
assert result == is_vdom(value)
2830

2931

3032
@pytest.mark.parametrize(
@@ -332,3 +334,9 @@ def test_invalid_vdom_keys():
332334

333335
with pytest.raises(ValueError, match="You must specify a 'tagName'*"):
334336
reactpy.Vdom()
337+
338+
with pytest.raises(ValueError, match="Invalid keys:*"):
339+
reactpy.types.VdomDict(foo="bar")
340+
341+
with pytest.raises(KeyError, match="Invalid key:*"):
342+
reactpy.types.VdomDict()["foo"] = "bar"

0 commit comments

Comments
 (0)