From 1fd9b66cda7891897134503304461d49bf8645a5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:10:25 -0800 Subject: [PATCH 01/14] Improved type hints for `reactpy.html` --- src/reactpy/_html.py | 14 ++- src/reactpy/types.py | 273 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 10 deletions(-) diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 61c6ae77f..bf8afe824 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, overload from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor @@ -176,6 +176,14 @@ class SvgConstructor: __cache__: ClassVar[dict[str, VdomDictConstructor]] = {} + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... + def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: @@ -261,6 +269,7 @@ def __getattr__(self, value: str) -> VdomDictConstructor: tspan: VdomDictConstructor use: VdomDictConstructor view: VdomDictConstructor + svg: VdomDictConstructor class HtmlConstructor: @@ -277,6 +286,7 @@ class HtmlConstructor: __cache__: ClassVar[dict[str, VdomDictConstructor]] = { "script": custom_vdom_constructor(_script), "fragment": custom_vdom_constructor(_fragment), + "svg": SvgConstructor(), } def __getattr__(self, value: str) -> VdomDictConstructor: @@ -410,7 +420,7 @@ def __getattr__(self, value: str) -> VdomDictConstructor: # Special Case: SVG elements # Since SVG elements have a different set of allowed children, they are # separated into a different constructor, and are accessed via `html.svg.example()` - svg: SvgConstructor = SvgConstructor() + svg: SvgConstructor html = HtmlConstructor() diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 189915873..e6fe648ce 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -9,12 +9,14 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Generic, Literal, NamedTuple, Protocol, TypeVar, + overload, runtime_checkable, ) @@ -92,14 +94,254 @@ async def __aexit__( """Clean up the view after its final render""" -VdomAttributes = dict[str, Any] -"""Describes the attributes of a :class:`VdomDict`""" - -VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" -"""A single child element of a :class:`VdomDict`""" - -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" -"""Describes a series of :class:`VdomChild` elements""" +EventFunc = Callable[[dict[str, Any]], Awaitable[None] | None] + +# TODO: It's probably better to break this one attributes dict down into what each specific +# HTML node's attributes can be, and make sure those types are resolved correctly within `HtmlConstructor` +VdomAttributes = TypedDict( + "VdomAttributes", + { + "dangerouslySetInnerHTML": dict[str, str], + "suppressContentEditableWarning": bool, + "suppressHydrationWarning": bool, + "style": dict[str, str | int | float], + "accessKey": str, + "aria-": None, + "autoCapitalize": str, + "className": str, + "contentEditable": bool, + "data-": None, + "dir": Literal["ltr", "rtl"], + "draggable": bool, + "enterKeyHint": str, + "htmlFor": str, + "hidden": bool | str, + "id": str, + "is": str, + "inputMode": str, + "itemProp": str, + "lang": str, + "onAnimationEnd": EventFunc, + "onAnimationEndCapture": EventFunc, + "onAnimationIteration": EventFunc, + "onAnimationIterationCapture": EventFunc, + "onAnimationStart": EventFunc, + "onAnimationStartCapture": EventFunc, + "onAuxClick": EventFunc, + "onAuxClickCapture": EventFunc, + "onBeforeInput": EventFunc, + "onBeforeInputCapture": EventFunc, + "onBlur": EventFunc, + "onBlurCapture": EventFunc, + "onClick": EventFunc, + "onClickCapture": EventFunc, + "onCompositionStart": EventFunc, + "onCompositionStartCapture": EventFunc, + "onCompositionEnd": EventFunc, + "onCompositionEndCapture": EventFunc, + "onCompositionUpdate": EventFunc, + "onCompositionUpdateCapture": EventFunc, + "onContextMenu": EventFunc, + "onContextMenuCapture": EventFunc, + "onCopy": EventFunc, + "onCopyCapture": EventFunc, + "onCut": EventFunc, + "onCutCapture": EventFunc, + "onDoubleClick": EventFunc, + "onDoubleClickCapture": EventFunc, + "onDrag": EventFunc, + "onDragCapture": EventFunc, + "onDragEnd": EventFunc, + "onDragEndCapture": EventFunc, + "onDragEnter": EventFunc, + "onDragEnterCapture": EventFunc, + "onDragOver": EventFunc, + "onDragOverCapture": EventFunc, + "onDragStart": EventFunc, + "onDragStartCapture": EventFunc, + "onDrop": EventFunc, + "onDropCapture": EventFunc, + "onFocus": EventFunc, + "onFocusCapture": EventFunc, + "onGotPointerCapture": EventFunc, + "onGotPointerCaptureCapture": EventFunc, + "onKeyDown": EventFunc, + "onKeyDownCapture": EventFunc, + "onKeyPress": EventFunc, + "onKeyPressCapture": EventFunc, + "onKeyUp": EventFunc, + "onKeyUpCapture": EventFunc, + "onLostPointerCapture": EventFunc, + "onLostPointerCaptureCapture": EventFunc, + "onMouseDown": EventFunc, + "onMouseDownCapture": EventFunc, + "onMouseEnter": EventFunc, + "onMouseLeave": EventFunc, + "onMouseMove": EventFunc, + "onMouseMoveCapture": EventFunc, + "onMouseOut": EventFunc, + "onMouseOutCapture": EventFunc, + "onMouseUp": EventFunc, + "onMouseUpCapture": EventFunc, + "onPointerCancel": EventFunc, + "onPointerCancelCapture": EventFunc, + "onPointerDown": EventFunc, + "onPointerDownCapture": EventFunc, + "onPointerEnter": EventFunc, + "onPointerLeave": EventFunc, + "onPointerMove": EventFunc, + "onPointerMoveCapture": EventFunc, + "onPointerOut": EventFunc, + "onPointerOutCapture": EventFunc, + "onPointerUp": EventFunc, + "onPointerUpCapture": EventFunc, + "onPaste": EventFunc, + "onPasteCapture": EventFunc, + "onScroll": EventFunc, + "onScrollCapture": EventFunc, + "onSelect": EventFunc, + "onSelectCapture": EventFunc, + "onTouchCancel": EventFunc, + "onTouchCancelCapture": EventFunc, + "onTouchEnd": EventFunc, + "onTouchEndCapture": EventFunc, + "onTouchMove": EventFunc, + "onTouchMoveCapture": EventFunc, + "onTouchStart": EventFunc, + "onTouchStartCapture": EventFunc, + "onTransitionEnd": EventFunc, + "onTransitionEndCapture": EventFunc, + "onWheel": EventFunc, + "onWheelCapture": EventFunc, + "role": str, + "slot": str, + "spellCheck": bool | None, + "tabIndex": int, + "title": str, + "translate": Literal["yes", "no"], + "onReset": EventFunc, + "onResetCapture": EventFunc, + "onSubmit": EventFunc, + "onSubmitCapture": EventFunc, + "formAction": str | Callable, + "checked": bool, + "value": str, + "defaultChecked": bool, + "defaultValue": str, + "accept": str, + "alt": str, + "capture": str, + "autoComplete": str, + "autoFocus": bool, + "dirname": str, + "disabled": bool, + "form": str, + "formEnctype": str, + "formMethod": str, + "formNoValidate": str, + "formTarget": str, + "height": str, + "list": str, + "max": int, + "maxLength": int, + "min": int, + "minLength": int, + "multiple": bool, + "name": str, + "onChange": EventFunc, + "onChangeCapture": EventFunc, + "onInput": EventFunc, + "onInputCapture": EventFunc, + "onInvalid": EventFunc, + "onInvalidCapture": EventFunc, + "pattern": str, + "placeholder": str, + "readOnly": bool, + "required": bool, + "size": int, + "src": str, + "step": int | Literal["any"], + "type": str, + "width": str, + "label": str, + "cols": int, + "rows": int, + "wrap": Literal["hard", "soft", "off"], + "rel": str, + "precedence": str, + "media": str, + "onError": EventFunc, + "onLoad": EventFunc, + "as": str, + "imageSrcSet": str, + "imageSizes": str, + "sizes": str, + "href": str, + "crossOrigin": str, + "referrerPolicy": str, + "fetchPriority": str, + "hrefLang": str, + "integrity": str, + "blocking": str, + "async": bool, + "noModule": bool, + "nonce": str, + "referrer": str, + "defer": str, + "onToggle": EventFunc, + "onToggleCapture": EventFunc, + "onLoadCapture": EventFunc, + "onErrorCapture": EventFunc, + "onAbort": EventFunc, + "onAbortCapture": EventFunc, + "onCanPlay": EventFunc, + "onCanPlayCapture": EventFunc, + "onCanPlayThrough": EventFunc, + "onCanPlayThroughCapture": EventFunc, + "onDurationChange": EventFunc, + "onDurationChangeCapture": EventFunc, + "onEmptied": EventFunc, + "onEmptiedCapture": EventFunc, + "onEncrypted": EventFunc, + "onEncryptedCapture": EventFunc, + "onEnded": EventFunc, + "onEndedCapture": EventFunc, + "onLoadedData": EventFunc, + "onLoadedDataCapture": EventFunc, + "onLoadedMetadata": EventFunc, + "onLoadedMetadataCapture": EventFunc, + "onLoadStart": EventFunc, + "onLoadStartCapture": EventFunc, + "onPause": EventFunc, + "onPauseCapture": EventFunc, + "onPlay": EventFunc, + "onPlayCapture": EventFunc, + "onPlaying": EventFunc, + "onPlayingCapture": EventFunc, + "onProgress": EventFunc, + "onProgressCapture": EventFunc, + "onRateChange": EventFunc, + "onRateChangeCapture": EventFunc, + "onResize": EventFunc, + "onResizeCapture": EventFunc, + "onSeeked": EventFunc, + "onSeekedCapture": EventFunc, + "onSeeking": EventFunc, + "onSeekingCapture": EventFunc, + "onStalled": EventFunc, + "onStalledCapture": EventFunc, + "onSuspend": EventFunc, + "onSuspendCapture": EventFunc, + "onTimeUpdate": EventFunc, + "onTimeUpdateCapture": EventFunc, + "onVolumeChange": EventFunc, + "onVolumeChangeCapture": EventFunc, + "onWaiting": EventFunc, + "onWaitingCapture": EventFunc, + }, + total=False, + extra_items=Any, +) class _VdomDictOptional(TypedDict, total=False): @@ -118,6 +360,13 @@ class VdomDict(_VdomDictRequired, _VdomDictOptional): """A :ref:`VDOM` dictionary""" +VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" +"""A single child element of a :class:`VdomDict`""" + +VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" +"""Describes a series of :class:`VdomChild` elements""" + + class ImportSourceDict(TypedDict): source: str fallback: Any @@ -194,6 +443,14 @@ class EventHandlerType(Protocol): class VdomDictConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... + def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: ... From ec924c4b7b07d5acf8d352d44acdaaf0a6f7913b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:16:28 -0800 Subject: [PATCH 02/14] Fix type checker errors --- src/reactpy/core/vdom.py | 10 +++++----- src/reactpy/transforms.py | 13 +++++++------ src/reactpy/types.py | 33 +++++++++++++++------------------ src/reactpy/widgets.py | 4 ++-- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 0e6e825a4..896e8af02 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -220,11 +220,11 @@ def wrapper(*attributes_and_children: Any) -> VdomDict: def separate_attributes_and_children( values: Sequence[Any], -) -> tuple[dict[str, Any], list[Any]]: +) -> tuple[VdomAttributes, list[Any]]: if not values: return {}, [] - attributes: dict[str, Any] + attributes: VdomAttributes children_or_iterables: Sequence[Any] if _is_attributes(values[0]): attributes, *children_or_iterables = values @@ -244,8 +244,8 @@ def separate_attributes_and_children( def separate_attributes_and_event_handlers( attributes: Mapping[str, Any], -) -> tuple[dict[str, Any], EventHandlerDict]: - separated_attributes = {} +) -> tuple[VdomAttributes, EventHandlerDict]: + separated_attributes: VdomAttributes = {} separated_event_handlers: dict[str, EventHandlerType] = {} for k, v in attributes.items(): @@ -265,7 +265,7 @@ def separate_attributes_and_event_handlers( separated_event_handlers[k] = handler - return separated_attributes, dict(separated_event_handlers.items()) + return separated_attributes, separated_event_handlers def _is_attributes(value: Any) -> bool: diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index 74c3f3f92..d16931974 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.types import VdomDict +from reactpy.types import VdomAttributes, VdomDict class RequiredTransforms: @@ -40,10 +40,11 @@ def normalize_style_attributes(self, vdom: VdomDict) -> None: def html_props_to_reactjs(vdom: VdomDict) -> None: """Convert HTML prop names to their ReactJS equivalents.""" if "attributes" in vdom: - vdom["attributes"] = { - REACT_PROP_SUBSTITUTIONS.get(k, k): v - for k, v in vdom["attributes"].items() - } + items = cast(VdomAttributes, vdom["attributes"].items()) + vdom["attributes"] = cast( + VdomAttributes, + {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in items}, + ) @staticmethod def textarea_children_to_prop(vdom: VdomDict) -> None: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index e6fe648ce..dc7f041b4 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -2,18 +2,18 @@ import sys from collections import namedtuple -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType from typing import ( TYPE_CHECKING, Any, - Awaitable, Callable, Generic, Literal, NamedTuple, + NotRequired, Protocol, TypeVar, overload, @@ -101,6 +101,9 @@ async def __aexit__( VdomAttributes = TypedDict( "VdomAttributes", { + "key": Key, + "value": Any, + "defaultValue": Any, "dangerouslySetInnerHTML": dict[str, str], "suppressContentEditableWarning": bool, "suppressHydrationWarning": bool, @@ -225,9 +228,7 @@ async def __aexit__( "onSubmitCapture": EventFunc, "formAction": str | Callable, "checked": bool, - "value": str, "defaultChecked": bool, - "defaultValue": str, "accept": str, "alt": str, "capture": str, @@ -340,24 +341,20 @@ async def __aexit__( "onWaitingCapture": EventFunc, }, total=False, - extra_items=Any, + # TODO: Enable this when Python 3.14 typing extensions are released + # extra_items=Any, ) -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ComponentType | VdomChild] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict - +class VdomDict(TypedDict): + """A :ref:`VDOM` dictionary""" -class _VdomDictRequired(TypedDict, total=True): tagName: str - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" + key: NotRequired[Key | None] + children: NotRequired[Sequence[ComponentType | VdomChild]] + attributes: NotRequired[VdomAttributes] + eventHandlers: NotRequired[EventHandlerDict] + importSource: NotRequired[ImportSourceDict] VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" @@ -378,7 +375,7 @@ class _OptionalVdomJson(TypedDict, total=False): key: Key error: str children: list[Any] - attributes: dict[str, Any] + attributes: VdomAttributes eventHandlers: dict[str, _JsonEventTarget] importSource: _JsonImportSource diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index 01532b277..db8452cbd 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -7,13 +7,13 @@ import reactpy from reactpy._html import html from reactpy._warnings import warn -from reactpy.types import ComponentConstructor, VdomDict +from reactpy.types import ComponentConstructor, VdomAttributes, VdomDict def image( format: str, value: str | bytes = "", - attributes: dict[str, Any] | None = None, + attributes: VdomAttributes | None = None, ) -> VdomDict: """Utility for constructing an image from a string or bytes From 9c85a3eac5a78ac9f4c477e08abafb2660333333 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 14 Feb 2025 18:14:47 -0800 Subject: [PATCH 03/14] Clean up a few other type hints --- src/reactpy/types.py | 55 +++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index dc7f041b4..48921f3e2 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -43,7 +43,7 @@ class State(NamedTuple, Generic[_Type]): """The root component should be constructed by a function accepting no arguments.""" -Key: TypeAlias = "str | int" +Key: TypeAlias = str | int @runtime_checkable @@ -98,8 +98,8 @@ async def __aexit__( # TODO: It's probably better to break this one attributes dict down into what each specific # HTML node's attributes can be, and make sure those types are resolved correctly within `HtmlConstructor` -VdomAttributes = TypedDict( - "VdomAttributes", +_VdomAttributes = TypedDict( + "_VdomAttributes", { "key": Key, "value": Any, @@ -107,7 +107,7 @@ async def __aexit__( "dangerouslySetInnerHTML": dict[str, str], "suppressContentEditableWarning": bool, "suppressHydrationWarning": bool, - "style": dict[str, str | int | float], + "style": dict[str, Any], "accessKey": str, "aria-": None, "autoCapitalize": str, @@ -341,10 +341,12 @@ async def __aexit__( "onWaitingCapture": EventFunc, }, total=False, - # TODO: Enable this when Python 3.14 typing extensions are released # extra_items=Any, ) +# TODO: Enable `extra_items` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +VdomAttributes = _VdomAttributes | dict[str, Any] + class VdomDict(TypedDict): """A :ref:`VDOM` dictionary""" @@ -357,10 +359,10 @@ class VdomDict(TypedDict): importSource: NotRequired[ImportSourceDict] -VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" +VdomChild: TypeAlias = ComponentType | VdomDict | str | None | Any """A single child element of a :class:`VdomDict`""" -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" +VdomChildren: TypeAlias = Sequence[VdomChild] | VdomChild """Describes a series of :class:`VdomChild` elements""" @@ -371,41 +373,29 @@ class ImportSourceDict(TypedDict): unmountBeforeUpdate: bool -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: VdomAttributes - eventHandlers: dict[str, _JsonEventTarget] - importSource: _JsonImportSource - +class VdomJson(TypedDict): + """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" -class _RequiredVdomJson(TypedDict, total=True): tagName: str + key: NotRequired[Key] + error: NotRequired[str] + children: NotRequired[list[Any]] + attributes: NotRequired[VdomAttributes] + eventHandlers: NotRequired[dict[str, JsonEventTarget]] + importSource: NotRequired[JsonImportSource] -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): +class JsonEventTarget(TypedDict): target: str preventDefault: bool stopPropagation: bool -class _JsonImportSource(TypedDict): +class JsonImportSource(TypedDict): source: str fallback: Any -EventHandlerMapping = Mapping[str, "EventHandlerType"] -"""A generic mapping between event names to their handlers""" - -EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" -"""A dict mapping between event names to their handlers""" - - class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" @@ -437,6 +427,13 @@ class EventHandlerType(Protocol): """ +EventHandlerMapping = Mapping[str, EventHandlerType] +"""A generic mapping between event names to their handlers""" + +EventHandlerDict: TypeAlias = dict[str, EventHandlerType] +"""A dict mapping between event names to their handlers""" + + class VdomDictConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" From 8c05dd23131c8e483fc1b219e103b91607d038bf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 14 Feb 2025 19:59:31 -0800 Subject: [PATCH 04/14] Quick and dirty CSS style type hints --- src/reactpy/types.py | 433 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 429 insertions(+), 4 deletions(-) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 48921f3e2..6e5aef32a 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -94,20 +94,446 @@ async def __aexit__( """Clean up the view after its final render""" +class _CssStyleDict(TypedDict, total=False): + # TODO: This could generated by parsing from `csstype` in the future + # https://www.npmjs.com/package/csstype + accentColor: str | int + alignContent: str | int + alignItems: str | int + alignSelf: str | int + alignTracks: str | int + all: str | int + animation: str | int + animationComposition: str | int + animationDelay: str | int + animationDirection: str | int + animationDuration: str | int + animationFillMode: str | int + animationIterationCount: str | int + animationName: str | int + animationPlayState: str | int + animationTimeline: str | int + animationTimingFunction: str | int + appearance: str | int + aspectRatio: str | int + backdropFilter: str | int + backfaceVisibility: str | int + background: str | int + backgroundAttachment: str | int + backgroundBlendMode: str | int + backgroundClip: str | int + backgroundColor: str | int + backgroundImage: str | int + backgroundOrigin: str | int + backgroundPosition: str | int + backgroundPositionX: str | int + backgroundPositionY: str | int + backgroundRepeat: str | int + backgroundSize: str | int + blockOverflow: str | int + blockSize: str | int + border: str | int + borderBlock: str | int + borderBlockColor: str | int + borderBlockEnd: str | int + borderBlockEndColor: str | int + borderBlockEndStyle: str | int + borderBlockEndWidth: str | int + borderBlockStart: str | int + borderBlockStartColor: str | int + borderBlockStartStyle: str | int + borderBlockStartWidth: str | int + borderBlockStyle: str | int + borderBlockWidth: str | int + borderBottom: str | int + borderBottomColor: str | int + borderBottomLeftRadius: str | int + borderBottomRightRadius: str | int + borderBottomStyle: str | int + borderBottomWidth: str | int + borderCollapse: str | int + borderColor: str | int + borderEndEndRadius: str | int + borderEndStartRadius: str | int + borderImage: str | int + borderImageOutset: str | int + borderImageRepeat: str | int + borderImageSlice: str | int + borderImageSource: str | int + borderImageWidth: str | int + borderInline: str | int + borderInlineColor: str | int + borderInlineEnd: str | int + borderInlineEndColor: str | int + borderInlineEndStyle: str | int + borderInlineEndWidth: str | int + borderInlineStart: str | int + borderInlineStartColor: str | int + borderInlineStartStyle: str | int + borderInlineStartWidth: str | int + borderInlineStyle: str | int + borderInlineWidth: str | int + borderLeft: str | int + borderLeftColor: str | int + borderLeftStyle: str | int + borderLeftWidth: str | int + borderRadius: str | int + borderRight: str | int + borderRightColor: str | int + borderRightStyle: str | int + borderRightWidth: str | int + borderSpacing: str | int + borderStartEndRadius: str | int + borderStartStartRadius: str | int + borderStyle: str | int + borderTop: str | int + borderTopColor: str | int + borderTopLeftRadius: str | int + borderTopRightRadius: str | int + borderTopStyle: str | int + borderTopWidth: str | int + borderWidth: str | int + bottom: str | int + boxDecorationBreak: str | int + boxShadow: str | int + boxSizing: str | int + breakAfter: str | int + breakBefore: str | int + breakInside: str | int + captionSide: str | int + caret: str | int + caretColor: str | int + caretShape: str | int + clear: str | int + clip: str | int + clipPath: str | int + color: str | int + colorScheme: str | int + columnCount: str | int + columnFill: str | int + columnGap: str | int + columnRule: str | int + columnRuleColor: str | int + columnRuleStyle: str | int + columnRuleWidth: str | int + columnSpan: str | int + columnWidth: str | int + columns: str | int + contain: str | int + containIntrinsicBlockSize: str | int + containIntrinsicHeight: str | int + containIntrinsicInlineSize: str | int + containIntrinsicSize: str | int + containIntrinsicWidth: str | int + content: str | int + contentVisibility: str | int + counterIncrement: str | int + counterReset: str | int + counterSet: str | int + cursor: str | int + direction: str | int + display: str | int + emptyCells: str | int + filter: str | int + flex: str | int + flexBasis: str | int + flexDirection: str | int + flexFlow: str | int + flexGrow: str | int + flexShrink: str | int + flexWrap: str | int + float: str | int + font: str | int + fontFamily: str | int + fontFeatureSettings: str | int + fontKerning: str | int + fontLanguageOverride: str | int + fontOpticalSizing: str | int + fontSize: str | int + fontSizeAdjust: str | int + fontStretch: str | int + fontStyle: str | int + fontSynthesis: str | int + fontVariant: str | int + fontVariantAlternates: str | int + fontVariantCaps: str | int + fontVariantEastAsian: str | int + fontVariantLigatures: str | int + fontVariantNumeric: str | int + fontVariantPosition: str | int + fontVariationSettings: str | int + fontWeight: str | int + forcedColorAdjust: str | int + gap: str | int + grid: str | int + gridArea: str | int + gridAutoColumns: str | int + gridAutoFlow: str | int + gridAutoRows: str | int + gridColumn: str | int + gridColumnEnd: str | int + gridColumnStart: str | int + gridRow: str | int + gridRowEnd: str | int + gridRowStart: str | int + gridTemplate: str | int + gridTemplateAreas: str | int + gridTemplateColumns: str | int + gridTemplateRows: str | int + hangingPunctuation: str | int + height: str | int + hyphenateCharacter: str | int + hyphenateLimitChars: str | int + hyphens: str | int + imageOrientation: str | int + imageRendering: str | int + imageResolution: str | int + inherit: str | int + initial: str | int + initialLetter: str | int + initialLetterAlign: str | int + inlineSize: str | int + inputSecurity: str | int + inset: str | int + insetBlock: str | int + insetBlockEnd: str | int + insetBlockStart: str | int + insetInline: str | int + insetInlineEnd: str | int + insetInlineStart: str | int + isolation: str | int + justifyContent: str | int + justifyItems: str | int + justifySelf: str | int + justifyTracks: str | int + left: str | int + letterSpacing: str | int + lineBreak: str | int + lineClamp: str | int + lineHeight: str | int + lineHeightStep: str | int + listStyle: str | int + listStyleImage: str | int + listStylePosition: str | int + listStyleType: str | int + margin: str | int + marginBlock: str | int + marginBlockEnd: str | int + marginBlockStart: str | int + marginBottom: str | int + marginInline: str | int + marginInlineEnd: str | int + marginInlineStart: str | int + marginLeft: str | int + marginRight: str | int + marginTop: str | int + marginTrim: str | int + mask: str | int + maskBorder: str | int + maskBorderMode: str | int + maskBorderOutset: str | int + maskBorderRepeat: str | int + maskBorderSlice: str | int + maskBorderSource: str | int + maskBorderWidth: str | int + maskClip: str | int + maskComposite: str | int + maskImage: str | int + maskMode: str | int + maskOrigin: str | int + maskPosition: str | int + maskRepeat: str | int + maskSize: str | int + maskType: str | int + masonryAutoFlow: str | int + mathDepth: str | int + mathShift: str | int + mathStyle: str | int + maxBlockSize: str | int + maxHeight: str | int + maxInlineSize: str | int + maxLines: str | int + maxWidth: str | int + minBlockSize: str | int + minHeight: str | int + minInlineSize: str | int + minWidth: str | int + mixBlendMode: str | int + objectFit: str | int + objectPosition: str | int + offset: str | int + offsetAnchor: str | int + offsetDistance: str | int + offsetPath: str | int + offsetPosition: str | int + offsetRotate: str | int + opacity: str | int + order: str | int + orphans: str | int + outline: str | int + outlineColor: str | int + outlineOffset: str | int + outlineStyle: str | int + outlineWidth: str | int + overflow: str | int + overflowAnchor: str | int + overflowBlock: str | int + overflowClipMargin: str | int + overflowInline: str | int + overflowWrap: str | int + overflowX: str | int + overflowY: str | int + overscrollBehavior: str | int + overscrollBehaviorBlock: str | int + overscrollBehaviorInline: str | int + overscrollBehaviorX: str | int + overscrollBehaviorY: str | int + padding: str | int + paddingBlock: str | int + paddingBlockEnd: str | int + paddingBlockStart: str | int + paddingBottom: str | int + paddingInline: str | int + paddingInlineEnd: str | int + paddingInlineStart: str | int + paddingLeft: str | int + paddingRight: str | int + paddingTop: str | int + pageBreakAfter: str | int + pageBreakBefore: str | int + pageBreakInside: str | int + paintOrder: str | int + perspective: str | int + perspectiveOrigin: str | int + placeContent: str | int + placeItems: str | int + placeSelf: str | int + pointerEvents: str | int + position: str | int + printColorAdjust: str | int + quotes: str | int + resize: str | int + revert: str | int + right: str | int + rotate: str | int + rowGap: str | int + rubyAlign: str | int + rubyMerge: str | int + rubyPosition: str | int + scale: str | int + scrollBehavior: str | int + scrollMargin: str | int + scrollMarginBlock: str | int + scrollMarginBlockEnd: str | int + scrollMarginBlockStart: str | int + scrollMarginBottom: str | int + scrollMarginInline: str | int + scrollMarginInlineEnd: str | int + scrollMarginInlineStart: str | int + scrollMarginLeft: str | int + scrollMarginRight: str | int + scrollMarginTop: str | int + scrollPadding: str | int + scrollPaddingBlock: str | int + scrollPaddingBlockEnd: str | int + scrollPaddingBlockStart: str | int + scrollPaddingBottom: str | int + scrollPaddingInline: str | int + scrollPaddingInlineEnd: str | int + scrollPaddingInlineStart: str | int + scrollPaddingLeft: str | int + scrollPaddingRight: str | int + scrollPaddingTop: str | int + scrollSnapAlign: str | int + scrollSnapStop: str | int + scrollSnapType: str | int + scrollTimeline: str | int + scrollTimelineAxis: str | int + scrollTimelineName: str | int + scrollbarColor: str | int + scrollbarGutter: str | int + scrollbarWidth: str | int + shapeImageThreshold: str | int + shapeMargin: str | int + shapeOutside: str | int + tabSize: str | int + tableLayout: str | int + textAlign: str | int + textAlignLast: str | int + textCombineUpright: str | int + textDecoration: str | int + textDecorationColor: str | int + textDecorationLine: str | int + textDecorationSkip: str | int + textDecorationSkipInk: str | int + textDecorationStyle: str | int + textDecorationThickness: str | int + textEmphasis: str | int + textEmphasisColor: str | int + textEmphasisPosition: str | int + textEmphasisStyle: str | int + textIndent: str | int + textJustify: str | int + textOrientation: str | int + textOverflow: str | int + textRendering: str | int + textShadow: str | int + textSizeAdjust: str | int + textTransform: str | int + textUnderlineOffset: str | int + textUnderlinePosition: str | int + top: str | int + touchAction: str | int + transform: str | int + transformBox: str | int + transformOrigin: str | int + transformStyle: str | int + transition: str | int + transitionDelay: str | int + transitionDuration: str | int + transitionProperty: str | int + transitionTimingFunction: str | int + translate: str | int + unicodeBidi: str | int + unset: str | int + userSelect: str | int + verticalAlign: str | int + visibility: str | int + whiteSpace: str | int + widows: str | int + width: str | int + willChange: str | int + wordBreak: str | int + wordSpacing: str | int + wordWrap: str | int + writingMode: str | int + zIndex: str | int + + +# TODO: Enable `extra_items` on `_CssStyleDict` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +CssStyleDict = _CssStyleDict | dict[str, Any] + EventFunc = Callable[[dict[str, Any]], Awaitable[None] | None] + +class DangerouslySetInnerHTML(TypedDict): + __html: str + + # TODO: It's probably better to break this one attributes dict down into what each specific # HTML node's attributes can be, and make sure those types are resolved correctly within `HtmlConstructor` +# TODO: This could be generated by parsing from `@types/react` in the future +# https://www.npmjs.com/package/@types/react?activeTab=code _VdomAttributes = TypedDict( "_VdomAttributes", { "key": Key, "value": Any, "defaultValue": Any, - "dangerouslySetInnerHTML": dict[str, str], + "dangerouslySetInnerHTML": DangerouslySetInnerHTML, "suppressContentEditableWarning": bool, "suppressHydrationWarning": bool, - "style": dict[str, Any], + "style": CssStyleDict, "accessKey": str, "aria-": None, "autoCapitalize": str, @@ -341,10 +767,9 @@ async def __aexit__( "onWaitingCapture": EventFunc, }, total=False, - # extra_items=Any, ) -# TODO: Enable `extra_items` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +# TODO: Enable `extra_items` on `_VdomAttributes` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ VdomAttributes = _VdomAttributes | dict[str, Any] From d3422181750b230fcec54bc59f14208209507f09 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:38:02 -0800 Subject: [PATCH 05/14] Update comments --- src/reactpy/_html.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index bf8afe824..bd5eb740d 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -301,9 +301,9 @@ def __getattr__(self, value: str) -> VdomDictConstructor: return self.__cache__[value] - # HTML elements, written out here for auto-complete purposes - # The actual elements are created dynamically in the __getattr__ method. - # Elements other than these can still be created. + # Standard HTML elements are written below for auto-complete purposes + # The actual elements are created dynamically when __getattr__ is called. + # Elements other than those type-hinted below can still be created. a: VdomDictConstructor abbr: VdomDictConstructor address: VdomDictConstructor From 04b7b1bbc7f30204a4ff15230061775be24b191a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:53:17 -0800 Subject: [PATCH 06/14] Vdom constructor as a class-based interface --- pyproject.toml | 2 + src/reactpy/__init__.py | 4 +- src/reactpy/_html.py | 14 +- src/reactpy/core/vdom.py | 228 ++++++++++++++---------------- src/reactpy/transforms.py | 2 +- src/reactpy/types.py | 66 +++++---- src/reactpy/utils.py | 13 +- src/reactpy/web/module.py | 8 +- tests/test_core/test_component.py | 2 +- tests/test_core/test_layout.py | 2 +- tests/test_core/test_vdom.py | 55 ++++--- 11 files changed, 205 insertions(+), 191 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4c1dee04a..173fdb173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,8 @@ filterwarnings = """ ignore::DeprecationWarning:uvicorn.* ignore::DeprecationWarning:websockets.* ignore::UserWarning:tests.test_core.test_vdom + ignore::UserWarning:tests.test_pyscript.test_components + ignore::UserWarning:tests.test_utils """ testpaths = "tests" xfail_strict = true diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 3c496d974..7408f57df 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -19,7 +19,7 @@ use_state, ) from reactpy.core.layout import Layout -from reactpy.core.vdom import vdom +from reactpy.core.vdom import Vdom from reactpy.pyscript.components import pyscript_component from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy @@ -29,6 +29,7 @@ __all__ = [ "Layout", "Ref", + "Vdom", "component", "config", "create_context", @@ -52,7 +53,6 @@ "use_ref", "use_scope", "use_state", - "vdom", "web", "widgets", ] diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index bd5eb740d..50f26f2a7 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, ClassVar, overload -from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor +from reactpy.core.vdom import Vdom if TYPE_CHECKING: from reactpy.types import ( @@ -195,8 +195,8 @@ def __getattr__(self, value: str) -> VdomDictConstructor: if value in self.__cache__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( - value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG + self.__cache__[value] = Vdom( + tagName=value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG ) return self.__cache__[value] @@ -284,8 +284,8 @@ class HtmlConstructor: # ruff: noqa: N815 __cache__: ClassVar[dict[str, VdomDictConstructor]] = { - "script": custom_vdom_constructor(_script), - "fragment": custom_vdom_constructor(_fragment), + "script": Vdom(tagName="script", custom_constructor=_script), + "fragment": Vdom(tagName="", custom_constructor=_fragment), "svg": SvgConstructor(), } @@ -295,8 +295,8 @@ def __getattr__(self, value: str) -> VdomDictConstructor: if value in self.__cache__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( - value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY + self.__cache__[value] = Vdom( + tagName=value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY ) return self.__cache__[value] diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 896e8af02..90bee374f 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -1,9 +1,15 @@ +# pyright: reportIncompatibleMethodOverride=false from __future__ import annotations import json -from collections.abc import Mapping, Sequence -from functools import wraps -from typing import Any, Callable, Protocol, cast +from collections.abc import Iterable, Mapping, Sequence +from typing import ( + Any, + Callable, + Unpack, + cast, + overload, +) from fastjsonschema import compile as compile_json_schema @@ -12,17 +18,16 @@ from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.types import ( + ALLOWED_VDOM_KEYS, ComponentType, + CustomVdomConstructor, + EllipsisRepr, EventHandlerDict, EventHandlerType, - ImportSourceDict, - Key, VdomAttributes, - VdomChild, VdomChildren, - VdomDict, - VdomDictConstructor, VdomJson, + _VdomDict, ) VDOM_JSON_SCHEMA = { @@ -124,98 +129,83 @@ def is_vdom(value: Any) -> bool: ) -def vdom(tag: str, *attributes_and_children: VdomAttributes | VdomChildren) -> VdomDict: - """A helper function for creating VDOM elements. - - Parameters: - tag: - The type of element (e.g. 'div', 'h1', 'img') - attributes_and_children: - An optional attribute mapping followed by any number of children or - iterables of children. The attribute mapping **must** precede the children, - or children which will be merged into their respective parts of the model. - key: - A string indicating the identity of a particular element. This is significant - to preserve event handlers across updates - without a key, a re-render would - cause these handlers to be deleted, but with a key, they would be redirected - to any newly defined handlers. - event_handlers: - Maps event types to coroutines that are responsible for handling those events. - import_source: - (subject to change) specifies javascript that, when evaluated returns a - React component. - """ - model: VdomDict = {"tagName": tag} +class Vdom: + """Class that follows VDOM spec, and exposes the user API that can create VDOM elements.""" - if not attributes_and_children: - return model + def __init__( + self, + /, + allow_children: bool = True, + custom_constructor: CustomVdomConstructor | None = None, + **kwargs: Unpack[_VdomDict], + ) -> None: + """This init method is used to declare the VDOM dictionary default values, as well as configurable properties + related to the construction of VDOM dictionaries.""" + if "tagName" not in kwargs: + msg = "You must specify a 'tagName' for a VDOM element." + raise ValueError(msg) + self._validate_keys(kwargs.keys()) + self.allow_children = allow_children + self.custom_constructor = custom_constructor + self.default_values = kwargs + + # Configure Python debugger attributes + self.__name__ = kwargs["tagName"] + module_name = f_module_name(1) + if module_name: + self.__module__ = module_name + self.__qualname__ = f"{module_name}.{kwargs['tagName']}" + + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> _VdomDict: ... - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) + @overload + def __call__(self, *children: VdomChildren) -> _VdomDict: ... - if attributes: + def __call__( + self, *attributes_and_children: VdomAttributes | VdomChildren + ) -> _VdomDict: + """The entry point for the VDOM API, for example reactpy.html().""" + attributes, children = separate_attributes_and_children(attributes_and_children) + key = attributes.pop("key", None) + attributes, event_handlers = separate_attributes_and_event_handlers(attributes) if REACTPY_CHECK_JSON_ATTRS.current: json.dumps(attributes) - model["attributes"] = attributes - - if children: - model["children"] = children - - if key is not None: - model["key"] = key - if event_handlers: - model["eventHandlers"] = event_handlers - - return model - - -def make_vdom_constructor( - tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None -) -> VdomDictConstructor: - """Return a constructor for VDOM dictionaries with the given tag name. - - The resulting callable will have the same interface as :func:`vdom` but without its - first ``tag`` argument. - """ - - def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict: - model = vdom(tag, *attributes_and_children, **kwargs) - if not allow_children and "children" in model: - msg = f"{tag!r} nodes cannot have children." + # Run custom constructor, if defined + if self.custom_constructor: + result = self.custom_constructor( + key=key, + children=children, + attributes=attributes, + event_handlers=event_handlers, + ) + # Otherwise, use the default constructor + else: + result = { + **({"key": key} if key is not None else {}), + **({"children": children} if children else {}), + **({"attributes": attributes} if attributes else {}), + **({"eventHandlers": event_handlers} if event_handlers else {}), + } + + # Validate the result + if children and not self.allow_children: + msg = f"{self.default_values.get('tagName')!r} nodes cannot have children." raise TypeError(msg) - if import_source: - model["importSource"] = import_source - return model - - # replicate common function attributes - constructor.__name__ = tag - constructor.__doc__ = ( - "Return a new " - f"`<{tag}> `__ " - "element represented by a :class:`VdomDict`." - ) - - module_name = f_module_name(1) - if module_name: - constructor.__module__ = module_name - constructor.__qualname__ = f"{module_name}.{tag}" + if REACTPY_DEBUG.current: + self._validate_keys(result.keys()) - return cast(VdomDictConstructor, constructor) + return cast(_VdomDict, self.default_values | result) - -def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor: - """Cast function to VdomDictConstructor""" - - @wraps(func) - def wrapper(*attributes_and_children: Any) -> VdomDict: - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - return func(attributes, children, key, event_handlers) - - return cast(VdomDictConstructor, wrapper) + @staticmethod + def _validate_keys(keys: Sequence[str] | Iterable[str]) -> None: + invalid_keys = set(keys) - ALLOWED_VDOM_KEYS + if invalid_keys: + msg = f"Invalid keys {invalid_keys} provided." + raise ValueError(msg) def separate_attributes_and_children( @@ -224,29 +214,24 @@ def separate_attributes_and_children( if not values: return {}, [] - attributes: VdomAttributes + _attributes: VdomAttributes children_or_iterables: Sequence[Any] if _is_attributes(values[0]): - attributes, *children_or_iterables = values + _attributes, *children_or_iterables = values else: - attributes = {} + _attributes = {} children_or_iterables = values - children: list[Any] = [] - for child in children_or_iterables: - if _is_single_child(child): - children.append(child) - else: - children.extend(child) + _children: list[Any] = _flatten_children(children_or_iterables) - return attributes, children + return _attributes, _children def separate_attributes_and_event_handlers( attributes: Mapping[str, Any], ) -> tuple[VdomAttributes, EventHandlerDict]: - separated_attributes: VdomAttributes = {} - separated_event_handlers: dict[str, EventHandlerType] = {} + _attributes: VdomAttributes = {} + _event_handlers: dict[str, EventHandlerType] = {} for k, v in attributes.items(): handler: EventHandlerType @@ -254,18 +239,28 @@ def separate_attributes_and_event_handlers( if callable(v): handler = EventHandler(to_event_handler_function(v)) elif ( - # isinstance check on protocols is slow - use function attr pre-check as a - # quick filter before actually performing slow EventHandlerType type check + # `isinstance` check on `Protocol` types is slow. We use pre-checks as an optimization + # before actually performing slow EventHandlerType type check hasattr(v, "function") and isinstance(v, EventHandlerType) ): handler = v else: - separated_attributes[k] = v + _attributes[k] = v continue - separated_event_handlers[k] = handler + _event_handlers[k] = handler + + return _attributes, _event_handlers - return separated_attributes, separated_event_handlers + +def _flatten_children(children: Sequence[Any]) -> list[Any]: + _children: list[VdomChildren] = [] + for child in children: + if _is_single_child(child): + _children.append(child) + else: + _children.extend(_flatten_children(child)) + return _children def _is_attributes(value: Any) -> bool: @@ -292,20 +287,5 @@ def _validate_child_key_integrity(value: Any) -> None: warn(f"Key not specified for child in list {child}", UserWarning) elif isinstance(child, Mapping) and "key" not in child: # remove 'children' to reduce log spam - child_copy = {**child, "children": _EllipsisRepr()} + child_copy = {**child, "children": EllipsisRepr()} warn(f"Key not specified for child in list {child_copy}", UserWarning) - - -class _CustomVdomDictConstructor(Protocol): - def __call__( - self, - attributes: VdomAttributes, - children: Sequence[VdomChild], - key: Key | None, - event_handlers: EventHandlerDict, - ) -> VdomDict: ... - - -class _EllipsisRepr: - def __repr__(self) -> str: - return "..." diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index d16931974..027fb3e3b 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -20,7 +20,7 @@ def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None: if not name.startswith("_"): getattr(self, name)(vdom) - def normalize_style_attributes(self, vdom: VdomDict) -> None: + def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: """Convert style attribute from str -> dict with camelCase keys""" if ( "attributes" in vdom diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 6e5aef32a..98e6d1b0d 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,18 +1,14 @@ from __future__ import annotations -import sys -from collections import namedtuple from collections.abc import Awaitable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType from typing import ( - TYPE_CHECKING, Any, Callable, Generic, Literal, - NamedTuple, NotRequired, Protocol, TypeVar, @@ -20,20 +16,15 @@ runtime_checkable, ) -from typing_extensions import TypeAlias, TypedDict +from typing_extensions import NamedTuple, TypeAlias, TypedDict CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") -if TYPE_CHECKING or sys.version_info >= (3, 11): - - class State(NamedTuple, Generic[_Type]): - value: _Type - set_value: Callable[[_Type | Callable[[_Type], _Type]], None] - -else: # nocov - State = namedtuple("State", ("value", "set_value")) +class State(NamedTuple, Generic[_Type]): + value: _Type + set_value: Callable[[_Type | Callable[[_Type], _Type]], None] ComponentConstructor = Callable[..., "ComponentType"] @@ -94,7 +85,7 @@ async def __aexit__( """Clean up the view after its final render""" -class _CssStyleDict(TypedDict, total=False): +class CssStyleTypeDict(TypedDict, total=False): # TODO: This could generated by parsing from `csstype` in the future # https://www.npmjs.com/package/csstype accentColor: str | int @@ -510,8 +501,8 @@ class _CssStyleDict(TypedDict, total=False): zIndex: str | int -# TODO: Enable `extra_items` on `_CssStyleDict` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ -CssStyleDict = _CssStyleDict | dict[str, Any] +# TODO: Enable `extra_items` on `CssStyleDict` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +CssStyleDict = CssStyleTypeDict | dict[str, Any] EventFunc = Callable[[dict[str, Any]], Awaitable[None] | None] @@ -524,8 +515,8 @@ class DangerouslySetInnerHTML(TypedDict): # HTML node's attributes can be, and make sure those types are resolved correctly within `HtmlConstructor` # TODO: This could be generated by parsing from `@types/react` in the future # https://www.npmjs.com/package/@types/react?activeTab=code -_VdomAttributes = TypedDict( - "_VdomAttributes", +VdomAttributesTypeDict = TypedDict( + "VdomAttributesTypeDict", { "key": Key, "value": Any, @@ -769,12 +760,19 @@ class DangerouslySetInnerHTML(TypedDict): total=False, ) -# TODO: Enable `extra_items` on `_VdomAttributes` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ -VdomAttributes = _VdomAttributes | dict[str, Any] +# TODO: Enable `extra_items` on `VdomAttributes` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +VdomAttributes = VdomAttributesTypeDict | dict[str, Any] + +VdomDictKeys = Literal[ + "tagName", "key", "children", "attributes", "eventHandlers", "importSource" +] + +# This is a hack to pull the keys from the TypedDict +ALLOWED_VDOM_KEYS = set(VdomDictKeys._determine_new_args([])) # type: ignore -class VdomDict(TypedDict): - """A :ref:`VDOM` dictionary""" +class _VdomDict(TypedDict): + """Dictionary representation of what the `Vdom` class eventually resolves into.""" tagName: str key: NotRequired[Key | None] @@ -784,6 +782,9 @@ class VdomDict(TypedDict): importSource: NotRequired[ImportSourceDict] +VdomDict = _VdomDict | dict[str, Any] + + VdomChild: TypeAlias = ComponentType | VdomDict | str | None | Any """A single child element of a :class:`VdomDict`""" @@ -865,14 +866,14 @@ class VdomDictConstructor(Protocol): @overload def __call__( self, attributes: VdomAttributes, /, *children: VdomChildren - ) -> VdomDict: ... + ) -> VdomDict | dict[str, Any]: ... @overload - def __call__(self, *children: VdomChildren) -> VdomDict: ... + def __call__(self, *children: VdomChildren) -> VdomDict | dict[str, Any]: ... def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict: ... + ) -> VdomDict | dict[str, Any]: ... class LayoutUpdateMessage(TypedDict): @@ -970,3 +971,18 @@ class PyScriptOptions(TypedDict, total=False): extra_py: Sequence[str] extra_js: dict[str, Any] | str config: dict[str, Any] | str + + +class CustomVdomConstructor(Protocol): + def __call__( + self, + attributes: VdomAttributes, + children: Sequence[VdomChildren], + key: Key | None, + event_handlers: EventHandlerDict, + ) -> VdomDict: ... + + +class EllipsisRepr: + def __repr__(self) -> str: + return "..." diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 666b97241..2bbe675ac 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -7,9 +7,9 @@ from typing import Any, Callable, Generic, TypeVar, cast from lxml import etree -from lxml.html import fromstring, tostring +from lxml.html import fromstring -from reactpy.core.vdom import vdom as make_vdom +from reactpy import html from reactpy.transforms import RequiredTransforms from reactpy.types import ComponentType, VdomDict @@ -73,7 +73,7 @@ def reactpy_to_string(root: VdomDict | ComponentType) -> str: root = component_to_vdom(root) _add_vdom_to_etree(temp_container, root) - html = cast(bytes, tostring(temp_container)).decode() # type: ignore + html = etree.tostring(temp_container, method="html").decode() # Strip out temp root <__temp__> element return html[10:-11] @@ -149,7 +149,8 @@ def _etree_to_vdom( children = _generate_vdom_children(node, transforms, intercept_links) # Convert the lxml node to a VDOM dict - el = make_vdom(str(node.tag), dict(node.items()), *children) + constructor = getattr(html, str(node.tag)) + el = constructor(dict(node.items()), children) # Perform necessary transformations on the VDOM attributes to meet VDOM spec RequiredTransforms(el, intercept_links) @@ -236,8 +237,8 @@ def component_to_vdom(component: ComponentType) -> VdomDict: if hasattr(result, "render"): return component_to_vdom(cast(ComponentType, result)) elif isinstance(result, str): - return make_vdom("div", {}, result) - return make_vdom("") + return html.div(result) + return html.fragment() def _react_attribute_to_html(key: str, value: Any) -> tuple[str, str]: diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 5148c9669..8a5489f31 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -8,7 +8,7 @@ from typing import Any, NewType, overload from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR -from reactpy.core.vdom import make_vdom_constructor +from reactpy.core.vdom import Vdom from reactpy.types import ImportSourceDict, VdomDictConstructor from reactpy.web.utils import ( module_name_suffix, @@ -283,10 +283,10 @@ def _make_export( fallback: Any | None, allow_children: bool, ) -> VdomDictConstructor: - return make_vdom_constructor( - name, + return Vdom( + tagName=name, allow_children=allow_children, - import_source=ImportSourceDict( + importSource=ImportSourceDict( source=web_module.source, sourceType=web_module.source_type, fallback=(fallback or web_module.default_fallback), diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index aa8996d4e..e405bdc0a 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -27,7 +27,7 @@ def SimpleDiv(): async def test_simple_parameterized_component(): @reactpy.component def SimpleParamComponent(tag): - return reactpy.vdom(tag) + return reactpy.Vdom(tagName=tag)() assert SimpleParamComponent("div").render() == {"tagName": "div"} diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index b4de2e7e9..b634355f4 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -82,7 +82,7 @@ async def test_simple_layout(): @reactpy.component def SimpleComponent(): tag, set_state_hook.current = reactpy.hooks.use_state("div") - return reactpy.vdom(tag) + return reactpy.Vdom(tagName=tag)() async with reactpy.Layout(SimpleComponent()) as layout: update_1 = await layout.render() diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 8e349fcc4..445ad733e 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -6,8 +6,8 @@ import reactpy from reactpy.config import REACTPY_DEBUG from reactpy.core.events import EventHandler -from reactpy.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json -from reactpy.types import VdomDict +from reactpy.core.vdom import Vdom, is_vdom, validate_vdom_json +from reactpy.types import _VdomDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} @@ -18,9 +18,9 @@ [ (False, {}), (False, {"tagName": None}), - (False, VdomDict()), + (False, _VdomDict()), (True, {"tagName": ""}), - (True, VdomDict(tagName="")), + (True, _VdomDict(tagName="")), ], ) def test_is_vdom(result, value): @@ -31,27 +31,34 @@ def test_is_vdom(result, value): "actual, expected", [ ( - reactpy.vdom("div", [reactpy.vdom("div")]), + reactpy.Vdom(tagName="div")([reactpy.Vdom(tagName="div")()]), {"tagName": "div", "children": [{"tagName": "div"}]}, ), ( - reactpy.vdom("div", {"style": {"backgroundColor": "red"}}), + reactpy.Vdom(tagName="div")({"style": {"backgroundColor": "red"}}), {"tagName": "div", "attributes": {"style": {"backgroundColor": "red"}}}, ), ( # multiple iterables of children are merged - reactpy.vdom("div", [reactpy.vdom("div"), 1], (reactpy.vdom("div"), 2)), + reactpy.Vdom(tagName="div")( + ( + [reactpy.Vdom(tagName="div")(), 1], + (reactpy.Vdom(tagName="div")(), 2), + ) + ), { "tagName": "div", "children": [{"tagName": "div"}, 1, {"tagName": "div"}, 2], }, ), ( - reactpy.vdom("div", {"onEvent": FAKE_EVENT_HANDLER}), + reactpy.Vdom(tagName="div")({"onEvent": FAKE_EVENT_HANDLER}), {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, ), ( - reactpy.vdom("div", reactpy.html.h1("hello"), reactpy.html.h2("world")), + reactpy.Vdom( + tagName="div", + )((reactpy.html.h1("hello"), reactpy.html.h2("world"))), { "tagName": "div", "children": [ @@ -61,15 +68,15 @@ def test_is_vdom(result, value): }, ), ( - reactpy.vdom("div", {"tagName": "div"}), + reactpy.Vdom(tagName="div")({"tagName": "div"}), {"tagName": "div", "children": [{"tagName": "div"}]}, ), ( - reactpy.vdom("div", (i for i in range(3))), + reactpy.Vdom(tagName="div")((i for i in range(3))), {"tagName": "div", "children": [0, 1, 2]}, ), ( - reactpy.vdom("div", (x**2 for x in [1, 2, 3])), + reactpy.Vdom(tagName="div")((x**2 for x in [1, 2, 3])), {"tagName": "div", "children": [1, 4, 9]}, ), ], @@ -81,8 +88,8 @@ def test_simple_node_construction(actual, expected): async def test_callable_attributes_are_cast_to_event_handlers(): params_from_calls = [] - node = reactpy.vdom( - "div", {"onEvent": lambda *args: params_from_calls.append(args)} + node = reactpy.Vdom(tagName="div")( + {"onEvent": lambda *args: params_from_calls.append(args)} ) event_handlers = node.pop("eventHandlers") @@ -97,7 +104,7 @@ async def test_callable_attributes_are_cast_to_event_handlers(): def test_make_vdom_constructor(): - elmt = make_vdom_constructor("some-tag") + elmt = Vdom(tagName="some-tag") assert elmt({"data": 1}, [elmt()]) == { "tagName": "some-tag", @@ -105,7 +112,7 @@ def test_make_vdom_constructor(): "attributes": {"data": 1}, } - no_children = make_vdom_constructor("no-children", allow_children=False) + no_children = Vdom(tagName="no-children", allow_children=False) with pytest.raises(TypeError, match="cannot have children"): no_children([1, 2, 3]) @@ -283,7 +290,7 @@ def test_invalid_vdom(value, error_message_pattern): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: - reactpy.vdom("div", (1 for i in range(10))) + reactpy.Vdom(tagName="div")((1 for i in range(10))) assert len(record) == 1 assert ( record[0] @@ -295,16 +302,16 @@ def test_warn_cannot_verify_keypath_for_genereators(): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_dynamic_children_must_have_keys(): with pytest.warns(UserWarning) as record: - reactpy.vdom("div", [reactpy.vdom("div")]) + reactpy.Vdom(tagName="div")([reactpy.Vdom(tagName="div")()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @reactpy.component def MyComponent(): - return reactpy.vdom("div") + return reactpy.Vdom(tagName="div")() with pytest.warns(UserWarning) as record: - reactpy.vdom("div", [MyComponent()]) + reactpy.Vdom(tagName="div")([MyComponent()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @@ -313,3 +320,11 @@ def MyComponent(): def test_raise_for_non_json_attrs(): with pytest.raises(TypeError, match="JSON serializable"): reactpy.html.div({"nonJsonSerializableObject": object()}) + + +def test_invalid_vdom_keys(): + with pytest.raises(ValueError, match="Invalid keys*"): + reactpy.Vdom(tagName="div", foo="bar") + + with pytest.raises(ValueError, match="You must specify a 'tagName'*"): + reactpy.Vdom() From 398dc2ae8ad69258bac668a562ce97cdb876636c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:59:30 -0800 Subject: [PATCH 07/14] Fix python 3.10 compat --- src/reactpy/core/vdom.py | 2 +- src/reactpy/types.py | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 90bee374f..513b37731 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -6,12 +6,12 @@ from typing import ( Any, Callable, - Unpack, cast, overload, ) from fastjsonschema import compile as compile_json_schema +from typing_extensions import Unpack from reactpy._warnings import warn from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 98e6d1b0d..dba9b2ff2 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -9,14 +9,13 @@ Callable, Generic, Literal, - NotRequired, Protocol, TypeVar, overload, runtime_checkable, ) -from typing_extensions import NamedTuple, TypeAlias, TypedDict +from typing_extensions import NamedTuple, NotRequired, TypeAlias, TypedDict CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") @@ -764,11 +763,21 @@ class DangerouslySetInnerHTML(TypedDict): VdomAttributes = VdomAttributesTypeDict | dict[str, Any] VdomDictKeys = Literal[ - "tagName", "key", "children", "attributes", "eventHandlers", "importSource" + "tagName", + "key", + "children", + "attributes", + "eventHandlers", + "importSource", ] - -# This is a hack to pull the keys from the TypedDict -ALLOWED_VDOM_KEYS = set(VdomDictKeys._determine_new_args([])) # type: ignore +ALLOWED_VDOM_KEYS = { + "tagName", + "key", + "children", + "attributes", + "eventHandlers", + "importSource", +} class _VdomDict(TypedDict): From 8ddccf395cecb785f88f8f0837c49a31eb5a3a74 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:54:21 -0800 Subject: [PATCH 08/14] Add test case for recursive flattening --- tests/test_core/test_vdom.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 445ad733e..0f98a057b 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -79,6 +79,10 @@ def test_is_vdom(result, value): reactpy.Vdom(tagName="div")((x**2 for x in [1, 2, 3])), {"tagName": "div", "children": [1, 4, 9]}, ), + ( + reactpy.Vdom(tagName="div")(["child_1", ["child_2"]]), + {"tagName": "div", "children": ["child_1", "child_2"]}, + ), ], ) def test_simple_node_construction(actual, expected): From ef21aeb5b696477cb27af311500bbffeefd43ea4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:58:03 -0800 Subject: [PATCH 09/14] Add changelog --- docs/source/about/changelog.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 8586f2325..bb8eac035 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -19,12 +19,15 @@ Unreleased - :pull:`1113` - Added ``reactpy.executors.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI. - :pull:`1269` - Added ``reactpy.executors.asgi.ReactPyPyodide`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. - :pull:`1113` - Added ``reactpy.executors.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework. -- :pull:`1113` :pull:`1269` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. This includes the following template tags: ``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``. +- :pull:`1269` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. This includes the following template tags: ``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``. - :pull:`1269` - Added ``reactpy.pyscript_component`` that can be used to embed ReactPy components into your existing application. - :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. - :pull:`1264` - Added ``reactpy.use_async_effect`` hook. - :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. +- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) +- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. +- :pull:`1281` - Added type hints to ``reactpy.html`` attributes. **Changed** @@ -56,6 +59,9 @@ Unreleased - :pull:`1113` - Removed deprecated function ``module_from_template``. - :pull:`1113` - Removed support for Python 3.9. - :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` instead. +- :pull:`1281` - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead. +- :pull:`1281` - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead. +- :pull:`1281` - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead. **Fixed** From 29c0e851f3373c1a10bccc6c8201a0270d220ae0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Feb 2025 19:13:19 -0800 Subject: [PATCH 10/14] Change `VdomDict` into a `dict` subclass --- src/reactpy/_html.py | 26 +++++++-------- src/reactpy/core/hooks.py | 11 +++++-- src/reactpy/core/layout.py | 2 +- src/reactpy/core/vdom.py | 35 +++++--------------- src/reactpy/types.py | 64 ++++++++++++++++++++++++++++++++---- src/reactpy/widgets.py | 2 +- tests/sample.py | 3 +- tests/test_core/test_vdom.py | 18 +++++++--- 8 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 50f26f2a7..5c992f549 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -1,20 +1,18 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, ClassVar, overload +from typing import ClassVar, overload from reactpy.core.vdom import Vdom - -if TYPE_CHECKING: - from reactpy.types import ( - EventHandlerDict, - Key, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomDictConstructor, - ) +from reactpy.types import ( + EventHandlerDict, + Key, + VdomAttributes, + VdomChild, + VdomChildren, + VdomDict, + VdomDictConstructor, +) __all__ = ["html"] @@ -109,7 +107,7 @@ def _fragment( if attributes or event_handlers: msg = "Fragments cannot have attributes besides 'key'" raise TypeError(msg) - model: VdomDict = {"tagName": ""} + model = VdomDict(tagName="") if children: model["children"] = children @@ -143,7 +141,7 @@ def _script( Doing so may allow for malicious code injection (`XSS `__`). """ - model: VdomDict = {"tagName": "script"} + model = VdomDict(tagName="script") if event_handlers: msg = "'script' elements do not support event handlers" diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index d2dcea8e7..b70b80b48 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -20,7 +20,14 @@ from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import HOOK_STACK -from reactpy.types import Connection, Context, Key, Location, State, VdomDict +from reactpy.types import ( + Connection, + Context, + Key, + Location, + State, + VdomDict, +) from reactpy.utils import Ref if not TYPE_CHECKING: @@ -362,7 +369,7 @@ def __init__( def render(self) -> VdomDict: HOOK_STACK.current_hook().set_context_provider(self) - return {"tagName": "", "children": self.children} + return VdomDict(tagName="ContextProvider", children=self.children) def __repr__(self) -> str: return f"ContextProvider({self.type})" diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 5115120de..a32f97083 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -196,7 +196,7 @@ async def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]} + wrapper_model = VdomDict(tagName="", children=[raw_model]) await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 513b37731..ff64be7f1 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -26,8 +26,9 @@ EventHandlerType, VdomAttributes, VdomChildren, + VdomDict, VdomJson, - _VdomDict, + VdomTypeDict, ) VDOM_JSON_SCHEMA = { @@ -107,26 +108,8 @@ def validate_vdom_json(value: Any) -> VdomJson: def is_vdom(value: Any) -> bool: - """Return whether a value is a :class:`VdomDict` - - This employs a very simple heuristic - something is VDOM if: - - 1. It is a ``dict`` instance - 2. It contains the key ``"tagName"`` - 3. The value of the key ``"tagName"`` is a string - - .. note:: - - Performing an ``isinstance(value, VdomDict)`` check is too restrictive since the - user would be forced to import ``VdomDict`` every time they needed to declare a - VDOM element. Giving the user more flexibility, at the cost of this check's - accuracy, is worth it. - """ - return ( - isinstance(value, dict) - and "tagName" in value - and isinstance(value["tagName"], str) - ) + """Return whether a value is a :class:`VdomDict`""" + return isinstance(value, VdomDict) class Vdom: @@ -137,7 +120,7 @@ def __init__( /, allow_children: bool = True, custom_constructor: CustomVdomConstructor | None = None, - **kwargs: Unpack[_VdomDict], + **kwargs: Unpack[VdomTypeDict], ) -> None: """This init method is used to declare the VDOM dictionary default values, as well as configurable properties related to the construction of VDOM dictionaries.""" @@ -159,14 +142,14 @@ def __init__( @overload def __call__( self, attributes: VdomAttributes, /, *children: VdomChildren - ) -> _VdomDict: ... + ) -> VdomDict: ... @overload - def __call__(self, *children: VdomChildren) -> _VdomDict: ... + def __call__(self, *children: VdomChildren) -> VdomDict: ... def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> _VdomDict: + ) -> VdomDict: """The entry point for the VDOM API, for example reactpy.html().""" attributes, children = separate_attributes_and_children(attributes_and_children) key = attributes.pop("key", None) @@ -198,7 +181,7 @@ def __call__( if REACTPY_DEBUG.current: self._validate_keys(result.keys()) - return cast(_VdomDict, self.default_values | result) + return VdomDict(**(self.default_values | result)) # type: ignore @staticmethod def _validate_keys(keys: Sequence[str] | Iterable[str]) -> None: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index dba9b2ff2..a35c7bca4 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -11,6 +11,7 @@ Literal, Protocol, TypeVar, + Unpack, overload, runtime_checkable, ) @@ -780,8 +781,8 @@ class DangerouslySetInnerHTML(TypedDict): } -class _VdomDict(TypedDict): - """Dictionary representation of what the `Vdom` class eventually resolves into.""" +class VdomTypeDict(TypedDict): + """TypedDict representation of what the `VdomDict` should look like.""" tagName: str key: NotRequired[Key | None] @@ -791,7 +792,58 @@ class _VdomDict(TypedDict): importSource: NotRequired[ImportSourceDict] -VdomDict = _VdomDict | dict[str, Any] +class VdomDict(dict): + """A dictionary representing a virtual DOM element.""" + + def __init__(self, **kwargs: Unpack[VdomTypeDict]) -> None: + invalid_keys = set(kwargs) - ALLOWED_VDOM_KEYS + if invalid_keys: + msg = f"Invalid keys: {invalid_keys}." + raise ValueError(msg) + + super().__init__(**kwargs) + + @overload + def __getitem__(self, key: Literal["tagName"]) -> str: ... + @overload + def __getitem__(self, key: Literal["key"]) -> Key | None: ... + @overload + def __getitem__( + self, key: Literal["children"] + ) -> Sequence[ComponentType | VdomChild]: ... + @overload + def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ... + @overload + def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ... + @overload + def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ... + def __getitem__(self, key: VdomDictKeys) -> Any: + return super().__getitem__(key) + + @overload + def __setitem__(self, key: Literal["tagName"], value: str) -> None: ... + @overload + def __setitem__(self, key: Literal["key"], value: Key | None) -> None: ... + @overload + def __setitem__( + self, key: Literal["children"], value: Sequence[ComponentType | VdomChild] + ) -> None: ... + @overload + def __setitem__( + self, key: Literal["attributes"], value: VdomAttributes + ) -> None: ... + @overload + def __setitem__( + self, key: Literal["eventHandlers"], value: EventHandlerDict + ) -> None: ... + @overload + def __setitem__( + self, key: Literal["importSource"], value: ImportSourceDict + ) -> None: ... + def __setitem__(self, key: VdomDictKeys, value: Any) -> None: + if key not in ALLOWED_VDOM_KEYS: + raise KeyError(f"Invalid key: {key}") + super().__setitem__(key, value) VdomChild: TypeAlias = ComponentType | VdomDict | str | None | Any @@ -875,14 +927,14 @@ class VdomDictConstructor(Protocol): @overload def __call__( self, attributes: VdomAttributes, /, *children: VdomChildren - ) -> VdomDict | dict[str, Any]: ... + ) -> VdomDict: ... @overload - def __call__(self, *children: VdomChildren) -> VdomDict | dict[str, Any]: ... + def __call__(self, *children: VdomChildren) -> VdomDict: ... def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict | dict[str, Any]: ... + ) -> VdomDict: ... class LayoutUpdateMessage(TypedDict): diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index db8452cbd..34242a189 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -30,7 +30,7 @@ def image( base64_value = b64encode(bytes_value).decode() src = f"data:image/{format};base64,{base64_value}" - return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} + return VdomDict(tagName="img", attributes={"src": src, **(attributes or {})}) _Value = TypeVar("_Value") diff --git a/tests/sample.py b/tests/sample.py index fe5dfde07..0c24144c7 100644 --- a/tests/sample.py +++ b/tests/sample.py @@ -2,11 +2,10 @@ from reactpy import html from reactpy.core.component import component -from reactpy.types import VdomDict @component -def SampleApp() -> VdomDict: +def SampleApp(): return html.div( {"id": "sample", "style": {"padding": "15px"}}, html.h1("Sample Application"), diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 0f98a057b..ad5be1a23 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -7,7 +7,7 @@ from reactpy.config import REACTPY_DEBUG from reactpy.core.events import EventHandler from reactpy.core.vdom import Vdom, is_vdom, validate_vdom_json -from reactpy.types import _VdomDict +from reactpy.types import VdomDict, VdomTypeDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} @@ -18,13 +18,15 @@ [ (False, {}), (False, {"tagName": None}), - (False, _VdomDict()), - (True, {"tagName": ""}), - (True, _VdomDict(tagName="")), + (False, {"tagName": ""}), + (False, VdomTypeDict()), + (False, VdomDict()), + (True, VdomDict(tagName="")), + (True, VdomDict(tagName="div")), ], ) def test_is_vdom(result, value): - assert is_vdom(value) == result + assert result == is_vdom(value) @pytest.mark.parametrize( @@ -332,3 +334,9 @@ def test_invalid_vdom_keys(): with pytest.raises(ValueError, match="You must specify a 'tagName'*"): reactpy.Vdom() + + with pytest.raises(ValueError, match="Invalid keys:*"): + reactpy.types.VdomDict(foo="bar") + + with pytest.raises(KeyError, match="Invalid key:*"): + reactpy.types.VdomDict()["foo"] = "bar" From 5c0abf3444bad89da4de56cd74c75389ea8a0105 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Feb 2025 19:38:07 -0800 Subject: [PATCH 11/14] better vdom checks --- src/reactpy/core/vdom.py | 6 +----- src/reactpy/types.py | 3 +++ tests/test_core/test_vdom.py | 12 +++++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index ff64be7f1..bf15c4452 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -199,7 +199,7 @@ def separate_attributes_and_children( _attributes: VdomAttributes children_or_iterables: Sequence[Any] - if _is_attributes(values[0]): + if type(values[0]) is dict: _attributes, *children_or_iterables = values else: _attributes = {} @@ -246,10 +246,6 @@ def _flatten_children(children: Sequence[Any]) -> list[Any]: return _children -def _is_attributes(value: Any) -> bool: - return isinstance(value, Mapping) and "tagName" not in value - - def _is_single_child(value: Any) -> bool: if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): return True diff --git a/src/reactpy/types.py b/src/reactpy/types.py index a35c7bca4..023616ab8 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -796,6 +796,9 @@ class VdomDict(dict): """A dictionary representing a virtual DOM element.""" def __init__(self, **kwargs: Unpack[VdomTypeDict]) -> None: + if "tagName" not in kwargs: + msg = "VdomDict requires a 'tagName' key." + raise ValueError(msg) invalid_keys = set(kwargs) - ALLOWED_VDOM_KEYS if invalid_keys: msg = f"Invalid keys: {invalid_keys}." diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index ad5be1a23..fa379a067 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -19,8 +19,7 @@ (False, {}), (False, {"tagName": None}), (False, {"tagName": ""}), - (False, VdomTypeDict()), - (False, VdomDict()), + (False, VdomTypeDict(tagName="div")), (True, VdomDict(tagName="")), (True, VdomDict(tagName="div")), ], @@ -71,7 +70,7 @@ def test_is_vdom(result, value): ), ( reactpy.Vdom(tagName="div")({"tagName": "div"}), - {"tagName": "div", "children": [{"tagName": "div"}]}, + {"tagName": "div", "attributes": {"tagName": "div"}}, ), ( reactpy.Vdom(tagName="div")((i for i in range(3))), @@ -336,7 +335,10 @@ def test_invalid_vdom_keys(): reactpy.Vdom() with pytest.raises(ValueError, match="Invalid keys:*"): - reactpy.types.VdomDict(foo="bar") + reactpy.types.VdomDict(tagName="test", foo="bar") with pytest.raises(KeyError, match="Invalid key:*"): - reactpy.types.VdomDict()["foo"] = "bar" + reactpy.types.VdomDict(tagName="test")["foo"] = "bar" + + with pytest.raises(ValueError, match="VdomDict requires a 'tagName' key."): + reactpy.types.VdomDict(foo="bar") From 1d9306da5f2ccf2586815856a707db93ad1295f2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:01:22 -0800 Subject: [PATCH 12/14] fix type errors --- src/reactpy/core/vdom.py | 1 + src/reactpy/types.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index bf15c4452..e5bf7a4d7 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -199,6 +199,7 @@ def separate_attributes_and_children( _attributes: VdomAttributes children_or_iterables: Sequence[Any] + # ruff: noqa: E721 if type(values[0]) is dict: _attributes, *children_or_iterables = values else: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 023616ab8..eb43d74a6 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -11,12 +11,11 @@ Literal, Protocol, TypeVar, - Unpack, overload, runtime_checkable, ) -from typing_extensions import NamedTuple, NotRequired, TypeAlias, TypedDict +from typing_extensions import NamedTuple, NotRequired, TypeAlias, TypedDict, Unpack CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") From f68efa330d1104b8a3a9a1bb130f43484a4a4fe4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 27 Feb 2025 04:09:30 -0800 Subject: [PATCH 13/14] Self review --- docs/source/about/changelog.rst | 3 + src/reactpy/_html.py | 366 ++++++++++++++++---------------- src/reactpy/core/hooks.py | 2 +- src/reactpy/core/vdom.py | 21 +- src/reactpy/types.py | 4 +- src/reactpy/web/module.py | 10 +- tests/test_core/test_vdom.py | 2 +- 7 files changed, 198 insertions(+), 210 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index bb8eac035..b2d605890 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -43,6 +43,9 @@ Unreleased - :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format. - :pull:`1278` - ``reactpy.utils.reactpy_to_string`` will now retain the user's original casing for ``data-*`` and ``aria-*`` attributes. - :pull:`1278` - ``reactpy.utils.string_to_reactpy`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. +- :pull:`1281` - ``reactpy.core.vdom._CustomVdomDictConstructor`` has been moved to ``reactpy.types.CustomVdomConstructor``. +- :pull:`1281` - ``reactpy.core.vdom._EllipsisRepr`` has been moved to ``reactpy.types.EllipsisRepr``. +- :pull:`1281` - ``reactpy.types.VdomDictConstructor`` has been renamed to ``reactpy.types.VdomConstructor``. **Removed** diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 5c992f549..31d18792f 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -10,8 +10,8 @@ VdomAttributes, VdomChild, VdomChildren, + VdomConstructor, VdomDict, - VdomDictConstructor, ) __all__ = ["html"] @@ -172,7 +172,7 @@ def _script( class SvgConstructor: """Constructor specifically for SVG children.""" - __cache__: ClassVar[dict[str, VdomDictConstructor]] = {} + __cache__: ClassVar[dict[str, VdomConstructor]] = {} @overload def __call__( @@ -187,7 +187,7 @@ def __call__( ) -> VdomDict: return self.svg(*attributes_and_children) - def __getattr__(self, value: str) -> VdomDictConstructor: + def __getattr__(self, value: str) -> VdomConstructor: value = value.rstrip("_").replace("_", "-") if value in self.__cache__: @@ -202,72 +202,72 @@ def __getattr__(self, value: str) -> VdomDictConstructor: # SVG child elements, written out here for auto-complete purposes # The actual elements are created dynamically in the __getattr__ method. # Elements other than these can still be created. - a: VdomDictConstructor - animate: VdomDictConstructor - animateMotion: VdomDictConstructor - animateTransform: VdomDictConstructor - circle: VdomDictConstructor - clipPath: VdomDictConstructor - defs: VdomDictConstructor - desc: VdomDictConstructor - discard: VdomDictConstructor - ellipse: VdomDictConstructor - feBlend: VdomDictConstructor - feColorMatrix: VdomDictConstructor - feComponentTransfer: VdomDictConstructor - feComposite: VdomDictConstructor - feConvolveMatrix: VdomDictConstructor - feDiffuseLighting: VdomDictConstructor - feDisplacementMap: VdomDictConstructor - feDistantLight: VdomDictConstructor - feDropShadow: VdomDictConstructor - feFlood: VdomDictConstructor - feFuncA: VdomDictConstructor - feFuncB: VdomDictConstructor - feFuncG: VdomDictConstructor - feFuncR: VdomDictConstructor - feGaussianBlur: VdomDictConstructor - feImage: VdomDictConstructor - feMerge: VdomDictConstructor - feMergeNode: VdomDictConstructor - feMorphology: VdomDictConstructor - feOffset: VdomDictConstructor - fePointLight: VdomDictConstructor - feSpecularLighting: VdomDictConstructor - feSpotLight: VdomDictConstructor - feTile: VdomDictConstructor - feTurbulence: VdomDictConstructor - filter: VdomDictConstructor - foreignObject: VdomDictConstructor - g: VdomDictConstructor - hatch: VdomDictConstructor - hatchpath: VdomDictConstructor - image: VdomDictConstructor - line: VdomDictConstructor - linearGradient: VdomDictConstructor - marker: VdomDictConstructor - mask: VdomDictConstructor - metadata: VdomDictConstructor - mpath: VdomDictConstructor - path: VdomDictConstructor - pattern: VdomDictConstructor - polygon: VdomDictConstructor - polyline: VdomDictConstructor - radialGradient: VdomDictConstructor - rect: VdomDictConstructor - script: VdomDictConstructor - set: VdomDictConstructor - stop: VdomDictConstructor - style: VdomDictConstructor - switch: VdomDictConstructor - symbol: VdomDictConstructor - text: VdomDictConstructor - textPath: VdomDictConstructor - title: VdomDictConstructor - tspan: VdomDictConstructor - use: VdomDictConstructor - view: VdomDictConstructor - svg: VdomDictConstructor + a: VdomConstructor + animate: VdomConstructor + animateMotion: VdomConstructor + animateTransform: VdomConstructor + circle: VdomConstructor + clipPath: VdomConstructor + defs: VdomConstructor + desc: VdomConstructor + discard: VdomConstructor + ellipse: VdomConstructor + feBlend: VdomConstructor + feColorMatrix: VdomConstructor + feComponentTransfer: VdomConstructor + feComposite: VdomConstructor + feConvolveMatrix: VdomConstructor + feDiffuseLighting: VdomConstructor + feDisplacementMap: VdomConstructor + feDistantLight: VdomConstructor + feDropShadow: VdomConstructor + feFlood: VdomConstructor + feFuncA: VdomConstructor + feFuncB: VdomConstructor + feFuncG: VdomConstructor + feFuncR: VdomConstructor + feGaussianBlur: VdomConstructor + feImage: VdomConstructor + feMerge: VdomConstructor + feMergeNode: VdomConstructor + feMorphology: VdomConstructor + feOffset: VdomConstructor + fePointLight: VdomConstructor + feSpecularLighting: VdomConstructor + feSpotLight: VdomConstructor + feTile: VdomConstructor + feTurbulence: VdomConstructor + filter: VdomConstructor + foreignObject: VdomConstructor + g: VdomConstructor + hatch: VdomConstructor + hatchpath: VdomConstructor + image: VdomConstructor + line: VdomConstructor + linearGradient: VdomConstructor + marker: VdomConstructor + mask: VdomConstructor + metadata: VdomConstructor + mpath: VdomConstructor + path: VdomConstructor + pattern: VdomConstructor + polygon: VdomConstructor + polyline: VdomConstructor + radialGradient: VdomConstructor + rect: VdomConstructor + script: VdomConstructor + set: VdomConstructor + stop: VdomConstructor + style: VdomConstructor + switch: VdomConstructor + symbol: VdomConstructor + text: VdomConstructor + textPath: VdomConstructor + title: VdomConstructor + tspan: VdomConstructor + use: VdomConstructor + view: VdomConstructor + svg: VdomConstructor class HtmlConstructor: @@ -281,13 +281,13 @@ class HtmlConstructor: with underscores (eg. `html.data_table` for ``).""" # ruff: noqa: N815 - __cache__: ClassVar[dict[str, VdomDictConstructor]] = { + __cache__: ClassVar[dict[str, VdomConstructor]] = { "script": Vdom(tagName="script", custom_constructor=_script), "fragment": Vdom(tagName="", custom_constructor=_fragment), "svg": SvgConstructor(), } - def __getattr__(self, value: str) -> VdomDictConstructor: + def __getattr__(self, value: str) -> VdomConstructor: value = value.rstrip("_").replace("_", "-") if value in self.__cache__: @@ -302,118 +302,118 @@ def __getattr__(self, value: str) -> VdomDictConstructor: # Standard HTML elements are written below for auto-complete purposes # The actual elements are created dynamically when __getattr__ is called. # Elements other than those type-hinted below can still be created. - a: VdomDictConstructor - abbr: VdomDictConstructor - address: VdomDictConstructor - area: VdomDictConstructor - article: VdomDictConstructor - aside: VdomDictConstructor - audio: VdomDictConstructor - b: VdomDictConstructor - body: VdomDictConstructor - base: VdomDictConstructor - bdi: VdomDictConstructor - bdo: VdomDictConstructor - blockquote: VdomDictConstructor - br: VdomDictConstructor - button: VdomDictConstructor - canvas: VdomDictConstructor - caption: VdomDictConstructor - cite: VdomDictConstructor - code: VdomDictConstructor - col: VdomDictConstructor - colgroup: VdomDictConstructor - data: VdomDictConstructor - dd: VdomDictConstructor - del_: VdomDictConstructor - details: VdomDictConstructor - dialog: VdomDictConstructor - div: VdomDictConstructor - dl: VdomDictConstructor - dt: VdomDictConstructor - em: VdomDictConstructor - embed: VdomDictConstructor - fieldset: VdomDictConstructor - figcaption: VdomDictConstructor - figure: VdomDictConstructor - footer: VdomDictConstructor - form: VdomDictConstructor - h1: VdomDictConstructor - h2: VdomDictConstructor - h3: VdomDictConstructor - h4: VdomDictConstructor - h5: VdomDictConstructor - h6: VdomDictConstructor - head: VdomDictConstructor - header: VdomDictConstructor - hr: VdomDictConstructor - html: VdomDictConstructor - i: VdomDictConstructor - iframe: VdomDictConstructor - img: VdomDictConstructor - input: VdomDictConstructor - ins: VdomDictConstructor - kbd: VdomDictConstructor - label: VdomDictConstructor - legend: VdomDictConstructor - li: VdomDictConstructor - link: VdomDictConstructor - main: VdomDictConstructor - map: VdomDictConstructor - mark: VdomDictConstructor - math: VdomDictConstructor - menu: VdomDictConstructor - menuitem: VdomDictConstructor - meta: VdomDictConstructor - meter: VdomDictConstructor - nav: VdomDictConstructor - noscript: VdomDictConstructor - object: VdomDictConstructor - ol: VdomDictConstructor - option: VdomDictConstructor - output: VdomDictConstructor - p: VdomDictConstructor - param: VdomDictConstructor - picture: VdomDictConstructor - portal: VdomDictConstructor - pre: VdomDictConstructor - progress: VdomDictConstructor - q: VdomDictConstructor - rp: VdomDictConstructor - rt: VdomDictConstructor - ruby: VdomDictConstructor - s: VdomDictConstructor - samp: VdomDictConstructor - script: VdomDictConstructor - section: VdomDictConstructor - select: VdomDictConstructor - slot: VdomDictConstructor - small: VdomDictConstructor - source: VdomDictConstructor - span: VdomDictConstructor - strong: VdomDictConstructor - style: VdomDictConstructor - sub: VdomDictConstructor - summary: VdomDictConstructor - sup: VdomDictConstructor - table: VdomDictConstructor - tbody: VdomDictConstructor - td: VdomDictConstructor - template: VdomDictConstructor - textarea: VdomDictConstructor - tfoot: VdomDictConstructor - th: VdomDictConstructor - thead: VdomDictConstructor - time: VdomDictConstructor - title: VdomDictConstructor - tr: VdomDictConstructor - track: VdomDictConstructor - u: VdomDictConstructor - ul: VdomDictConstructor - var: VdomDictConstructor - video: VdomDictConstructor - wbr: VdomDictConstructor - fragment: VdomDictConstructor + a: VdomConstructor + abbr: VdomConstructor + address: VdomConstructor + area: VdomConstructor + article: VdomConstructor + aside: VdomConstructor + audio: VdomConstructor + b: VdomConstructor + body: VdomConstructor + base: VdomConstructor + bdi: VdomConstructor + bdo: VdomConstructor + blockquote: VdomConstructor + br: VdomConstructor + button: VdomConstructor + canvas: VdomConstructor + caption: VdomConstructor + cite: VdomConstructor + code: VdomConstructor + col: VdomConstructor + colgroup: VdomConstructor + data: VdomConstructor + dd: VdomConstructor + del_: VdomConstructor + details: VdomConstructor + dialog: VdomConstructor + div: VdomConstructor + dl: VdomConstructor + dt: VdomConstructor + em: VdomConstructor + embed: VdomConstructor + fieldset: VdomConstructor + figcaption: VdomConstructor + figure: VdomConstructor + footer: VdomConstructor + form: VdomConstructor + h1: VdomConstructor + h2: VdomConstructor + h3: VdomConstructor + h4: VdomConstructor + h5: VdomConstructor + h6: VdomConstructor + head: VdomConstructor + header: VdomConstructor + hr: VdomConstructor + html: VdomConstructor + i: VdomConstructor + iframe: VdomConstructor + img: VdomConstructor + input: VdomConstructor + ins: VdomConstructor + kbd: VdomConstructor + label: VdomConstructor + legend: VdomConstructor + li: VdomConstructor + link: VdomConstructor + main: VdomConstructor + map: VdomConstructor + mark: VdomConstructor + math: VdomConstructor + menu: VdomConstructor + menuitem: VdomConstructor + meta: VdomConstructor + meter: VdomConstructor + nav: VdomConstructor + noscript: VdomConstructor + object: VdomConstructor + ol: VdomConstructor + option: VdomConstructor + output: VdomConstructor + p: VdomConstructor + param: VdomConstructor + picture: VdomConstructor + portal: VdomConstructor + pre: VdomConstructor + progress: VdomConstructor + q: VdomConstructor + rp: VdomConstructor + rt: VdomConstructor + ruby: VdomConstructor + s: VdomConstructor + samp: VdomConstructor + script: VdomConstructor + section: VdomConstructor + select: VdomConstructor + slot: VdomConstructor + small: VdomConstructor + source: VdomConstructor + span: VdomConstructor + strong: VdomConstructor + style: VdomConstructor + sub: VdomConstructor + summary: VdomConstructor + sup: VdomConstructor + table: VdomConstructor + tbody: VdomConstructor + td: VdomConstructor + template: VdomConstructor + textarea: VdomConstructor + tfoot: VdomConstructor + th: VdomConstructor + thead: VdomConstructor + time: VdomConstructor + title: VdomConstructor + tr: VdomConstructor + track: VdomConstructor + u: VdomConstructor + ul: VdomConstructor + var: VdomConstructor + video: VdomConstructor + wbr: VdomConstructor + fragment: VdomConstructor # Special Case: SVG elements # Since SVG elements have a different set of allowed children, they are diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index b70b80b48..8fc7db703 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -369,7 +369,7 @@ def __init__( def render(self) -> VdomDict: HOOK_STACK.current_hook().set_context_provider(self) - return VdomDict(tagName="ContextProvider", children=self.children) + return VdomDict(tagName="", children=self.children) def __repr__(self) -> str: return f"ContextProvider({self.type})" diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index e5bf7a4d7..c2f420988 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -18,7 +18,6 @@ from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.types import ( - ALLOWED_VDOM_KEYS, ComponentType, CustomVdomConstructor, EllipsisRepr, @@ -122,12 +121,11 @@ def __init__( custom_constructor: CustomVdomConstructor | None = None, **kwargs: Unpack[VdomTypeDict], ) -> None: - """This init method is used to declare the VDOM dictionary default values, as well as configurable properties - related to the construction of VDOM dictionaries.""" + """`**kwargs` provided here are considered as defaults for dictionary key values. + Other arguments exist to configure the way VDOM dictionaries are constructed.""" if "tagName" not in kwargs: msg = "You must specify a 'tagName' for a VDOM element." raise ValueError(msg) - self._validate_keys(kwargs.keys()) self.allow_children = allow_children self.custom_constructor = custom_constructor self.default_values = kwargs @@ -178,18 +176,9 @@ def __call__( if children and not self.allow_children: msg = f"{self.default_values.get('tagName')!r} nodes cannot have children." raise TypeError(msg) - if REACTPY_DEBUG.current: - self._validate_keys(result.keys()) return VdomDict(**(self.default_values | result)) # type: ignore - @staticmethod - def _validate_keys(keys: Sequence[str] | Iterable[str]) -> None: - invalid_keys = set(keys) - ALLOWED_VDOM_KEYS - if invalid_keys: - msg = f"Invalid keys {invalid_keys} provided." - raise ValueError(msg) - def separate_attributes_and_children( values: Sequence[Any], @@ -222,11 +211,7 @@ def separate_attributes_and_event_handlers( if callable(v): handler = EventHandler(to_event_handler_function(v)) - elif ( - # `isinstance` check on `Protocol` types is slow. We use pre-checks as an optimization - # before actually performing slow EventHandlerType type check - hasattr(v, "function") and isinstance(v, EventHandlerType) - ): + elif isinstance(v, EventHandler): handler = v else: _attributes[k] = v diff --git a/src/reactpy/types.py b/src/reactpy/types.py index eb43d74a6..ba8ce31f0 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -792,7 +792,7 @@ class VdomTypeDict(TypedDict): class VdomDict(dict): - """A dictionary representing a virtual DOM element.""" + """A light wrapper around Python `dict` that represents a Virtual DOM element.""" def __init__(self, **kwargs: Unpack[VdomTypeDict]) -> None: if "tagName" not in kwargs: @@ -923,7 +923,7 @@ class EventHandlerType(Protocol): """A dict mapping between event names to their handlers""" -class VdomDictConstructor(Protocol): +class VdomConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" @overload diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 8a5489f31..14343c091 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -9,7 +9,7 @@ from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR from reactpy.core.vdom import Vdom -from reactpy.types import ImportSourceDict, VdomDictConstructor +from reactpy.types import ImportSourceDict, VdomConstructor from reactpy.web.utils import ( module_name_suffix, resolve_module_exports_from_file, @@ -227,7 +227,7 @@ def export( export_names: str, fallback: Any | None = ..., allow_children: bool = ..., -) -> VdomDictConstructor: ... +) -> VdomConstructor: ... @overload @@ -236,7 +236,7 @@ def export( export_names: list[str] | tuple[str, ...], fallback: Any | None = ..., allow_children: bool = ..., -) -> list[VdomDictConstructor]: ... +) -> list[VdomConstructor]: ... def export( @@ -244,7 +244,7 @@ def export( export_names: str | list[str] | tuple[str, ...], fallback: Any | None = None, allow_children: bool = True, -) -> VdomDictConstructor | list[VdomDictConstructor]: +) -> VdomConstructor | list[VdomConstructor]: """Return one or more VDOM constructors from a :class:`WebModule` Parameters: @@ -282,7 +282,7 @@ def _make_export( name: str, fallback: Any | None, allow_children: bool, -) -> VdomDictConstructor: +) -> VdomConstructor: return Vdom( tagName=name, allow_children=allow_children, diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index fa379a067..f1efb4723 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -329,7 +329,7 @@ def test_raise_for_non_json_attrs(): def test_invalid_vdom_keys(): with pytest.raises(ValueError, match="Invalid keys*"): - reactpy.Vdom(tagName="div", foo="bar") + reactpy.Vdom(tagName="div", foo="bar")() with pytest.raises(ValueError, match="You must specify a 'tagName'*"): reactpy.Vdom() From 437878f4287243f9648321dcb63c8f3f2622a5cd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 27 Feb 2025 04:32:27 -0800 Subject: [PATCH 14/14] Simplier interface for `reactpy.Vdom` --- src/reactpy/_html.py | 8 +++--- src/reactpy/core/vdom.py | 31 +++++++++++----------- src/reactpy/web/module.py | 4 +-- tests/test_core/test_component.py | 2 +- tests/test_core/test_layout.py | 2 +- tests/test_core/test_vdom.py | 44 +++++++++++++------------------ 6 files changed, 42 insertions(+), 49 deletions(-) diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 31d18792f..9f160b403 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -194,7 +194,7 @@ def __getattr__(self, value: str) -> VdomConstructor: return self.__cache__[value] self.__cache__[value] = Vdom( - tagName=value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG + value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG ) return self.__cache__[value] @@ -282,8 +282,8 @@ class HtmlConstructor: # ruff: noqa: N815 __cache__: ClassVar[dict[str, VdomConstructor]] = { - "script": Vdom(tagName="script", custom_constructor=_script), - "fragment": Vdom(tagName="", custom_constructor=_fragment), + "script": Vdom("script", custom_constructor=_script), + "fragment": Vdom("", custom_constructor=_fragment), "svg": SvgConstructor(), } @@ -294,7 +294,7 @@ def __getattr__(self, value: str) -> VdomConstructor: return self.__cache__[value] self.__cache__[value] = Vdom( - tagName=value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY + value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY ) return self.__cache__[value] diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index c2f420988..4186ab5a6 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -2,7 +2,7 @@ from __future__ import annotations import json -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence from typing import ( Any, Callable, @@ -11,7 +11,6 @@ ) from fastjsonschema import compile as compile_json_schema -from typing_extensions import Unpack from reactpy._warnings import warn from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG @@ -23,11 +22,11 @@ EllipsisRepr, EventHandlerDict, EventHandlerType, + ImportSourceDict, VdomAttributes, VdomChildren, VdomDict, VdomJson, - VdomTypeDict, ) VDOM_JSON_SCHEMA = { @@ -112,30 +111,29 @@ def is_vdom(value: Any) -> bool: class Vdom: - """Class that follows VDOM spec, and exposes the user API that can create VDOM elements.""" + """Class-based constructor for VDOM dictionaries. + Once initialized, the `__call__` method on this class is used as the user API + for `reactpy.html`.""" def __init__( self, + tag_name: str, /, allow_children: bool = True, custom_constructor: CustomVdomConstructor | None = None, - **kwargs: Unpack[VdomTypeDict], + import_source: ImportSourceDict | None = None, ) -> None: - """`**kwargs` provided here are considered as defaults for dictionary key values. - Other arguments exist to configure the way VDOM dictionaries are constructed.""" - if "tagName" not in kwargs: - msg = "You must specify a 'tagName' for a VDOM element." - raise ValueError(msg) + """Initialize a VDOM constructor for the provided `tag_name`.""" self.allow_children = allow_children self.custom_constructor = custom_constructor - self.default_values = kwargs + self.import_source = import_source # Configure Python debugger attributes - self.__name__ = kwargs["tagName"] + self.__name__ = tag_name module_name = f_module_name(1) if module_name: self.__module__ = module_name - self.__qualname__ = f"{module_name}.{kwargs['tagName']}" + self.__qualname__ = f"{module_name}.{tag_name}" @overload def __call__( @@ -163,6 +161,7 @@ def __call__( attributes=attributes, event_handlers=event_handlers, ) + # Otherwise, use the default constructor else: result = { @@ -170,14 +169,16 @@ def __call__( **({"children": children} if children else {}), **({"attributes": attributes} if attributes else {}), **({"eventHandlers": event_handlers} if event_handlers else {}), + **({"importSource": self.import_source} if self.import_source else {}), } # Validate the result + result = result | {"tagName": self.__name__} if children and not self.allow_children: - msg = f"{self.default_values.get('tagName')!r} nodes cannot have children." + msg = f"{self.__name__!r} nodes cannot have children." raise TypeError(msg) - return VdomDict(**(self.default_values | result)) # type: ignore + return VdomDict(**result) # type: ignore def separate_attributes_and_children( diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 14343c091..04c898338 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -284,9 +284,9 @@ def _make_export( allow_children: bool, ) -> VdomConstructor: return Vdom( - tagName=name, + name, allow_children=allow_children, - importSource=ImportSourceDict( + import_source=ImportSourceDict( source=web_module.source, sourceType=web_module.source_type, fallback=(fallback or web_module.default_fallback), diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index e405bdc0a..4cbfebd54 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -27,7 +27,7 @@ def SimpleDiv(): async def test_simple_parameterized_component(): @reactpy.component def SimpleParamComponent(tag): - return reactpy.Vdom(tagName=tag)() + return reactpy.Vdom(tag)() assert SimpleParamComponent("div").render() == {"tagName": "div"} diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index b634355f4..eb292a1f0 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -82,7 +82,7 @@ async def test_simple_layout(): @reactpy.component def SimpleComponent(): tag, set_state_hook.current = reactpy.hooks.use_state("div") - return reactpy.Vdom(tagName=tag)() + return reactpy.Vdom(tag)() async with reactpy.Layout(SimpleComponent()) as layout: update_1 = await layout.render() diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index f1efb4723..2bbbf442f 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -32,19 +32,19 @@ def test_is_vdom(result, value): "actual, expected", [ ( - reactpy.Vdom(tagName="div")([reactpy.Vdom(tagName="div")()]), + reactpy.Vdom("div")([reactpy.Vdom("div")()]), {"tagName": "div", "children": [{"tagName": "div"}]}, ), ( - reactpy.Vdom(tagName="div")({"style": {"backgroundColor": "red"}}), + reactpy.Vdom("div")({"style": {"backgroundColor": "red"}}), {"tagName": "div", "attributes": {"style": {"backgroundColor": "red"}}}, ), ( # multiple iterables of children are merged - reactpy.Vdom(tagName="div")( + reactpy.Vdom("div")( ( - [reactpy.Vdom(tagName="div")(), 1], - (reactpy.Vdom(tagName="div")(), 2), + [reactpy.Vdom("div")(), 1], + (reactpy.Vdom("div")(), 2), ) ), { @@ -53,13 +53,11 @@ def test_is_vdom(result, value): }, ), ( - reactpy.Vdom(tagName="div")({"onEvent": FAKE_EVENT_HANDLER}), + reactpy.Vdom("div")({"onEvent": FAKE_EVENT_HANDLER}), {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, ), ( - reactpy.Vdom( - tagName="div", - )((reactpy.html.h1("hello"), reactpy.html.h2("world"))), + reactpy.Vdom("div")((reactpy.html.h1("hello"), reactpy.html.h2("world"))), { "tagName": "div", "children": [ @@ -69,19 +67,19 @@ def test_is_vdom(result, value): }, ), ( - reactpy.Vdom(tagName="div")({"tagName": "div"}), + reactpy.Vdom("div")({"tagName": "div"}), {"tagName": "div", "attributes": {"tagName": "div"}}, ), ( - reactpy.Vdom(tagName="div")((i for i in range(3))), + reactpy.Vdom("div")((i for i in range(3))), {"tagName": "div", "children": [0, 1, 2]}, ), ( - reactpy.Vdom(tagName="div")((x**2 for x in [1, 2, 3])), + reactpy.Vdom("div")((x**2 for x in [1, 2, 3])), {"tagName": "div", "children": [1, 4, 9]}, ), ( - reactpy.Vdom(tagName="div")(["child_1", ["child_2"]]), + reactpy.Vdom("div")(["child_1", ["child_2"]]), {"tagName": "div", "children": ["child_1", "child_2"]}, ), ], @@ -93,7 +91,7 @@ def test_simple_node_construction(actual, expected): async def test_callable_attributes_are_cast_to_event_handlers(): params_from_calls = [] - node = reactpy.Vdom(tagName="div")( + node = reactpy.Vdom("div")( {"onEvent": lambda *args: params_from_calls.append(args)} ) @@ -109,7 +107,7 @@ async def test_callable_attributes_are_cast_to_event_handlers(): def test_make_vdom_constructor(): - elmt = Vdom(tagName="some-tag") + elmt = Vdom("some-tag") assert elmt({"data": 1}, [elmt()]) == { "tagName": "some-tag", @@ -117,7 +115,7 @@ def test_make_vdom_constructor(): "attributes": {"data": 1}, } - no_children = Vdom(tagName="no-children", allow_children=False) + no_children = Vdom("no-children", allow_children=False) with pytest.raises(TypeError, match="cannot have children"): no_children([1, 2, 3]) @@ -295,7 +293,7 @@ def test_invalid_vdom(value, error_message_pattern): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: - reactpy.Vdom(tagName="div")((1 for i in range(10))) + reactpy.Vdom("div")((1 for i in range(10))) assert len(record) == 1 assert ( record[0] @@ -307,16 +305,16 @@ def test_warn_cannot_verify_keypath_for_genereators(): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_dynamic_children_must_have_keys(): with pytest.warns(UserWarning) as record: - reactpy.Vdom(tagName="div")([reactpy.Vdom(tagName="div")()]) + reactpy.Vdom("div")([reactpy.Vdom("div")()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @reactpy.component def MyComponent(): - return reactpy.Vdom(tagName="div")() + return reactpy.Vdom("div")() with pytest.warns(UserWarning) as record: - reactpy.Vdom(tagName="div")([MyComponent()]) + reactpy.Vdom("div")([MyComponent()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @@ -328,12 +326,6 @@ def test_raise_for_non_json_attrs(): def test_invalid_vdom_keys(): - with pytest.raises(ValueError, match="Invalid keys*"): - reactpy.Vdom(tagName="div", foo="bar")() - - with pytest.raises(ValueError, match="You must specify a 'tagName'*"): - reactpy.Vdom() - with pytest.raises(ValueError, match="Invalid keys:*"): reactpy.types.VdomDict(tagName="test", foo="bar")