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 all 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
10 changes: 6 additions & 4 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ 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.executors.asgi.ReactPy`` that can be used to run ReactPy in standalone mode via ASGI.
- :pull:`1269` - Added ``reactpy.executors.asgi.ReactPyPyodide`` that can be used to run ReactPy in standalone mode via ASGI, but rendered entirely client-sided.
- :pull:`1113` - Added ``reactpy.executors.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.
- :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
40 changes: 18 additions & 22 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 All @@ -28,24 +28,24 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
]
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",
"orjson >=3",
"asgi-tools",
"anyio >=3",
"typing-extensions >=3.10",
]
dynamic = ["version"]
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[asgi,jinja,uvicorn,testing]"]
asgi = ["asgiref", "asgi-tools", "servestatic", "orjson", "pip"]
jinja = ["jinja2-simple-tags", "jinja2 >=3"]
uvicorn = ["uvicorn[standard]"]
testing = ["playwright"]

[tool.hatch.version]
path = "src/reactpy/__init__.py"

Expand Down Expand Up @@ -75,32 +75,24 @@ 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"]


#############################
# >>> Hatch Test Runner <<< #
#############################

[tool.hatch.envs.hatch-test]
extra-dependencies = [
"reactpy[all]",
"pytest-sugar",
"pytest-asyncio",
"responses",
"playwright",
"exceptiongroup",
"jsonpointer",
"uvicorn[standard]",
"jinja2-simple-tags",
"jinja2",
"starlette",
]

Expand Down Expand Up @@ -160,6 +152,7 @@ serve = [

[tool.hatch.envs.python]
extra-dependencies = [
"reactpy[all]",
"ruff",
"toml",
"mypy==1.8",
Expand Down Expand Up @@ -240,6 +233,8 @@ omit = [
"src/reactpy/__init__.py",
"src/reactpy/_console/*",
"src/reactpy/__main__.py",
"src/reactpy/pyscript/layout_handler.py",
"src/reactpy/pyscript/component_template.py",
]

[tool.coverage.report]
Expand Down Expand Up @@ -325,6 +320,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
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
9 changes: 3 additions & 6 deletions src/reactpy/__init__.py
Original file line number Diff line number Diff line change
@@ -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.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 All @@ -22,17 +20,15 @@
)
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"
__version__ = "2.0.0a1"

__all__ = [
"Layout",
"ReactPy",
"ReactPyMiddleware",
"Ref",
"asgi",
"component",
"config",
"create_context",
Expand All @@ -41,6 +37,7 @@
"html",
"html_to_vdom",
"logging",
"pyscript_component",
"types",
"use_async_effect",
"use_callback",
Expand Down
7 changes: 4 additions & 3 deletions src/reactpy/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
overload,
)

from asgiref import typing as asgi_types
from typing_extensions import TypeAlias

from reactpy.config import REACTPY_DEBUG
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
File renamed without changes.
5 changes: 5 additions & 0 deletions src/reactpy/executors/asgi/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,26 @@
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
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.executors.asgi.types import (
AsgiApp,
AsgiHttpApp,
AsgiLifespanApp,
AsgiWebsocketApp,
AsgiWebsocketReceive,
AsgiWebsocketSend,
Connection,
Location,
ReactPyConfig,
RootComponentConstructor,
)
from reactpy.executors.utils import check_path, import_components, process_settings
from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -81,8 +78,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 All @@ -95,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)
Expand Down Expand Up @@ -134,14 +129,14 @@ 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
# routing within their root ASGI application.
# Custom defined routes are unused by default to encourage users to handle
# routing within their ASGI framework of choice.
return None


Expand Down Expand Up @@ -224,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],
Expand Down Expand Up @@ -263,7 +258,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 +280,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
Loading