Skip to content

Commit d529fed

Browse files
committed
functional pyscript prototype
1 parent 2e343aa commit d529fed

File tree

12 files changed

+243
-14
lines changed

12 files changed

+243
-14
lines changed

src/reactpy_django/pyscript/__init__.py

Whitespace-only changes.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Code within this module is designed to be run directly by PyScript, and is not
2+
intended to be run in a Django environment.
3+
4+
Our template tag performs string substitutions to turn this file into valid PyScript."""
5+
6+
import asyncio
7+
8+
import js
9+
from jsonpointer import set_pointer
10+
from pyodide.ffi.wrappers import add_event_listener
11+
from reactpy.core.layout import Layout
12+
13+
14+
# User component is inserted below by regex replacement
15+
def user_workspace_UUID():
16+
"""Encapsulate the user's code with a completely unique function (workspace)
17+
to prevent overlapping imports and variable names between different components."""
18+
19+
def root(): ...
20+
21+
return root()
22+
23+
24+
# ReactPy layout rendering starts here
25+
class LayoutManagerUUID:
26+
"""Encapsulate an entire layout manager with a completely unique class to prevent
27+
rendering bugs caused by the PyScript global interpreter."""
28+
29+
@staticmethod
30+
def apply_update(update, root_model):
31+
if update["path"]:
32+
set_pointer(root_model, update["path"], update["model"])
33+
else:
34+
root_model.update(update["model"])
35+
36+
def render_model(self, layout, model):
37+
container = js.document.getElementById("UUID")
38+
container.innerHTML = ""
39+
self._render_model(layout, container, model)
40+
41+
def _render_model(self, layout, parent, model):
42+
if isinstance(model, str):
43+
parent.appendChild(js.document.createTextNode(model))
44+
elif isinstance(model, dict):
45+
if not model["tagName"]:
46+
for child in model.get("children", []):
47+
self._render_model(layout, parent, child)
48+
return
49+
tag = model["tagName"]
50+
attributes = model.get("attributes", {})
51+
children = model.get("children", [])
52+
element = js.document.createElement(tag)
53+
for key, value in attributes.items():
54+
if key == "style":
55+
for style_key, style_value in value.items():
56+
setattr(element.style, style_key, style_value)
57+
else:
58+
element.setAttribute(key, value)
59+
for event_name, event_handler_model in model.get(
60+
"eventHandlers", {}
61+
).items():
62+
self._create_event_handler(
63+
layout, element, event_name, event_handler_model
64+
)
65+
for child in children:
66+
self._render_model(layout, element, child)
67+
parent.appendChild(element)
68+
else:
69+
raise ValueError(f"Unknown model type: {type(model)}")
70+
71+
@staticmethod
72+
def _create_event_handler(layout, element, event_name, event_handler_model):
73+
target = event_handler_model["target"]
74+
75+
def event_handler(*args):
76+
asyncio.create_task(
77+
layout.deliver(
78+
{
79+
"type": "layout-event",
80+
"target": target,
81+
"data": args,
82+
}
83+
)
84+
)
85+
86+
event_name = event_name.lstrip("on_").lower().replace("_", "")
87+
add_event_listener(element, event_name, event_handler)
88+
89+
async def run(self):
90+
root_model = {}
91+
async with Layout(user_workspace_UUID()) as layout:
92+
while True:
93+
update = await layout.render()
94+
self.apply_update(update, root_model)
95+
self.render_model(layout, root_model)
96+
97+
98+
asyncio.create_task(LayoutManagerUUID().run())
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% load static %}
2+
<link rel="stylesheet" href="{% static 'reactpy_django/pyscript/core.css' %}" />
3+
<script type="module" src="{% static 'reactpy_django/pyscript/core.js' %}"></script>
4+
{% if reactpy_class %}<div id="{{reactpy_uuid}}" class="{{reactpy_class}}">{{reactpy_initial_html}}</div>{% endif %}
5+
{% if not reactpy_class %}<div id="{{reactpy_uuid}}">{{reactpy_initial_html}}</div>{% endif %}
6+
<py-script async config='{{ reactpy_config }}'>{{ reactpy_executor }}</py-script>

src/reactpy_django/templatetags/reactpy.py

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
from __future__ import annotations
22

3+
import textwrap
34
from logging import getLogger
5+
from pathlib import Path
46
from uuid import uuid4
57

68
import dill as pickle
9+
import jsonpointer
10+
import orjson
11+
import reactpy
712
from django import template
813
from django.http import HttpRequest
914
from django.urls import NoReverseMatch, reverse
1015
from reactpy.backend.hooks import ConnectionContext
1116
from reactpy.backend.types import Connection, Location
12-
from reactpy.core.types import ComponentConstructor
17+
from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict
1318
from reactpy.utils import vdom_to_html
1419

15-
from reactpy_django import config, models
20+
from reactpy_django import config as reactpy_config
21+
from reactpy_django import models, pyscript
1622
from reactpy_django.exceptions import (
1723
ComponentCarrierError,
1824
ComponentDoesNotExistError,
@@ -30,14 +36,18 @@
3036
register = template.Library()
3137
_logger = getLogger(__name__)
3238

39+
pyscript_template = (Path(pyscript.__file__).parent / "executor.py").read_text(
40+
encoding="utf-8"
41+
)
42+
3343

3444
@register.inclusion_tag("reactpy/component.html", takes_context=True)
3545
def component(
3646
context: template.RequestContext,
3747
dotted_path: str,
3848
*args,
3949
host: str | None = None,
40-
prerender: str = str(config.REACTPY_PRERENDER),
50+
prerender: str = str(reactpy_config.REACTPY_PRERENDER),
4151
offline: str = "",
4252
**kwargs,
4353
):
@@ -73,7 +83,11 @@ def component(
7383
perceived_host = (request.get_host() if request else "").strip("/")
7484
host = (
7585
host
76-
or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "")
86+
or (
87+
next(reactpy_config.REACTPY_DEFAULT_HOSTS)
88+
if reactpy_config.REACTPY_DEFAULT_HOSTS
89+
else ""
90+
)
7791
).strip("/")
7892
is_local = not host or host.startswith(perceived_host)
7993
uuid = str(uuid4())
@@ -84,22 +98,22 @@ def component(
8498
_offline_html = ""
8599

86100
# Validate the host
87-
if host and config.REACTPY_DEBUG_MODE:
101+
if host and reactpy_config.REACTPY_DEBUG_MODE:
88102
try:
89103
validate_host(host)
90104
except InvalidHostError as e:
91105
return failure_context(dotted_path, e)
92106

93107
# Fetch the component
94108
if is_local:
95-
user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path)
109+
user_component = reactpy_config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path)
96110
if not user_component:
97111
msg = f"Component '{dotted_path}' is not registered as a root component. "
98112
_logger.error(msg)
99113
return failure_context(dotted_path, ComponentDoesNotExistError(msg))
100114

101115
# Validate the component args & kwargs
102-
if is_local and config.REACTPY_DEBUG_MODE:
116+
if is_local and reactpy_config.REACTPY_DEBUG_MODE:
103117
try:
104118
validate_component_args(user_component, *args, **kwargs)
105119
except ComponentParamError as e:
@@ -140,7 +154,7 @@ def component(
140154

141155
# Fetch the offline component's HTML, if requested
142156
if offline:
143-
offline_component = config.REACTPY_REGISTERED_COMPONENTS.get(offline)
157+
offline_component = reactpy_config.REACTPY_REGISTERED_COMPONENTS.get(offline)
144158
if not offline_component:
145159
msg = f"Cannot render offline component '{offline}'. It is not registered as a component."
146160
_logger.error(msg)
@@ -159,13 +173,13 @@ def component(
159173
"reactpy_class": class_,
160174
"reactpy_uuid": uuid,
161175
"reactpy_host": host or perceived_host,
162-
"reactpy_url_prefix": config.REACTPY_URL_PREFIX,
176+
"reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX,
163177
"reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/",
164178
"reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH,
165-
"reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL,
166-
"reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL,
167-
"reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
168-
"reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES,
179+
"reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL,
180+
"reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL,
181+
"reactpy_reconnect_backoff_multiplier": reactpy_config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER,
182+
"reactpy_reconnect_max_retries": reactpy_config.REACTPY_RECONNECT_MAX_RETRIES,
169183
"reactpy_prerender_html": _prerender_html,
170184
"reactpy_offline_html": _offline_html,
171185
}
@@ -174,7 +188,7 @@ def component(
174188
def failure_context(dotted_path: str, error: Exception):
175189
return {
176190
"reactpy_failure": True,
177-
"reactpy_debug_mode": config.REACTPY_DEBUG_MODE,
191+
"reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE,
178192
"reactpy_dotted_path": dotted_path,
179193
"reactpy_error": type(error).__name__,
180194
}
@@ -219,3 +233,57 @@ def prerender_component(
219233
vdom_tree = layout.render()["model"]
220234

221235
return vdom_to_html(vdom_tree)
236+
237+
238+
# TODO: Add micropython support
239+
@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True)
240+
def pyscript_component(
241+
context: template.RequestContext,
242+
file_path: str,
243+
*extra_packages: str,
244+
initial: str | VdomDict | ComponentType = "",
245+
config: str | dict = "",
246+
root: str = "root",
247+
):
248+
uuid = uuid4().hex
249+
request: HttpRequest | None = context.get("request")
250+
pyscript_config = {
251+
"packages": [
252+
f"reactpy=={reactpy.__version__}",
253+
f"jsonpointer=={jsonpointer.__version__}",
254+
"ssl",
255+
*extra_packages,
256+
]
257+
}
258+
if config and isinstance(config, str):
259+
pyscript_config.update(orjson.loads(config))
260+
elif isinstance(config, dict):
261+
pyscript_config.update(config)
262+
263+
# Convert the user provided initial HTML to a string, if needed
264+
if isinstance(initial, dict):
265+
initial = vdom_to_html(initial)
266+
elif hasattr(initial, "render"):
267+
if not request:
268+
raise ValueError(
269+
"Cannot render a component without a HTTP request. Are you missing the request "
270+
"context processor in settings.py:TEMPLATES['OPTIONS']['context_processors']?"
271+
)
272+
initial = prerender_component(initial, [], {}, uuid, request)
273+
274+
# Create a valid PyScript executor by replacing the template values
275+
executor = pyscript_template.replace("UUID", uuid)
276+
executor = executor.replace("return root()", f"return {root}()")
277+
278+
# Insert the user code into the template
279+
user_code = Path(file_path).read_text(encoding="utf-8")
280+
user_code = user_code.strip().replace("\t", " ") # Normalize the code text
281+
user_code = textwrap.indent(user_code, " ") # Add indentation to match template
282+
executor = executor.replace(" def root(): ...", user_code)
283+
284+
return {
285+
"reactpy_executor": executor,
286+
"reactpy_uuid": uuid,
287+
"reactpy_initial_html": initial,
288+
"reactpy_config": orjson.dumps(pyscript_config).decode(),
289+
}

tests/test_app/pyscript/__init__.py

Whitespace-only changes.

tests/test_app/pyscript/components/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from reactpy import component, html, use_state
2+
3+
4+
@component
5+
def root():
6+
value, set_value = use_state(0)
7+
return html.article(
8+
html.div(
9+
{"class": "grid"},
10+
html.button({"on_click": lambda event: set_value(value + 1)}, "+"),
11+
html.button({"on_click": lambda event: set_value(value - 1)}, "-"),
12+
),
13+
"Current value",
14+
html.pre({"style": {"font-style": "bold"}}, str(value)),
15+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from reactpy import component, html
2+
3+
4+
@component
5+
def root():
6+
return html.div("hello world")

tests/test_app/pyscript/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import re_path
2+
3+
from test_app.pyscript.views import pyscript
4+
5+
urlpatterns = [
6+
re_path(r"^pyscript/(?P<path>.*)/?$", pyscript),
7+
]

tests/test_app/pyscript/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.shortcuts import render
2+
3+
4+
def pyscript(request, path=None):
5+
return render(request, "pyscript.html", {})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% load static %} {% load reactpy %}
2+
<!DOCTYPE html>
3+
<html lang="en">
4+
5+
<head>
6+
<meta charset="UTF-8" />
7+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}" />
10+
<title>ReactPy</title>
11+
</head>
12+
13+
<body>
14+
<h1>ReactPy PyScript Test Page</h1>
15+
<hr>
16+
{% pyscript_component "./test_app/pyscript/components/hello_world.py" %}
17+
<hr>
18+
{% pyscript_component "./test_app/pyscript/components/counter.py" %}
19+
<hr>
20+
</body>
21+
22+
</html>

tests/test_app/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
1. Import the include() function: from django.urls import include, path
1515
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
1616
"""
17+
1718
from django.contrib import admin
1819
from django.urls import include, path
1920

@@ -30,6 +31,7 @@
3031
path("", include("test_app.prerender.urls")),
3132
path("", include("test_app.performance.urls")),
3233
path("", include("test_app.router.urls")),
34+
path("", include("test_app.pyscript.urls")),
3335
path("", include("test_app.offline.urls")),
3436
path("", include("test_app.channel_layers.urls")),
3537
path("reactpy/", include("reactpy_django.http.urls")),

0 commit comments

Comments
 (0)