Skip to content

Improved default transforms for vdom_to_html #1278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
@@ -38,6 +38,8 @@ Unreleased
- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``.
- :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``.
- :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.

**Removed**

@@ -48,6 +50,8 @@ Unreleased
- :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications.
- :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications.
- :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead.
- :pull:`1278` - Removed ``reactpy.utils.html_to_vdom``. Use ``reactpy.utils.string_to_reactpy`` instead.
- :pull:`1278` - Removed ``reactpy.utils.vdom_to_html``. Use ``reactpy.utils.reactpy_to_string`` instead.
- :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed.
- :pull:`1113` - Removed deprecated function ``module_from_template``.
- :pull:`1113` - Removed support for Python 3.9.
6 changes: 3 additions & 3 deletions src/reactpy/__init__.py
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@
from reactpy.core.layout import Layout
from reactpy.core.vdom import vdom
from reactpy.pyscript.components import pyscript_component
from reactpy.utils import Ref, html_to_vdom, vdom_to_html
from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy

__author__ = "The Reactive Python Team"
__version__ = "2.0.0a1"
@@ -35,9 +35,10 @@
"event",
"hooks",
"html",
"html_to_vdom",
"logging",
"pyscript_component",
"reactpy_to_string",
"string_to_reactpy",
"types",
"use_async_effect",
"use_callback",
@@ -52,7 +53,6 @@
"use_scope",
"use_state",
"vdom",
"vdom_to_html",
"web",
"widgets",
]
2 changes: 1 addition & 1 deletion src/reactpy/core/_life_cycle_hook.py
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ async def __call__(self, stop: Event) -> None: ...
logger = logging.getLogger(__name__)


class _HookStack(Singleton): # pragma: no cover
class _HookStack(Singleton): # nocov
"""A singleton object which manages the current component tree's hooks.
Life cycle hooks can be stored in a thread local or context variable depending
on the platform."""
2 changes: 1 addition & 1 deletion src/reactpy/core/_thread_local.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
_StateType = TypeVar("_StateType")


class ThreadLocal(Generic[_StateType]): # pragma: no cover
class ThreadLocal(Generic[_StateType]): # nocov
"""Utility for managing per-thread state information. This is only used in
environments where ContextVars are not available, such as the `pyodide`
executor."""
2 changes: 1 addition & 1 deletion src/reactpy/core/hooks.py
Original file line number Diff line number Diff line change
@@ -613,7 +613,7 @@ def strictly_equal(x: Any, y: Any) -> bool:
return x == y # type: ignore

# Fallback to identity check
return x is y # pragma: no cover
return x is y # nocov


