Skip to content

Commit 58a4e37

Browse files
committed
First draft of pyscript support
1 parent 49bdda1 commit 58a4e37

File tree

8 files changed

+398
-4
lines changed

8 files changed

+398
-4
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ readme = "README.md"
1313
keywords = ["react", "javascript", "reactpy", "component"]
1414
license = "MIT"
1515
authors = [
16-
{ name = "Ryan Morshead", email = "ryan.morshead@gmail.com" },
1716
{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" },
17+
{ name = "Ryan Morshead", email = "ryan.morshead@gmail.com" },
1818
]
1919
requires-python = ">=3.9"
2020
classifiers = [

src/reactpy/_html.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ def __getattr__(self, value: str) -> VdomDictConstructor:
406406
video: VdomDictConstructor
407407
wbr: VdomDictConstructor
408408
fragment: VdomDictConstructor
409+
py_script: VdomDictConstructor
409410

410411
# Special Case: SVG elements
411412
# Since SVG elements have a different set of allowed children, they are

src/reactpy/jinja.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
from jinja2_simple_tags import StandaloneTag
55

6+
from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX
7+
from reactpy.pyscript.utils import (
8+
PYSCRIPT_LAYOUT_HANDLER,
9+
extend_pyscript_config,
10+
render_pyscript_template,
11+
)
612
from reactpy.utils import render_mount_template
713

814

915
class Component(StandaloneTag): # type: ignore
10-
"""This allows enables a `component` tag to be used in any Jinja2 rendering context,
11-
as long as this template tag is registered as a Jinja2 extension."""
12-
1316
safe_output = True
1417
tags: ClassVar[set[str]] = {"component"}
1518

@@ -19,3 +22,47 @@ def render(self, dotted_path: str, **kwargs: str) -> str:
1922
class_=kwargs.pop("class", ""),
2023
append_component_path=f"{dotted_path}/",
2124
)
25+
26+
27+
class PyScriptComponent(StandaloneTag): # type: ignore
28+
safe_output = True
29+
tags: ClassVar[set[str]] = {"pyscript_component"}
30+
31+
def render(self, *file_paths: str, initial: str = "", root: str = "root") -> str:
32+
return render_pyscript_template(
33+
file_paths=file_paths, initial=initial, root=root
34+
)
35+
36+
37+
class PyScriptSetup(StandaloneTag): # type: ignore
38+
safe_output = True
39+
tags: ClassVar[set[str]] = {"pyscript_setup"}
40+
41+
def render(
42+
self, *extra_py: str, extra_js: str | dict = "", config: str | dict = ""
43+
) -> str:
44+
"""
45+
Args:
46+
extra_py: Dependencies that need to be loaded on the page for \
47+
your PyScript components. Each dependency must be contained \
48+
within it's own string and written in Python requirements file syntax.
49+
50+
Kwargs:
51+
extra_js: A JSON string or Python dictionary containing a vanilla \
52+
JavaScript module URL and the `name: str` to access it within \
53+
`pyscript.js_modules.*`.
54+
config: A JSON string or Python dictionary containing PyScript \
55+
configuration values.
56+
"""
57+
58+
hide_pyscript_debugger = f'<link rel="stylesheet" href="{REACTPY_PATH_PREFIX.current}static/pyscript-hide-debug.css" />'
59+
pyscript_config = extend_pyscript_config(extra_py, extra_js, config)
60+
61+
return (
62+
f'<link rel="stylesheet" href="{REACTPY_PATH_PREFIX.current}static/pyscript/core.css" />'
63+
f'<link rel="stylesheet" href="{REACTPY_PATH_PREFIX.current}static/pyscript-custom.css" />'
64+
f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}"
65+
f'<script type="module" async crossorigin="anonymous" src="{REACTPY_PATH_PREFIX.current}static/pyscript/core.js">'
66+
"</script>"
67+
f'<py-script async config="{pyscript_config}">{PYSCRIPT_LAYOUT_HANDLER}</py-script>'
68+
)

