Skip to content

Client-Side Python Components #1269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 36 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
58a4e37
First draft of pyscript support
Archmonger Feb 5, 2025
6dea8b3
move standalone to executors module
Archmonger Feb 5, 2025
9dccf73
prototype pysript executor
Archmonger Feb 5, 2025
e4f5901
functional rendering on standalone pyscript
Archmonger Feb 6, 2025
02d114c
Fix pyscript event handling
Archmonger Feb 6, 2025
f30a041
Resolve type checker warnings
Archmonger Feb 6, 2025
e6136a0
move jinja template tag module
Archmonger Feb 6, 2025
027f090
remove standard installation extra
Archmonger Feb 6, 2025
afbd437
automate pyscript/morphdom static building
Archmonger Feb 6, 2025
6f092a8
Move optional dependencies up
Archmonger Feb 6, 2025
6b1e50b
remove unused dependency
Archmonger Feb 6, 2025
5afa28b
fix tests
Archmonger Feb 6, 2025
2119fb1
Reduce dependencies by making ASGI optional
Archmonger Feb 7, 2025
36c6642
Use local ReactPy wheel for unpublished releases.
Archmonger Feb 7, 2025
d944643
move asgi_component_html function
Archmonger Feb 7, 2025
8f29887
remove useless async
Archmonger Feb 7, 2025
d5f3bec
docstring for ReactPyCSR
Archmonger Feb 7, 2025
74d13ef
ReactPyCSRApp
Archmonger Feb 7, 2025
208cdc6
Add JS as known third party pkg
Archmonger Feb 7, 2025
4169d46
Add changelog
Archmonger Feb 7, 2025
28c4374
Add JS as known third party pkg
Archmonger Feb 7, 2025
f0c47dc
Expose pyscript components at top level
Archmonger Feb 7, 2025
9a485bd
CSR -> Pyodide
Archmonger Feb 7, 2025
1e74681
Temporary fix to pyscript bug
Archmonger Feb 7, 2025
459bcc5
use new pyscript syntax
Archmonger Feb 7, 2025
f387f6d
regex based python minification
Archmonger Feb 7, 2025
16082ae
component_paths -> file_paths
Archmonger Feb 8, 2025
bab3d71
Add some test cases
Archmonger Feb 8, 2025
9a3418f
refactor executors module
Archmonger Feb 8, 2025
5f680b8
Add tests for standalone pyscript
Archmonger Feb 9, 2025
5d2877b
Add configuration option to standlone reactpy to auto-load pyscript
Archmonger Feb 9, 2025
1f6fb2c
Some pyscript component tests
Archmonger Feb 9, 2025
23213d1
100% coverage
Archmonger Feb 9, 2025
4749212
Fix type check warnings
Archmonger Feb 9, 2025
c9d16c3
format imports
Archmonger Feb 9, 2025
ea46a94
self review
Archmonger Feb 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# --- Build Artifacts ---
src/reactpy/static/*
src/reactpy/static/index.js*
src/reactpy/static/morphdom/
src/reactpy/static/pyscript/

# --- Jupyter ---
*.ipynb_checkpoints
Expand Down
4 changes: 2 additions & 2 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,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.jinja.Component`` 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 ``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.
- :pull:`1264` - Added ``reactpy.use_async_effect`` hook.
- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ readme = "README.md"
keywords = ["react", "javascript", "reactpy", "component"]
license = "MIT"
authors = [
{ name = "Ryan Morshead", email = "[email protected]" },
{ name = "Mark Bakhit", email = "[email protected]" },
{ name = "Ryan Morshead", email = "[email protected]" },
]
requires-python = ">=3.9"
classifiers = [
Expand Down Expand Up @@ -75,13 +75,13 @@ 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 = []

[project.optional-dependencies]
all = ["reactpy[jinja,uvicorn,testing]"]
standard = ["reactpy[jinja,uvicorn]"]
jinja = ["jinja2-simple-tags", "jinja2 >=3"]
uvicorn = ["uvicorn[standard]"]
testing = ["playwright"]
Expand Down
Binary file modified src/js/packages/@reactpy/app/bun.lockb
Binary file not shown.
6 changes: 4 additions & 2 deletions src/js/packages/@reactpy/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
"preact": "^10.25.4"
},
"devDependencies": {
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"@pyscript/core": "^0.6",
"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"
}
}
2 changes: 1 addition & 1 deletion src/js/packages/@reactpy/client/src/mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/js/packages/@reactpy/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type GenericReactPyClientProps = {
export type MountProps = {
mountElement: HTMLElement;
pathPrefix: string;
appendComponentPath?: string;
componentPath?: string;
reconnectInterval?: number;
reconnectMaxInterval?: number;
reconnectMaxRetries?: number;
Expand Down
2 changes: 1 addition & 1 deletion src/reactpy/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/reactpy/_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
100 changes: 100 additions & 0 deletions src/reactpy/asgi/executors/pyscript.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 import Any

from asgiref.typing import WebSocketScope
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, Any] | str = "",
pyscript_config: dict[str, Any] | 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

def match_dispatch_path(self, scope: WebSocketScope) -> bool:
return False


@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 = head_content.replace("</head>", f"{pyscript_setup}</head>")

self._index_html = (
"<!doctype html>"
f'<html lang="{self.parent.html_lang}">'
f"{head_content}"
"<body>"
f"{pyscript_component}"
"</body>"
"</html>"
)
self._etag = f'"{hashlib.md5(self._index_html.encode(), usedforsecurity=False).hexdigest()}"'
self._last_modified = formatdate(
datetime.now(tz=timezone.utc).timestamp(), usegmt=True
)
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
RootComponentConstructor,
VdomDict,
)
from reactpy.utils import render_mount_template
from reactpy.utils import asgi_component_html

_logger = getLogger(__name__)

Expand Down Expand Up @@ -151,7 +151,7 @@ class ReactPyApp:
to a user provided ASGI app."""

parent: ReactPy
_cached_index_html = ""
_index_html = ""
_etag = ""
_last_modified = ""

Expand All @@ -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_html()

# Response headers for `index.html` responses
request_headers = dict(scope["headers"])
Expand All @@ -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,
}
Expand All @@ -203,22 +203,21 @@ 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_html(self) -> None:
"""Process the index.html and store the results in this class."""
self._index_html = (
"<!doctype html>"
f'<html lang="{self.parent.html_lang}">'
f"{vdom_head_to_html(self.parent.html_head)}"
"<body>"
f"{render_mount_template('app', '', '')}"
f"{asgi_component_html(element_id='app', class_='', component_path='')}"
"</body>"
"</html>"
)

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
)
25 changes: 17 additions & 8 deletions src/reactpy/asgi/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Any

import orjson
from asgi_tools import ResponseWebSocket
from asgi_tools import ResponseText, ResponseWebSocket
from asgiref import typing as asgi_types
from asgiref.compatibility import guarantee_single_callable
from servestatic import ServeStaticASGI
Expand Down Expand Up @@ -81,8 +81,6 @@ def __init__(
self.dispatcher_pattern = re.compile(
f"^{self.dispatcher_path}(?P<dotted_path>[a-zA-Z0-9_.]+)/$"
)
self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*")
self.static_pattern = re.compile(f"^{self.static_path}.*")

# User defined ASGI apps
self.extra_http_routes: dict[str, AsgiHttpApp] = {}
Expand Down Expand Up @@ -134,13 +132,13 @@ def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool:
return bool(re.match(self.dispatcher_pattern, scope["path"]))

def match_static_path(self, scope: asgi_types.HTTPScope) -> bool:
return bool(re.match(self.static_pattern, scope["path"]))
return scope["path"].startswith(self.static_path)

def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool:
return bool(re.match(self.js_modules_pattern, scope["path"]))
return scope["path"].startswith(self.web_modules_path)

def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None:
# Custom defined routes are unused within middleware to encourage users to handle
# Custom defined routes are unused by default to encourage users to handle
# routing within their root ASGI application.
return None

Expand Down Expand Up @@ -263,7 +261,7 @@ async def __call__(
"""ASGI app for ReactPy static files."""
if not self._static_file_server:
self._static_file_server = ServeStaticASGI(
self.parent.asgi_app,
Error404App(),
root=self.parent.static_dir,
prefix=self.parent.static_path,
)
Expand All @@ -285,10 +283,21 @@ async def __call__(
"""ASGI app for ReactPy web modules."""
if not self._static_file_server:
self._static_file_server = ServeStaticASGI(
self.parent.asgi_app,
Error404App(),
root=self.parent.web_modules_dir,
prefix=self.parent.web_modules_path,
autorefresh=True,
)

await self._static_file_server(scope, receive, send)


class Error404App:
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) # type: ignore
21 changes: 0 additions & 21 deletions src/reactpy/jinja.py

This file was deleted.

Empty file.
28 changes: 28 additions & 0 deletions src/reactpy/pyscript/component_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# ruff: noqa: TC004, N802, N816, RUF006
# type: ignore
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))
Loading
Loading