From 58a4e3758608e2c4bd200677f4296236d3b78de0 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 5 Feb 2025 00:54:02 -0800
Subject: [PATCH 01/36] First draft of pyscript support
---
pyproject.toml | 2 +-
src/reactpy/_html.py | 1 +
src/reactpy/jinja.py | 53 ++++++-
src/reactpy/pyscript/__init__.py | 0
src/reactpy/pyscript/component_template.py | 27 ++++
src/reactpy/pyscript/components.py | 64 +++++++++
src/reactpy/pyscript/layout_handler.py | 154 +++++++++++++++++++++
src/reactpy/pyscript/utils.py | 101 ++++++++++++++
8 files changed, 398 insertions(+), 4 deletions(-)
create mode 100644 src/reactpy/pyscript/__init__.py
create mode 100644 src/reactpy/pyscript/component_template.py
create mode 100644 src/reactpy/pyscript/components.py
create mode 100644 src/reactpy/pyscript/layout_handler.py
create mode 100644 src/reactpy/pyscript/utils.py
diff --git a/pyproject.toml b/pyproject.toml
index c485dce2f..76557b7ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,8 +13,8 @@ readme = "README.md"
keywords = ["react", "javascript", "reactpy", "component"]
license = "MIT"
authors = [
- { name = "Ryan Morshead", email = "ryan.morshead@gmail.com" },
{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" },
+ { name = "Ryan Morshead", email = "ryan.morshead@gmail.com" },
]
requires-python = ">=3.9"
classifiers = [
diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py
index 61c6ae77f..05a6feaa3 100644
--- a/src/reactpy/_html.py
+++ b/src/reactpy/_html.py
@@ -406,6 +406,7 @@ def __getattr__(self, value: str) -> VdomDictConstructor:
video: VdomDictConstructor
wbr: VdomDictConstructor
fragment: VdomDictConstructor
+ py_script: VdomDictConstructor
# Special Case: SVG elements
# Since SVG elements have a different set of allowed children, they are
diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py
index 77d1570f1..6abb8e378 100644
--- a/src/reactpy/jinja.py
+++ b/src/reactpy/jinja.py
@@ -3,13 +3,16 @@
from jinja2_simple_tags import StandaloneTag
+from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX
+from reactpy.pyscript.utils import (
+ PYSCRIPT_LAYOUT_HANDLER,
+ extend_pyscript_config,
+ render_pyscript_template,
+)
from reactpy.utils import render_mount_template
class Component(StandaloneTag): # type: ignore
- """This allows enables a `component` tag to be used in any Jinja2 rendering context,
- as long as this template tag is registered as a Jinja2 extension."""
-
safe_output = True
tags: ClassVar[set[str]] = {"component"}
@@ -19,3 +22,47 @@ def render(self, dotted_path: str, **kwargs: str) -> str:
class_=kwargs.pop("class", ""),
append_component_path=f"{dotted_path}/",
)
+
+
+class PyScriptComponent(StandaloneTag): # type: ignore
+ safe_output = True
+ tags: ClassVar[set[str]] = {"pyscript_component"}
+
+ def render(self, *file_paths: str, initial: str = "", root: str = "root") -> str:
+ return render_pyscript_template(
+ file_paths=file_paths, initial=initial, root=root
+ )
+
+
+class PyScriptSetup(StandaloneTag): # type: ignore
+ safe_output = True
+ tags: ClassVar[set[str]] = {"pyscript_setup"}
+
+ def render(
+ self, *extra_py: str, extra_js: str | dict = "", config: str | dict = ""
+ ) -> str:
+ """
+ Args:
+ extra_py: Dependencies that need to be loaded on the page for \
+ your PyScript components. Each dependency must be contained \
+ within it's own string and written in Python requirements file syntax.
+
+ Kwargs:
+ extra_js: A JSON string or Python dictionary containing a vanilla \
+ JavaScript module URL and the `name: str` to access it within \
+ `pyscript.js_modules.*`.
+ config: A JSON string or Python dictionary containing PyScript \
+ configuration values.
+ """
+
+ hide_pyscript_debugger = f''
+ pyscript_config = extend_pyscript_config(extra_py, extra_js, config)
+
+ return (
+ f''
+ f''
+ f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}"
+ f'"
+ f'{PYSCRIPT_LAYOUT_HANDLER}'
+ )
diff --git a/src/reactpy/pyscript/__init__.py b/src/reactpy/pyscript/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/reactpy/pyscript/component_template.py b/src/reactpy/pyscript/component_template.py
new file mode 100644
index 000000000..e952845d6
--- /dev/null
+++ b/src/reactpy/pyscript/component_template.py
@@ -0,0 +1,27 @@
+# ruff: noqa: TC004, N802, N816, RUF006
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import asyncio
+
+ from reactpy.pyscript.layout_handler import ReactPyLayoutHandler
+
+
+# User component is inserted below by regex replacement
+def user_workspace_UUID():
+ """Encapsulate the user's code with a completely unique function (workspace)
+ to prevent overlapping imports and variable names between different components.
+
+ This code is designed to be run directly by PyScript, and is not intended to be run
+ in a normal Python environment.
+
+ ReactPy-Django performs string substitutions to turn this file into valid PyScript.
+ """
+
+ def root(): ...
+
+ return root()
+
+
+# Create a task to run the user's component workspace
+task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID))
diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py
new file mode 100644
index 000000000..dad4c4542
--- /dev/null
+++ b/src/reactpy/pyscript/components.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+from reactpy import component, hooks, html
+from reactpy.pyscript.utils import render_pyscript_executor
+from reactpy.types import ComponentType
+from reactpy.utils import vdom_to_html
+
+if TYPE_CHECKING:
+ from reactpy.types import VdomDict
+
+
+@component
+def _pyscript_component(
+ *file_paths: str,
+ initial: str | VdomDict = "",
+ root: str = "root",
+):
+ if not file_paths:
+ raise ValueError("At least one file path must be provided.")
+
+ rendered, set_rendered = hooks.use_state(False)
+ uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
+ initial = initial if isinstance(initial, str) else vdom_to_html(initial)
+ executor = render_pyscript_executor(file_paths=file_paths, uuid=uuid, root=root)
+
+ 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
+
+ return html.fragment(
+ html.div(
+ {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid},
+ initial,
+ ),
+ html.py_script({"async": ""}, executor),
+ )
+
+
+def pyscript_component(
+ *file_paths: str,
+ initial: str | VdomDict | ComponentType = "",
+ root: str = "root",
+) -> ComponentType:
+ """
+ Args:
+ file_paths: File path to your client-side ReactPy component. If multiple paths are \
+ provided, the contents are automatically merged.
+
+ Kwargs:
+ initial: The initial HTML that is displayed prior to the PyScript component \
+ loads. This can either be a string containing raw HTML, a \
+ `#!python reactpy.html` snippet, or a non-interactive component.
+ root: The name of the root component function.
+ """
+ return _pyscript_component(
+ *file_paths,
+ initial=initial,
+ root=root,
+ )
diff --git a/src/reactpy/pyscript/layout_handler.py b/src/reactpy/pyscript/layout_handler.py
new file mode 100644
index 000000000..1794e4188
--- /dev/null
+++ b/src/reactpy/pyscript/layout_handler.py
@@ -0,0 +1,154 @@
+# type: ignore
+import asyncio
+import logging
+
+from jsonpointer import set_pointer
+from pyodide.ffi.wrappers import add_event_listener
+from pyscript.js_modules import morphdom
+
+import js
+from reactpy.core.layout import Layout
+
+
+class ReactPyLayoutHandler:
+ """Encapsulate the entire layout handler with a class to prevent overlapping
+ variable names between user code.
+
+ This code is designed to be run directly by PyScript, and is not intended to be run
+ in a normal Python environment.
+ """
+
+ def __init__(self, uuid):
+ self.uuid = uuid
+ self.running_tasks = set()
+
+ @staticmethod
+ def update_model(update, root_model):
+ """Apply an update ReactPy's internal DOM model."""
+ if update["path"]:
+ set_pointer(root_model, update["path"], update["model"])
+ else:
+ root_model.update(update["model"])
+
+ def render_html(self, layout, model):
+ """Submit ReactPy's internal DOM model into the HTML DOM."""
+ # Create a new container to render the layout into
+ container = js.document.getElementById(f"pyscript-{self.uuid}")
+ temp_root_container = container.cloneNode(False)
+ self.build_element_tree(layout, temp_root_container, model)
+
+ # Use morphdom to update the DOM
+ morphdom.default(container, temp_root_container)
+
+ # Remove the cloned container to prevent memory leaks
+ temp_root_container.remove()
+
+ def build_element_tree(self, layout, parent, model):
+ """Recursively build an element tree, starting from the root component."""
+ # If the model is a string, add it as a text node
+ if isinstance(model, str):
+ parent.appendChild(js.document.createTextNode(model))
+
+ # If the model is a VdomDict, construct an element
+ elif isinstance(model, dict):
+ # If the model is a fragment, build the children
+ if not model["tagName"]:
+ for child in model.get("children", []):
+ self.build_element_tree(layout, parent, child)
+ return
+
+ # Otherwise, get the VdomDict attributes
+ tag = model["tagName"]
+ attributes = model.get("attributes", {})
+ children = model.get("children", [])
+ element = js.document.createElement(tag)
+
+ # Set the element's HTML attributes
+ for key, value in attributes.items():
+ if key == "style":
+ for style_key, style_value in value.items():
+ setattr(element.style, style_key, style_value)
+ elif key == "className":
+ element.className = value
+ else:
+ element.setAttribute(key, value)
+
+ # Add event handlers to the element
+ for event_name, event_handler_model in model.get(
+ "eventHandlers", {}
+ ).items():
+ self.create_event_handler(
+ layout, element, event_name, event_handler_model
+ )
+
+ # Recursively build the children
+ for child in children:
+ self.build_element_tree(layout, element, child)
+
+ # Append the element to the parent
+ parent.appendChild(element)
+
+ # Unknown data type provided
+ else:
+ msg = f"Unknown model type: {type(model)}"
+ raise TypeError(msg)
+
+ def create_event_handler(self, layout, element, event_name, event_handler_model):
+ """Create an event handler for an element. This function is used as an
+ adapter between ReactPy and browser events."""
+ target = event_handler_model["target"]
+
+ def event_handler(*args):
+ # When the event is triggered, deliver the event to the `Layout` within a background task
+ task = asyncio.create_task(
+ layout.deliver({"type": "layout-event", "target": target, "data": args})
+ )
+ # Store the task to prevent automatic garbage collection from killing it
+ self.running_tasks.add(task)
+ task.add_done_callback(self.running_tasks.remove)
+
+ add_event_listener(element, event_name, event_handler)
+
+ @staticmethod
+ def delete_old_workspaces():
+ """To prevent memory leaks, we must delete all user generated Python code when
+ it is no longer in use (removed from the page). To do this, we compare what
+ UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global
+ interpreter."""
+ # Find all PyScript workspaces that are still on the page
+ dom_workspaces = js.document.querySelectorAll(".pyscript")
+ dom_uuids = {element.dataset.uuid for element in dom_workspaces}
+ python_uuids = {
+ value.split("_")[-1]
+ for value in globals()
+ if value.startswith("user_workspace_")
+ }
+
+ # Delete any workspaces that are no longer in use
+ for uuid in python_uuids - dom_uuids:
+ task_name = f"task_{uuid}"
+ if task_name in globals():
+ task: asyncio.Task = globals()[task_name]
+ task.cancel()
+ del globals()[task_name]
+ else:
+ logging.error("Could not auto delete PyScript task %s", task_name)
+
+ workspace_name = f"user_workspace_{uuid}"
+ if workspace_name in globals():
+ del globals()[workspace_name]
+ else:
+ logging.error(
+ "Could not auto delete PyScript workspace %s", workspace_name
+ )
+
+ async def run(self, workspace_function):
+ """Run the layout handler. This function is main executor for all user generated code."""
+ self.delete_old_workspaces()
+ root_model: dict = {}
+
+ async with Layout(workspace_function()) as root_layout:
+ while True:
+ update = await root_layout.render()
+ self.update_model(update, root_model)
+ self.render_html(root_layout, root_model)
diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py
new file mode 100644
index 000000000..ed7508011
--- /dev/null
+++ b/src/reactpy/pyscript/utils.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import functools
+import json
+import textwrap
+from pathlib import Path
+from typing import TYPE_CHECKING, Callable
+from uuid import uuid4
+
+import jsonpointer
+import orjson
+
+import reactpy
+from reactpy.config import REACTPY_PATH_PREFIX
+from reactpy.types import VdomDict
+from reactpy.utils import vdom_to_html
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+
+PYSCRIPT_COMPONENT_TEMPLATE = (
+ Path(__file__).parent / "component_template.py"
+).read_text(encoding="utf-8")
+PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "layout_handler.py").read_text(
+ encoding="utf-8"
+)
+
+
+def render_pyscript_executor(file_paths: tuple[str, ...], uuid: str, root: str) -> str:
+ """Inserts the user's code into the PyScript template using pattern matching."""
+ # Create a valid PyScript executor by replacing the template values
+ executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid)
+ executor = executor.replace("return root()", f"return {root}()")
+
+ # Fetch the user's PyScript code
+ all_file_contents: list[str] = []
+ all_file_contents.extend(cached_file_read(file_path) for file_path in file_paths)
+
+ # Prepare the PyScript code block
+ user_code = "\n".join(all_file_contents) # Combine all user code
+ user_code = user_code.replace("\t", " ") # Normalize the text
+ user_code = textwrap.indent(user_code, " ") # Add indentation to match template
+
+ # Insert the user code into the PyScript template
+ return executor.replace(" def root(): ...", user_code)
+
+
+def render_pyscript_template(
+ file_paths: tuple[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)
+ uuid = uuid4().hex
+ executor_code = render_pyscript_executor(
+ file_paths=file_paths, uuid=uuid, root=root
+ )
+
+ return (
+ f'
'
+ f"{_initial}"
+ "
"
+ f"{executor_code}"
+ )
+
+
+def extend_pyscript_config(
+ extra_py: Sequence[str], extra_js: dict | str, config: dict | str
+) -> str:
+ # Extends ReactPy's default PyScript config with user provided values.
+ pyscript_config = {
+ "packages": [
+ f"reactpy=={reactpy.__version__}",
+ f"jsonpointer=={jsonpointer.__version__}",
+ "ssl",
+ ],
+ "js_modules": {
+ "main": {
+ f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom"
+ }
+ },
+ }
+ pyscript_config["packages"].extend(extra_py)
+
+ # Extend the JavaScript dependency list
+ if extra_js and isinstance(extra_js, str):
+ pyscript_config["js_modules"]["main"].update(json.loads(extra_js))
+ elif extra_js and isinstance(extra_js, dict):
+ pyscript_config["js_modules"]["main"].update(extra_py)
+
+ # Update other config attributes
+ if config and isinstance(config, str):
+ pyscript_config.update(json.loads(config))
+ elif config and isinstance(config, dict):
+ pyscript_config.update(config)
+ return orjson.dumps(pyscript_config).decode("utf-8")
+
+
+@functools.cache
+def cached_file_read(file_path: str) -> str:
+ return Path(file_path).read_text(encoding="utf-8").strip()
From 6dea8b3109be59a586a09a47772426efb19542d5 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 5 Feb 2025 13:34:05 -0800
Subject: [PATCH 02/36] move standalone to executors module
---
src/reactpy/__init__.py | 2 +-
src/reactpy/asgi/executors/__init__.py | 0
.../asgi/{ => executors}/standalone.py | 19 +++++++++----------
src/reactpy/testing/backend.py | 2 +-
tests/test_asgi/test_standalone.py | 2 +-
tests/test_web/test_module.py | 2 +-
6 files changed, 13 insertions(+), 14 deletions(-)
create mode 100644 src/reactpy/asgi/executors/__init__.py
rename src/reactpy/asgi/{ => executors}/standalone.py (93%)
diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py
index 258cd5053..299f1ac61 100644
--- a/src/reactpy/__init__.py
+++ b/src/reactpy/__init__.py
@@ -1,7 +1,7 @@
from reactpy import asgi, config, logging, types, web, widgets
from reactpy._html import html
+from reactpy.asgi.executors.standalone import ReactPy
from reactpy.asgi.middleware import ReactPyMiddleware
-from reactpy.asgi.standalone import ReactPy
from reactpy.core import hooks
from reactpy.core.component import component
from reactpy.core.events import event
diff --git a/src/reactpy/asgi/executors/__init__.py b/src/reactpy/asgi/executors/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/executors/standalone.py
similarity index 93%
rename from src/reactpy/asgi/standalone.py
rename to src/reactpy/asgi/executors/standalone.py
index 1f1298396..5dfff7f97 100644
--- a/src/reactpy/asgi/standalone.py
+++ b/src/reactpy/asgi/executors/standalone.py
@@ -151,7 +151,7 @@ class ReactPyApp:
to a user provided ASGI app."""
parent: ReactPy
- _cached_index_html = ""
+ _index_html = ""
_etag = ""
_last_modified = ""
@@ -173,8 +173,8 @@ async def __call__(
return
# Store the HTTP response in memory for performance
- if not self._cached_index_html:
- self.process_index_html()
+ if not self._index_html:
+ self.render_index_template()
# Response headers for `index.html` responses
request_headers = dict(scope["headers"])
@@ -183,7 +183,7 @@ async def __call__(
"last-modified": self._last_modified,
"access-control-allow-origin": "*",
"cache-control": "max-age=60, public",
- "content-length": str(len(self._cached_index_html)),
+ "content-length": str(len(self._index_html)),
"content-type": "text/html; charset=utf-8",
**self.parent.extra_headers,
}
@@ -203,12 +203,12 @@ async def __call__(
return await response(scope, receive, send) # type: ignore
# Send the index.html
- response = ResponseHTML(self._cached_index_html, headers=response_headers)
+ response = ResponseHTML(self._index_html, headers=response_headers)
await response(scope, receive, send) # type: ignore
- def process_index_html(self) -> None:
- """Process the index.html and store the results in memory."""
- self._cached_index_html = (
+ def render_index_template(self) -> None:
+ """Process the index.html and store the results in this class."""
+ self._index_html = (
""
f''
f"{vdom_head_to_html(self.parent.html_head)}"
@@ -217,8 +217,7 @@ def process_index_html(self) -> None:
""
"