diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 8586f2325..b2d605890 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** @@ -40,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** @@ -56,6 +62,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** 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 61c6ae77f..9f160b403 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 - -from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor - -if TYPE_CHECKING: - from reactpy.types import ( - EventHandlerDict, - Key, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomDictConstructor, - ) +from typing import ClassVar, overload + +from reactpy.core.vdom import Vdom +from reactpy.types import ( + EventHandlerDict, + Key, + VdomAttributes, + VdomChild, + VdomChildren, + VdomConstructor, + VdomDict, +) __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" @@ -174,20 +172,28 @@ def _script( class SvgConstructor: """Constructor specifically for SVG children.""" - __cache__: ClassVar[dict[str, VdomDictConstructor]] = {} + __cache__: ClassVar[dict[str, VdomConstructor]] = {} + + @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: 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__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( + self.__cache__[value] = Vdom( value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG ) @@ -196,71 +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 + 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: @@ -274,143 +281,144 @@ class HtmlConstructor: with underscores (eg. `html.data_table` for ``).""" # ruff: noqa: N815 - __cache__: ClassVar[dict[str, VdomDictConstructor]] = { - "script": custom_vdom_constructor(_script), - "fragment": custom_vdom_constructor(_fragment), + __cache__: ClassVar[dict[str, VdomConstructor]] = { + "script": Vdom("script", custom_constructor=_script), + "fragment": Vdom("", 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__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( + self.__cache__[value] = Vdom( value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY ) 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. - 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 + # 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: 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 # separated into a different constructor, and are accessed via `html.svg.example()` - svg: SvgConstructor = SvgConstructor() + svg: SvgConstructor html = HtmlConstructor() diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index d2dcea8e7..8fc7db703 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="", 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 0e6e825a4..4186ab5a6 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -1,9 +1,14 @@ +# 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 typing import ( + Any, + Callable, + cast, + overload, +) from fastjsonschema import compile as compile_json_schema @@ -13,15 +18,14 @@ from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.types import ( ComponentType, + CustomVdomConstructor, + EllipsisRepr, EventHandlerDict, EventHandlerType, ImportSourceDict, - Key, VdomAttributes, - VdomChild, VdomChildren, VdomDict, - VdomDictConstructor, VdomJson, ) @@ -102,174 +106,131 @@ 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) - ) - - -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} - - if not attributes_and_children: - return model - - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - - if 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 + """Return whether a value is a :class:`VdomDict`""" + return isinstance(value, VdomDict) - 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." - 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}" - - return cast(VdomDictConstructor, constructor) +class Vdom: + """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, + import_source: ImportSourceDict | None = None, + ) -> None: + """Initialize a VDOM constructor for the provided `tag_name`.""" + self.allow_children = allow_children + self.custom_constructor = custom_constructor + self.import_source = import_source + + # Configure Python debugger attributes + self.__name__ = tag_name + module_name = f_module_name(1) + if module_name: + self.__module__ = module_name + self.__qualname__ = f"{module_name}.{tag_name}" + + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... -def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor: - """Cast function to VdomDictConstructor""" + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... - @wraps(func) - def wrapper(*attributes_and_children: Any) -> VdomDict: + 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) - return func(attributes, children, key, event_handlers) + if REACTPY_CHECK_JSON_ATTRS.current: + json.dumps(attributes) + + # Run custom constructor, if defined + if self.custom_constructor: + result = self.custom_constructor( + key=key, + children=children, + attributes=attributes, + event_handlers=event_handlers, + ) - return cast(VdomDictConstructor, wrapper) + # 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 {}), + **({"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.__name__!r} nodes cannot have children." + raise TypeError(msg) + + return VdomDict(**result) # type: ignore 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 + # ruff: noqa: E721 + if type(values[0]) is dict: + _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[dict[str, Any], EventHandlerDict]: - separated_attributes = {} - separated_event_handlers: dict[str, EventHandlerType] = {} +) -> tuple[VdomAttributes, EventHandlerDict]: + _attributes: VdomAttributes = {} + _event_handlers: dict[str, EventHandlerType] = {} for k, v in attributes.items(): handler: EventHandlerType 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 - hasattr(v, "function") and isinstance(v, EventHandlerType) - ): + elif isinstance(v, EventHandler): handler = v else: - separated_attributes[k] = v + _attributes[k] = v continue - separated_event_handlers[k] = handler + _event_handlers[k] = handler - return separated_attributes, dict(separated_event_handlers.items()) + return _attributes, _event_handlers -def _is_attributes(value: Any) -> bool: - return isinstance(value, Mapping) and "tagName" not in value +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_single_child(value: Any) -> bool: @@ -292,20 +253,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 74c3f3f92..027fb3e3b 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: @@ -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 @@ -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 189915873..ba8ce31f0 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,37 +1,29 @@ from __future__ import annotations -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, Callable, Generic, Literal, - NamedTuple, Protocol, TypeVar, + overload, runtime_checkable, ) -from typing_extensions import TypeAlias, TypedDict +from typing_extensions import NamedTuple, NotRequired, TypeAlias, TypedDict, Unpack 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"] @@ -41,7 +33,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 @@ -92,30 +84,775 @@ 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""" - - -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ComponentType | VdomChild] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict +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 + 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 = CssStyleTypeDict | 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 +VdomAttributesTypeDict = TypedDict( + "VdomAttributesTypeDict", + { + "key": Key, + "value": Any, + "defaultValue": Any, + "dangerouslySetInnerHTML": DangerouslySetInnerHTML, + "suppressContentEditableWarning": bool, + "suppressHydrationWarning": bool, + "style": CssStyleDict, + "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, + "defaultChecked": bool, + "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, +) +# 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", +] +ALLOWED_VDOM_KEYS = { + "tagName", + "key", + "children", + "attributes", + "eventHandlers", + "importSource", +} + + +class VdomTypeDict(TypedDict): + """TypedDict representation of what the `VdomDict` should look like.""" -class _VdomDictRequired(TypedDict, total=True): tagName: str + key: NotRequired[Key | None] + children: NotRequired[Sequence[ComponentType | VdomChild]] + attributes: NotRequired[VdomAttributes] + eventHandlers: NotRequired[EventHandlerDict] + importSource: NotRequired[ImportSourceDict] + + +class VdomDict(dict): + """A light wrapper around Python `dict` that represents 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}." + 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 +"""A single child element of a :class:`VdomDict`""" - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" +VdomChildren: TypeAlias = Sequence[VdomChild] | VdomChild +"""Describes a series of :class:`VdomChild` elements""" class ImportSourceDict(TypedDict): @@ -125,41 +862,29 @@ class ImportSourceDict(TypedDict): unmountBeforeUpdate: bool -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: dict[str, Any] - 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""" @@ -191,9 +916,24 @@ class EventHandlerType(Protocol): """ -class VdomDictConstructor(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 VdomConstructor(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: ... @@ -294,3 +1034,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..04c898338 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -8,8 +8,8 @@ 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.types import ImportSourceDict, VdomDictConstructor +from reactpy.core.vdom import Vdom +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,8 +282,8 @@ def _make_export( name: str, fallback: Any | None, allow_children: bool, -) -> VdomDictConstructor: - return make_vdom_constructor( +) -> VdomConstructor: + return Vdom( name, allow_children=allow_children, import_source=ImportSourceDict( diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index 01532b277..34242a189 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 @@ -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_component.py b/tests/test_core/test_component.py index aa8996d4e..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(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 b4de2e7e9..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(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 8e349fcc4..2bbbf442f 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, VdomTypeDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} @@ -18,40 +18,46 @@ [ (False, {}), (False, {"tagName": None}), - (False, VdomDict()), - (True, {"tagName": ""}), + (False, {"tagName": ""}), + (False, VdomTypeDict(tagName="div")), (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( "actual, expected", [ ( - reactpy.vdom("div", [reactpy.vdom("div")]), + reactpy.Vdom("div")([reactpy.Vdom("div")()]), {"tagName": "div", "children": [{"tagName": "div"}]}, ), ( - reactpy.vdom("div", {"style": {"backgroundColor": "red"}}), + reactpy.Vdom("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("div")( + ( + [reactpy.Vdom("div")(), 1], + (reactpy.Vdom("div")(), 2), + ) + ), { "tagName": "div", "children": [{"tagName": "div"}, 1, {"tagName": "div"}, 2], }, ), ( - reactpy.vdom("div", {"onEvent": FAKE_EVENT_HANDLER}), + reactpy.Vdom("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("div")((reactpy.html.h1("hello"), reactpy.html.h2("world"))), { "tagName": "div", "children": [ @@ -61,17 +67,21 @@ def test_is_vdom(result, value): }, ), ( - reactpy.vdom("div", {"tagName": "div"}), - {"tagName": "div", "children": [{"tagName": "div"}]}, + reactpy.Vdom("div")({"tagName": "div"}), + {"tagName": "div", "attributes": {"tagName": "div"}}, ), ( - reactpy.vdom("div", (i for i in range(3))), + reactpy.Vdom("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("div")((x**2 for x in [1, 2, 3])), {"tagName": "div", "children": [1, 4, 9]}, ), + ( + reactpy.Vdom("div")(["child_1", ["child_2"]]), + {"tagName": "div", "children": ["child_1", "child_2"]}, + ), ], ) def test_simple_node_construction(actual, expected): @@ -81,8 +91,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("div")( + {"onEvent": lambda *args: params_from_calls.append(args)} ) event_handlers = node.pop("eventHandlers") @@ -97,7 +107,7 @@ async def test_callable_attributes_are_cast_to_event_handlers(): def test_make_vdom_constructor(): - elmt = make_vdom_constructor("some-tag") + elmt = Vdom("some-tag") assert elmt({"data": 1}, [elmt()]) == { "tagName": "some-tag", @@ -105,7 +115,7 @@ def test_make_vdom_constructor(): "attributes": {"data": 1}, } - no_children = make_vdom_constructor("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]) @@ -283,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("div", (1 for i in range(10))) + reactpy.Vdom("div")((1 for i in range(10))) assert len(record) == 1 assert ( record[0] @@ -295,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("div", [reactpy.vdom("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("div") + return reactpy.Vdom("div")() with pytest.warns(UserWarning) as record: - reactpy.vdom("div", [MyComponent()]) + reactpy.Vdom("div")([MyComponent()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @@ -313,3 +323,14 @@ 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.types.VdomDict(tagName="test", foo="bar") + + with pytest.raises(KeyError, match="Invalid key:*"): + reactpy.types.VdomDict(tagName="test")["foo"] = "bar" + + with pytest.raises(ValueError, match="VdomDict requires a 'tagName' key."): + reactpy.types.VdomDict(foo="bar")