Skip to content

Commit 04b7b1b

Browse files
committed
Vdom constructor as a class-based interface
1 parent d342218 commit 04b7b1b

File tree

11 files changed

+205
-191
lines changed

11 files changed

+205
-191
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ filterwarnings = """
107107
ignore::DeprecationWarning:uvicorn.*
108108
ignore::DeprecationWarning:websockets.*
109109
ignore::UserWarning:tests.test_core.test_vdom
110+
ignore::UserWarning:tests.test_pyscript.test_components
111+
ignore::UserWarning:tests.test_utils
110112
"""
111113
testpaths = "tests"
112114
xfail_strict = true

src/reactpy/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
use_state,
2020
)
2121
from reactpy.core.layout import Layout
22-
from reactpy.core.vdom import vdom
22+
from reactpy.core.vdom import Vdom
2323
from reactpy.pyscript.components import pyscript_component
2424
from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy
2525

@@ -29,6 +29,7 @@
2929
__all__ = [
3030
"Layout",
3131
"Ref",
32+
"Vdom",
3233
"component",
3334
"config",
3435
"create_context",
@@ -52,7 +53,6 @@
5253
"use_ref",
5354
"use_scope",
5455
"use_state",
55-
"vdom",
5656
"web",
5757
"widgets",
5858
]

src/reactpy/_html.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Sequence
44
from typing import TYPE_CHECKING, ClassVar, overload
55

6-
from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor
6+
from reactpy.core.vdom import Vdom
77

