From bdac4161af689da446f892e05c11c49c26ea75cf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 11 Feb 2025 23:05:07 -0800 Subject: [PATCH 1/9] `pragma` to `nocov` --- src/reactpy/core/_life_cycle_hook.py | 2 +- src/reactpy/core/_thread_local.py | 2 +- src/reactpy/core/hooks.py | 2 +- src/reactpy/executors/asgi/middleware.py | 6 ++--- src/reactpy/executors/asgi/pyscript.py | 4 +-- src/reactpy/executors/asgi/standalone.py | 2 +- src/reactpy/executors/utils.py | 2 +- src/reactpy/pyscript/utils.py | 2 +- src/reactpy/templatetags/jinja.py | 2 +- src/reactpy/testing/display.py | 2 +- src/reactpy/testing/utils.py | 2 +- tests/test_utils.py | 31 ------------------------ 12 files changed, 13 insertions(+), 46 deletions(-) diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 8600b3f01..c940bf01b 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -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.""" diff --git a/src/reactpy/core/_thread_local.py b/src/reactpy/core/_thread_local.py index 0d83f7e41..eb582e8e8 100644 --- a/src/reactpy/core/_thread_local.py +++ b/src/reactpy/core/_thread_local.py @@ -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.""" diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index a0a4e161c..d2dcea8e7 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -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: diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 54b8df511..976119c8f 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -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. diff --git a/src/reactpy/executors/asgi/pyscript.py b/src/reactpy/executors/asgi/pyscript.py index 79ccfb2ad..b3f2cd38f 100644 --- a/src/reactpy/executors/asgi/pyscript.py +++ b/src/reactpy/executors/asgi/pyscript.py @@ -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 diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py index 56c7f6367..1a8caad2f 100644 --- a/src/reactpy/executors/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -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'", diff --git a/src/reactpy/executors/utils.py b/src/reactpy/executors/utils.py index e29cdf5c6..03006a3ef 100644 --- a/src/reactpy/executors/utils.py +++ b/src/reactpy/executors/utils.py @@ -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." diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index eb277cfb5..ed115e346 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -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__ diff --git a/src/reactpy/templatetags/jinja.py b/src/reactpy/templatetags/jinja.py index 672089752..c4256b525 100644 --- a/src/reactpy/templatetags/jinja.py +++ b/src/reactpy/templatetags/jinja.py @@ -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: diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index e3aced083..aeaf6a34d 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -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) diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py index f1808022c..6a48516ed 100644 --- a/src/reactpy/testing/utils.py +++ b/src/reactpy/testing/utils.py @@ -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: diff --git a/tests/test_utils.py b/tests/test_utils.py index fbc1b7112..e7728f902 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -153,37 +153,6 @@ def test_html_to_vdom_with_no_parent_node(): assert utils.html_to_vdom(source) == expected -def test_del_html_body_transform(): - source = """ - - - - - My Title - - -

Hello World