def run_effect_cleanup(cleanup_func: Ref[_EffectCleanFunc | None]) -> None:
6 changes: 3 additions & 3 deletions src/reactpy/executors/asgi/middleware.py
Original file line number Diff line number Diff line change
@@ -166,7 +166,7 @@ async def __call__(
msg: dict[str, str] = orjson.loads(event["text"])
if msg.get("type") == "layout-event":
await ws.rendering_queue.put(msg)
else: # pragma: no cover
else: # nocov
await asyncio.to_thread(
_logger.warning, f"Unknown message type: {msg.get('type')}"
)
@@ -205,7 +205,7 @@ async def run_dispatcher(self) -> None:
# Determine component to serve by analyzing the URL and/or class parameters.
if self.parent.multiple_root_components:
url_match = re.match(self.parent.dispatcher_pattern, self.scope["path"])
if not url_match: # pragma: no cover
if not url_match: # nocov
raise RuntimeError("Could not find component in URL path.")
dotted_path = url_match["dotted_path"]
if dotted_path not in self.parent.root_components:
@@ -215,7 +215,7 @@ async def run_dispatcher(self) -> None:
component = self.parent.root_components[dotted_path]
elif self.parent.root_component:
component = self.parent.root_component
else: # pragma: no cover
else: # nocov
raise RuntimeError("No root component provided.")

# Create a connection object by analyzing the websocket's query string.
4 changes: 1 addition & 3 deletions src/reactpy/executors/asgi/pyscript.py
Original file line number Diff line number Diff line change
@@ -79,9 +79,7 @@ def __init__(
self.html_head = html_head or html.head()
self.html_lang = html_lang

def match_dispatch_path(
self, scope: AsgiWebsocketScope
) -> bool: # pragma: no cover
def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: # nocov
"""We do not use a WebSocket dispatcher for Client-Side Rendering (CSR)."""
return False

6 changes: 3 additions & 3 deletions src/reactpy/executors/asgi/standalone.py
Original file line number Diff line number Diff line change
@@ -31,7 +31,7 @@
RootComponentConstructor,
VdomDict,
)
from reactpy.utils import html_to_vdom, import_dotted_path
from reactpy.utils import import_dotted_path, string_to_reactpy

_logger = getLogger(__name__)

@@ -74,7 +74,7 @@ def __init__(
extra_py = pyscript_options.get("extra_py", [])
extra_js = pyscript_options.get("extra_js", {})
config = pyscript_options.get("config", {})
pyscript_head_vdom = html_to_vdom(
pyscript_head_vdom = string_to_reactpy(
pyscript_setup_html(extra_py, extra_js, config)
)
pyscript_head_vdom["tagName"] = ""
@@ -182,7 +182,7 @@ class ReactPyApp:
async def __call__(
self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend
) -> None:
if scope["type"] != "http": # pragma: no cover
if scope["type"] != "http": # nocov
if scope["type"] != "lifespan":
msg = (
"ReactPy app received unsupported request of type '%s' at path '%s'",
6 changes: 3 additions & 3 deletions src/reactpy/executors/utils.py
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@
REACTPY_RECONNECT_MAX_RETRIES,
)
from reactpy.types import ReactPyConfig, VdomDict
from reactpy.utils import import_dotted_path, vdom_to_html
from reactpy.utils import import_dotted_path, reactpy_to_string

logger = logging.getLogger(__name__)

@@ -25,7 +25,7 @@ def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]:
}


def check_path(url_path: str) -> str: # pragma: no cover
def check_path(url_path: str) -> str: # nocov
"""Check that a path is valid URL path."""
if not url_path:
return "URL path must not be empty."
@@ -41,7 +41,7 @@ def check_path(url_path: str) -> str: # pragma: no cover

def vdom_head_to_html(head: VdomDict) -> str:
if isinstance(head, dict) and head.get("tagName") == "head":
return vdom_to_html(head)
return reactpy_to_string(head)

raise ValueError(
"Invalid head element! Element must be either `html.head` or a string."
6 changes: 3 additions & 3 deletions src/reactpy/pyscript/components.py
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@
from reactpy import component, hooks
from reactpy.pyscript.utils import pyscript_component_html
from reactpy.types import ComponentType, Key
from reactpy.utils import html_to_vdom
from reactpy.utils import string_to_reactpy

if TYPE_CHECKING:
from reactpy.types import VdomDict
@@ -22,15 +22,15 @@ def _pyscript_component(
raise ValueError("At least one file path must be provided.")

rendered, set_rendered = hooks.use_state(False)
initial = html_to_vdom(initial) if isinstance(initial, str) else initial
initial = string_to_reactpy(initial) if isinstance(initial, str) else initial

if not rendered:
# FIXME: This is needed to properly re-render PyScript during a WebSocket
# disconnection / reconnection. There may be a better way to do this in the future.
set_rendered(True)
return None

component_vdom = html_to_vdom(
component_vdom = string_to_reactpy(
pyscript_component_html(tuple(str(fp) for fp in file_paths), initial, root)
)
component_vdom["tagName"] = ""
6 changes: 3 additions & 3 deletions src/reactpy/pyscript/utils.py
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@
import reactpy
from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR
from reactpy.types import VdomDict
from reactpy.utils import vdom_to_html
from reactpy.utils import reactpy_to_string

if TYPE_CHECKING:
from collections.abc import Sequence
@@ -77,7 +77,7 @@ def pyscript_component_html(
file_paths: Sequence[str], initial: str | VdomDict, root: str
) -> str:
"""Renders a PyScript component with the user's code."""
_initial = initial if isinstance(initial, str) else vdom_to_html(initial)
_initial = initial if isinstance(initial, str) else reactpy_to_string(initial)
uuid = uuid4().hex
executor_code = pyscript_executor_html(file_paths=file_paths, uuid=uuid, root=root)

@@ -144,7 +144,7 @@ def extend_pyscript_config(
return orjson.dumps(pyscript_config).decode("utf-8")


def reactpy_version_string() -> str: # pragma: no cover
def reactpy_version_string() -> str: # nocov
from reactpy.testing.common import GITHUB_ACTIONS

local_version = reactpy.__version__
2 changes: 1 addition & 1 deletion src/reactpy/templatetags/jinja.py
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ def render(self, *args: str, **kwargs: str) -> str:
return pyscript_setup(*args, **kwargs)

# This should never happen, but we validate it for safety.
raise ValueError(f"Unknown tag: {self.tag_name}") # pragma: no cover
raise ValueError(f"Unknown tag: {self.tag_name}") # nocov


def component(dotted_path: str, **kwargs: str) -> str:
10 changes: 2 additions & 8 deletions src/reactpy/testing/common.py
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@
from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR
from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook
from reactpy.core.events import EventHandler, to_event_handler_function
from reactpy.utils import str_to_bool


def clear_reactpy_web_modules_dir() -> None:
@@ -29,14 +30,7 @@ def clear_reactpy_web_modules_dir() -> None:


_DEFAULT_POLL_DELAY = 0.1
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in {
"y",
"yes",
"t",
"true",
"on",
"1",
}
GITHUB_ACTIONS = str_to_bool(os.getenv("GITHUB_ACTIONS", ""))


class poll(Generic[_R]): # noqa: N801
2 changes: 1 addition & 1 deletion src/reactpy/testing/display.py
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ async def __aenter__(self) -> DisplayFixture:

self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000)

if not hasattr(self, "backend"): # pragma: no cover
if not hasattr(self, "backend"): # nocov
self.backend = BackendFixture()
await es.enter_async_context(self.backend)

2 changes: 1 addition & 1 deletion src/reactpy/testing/utils.py
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@

def find_available_port(
host: str, port_min: int = 8000, port_max: int = 9000
) -> int: # pragma: no cover
) -> int: # nocov
"""Get a port that's available for the given host and port range"""
for port in range(port_min, port_max):
with closing(socket.socket()) as sock:
408 changes: 408 additions & 0 deletions src/reactpy/transforms.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/reactpy/types.py
Original file line number Diff line number Diff line change
@@ -92,7 +92,7 @@ async def __aexit__(
"""Clean up the view after its final render"""


VdomAttributes = Mapping[str, Any]
VdomAttributes = dict[str, Any]
"""Describes the attributes of a :class:`VdomDict`"""

VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any"
188 changes: 74 additions & 114 deletions src/reactpy/utils.py
Original file line number Diff line number Diff line change
@@ -4,12 +4,13 @@
from collections.abc import Iterable
from importlib import import_module
from itertools import chain
from typing import Any, Callable, Generic, TypeVar, Union, cast
from typing import Any, Callable, Generic, TypeVar, cast

from lxml import etree
from lxml.html import fromstring, tostring

from reactpy.core.vdom import vdom as make_vdom
from reactpy.transforms import RequiredTransforms
from reactpy.types import ComponentType, VdomDict

_RefValue = TypeVar("_RefValue")
@@ -60,41 +61,47 @@ def __repr__(self) -> str:
return f"{type(self).__name__}({current})"


def vdom_to_html(vdom: VdomDict) -> str:
"""Convert a VDOM dictionary into an HTML string
Only the following keys are translated to HTML:
- ``tagName``
- ``attributes``
- ``children`` (must be strings or more VDOM dicts)
def reactpy_to_string(root: VdomDict | ComponentType) -> str:
"""Convert a ReactPy component or `reactpy.html` element into an HTML string.
Parameters:
vdom: The VdomDict element to convert to HTML
root: The ReactPy element to convert to a string.
"""
temp_root = etree.Element("__temp__")
_add_vdom_to_etree(temp_root, vdom)
html = cast(bytes, tostring(temp_root)).decode() # type: ignore
# strip out temp root <__temp__> element
temp_container = etree.Element("__temp__")

if not isinstance(root, dict):
root = component_to_vdom(root)

_add_vdom_to_etree(temp_container, root)
html = cast(bytes, tostring(temp_container)).decode() # type: ignore

# Strip out temp root <__temp__> element
return html[10:-11]


def html_to_vdom(
html: str, *transforms: _ModelTransform, strict: bool = True
def string_to_reactpy(
html: str,
*transforms: _ModelTransform,
strict: bool = True,
intercept_links: bool = True,
) -> VdomDict:
"""Transform HTML into a DOM model. Unique keys can be provided to HTML elements
"""Transform HTML string into a ReactPy DOM model. ReactJS keys can be provided to HTML elements
using a ``key=...`` attribute within your HTML tag.
Parameters:
html:
The raw HTML as a string
transforms:
Functions of the form ``transform(old) -> new`` where ``old`` is a VDOM
dictionary which will be replaced by ``new``. For example, you could use a
transform function to add highlighting to a ``<code/>`` block.
Function that takes a VDOM dictionary input and returns the new (mutated)
VDOM in the form ``transform(old) -> new``. This function is automatically
called on every node within the VDOM tree.
strict:
If ``True``, raise an exception if the HTML does not perfectly follow HTML5
syntax.
intercept_links:
If ``True``, convert all anchor tags into ``<a>`` tags with an ``onClick``
event handler that prevents the browser from navigating to the link. This is
useful if you would rather have `reactpy-router` handle your URL navigation.
"""
if not isinstance(html, str): # nocov
msg = f"Expected html to be a string, not {type(html).__name__}"
@@ -114,43 +121,40 @@ def html_to_vdom(
except etree.XMLSyntaxError as e:
if not strict:
raise e # nocov
msg = "An error has occurred while parsing the HTML.\n\nThis HTML may be malformatted, or may not perfectly adhere to HTML5.\nIf you believe the exception above was due to something intentional, you can disable the strict parameter on html_to_vdom().\nOtherwise, repair your broken HTML and try again."
msg = (
"An error has occurred while parsing the HTML.\n\n"
"This HTML may be malformatted, or may not perfectly adhere to HTML5.\n"
"If you believe the exception above was due to something intentional, you "
"can disable the strict parameter on string_to_reactpy().\n"
"Otherwise, repair your broken HTML and try again."
)
raise HTMLParseError(msg) from e

return _etree_to_vdom(root_node, transforms)
return _etree_to_vdom(root_node, transforms, intercept_links)


class HTMLParseError(etree.LxmlSyntaxError): # type: ignore[misc]
"""Raised when an HTML document cannot be parsed using strict parsing."""


def _etree_to_vdom(
node: etree._Element, transforms: Iterable[_ModelTransform]
node: etree._Element, transforms: Iterable[_ModelTransform], intercept_links: bool
) -> VdomDict:
"""Transform an lxml etree node into a DOM model
Parameters:
node:
The ``lxml.etree._Element`` node
transforms:
Functions of the form ``transform(old) -> new`` where ``old`` is a VDOM
dictionary which will be replaced by ``new``. For example, you could use a
transform function to add highlighting to a ``<code/>`` block.
"""
"""Transform an lxml etree node into a DOM model."""
if not isinstance(node, etree._Element): # nocov
msg = f"Expected node to be a etree._Element, not {type(node).__name__}"
raise TypeError(msg)

# Recursively call _etree_to_vdom() on all children
children = _generate_vdom_children(node, transforms)
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)

# Perform any necessary mutations on the VDOM attributes to meet VDOM spec
_mutate_vdom(el)
# Perform necessary transformations on the VDOM attributes to meet VDOM spec
RequiredTransforms(el, intercept_links)

# Apply any provided transforms.
# Apply any user provided transforms.
for transform in transforms:
el = transform(el)

@@ -169,14 +173,15 @@ def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any])
if tag:
element = etree.SubElement(parent, tag)
element.attrib.update(
_vdom_attr_to_html_str(k, v) for k, v in vdom.get("attributes", {}).items()
_react_attribute_to_html(k, v)
for k, v in vdom.get("attributes", {}).items()
)
else:
element = parent

for c in vdom.get("children", []):
if hasattr(c, "render"):
c = _component_to_vdom(cast(ComponentType, c))
c = component_to_vdom(cast(ComponentType, c))
if isinstance(c, dict):
_add_vdom_to_etree(element, c)

@@ -200,36 +205,8 @@ def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any])
element.text = f"{element.text or ''}{c}"


