diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 6be65b7e7..8586f2325 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -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. 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/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..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"] = "" @@ -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..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__) @@ -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." 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 eb277cfb5..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) @@ -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/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/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/src/reactpy/transforms.py b/src/reactpy/transforms.py new file mode 100644 index 000000000..74c3f3f92 --- /dev/null +++ b/src/reactpy/transforms.py @@ -0,0 +1,408 @@ +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 `string_to_reactpy` 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"}, + }, + }, + # 8: Infer ReactJS `key` from the `key` attribute + { + "source": '
', + "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 = 'Hello World' + 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 = "

hello world and universelmao

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

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 - - -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 + 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(): '
hello
example
', ), ( - html.div( - {"data_Something": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} - ), - '
', + html.div({"data-Something": 1, "dataCamelCase": 2, "datalowercase": 3}), + '
', ), ( html.div(example_parent()), - '

Sample Application

', + '

Example

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

Example

', + ), + ( + html.form({"acceptCharset": "utf-8"}), + '
', + ), + ( + example_str_return(), + "
Example
", + ), + ( + 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():