- - - """ - - 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 - - SOME_OBJECT = object() From 70cb572ed45201b2310c0741ac2fa515de19e13d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:52:33 -0800 Subject: [PATCH 2/9] Improved default transforms for `vdom_to_html` --- src/reactpy/testing/common.py | 10 +- src/reactpy/transforms.py | 402 ++++++++++++++++++++++++++++++++++ src/reactpy/types.py | 2 +- src/reactpy/utils.py | 150 +++++-------- tests/test_utils.py | 198 +++++++++++++---- 5 files changed, 608 insertions(+), 154 deletions(-) create mode 100644 src/reactpy/transforms.py diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index cb015a672..a0aec3527 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -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 diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py new file mode 100644 index 000000000..cbb3a1b10 --- /dev/null +++ b/src/reactpy/transforms.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +from typing import Any + +from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.types import VdomDict + + +class RequiredTransforms: + """Performs any necessary transformations related to `html_to_vdom` to automatically prevent + issues with React's rendering engine. + """ + + def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None: + self._intercept_links = intercept_links + + # Run every transform in this class. + for name in dir(self): + # Any method that doesn't start with an underscore is assumed to be a transform. + if not name.startswith("_"): + getattr(self, name)(vdom) + + def normalize_style_attributes(self, vdom: VdomDict) -> None: + """Convert style attribute from str -> dict with camelCase keys""" + if ( + "attributes" in vdom + and "style" in vdom["attributes"] + and isinstance(vdom["attributes"]["style"], str) + ): + vdom["attributes"]["style"] = { + self._kebab_to_camel_case(key.strip()): value.strip() + for key, value in ( + part.split(":", 1) + for part in vdom["attributes"]["style"].split(";") + if ":" in part + ) + } + + @staticmethod + 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() + } + + @staticmethod + def textarea_children_to_prop(vdom: VdomDict) -> None: + """Transformation that converts the text content of a ", + "model": { + "tagName": "textarea", + "attributes": {"defaultValue": "Hello World."}, + }, + }, + # 3: Convert ", + "model": { + "tagName": "select", + "attributes": {"defaultValue": "Option 1"}, + "children": [ + { + "children": ["Option 1"], + "tagName": "option", + "attributes": {"value": "Option 1"}, + } + ], + }, + }, + # 4: Convert ", + "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 value attribute into `defaultValue` + { + "source": '', + "model": { + "tagName": "input", + "attributes": {"defaultValue": "Hello World.", "type": "text"}, + }, + }, + # 6: Infer ReactJS `key` from the `id` attribute + { + "source": '
', + "model": { + "tagName": "div", + "key": "my-key", + "attributes": {"id": "my-key"}, + }, + }, + # 7: Infer ReactJS `key` from the `name` attribute + { + "source": '', + "model": { + "tagName": "input", + "key": "my-input", + "attributes": {"type": "text", "name": "my-input"}, + }, + }, + ], +) +def test_html_to_vdom_default_transforms(case): + assert utils.html_to_vdom(case["source"]) == case["model"] + + +def test_html_to_vdom_intercept_links(): + source = 'Hello World' + expected = { + "tagName": "a", + "children": ["Hello World"], + "attributes": {"href": "https://example.com"}, + } + result = utils.html_to_vdom(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_custom_transform(): source = "

hello world and universelmao

" def make_links_blue(node): @@ -92,7 +235,9 @@ def make_links_blue(node): ], } - assert utils.html_to_vdom(source, make_links_blue) == expected + assert ( + utils.html_to_vdom(source, make_links_blue, intercept_links=False) == expected + ) def test_non_html_tag_behavior(): @@ -112,47 +257,6 @@ def test_non_html_tag_behavior(): utils.html_to_vdom(source, strict=True) -def test_html_to_vdom_with_null_tag(): - source = "

hello
world

" - - expected = { - "tagName": "p", - "children": [ - "hello", - {"tagName": "br"}, - "world", - ], - } - - assert utils.html_to_vdom(source) == expected - - -def test_html_to_vdom_with_style_attr(): - source = '

Hello World.

' - - 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 = "

Hello

World
" - - expected = { - "tagName": "div", - "children": [ - {"tagName": "p", "children": ["Hello"]}, - {"tagName": "div", "children": ["World"]}, - ], - } - - assert utils.html_to_vdom(source) == expected - - SOME_OBJECT = object() @@ -226,10 +330,8 @@ def example_child(): '
hello
example
', ), ( - html.div( - {"data_Something": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} - ), - '
', + html.div({"data-Something": 1, "dataCamelCase": 2, "datalowercase": 3}), + '
', ), ( html.div(example_parent()), From b927701a833e7ce15b9d16bf9a8f9d6165c96943 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:02:17 -0800 Subject: [PATCH 3/9] Fix coverage --- tests/test_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index d6c565b31..e073f14c3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -337,6 +337,10 @@ def example_child(): html.div(example_parent()), '

Sample Application

', ), + ( + html.form({"acceptCharset": "utf-8"}), + '
', + ), ], ) def test_vdom_to_html(vdom_in, html_out): From af68f0e957b74e9a97cbbcabf5e7f8584a031de7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:02:40 -0800 Subject: [PATCH 4/9] Add changelog --- docs/source/about/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 6be65b7e7..db9e90472 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -25,6 +25,7 @@ Unreleased - :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:`1278` - ``reactpy.utils.html_to_vdom`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. **Changed** @@ -38,6 +39,7 @@ 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.vdom_to_html`` will now retain the user's original casing for element ``data-*`` and ``aria-*`` attributes. **Removed** From ee51f6794a898aa80c47959bc5b5a10fb25e8bce Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:54:31 -0800 Subject: [PATCH 5/9] Infer key from `attributes["key"]` --- src/reactpy/transforms.py | 12 +++++++++--- tests/test_utils.py | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index cbb3a1b10..38dff5370 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -98,12 +98,18 @@ def input_element_value_prop_to_defaultValue(vdom: VdomDict) -> None: def infer_key_from_attributes(vdom: VdomDict) -> None: """Infer the ReactJS `key` by looking at any attributes that should be unique.""" attributes = vdom.get("attributes", {}) + if not attributes: + return + + # Infer 'key' from 'attributes.key' + key = attributes.pop("key", None) - # Infer 'key' from 'id' + # Infer 'key' from 'attributes.id' + if key is None: key = attributes.get("id") - # Fallback: Infer 'key' from 'name' - if not key and vdom["tagName"] in {"input", "select", "textarea"}: + # Infer 'key' from 'attributes.name' + if key is None and vdom["tagName"] in {"input", "select", "textarea"}: key = attributes.get("name") if key: diff --git a/tests/test_utils.py b/tests/test_utils.py index e073f14c3..4663011d5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -185,6 +185,11 @@ def test_html_to_vdom(case): "attributes": {"type": "text", "name": "my-input"}, }, }, + # 8: Infer ReactJS `key` from the `key` attribute + { + "source": '
', + "model": {"tagName": "div", "key": "my-key"}, + }, ], ) def test_html_to_vdom_default_transforms(case): From db7676f0bc38f84e5337ee0cffe7d61c1228d407 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:23:07 -0800 Subject: [PATCH 6/9] `string_to_reactpy` and `reactpy_to_string` --- src/reactpy/__init__.py | 6 ++-- src/reactpy/executors/asgi/standalone.py | 4 +-- src/reactpy/executors/utils.py | 4 +-- src/reactpy/pyscript/components.py | 6 ++-- src/reactpy/pyscript/utils.py | 4 +-- src/reactpy/transforms.py | 6 ++-- src/reactpy/utils.py | 42 +++++++++++++----------- tests/test_utils.py | 33 +++++++++++-------- 8 files changed, 57 insertions(+), 48 deletions(-) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 5413d0b07..3c496d974 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -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", ] diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py index 1a8caad2f..fac9e7ce6 100644 --- a/src/reactpy/executors/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -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"] = "" diff --git a/src/reactpy/executors/utils.py b/src/reactpy/executors/utils.py index 03006a3ef..e0008df9a 100644 --- a/src/reactpy/executors/utils.py +++ b/src/reactpy/executors/utils.py @@ -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__) @@ -41,7 +41,7 @@ def check_path(url_path: str) -> str: # nocov 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." diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py index e51cc0766..5709bd7ca 100644 --- a/src/reactpy/pyscript/components.py +++ b/src/reactpy/pyscript/components.py @@ -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,7 +22,7 @@ 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 @@ -30,7 +30,7 @@ def _pyscript_component( 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"] = "" diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index ed115e346..34b54576d 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -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) diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index 38dff5370..74c3f3f92 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -7,7 +7,7 @@ class RequiredTransforms: - """Performs any necessary transformations related to `html_to_vdom` to automatically prevent + """Performs any necessary transformations related to `string_to_reactpy` to automatically prevent issues with React's rendering engine. """ @@ -83,7 +83,7 @@ def select_element_to_reactjs(self, vdom: VdomDict) -> None: def input_element_value_prop_to_defaultValue(vdom: VdomDict) -> None: """ReactJS will complain that inputs are uncontrolled if defining the `value` prop, so we use `defaultValue` instead. This has an added benefit of not deleting/overriding - any user input when a `html_to_vdom` re-renders fields that do not retain their `value`, + any user input when a `string_to_reactpy` re-renders fields that do not retain their `value`, such as password fields.""" if vdom["tagName"] != "input": return @@ -106,7 +106,7 @@ def infer_key_from_attributes(vdom: VdomDict) -> None: # Infer 'key' from 'attributes.id' if key is None: - key = attributes.get("id") + key = attributes.get("id") # Infer 'key' from 'attributes.name' if key is None and vdom["tagName"] in {"input", "select", "textarea"}: diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index f971163e3..18906983d 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -61,32 +61,31 @@ 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( +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: @@ -126,7 +125,7 @@ def html_to_vdom( "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 html_to_vdom().\n" + "can disable the strict parameter on string_to_reactpy().\n" "Otherwise, repair your broken HTML and try again." ) raise HTMLParseError(msg) from e @@ -228,12 +227,17 @@ def _generate_vdom_children( ) -def component_to_vdom(component: ComponentType) -> VdomDict | str | None: +def component_to_vdom(component: ComponentType) -> VdomDict: """Convert the first render of a component into a VDOM dictionary""" result = component.render() + + if isinstance(result, dict): + return result if hasattr(result, "render"): - result = component_to_vdom(cast(ComponentType, result)) - return cast(Union[VdomDict, str, None], result) + return component_to_vdom(cast(ComponentType, result)) + elif isinstance(result, str): + return make_vdom("div", {}, result) + return make_vdom("") def _react_attribute_to_html(key: str, value: Any) -> tuple[str, str]: diff --git a/tests/test_utils.py b/tests/test_utils.py index 4663011d5..563fb34a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -88,8 +88,8 @@ def test_ref_repr(): }, ], ) -def test_html_to_vdom(case): - assert utils.html_to_vdom(case["source"]) == case["model"] +def test_string_to_reactpy(case): + assert utils.string_to_reactpy(case["source"]) == case["model"] @pytest.mark.parametrize( @@ -192,18 +192,18 @@ def test_html_to_vdom(case): }, ], ) -def test_html_to_vdom_default_transforms(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_html_to_vdom_intercept_links(): +def test_string_to_reactpy_intercept_links(): source = 'Hello World' expected = { "tagName": "a", "children": ["Hello World"], "attributes": {"href": "https://example.com"}, } - result = utils.html_to_vdom(source, intercept_links=True) + 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", {}) @@ -213,7 +213,7 @@ def test_html_to_vdom_intercept_links(): assert "onClick" in event_handlers -def test_html_to_vdom_custom_transform(): +def test_string_to_reactpy_custom_transform(): source = "