src/reactpy/pyscript/__init__.py

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# ruff: noqa: TC004, N802, N816, RUF006
2+
from typing import TYPE_CHECKING
3+
4+
if TYPE_CHECKING:
5+
import asyncio
6+
7+
from reactpy.pyscript.layout_handler import ReactPyLayoutHandler
8+
9+
10+
# User component is inserted below by regex replacement
11+
def user_workspace_UUID():
12+
"""Encapsulate the user's code with a completely unique function (workspace)
13+
to prevent overlapping imports and variable names between different components.
14+
15+
This code is designed to be run directly by PyScript, and is not intended to be run
16+
in a normal Python environment.
17+
18+
ReactPy-Django performs string substitutions to turn this file into valid PyScript.
19+
"""
20+
21+
def root(): ...
22+
23+
return root()
24+
25+
26+
# Create a task to run the user's component workspace
27+
task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID))

src/reactpy/pyscript/components.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
from uuid import uuid4
5+
6+
from reactpy import component, hooks, html
7+
from reactpy.pyscript.utils import render_pyscript_executor
8+
from reactpy.types import ComponentType
9+
from reactpy.utils import vdom_to_html
10+
11+
if TYPE_CHECKING:
12+
from reactpy.types import VdomDict
13+
14+
15+
@component
16+
def _pyscript_component(
17+
*file_paths: str,
18+
initial: str | VdomDict = "",
19+
root: str = "root",
20+
):
21+
if not file_paths:
22+
raise ValueError("At least one file path must be provided.")
23+
24+
rendered, set_rendered = hooks.use_state(False)
25+
uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
26+
initial = initial if isinstance(initial, str) else vdom_to_html(initial)
27+
executor = render_pyscript_executor(file_paths=file_paths, uuid=uuid, root=root)
28+
29+
if not rendered:
30+
# FIXME: This is needed to properly re-render PyScript during a WebSocket
31+
# disconnection / reconnection. There may be a better way to do this in the future.
32+
set_rendered(True)
33+
return None
34+
35+
return html.fragment(
36+
html.div(
37+
{"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid},
38+
initial,
39+
),
40+
html.py_script({"async": ""}, executor),
41+
)
42+
43+
44+
def pyscript_component(
45+
*file_paths: str,
46+
initial: str | VdomDict | ComponentType = "",
47+
root: str = "root",
48+
) -> ComponentType:
49+
"""
50+
Args:
51+
file_paths: File path to your client-side ReactPy component. If multiple paths are \
52+
provided, the contents are automatically merged.
53+
54+
Kwargs:
55+
initial: The initial HTML that is displayed prior to the PyScript component \
56+
loads. This can either be a string containing raw HTML, a \
57+
`#!python reactpy.html` snippet, or a non-interactive component.
58+
root: The name of the root component function.
59+
"""
60+
return _pyscript_component(
61+
*file_paths,
62+
initial=initial,
63+
root=root,
64+
)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# type: ignore
2+
import asyncio
3+
import logging
4+
5+
from jsonpointer import set_pointer
6+
from pyodide.ffi.wrappers import add_event_listener
7+
from pyscript.js_modules import morphdom
8+
9+
import js
10+
from reactpy.core.layout import Layout
11+
12+
13+
class ReactPyLayoutHandler:
14+
"""Encapsulate the entire layout handler with a class to prevent overlapping
15+
variable names between user code.
16+
17+
This code is designed to be run directly by PyScript, and is not intended to be run
18+
in a normal Python environment.
19+
"""
20+
21+
def __init__(self, uuid):
22+
self.uuid = uuid
23+
self.running_tasks = set()
24+
25+
@staticmethod
26+
def update_model(update, root_model):
27+
"""Apply an update ReactPy's internal DOM model."""
28+
if update["path"]:
29+
set_pointer(root_model, update["path"], update["model"])
30+
else:
31+
root_model.update(update["model"])
32+
33+
def render_html(self, layout, model):
34+
"""Submit ReactPy's internal DOM model into the HTML DOM."""
35+
# Create a new container to render the layout into
36+
container = js.document.getElementById(f"pyscript-{self.uuid}")
37+
temp_root_container = container.cloneNode(False)
38+
self.build_element_tree(layout, temp_root_container, model)
39+
40+
# Use morphdom to update the DOM
41+
morphdom.default(container, temp_root_container)
42+
43+
# Remove the cloned container to prevent memory leaks
44+
temp_root_container.remove()
45+
46+
def build_element_tree(self, layout, parent, model):
47+
"""Recursively build an element tree, starting from the root component."""
48+
# If the model is a string, add it as a text node
49+
if isinstance(model, str):
50+
parent.appendChild(js.document.createTextNode(model))
51+
52+
# If the model is a VdomDict, construct an element
53+
elif isinstance(model, dict):
54+
# If the model is a fragment, build the children
55+
if not model["tagName"]:
56+
for child in model.get("children", []):
57+
self.build_element_tree(layout, parent, child)
58+
return
59+
60+
# Otherwise, get the VdomDict attributes
61+
tag = model["tagName"]
62+
attributes = model.get("attributes", {})
63+
children = model.get("children", [])
64+
element = js.document.createElement(tag)
65+
66+
# Set the element's HTML attributes
67+
for key, value in attributes.items():
68+
if key == "style":
69+
for style_key, style_value in value.items():
70+
setattr(element.style, style_key, style_value)
71+
elif key == "className":
72+
element.className = value
73+
else:
74+
element.setAttribute(key, value)
75+
76+
# Add event handlers to the element
77+
for event_name, event_handler_model in model.get(
78+
"eventHandlers", {}
79+
).items():
80+
self.create_event_handler(
81+
layout, element, event_name, event_handler_model
82+
)
83+
84+
# Recursively build the children
85+
for child in children:
86+
self.build_element_tree(layout, element, child)
87+
88+
# Append the element to the parent
89+
parent.appendChild(element)
90+
91+
# Unknown data type provided
92+
else:
93+
msg = f"Unknown model type: {type(model)}"
94+
raise TypeError(msg)
95+
96+
def create_event_handler(self, layout, element, event_name, event_handler_model):
97+
"""Create an event handler for an element. This function is used as an
98+
adapter between ReactPy and browser events."""
99+
target = event_handler_model["target"]
100+
101+
def event_handler(*args):
102+
# When the event is triggered, deliver the event to the `Layout` within a background task
103+
task = asyncio.create_task(
104+
layout.deliver({"type": "layout-event", "target": target, "data": args})
105+
)
106+
# Store the task to prevent automatic garbage collection from killing it
107+
self.running_tasks.add(task)
108+
task.add_done_callback(self.running_tasks.remove)
109+
110+
add_event_listener(element, event_name, event_handler)
111+
112+
@staticmethod
113+
def delete_old_workspaces():
114+
"""To prevent memory leaks, we must delete all user generated Python code when
115+
it is no longer in use (removed from the page). To do this, we compare what
116+
UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global
117+
interpreter."""
118+
# Find all PyScript workspaces that are still on the page
119+
dom_workspaces = js.document.querySelectorAll(".pyscript")
120+
dom_uuids = {element.dataset.uuid for element in dom_workspaces}
121+
python_uuids = {
122+
value.split("_")[-1]
123+
for value in globals()
124+
if value.startswith("user_workspace_")
125+
}
126+
127+
# Delete any workspaces that are no longer in use
128+
for uuid in python_uuids - dom_uuids:
129+
task_name = f"task_{uuid}"
130+
if task_name in globals():
131+
task: asyncio.Task = globals()[task_name]
132+
task.cancel()
133+
del globals()[task_name]
134+
else:
135+
logging.error("Could not auto delete PyScript task %s", task_name)
136+
137+
workspace_name = f"user_workspace_{uuid}"
138+
if workspace_name in globals():
139+
del globals()[workspace_name]
140+
else:
141+
logging.error(
142+
"Could not auto delete PyScript workspace %s", workspace_name
143+
)
144+
145+
async def run(self, workspace_function):
146+
"""Run the layout handler. This function is main executor for all user generated code."""
147+
self.delete_old_workspaces()
148+
root_model: dict = {}
149+
150+
async with Layout(workspace_function()) as root_layout:
151+
while True:
152+
update = await root_layout.render()
153+
self.update_model(update, root_model)
154+
self.render_html(root_layout, root_model)

0 commit comments

Comments
 (0)