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: "" "" ) - - self._etag = f'"{hashlib.md5(self._cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' + self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"' self._last_modified = formatdate( datetime.now(tz=timezone.utc).timestamp(), usegmt=True ) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 94f85687c..8b3a8143c 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -10,8 +10,8 @@ import uvicorn from asgiref import typing as asgi_types +from reactpy.asgi.executors.standalone import ReactPy from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.standalone import ReactPy from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.core.component import component from reactpy.core.hooks import use_callback, use_effect, use_state diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 8d5fdee45..64e81797b 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -8,7 +8,7 @@ import reactpy from reactpy import html -from reactpy.asgi.standalone import ReactPy +from reactpy.asgi.executors.standalone import ReactPy from reactpy.testing import BackendFixture, DisplayFixture, poll from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.types import Connection, Location diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 6693a5301..badd39740 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -4,7 +4,7 @@ from servestatic import ServeStaticASGI import reactpy -from reactpy.asgi.standalone import ReactPy +from reactpy.asgi.executors.standalone import ReactPy from reactpy.testing import ( BackendFixture, DisplayFixture, From 9dccf7351e5f2d056a188d780730de0078d9f2b8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:42:26 -0800 Subject: [PATCH 03/36] prototype pysript executor --- src/js/packages/@reactpy/client/src/mount.tsx | 2 +- src/js/packages/@reactpy/client/src/types.ts | 2 +- src/reactpy/asgi/executors/pyscript.py | 95 +++++++++++++++++++ src/reactpy/asgi/executors/standalone.py | 8 +- src/reactpy/jinja.py | 26 ++--- src/reactpy/pyscript/layout_handler.py | 2 +- src/reactpy/pyscript/utils.py | 23 ++++- src/reactpy/utils.py | 6 +- 8 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 src/reactpy/asgi/executors/pyscript.py diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 820bc0631..55ba4245e 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -8,7 +8,7 @@ export function mountReactPy(props: MountProps) { const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; const wsOrigin = `${wsProtocol}//${window.location.host}`; const componentUrl = new URL( - `${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`, + `${wsOrigin}${props.pathPrefix}${props.componentPath || ""}`, ); // Embed the initial HTTP path into the WebSocket URL diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 0792b3586..3c0330a07 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -35,7 +35,7 @@ export type GenericReactPyClientProps = { export type MountProps = { mountElement: HTMLElement; pathPrefix: string; - appendComponentPath?: string; + componentPath?: string; reconnectInterval?: number; reconnectMaxInterval?: number; reconnectMaxRetries?: number; diff --git a/src/reactpy/asgi/executors/pyscript.py b/src/reactpy/asgi/executors/pyscript.py new file mode 100644 index 000000000..74c2a5d48 --- /dev/null +++ b/src/reactpy/asgi/executors/pyscript.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import formatdate +from pathlib import Path + +from typing_extensions import Unpack + +from reactpy import html +from reactpy.asgi.executors.standalone import ReactPy, ReactPyApp +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.asgi.utils import vdom_head_to_html +from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html +from reactpy.types import ( + ReactPyConfig, + VdomDict, +) + + +class ReactPyCSR(ReactPy): + def __init__( + self, + *component_paths: str | Path, + extra_py: tuple[str, ...] = (), + extra_js: dict | str = "", + pyscript_config: dict | str = "", + root_name: str = "root", + initial: str | VdomDict = "", + http_headers: dict[str, str] | None = None, + html_head: VdomDict | None = None, + html_lang: str = "en", + **settings: Unpack[ReactPyConfig], + ) -> None: + """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR). + + Parameters: + ... + """ + ReactPyMiddleware.__init__( + self, app=ReactPyAppCSR(self), root_components=[], **settings + ) + if not component_paths: + raise ValueError("At least one component file path must be provided.") + self.component_paths = tuple(str(path) for path in component_paths) + self.extra_py = extra_py + self.extra_js = extra_js + self.pyscript_config = pyscript_config + self.root_name = root_name + self.initial = initial + self.extra_headers = http_headers or {} + self.dispatcher_pattern = re.compile(f"^{self.dispatcher_path}?") + self.html_head = html_head or html.head() + self.html_lang = html_lang + + +@dataclass +class ReactPyAppCSR(ReactPyApp): + """ReactPy's standalone ASGI application for Client-Side Rendering (CSR).""" + + parent: ReactPyCSR + _index_html = "" + _etag = "" + _last_modified = "" + + def render_index_html(self) -> None: + """Process the index.html and store the results in this class.""" + head_content = vdom_head_to_html(self.parent.html_head) + pyscript_setup = pyscript_setup_html( + extra_py=self.parent.extra_py, + extra_js=self.parent.extra_js, + config=self.parent.pyscript_config, + ) + pyscript_component = pyscript_component_html( + file_paths=self.parent.component_paths, + initial=self.parent.initial, + root=self.parent.root_name, + ) + head_content.replace("", f"{pyscript_setup}") + + self._index_html = ( + "" + f'' + f"{head_content}" + "" + f"{pyscript_component}" + "" + "" + ) + self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"' + self._last_modified = formatdate( + datetime.now(tz=timezone.utc).timestamp(), usegmt=True + ) diff --git a/src/reactpy/asgi/executors/standalone.py b/src/reactpy/asgi/executors/standalone.py index 5dfff7f97..e4773216f 100644 --- a/src/reactpy/asgi/executors/standalone.py +++ b/src/reactpy/asgi/executors/standalone.py @@ -24,7 +24,7 @@ RootComponentConstructor, VdomDict, ) -from reactpy.utils import render_mount_template +from reactpy.utils import asgi_component_html _logger = getLogger(__name__) @@ -174,7 +174,7 @@ async def __call__( # Store the HTTP response in memory for performance if not self._index_html: - self.render_index_template() + self.render_index_html() # Response headers for `index.html` responses request_headers = dict(scope["headers"]) @@ -206,14 +206,14 @@ async def __call__( response = ResponseHTML(self._index_html, headers=response_headers) await response(scope, receive, send) # type: ignore - def render_index_template(self) -> None: + def render_index_html(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)}" "" - f"{render_mount_template('app', '', '')}" + f"{asgi_component_html(element_id='app', class_='', component_path='')}" "" "" ) diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 6abb8e378..4cd2493df 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -3,13 +3,11 @@ 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, + pyscript_component_html, + pyscript_setup_html, ) -from reactpy.utils import render_mount_template +from reactpy.utils import asgi_component_html class Component(StandaloneTag): # type: ignore @@ -17,10 +15,10 @@ class Component(StandaloneTag): # type: ignore tags: ClassVar[set[str]] = {"component"} def render(self, dotted_path: str, **kwargs: str) -> str: - return render_mount_template( + return asgi_component_html( element_id=uuid4().hex, class_=kwargs.pop("class", ""), - append_component_path=f"{dotted_path}/", + component_path=f"{dotted_path}/", ) @@ -29,7 +27,7 @@ class PyScriptComponent(StandaloneTag): # type: ignore tags: ClassVar[set[str]] = {"pyscript_component"} def render(self, *file_paths: str, initial: str = "", root: str = "root") -> str: - return render_pyscript_template( + return pyscript_component_html( file_paths=file_paths, initial=initial, root=root ) @@ -55,14 +53,4 @@ def render( 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}' - ) + return pyscript_setup_html(extra_py=extra_py, extra_js=extra_js, config=config) diff --git a/src/reactpy/pyscript/layout_handler.py b/src/reactpy/pyscript/layout_handler.py index 1794e4188..09b7452a2 100644 --- a/src/reactpy/pyscript/layout_handler.py +++ b/src/reactpy/pyscript/layout_handler.py @@ -2,11 +2,11 @@ import asyncio import logging +import js 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 diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index ed7508011..e0e2e4fb1 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -4,14 +4,14 @@ import json import textwrap from pathlib import Path -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from uuid import uuid4 import jsonpointer import orjson import reactpy -from reactpy.config import REACTPY_PATH_PREFIX +from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX from reactpy.types import VdomDict from reactpy.utils import vdom_to_html @@ -46,7 +46,7 @@ def render_pyscript_executor(file_paths: tuple[str, ...], uuid: str, root: str) return executor.replace(" def root(): ...", user_code) -def render_pyscript_template( +def pyscript_component_html( file_paths: tuple[str, ...], initial: str | VdomDict, root: str ) -> str: """Renders a PyScript component with the user's code.""" @@ -64,6 +64,23 @@ def render_pyscript_template( ) +def pyscript_setup_html( + extra_py: Sequence[str], extra_js: dict | str, config: dict | str +) -> str: + """Renders the PyScript setup code.""" + 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}' + ) + + def extend_pyscript_config( extra_py: Sequence[str], extra_js: dict | str, config: dict | str ) -> str: diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 30495d6c1..adb7d07d4 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -316,9 +316,7 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str: +def asgi_component_html(element_id: str, class_: str, component_path: str) -> str: return ( f'
' '" - f'{PYSCRIPT_LAYOUT_HANDLER}' + f"{PYSCRIPT_LAYOUT_HANDLER}" ) diff --git a/src/reactpy/static/pyscript-custom.css b/src/reactpy/static/pyscript-custom.css new file mode 100644 index 000000000..5793fd52f --- /dev/null +++ b/src/reactpy/static/pyscript-custom.css @@ -0,0 +1,3 @@ +py-script { + display: none; +} diff --git a/src/reactpy/static/pyscript-hide-debug.css b/src/reactpy/static/pyscript-hide-debug.css new file mode 100644 index 000000000..9cd8541e4 --- /dev/null +++ b/src/reactpy/static/pyscript-hide-debug.css @@ -0,0 +1,3 @@ +.py-error { + display: none; +} From 02d114c8c323bcbb081975cd52d8e8a446160047 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:38:58 -0800 Subject: [PATCH 05/36] Fix pyscript event handling --- src/reactpy/pyscript/layout_handler.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/reactpy/pyscript/layout_handler.py b/src/reactpy/pyscript/layout_handler.py index 09b7452a2..744b7b0c1 100644 --- a/src/reactpy/pyscript/layout_handler.py +++ b/src/reactpy/pyscript/layout_handler.py @@ -2,11 +2,11 @@ import asyncio import logging -import js from jsonpointer import set_pointer from pyodide.ffi.wrappers import add_event_listener -from pyscript.js_modules import morphdom +import js +from pyscript.js_modules import morphdom from reactpy.core.layout import Layout @@ -107,6 +107,11 @@ def event_handler(*args): self.running_tasks.add(task) task.add_done_callback(self.running_tasks.remove) + # Convert ReactJS-style event names to HTML event names + event_name = event_name.lower() + if event_name.startswith("on"): + event_name = event_name[2:] + add_event_listener(element, event_name, event_handler) @staticmethod From f30a0410bb72e3e4de8e0731180f47e3d57f5619 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:02:15 -0800 Subject: [PATCH 06/36] Resolve type checker warnings --- src/reactpy/asgi/executors/pyscript.py | 5 +++-- src/reactpy/asgi/middleware.py | 9 +++++++-- src/reactpy/jinja.py | 7 +++++-- src/reactpy/pyscript/component_template.py | 1 + src/reactpy/pyscript/components.py | 2 +- src/reactpy/pyscript/utils.py | 12 ++++++++---- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/reactpy/asgi/executors/pyscript.py b/src/reactpy/asgi/executors/pyscript.py index 6b9c6fb04..9677f0980 100644 --- a/src/reactpy/asgi/executors/pyscript.py +++ b/src/reactpy/asgi/executors/pyscript.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from email.utils import formatdate from pathlib import Path +from typing import Any from asgiref.typing import WebSocketScope from typing_extensions import Unpack @@ -26,8 +27,8 @@ def __init__( self, *component_paths: str | Path, extra_py: tuple[str, ...] = (), - extra_js: dict | str = "", - pyscript_config: dict | str = "", + extra_js: dict[str, Any] | str = "", + pyscript_config: dict[str, Any] | str = "", root_name: str = "root", initial: str | VdomDict = "", http_headers: dict[str, str] | None = None, diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index a4fcd9977..42c4f9390 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -293,6 +293,11 @@ async def __call__( class Error404App: - async def __call__(self, scope, receive, send): + async def __call__( + self, + scope: asgi_types.HTTPScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + ) -> None: response = ResponseText("Resource not found on this server.", status_code=404) - await response(scope, receive, send) + await response(scope, receive, send) # type: ignore diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py index 4cd2493df..c60838298 100644 --- a/src/reactpy/jinja.py +++ b/src/reactpy/jinja.py @@ -1,4 +1,4 @@ -from typing import ClassVar +from typing import Any, ClassVar from uuid import uuid4 from jinja2_simple_tags import StandaloneTag @@ -37,7 +37,10 @@ class PyScriptSetup(StandaloneTag): # type: ignore tags: ClassVar[set[str]] = {"pyscript_setup"} def render( - self, *extra_py: str, extra_js: str | dict = "", config: str | dict = "" + self, + *extra_py: str, + extra_js: str | dict[str, Any] = "", + config: str | dict[str, Any] = "", ) -> str: """ Args: diff --git a/src/reactpy/pyscript/component_template.py b/src/reactpy/pyscript/component_template.py index e952845d6..47bf4d6a3 100644 --- a/src/reactpy/pyscript/component_template.py +++ b/src/reactpy/pyscript/component_template.py @@ -1,4 +1,5 @@ # ruff: noqa: TC004, N802, N816, RUF006 +# type: ignore from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py index dad4c4542..c892d5cb3 100644 --- a/src/reactpy/pyscript/components.py +++ b/src/reactpy/pyscript/components.py @@ -17,7 +17,7 @@ def _pyscript_component( *file_paths: str, initial: str | VdomDict = "", root: str = "root", -): +) -> None | VdomDict: if not file_paths: raise ValueError("At least one file path must be provided.") diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 1286d5b13..15c9052f8 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -4,7 +4,7 @@ import json import textwrap from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import uuid4 import jsonpointer @@ -65,7 +65,9 @@ def pyscript_component_html( def pyscript_setup_html( - extra_py: Sequence[str], extra_js: dict | str, config: dict | str + extra_py: Sequence[str], + extra_js: dict[str, Any] | str, + config: dict[str, Any] | str, ) -> str: """Renders the PyScript setup code.""" hide_pyscript_debugger = f'' @@ -82,10 +84,12 @@ def pyscript_setup_html( def extend_pyscript_config( - extra_py: Sequence[str], extra_js: dict | str, config: dict | str + extra_py: Sequence[str], + extra_js: dict[str, Any] | str, + config: dict[str, Any] | str, ) -> str: # Extends ReactPy's default PyScript config with user provided values. - pyscript_config = { + pyscript_config: dict[str, Any] = { "packages": [ f"reactpy=={reactpy.__version__}", f"jsonpointer=={jsonpointer.__version__}", From e6136a0cd4b683af52d99766e777cee24094fe0e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 03:32:08 -0800 Subject: [PATCH 07/36] move jinja template tag module --- docs/source/about/changelog.rst | 2 +- src/reactpy/jinja.py | 59 ---------------------------- src/reactpy/templatetags/__init__.py | 3 ++ src/reactpy/templatetags/jinja.py | 44 +++++++++++++++++++++ 4 files changed, 48 insertions(+), 60 deletions(-) delete mode 100644 src/reactpy/jinja.py create mode 100644 src/reactpy/templatetags/__init__.py create mode 100644 src/reactpy/templatetags/jinja.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 848153251..6a58d0978 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -18,7 +18,7 @@ Unreleased **Added** - :pull:`1113` - Added ``reactpy.ReactPy`` that can be used to run ReactPy in standalone mode. - :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. -- :pull:`1113` - Added ``reactpy.jinja.Component`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. +- :pull:`1113` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. - :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. - :pull:`1264` - Added ``reactpy.use_async_effect`` hook. diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py deleted file mode 100644 index c60838298..000000000 --- a/src/reactpy/jinja.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any, ClassVar -from uuid import uuid4 - -from jinja2_simple_tags import StandaloneTag - -from reactpy.pyscript.utils import ( - pyscript_component_html, - pyscript_setup_html, -) -from reactpy.utils import asgi_component_html - - -class Component(StandaloneTag): # type: ignore - safe_output = True - tags: ClassVar[set[str]] = {"component"} - - def render(self, dotted_path: str, **kwargs: str) -> str: - return asgi_component_html( - element_id=uuid4().hex, - class_=kwargs.pop("class", ""), - 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 pyscript_component_html( - 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[str, Any] = "", - config: str | dict[str, Any] = "", - ) -> 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. - """ - - return pyscript_setup_html(extra_py=extra_py, extra_js=extra_js, config=config) diff --git a/src/reactpy/templatetags/__init__.py b/src/reactpy/templatetags/__init__.py new file mode 100644 index 000000000..c6f792d27 --- /dev/null +++ b/src/reactpy/templatetags/__init__.py @@ -0,0 +1,3 @@ +from reactpy.templatetags.jinja import Jinja + +__all__ = ["Jinja"] diff --git a/src/reactpy/templatetags/jinja.py b/src/reactpy/templatetags/jinja.py new file mode 100644 index 000000000..a921f726a --- /dev/null +++ b/src/reactpy/templatetags/jinja.py @@ -0,0 +1,44 @@ +from typing import ClassVar +from uuid import uuid4 + +from jinja2_simple_tags import StandaloneTag + +from reactpy.pyscript.utils import ( + pyscript_component_html, + pyscript_setup_html, +) +from reactpy.utils import asgi_component_html + + +class Jinja(StandaloneTag): # type: ignore + safe_output = True + tags: ClassVar[set[str]] = {"component", "pyscript_component", "pyscript_setup"} + + def render(self, *args: str, **kwargs: str) -> str: + if self.tag_name == "component": + return component(*args, **kwargs) + + if self.tag_name == "pyscript_component": + return pyscript_component(*args, **kwargs) + + if self.tag_name == "pyscript_setup": + return pyscript_setup(*args, **kwargs) + + raise ValueError(f"Unknown tag: {self.tag_name}") + + +def component(dotted_path: str, **kwargs: str) -> str: + class_ = kwargs.pop("class", "") + if kwargs: + raise ValueError(f"Unexpected keyword arguments: {', '.join(kwargs)}") + return asgi_component_html( + element_id=uuid4().hex, class_=class_, component_path=f"{dotted_path}/" + ) + + +def pyscript_component(*file_paths: str, initial: str = "", root: str = "root") -> str: + return pyscript_component_html(file_paths=file_paths, initial=initial, root=root) + + +def pyscript_setup(*extra_py: str, extra_js: str = "", config: str = "") -> str: + return pyscript_setup_html(extra_py=extra_py, extra_js=extra_js, config=config) From 027f09097030a25f58304430d8a9a5ed6d49369b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 03:35:46 -0800 Subject: [PATCH 08/36] remove standard installation extra --- docs/source/about/changelog.rst | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 6a58d0978..c3c26c6da 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -19,7 +19,7 @@ Unreleased - :pull:`1113` - Added ``reactpy.ReactPy`` that can be used to run ReactPy in standalone mode. - :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. - :pull:`1113` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. -- :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``). +- :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. - :pull:`1264` - Added ``reactpy.use_async_effect`` hook. - :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. diff --git a/pyproject.toml b/pyproject.toml index 76557b7ae..b52f01fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,6 @@ artifacts = [] [project.optional-dependencies] all = ["reactpy[jinja,uvicorn,testing]"] -standard = ["reactpy[jinja,uvicorn]"] jinja = ["jinja2-simple-tags", "jinja2 >=3"] uvicorn = ["uvicorn[standard]"] testing = ["playwright"] From afbd4379b90ec8f18b48a0fd1718c7646de23f56 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 04:03:19 -0800 Subject: [PATCH 09/36] automate pyscript/morphdom static building --- pyproject.toml | 3 ++- src/js/packages/@reactpy/app/package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b52f01fd0..6f7f671df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,8 @@ commands = [ 'bun run --cwd "src/js/packages/@reactpy/client" build', 'bun install --cwd "src/js/packages/@reactpy/app"', 'bun run --cwd "src/js/packages/@reactpy/app" build', - 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/node_modules/@pyscript/core/dist" "src/reactpy/static/pyscript"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/node_modules/morphdom/dist" "src/reactpy/static/morphdom"', ] artifacts = [] diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index 1d7c4b518..ca3a4370d 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -13,7 +13,7 @@ "morphdom": "^2" }, "scripts": { - "build": "bun build \"src/index.ts\" --outdir=\"dist\" --minify --sourcemap=\"linked\"", + "build": "bun build \"src/index.ts\" --outdir=\"../../../../reactpy/static/\" --minify --sourcemap=\"linked\"", "checkTypes": "tsc --noEmit" } } From 6f092a8597acab3e4ec6956f5461b534be30be59 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 04:05:38 -0800 Subject: [PATCH 10/36] Move optional dependencies up --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f7f671df..a8bcdb0dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,12 @@ urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" urls.Documentation = "https://reactpy.dev/" urls.Source = "https://github.com/reactive-python/reactpy" +[project.optional-dependencies] +all = ["reactpy[jinja,uvicorn,testing]"] +jinja = ["jinja2-simple-tags", "jinja2 >=3"] +uvicorn = ["uvicorn[standard]"] +testing = ["playwright"] + [tool.hatch.version] path = "src/reactpy/__init__.py" @@ -80,12 +86,6 @@ commands = [ ] artifacts = [] -[project.optional-dependencies] -all = ["reactpy[jinja,uvicorn,testing]"] -jinja = ["jinja2-simple-tags", "jinja2 >=3"] -uvicorn = ["uvicorn[standard]"] -testing = ["playwright"] - ############################# # >>> Hatch Test Runner <<< # From 6b1e50b15988a1b47b8251b33e331ecd9987f4a1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 04:25:05 -0800 Subject: [PATCH 11/36] remove unused dependency --- pyproject.toml | 2 -- src/reactpy/logging.py | 1 - 2 files changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a8bcdb0dc..218eb12e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,8 @@ dependencies = [ "exceptiongroup >=1.0", "typing-extensions >=3.10", "anyio >=3", - "jsonpatch >=1.32", "fastjsonschema >=2.14.5", "requests >=2", - "colorlog >=6", "asgiref >=3", "lxml >=4", "servestatic >=3.0.0", diff --git a/src/reactpy/logging.py b/src/reactpy/logging.py index 62b507db8..ecaf71db4 100644 --- a/src/reactpy/logging.py +++ b/src/reactpy/logging.py @@ -22,7 +22,6 @@ "generic": { "format": "%(asctime)s | %(log_color)s%(levelname)s%(reset)s | %(message)s", "datefmt": r"%Y-%m-%dT%H:%M:%S%z", - "class": "colorlog.ColoredFormatter", } }, } From 5afa28bbe67e012a2fcd418890a9e2ad5a41636c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 04:25:20 -0800 Subject: [PATCH 12/36] fix tests --- tests/test_asgi/test_middleware.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index 84dc545b8..fdd0a1534 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -19,7 +19,7 @@ async def display(page): templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), - extensions=["reactpy.jinja.Component"], + extensions=["reactpy.templatetags.Jinja"], ) ) @@ -59,7 +59,7 @@ async def test_unregistered_root_component(): templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), - extensions=["reactpy.jinja.Component"], + extensions=["reactpy.templatetags.Jinja"], ) ) From 2119fb15af179734fa2ac07ade933c138d94dbd6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:55:20 -0800 Subject: [PATCH 13/36] Reduce dependencies by making ASGI optional --- docs/source/about/changelog.rst | 4 +- pyproject.toml | 20 +++--- src/reactpy/__init__.py | 7 +-- src/reactpy/asgi/__init__.py | 5 ++ src/reactpy/asgi/executors/pyscript.py | 5 +- src/reactpy/asgi/executors/standalone.py | 11 +--- src/reactpy/asgi/middleware.py | 17 +++--- src/reactpy/asgi/types.py | 77 ++++++++++++++++++++++++ src/reactpy/core/hooks.py | 7 ++- src/reactpy/logging.py | 7 +-- src/reactpy/testing/backend.py | 6 +- src/reactpy/types.py | 76 +---------------------- tests/test_asgi/test_middleware.py | 6 +- 13 files changed, 116 insertions(+), 132 deletions(-) create mode 100644 src/reactpy/asgi/types.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index c3c26c6da..43b22d0fb 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -16,8 +16,8 @@ Unreleased ---------- **Added** -- :pull:`1113` - Added ``reactpy.ReactPy`` that can be used to run ReactPy in standalone mode. -- :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework. +- :pull:`1113` - Added ``reactpy.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI. +- :pull:`1113` - Added ``reactpy.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework. - :pull:`1113` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. - :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. diff --git a/pyproject.toml b/pyproject.toml index 218eb12e6..d49f0ed48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,16 +28,11 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "exceptiongroup >=1.0", - "typing-extensions >=3.10", - "anyio >=3", "fastjsonschema >=2.14.5", "requests >=2", - "asgiref >=3", "lxml >=4", - "servestatic >=3.0.0", - "orjson >=3", - "asgi-tools", + "anyio >=3", + "typing-extensions >=3.10", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -45,7 +40,9 @@ urls.Documentation = "https://reactpy.dev/" urls.Source = "https://github.com/reactive-python/reactpy" [project.optional-dependencies] -all = ["reactpy[jinja,uvicorn,testing]"] +all = ["reactpy[asgi,jinja,uvicorn,testing]"] +standard = ["reactpy[asgi]"] +asgi = ["asgiref", "asgi-tools", "servestatic", "orjson"] jinja = ["jinja2-simple-tags", "jinja2 >=3"] uvicorn = ["uvicorn[standard]"] testing = ["playwright"] @@ -91,14 +88,12 @@ artifacts = [] [tool.hatch.envs.hatch-test] extra-dependencies = [ + "reactpy[all]", "pytest-sugar", "pytest-asyncio", "responses", - "playwright", + "exceptiongroup", "jsonpointer", - "uvicorn[standard]", - "jinja2-simple-tags", - "jinja2", "starlette", ] @@ -158,6 +153,7 @@ serve = [ [tool.hatch.envs.python] extra-dependencies = [ + "reactpy[all]", "ruff", "toml", "mypy==1.8", diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 299f1ac61..804e3fc28 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -1,7 +1,5 @@ -from reactpy import asgi, config, logging, types, web, widgets +from reactpy import 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.core import hooks from reactpy.core.component import component from reactpy.core.events import event @@ -29,10 +27,7 @@ __all__ = [ "Layout", - "ReactPy", - "ReactPyMiddleware", "Ref", - "asgi", "component", "config", "create_context", diff --git a/src/reactpy/asgi/__init__.py b/src/reactpy/asgi/__init__.py index e69de29bb..4b97ba47e 100644 --- a/src/reactpy/asgi/__init__.py +++ b/src/reactpy/asgi/__init__.py @@ -0,0 +1,5 @@ +from reactpy.asgi.executors.pyscript import ReactPyCSR +from reactpy.asgi.executors.standalone import ReactPy +from reactpy.asgi.middleware import ReactPyMiddleware + +__all__ = ["ReactPy", "ReactPyCSR", "ReactPyMiddleware"] diff --git a/src/reactpy/asgi/executors/pyscript.py b/src/reactpy/asgi/executors/pyscript.py index 9677f0980..0137e426c 100644 --- a/src/reactpy/asgi/executors/pyscript.py +++ b/src/reactpy/asgi/executors/pyscript.py @@ -16,10 +16,7 @@ from reactpy.asgi.middleware import ReactPyMiddleware from reactpy.asgi.utils import vdom_head_to_html from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html -from reactpy.types import ( - ReactPyConfig, - VdomDict, -) +from reactpy.types import ReactPyConfig, VdomDict class ReactPyCSR(ReactPy): diff --git a/src/reactpy/asgi/executors/standalone.py b/src/reactpy/asgi/executors/standalone.py index e4773216f..324c40f71 100644 --- a/src/reactpy/asgi/executors/standalone.py +++ b/src/reactpy/asgi/executors/standalone.py @@ -14,16 +14,9 @@ from reactpy import html from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.asgi.types import AsgiApp, AsgiHttpApp, AsgiLifespanApp, AsgiWebsocketApp from reactpy.asgi.utils import import_dotted_path, vdom_head_to_html -from reactpy.types import ( - AsgiApp, - AsgiHttpApp, - AsgiLifespanApp, - AsgiWebsocketApp, - ReactPyConfig, - RootComponentConstructor, - VdomDict, -) +from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict from reactpy.utils import asgi_component_html _logger = getLogger(__name__) diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index 42c4f9390..fde58087c 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -18,22 +18,19 @@ from typing_extensions import Unpack from reactpy import config -from reactpy.asgi.utils import check_path, import_components, process_settings -from reactpy.core.hooks import ConnectionContext -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.types import ( +from reactpy.asgi.types import ( AsgiApp, AsgiHttpApp, AsgiLifespanApp, AsgiWebsocketApp, AsgiWebsocketReceive, AsgiWebsocketSend, - Connection, - Location, - ReactPyConfig, - RootComponentConstructor, ) +from reactpy.asgi.utils import check_path, import_components, process_settings +from reactpy.core.hooks import ConnectionContext +from reactpy.core.layout import Layout +from reactpy.core.serve import serve_layout +from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor _logger = logging.getLogger(__name__) @@ -222,7 +219,7 @@ async def run_dispatcher(self) -> None: self.scope["query_string"].decode(), strict_parsing=True ) connection = Connection( - scope=self.scope, + scope=self.scope, # type: ignore location=Location( path=ws_query_string.get("http_pathname", [""])[0], query_string=ws_query_string.get("http_query_string", [""])[0], diff --git a/src/reactpy/asgi/types.py b/src/reactpy/asgi/types.py new file mode 100644 index 000000000..bff5e0ca7 --- /dev/null +++ b/src/reactpy/asgi/types.py @@ -0,0 +1,77 @@ +"""These types are separated from the main module to avoid dependency issues.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Callable, Union + +from asgiref import typing as asgi_types + +AsgiHttpReceive = Callable[ + [], + Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent], +] + +AsgiHttpSend = Callable[ + [ + asgi_types.HTTPResponseStartEvent + | asgi_types.HTTPResponseBodyEvent + | asgi_types.HTTPResponseTrailersEvent + | asgi_types.HTTPServerPushEvent + | asgi_types.HTTPDisconnectEvent + ], + Awaitable[None], +] + +AsgiWebsocketReceive = Callable[ + [], + Awaitable[ + asgi_types.WebSocketConnectEvent + | asgi_types.WebSocketDisconnectEvent + | asgi_types.WebSocketReceiveEvent + ], +] + +AsgiWebsocketSend = Callable[ + [ + asgi_types.WebSocketAcceptEvent + | asgi_types.WebSocketSendEvent + | asgi_types.WebSocketResponseStartEvent + | asgi_types.WebSocketResponseBodyEvent + | asgi_types.WebSocketCloseEvent + ], + Awaitable[None], +] + +AsgiLifespanReceive = Callable[ + [], + Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], +] + +AsgiLifespanSend = Callable[ + [ + asgi_types.LifespanStartupCompleteEvent + | asgi_types.LifespanStartupFailedEvent + | asgi_types.LifespanShutdownCompleteEvent + | asgi_types.LifespanShutdownFailedEvent + ], + Awaitable[None], +] + +AsgiHttpApp = Callable[ + [asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend], + Awaitable[None], +] + +AsgiWebsocketApp = Callable[ + [asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], + Awaitable[None], +] + +AsgiLifespanApp = Callable[ + [asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend], + Awaitable[None], +] + + +AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp] diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 70f72268d..8420ba1fe 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -16,7 +16,6 @@ overload, ) -from asgiref import typing as asgi_types from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG @@ -25,9 +24,11 @@ from reactpy.utils import Ref if not TYPE_CHECKING: - # make flake8 think that this variable exists ellipsis = type(...) +if TYPE_CHECKING: + from asgiref import typing as asgi_types + __all__ = [ "use_async_effect", @@ -339,7 +340,7 @@ def use_connection() -> Connection[Any]: def use_scope() -> asgi_types.HTTPScope | asgi_types.WebSocketScope: """Get the current :class:`~reactpy.types.Connection`'s scope.""" - return use_connection().scope + return use_connection().scope # type: ignore def use_location() -> Location: diff --git a/src/reactpy/logging.py b/src/reactpy/logging.py index ecaf71db4..160141c09 100644 --- a/src/reactpy/logging.py +++ b/src/reactpy/logging.py @@ -18,12 +18,7 @@ "stream": sys.stdout, } }, - "formatters": { - "generic": { - "format": "%(asctime)s | %(log_color)s%(levelname)s%(reset)s | %(message)s", - "datefmt": r"%Y-%m-%dT%H:%M:%S%z", - } - }, + "formatters": {"generic": {"datefmt": r"%Y-%m-%dT%H:%M:%S%z"}}, } ) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 8b3a8143c..4bd55132c 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -4,11 +4,10 @@ import logging from contextlib import AsyncExitStack from types import TracebackType -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from urllib.parse import urlencode, urlunparse import uvicorn -from asgiref import typing as asgi_types from reactpy.asgi.executors.standalone import ReactPy from reactpy.asgi.middleware import ReactPyMiddleware @@ -24,6 +23,9 @@ from reactpy.types import ComponentConstructor, ReactPyConfig from reactpy.utils import Ref +if TYPE_CHECKING: + from asgiref import typing as asgi_types + class BackendFixture: """A test fixture for running a server and imperatively displaying views diff --git a/src/reactpy/types.py b/src/reactpy/types.py index ee4e67776..f387a2c14 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -2,7 +2,7 @@ import sys from collections import namedtuple -from collections.abc import Awaitable, Mapping, Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType @@ -15,12 +15,10 @@ NamedTuple, Protocol, TypeVar, - Union, overload, runtime_checkable, ) -from asgiref import typing as asgi_types from typing_extensions import TypeAlias, TypedDict CarrierType = TypeVar("CarrierType") @@ -255,7 +253,7 @@ def value(self) -> _Type: class Connection(Generic[CarrierType]): """Represents a connection with a client""" - scope: asgi_types.HTTPScope | asgi_types.WebSocketScope + scope: dict[str, Any] """A scope dictionary related to the current connection.""" location: Location @@ -297,73 +295,3 @@ class ReactPyConfig(TypedDict, total=False): async_rendering: bool debug: bool tests_default_timeout: int - - -AsgiHttpReceive = Callable[ - [], - Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent], -] - -AsgiHttpSend = Callable[ - [ - asgi_types.HTTPResponseStartEvent - | asgi_types.HTTPResponseBodyEvent - | asgi_types.HTTPResponseTrailersEvent - | asgi_types.HTTPServerPushEvent - | asgi_types.HTTPDisconnectEvent - ], - Awaitable[None], -] - -AsgiWebsocketReceive = Callable[ - [], - Awaitable[ - asgi_types.WebSocketConnectEvent - | asgi_types.WebSocketDisconnectEvent - | asgi_types.WebSocketReceiveEvent - ], -] - -AsgiWebsocketSend = Callable[ - [ - asgi_types.WebSocketAcceptEvent - | asgi_types.WebSocketSendEvent - | asgi_types.WebSocketResponseStartEvent - | asgi_types.WebSocketResponseBodyEvent - | asgi_types.WebSocketCloseEvent - ], - Awaitable[None], -] - -AsgiLifespanReceive = Callable[ - [], - Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], -] - -AsgiLifespanSend = Callable[ - [ - asgi_types.LifespanStartupCompleteEvent - | asgi_types.LifespanStartupFailedEvent - | asgi_types.LifespanShutdownCompleteEvent - | asgi_types.LifespanShutdownFailedEvent - ], - Awaitable[None], -] - -AsgiHttpApp = Callable[ - [asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend], - Awaitable[None], -] - -AsgiWebsocketApp = Callable[ - [asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], - Awaitable[None], -] - -AsgiLifespanApp = Callable[ - [asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend], - Awaitable[None], -] - - -AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp] diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index fdd0a1534..6027f7782 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -39,7 +39,7 @@ def test_invalid_path_prefix(): async def app(scope, receive, send): pass - reactpy.ReactPyMiddleware(app, root_components=["abc"], path_prefix="invalid") + ReactPyMiddleware(app, root_components=["abc"], path_prefix="invalid") def test_invalid_web_modules_dir(): @@ -50,9 +50,7 @@ def test_invalid_web_modules_dir(): async def app(scope, receive, send): pass - reactpy.ReactPyMiddleware( - app, root_components=["abc"], web_modules_dir=Path("invalid") - ) + ReactPyMiddleware(app, root_components=["abc"], web_modules_dir=Path("invalid")) async def test_unregistered_root_component(): From 36c664226c4b35774458f2ae42f7d543d800312c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:01:00 -0800 Subject: [PATCH 14/36] Use local ReactPy wheel for unpublished releases. --- pyproject.toml | 3 +- src/reactpy/pyscript/utils.py | 91 ++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d49f0ed48..1da311936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,7 @@ urls.Source = "https://github.com/reactive-python/reactpy" [project.optional-dependencies] all = ["reactpy[asgi,jinja,uvicorn,testing]"] -standard = ["reactpy[asgi]"] -asgi = ["asgiref", "asgi-tools", "servestatic", "orjson"] +asgi = ["asgiref", "asgi-tools", "servestatic", "orjson", "pip"] jinja = ["jinja2-simple-tags", "jinja2 >=3"] uvicorn = ["uvicorn[standard]"] testing = ["playwright"] diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 15c9052f8..395aaf0cc 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -1,8 +1,13 @@ +# ruff: noqa: S603, S607 from __future__ import annotations import functools import json +import shutil +import subprocess import textwrap +from glob import glob +from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING, Any from uuid import uuid4 @@ -11,7 +16,7 @@ import orjson import reactpy -from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX +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 @@ -25,6 +30,7 @@ PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "layout_handler.py").read_text( encoding="utf-8" ) +_logger = getLogger(__name__) def render_pyscript_executor(file_paths: tuple[str, ...], uuid: str, root: str) -> str: @@ -91,7 +97,7 @@ def extend_pyscript_config( # Extends ReactPy's default PyScript config with user provided values. pyscript_config: dict[str, Any] = { "packages": [ - f"reactpy=={reactpy.__version__}", + reactpy_version_string(), f"jsonpointer=={jsonpointer.__version__}", "ssl", ], @@ -117,6 +123,87 @@ def extend_pyscript_config( return orjson.dumps(pyscript_config).decode("utf-8") +@functools.cache +def reactpy_version_string() -> str: # pragma: no cover + local_version = reactpy.__version__ + + # Get a list of all versions via `pip index versions` + result = subprocess.run( + ["pip", "index", "versions", "reactpy"], + capture_output=True, + text=True, + check=False, + ) + + # Check if the command failed + if result.returncode != 0: + _logger.warning( + "Failed to verify what versions of ReactPy exist on PyPi. " + "PyScript functionality may not work as expected.", + ) + return f"reactpy=={local_version}" + + # Have `pip` tell us what versions are available + available_version_symbol = "Available versions: " + latest_version_symbol = "LATEST: " + known_versions: list[str] = [] + latest_version: str = "" + for line in result.stdout.splitlines(): + if line.startswith(available_version_symbol): + known_versions.extend(line[len(available_version_symbol) :].split(", ")) + elif latest_version_symbol in line: + symbol_postion = line.index(latest_version_symbol) + latest_version = line[symbol_postion + len(latest_version_symbol) :].strip() + + # Return early if local version of ReactPy is available on PyPi + if local_version in known_versions: + return f"reactpy=={local_version}" + + # Begin determining an alternative method of installing ReactPy + _logger.warning( + "'reactpy==%s' is not available on PyPi, " + "Attempting to determine an alternative to use within PyScript...", + local_version, + ) + if not latest_version: + _logger.warning("Failed to determine the latest version of ReactPy on PyPi. ") + + # Build a local wheel for ReactPy, if needed + dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist" + wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) + if not wheel_glob: + _logger.warning("Attempting to build a local wheel for ReactPy...") + subprocess.run( + ["hatch", "build", "-t", "wheel"], + capture_output=True, + text=True, + check=False, + cwd=Path(reactpy.__file__).parent.parent.parent, + ) + wheel_glob = glob(str(dist_dir / f"reactpy-{local_version}-*.whl")) + + # Building a local wheel failed, find an alternative installation method + if not wheel_glob: + if latest_version: + _logger.warning( + "Failed to build a local wheel for ReactPy, likely due to missing build dependencies. " + "PyScript will default to using the latest ReactPy version on PyPi." + ) + return f"reactpy=={latest_version}" + _logger.error( + "Failed to build a local wheel for ReactPy and could not determine the latest version on PyPi. " + "PyScript functionality may not work as expected.", + ) + return f"reactpy=={local_version}" + + # Move the wheel file to the web_modules directory, if needed + wheel_file = Path(wheel_glob[0]) + new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name + if not new_path.exists(): + shutil.copy(wheel_file, new_path) + return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" + + @functools.cache def cached_file_read(file_path: str) -> str: return Path(file_path).read_text(encoding="utf-8").strip() From d944643c24e315f938456c0bce24d4159f60b931 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:11:09 -0800 Subject: [PATCH 15/36] move asgi_component_html function --- src/reactpy/asgi/executors/standalone.py | 7 +++++-- src/reactpy/asgi/utils.py | 25 ++++++++++++++++++++++++ src/reactpy/templatetags/jinja.py | 7 ++----- src/reactpy/utils.py | 19 ------------------ 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/reactpy/asgi/executors/standalone.py b/src/reactpy/asgi/executors/standalone.py index 324c40f71..35b5b88cf 100644 --- a/src/reactpy/asgi/executors/standalone.py +++ b/src/reactpy/asgi/executors/standalone.py @@ -15,9 +15,12 @@ from reactpy import html from reactpy.asgi.middleware import ReactPyMiddleware from reactpy.asgi.types import AsgiApp, AsgiHttpApp, AsgiLifespanApp, AsgiWebsocketApp -from reactpy.asgi.utils import import_dotted_path, vdom_head_to_html +from reactpy.asgi.utils import ( + asgi_component_html, + import_dotted_path, + vdom_head_to_html, +) from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict -from reactpy.utils import asgi_component_html _logger = getLogger(__name__) diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index 85ad56056..11f00b686 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -6,6 +6,13 @@ from typing import Any from reactpy._option import Option +from reactpy.config import ( + REACTPY_PATH_PREFIX, + REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + REACTPY_RECONNECT_INTERVAL, + REACTPY_RECONNECT_MAX_INTERVAL, + REACTPY_RECONNECT_MAX_RETRIES, +) from reactpy.types import ReactPyConfig, VdomDict from reactpy.utils import vdom_to_html @@ -73,3 +80,21 @@ def process_settings(settings: ReactPyConfig) -> None: config_object.set_current(settings[setting]) # type: ignore else: raise ValueError(f'Unknown ReactPy setting "{setting}".') + + +def asgi_component_html(element_id: str, class_: str, component_path: str) -> str: + return ( + f'
' + '" + ) diff --git a/src/reactpy/templatetags/jinja.py b/src/reactpy/templatetags/jinja.py index a921f726a..ac60d7cd4 100644 --- a/src/reactpy/templatetags/jinja.py +++ b/src/reactpy/templatetags/jinja.py @@ -3,11 +3,8 @@ from jinja2_simple_tags import StandaloneTag -from reactpy.pyscript.utils import ( - pyscript_component_html, - pyscript_setup_html, -) -from reactpy.utils import asgi_component_html +from reactpy.asgi.utils import asgi_component_html +from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html class Jinja(StandaloneTag): # type: ignore diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index adb7d07d4..2638ac0c3 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -8,7 +8,6 @@ from lxml import etree from lxml.html import fromstring, tostring -from reactpy import config from reactpy.core.vdom import vdom as make_vdom from reactpy.types import ComponentType, VdomDict @@ -314,21 +313,3 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: # Pattern for delimitting camelCase names (e.g. camelCase to camel-case) _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str: - return ( - f'
' - '" - ) From 8f2988792d8970f1b99adfd87122806666782c7f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:18:34 -0800 Subject: [PATCH 16/36] remove useless async --- src/reactpy/pyscript/components.py | 2 +- src/reactpy/pyscript/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py index c892d5cb3..ae3758fec 100644 --- a/src/reactpy/pyscript/components.py +++ b/src/reactpy/pyscript/components.py @@ -37,7 +37,7 @@ def _pyscript_component( {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, initial, ), - html.py_script({"async": ""}, executor), + html.py_script(executor), ) diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 395aaf0cc..3309e3554 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -66,7 +66,7 @@ def pyscript_component_html( f'
' f"{_initial}" "
" - f"{executor_code}" + f"{executor_code}" ) @@ -85,7 +85,7 @@ def pyscript_setup_html( f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}" f'" - f"{PYSCRIPT_LAYOUT_HANDLER}" + f"{PYSCRIPT_LAYOUT_HANDLER}" ) From d5f3bece193a7072baddf467703c6927a360509f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:03:04 -0800 Subject: [PATCH 17/36] docstring for ReactPyCSR --- src/reactpy/asgi/executors/pyscript.py | 34 ++++++++++++++++++++++---- src/reactpy/pyscript/utils.py | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/reactpy/asgi/executors/pyscript.py b/src/reactpy/asgi/executors/pyscript.py index 0137e426c..a7fc313a4 100644 --- a/src/reactpy/asgi/executors/pyscript.py +++ b/src/reactpy/asgi/executors/pyscript.py @@ -24,8 +24,8 @@ def __init__( self, *component_paths: str | Path, extra_py: tuple[str, ...] = (), - extra_js: dict[str, Any] | str = "", - pyscript_config: dict[str, Any] | str = "", + extra_js: dict[str, str] | None = None, + pyscript_config: dict[str, Any] | None = None, root_name: str = "root", initial: str | VdomDict = "", http_headers: dict[str, str] | None = None, @@ -35,8 +35,31 @@ def __init__( ) -> None: """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR). + This ASGI webserver is only used to serve the initial HTML document and static files. + Parameters: - ... + component_paths: + File paths to the Python files containing the root component. If multuple paths are + provided, the components will be concatenated in the order they were provided. + extra_py: + Additional Python packages to be made available to the root component. These packages + will be automatically installed from PyPi. Any packages names ending with `.whl` will + be assumed to be a URL to a wheel file. + extra_js: Dictionary where the `key` is the URL to the JavaScript file and the `value` is + the name you'd like to export it as. Any JavaScript files declared here will be available + to your root component via the `pyscript.js_modules.*` object. + pyscript_config: + Additional configuration options for the PyScript runtime. This will be merged with the + default configuration. + root_name: The name of the root component in your Python file. + initial: The initial HTML that is rendered prior to your component loading in. This is most + commonly used to render a loading animation. + http_headers: Additional headers to include in the HTTP response for the base HTML document. + html_head: Additional head elements to include in the HTML response. + html_lang: The language of the HTML document. + settings: + Global ReactPy configuration settings that affect behavior and performance. Most settings + are not applicable to CSR and will have no effect. """ ReactPyMiddleware.__init__( self, app=ReactPyAppCSR(self), root_components=[], **settings @@ -45,8 +68,8 @@ def __init__( raise ValueError("At least one component file path must be provided.") self.component_paths = tuple(str(path) for path in component_paths) self.extra_py = extra_py - self.extra_js = extra_js - self.pyscript_config = pyscript_config + self.extra_js = extra_js or {} + self.pyscript_config = pyscript_config or {} self.root_name = root_name self.initial = initial self.extra_headers = http_headers or {} @@ -55,6 +78,7 @@ def __init__( self.html_lang = html_lang def match_dispatch_path(self, scope: WebSocketScope) -> bool: + """We do not use a WebSocket dispatcher for Client-Side Rendering (CSR).""" return False diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 3309e3554..9245e51d7 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -91,7 +91,7 @@ def pyscript_setup_html( def extend_pyscript_config( extra_py: Sequence[str], - extra_js: dict[str, Any] | str, + extra_js: dict[str, str] | str, config: dict[str, Any] | str, ) -> str: # Extends ReactPy's default PyScript config with user provided values. From 74d13efdde465a8c4bed2c7a57c04d338ddc566b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:12:46 -0800 Subject: [PATCH 18/36] ReactPyCSRApp --- src/reactpy/asgi/executors/pyscript.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy/asgi/executors/pyscript.py b/src/reactpy/asgi/executors/pyscript.py index a7fc313a4..09a58375d 100644 --- a/src/reactpy/asgi/executors/pyscript.py +++ b/src/reactpy/asgi/executors/pyscript.py @@ -62,7 +62,7 @@ def __init__( are not applicable to CSR and will have no effect. """ ReactPyMiddleware.__init__( - self, app=ReactPyAppCSR(self), root_components=[], **settings + self, app=ReactPyCSRApp(self), root_components=[], **settings ) if not component_paths: raise ValueError("At least one component file path must be provided.") @@ -83,7 +83,7 @@ def match_dispatch_path(self, scope: WebSocketScope) -> bool: @dataclass -class ReactPyAppCSR(ReactPyApp): +class ReactPyCSRApp(ReactPyApp): """ReactPy's standalone ASGI application for Client-Side Rendering (CSR).""" parent: ReactPyCSR From 208cdc606f591739c3f16f6b708d1bb61b0c0c7b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:22:33 -0800 Subject: [PATCH 19/36] Add JS as known third party pkg --- src/reactpy/pyscript/layout_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/pyscript/layout_handler.py b/src/reactpy/pyscript/layout_handler.py index 744b7b0c1..4ae7a5b85 100644 --- a/src/reactpy/pyscript/layout_handler.py +++ b/src/reactpy/pyscript/layout_handler.py @@ -2,10 +2,10 @@ import asyncio import logging +import js from jsonpointer import set_pointer from pyodide.ffi.wrappers import add_event_listener -import js from pyscript.js_modules import morphdom from reactpy.core.layout import Layout From 4169d46e01961c01b5a6b00fc63da6b0d8d3a8c2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:35:57 -0800 Subject: [PATCH 20/36] Add changelog --- docs/source/about/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 43b22d0fb..5f3fed487 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -17,6 +17,7 @@ Unreleased **Added** - :pull:`1113` - Added ``reactpy.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI. +- :pull:`1269` - Added ``reactpy.asgi.ReactPyCSR`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. - :pull:`1113` - Added ``reactpy.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework. - :pull:`1113` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. - :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``). From 28c4374470a6a15ae959def1de8a45ba3535612e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:36:48 -0800 Subject: [PATCH 21/36] Add JS as known third party pkg --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1da311936..c3279fd64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -318,6 +318,7 @@ lint.unfixable = [ [tool.ruff.lint.isort] known-first-party = ["reactpy"] +known-third-party = ["js"] [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports From f0c47dc4315878243746556f0f6259b884a21430 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 6 Feb 2025 22:50:16 -0800 Subject: [PATCH 22/36] Expose pyscript components at top level --- docs/source/about/changelog.rst | 3 ++- src/reactpy/__init__.py | 2 ++ src/reactpy/pyscript/utils.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 5f3fed487..9cf4479cb 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -19,7 +19,8 @@ Unreleased - :pull:`1113` - Added ``reactpy.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI. - :pull:`1269` - Added ``reactpy.asgi.ReactPyCSR`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. - :pull:`1113` - Added ``reactpy.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework. -- :pull:`1113` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. +- :pull:`1113` :pull:`1269` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. This includes the following template tags: ``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``. +- :pull:`1269` - Added ``reactpy.pyscript_component`` that can be used to embed ReactPy components into your existing application. - :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. - :pull:`1264` - Added ``reactpy.use_async_effect`` hook. diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 804e3fc28..5413d0b07 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -20,6 +20,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 __author__ = "The Reactive Python Team" @@ -36,6 +37,7 @@ "html", "html_to_vdom", "logging", + "pyscript_component", "types", "use_async_effect", "use_callback", diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 9245e51d7..9210f2cc0 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -13,7 +13,6 @@ from uuid import uuid4 import jsonpointer -import orjson import reactpy from reactpy.config import REACTPY_DEBUG, REACTPY_PATH_PREFIX, REACTPY_WEB_MODULES_DIR @@ -94,6 +93,8 @@ def extend_pyscript_config( extra_js: dict[str, str] | str, config: dict[str, Any] | str, ) -> str: + import orjson + # Extends ReactPy's default PyScript config with user provided values. pyscript_config: dict[str, Any] = { "packages": [ From 9a485bd551ba9e0c6073ac2d455c69697176600a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 7 Feb 2025 01:07:07 -0800 Subject: [PATCH 23/36] CSR -> Pyodide --- docs/source/about/changelog.rst | 2 +- src/reactpy/asgi/__init__.py | 4 ++-- .../asgi/executors/{pyscript.py => pyodide.py} | 11 ++++++----- 3 files changed, 9 insertions(+), 8 deletions(-) rename src/reactpy/asgi/executors/{pyscript.py => pyodide.py} (95%) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 9cf4479cb..e106b1a90 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -17,7 +17,7 @@ Unreleased **Added** - :pull:`1113` - Added ``reactpy.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI. -- :pull:`1269` - Added ``reactpy.asgi.ReactPyCSR`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. +- :pull:`1269` - Added ``reactpy.asgi.ReactPyPyodide`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided. - :pull:`1113` - Added ``reactpy.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework. - :pull:`1113` :pull:`1269` - Added ``reactpy.templatetags.Jinja`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application. This includes the following template tags: ``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``. - :pull:`1269` - Added ``reactpy.pyscript_component`` that can be used to embed ReactPy components into your existing application. diff --git a/src/reactpy/asgi/__init__.py b/src/reactpy/asgi/__init__.py index 4b97ba47e..edbc2e28e 100644 --- a/src/reactpy/asgi/__init__.py +++ b/src/reactpy/asgi/__init__.py @@ -1,5 +1,5 @@ -from reactpy.asgi.executors.pyscript import ReactPyCSR +from reactpy.asgi.executors.pyodide import ReactPyPyodide from reactpy.asgi.executors.standalone import ReactPy from reactpy.asgi.middleware import ReactPyMiddleware -__all__ = ["ReactPy", "ReactPyCSR", "ReactPyMiddleware"] +__all__ = ["ReactPy", "ReactPyMiddleware", "ReactPyPyodide"] diff --git a/src/reactpy/asgi/executors/pyscript.py b/src/reactpy/asgi/executors/pyodide.py similarity index 95% rename from src/reactpy/asgi/executors/pyscript.py rename to src/reactpy/asgi/executors/pyodide.py index 09a58375d..fe5a4d8d6 100644 --- a/src/reactpy/asgi/executors/pyscript.py +++ b/src/reactpy/asgi/executors/pyodide.py @@ -19,7 +19,7 @@ from reactpy.types import ReactPyConfig, VdomDict -class ReactPyCSR(ReactPy): +class ReactPyPyodide(ReactPy): def __init__( self, *component_paths: str | Path, @@ -33,7 +33,8 @@ def __init__( html_lang: str = "en", **settings: Unpack[ReactPyConfig], ) -> None: - """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR). + """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR) via + PyScript (using a Pyodide interpreter). This ASGI webserver is only used to serve the initial HTML document and static files. @@ -62,7 +63,7 @@ def __init__( are not applicable to CSR and will have no effect. """ ReactPyMiddleware.__init__( - self, app=ReactPyCSRApp(self), root_components=[], **settings + self, app=ReactPyPyodideApp(self), root_components=[], **settings ) if not component_paths: raise ValueError("At least one component file path must be provided.") @@ -83,10 +84,10 @@ def match_dispatch_path(self, scope: WebSocketScope) -> bool: @dataclass -class ReactPyCSRApp(ReactPyApp): +class ReactPyPyodideApp(ReactPyApp): """ReactPy's standalone ASGI application for Client-Side Rendering (CSR).""" - parent: ReactPyCSR + parent: ReactPyPyodide _index_html = "" _etag = "" _last_modified = "" From 1e74681cb19c5297950bf47176b7ba49914ce694 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 7 Feb 2025 01:55:33 -0800 Subject: [PATCH 24/36] Temporary fix to pyscript bug --- src/reactpy/pyscript/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 9210f2cc0..441a0706d 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -107,6 +107,7 @@ def extend_pyscript_config( f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" } }, + "packages_cache": "never", } pyscript_config["packages"].extend(extra_py) From 459bcc51f677d9bbd15699f8fe3edde3ed115753 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 7 Feb 2025 02:36:09 -0800 Subject: [PATCH 25/36] use new pyscript syntax --- src/reactpy/_html.py | 1 - src/reactpy/pyscript/components.py | 2 +- src/reactpy/pyscript/utils.py | 5 ++--- src/reactpy/static/pyscript-custom.css | 3 --- 4 files changed, 3 insertions(+), 8 deletions(-) delete mode 100644 src/reactpy/static/pyscript-custom.css diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 05a6feaa3..61c6ae77f 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -406,7 +406,6 @@ 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/pyscript/components.py b/src/reactpy/pyscript/components.py index ae3758fec..12082cf3a 100644 --- a/src/reactpy/pyscript/components.py +++ b/src/reactpy/pyscript/components.py @@ -37,7 +37,7 @@ def _pyscript_component( {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, initial, ), - html.py_script(executor), + html.script({"type": "py"}, executor), ) diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index 441a0706d..bead7a896 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -65,7 +65,7 @@ def pyscript_component_html( f'
' f"{_initial}" "
" - f"{executor_code}" + f"" ) @@ -80,11 +80,10 @@ def pyscript_setup_html( return ( f'' - f'' f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}" f'" - f"{PYSCRIPT_LAYOUT_HANDLER}" + f"" ) diff --git a/src/reactpy/static/pyscript-custom.css b/src/reactpy/static/pyscript-custom.css deleted file mode 100644 index 5793fd52f..000000000 --- a/src/reactpy/static/pyscript-custom.css +++ /dev/null @@ -1,3 +0,0 @@ -py-script { - display: none; -} From f387f6df2ad124511c0b3cd52ed7eb961382ad12 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 7 Feb 2025 04:02:39 -0800 Subject: [PATCH 26/36] regex based python minification --- src/reactpy/pyscript/utils.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index bead7a896..794eef938 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -3,6 +3,7 @@ import functools import json +import re import shutil import subprocess import textwrap @@ -22,14 +23,29 @@ if TYPE_CHECKING: from collections.abc import Sequence +_logger = getLogger(__name__) + + +def minify_python(source: str): + """Minify Python source code.""" + # Remove comments + source = re.sub(r"#.*\n", "\n", source) + # Remove docstrings + source = re.sub(r'""".*?"""', "", source, flags=re.DOTALL) + # Remove extra newlines + source = re.sub(r"\n+", "\n", source) + # Remove empty lines + source = re.sub(r"\s+\n", "\n", source) + # Remove leading and trailing whitespace + return source.strip() -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" + +PYSCRIPT_COMPONENT_TEMPLATE = minify_python( + (Path(__file__).parent / "component_template.py").read_text(encoding="utf-8") +) +PYSCRIPT_LAYOUT_HANDLER = minify_python( + (Path(__file__).parent / "layout_handler.py").read_text(encoding="utf-8") ) -_logger = getLogger(__name__) def render_pyscript_executor(file_paths: tuple[str, ...], uuid: str, root: str) -> str: @@ -206,5 +222,6 @@ def reactpy_version_string() -> str: # pragma: no cover @functools.cache -def cached_file_read(file_path: str) -> str: - return Path(file_path).read_text(encoding="utf-8").strip() +def cached_file_read(file_path: str, minifiy=True) -> str: + content = Path(file_path).read_text(encoding="utf-8").strip() + return minify_python(content) if minifiy else content From 16082aee915408db5b8b3a5ae28684e30113ed1e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 7 Feb 2025 21:37:03 -0800 Subject: [PATCH 27/36] component_paths -> file_paths --- src/reactpy/asgi/executors/pyodide.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/reactpy/asgi/executors/pyodide.py b/src/reactpy/asgi/executors/pyodide.py index fe5a4d8d6..5e18e021e 100644 --- a/src/reactpy/asgi/executors/pyodide.py +++ b/src/reactpy/asgi/executors/pyodide.py @@ -22,7 +22,7 @@ class ReactPyPyodide(ReactPy): def __init__( self, - *component_paths: str | Path, + *file_paths: str | Path, extra_py: tuple[str, ...] = (), extra_js: dict[str, str] | None = None, pyscript_config: dict[str, Any] | None = None, @@ -34,13 +34,13 @@ def __init__( **settings: Unpack[ReactPyConfig], ) -> None: """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR) via - PyScript (using a Pyodide interpreter). + Pyodide (using the PyScript API). This ASGI webserver is only used to serve the initial HTML document and static files. Parameters: - component_paths: - File paths to the Python files containing the root component. If multuple paths are + file_paths: + File path(s) to the Python files containing the root component. If multuple paths are provided, the components will be concatenated in the order they were provided. extra_py: Additional Python packages to be made available to the root component. These packages @@ -65,9 +65,9 @@ def __init__( ReactPyMiddleware.__init__( self, app=ReactPyPyodideApp(self), root_components=[], **settings ) - if not component_paths: + if not file_paths: raise ValueError("At least one component file path must be provided.") - self.component_paths = tuple(str(path) for path in component_paths) + self.file_paths = tuple(str(path) for path in file_paths) self.extra_py = extra_py self.extra_js = extra_js or {} self.pyscript_config = pyscript_config or {} @@ -101,7 +101,7 @@ def render_index_html(self) -> None: config=self.parent.pyscript_config, ) pyscript_component = pyscript_component_html( - file_paths=self.parent.component_paths, + file_paths=self.parent.file_paths, initial=self.parent.initial, root=self.parent.root_name, ) From bab3d7141d4903a01c9558dd9ed63c03ddd8365e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:30:34 -0800 Subject: [PATCH 28/36] Add some test cases --- tests/test_asgi/test_middleware.py | 16 ++++++++++++++++ tests/test_asgi/test_pyodide.py | 0 tests/test_asgi/test_standalone.py | 27 +++++++++------------------ 3 files changed, 25 insertions(+), 18 deletions(-) create mode 100644 tests/test_asgi/test_pyodide.py diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index 6027f7782..1bf2192ec 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -5,17 +5,20 @@ import pytest from jinja2 import Environment as JinjaEnvironment from jinja2 import FileSystemLoader as JinjaFileSystemLoader +from requests import request from starlette.applications import Starlette from starlette.routing import Route from starlette.templating import Jinja2Templates import reactpy from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.config import REACTPY_PATH_PREFIX, REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.testing import BackendFixture, DisplayFixture @pytest.fixture() async def display(page): + """Override for the display fixture that uses ReactPyMiddleware.""" templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), @@ -101,3 +104,16 @@ def Hello(): await display.page.reload() await display.page.wait_for_selector("#hello") + + +async def test_static_file_not_found(page): + async def app(scope, receive, send): ... + + app = ReactPyMiddleware(app, []) + + async with BackendFixture(app) as server: + url = f"http://{server.host}:{server.port}{REACTPY_PATH_PREFIX.current}static/invalid.js" + response = await asyncio.to_thread( + request, "GET", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) + assert response.status_code == 404 diff --git a/tests/test_asgi/test_pyodide.py b/tests/test_asgi/test_pyodide.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 64e81797b..a091aae6b 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -14,13 +14,6 @@ from reactpy.types import Connection, Location -@pytest.fixture() -async def display(page): - async with BackendFixture() as server: - async with DisplayFixture(backend=server, driver=page) as display: - yield display - - async def test_display_simple_hello_world(display: DisplayFixture): @reactpy.component def Hello(): @@ -153,17 +146,15 @@ def sample(): app = ReactPy(sample) async with BackendFixture(app) as server: - async with DisplayFixture(backend=server, driver=page) as new_display: - await new_display.show(sample) - url = f"http://{server.host}:{server.port}" - response = await asyncio.to_thread( - request, "HEAD", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current - ) - assert response.status_code == 200 - assert response.headers["content-type"] == "text/html; charset=utf-8" - assert response.headers["cache-control"] == "max-age=60, public" - assert response.headers["access-control-allow-origin"] == "*" - assert response.content == b"" + url = f"http://{server.host}:{server.port}" + response = await asyncio.to_thread( + request, "HEAD", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + assert response.headers["cache-control"] == "max-age=60, public" + assert response.headers["access-control-allow-origin"] == "*" + assert response.content == b"" async def test_custom_http_app(): From 9a3418f31014660be29bbfe50a5382f995fb59df Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:54 -0800 Subject: [PATCH 29/36] refactor executors module --- src/reactpy/asgi/__init__.py | 5 -- src/reactpy/{asgi => }/executors/__init__.py | 0 src/reactpy/executors/asgi/__init__.py | 5 ++ .../{ => executors}/asgi/middleware.py | 12 ++--- .../pyodide.py => executors/asgi/pyscript.py} | 12 ++--- .../asgi}/standalone.py | 16 ++++--- src/reactpy/{ => executors}/asgi/types.py | 0 src/reactpy/{asgi => executors}/utils.py | 27 ++--------- src/reactpy/utils.py | 21 +++++++++ tests/test_asgi/test_utils.py | 19 +------- tests/test_utils.py | 47 ++++++++++++------- 11 files changed, 81 insertions(+), 83 deletions(-) delete mode 100644 src/reactpy/asgi/__init__.py rename src/reactpy/{asgi => }/executors/__init__.py (100%) create mode 100644 src/reactpy/executors/asgi/__init__.py rename src/reactpy/{ => executors}/asgi/middleware.py (98%) rename src/reactpy/{asgi/executors/pyodide.py => executors/asgi/pyscript.py} (94%) rename src/reactpy/{asgi/executors => executors/asgi}/standalone.py (94%) rename src/reactpy/{ => executors}/asgi/types.py (100%) rename src/reactpy/{asgi => executors}/utils.py (75%) diff --git a/src/reactpy/asgi/__init__.py b/src/reactpy/asgi/__init__.py deleted file mode 100644 index edbc2e28e..000000000 --- a/src/reactpy/asgi/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from reactpy.asgi.executors.pyodide import ReactPyPyodide -from reactpy.asgi.executors.standalone import ReactPy -from reactpy.asgi.middleware import ReactPyMiddleware - -__all__ = ["ReactPy", "ReactPyMiddleware", "ReactPyPyodide"] diff --git a/src/reactpy/asgi/executors/__init__.py b/src/reactpy/executors/__init__.py similarity index 100% rename from src/reactpy/asgi/executors/__init__.py rename to src/reactpy/executors/__init__.py diff --git a/src/reactpy/executors/asgi/__init__.py b/src/reactpy/executors/asgi/__init__.py new file mode 100644 index 000000000..e7c9716af --- /dev/null +++ b/src/reactpy/executors/asgi/__init__.py @@ -0,0 +1,5 @@ +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.pyscript import ReactPyPyscript +from reactpy.executors.asgi.standalone import ReactPy + +__all__ = ["ReactPy", "ReactPyMiddleware", "ReactPyPyscript"] diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py similarity index 98% rename from src/reactpy/asgi/middleware.py rename to src/reactpy/executors/asgi/middleware.py index fde58087c..7500f03e2 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -18,7 +18,10 @@ from typing_extensions import Unpack from reactpy import config -from reactpy.asgi.types import ( +from reactpy.core.hooks import ConnectionContext +from reactpy.core.layout import Layout +from reactpy.core.serve import serve_layout +from reactpy.executors.asgi.types import ( AsgiApp, AsgiHttpApp, AsgiLifespanApp, @@ -26,10 +29,7 @@ AsgiWebsocketReceive, AsgiWebsocketSend, ) -from reactpy.asgi.utils import check_path, import_components, process_settings -from reactpy.core.hooks import ConnectionContext -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout +from reactpy.executors.utils import check_path, import_components, process_settings from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor _logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ def __init__( # Directory attributes self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current - self.static_dir = Path(__file__).parent.parent / "static" + self.static_dir = Path(__file__).parent.parent.parent / "static" # Initialize the sub-applications self.component_dispatch_app = ComponentDispatchApp(parent=self) diff --git a/src/reactpy/asgi/executors/pyodide.py b/src/reactpy/executors/asgi/pyscript.py similarity index 94% rename from src/reactpy/asgi/executors/pyodide.py rename to src/reactpy/executors/asgi/pyscript.py index 5e18e021e..d1e8cb3ac 100644 --- a/src/reactpy/asgi/executors/pyodide.py +++ b/src/reactpy/executors/asgi/pyscript.py @@ -12,14 +12,14 @@ from typing_extensions import Unpack from reactpy import html -from reactpy.asgi.executors.standalone import ReactPy, ReactPyApp -from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.utils import vdom_head_to_html +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.standalone import ReactPy, ReactPyApp +from reactpy.executors.utils import vdom_head_to_html from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_html from reactpy.types import ReactPyConfig, VdomDict -class ReactPyPyodide(ReactPy): +class ReactPyPyscript(ReactPy): def __init__( self, *file_paths: str | Path, @@ -34,7 +34,7 @@ def __init__( **settings: Unpack[ReactPyConfig], ) -> None: """Variant of ReactPy's standalone that only performs Client-Side Rendering (CSR) via - Pyodide (using the PyScript API). + PyScript (using the Pyodide interpreter). This ASGI webserver is only used to serve the initial HTML document and static files. @@ -87,7 +87,7 @@ def match_dispatch_path(self, scope: WebSocketScope) -> bool: class ReactPyPyodideApp(ReactPyApp): """ReactPy's standalone ASGI application for Client-Side Rendering (CSR).""" - parent: ReactPyPyodide + parent: ReactPyPyscript _index_html = "" _etag = "" _last_modified = "" diff --git a/src/reactpy/asgi/executors/standalone.py b/src/reactpy/executors/asgi/standalone.py similarity index 94% rename from src/reactpy/asgi/executors/standalone.py rename to src/reactpy/executors/asgi/standalone.py index 35b5b88cf..4fbb42119 100644 --- a/src/reactpy/asgi/executors/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -13,14 +13,16 @@ from typing_extensions import Unpack from reactpy import html -from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.types import AsgiApp, AsgiHttpApp, AsgiLifespanApp, AsgiWebsocketApp -from reactpy.asgi.utils import ( - asgi_component_html, - import_dotted_path, - vdom_head_to_html, +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.types import ( + AsgiApp, + AsgiHttpApp, + AsgiLifespanApp, + AsgiWebsocketApp, ) +from reactpy.executors.utils import server_side_component_html, vdom_head_to_html from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict +from reactpy.utils import import_dotted_path _logger = getLogger(__name__) @@ -209,7 +211,7 @@ def render_index_html(self) -> None: f'' f"{vdom_head_to_html(self.parent.html_head)}" "" - f"{asgi_component_html(element_id='app', class_='', component_path='')}" + f"{server_side_component_html(element_id='app', class_='', component_path='')}" "" "" ) diff --git a/src/reactpy/asgi/types.py b/src/reactpy/executors/asgi/types.py similarity index 100% rename from src/reactpy/asgi/types.py rename to src/reactpy/executors/asgi/types.py diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/executors/utils.py similarity index 75% rename from src/reactpy/asgi/utils.py rename to src/reactpy/executors/utils.py index 11f00b686..e29cdf5c6 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/executors/utils.py @@ -2,7 +2,6 @@ import logging from collections.abc import Iterable -from importlib import import_module from typing import Any from reactpy._option import Option @@ -14,31 +13,11 @@ REACTPY_RECONNECT_MAX_RETRIES, ) from reactpy.types import ReactPyConfig, VdomDict -from reactpy.utils import vdom_to_html +from reactpy.utils import import_dotted_path, vdom_to_html logger = logging.getLogger(__name__) -def import_dotted_path(dotted_path: str) -> Any: - """Imports a dotted path and returns the callable.""" - if "." not in dotted_path: - raise ValueError(f'"{dotted_path}" is not a valid dotted path.') - - module_name, component_name = dotted_path.rsplit(".", 1) - - try: - module = import_module(module_name) - except ImportError as error: - msg = f'ReactPy failed to import "{module_name}"' - raise ImportError(msg) from error - - try: - return getattr(module, component_name) - except AttributeError as error: - msg = f'ReactPy failed to import "{component_name}" from "{module_name}"' - raise AttributeError(msg) from error - - def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: """Imports a list of dotted paths and returns the callables.""" return { @@ -82,7 +61,9 @@ def process_settings(settings: ReactPyConfig) -> None: raise ValueError(f'Unknown ReactPy setting "{setting}".') -def asgi_component_html(element_id: str, class_: str, component_path: str) -> str: +def server_side_component_html( + element_id: str, class_: str, component_path: str +) -> str: return ( f'
' '