Skip to content

Commit babc2de

Browse files
authored
Client-Side Python Components (#1269)
- Add template tags for rendering pyscript components - Add `pyscript_component` component to embed pyscript components into standard ReactPy server-side applications - Create new ASGI app that can run standalone client-side ReactPy - Convert all ASGI dependencies into an optional `reactpy[asgi]` parameter to minimize client-side install size - Start throwing 404 errors when static files are not found
1 parent 49bdda1 commit babc2de

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1320
-291
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# --- Build Artifacts ---
2-
src/reactpy/static/*
2+
src/reactpy/static/index.js*
3+
src/reactpy/static/morphdom/
4+
src/reactpy/static/pyscript/
35

46
# --- Jupyter ---
57
*.ipynb_checkpoints

docs/source/about/changelog.rst

+6-4
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ Unreleased
1616
----------
1717

1818
**Added**
19-
- :pull:`1113` - Added ``reactpy.ReactPy`` that can be used to run ReactPy in standalone mode.
20-
- :pull:`1113` - Added ``reactpy.ReactPyMiddleware`` that can be used to run ReactPy with any ASGI compatible framework.
21-
- :pull:`1113` - Added ``reactpy.jinja.Component`` that can be used alongside ``ReactPyMiddleware`` to embed several ReactPy components into your existing application.
22-
- :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``).
19+
- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI.
20+
- :pull:`1269` - Added ``reactpy.executors.asgi.ReactPyPyodide`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided.
21+
- :pull:`1113` - Added ``reactpy.executors.asgi.ReactPyMiddleware`` that can be used to utilize ReactPy within any ASGI compatible framework.
22+
- :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 %}``.
23+
- :pull:`1269` - Added ``reactpy.pyscript_component`` that can be used to embed ReactPy components into your existing application.
24+
- :pull:`1113` - Added ``uvicorn`` and ``jinja`` installation extras (for example ``pip install reactpy[jinja]``).
2325
- :pull:`1113` - Added support for Python 3.12 and 3.13.
2426
- :pull:`1264` - Added ``reactpy.use_async_effect`` hook.
2527
- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook.

pyproject.toml

+18-22
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ readme = "README.md"
1313
keywords = ["react", "javascript", "reactpy", "component"]
1414
license = "MIT"
1515
authors = [
16-
{ name = "Ryan Morshead", email = "[email protected]" },
1716
{ name = "Mark Bakhit", email = "[email protected]" },
17+
{ name = "Ryan Morshead", email = "[email protected]" },
1818
]
1919
requires-python = ">=3.9"
2020
classifiers = [
@@ -28,24 +28,24 @@ classifiers = [
2828
"Programming Language :: Python :: Implementation :: PyPy",
2929
]
3030
dependencies = [
31-
"exceptiongroup >=1.0",
32-
"typing-extensions >=3.10",
33-
"anyio >=3",
34-
"jsonpatch >=1.32",
3531
"fastjsonschema >=2.14.5",
3632
"requests >=2",
37-
"colorlog >=6",
38-
"asgiref >=3",
3933
"lxml >=4",
40-
"servestatic >=3.0.0",
41-
"orjson >=3",
42-
"asgi-tools",
34+
"anyio >=3",
35+
"typing-extensions >=3.10",
4336
]
4437
dynamic = ["version"]
4538
urls.Changelog = "https://reactpy.dev/docs/about/changelog.html"
4639
urls.Documentation = "https://reactpy.dev/"
4740
urls.Source = "https://github.com/reactive-python/reactpy"
4841

42+
[project.optional-dependencies]
43+
all = ["reactpy[asgi,jinja,uvicorn,testing]"]
44+
asgi = ["asgiref", "asgi-tools", "servestatic", "orjson", "pip"]
45+
jinja = ["jinja2-simple-tags", "jinja2 >=3"]
46+
uvicorn = ["uvicorn[standard]"]
47+
testing = ["playwright"]
48+
4949
[tool.hatch.version]
5050
path = "src/reactpy/__init__.py"
5151

@@ -75,32 +75,24 @@ commands = [
7575
'bun run --cwd "src/js/packages/@reactpy/client" build',
7676
'bun install --cwd "src/js/packages/@reactpy/app"',
7777
'bun run --cwd "src/js/packages/@reactpy/app" build',
78-
'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static"',
78+
'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/node_modules/@pyscript/core/dist" "src/reactpy/static/pyscript"',
79+
'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/node_modules/morphdom/dist" "src/reactpy/static/morphdom"',
7980
]
8081
artifacts = []
8182

82-
[project.optional-dependencies]
83-
all = ["reactpy[jinja,uvicorn,testing]"]
84-
standard = ["reactpy[jinja,uvicorn]"]
85-
jinja = ["jinja2-simple-tags", "jinja2 >=3"]
86-
uvicorn = ["uvicorn[standard]"]
87-
testing = ["playwright"]
88-
8983

9084
#############################
9185
# >>> Hatch Test Runner <<< #
9286
#############################
9387

9488
[tool.hatch.envs.hatch-test]
9589
extra-dependencies = [
90+
"reactpy[all]",
9691
"pytest-sugar",
9792
"pytest-asyncio",
9893
"responses",
99-
"playwright",
94+
"exceptiongroup",
10095
"jsonpointer",
101-
"uvicorn[standard]",
102-
"jinja2-simple-tags",
103-
"jinja2",
10496
"starlette",
10597
]
10698

@@ -160,6 +152,7 @@ serve = [
160152

161153
[tool.hatch.envs.python]
162154
extra-dependencies = [
155+
"reactpy[all]",
163156
"ruff",
164157
"toml",
165158
"mypy==1.8",
@@ -240,6 +233,8 @@ omit = [
240233
"src/reactpy/__init__.py",
241234
"src/reactpy/_console/*",
242235
"src/reactpy/__main__.py",
236+
"src/reactpy/pyscript/layout_handler.py",
237+
"src/reactpy/pyscript/component_template.py",
243238
]
244239

245240
[tool.coverage.report]
@@ -325,6 +320,7 @@ lint.unfixable = [
325320

326321
[tool.ruff.lint.isort]
327322
known-first-party = ["reactpy"]
323+
known-third-party = ["js"]
328324

329325
[tool.ruff.lint.per-file-ignores]
330326
# Tests can use magic values, assertions, and relative imports
8.39 KB
Binary file not shown.

src/js/packages/@reactpy/app/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
"preact": "^10.25.4"
99
},
1010
"devDependencies": {
11-
"typescript": "^5.7.3"
11+
"typescript": "^5.7.3",
12+
"@pyscript/core": "^0.6",
13+
"morphdom": "^2"
1214
},
1315
"scripts": {
14-
"build": "bun build \"src/index.ts\" --outdir=\"dist\" --minify --sourcemap=\"linked\"",
16+
"build": "bun build \"src/index.ts\" --outdir=\"../../../../reactpy/static/\" --minify --sourcemap=\"linked\"",
1517
"checkTypes": "tsc --noEmit"
1618
}
1719
}

src/js/packages/@reactpy/client/src/mount.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function mountReactPy(props: MountProps) {
88
const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`;
99
const wsOrigin = `${wsProtocol}//${window.location.host}`;
1010
const componentUrl = new URL(
11-
`${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`,
11+
`${wsOrigin}${props.pathPrefix}${props.componentPath || ""}`,
1212
);
1313

1414
// Embed the initial HTTP path into the WebSocket URL

src/js/packages/@reactpy/client/src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export type GenericReactPyClientProps = {
3535
export type MountProps = {
3636
mountElement: HTMLElement;
3737
pathPrefix: string;
38-
appendComponentPath?: string;
38+
componentPath?: string;
3939
reconnectInterval?: number;
4040
reconnectMaxInterval?: number;
4141
reconnectMaxRetries?: number;

src/reactpy/__init__.py

+3-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
from reactpy import asgi, config, logging, types, web, widgets
1+
from reactpy import config, logging, types, web, widgets
22
from reactpy._html import html
3-
from reactpy.asgi.middleware import ReactPyMiddleware
4-
from reactpy.asgi.standalone import ReactPy
53
from reactpy.core import hooks
64
from reactpy.core.component import component
75
from reactpy.core.events import event
@@ -22,17 +20,15 @@
2220
)
2321
from reactpy.core.layout import Layout
2422
from reactpy.core.vdom import vdom
23+
from reactpy.pyscript.components import pyscript_component
2524
from reactpy.utils import Ref, html_to_vdom, vdom_to_html
2625

2726
__author__ = "The Reactive Python Team"
2827
__version__ = "2.0.0a1"
2928

3029
__all__ = [
3130
"Layout",
32-
"ReactPy",
33-
"ReactPyMiddleware",
3431
"Ref",
35-
"asgi",
3632
"component",
3733
"config",
3834
"create_context",
@@ -41,6 +37,7 @@
4137
"html",
4238
"html_to_vdom",
4339
"logging",
40+
"pyscript_component",
4441
"types",
4542
"use_async_effect",
4643
"use_callback",

src/reactpy/core/hooks.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
overload,
1717
)
1818

19-
from asgiref import typing as asgi_types
2019
from typing_extensions import TypeAlias
2120

2221
from reactpy.config import REACTPY_DEBUG
@@ -25,9 +24,11 @@
2524
from reactpy.utils import Ref
2625

2726
if not TYPE_CHECKING:
28-
# make flake8 think that this variable exists
2927
ellipsis = type(...)
3028

29+
if TYPE_CHECKING:
30+
from asgiref import typing as asgi_types
31+
3132

3233
__all__ = [
3334
"use_async_effect",
@@ -339,7 +340,7 @@ def use_connection() -> Connection[Any]:
339340

340341
def use_scope() -> asgi_types.HTTPScope | asgi_types.WebSocketScope:
341342
"""Get the current :class:`~reactpy.types.Connection`'s scope."""
342-
return use_connection().scope
343+
return use_connection().scope # type: ignore
343344

344345

345346
def use_location() -> Location:
File renamed without changes.
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from reactpy.executors.asgi.middleware import ReactPyMiddleware
2+
from reactpy.executors.asgi.pyscript import ReactPyPyscript
3+
from reactpy.executors.asgi.standalone import ReactPy
4+
5+
__all__ = ["ReactPy", "ReactPyMiddleware", "ReactPyPyscript"]

src/reactpy/asgi/middleware.py src/reactpy/executors/asgi/middleware.py

+23-17
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,26 @@
1111
from typing import Any
1212

1313
import orjson
14-
from asgi_tools import ResponseWebSocket
14+
from asgi_tools import ResponseText, ResponseWebSocket
1515
from asgiref import typing as asgi_types
1616
from asgiref.compatibility import guarantee_single_callable
1717
from servestatic import ServeStaticASGI
1818
from typing_extensions import Unpack
1919

2020
from reactpy import config
21-
from reactpy.asgi.utils import check_path, import_components, process_settings
2221
from reactpy.core.hooks import ConnectionContext
2322
from reactpy.core.layout import Layout
2423
from reactpy.core.serve import serve_layout
25-
from reactpy.types import (
24+
from reactpy.executors.asgi.types import (
2625
AsgiApp,
2726
AsgiHttpApp,
2827
AsgiLifespanApp,
2928
AsgiWebsocketApp,
3029
AsgiWebsocketReceive,
3130
AsgiWebsocketSend,
32-
Connection,
33-
Location,
34-
ReactPyConfig,
35-
RootComponentConstructor,
3631
)
32+
from reactpy.executors.utils import check_path, import_components, process_settings
33+
from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor
3734

3835
_logger = logging.getLogger(__name__)
3936

@@ -81,8 +78,6 @@ def __init__(
8178
self.dispatcher_pattern = re.compile(
8279
f"^{self.dispatcher_path}(?P<dotted_path>[a-zA-Z0-9_.]+)/$"
8380
)
84-
self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*")
85-
self.static_pattern = re.compile(f"^{self.static_path}.*")
8681

8782
# User defined ASGI apps
8883
self.extra_http_routes: dict[str, AsgiHttpApp] = {}
@@ -95,7 +90,7 @@ def __init__(
9590

9691
# Directory attributes
9792
self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current
98-
self.static_dir = Path(__file__).parent.parent / "static"
93+
self.static_dir = Path(__file__).parent.parent.parent / "static"
9994

10095
# Initialize the sub-applications
10196
self.component_dispatch_app = ComponentDispatchApp(parent=self)
@@ -134,14 +129,14 @@ def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool:
134129
return bool(re.match(self.dispatcher_pattern, scope["path"]))
135130

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

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

142137
def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None:
143-
# Custom defined routes are unused within middleware to encourage users to handle
144-
# routing within their root ASGI application.
138+
# Custom defined routes are unused by default to encourage users to handle
139+
# routing within their ASGI framework of choice.
145140
return None
146141

147142

@@ -224,7 +219,7 @@ async def run_dispatcher(self) -> None:
224219
self.scope["query_string"].decode(), strict_parsing=True
225220
)
226221
connection = Connection(
227-
scope=self.scope,
222+
scope=self.scope, # type: ignore
228223
location=Location(
229224
path=ws_query_string.get("http_pathname", [""])[0],
230225
query_string=ws_query_string.get("http_query_string", [""])[0],
@@ -263,7 +258,7 @@ async def __call__(
263258
"""ASGI app for ReactPy static files."""
264259
if not self._static_file_server:
265260
self._static_file_server = ServeStaticASGI(
266-
self.parent.asgi_app,
261+
Error404App(),
267262
root=self.parent.static_dir,
268263
prefix=self.parent.static_path,
269264
)
@@ -285,10 +280,21 @@ async def __call__(
285280
"""ASGI app for ReactPy web modules."""
286281
if not self._static_file_server:
287282
self._static_file_server = ServeStaticASGI(
288-
self.parent.asgi_app,
283+
Error404App(),
289284
root=self.parent.web_modules_dir,
290285
prefix=self.parent.web_modules_path,
291286
autorefresh=True,
292287
)
293288

294289
await self._static_file_server(scope, receive, send)
290+
291+
292+
class Error404App:
293+
async def __call__(
294+
self,
295+
scope: asgi_types.HTTPScope,
296+
receive: asgi_types.ASGIReceiveCallable,
297+
send: asgi_types.ASGISendCallable,
298+
) -> None:
299+
response = ResponseText("Resource not found on this server.", status_code=404)
300+
await response(scope, receive, send) # type: ignore

0 commit comments

Comments
 (0)