def _mutate_vdom(vdom: VdomDict) -> None:
"""Performs any necessary mutations on the VDOM attributes to meet VDOM spec.
Currently, this function only transforms the ``style`` attribute into a dictionary whose keys are
camelCase so as to be renderable by React.
This function may be extended in the future.
"""
# Determine if the style attribute needs to be converted to a dict
if (
"attributes" in vdom
and "style" in vdom["attributes"]
and isinstance(vdom["attributes"]["style"], str)
):
# Convince type checker that it's safe to mutate attributes
assert isinstance(vdom["attributes"], dict) # noqa: S101

# Convert style attribute from str -> dict with camelCase keys
vdom["attributes"]["style"] = {
key.strip().replace("-", "_"): value.strip()
for key, value in (
part.split(":", 1)
for part in vdom["attributes"]["style"].split(";")
if ":" in part
)
}


def _generate_vdom_children(
node: etree._Element, transforms: Iterable[_ModelTransform]
node: etree._Element, transforms: Iterable[_ModelTransform], intercept_links: bool
) -> list[VdomDict | str]:
"""Generates a list of VDOM children from an lxml node.
@@ -241,7 +218,7 @@ def _generate_vdom_children(
chain(
*(
# Recursively convert each child node to VDOM
[_etree_to_vdom(child, transforms)]
[_etree_to_vdom(child, transforms, intercept_links)]
# Insert the tail text between each child node
+ ([child.tail] if child.tail else [])
for child in node.iterchildren(None)
@@ -250,70 +227,48 @@ def _generate_vdom_children(
)


def _component_to_vdom(component: ComponentType) -> VdomDict | str | None:
"""Convert a component to a VDOM dictionary"""
def component_to_vdom(component: ComponentType) -> VdomDict:
"""Convert the first render of a component into a VDOM dictionary"""
result = component.render()
if hasattr(result, "render"):
result = _component_to_vdom(cast(ComponentType, result))
return cast(Union[VdomDict, str, None], result)


def del_html_head_body_transform(vdom: VdomDict) -> VdomDict:
"""Transform intended for use with `html_to_vdom`.

