From d529feda27e761dff6b8bcc9744182205c17026b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 20 Jun 2024 00:34:09 -0700
Subject: [PATCH 01/28] functional pyscript prototype
---
src/reactpy_django/pyscript/__init__.py | 0
src/reactpy_django/pyscript/executor.py | 98 +++++++++++++++++++
.../templates/reactpy/pyscript_component.html | 6 ++
src/reactpy_django/templatetags/reactpy.py | 96 +++++++++++++++---
tests/test_app/pyscript/__init__.py | 0
.../test_app/pyscript/components/__init__.py | 0
tests/test_app/pyscript/components/counter.py | 15 +++
.../pyscript/components/hello_world.py | 6 ++
tests/test_app/pyscript/urls.py | 7 ++
tests/test_app/pyscript/views.py | 5 +
tests/test_app/templates/pyscript.html | 22 +++++
tests/test_app/urls.py | 2 +
12 files changed, 243 insertions(+), 14 deletions(-)
create mode 100644 src/reactpy_django/pyscript/__init__.py
create mode 100644 src/reactpy_django/pyscript/executor.py
create mode 100644 src/reactpy_django/templates/reactpy/pyscript_component.html
create mode 100644 tests/test_app/pyscript/__init__.py
create mode 100644 tests/test_app/pyscript/components/__init__.py
create mode 100644 tests/test_app/pyscript/components/counter.py
create mode 100644 tests/test_app/pyscript/components/hello_world.py
create mode 100644 tests/test_app/pyscript/urls.py
create mode 100644 tests/test_app/pyscript/views.py
create mode 100644 tests/test_app/templates/pyscript.html
diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/reactpy_django/pyscript/executor.py b/src/reactpy_django/pyscript/executor.py
new file mode 100644
index 00000000..2d403441
--- /dev/null
+++ b/src/reactpy_django/pyscript/executor.py
@@ -0,0 +1,98 @@
+"""Code within this module is designed to be run directly by PyScript, and is not
+intended to be run in a Django environment.
+
+Our template tag performs string substitutions to turn this file into valid PyScript."""
+
+import asyncio
+
+import js
+from jsonpointer import set_pointer
+from pyodide.ffi.wrappers import add_event_listener
+from reactpy.core.layout import Layout
+
+
+# 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."""
+
+ def root(): ...
+
+ return root()
+
+
+# ReactPy layout rendering starts here
+class LayoutManagerUUID:
+ """Encapsulate an entire layout manager with a completely unique class to prevent
+ rendering bugs caused by the PyScript global interpreter."""
+
+ @staticmethod
+ def apply_update(update, root_model):
+ if update["path"]:
+ set_pointer(root_model, update["path"], update["model"])
+ else:
+ root_model.update(update["model"])
+
+ def render_model(self, layout, model):
+ container = js.document.getElementById("UUID")
+ container.innerHTML = ""
+ self._render_model(layout, container, model)
+
+ def _render_model(self, layout, parent, model):
+ if isinstance(model, str):
+ parent.appendChild(js.document.createTextNode(model))
+ elif isinstance(model, dict):
+ if not model["tagName"]:
+ for child in model.get("children", []):
+ self._render_model(layout, parent, child)
+ return
+ tag = model["tagName"]
+ attributes = model.get("attributes", {})
+ children = model.get("children", [])
+ element = js.document.createElement(tag)
+ for key, value in attributes.items():
+ if key == "style":
+ for style_key, style_value in value.items():
+ setattr(element.style, style_key, style_value)
+ else:
+ element.setAttribute(key, value)
+ for event_name, event_handler_model in model.get(
+ "eventHandlers", {}
+ ).items():
+ self._create_event_handler(
+ layout, element, event_name, event_handler_model
+ )
+ for child in children:
+ self._render_model(layout, element, child)
+ parent.appendChild(element)
+ else:
+ raise ValueError(f"Unknown model type: {type(model)}")
+
+ @staticmethod
+ def _create_event_handler(layout, element, event_name, event_handler_model):
+ target = event_handler_model["target"]
+
+ def event_handler(*args):
+ asyncio.create_task(
+ layout.deliver(
+ {
+ "type": "layout-event",
+ "target": target,
+ "data": args,
+ }
+ )
+ )
+
+ event_name = event_name.lstrip("on_").lower().replace("_", "")
+ add_event_listener(element, event_name, event_handler)
+
+ async def run(self):
+ root_model = {}
+ async with Layout(user_workspace_UUID()) as layout:
+ while True:
+ update = await layout.render()
+ self.apply_update(update, root_model)
+ self.render_model(layout, root_model)
+
+
+asyncio.create_task(LayoutManagerUUID().run())
diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html
new file mode 100644
index 00000000..b37ad7d1
--- /dev/null
+++ b/src/reactpy_django/templates/reactpy/pyscript_component.html
@@ -0,0 +1,6 @@
+{% load static %}
+
+
+{% if reactpy_class %}
{{reactpy_initial_html}}
{% endif %}
+{% if not reactpy_class %}{{reactpy_initial_html}}
{% endif %}
+{{ reactpy_executor }}
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index e33d8387..0ceb1a3f 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -1,18 +1,24 @@
from __future__ import annotations
+import textwrap
from logging import getLogger
+from pathlib import Path
from uuid import uuid4
import dill as pickle
+import jsonpointer
+import orjson
+import reactpy
from django import template
from django.http import HttpRequest
from django.urls import NoReverseMatch, reverse
from reactpy.backend.hooks import ConnectionContext
from reactpy.backend.types import Connection, Location
-from reactpy.core.types import ComponentConstructor
+from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict
from reactpy.utils import vdom_to_html
-from reactpy_django import config, models
+from reactpy_django import config as reactpy_config
+from reactpy_django import models, pyscript
from reactpy_django.exceptions import (
ComponentCarrierError,
ComponentDoesNotExistError,
@@ -30,6 +36,10 @@
register = template.Library()
_logger = getLogger(__name__)
+pyscript_template = (Path(pyscript.__file__).parent / "executor.py").read_text(
+ encoding="utf-8"
+)
+
@register.inclusion_tag("reactpy/component.html", takes_context=True)
def component(
@@ -37,7 +47,7 @@ def component(
dotted_path: str,
*args,
host: str | None = None,
- prerender: str = str(config.REACTPY_PRERENDER),
+ prerender: str = str(reactpy_config.REACTPY_PRERENDER),
offline: str = "",
**kwargs,
):
@@ -73,7 +83,11 @@ def component(
perceived_host = (request.get_host() if request else "").strip("/")
host = (
host
- or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "")
+ or (
+ next(reactpy_config.REACTPY_DEFAULT_HOSTS)
+ if reactpy_config.REACTPY_DEFAULT_HOSTS
+ else ""
+ )
).strip("/")
is_local = not host or host.startswith(perceived_host)
uuid = str(uuid4())
@@ -84,7 +98,7 @@ def component(
_offline_html = ""
# Validate the host
- if host and config.REACTPY_DEBUG_MODE:
+ if host and reactpy_config.REACTPY_DEBUG_MODE:
try:
validate_host(host)
except InvalidHostError as e:
@@ -92,14 +106,14 @@ def component(
# Fetch the component
if is_local:
- user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path)
+ user_component = reactpy_config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path)
if not user_component:
msg = f"Component '{dotted_path}' is not registered as a root component. "
_logger.error(msg)
return failure_context(dotted_path, ComponentDoesNotExistError(msg))
# Validate the component args & kwargs
- if is_local and config.REACTPY_DEBUG_MODE:
+ if is_local and reactpy_config.REACTPY_DEBUG_MODE:
try:
validate_component_args(user_component, *args, **kwargs)
except ComponentParamError as e:
@@ -140,7 +154,7 @@ def component(
# Fetch the offline component's HTML, if requested
if offline:
- offline_component = config.REACTPY_REGISTERED_COMPONENTS.get(offline)
+ offline_component = reactpy_config.REACTPY_REGISTERED_COMPONENTS.get(offline)
if not offline_component:
msg = f"Cannot render offline component '{offline}'. It is not registered as a component."
_logger.error(msg)
@@ -159,13 +173,13 @@ def component(
"reactpy_class": class_,
"reactpy_uuid": uuid,
"reactpy_host": host or perceived_host,
- "reactpy_url_prefix": config.REACTPY_URL_PREFIX,
+ "reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX,
"reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/",
"reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
- "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL,
- "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL,
- "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
- "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES,
+ "reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL,
+ "reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL,
+ "reactpy_reconnect_backoff_multiplier": reactpy_config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
+ "reactpy_reconnect_max_retries": reactpy_config.REACTPY_RECONNECT_MAX_RETRIES,
"reactpy_prerender_html": _prerender_html,
"reactpy_offline_html": _offline_html,
}
@@ -174,7 +188,7 @@ def component(
def failure_context(dotted_path: str, error: Exception):
return {
"reactpy_failure": True,
- "reactpy_debug_mode": config.REACTPY_DEBUG_MODE,
+ "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE,
"reactpy_dotted_path": dotted_path,
"reactpy_error": type(error).__name__,
}
@@ -219,3 +233,57 @@ def prerender_component(
vdom_tree = layout.render()["model"]
return vdom_to_html(vdom_tree)
+
+
+# TODO: Add micropython support
+@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True)
+def pyscript_component(
+ context: template.RequestContext,
+ file_path: str,
+ *extra_packages: str,
+ initial: str | VdomDict | ComponentType = "",
+ config: str | dict = "",
+ root: str = "root",
+):
+ uuid = uuid4().hex
+ request: HttpRequest | None = context.get("request")
+ pyscript_config = {
+ "packages": [
+ f"reactpy=={reactpy.__version__}",
+ f"jsonpointer=={jsonpointer.__version__}",
+ "ssl",
+ *extra_packages,
+ ]
+ }
+ if config and isinstance(config, str):
+ pyscript_config.update(orjson.loads(config))
+ elif isinstance(config, dict):
+ pyscript_config.update(config)
+
+ # Convert the user provided initial HTML to a string, if needed
+ if isinstance(initial, dict):
+ initial = vdom_to_html(initial)
+ elif hasattr(initial, "render"):
+ if not request:
+ raise ValueError(
+ "Cannot render a component without a HTTP request. Are you missing the request "
+ "context processor in settings.py:TEMPLATES['OPTIONS']['context_processors']?"
+ )
+ initial = prerender_component(initial, [], {}, uuid, request)
+
+ # Create a valid PyScript executor by replacing the template values
+ executor = pyscript_template.replace("UUID", uuid)
+ executor = executor.replace("return root()", f"return {root}()")
+
+ # Insert the user code into the template
+ user_code = Path(file_path).read_text(encoding="utf-8")
+ user_code = user_code.strip().replace("\t", " ") # Normalize the code text
+ user_code = textwrap.indent(user_code, " ") # Add indentation to match template
+ executor = executor.replace(" def root(): ...", user_code)
+
+ return {
+ "reactpy_executor": executor,
+ "reactpy_uuid": uuid,
+ "reactpy_initial_html": initial,
+ "reactpy_config": orjson.dumps(pyscript_config).decode(),
+ }
diff --git a/tests/test_app/pyscript/__init__.py b/tests/test_app/pyscript/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_app/pyscript/components/__init__.py b/tests/test_app/pyscript/components/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_app/pyscript/components/counter.py b/tests/test_app/pyscript/components/counter.py
new file mode 100644
index 00000000..f7717181
--- /dev/null
+++ b/tests/test_app/pyscript/components/counter.py
@@ -0,0 +1,15 @@
+from reactpy import component, html, use_state
+
+
+@component
+def root():
+ value, set_value = use_state(0)
+ return html.article(
+ html.div(
+ {"class": "grid"},
+ html.button({"on_click": lambda event: set_value(value + 1)}, "+"),
+ html.button({"on_click": lambda event: set_value(value - 1)}, "-"),
+ ),
+ "Current value",
+ html.pre({"style": {"font-style": "bold"}}, str(value)),
+ )
diff --git a/tests/test_app/pyscript/components/hello_world.py b/tests/test_app/pyscript/components/hello_world.py
new file mode 100644
index 00000000..8081c028
--- /dev/null
+++ b/tests/test_app/pyscript/components/hello_world.py
@@ -0,0 +1,6 @@
+from reactpy import component, html
+
+
+@component
+def root():
+ return html.div("hello world")
diff --git a/tests/test_app/pyscript/urls.py b/tests/test_app/pyscript/urls.py
new file mode 100644
index 00000000..e71c18e5
--- /dev/null
+++ b/tests/test_app/pyscript/urls.py
@@ -0,0 +1,7 @@
+from django.urls import re_path
+
+from test_app.pyscript.views import pyscript
+
+urlpatterns = [
+ re_path(r"^pyscript/(?P.*)/?$", pyscript),
+]
diff --git a/tests/test_app/pyscript/views.py b/tests/test_app/pyscript/views.py
new file mode 100644
index 00000000..f4891be1
--- /dev/null
+++ b/tests/test_app/pyscript/views.py
@@ -0,0 +1,5 @@
+from django.shortcuts import render
+
+
+def pyscript(request, path=None):
+ return render(request, "pyscript.html", {})
diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html
new file mode 100644
index 00000000..81a95817
--- /dev/null
+++ b/tests/test_app/templates/pyscript.html
@@ -0,0 +1,22 @@
+{% load static %} {% load reactpy %}
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+ ReactPy PyScript Test Page
+
+ {% pyscript_component "./test_app/pyscript/components/hello_world.py" %}
+
+ {% pyscript_component "./test_app/pyscript/components/counter.py" %}
+
+
+
+
diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py
index 05acb163..070b74f1 100644
--- a/tests/test_app/urls.py
+++ b/tests/test_app/urls.py
@@ -14,6 +14,7 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+
from django.contrib import admin
from django.urls import include, path
@@ -30,6 +31,7 @@
path("", include("test_app.prerender.urls")),
path("", include("test_app.performance.urls")),
path("", include("test_app.router.urls")),
+ path("", include("test_app.pyscript.urls")),
path("", include("test_app.offline.urls")),
path("", include("test_app.channel_layers.urls")),
path("reactpy/", include("reactpy_django.http.urls")),
From bdf196080a41bcd4d4f8454107733a20f341b68d Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 20 Jun 2024 15:46:55 -0700
Subject: [PATCH 02/28] first attempt at pyscript components with server-side
parent components
---
src/reactpy_django/components.py | 80 +++++++++++++--
src/reactpy_django/pyscript/__init__.py | 0
.../executor.py => pyscript_template.py} | 26 ++---
.../templates/reactpy/pyscript_component.html | 5 +-
src/reactpy_django/templatetags/reactpy.py | 85 +++-------------
src/reactpy_django/utils.py | 98 +++++++++++++++++++
tests/test_app/components.py | 12 +--
tests/test_app/pyscript/components/child.py | 10 ++
.../pyscript/components/child_embed.py | 16 +++
tests/test_app/pyscript/components/counter.py | 2 +-
tests/test_app/templates/pyscript.html | 2 +
11 files changed, 233 insertions(+), 103 deletions(-)
delete mode 100644 src/reactpy_django/pyscript/__init__.py
rename src/reactpy_django/{pyscript/executor.py => pyscript_template.py} (79%)
create mode 100644 tests/test_app/pyscript/components/child.py
create mode 100644 tests/test_app/pyscript/components/child_embed.py
diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py
index 75b0c321..dad3f576 100644
--- a/src/reactpy_django/components.py
+++ b/src/reactpy_django/components.py
@@ -4,18 +4,29 @@
import os
from typing import Any, Callable, Sequence, Union, cast, overload
from urllib.parse import urlencode
+from uuid import uuid4
from warnings import warn
+import orjson
from django.contrib.staticfiles.finders import find
from django.core.cache import caches
from django.http import HttpRequest
+from django.templatetags.static import static
from django.urls import reverse
from django.views import View
from reactpy import component, hooks, html, utils
-from reactpy.types import Key, VdomDict
+from reactpy.types import ComponentType, Key, VdomDict
from reactpy_django.exceptions import ViewNotRegisteredError
-from reactpy_django.utils import generate_obj_name, import_module, render_view
+from reactpy_django.utils import (
+ PYSCRIPT_TAG,
+ extend_pyscript_config,
+ generate_obj_name,
+ import_module,
+ render_pyscript_template,
+ render_view,
+ vdom_or_component_to_string,
+)
# Type hints for:
@@ -27,8 +38,7 @@ def view_to_component(
compatibility: bool = False,
transforms: Sequence[Callable[[VdomDict], Any]] = (),
strict_parsing: bool = True,
-) -> Any:
- ...
+) -> Any: ...
# Type hints for:
@@ -39,8 +49,7 @@ def view_to_component(
compatibility: bool = False,
transforms: Sequence[Callable[[VdomDict], Any]] = (),
strict_parsing: bool = True,
-) -> Callable[[Callable], Any]:
- ...
+) -> Callable[[Callable], Any]: ...
def view_to_component(
@@ -148,6 +157,24 @@ def django_js(static_path: str, key: Key | None = None):
return _django_js(static_path=static_path, key=key)
+def python_to_pyscript(
+ file_path: str,
+ *extra_packages: str,
+ extra_props: dict[str, Any] | None = None,
+ initial: str | VdomDict | ComponentType = "",
+ config: str | dict = "",
+ root: str = "root",
+):
+ return _python_to_pyscript(
+ file_path,
+ *extra_packages,
+ extra_props=extra_props,
+ initial=initial,
+ config=config,
+ root=root,
+ )
+
+
@component
def _view_to_component(
view: Callable | View | str,
@@ -284,3 +311,44 @@ def _cached_static_contents(static_path: str) -> str:
)
return file_contents
+
+
+@component
+def _python_to_pyscript(
+ file_path: str,
+ *extra_packages: str,
+ extra_props: dict[str, Any] | None = None,
+ initial: str | VdomDict | ComponentType = "",
+ config: str | dict = "",
+ root: str = "root",
+):
+ uuid = uuid4().hex.replace("-", "")
+ initial = vdom_or_component_to_string(initial, uuid=uuid)
+ executor = render_pyscript_template(file_path, uuid, root)
+ new_config = extend_pyscript_config(config, extra_packages)
+
+ return html.div(
+ html.link(
+ {"rel": "stylesheet", "href": static("reactpy_django/pyscript/core.css")}
+ ),
+ html.script(
+ {
+ "type": "module",
+ "src": static(
+ "reactpy_django/pyscript/core.js",
+ ),
+ }
+ ),
+ html.div((extra_props or {}) | {"id": f"pyscript-{uuid}"}, initial),
+ PYSCRIPT_TAG(
+ {
+ "async": "",
+ "config": orjson.dumps(new_config).decode(),
+ "id": f"script-{uuid}",
+ },
+ executor,
+ ),
+ html.script(
+ f"if (document.querySelector('#pyscript-{uuid}') && document.querySelector('#pyscript-{uuid}').childElementCount != 0 && document.querySelector('#script-{uuid}')) document.querySelector('#script-{uuid}').remove();"
+ ),
+ )
diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/reactpy_django/pyscript/executor.py b/src/reactpy_django/pyscript_template.py
similarity index 79%
rename from src/reactpy_django/pyscript/executor.py
rename to src/reactpy_django/pyscript_template.py
index 2d403441..7374e2da 100644
--- a/src/reactpy_django/pyscript/executor.py
+++ b/src/reactpy_django/pyscript_template.py
@@ -33,18 +33,18 @@ def apply_update(update, root_model):
else:
root_model.update(update["model"])
- def render_model(self, layout, model):
- container = js.document.getElementById("UUID")
+ def render(self, layout, model):
+ container = js.document.getElementById("pyscript-UUID")
container.innerHTML = ""
- self._render_model(layout, container, model)
+ self.build_element_tree(layout, container, model)
- def _render_model(self, layout, parent, model):
+ def build_element_tree(self, layout, parent, model):
if isinstance(model, str):
parent.appendChild(js.document.createTextNode(model))
elif isinstance(model, dict):
if not model["tagName"]:
for child in model.get("children", []):
- self._render_model(layout, parent, child)
+ self.build_element_tree(layout, parent, child)
return
tag = model["tagName"]
attributes = model.get("attributes", {})
@@ -59,28 +59,22 @@ def _render_model(self, layout, parent, model):
for event_name, event_handler_model in model.get(
"eventHandlers", {}
).items():
- self._create_event_handler(
+ self.create_event_handler(
layout, element, event_name, event_handler_model
)
for child in children:
- self._render_model(layout, element, child)
+ self.build_element_tree(layout, element, child)
parent.appendChild(element)
else:
raise ValueError(f"Unknown model type: {type(model)}")
@staticmethod
- def _create_event_handler(layout, element, event_name, event_handler_model):
+ def create_event_handler(layout, element, event_name, event_handler_model):
target = event_handler_model["target"]
def event_handler(*args):
asyncio.create_task(
- layout.deliver(
- {
- "type": "layout-event",
- "target": target,
- "data": args,
- }
- )
+ layout.deliver({"type": "layout-event", "target": target, "data": args})
)
event_name = event_name.lstrip("on_").lower().replace("_", "")
@@ -92,7 +86,7 @@ async def run(self):
while True:
update = await layout.render()
self.apply_update(update, root_model)
- self.render_model(layout, root_model)
+ self.render(layout, root_model)
asyncio.create_task(LayoutManagerUUID().run())
diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html
index b37ad7d1..7dd7ab17 100644
--- a/src/reactpy_django/templates/reactpy/pyscript_component.html
+++ b/src/reactpy_django/templates/reactpy/pyscript_component.html
@@ -1,6 +1,7 @@
{% load static %}
-{% if reactpy_class %}{{reactpy_initial_html}}
{% endif %}
-{% if not reactpy_class %}{{reactpy_initial_html}}
{% endif %}
+{% if reactpy_class %}{{reactpy_initial_html}}
+{% endif %}
+{% if not reactpy_class %}{{reactpy_initial_html}}
{% endif %}
{{ reactpy_executor }}
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index 0ceb1a3f..7e27afa5 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -1,24 +1,17 @@
from __future__ import annotations
-import textwrap
from logging import getLogger
-from pathlib import Path
from uuid import uuid4
import dill as pickle
-import jsonpointer
import orjson
-import reactpy
from django import template
from django.http import HttpRequest
from django.urls import NoReverseMatch, reverse
-from reactpy.backend.hooks import ConnectionContext
-from reactpy.backend.types import Connection, Location
from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict
-from reactpy.utils import vdom_to_html
from reactpy_django import config as reactpy_config
-from reactpy_django import models, pyscript
+from reactpy_django import models
from reactpy_django.exceptions import (
ComponentCarrierError,
ComponentDoesNotExistError,
@@ -27,7 +20,14 @@
OfflineComponentMissing,
)
from reactpy_django.types import ComponentParams
-from reactpy_django.utils import SyncLayout, strtobool, validate_component_args
+from reactpy_django.utils import (
+ extend_pyscript_config,
+ prerender_component,
+ render_pyscript_template,
+ strtobool,
+ validate_component_args,
+ vdom_or_component_to_string,
+)
try:
RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/")
@@ -36,10 +36,6 @@
register = template.Library()
_logger = getLogger(__name__)
-pyscript_template = (Path(pyscript.__file__).parent / "executor.py").read_text(
- encoding="utf-8"
-)
-
@register.inclusion_tag("reactpy/component.html", takes_context=True)
def component(
@@ -211,31 +207,6 @@ def validate_host(host: str):
raise InvalidHostError(msg)
-def prerender_component(
- user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest
-):
- search = request.GET.urlencode()
- scope = getattr(request, "scope", {})
- scope["reactpy"] = {"id": str(uuid)}
-
- with SyncLayout(
- ConnectionContext(
- user_component(*args, **kwargs),
- value=Connection(
- scope=scope,
- location=Location(
- pathname=request.path, search=f"?{search}" if search else ""
- ),
- carrier=request,
- ),
- )
- ) as layout:
- vdom_tree = layout.render()["model"]
-
- return vdom_to_html(vdom_tree)
-
-
-# TODO: Add micropython support
@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True)
def pyscript_component(
context: template.RequestContext,
@@ -247,43 +218,13 @@ def pyscript_component(
):
uuid = uuid4().hex
request: HttpRequest | None = context.get("request")
- pyscript_config = {
- "packages": [
- f"reactpy=={reactpy.__version__}",
- f"jsonpointer=={jsonpointer.__version__}",
- "ssl",
- *extra_packages,
- ]
- }
- if config and isinstance(config, str):
- pyscript_config.update(orjson.loads(config))
- elif isinstance(config, dict):
- pyscript_config.update(config)
-
- # Convert the user provided initial HTML to a string, if needed
- if isinstance(initial, dict):
- initial = vdom_to_html(initial)
- elif hasattr(initial, "render"):
- if not request:
- raise ValueError(
- "Cannot render a component without a HTTP request. Are you missing the request "
- "context processor in settings.py:TEMPLATES['OPTIONS']['context_processors']?"
- )
- initial = prerender_component(initial, [], {}, uuid, request)
-
- # Create a valid PyScript executor by replacing the template values
- executor = pyscript_template.replace("UUID", uuid)
- executor = executor.replace("return root()", f"return {root}()")
-
- # Insert the user code into the template
- user_code = Path(file_path).read_text(encoding="utf-8")
- user_code = user_code.strip().replace("\t", " ") # Normalize the code text
- user_code = textwrap.indent(user_code, " ") # Add indentation to match template
- executor = executor.replace(" def root(): ...", user_code)
+ initial = vdom_or_component_to_string(initial, request=request, uuid=uuid)
+ executor = render_pyscript_template(file_path, uuid, root)
+ new_config = extend_pyscript_config(config, extra_packages)
return {
"reactpy_executor": executor,
"reactpy_uuid": uuid,
"reactpy_initial_html": initial,
- "reactpy_config": orjson.dumps(pyscript_config).decode(),
+ "reactpy_config": orjson.dumps(new_config).decode(),
}
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 73538ad7..f2fde3dd 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -5,11 +5,18 @@
import logging
import os
import re
+import textwrap
from asyncio import iscoroutinefunction
+from copy import deepcopy
from fnmatch import fnmatch
from importlib import import_module
+from pathlib import Path
from typing import Any, Callable, Sequence
+from uuid import UUID, uuid4
+import jsonpointer
+import orjson
+import reactpy
from asgiref.sync import async_to_sync
from channels.db import database_sync_to_async
from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects
@@ -19,7 +26,11 @@
from django.template import engines
from django.utils.encoding import smart_str
from django.views import View
+from reactpy import vdom_to_html
+from reactpy.backend.hooks import ConnectionContext
+from reactpy.backend.types import Connection, Location
from reactpy.core.layout import Layout
+from reactpy.core.vdom import make_vdom_constructor
from reactpy.types import ComponentConstructor
from reactpy_django.exceptions import (
@@ -44,6 +55,17 @@
+ rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?"
+ r"\s*%}"
)
+PYSCRIPT_TEMPLATE = (Path(__file__).parent / "pyscript_template.py").read_text(
+ encoding="utf-8"
+)
+PYSCRIPT_TAG = make_vdom_constructor("py-script")
+PYSCRIPT_DEFAULT_CONFIG = {
+ "packages": [
+ f"reactpy=={reactpy.__version__}",
+ f"jsonpointer=={jsonpointer.__version__}",
+ "ssl",
+ ]
+}
async def render_view(
@@ -381,3 +403,79 @@ def strtobool(val):
return 0
else:
raise ValueError(f"invalid truth value {val}")
+
+
+def prerender_component(
+ user_component: ComponentConstructor, args, kwargs, uuid, request: HttpRequest
+) -> str:
+ """Prerenders a ReactPy component and returns the HTML string."""
+ search = request.GET.urlencode()
+ scope = getattr(request, "scope", {})
+ scope["reactpy"] = {"id": str(uuid)}
+
+ with SyncLayout(
+ ConnectionContext(
+ user_component(*args, **kwargs),
+ value=Connection(
+ scope=scope,
+ location=Location(
+ pathname=request.path, search=f"?{search}" if search else ""
+ ),
+ carrier=request,
+ ),
+ )
+ ) as layout:
+ vdom_tree = layout.render()["model"]
+
+ return vdom_to_html(vdom_tree)
+
+
+def vdom_or_component_to_string(
+ vdom_or_component: Any, request: HttpRequest | None = None, uuid: UUID | None = None
+) -> str:
+ """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be
+ automatically returned."""
+ if isinstance(vdom_or_component, dict):
+ return vdom_to_html(vdom_or_component)
+
+ if hasattr(vdom_or_component, "render"):
+ if not request:
+ request = HttpRequest()
+ request.method = "GET"
+ if not uuid:
+ uuid = uuid4().hex
+ return prerender_component(vdom_or_component, [], {}, uuid, request)
+
+ if isinstance(vdom_or_component, str):
+ return vdom_or_component
+
+ raise ValueError(
+ f"Invalid type for vdom_or_component: {type(vdom_or_component)}. "
+ "Expected a VdomDict, component, or string."
+ )
+
+
+def render_pyscript_template(file_path: str, uuid: str, root: str):
+ """Inserts the user's code into our PyScript template using pattern matching."""
+ # Create a valid PyScript executor by replacing the template values
+ executor = PYSCRIPT_TEMPLATE.replace("UUID", uuid)
+ executor = executor.replace("return root()", f"return {root}()")
+
+ # Insert the user code into the template
+ user_code = Path(file_path).read_text(encoding="utf-8")
+ user_code = user_code.strip().replace("\t", " ") # Normalize the code text
+ user_code = textwrap.indent(user_code, " ") # Add indentation to match template
+ executor = executor.replace(" def root(): ...", user_code)
+
+ return executor
+
+
+def extend_pyscript_config(config: dict | str, extra_packages: Sequence) -> dict:
+ """Extends the default PyScript configuration with user configuration."""
+ pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG)
+ pyscript_config["packages"].extend(extra_packages)
+ if config and isinstance(config, str):
+ pyscript_config.update(orjson.loads(config))
+ elif isinstance(config, dict):
+ pyscript_config.update(config)
+ return pyscript_config
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index fe6df2f0..69b1541c 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -724,10 +724,10 @@ async def on_submit(event):
),
},
html.div("use_user_data"),
- html.button({"class": "login-1", "on_click": login_user1}, "Login 1"),
- html.button({"class": "login-2", "on_click": login_user2}, "Login 2"),
- html.button({"class": "logout", "on_click": logout_user}, "Logout"),
- html.button({"class": "clear", "on_click": clear_data}, "Clear Data"),
+ html.button({"className": "login-1", "on_click": login_user1}, "Login 1"),
+ html.button({"className": "login-2", "on_click": login_user2}, "Login 2"),
+ html.button({"className": "logout", "on_click": logout_user}, "Logout"),
+ html.button({"className": "clear", "on_click": clear_data}, "Clear Data"),
html.div(f"User: {current_user}"),
html.div(f"Data: {user_data_query.data}"),
html.div(
@@ -792,8 +792,8 @@ async def on_submit(event):
),
},
html.div("use_user_data_with_default"),
- html.button({"class": "login-3", "on_click": login_user3}, "Login 3"),
- html.button({"class": "clear", "on_click": clear_data}, "Clear Data"),
+ html.button({"className": "login-3", "on_click": login_user3}, "Login 3"),
+ html.button({"className": "clear", "on_click": clear_data}, "Clear Data"),
html.div(f"User: {current_user}"),
html.div(f"Data: {user_data_query.data}"),
html.div(
diff --git a/tests/test_app/pyscript/components/child.py b/tests/test_app/pyscript/components/child.py
new file mode 100644
index 00000000..6edf74a5
--- /dev/null
+++ b/tests/test_app/pyscript/components/child.py
@@ -0,0 +1,10 @@
+from reactpy import component, html
+from reactpy_django.components import python_to_pyscript
+
+
+@component
+def embed():
+ return html.div(
+ {"className": "embeddable"},
+ python_to_pyscript("./test_app/pyscript/components/child_embed.py"),
+ )
diff --git a/tests/test_app/pyscript/components/child_embed.py b/tests/test_app/pyscript/components/child_embed.py
new file mode 100644
index 00000000..3b2a0fbb
--- /dev/null
+++ b/tests/test_app/pyscript/components/child_embed.py
@@ -0,0 +1,16 @@
+from reactpy import component, html, use_state
+
+
+@component
+def root():
+ value, set_value = use_state(0)
+ return html.article(
+ "This was embedded via a server-side component.",
+ html.div(
+ {"className": "grid"},
+ html.button({"on_click": lambda event: set_value(value + 1)}, "+"),
+ html.button({"on_click": lambda event: set_value(value - 1)}, "-"),
+ ),
+ "Current value",
+ html.pre({"style": {"font-style": "bold"}}, str(value)),
+ )
diff --git a/tests/test_app/pyscript/components/counter.py b/tests/test_app/pyscript/components/counter.py
index f7717181..8d024e17 100644
--- a/tests/test_app/pyscript/components/counter.py
+++ b/tests/test_app/pyscript/components/counter.py
@@ -6,7 +6,7 @@ def root():
value, set_value = use_state(0)
return html.article(
html.div(
- {"class": "grid"},
+ {"className": "grid"},
html.button({"on_click": lambda event: set_value(value + 1)}, "+"),
html.button({"on_click": lambda event: set_value(value - 1)}, "-"),
),
diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html
index 81a95817..065ad414 100644
--- a/tests/test_app/templates/pyscript.html
+++ b/tests/test_app/templates/pyscript.html
@@ -17,6 +17,8 @@ ReactPy PyScript Test Page
{% pyscript_component "./test_app/pyscript/components/counter.py" %}
+ {% component "test_app.pyscript.components.child.embed" %}
+