88
if TYPE_CHECKING:
99
from reactpy.types import (
@@ -195,8 +195,8 @@ def __getattr__(self, value: str) -> VdomDictConstructor:
195195
if value in self.__cache__:
196196
return self.__cache__[value]
197197

198-
self.__cache__[value] = make_vdom_constructor(
199-
value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG
198+
self.__cache__[value] = Vdom(
199+
tagName=value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG
200200
)
201201

202202
return self.__cache__[value]
@@ -284,8 +284,8 @@ class HtmlConstructor:
284284

285285
# ruff: noqa: N815
286286
__cache__: ClassVar[dict[str, VdomDictConstructor]] = {
287-
"script": custom_vdom_constructor(_script),
288-
"fragment": custom_vdom_constructor(_fragment),
287+
"script": Vdom(tagName="script", custom_constructor=_script),
288+
"fragment": Vdom(tagName="", custom_constructor=_fragment),
289289
"svg": SvgConstructor(),
290290
}
291291

@@ -295,8 +295,8 @@ def __getattr__(self, value: str) -> VdomDictConstructor:
295295
if value in self.__cache__:
296296
return self.__cache__[value]
297297

298-
self.__cache__[value] = make_vdom_constructor(
299-
value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY
298+
self.__cache__[value] = Vdom(
299+
tagName=value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY
300300
)
301301

302302
return self.__cache__[value]

src/reactpy/core/vdom.py

Lines changed: 104 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
# pyright: reportIncompatibleMethodOverride=false
12
from __future__ import annotations
23

34
import json
4-
from collections.abc import Mapping, Sequence
5-
from functools import wraps
6-
from typing import Any, Callable, Protocol, cast
5+
from collections.abc import Iterable, Mapping, Sequence
6+
from typing import (
7+
Any,
8+
Callable,
9+
Unpack,
10+
cast,
11+
overload,
12+
)
713

814
from fastjsonschema import compile as compile_json_schema
915

@@ -12,17 +18,16 @@
1218
from reactpy.core._f_back import f_module_name
1319
from reactpy.core.events import EventHandler, to_event_handler_function
1420
from reactpy.types import (
21+
ALLOWED_VDOM_KEYS,
1522
ComponentType,
23+
CustomVdomConstructor,
24+
EllipsisRepr,
1625
EventHandlerDict,
1726
EventHandlerType,
18-
ImportSourceDict,
19-
Key,
2027
VdomAttributes,
21-
VdomChild,
2228
VdomChildren,
23-
VdomDict,
24-
VdomDictConstructor,
2529
VdomJson,
30+
_VdomDict,
2631
)
2732

2833
VDOM_JSON_SCHEMA = {
@@ -124,98 +129,83 @@ def is_vdom(value: Any) -> bool:
124129
)
125130

126131

127-
def vdom(tag: str, *attributes_and_children: VdomAttributes | VdomChildren) -> VdomDict:
128-
"""A helper function for creating VDOM elements.
129-
130-
Parameters:
131-
tag:
132-
The type of element (e.g. 'div', 'h1', 'img')
133-
attributes_and_children:
134-
An optional attribute mapping followed by any number of children or
135-
iterables of children. The attribute mapping **must** precede the children,
136-
or children which will be merged into their respective parts of the model.
137-
key:
138-
A string indicating the identity of a particular element. This is significant
139-
to preserve event handlers across updates - without a key, a re-render would
140-
cause these handlers to be deleted, but with a key, they would be redirected
141-
to any newly defined handlers.
142-
event_handlers:
143-
Maps event types to coroutines that are responsible for handling those events.
144-
import_source:
145-
(subject to change) specifies javascript that, when evaluated returns a
146-
React component.
147-
"""
148-
model: VdomDict = {"tagName": tag}
132+
class Vdom:
133+
"""Class that follows VDOM spec, and exposes the user API that can create VDOM elements."""
149134

150-
if not attributes_and_children:
151-
return model
135+
def __init__(
136+
self,
137+
/,
138+
allow_children: bool = True,
139+
custom_constructor: CustomVdomConstructor | None = None,
140+
**kwargs: Unpack[_VdomDict],
141+
) -> None:
142+
"""This init method is used to declare the VDOM dictionary default values, as well as configurable properties
143+
related to the construction of VDOM dictionaries."""
144+
if "tagName" not in kwargs:
145+
msg = "You must specify a 'tagName' for a VDOM element."
146+
raise ValueError(msg)
147+
self._validate_keys(kwargs.keys())
148+
self.allow_children = allow_children
149+
self.custom_constructor = custom_constructor
150+
self.default_values = kwargs
151+
152+
# Configure Python debugger attributes
153+
self.__name__ = kwargs["tagName"]
154+
module_name = f_module_name(1)
155+
if module_name:
156+
self.__module__ = module_name
157+
self.__qualname__ = f"{module_name}.{kwargs['tagName']}"
158+
159+
@overload
160+
def __call__(
161+
self, attributes: VdomAttributes, /, *children: VdomChildren
162+
) -> _VdomDict: ...
152163

153-
attributes, children = separate_attributes_and_children(attributes_and_children)
154-
key = attributes.pop("key", None)
155-
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
164+
@overload
165+
def __call__(self, *children: VdomChildren) -> _VdomDict: ...
156166

157-
if attributes:
167+
def __call__(
168+
self, *attributes_and_children: VdomAttributes | VdomChildren
169+
) -> _VdomDict:
170+
"""The entry point for the VDOM API, for example reactpy.html(<WE_ARE_HERE>)."""
171+
attributes, children = separate_attributes_and_children(attributes_and_children)
172+
key = attributes.pop("key", None)
173+
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
158174
if REACTPY_CHECK_JSON_ATTRS.current:
159175
json.dumps(attributes)
160-
model["attributes"] = attributes
161-
162-
if children:
163-
model["children"] = children
164-
165-
if key is not None:
166-
model["key"] = key
167176

168-
if event_handlers:
169-
model["eventHandlers"] = event_handlers
170-
171-
return model
172-
173-
174-
def make_vdom_constructor(
175-
tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None
176-
) -> VdomDictConstructor:
177-
"""Return a constructor for VDOM dictionaries with the given tag name.
178-
179-
The resulting callable will have the same interface as :func:`vdom` but without its
180-
first ``tag`` argument.
181-
"""
182-
183-
def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict:
184-
model = vdom(tag, *attributes_and_children, **kwargs)
185-
if not allow_children and "children" in model:
186-
msg = f"{tag!r} nodes cannot have children."
177+
# Run custom constructor, if defined
178+
if self.custom_constructor:
179+
result = self.custom_constructor(
180+
key=key,
181+
children=children,
182+
attributes=attributes,
183+
event_handlers=event_handlers,
184+
)
185+
# Otherwise, use the default constructor
186+
else:
187+
result = {
188+
**({"key": key} if key is not None else {}),
189+
**({"children": children} if children else {}),
190+
**({"attributes": attributes} if attributes else {}),
191+
**({"eventHandlers": event_handlers} if event_handlers else {}),
192+
}
193+
194+
# Validate the result
195+
if children and not self.allow_children:
196+
msg = f"{self.default_values.get('tagName')!r} nodes cannot have children."
187197
raise TypeError(msg)
188-
if import_source:
189-
model["importSource"] = import_source
190-
return model
191-
192-
# replicate common function attributes
193-
constructor.__name__ = tag
194-
constructor.__doc__ = (
195-
"Return a new "
196-
f"`<{tag}> <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/{tag}>`__ "
197-
"element represented by a :class:`VdomDict`."
198-
)
199-
200-
module_name = f_module_name(1)
201-
if module_name:
202-
constructor.__module__ = module_name
203-
constructor.__qualname__ = f"{module_name}.{tag}"
198+
if REACTPY_DEBUG.current:
199+
self._validate_keys(result.keys())
204200

205-
return cast(VdomDictConstructor, constructor)
201+
return cast(_VdomDict, self.default_values | result)
206202

207-
208-
def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor:
209-
"""Cast function to VdomDictConstructor"""
210-
211-
@wraps(func)
212-
def wrapper(*attributes_and_children: Any) -> VdomDict:
213-
attributes, children = separate_attributes_and_children(attributes_and_children)
214-
key = attributes.pop("key", None)
215-
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
216-
return func(attributes, children, key, event_handlers)
217-
218-
return cast(VdomDictConstructor, wrapper)
203+
@staticmethod
204+
def _validate_keys(keys: Sequence[str] | Iterable[str]) -> None:
205+
invalid_keys = set(keys) - ALLOWED_VDOM_KEYS
206+
if invalid_keys:
207+
msg = f"Invalid keys {invalid_keys} provided."
208+
raise ValueError(msg)
219209

220210

221211
def separate_attributes_and_children(
@@ -224,48 +214,53 @@ def separate_attributes_and_children(
224214
if not values:
225215
return {}, []
226216

227-
attributes: VdomAttributes
217+
_attributes: VdomAttributes
228218
children_or_iterables: Sequence[Any]
229219
if _is_attributes(values[0]):
230-
attributes, *children_or_iterables = values
220+
_attributes, *children_or_iterables = values
231221
else:
232-
attributes = {}
222+
_attributes = {}
233223
children_or_iterables = values
234224

235-
children: list[Any] = []
236-
for child in children_or_iterables:
237-
if _is_single_child(child):
238-
children.append(child)
239-
else:
240-
children.extend(child)
225+
_children: list[Any] = _flatten_children(children_or_iterables)
241226

242-
return attributes, children
227+
return _attributes, _children
243228

244229

245230
def separate_attributes_and_event_handlers(
246231
attributes: Mapping[str, Any],
247232
) -> tuple[VdomAttributes, EventHandlerDict]:
248-
separated_attributes: VdomAttributes = {}
249-
separated_event_handlers: dict[str, EventHandlerType] = {}
233+
_attributes: VdomAttributes = {}
234+
_event_handlers: dict[str, EventHandlerType] = {}
250235

251236
for k, v in attributes.items():
252237
handler: EventHandlerType
253238

254239
if callable(v):
255240
handler = EventHandler(to_event_handler_function(v))
256241
elif (
257-
# isinstance check on protocols is slow - use function attr pre-check as a
258-
# quick filter before actually performing slow EventHandlerType type check
242+
# `isinstance` check on `Protocol` types is slow. We use pre-checks as an optimization
243+
# before actually performing slow EventHandlerType type check
259244
hasattr(v, "function") and isinstance(v, EventHandlerType)
260245
):
261246
handler = v
262247
else:
263-
separated_attributes[k] = v
248+
_attributes[k] = v
264249
continue
265250

266-
separated_event_handlers[k] = handler
251+
_event_handlers[k] = handler
252+
253+
return _attributes, _event_handlers
267254

268-
return separated_attributes, separated_event_handlers
255+
256+
def _flatten_children(children: Sequence[Any]) -> list[Any]:
257+
_children: list[VdomChildren] = []
258+
for child in children:
259+
if _is_single_child(child):
260+
_children.append(child)
261+
else:
262+
_children.extend(_flatten_children(child))
263+
return _children
269264

270265

271266
def _is_attributes(value: Any) -> bool:
@@ -292,20 +287,5 @@ def _validate_child_key_integrity(value: Any) -> None:
292287
warn(f"Key not specified for child in list {child}", UserWarning)
293288
elif isinstance(child, Mapping) and "key" not in child:
294289
# remove 'children' to reduce log spam
295-
child_copy = {**child, "children": _EllipsisRepr()}
290+
child_copy = {**child, "children": EllipsisRepr()}
296291
warn(f"Key not specified for child in list {child_copy}", UserWarning)
297-
298-
299-
class _CustomVdomDictConstructor(Protocol):
300-
def __call__(
301-
self,
302-
attributes: VdomAttributes,
303-
children: Sequence[VdomChild],
304-
key: Key | None,
305-
event_handlers: EventHandlerDict,
306-
) -> VdomDict: ...
307-
308-
309-
class _EllipsisRepr:
310-
def __repr__(self) -> str:
311-
return "..."

src/reactpy/transforms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None:
2020
if not name.startswith("_"):
2121
getattr(self, name)(vdom)
2222

23-
def normalize_style_attributes(self, vdom: VdomDict) -> None:
23+
def normalize_style_attributes(self, vdom: dict[str, Any]) -> None:
2424
"""Convert style attribute from str -> dict with camelCase keys"""
2525
if (
2626
"attributes" in vdom

0 commit comments

Comments
 (0)