diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3bffbce6..60f9486b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/ambv/black - rev: stable + rev: 20.8b1 hooks: - id: black - repo: https://github.com/PyCQA/flake8 diff --git a/docs/.gitignore b/docs/.gitignore index b48fbb87c..5237029f1 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,3 +2,4 @@ build # VS Code RST extension builds here by default source/_build +source/vdom-json-schema.json diff --git a/docs/source/_exts/copy_vdom_json_schema.py b/docs/source/_exts/copy_vdom_json_schema.py new file mode 100644 index 000000000..34ff0063e --- /dev/null +++ b/docs/source/_exts/copy_vdom_json_schema.py @@ -0,0 +1,17 @@ +import json +from pathlib import Path + +from sphinx.application import Sphinx + +from idom.core.vdom import SERIALIZED_VDOM_JSON_SCHEMA + + +def setup(app: Sphinx) -> None: + schema_file = Path(__file__).parent.parent / "vdom-json-schema.json" + current_schema = json.dumps(SERIALIZED_VDOM_JSON_SCHEMA, indent=2, sort_keys=True) + + # We need to make this check because the autoreload system for the docs checks + # to see if the file has changed to determine whether to re-build. Thus we should + # only write to the file if its contents will be different. + if not schema_file.exists() or schema_file.read_text() != current_schema: + schema_file.write_text(current_schema) diff --git a/docs/source/conf.py b/docs/source/conf.py index c24f4e584..6518f0cbc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -43,11 +43,13 @@ "sphinx.ext.napoleon", "sphinx.ext.autosectionlabel", "sphinx_autodoc_typehints", + "sphinx_panels", + "sphinx_copybutton", + # custom extensions "interactive_widget", "widget_example", "async_doctest", - "sphinx_panels", - "sphinx_copybutton", + "copy_vdom_json_schema", ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/index.rst b/docs/source/index.rst index 56376582c..24658424a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,7 +12,8 @@ Libraries for defining and controlling interactive webpages with Python life-cycle-hooks core-concepts javascript-components - + specifications + package-api examples .. toctree:: @@ -21,8 +22,6 @@ Libraries for defining and controlling interactive webpages with Python contributing developer-guide - specifications - package-api .. toctree:: :hidden: diff --git a/docs/source/mimetype.json b/docs/source/mimetype.json deleted file mode 100644 index 565df15e7..000000000 --- a/docs/source/mimetype.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "this-is": "under-construction" -} diff --git a/docs/source/specifications.rst b/docs/source/specifications.rst index ed71cb8a9..4a68277b3 100644 --- a/docs/source/specifications.rst +++ b/docs/source/specifications.rst @@ -151,11 +151,10 @@ type name. The various properties for the ``onChange`` handler are: To clearly describe the VDOM schema we've created a `JSON Schema `_: -.. literalinclude:: ./mimetype.json +.. literalinclude:: ./vdom-json-schema.json :language: json - JSON Patch ---------- diff --git a/idom/core/layout.py b/idom/core/layout.py index f3fe0d22b..4fccd1ac6 100644 --- a/idom/core/layout.py +++ b/idom/core/layout.py @@ -1,5 +1,6 @@ import abc import asyncio +from functools import wraps from typing import ( Any, AsyncIterator, @@ -17,10 +18,13 @@ from jsonpatch import apply_patch, make_patch from loguru import logger +from idom.options import IDOM_DEBUG + from .component import AbstractComponent from .events import EventHandler, EventTarget from .hooks import LifeCycleHook from .utils import CannotAccessResource, HasAsyncResources, async_resource +from .vdom import validate_serialized_vdom class LayoutUpdate(NamedTuple): @@ -92,6 +96,18 @@ async def render(self) -> LayoutUpdate: if self._has_component_state(component): return self._create_layout_update(component) + if IDOM_DEBUG: + from loguru import logger + + _debug_render = render + + @wraps(_debug_render) + async def render(self) -> LayoutUpdate: + # Ensure that the model is valid VDOM on each render + result = await self._debug_render() + validate_serialized_vdom(self._component_states[id(self.root)].model) + return result + @async_resource async def _rendering_queue(self) -> AsyncIterator["_ComponentQueue"]: queue = _ComponentQueue() diff --git a/idom/core/vdom.py b/idom/core/vdom.py index 4d79e653c..a00ce79ef 100644 --- a/idom/core/vdom.py +++ b/idom/core/vdom.py @@ -1,11 +1,69 @@ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Union +from fastjsonschema import compile as compile_json_schema from mypy_extensions import TypedDict from typing_extensions import Protocol from .component import AbstractComponent from .events import EventsMapping +SERIALIZED_VDOM_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$ref": "#/definitions/element", + "definitions": { + "element": { + "type": "object", + "properties": { + "tagName": {"type": "string"}, + "children": {"$ref": "#/definitions/elementChildren"}, + "attributes": {"type": "object"}, + "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, + "importSource": {"$ref": "#/definitions/importSource"}, + }, + "required": ["tagName"], + }, + "elementChildren": { + "type": "array", + "items": {"$ref": "#/definitions/elementOrString"}, + }, + "elementEventHandlers": { + "type": "object", + "patternProperties": { + ".*": {"$ref": "#/definitions/eventHander"}, + }, + }, + "eventHander": { + "type": "object", + "properties": { + "target": {"type": "string"}, + "preventDefault": {"type": "boolean"}, + "stopPropagation": {"type": "boolean"}, + }, + "required": ["target"], + }, + "importSource": { + "type": "object", + "properties": { + "source": {"type": "string"}, + "fallback": { + "type": ["object", "string", "null"], + "if": {"not": {"type": "null"}}, + "then": {"$ref": "#/definitions/elementOrString"}, + }, + }, + "required": ["source"], + }, + "elementOrString": { + "type": ["object", "string"], + "if": {"type": "object"}, + "then": {"$ref": "#/definitions/element"}, + }, + }, +} + + +validate_serialized_vdom = compile_json_schema(SERIALIZED_VDOM_JSON_SCHEMA) + class ImportSourceDict(TypedDict): source: str diff --git a/idom/options.py b/idom/options.py new file mode 100644 index 000000000..e9e1e8967 --- /dev/null +++ b/idom/options.py @@ -0,0 +1,25 @@ +from typing import Any, Callable, Dict, Type + +IDOM_DEBUG = False + + +def _init() -> None: + """Collect options from :attr:`os.environ`""" + import os + + from_string: Dict[Type[Any], Callable[[Any], Any]] = { + bool: lambda v: bool(int(v)), + } + + module = globals() + for name, default in globals().items(): + value_type = type(default) + value = os.environ.get(name, default) + if value_type in from_string: + value = from_string[value_type](value) + module[name] = value + + return None + + +_init() diff --git a/noxfile.py b/noxfile.py index 078c8df15..2d666e946 100644 --- a/noxfile.py +++ b/noxfile.py @@ -85,6 +85,7 @@ def test(session: Session) -> None: @nox.session def test_python(session: Session) -> None: """Run the Python-based test suite""" + session.env["IDOM_DEBUG"] = "1" session.install("-r", "requirements/test-env.txt") session.install(".[all]") args = ["pytest", "tests"] + get_posargs("pytest", session) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index fc8d25e80..9db6170f3 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -7,4 +7,4 @@ async_exit_stack >=1.0.1; python_version<"3.7" jsonpatch >=1.26 typer >=0.3.2 click-spinner >=0.1.10 -jsonschema >=3.2.0 +fastjsonschema >=2.14.5 diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 7236a6d5c..d5c182399 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -1,7 +1,8 @@ import pytest +from fastjsonschema import JsonSchemaException import idom -from idom.core.vdom import component, make_vdom_constructor +from idom.core.vdom import component, make_vdom_constructor, validate_serialized_vdom fake_events = idom.Events() @@ -147,3 +148,167 @@ def MyComponentWithChildrenAndAttributes(children, x): "attributes": {"x": 4}, "children": ["hello", "world"], } + + +@pytest.mark.parametrize( + "value", + [ + { + "tagName": "div", + "children": [ + "Some text", + {"tagName": "div"}, + ], + }, + { + "tagName": "div", + "attributes": {"style": {"color": "blue"}}, + }, + { + "tagName": "div", + "eventHandler": {"target": "something"}, + }, + { + "tagName": "div", + "eventHandler": { + "target": "something", + "preventDefault": False, + "stopPropogation": True, + }, + }, + { + "tagName": "div", + "importSource": {"source": "something"}, + }, + { + "tagName": "div", + "importSource": {"source": "something", "fallback": None}, + }, + { + "tagName": "div", + "importSource": {"source": "something", "fallback": "loading..."}, + }, + { + "tagName": "div", + "importSource": {"source": "something", "fallback": {"tagName": "div"}}, + }, + { + "tagName": "div", + "children": [ + "Some text", + {"tagName": "div"}, + ], + "attributes": {"style": {"color": "blue"}}, + "eventHandler": { + "target": "something", + "preventDefault": False, + "stopPropogation": True, + }, + "importSource": { + "source": "something", + "fallback": {"tagName": "div"}, + }, + }, + ], +) +def test_valid_vdom(value): + validate_serialized_vdom(value) + + +@pytest.mark.parametrize( + "value, error_message_pattern", + [ + ( + None, + r"data must be object", + ), + ( + {}, + r"data must contain \['tagName'\] properties", + ), + ( + {"tagName": 0}, + r"data\.tagName must be string", + ), + ( + {"tagName": "tag", "children": None}, + r"data must be array", + ), + ( + {"tagName": "tag", "children": [None]}, + r"data must be object or string", + ), + ( + {"tagName": "tag", "children": [{"tagName": None}]}, + r"data\.tagName must be string", + ), + ( + {"tagName": "tag", "attributes": None}, + r"data.attributes must be object", + ), + ( + {"tagName": "tag", "eventHandlers": None}, + r"data must be object", + ), + ( + {"tagName": "tag", "eventHandlers": {"onEvent": None}}, + r"data must be object", + ), + ( + { + "tagName": "tag", + "eventHandlers": {"onEvent": {}}, + }, + r"data must contain \['target'\] properties", + ), + ( + { + "tagName": "tag", + "eventHandlers": { + "onEvent": { + "target": "something", + "preventDefault": None, + } + }, + }, + r"data\.preventDefault must be boolean", + ), + ( + { + "tagName": "tag", + "eventHandlers": { + "onEvent": { + "target": "something", + "stopPropagation": None, + } + }, + }, + r"data\.stopPropagation must be boolean", + ), + ( + {"tagName": "tag", "importSource": None}, + r"data must be object", + ), + ( + {"tagName": "tag", "importSource": {}}, + r"data must contain \['source'\] properties", + ), + ( + { + "tagName": "tag", + "importSource": {"source": "something", "fallback": 0}, + }, + r"data\.fallback must be object or string or null", + ), + ( + { + "tagName": "tag", + "importSource": {"source": "something", "fallback": {"tagName": None}}, + }, + r"data.tagName must be string", + ), + ], +) +def test_invalid_vdom(value, error_message_pattern): + with pytest.raises(JsonSchemaException, match=error_message_pattern): + validate_serialized_vdom(value)