hello world and universelmao

" def make_links_blue(node): @@ -241,7 +241,8 @@ def make_links_blue(node): } assert ( - utils.html_to_vdom(source, make_links_blue, intercept_links=False) == expected + utils.string_to_reactpy(source, make_links_blue, intercept_links=False) + == expected ) @@ -256,10 +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) + utils.string_to_reactpy(source, strict=True) SOME_OBJECT = object() @@ -342,19 +343,23 @@ def example_child(): html.div(example_parent()), '

Sample Application

', ), + ( + example_parent(), + '

Sample Application

', + ), ( html.form({"acceptCharset": "utf-8"}), '
', ), ], ) -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(): From 09a56746f07efc103998c8d313c38ec80ed5dceb Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:02:12 -0800 Subject: [PATCH 7/9] Fix coverage --- tests/test_utils.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 563fb34a2..7e334dda5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -278,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( @@ -341,16 +351,24 @@ def example_child(): ), ( html.div(example_parent()), - '

Sample Application

', + '

Example

', ), ( example_parent(), - '

Sample Application

', + '

Example

', ), ( html.form({"acceptCharset": "utf-8"}), '
', ), + ( + example_str_return(), + "
Example
", + ), + ( + example_none_return(), + "", + ), ], ) def test_reactpy_to_string(vdom_in, html_out): From bbe71b56cd8572a432c15e3dc30c0dd0a69a3b0d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:02:22 -0800 Subject: [PATCH 8/9] Update changelog --- docs/source/about/changelog.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index db9e90472..8586f2325 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -25,7 +25,6 @@ Unreleased - :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:`1278` - ``reactpy.utils.html_to_vdom`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. **Changed** @@ -39,7 +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.vdom_to_html`` will now retain the user's original casing for element ``data-*`` and ``aria-*`` attributes. +- :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** @@ -50,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. From 393de1c39b7216467fa0b7a19da4586543c93918 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:40:06 -0800 Subject: [PATCH 9/9] Remove unused import of Union from typing --- src/reactpy/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 18906983d..666b97241 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -4,7 +4,7 @@ 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