Removes `<html>`, `<head>`, and `<body>` while preserving their children.
if isinstance(result, dict):
return result
if hasattr(result, "render"):
return component_to_vdom(cast(ComponentType, result))
elif isinstance(result, str):
return make_vdom("div", {}, result)
return make_vdom("")

Parameters:
vdom:
The VDOM dictionary to transform.
"""
if vdom["tagName"] in {"html", "body", "head"}:
return {"tagName": "", "children": vdom.setdefault("children", [])}
return vdom

def _react_attribute_to_html(key: str, value: Any) -> tuple[str, str]:
"""Convert a React attribute to an HTML attribute string."""
if callable(value): # nocov
raise TypeError(f"Cannot convert callable attribute {key}={value} to HTML")

def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]:
if key == "style":
if isinstance(value, dict):
value = ";".join(
# We lower only to normalize - CSS is case-insensitive:
# https://www.w3.org/TR/css-fonts-3/#font-family-casing
f"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}"
f"{CAMEL_CASE_PATTERN.sub('-', k).lower()}:{v}"
for k, v in value.items()
)
elif (
# camel to data-* attributes
key.startswith("data_")
# camel to aria-* attributes
or key.startswith("aria_")
# handle special cases
or key in DASHED_HTML_ATTRS
):
key = key.replace("_", "-")
elif (
# camel to data-* attributes
key.startswith("data")
# camel to aria-* attributes
or key.startswith("aria")
# handle special cases
or key in DASHED_HTML_ATTRS
):
key = _CAMEL_CASE_SUB_PATTERN.sub("-", key)

if callable(value): # nocov
raise TypeError(f"Cannot convert callable attribute {key}={value} to HTML")
# Convert special attributes to kebab-case
elif key in DASHED_HTML_ATTRS:
key = CAMEL_CASE_PATTERN.sub("-", key)

# Retain data-* and aria-* attributes as provided
elif key.startswith("data-") or key.startswith("aria-"):
return key, str(value)

# Again, we lower the attribute name only to normalize - HTML is case-insensitive:
# http://w3c.github.io/html-reference/documents.html#case-insensitivity
return key.lower(), str(value)


# see list of HTML attributes with dashes in them:
# https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list
DASHED_HTML_ATTRS = {"accept_charset", "acceptCharset", "http_equiv", "httpEquiv"}
DASHED_HTML_ATTRS = {"acceptCharset", "httpEquiv"}

# Pattern for delimitting camelCase names (e.g. camelCase to camel-case)
_CAMEL_CASE_SUB_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
CAMEL_CASE_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")


def import_dotted_path(dotted_path: str) -> Any:
@@ -344,3 +299,8 @@ def __new__(cls, *args, **kw):
orig = super()
cls._instance = orig.__new__(cls, *args, **kw)
return cls._instance


def str_to_bool(s: str) -> bool:
"""Convert a string to a boolean value."""
return s.lower() in {"y", "yes", "t", "true", "on", "1"}
281 changes: 192 additions & 89 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -40,32 +40,180 @@ def test_ref_repr():
@pytest.mark.parametrize(
"case",
[
# 0: Single terminating tag
{"source": "<div/>", "model": {"tagName": "div"}},
# 1: Single terminating tag with attributes
{
"source": "<div style='background-color:blue'/>",
"model": {
"tagName": "div",
"attributes": {"style": {"background_color": "blue"}},
"attributes": {"style": {"backgroundColor": "blue"}},
},
},
# 2: Single tag with closure and a text-based child
{
"source": "<div>Hello!</div>",
"model": {"tagName": "div", "children": ["Hello!"]},
},
# 3: Single tag with closure and a tag-based child
{
"source": "<div>Hello!<p>World!</p></div>",
"model": {
"tagName": "div",
"children": ["Hello!", {"tagName": "p", "children": ["World!"]}],
},
},
# 4: A snippet with no root HTML node
{
"source": "<p>Hello</p><div>World</div>",
"model": {
"tagName": "div",
"children": [
{"tagName": "p", "children": ["Hello"]},
{"tagName": "div", "children": ["World"]},
],
},
},
# 5: Self-closing tags
{
"source": "<p>hello<br>world</p>",
"model": {
"tagName": "p",
"children": [
"hello",
{"tagName": "br"},
"world",
],
},
},
],
)
def test_string_to_reactpy(case):
assert utils.string_to_reactpy(case["source"]) == case["model"]


@pytest.mark.parametrize(
"case",
[
# 0: Style attribute transformation
{
"source": '<p style="color: red; background-color : green; ">Hello World.</p>',
"model": {
"tagName": "p",
"attributes": {"style": {"backgroundColor": "green", "color": "red"}},
"children": ["Hello World."],
},
},
# 1: Convert HTML style properties to ReactJS style
{
"source": '<p class="my-class">Hello World.</p>',
"model": {
"tagName": "p",
"attributes": {"className": "my-class"},
"children": ["Hello World."],
},
},
# 2: Convert <textarea> children into the ReactJS `defaultValue` prop
{
"source": "<textarea>Hello World.</textarea>",
"model": {
"tagName": "textarea",
"attributes": {"defaultValue": "Hello World."},
},
},
# 3: Convert <select> trees into ReactJS equivalent
{
"source": "<select><option selected>Option 1</option></select>",
"model": {
"tagName": "select",
"attributes": {"defaultValue": "Option 1"},
"children": [
{
"children": ["Option 1"],
"tagName": "option",
"attributes": {"value": "Option 1"},
}
],
},
},
# 4: Convert <select> trees into ReactJS equivalent (multiple choice, multiple selected)
{
"source": "<select multiple><option selected>Option 1</option><option selected>Option 2</option></select>",
"model": {
"tagName": "select",
"attributes": {
"defaultValue": ["Option 1", "Option 2"],
"multiple": True,
},
"children": [
{
"children": ["Option 1"],
"tagName": "option",
"attributes": {"value": "Option 1"},
},
{
"children": ["Option 2"],
"tagName": "option",
"attributes": {"value": "Option 2"},
},
],
},
},
# 5: Convert <input> value attribute into `defaultValue`
{
"source": '<input type="text" value="Hello World.">',
"model": {
"tagName": "input",
"attributes": {"defaultValue": "Hello World.", "type": "text"},
},
},
# 6: Infer ReactJS `key` from the `id` attribute
{
"source": '<div id="my-key"></div>',
"model": {
"tagName": "div",
"key": "my-key",
"attributes": {"id": "my-key"},
},
},
# 7: Infer ReactJS `key` from the `name` attribute
{
"source": '<input type="text" name="my-input">',
"model": {
"tagName": "input",
"key": "my-input",
"attributes": {"type": "text", "name": "my-input"},
},
},
# 8: Infer ReactJS `key` from the `key` attribute
{
"source": '<div key="my-key"></div>',
"model": {"tagName": "div", "key": "my-key"},
},
],
)
def test_html_to_vdom(case):
assert utils.html_to_vdom(case["source"]) == case["model"]
def test_string_to_reactpy_default_transforms(case):
assert utils.string_to_reactpy(case["source"]) == case["model"]


def test_string_to_reactpy_intercept_links():
source = '<a href="https://example.com">Hello World</a>'
expected = {
"tagName": "a",
"children": ["Hello World"],
"attributes": {"href": "https://example.com"},
}
result = utils.string_to_reactpy(source, intercept_links=True)

# Check if the result equals expected when removing `eventHandlers` from the result dict
event_handlers = result.pop("eventHandlers", {})
assert result == expected

# Make sure the event handlers dict contains an `onClick` key
assert "onClick" in event_handlers

def test_html_to_vdom_transform():

def test_string_to_reactpy_custom_transform():
source = "<p>hello <a>world</a> and <a>universe</a>lmao</p>"

def make_links_blue(node):
@@ -92,7 +240,10 @@ def make_links_blue(node):
],
}

assert utils.html_to_vdom(source, make_links_blue) == expected
assert (
utils.string_to_reactpy(source, make_links_blue, intercept_links=False)
== expected
)


def test_non_html_tag_behavior():
@@ -106,82 +257,10 @@ def test_non_html_tag_behavior():
],
}

assert utils.html_to_vdom(source, strict=False) == expected
assert utils.string_to_reactpy(source, strict=False) == expected

with pytest.raises(utils.HTMLParseError):
utils.html_to_vdom(source, strict=True)


def test_html_to_vdom_with_null_tag():
source = "<p>hello<br>world</p>"

expected = {
"tagName": "p",
"children": [
"hello",
{"tagName": "br"},
"world",
],
}

assert utils.html_to_vdom(source) == expected


def test_html_to_vdom_with_style_attr():
source = '<p style="color: red; background-color : green; ">Hello World.</p>'

expected = {
"attributes": {"style": {"background_color": "green", "color": "red"}},
"children": ["Hello World."],
"tagName": "p",
}

assert utils.html_to_vdom(source) == expected


def test_html_to_vdom_with_no_parent_node():
source = "<p>Hello</p><div>World</div>"

expected = {
"tagName": "div",
"children": [
{"tagName": "p", "children": ["Hello"]},
{"tagName": "div", "children": ["World"]},
],
}

assert utils.html_to_vdom(source) == expected


def test_del_html_body_transform():
source = """
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Title</title>
</head>
<body><h1>Hello World</h1></body>
</html>
"""

expected = {
"tagName": "",
"children": [
{
"tagName": "",
"children": [{"tagName": "title", "children": ["My Title"]}],
},
{
"tagName": "",
"children": [{"tagName": "h1", "children": ["Hello World"]}],
},
],
}

assert utils.html_to_vdom(source, utils.del_html_head_body_transform) == expected
utils.string_to_reactpy(source, strict=True)


SOME_OBJECT = object()
@@ -199,7 +278,17 @@ def example_middle():

@component
def example_child():
return html.h1("Sample Application")
return html.h1("Example")


@component
def example_str_return():
return "Example"


@component
def example_none_return():
return None


@pytest.mark.parametrize(
@@ -257,24 +346,38 @@ def example_child():
'<div><div>hello</div><a href="https://example.com">example</a><button></button></div>',
),
(
html.div(
{"data_Something": 1, "dataSomethingElse": 2, "dataisnotdashed": 3}
),
'<div data-something="1" data-something-else="2" dataisnotdashed="3"></div>',
html.div({"data-Something": 1, "dataCamelCase": 2, "datalowercase": 3}),
'<div data-Something="1" datacamelcase="2" datalowercase="3"></div>',
),
(
html.div(example_parent()),
'<div><div id="sample" style="padding:15px"><h1>Sample Application</h1></div></div>',
'<div><div id="sample" style="padding:15px"><h1>Example</h1></div></div>',
),
(
example_parent(),
'<div id="sample" style="padding:15px"><h1>Example</h1></div>',
),
(
html.form({"acceptCharset": "utf-8"}),
'<form accept-charset="utf-8"></form>',
),
(
example_str_return(),
"<div>Example</div>",
),
(
example_none_return(),
"",
),
],
)
def test_vdom_to_html(vdom_in, html_out):
assert utils.vdom_to_html(vdom_in) == html_out
def test_reactpy_to_string(vdom_in, html_out):
assert utils.reactpy_to_string(vdom_in) == html_out


def test_vdom_to_html_error():
def test_reactpy_to_string_error():
with pytest.raises(TypeError, match="Expected a VDOM dict"):
utils.vdom_to_html({"notVdom": True})
utils.reactpy_to_string({"notVdom": True})


def test_invalid_dotted_path():