diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index db1bd7fd2..382c59934 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -299,7 +299,7 @@ to allow users to enable this behavior early. - minor doc updates - :commit:`e5511d9` - add tests for callback identity preservation with keys - :commit:`72e03ec` - add 'key' to VDOM spec - :commit:`c3236fe` -- Rename validate_serialized_vdom to validate_vdom - :commit:`d04faf9` +- Rename validate_serialized_vdom to validate_vdom_json - :commit:`d04faf9` - EventHandler should not serialize itself - :commit:`f7a59f2` - fix docs typos - :commit:`42b2e20` - fixes: #331 - add roadmap to docs - :commit:`4226c12` diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 826cc64bf..9a0d30add 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -3,7 +3,7 @@ from .core.component import Component, component from .core.events import EventHandler, event from .core.layout import Layout -from .core.vdom import VdomDict, vdom +from .core.vdom import vdom from .server.prefab import run from .utils import Ref, html_to_vdom from .widgets import hotswap, multiview @@ -28,6 +28,5 @@ "Ref", "run", "vdom", - "VdomDict", "web", ] diff --git a/src/idom/core/component.py b/src/idom/core/component.py index 2789f7eb2..d16fee105 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -10,8 +10,7 @@ from functools import wraps from typing import Any, Callable, Dict, Optional, Tuple, Union -from .proto import ComponentType -from .vdom import VdomDict +from .proto import ComponentType, VdomDict def component( diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py index 2fb9fa724..e8b03d211 100644 --- a/src/idom/core/dispatcher.py +++ b/src/idom/core/dispatcher.py @@ -26,11 +26,10 @@ from anyio import create_task_group from jsonpatch import apply_patch, make_patch -from idom.core.vdom import VdomJson from idom.utils import Ref from .layout import LayoutEvent, LayoutUpdate -from .proto import LayoutType +from .proto import LayoutType, VdomJson logger = getLogger(__name__) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index 106af67c7..2db20fdf3 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -30,8 +30,8 @@ from idom.utils import Ref from .hooks import LifeCycleHook -from .proto import ComponentType, EventHandlerDict -from .vdom import VdomJson, validate_vdom +from .proto import ComponentType, EventHandlerDict, VdomJson +from .vdom import validate_vdom_json logger = getLogger(__name__) @@ -154,7 +154,7 @@ async def render(self) -> LayoutUpdate: # Ensure that the model is valid VDOM on each render root_id = self._root_life_cycle_state_id root_model = self._model_states_by_life_cycle_state_id[root_id] - validate_vdom(root_model.model.current) + validate_vdom_json(root_model.model.current) return result def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: diff --git a/src/idom/core/proto.py b/src/idom/core/proto.py index 2c8f43407..d82964795 100644 --- a/src/idom/core/proto.py +++ b/src/idom/core/proto.py @@ -7,22 +7,20 @@ from types import TracebackType from typing import ( - TYPE_CHECKING, Any, Callable, Dict, + Iterable, List, Mapping, Optional, + Sequence, Type, TypeVar, + Union, ) -from typing_extensions import Protocol, runtime_checkable - - -if TYPE_CHECKING: # pragma: no cover - from .vdom import VdomDict +from typing_extensions import Protocol, TypedDict, runtime_checkable ComponentConstructor = Callable[..., "ComponentType"] @@ -64,6 +62,77 @@ def __exit__( """Clean up the view after its final render""" +VdomAttributes = Mapping[str, Any] +"""Describes the attributes of a :class:`VdomDict`""" + +VdomChild = Union[ComponentType, "VdomDict", str] +"""A single child element of a :class:`VdomDict`""" + +VdomChildren = Sequence[VdomChild] +"""Describes a series of :class:`VdomChild` elements""" + +VdomAttributesAndChildren = Union[ + Mapping[str, Any], # this describes both VdomDict and VdomAttributes + Iterable[VdomChild], +] +"""Useful for the ``*attributes_and_children`` parameter in :func:`idom.core.vdom.vdom`""" + + +class _VdomDictOptional(TypedDict, total=False): + key: str + children: Sequence[ + # recursive types are not allowed yet: + # https://github.com/python/mypy/issues/731 + Union[ComponentType, Dict[str, Any], str] + ] + attributes: VdomAttributes + eventHandlers: EventHandlerDict # noqa + importSource: ImportSourceDict # noqa + + +class _VdomDictRequired(TypedDict, total=True): + tagName: str # noqa + + +class VdomDict(_VdomDictRequired, _VdomDictOptional): + """A :ref:`VDOM` dictionary""" + + +class ImportSourceDict(TypedDict): + source: str + fallback: Any + sourceType: str # noqa + unmountBeforeUpdate: bool # noqa + + +class _OptionalVdomJson(TypedDict, total=False): + key: str + error: str + children: List[Any] + attributes: Dict[str, Any] + eventHandlers: Dict[str, _JsonEventTarget] # noqa + importSource: _JsonImportSource # noqa + + +class _RequiredVdomJson(TypedDict, total=True): + tagName: str # noqa + + +class VdomJson(_RequiredVdomJson, _OptionalVdomJson): + """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" + + +class _JsonEventTarget(TypedDict): + target: str + preventDefault: bool # noqa + stopPropagation: bool # noqa + + +class _JsonImportSource(TypedDict): + source: str + fallback: Any + + EventHandlerMapping = Mapping[str, "EventHandlerType"] """A generic mapping between event names to their handlers""" diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 710a70842..898b5e32f 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -7,21 +7,9 @@ import inspect import logging -from typing import ( - Any, - Dict, - Iterable, - List, - Mapping, - Optional, - Sequence, - Tuple, - Union, - cast, -) +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, cast from fastjsonschema import compile as compile_json_schema -from mypy_extensions import TypedDict from typing_extensions import Protocol from idom.config import IDOM_DEBUG_MODE @@ -30,7 +18,15 @@ merge_event_handlers, to_event_handler_function, ) -from idom.core.proto import EventHandlerDict, EventHandlerMapping, EventHandlerType +from idom.core.proto import ( + EventHandlerDict, + EventHandlerMapping, + EventHandlerType, + ImportSourceDict, + VdomAttributesAndChildren, + VdomDict, + VdomJson, +) logger = logging.getLogger() @@ -106,23 +102,41 @@ _COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA) -def validate_vdom(value: Any) -> VdomJson: +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) -_AttributesAndChildrenArg = Union[Mapping[str, Any], str, Iterable[Any], Any] -_EventHandlersArg = Optional[EventHandlerMapping] -_ImportSourceArg = Optional["ImportSourceDict"] +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: _AttributesAndChildrenArg, + *attributes_and_children: VdomAttributesAndChildren, key: str = "", - event_handlers: _EventHandlersArg = None, - import_source: _ImportSourceArg = None, + event_handlers: Optional[EventHandlerMapping] = None, + import_source: Optional[ImportSourceDict] = None, ) -> VdomDict: """A helper function for creating VDOM dictionaries. @@ -169,17 +183,20 @@ def vdom( return model -class VdomDictConstructor(Protocol): +class _VdomDictConstructor(Protocol): def __call__( self, - *args: _AttributesAndChildrenArg, - event_handlers: _EventHandlersArg = None, - import_source: _ImportSourceArg = None, + *attributes_and_children: VdomAttributesAndChildren, + key: str = ..., + event_handlers: Optional[EventHandlerMapping] = ..., + import_source: Optional[ImportSourceDict] = ..., ) -> VdomDict: ... -def make_vdom_constructor(tag: str, allow_children: bool = True) -> VdomDictConstructor: +def make_vdom_constructor( + tag: str, allow_children: bool = True +) -> _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 @@ -187,10 +204,10 @@ def make_vdom_constructor(tag: str, allow_children: bool = True) -> VdomDictCons """ def constructor( - *attributes_and_children: _AttributesAndChildrenArg, + *attributes_and_children: VdomAttributesAndChildren, key: str = "", - event_handlers: _EventHandlersArg = None, - import_source: _ImportSourceArg = None, + event_handlers: Optional[EventHandlerMapping] = None, + import_source: Optional[ImportSourceDict] = None, ) -> VdomDict: model = vdom( tag, @@ -319,54 +336,3 @@ def _is_single_child(value: Any) -> bool: logger.error(f"Key not specified for dynamic child {child}") return False - - -class _VdomDictOptional(TypedDict, total=False): - key: str - children: Sequence[Any] - attributes: Dict[str, Any] - eventHandlers: EventHandlerDict # noqa - importSource: ImportSourceDict # noqa - - -class _VdomDictRequired(TypedDict, total=True): - tagName: str # noqa - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" - - -class ImportSourceDict(TypedDict): - source: str - fallback: Any - sourceType: str # noqa - unmountBeforeUpdate: bool # noqa - - -class _OptionalVdomJson(TypedDict, total=False): - key: str - error: str - children: List[Any] - attributes: Dict[str, Any] - eventHandlers: Dict[str, _JsonEventTarget] # noqa - importSource: _JsonImportSource # noqa - - -class _RequiredVdomJson(TypedDict, total=True): - tagName: str # noqa - - -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): - target: str - preventDefault: bool # noqa - stopPropagation: bool # noqa - - -class _JsonImportSource(TypedDict): - source: str - fallback: Any diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 7d25152b5..1d4f6b857 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -12,8 +12,16 @@ from typing import Any, List, NewType, Optional, Set, Tuple, Union, overload from urllib.parse import urlparse +from typing_extensions import Protocol + from idom.config import IDOM_DEBUG_MODE, IDOM_WED_MODULES_DIR -from idom.core.vdom import ImportSourceDict, VdomDictConstructor, make_vdom_constructor +from idom.core.proto import ( + EventHandlerMapping, + ImportSourceDict, + VdomAttributesAndChildren, + VdomDict, +) +from idom.core.vdom import make_vdom_constructor from .utils import ( module_name_suffix, @@ -203,6 +211,16 @@ def module_from_file( ) +class _VdomDictConstructor(Protocol): + def __call__( + self, + *attributes_and_children: VdomAttributesAndChildren, + key: str = ..., + event_handlers: Optional[EventHandlerMapping] = ..., + ) -> VdomDict: + ... + + @dataclass(frozen=True) class WebModule: source: str @@ -219,7 +237,7 @@ def export( export_names: str, fallback: Optional[Any], allow_children: bool, -) -> VdomDictConstructor: +) -> _VdomDictConstructor: ... @@ -229,7 +247,7 @@ def export( export_names: Union[List[str], Tuple[str]], fallback: Optional[Any], allow_children: bool, -) -> List[VdomDictConstructor]: +) -> List[_VdomDictConstructor]: ... @@ -238,7 +256,7 @@ def export( export_names: Union[str, List[str], Tuple[str]], fallback: Optional[Any] = None, allow_children: bool = True, -) -> Union[VdomDictConstructor, List[VdomDictConstructor]]: +) -> Union[_VdomDictConstructor, List[_VdomDictConstructor]]: """Return one or more VDOM constructors from a :class:`WebModule` Parameters: @@ -276,7 +294,7 @@ def _make_export( name: str, fallback: Optional[Any], allow_children: bool, -) -> VdomDictConstructor: +) -> _VdomDictConstructor: return partial( make_vdom_constructor( name, diff --git a/src/idom/widgets.py b/src/idom/widgets.py index c18c22eb3..f21943a82 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -12,8 +12,7 @@ from . import html from .core import hooks from .core.component import component -from .core.proto import ComponentConstructor -from .core.vdom import VdomDict +from .core.proto import ComponentConstructor, VdomDict from .utils import Ref @@ -60,7 +59,8 @@ def on_change(event: Dict[str, Any]) -> None: return callback(value if cast is None else cast(value)) - return html.input({**attrs, "type": type, "value": value, "onChange": on_change}) + attributes = {**attrs, "type": type, "value": value, "onChange": on_change} + return html.input(attributes) MountFunc = Callable[[ComponentConstructor], None] diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index c73fea1da..5a54dc6ef 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -4,13 +4,28 @@ import idom from idom.config import IDOM_DEBUG_MODE from idom.core.events import EventHandler -from idom.core.vdom import make_vdom_constructor, validate_vdom +from idom.core.proto import VdomDict +from idom.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} +@pytest.mark.parametrize( + "result, value", + [ + (False, {}), + (False, {"tagName": None}), + (False, VdomDict()), + (True, {"tagName": ""}), + (True, VdomDict(tagName="")), + ], +) +def test_is_vdom(result, value): + assert is_vdom(value) == result + + @pytest.mark.parametrize( "actual, expected", [ @@ -195,7 +210,7 @@ def test_make_vdom_constructor(): ], ) def test_valid_vdom(value): - validate_vdom(value) + validate_vdom_json(value) @pytest.mark.parametrize( @@ -294,7 +309,7 @@ def test_valid_vdom(value): ) def test_invalid_vdom(value, error_message_pattern): with pytest.raises(JsonSchemaException, match=error_message_pattern): - validate_vdom(value) + validate_vdom_json(value) @pytest.mark.skipif(not IDOM_DEBUG_MODE.current, reason="Only logs in debug mode")