from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from functools import wraps
from typing import Any, Protocol, cast, overload
from fastjsonschema import compile as compile_json_schema
from reactpy._warnings import warn
from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG_MODE
from reactpy.core._f_back import f_module_name
from reactpy.core.events import EventHandler, to_event_handler_function
from reactpy.core.types import (
ComponentType,
EventHandlerDict,
EventHandlerType,
ImportSourceDict,
Key,
VdomAttributes,
VdomChild,
VdomChildren,
VdomDict,
VdomDictConstructor,
VdomJson,
)
VDOM_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/definitions/element",
"definitions": {
"element": {
"type": "object",
"properties": {
"tagName": {"type": "string"},
"key": {"type": ["string", "number", "null"]},
"error": {"type": "string"},
"children": {"$ref": "#/definitions/elementChildren"},
"attributes": {"type": "object"},
"eventHandlers": {"$ref": "#/definitions/elementEventHandlers"},
"importSource": {"$ref": "#/definitions/importSource"},
},
# The 'tagName' is required because its presence is a useful indicator of
# whether a dictionary describes a VDOM model or not.
"required": ["tagName"],
"dependentSchemas": {
# When 'error' is given, the 'tagName' should be empty.
"error": {"properties": {"tagName": {"maxLength": 0}}}
},
},
"elementChildren": {
"type": "array",
"items": {"$ref": "#/definitions/elementOrString"},
},
"elementEventHandlers": {
"type": "object",
"patternProperties": {
".*": {"$ref": "#/definitions/eventHandler"},
},
},
"eventHandler": {
"type": "object",
"properties": {
"target": {"type": "string"},
"preventDefault": {"type": "boolean"},
"stopPropagation": {"type": "boolean"},
},
"required": ["target"],
},
"importSource": {
"type": "object",
"properties": {
"source": {"type": "string"},
"sourceType": {"enum": ["URL", "NAME"]},
"fallback": {
"type": ["object", "string", "null"],
"if": {"not": {"type": "null"}},
"then": {"$ref": "#/definitions/elementOrString"},
},
"unmountBeforeUpdate": {"type": "boolean"},
},
"required": ["source"],
},
"elementOrString": {
"type": ["object", "string"],
"if": {"type": "object"},
"then": {"$ref": "#/definitions/element"},
},
},
}
"""JSON Schema describing serialized VDOM - see :ref:`VDOM` for more info"""
# we can't add a docstring to this because Sphinx doesn't know how to find its source
_COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA)
[docs]def validate_vdom_json(value: Any) -> VdomJson:
"""Validate serialized VDOM - see :attr:`VDOM_JSON_SCHEMA` for more info"""
_COMPILED_VDOM_VALIDATOR(value)
return cast(VdomJson, value)
[docs]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)
)
@overload
def vdom(tag: str, *children: VdomChildren) -> VdomDict: ...
@overload
def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ...
[docs]def vdom(
tag: str,
*attributes_and_children: Any,
**kwargs: Any,
) -> 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.
"""
if kwargs: # nocov
if "key" in kwargs:
if attributes_and_children:
maybe_attributes, *children = attributes_and_children
if _is_attributes(maybe_attributes):
attributes_and_children = (
{**maybe_attributes, "key": kwargs.pop("key")},
*children,
)
else:
attributes_and_children = (
{"key": kwargs.pop("key")},
maybe_attributes,
*children,
)
else:
attributes_and_children = ({"key": kwargs.pop("key")},)
warn(
"An element's 'key' must be declared in an attribute dict instead "
"of as a keyword argument. This will error in a future version.",
DeprecationWarning,
)
if kwargs:
msg = f"Extra keyword arguments {kwargs}"
raise ValueError(msg)
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
if event_handlers:
model["eventHandlers"] = event_handlers
return model
[docs]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}> <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/{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)
[docs]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)
def separate_attributes_and_children(
values: Sequence[Any],
) -> tuple[dict[str, Any], list[Any]]:
if not values:
return {}, []
attributes: dict[str, Any]
children_or_iterables: Sequence[Any]
if _is_attributes(values[0]):
attributes, *children_or_iterables = values
else:
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)
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] = {}
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)
):
handler = v
else:
separated_attributes[k] = v
continue
separated_event_handlers[k] = handler
return separated_attributes, dict(separated_event_handlers.items())
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
if REACTPY_DEBUG_MODE.current:
_validate_child_key_integrity(value)
return False
def _validate_child_key_integrity(value: Any) -> None:
if hasattr(value, "__iter__") and not hasattr(value, "__len__"):
warn(
f"Did not verify key-path integrity of children in generator {value} "
"- pass a sequence (i.e. list of finite length) in order to verify"
)
else:
for child in value:
if isinstance(child, ComponentType) and child.key is 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()}
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 "..."