diff --git a/.gitignore b/.gitignore index c5f91d024..40cd524ba 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 848153251..c90a8dcff 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -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. diff --git a/pyproject.toml b/pyproject.toml index c485dce2f..fc9804508 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 = [ @@ -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" @@ -75,17 +75,11 @@ 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 <<< # @@ -93,14 +87,12 @@ testing = ["playwright"] [tool.hatch.envs.hatch-test] extra-dependencies = [ + "reactpy[all]", "pytest-sugar", "pytest-asyncio", "responses", - "playwright", + "exceptiongroup", "jsonpointer", - "uvicorn[standard]", - "jinja2-simple-tags", - "jinja2", "starlette", ] @@ -160,6 +152,7 @@ serve = [ [tool.hatch.envs.python] extra-dependencies = [ + "reactpy[all]", "ruff", "toml", "mypy==1.8", @@ -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] @@ -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 diff --git a/src/js/packages/@reactpy/app/bun.lockb b/src/js/packages/@reactpy/app/bun.lockb index b32e03eae..bd09c30d6 100644 Binary files a/src/js/packages/@reactpy/app/bun.lockb and b/src/js/packages/@reactpy/app/bun.lockb differ diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index 5efc163c3..ca3a4370d 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -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" } } 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/__init__.py b/src/reactpy/__init__.py index 258cd5053..5413d0b07 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.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 @@ -22,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" @@ -29,10 +28,7 @@ __all__ = [ "Layout", - "ReactPy", - "ReactPyMiddleware", "Ref", - "asgi", "component", "config", "create_context", @@ -41,6 +37,7 @@ "html", "html_to_vdom", "logging", + "pyscript_component", "types", "use_async_effect", "use_callback", 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/asgi/__init__.py b/src/reactpy/executors/__init__.py similarity index 100% rename from src/reactpy/asgi/__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 90% rename from src/reactpy/asgi/middleware.py rename to src/reactpy/executors/asgi/middleware.py index b61df48a7..58dcdc8c6 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -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__) @@ -81,8 +78,6 @@ def __init__( self.dispatcher_pattern = re.compile( f"^{self.dispatcher_path}(?P[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] = {} @@ -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) @@ -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 @@ -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], @@ -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, ) @@ -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 diff --git a/src/reactpy/executors/asgi/pyscript.py b/src/reactpy/executors/asgi/pyscript.py new file mode 100644 index 000000000..af2b6fafd --- /dev/null +++ b/src/reactpy/executors/asgi/pyscript.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import hashlib +import re +from collections.abc import Sequence +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.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 ReactPyPyscript(ReactPy): + def __init__( + self, + *file_paths: str | Path, + extra_py: Sequence[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, + 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) via + PyScript (using the Pyodide interpreter). + + This ASGI webserver is only used to serve the initial HTML document and static files. + + Parameters: + 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 + 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=ReactPyPyscriptApp(self), root_components=[], **settings + ) + if not file_paths: + raise ValueError("At least one component file path must be provided.") + 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 {} + 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: # pragma: no cover + """We do not use a WebSocket dispatcher for Client-Side Rendering (CSR).""" + return False + + +@dataclass +class ReactPyPyscriptApp(ReactPyApp): + """ReactPy's standalone ASGI application for Client-Side Rendering (CSR) via PyScript.""" + + parent: ReactPyPyscript + _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.file_paths, + initial=self.parent.initial, + root=self.parent.root_name, + ) + head_content = 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/standalone.py b/src/reactpy/executors/asgi/standalone.py similarity index 80% rename from src/reactpy/asgi/standalone.py rename to src/reactpy/executors/asgi/standalone.py index 1f1298396..41fb050ff 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -13,18 +13,22 @@ from typing_extensions import Unpack from reactpy import html -from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.utils import import_dotted_path, vdom_head_to_html -from reactpy.types import ( +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.pyscript.utils import pyscript_setup_html +from reactpy.types import ( + PyScriptOptions, ReactPyConfig, RootComponentConstructor, VdomDict, ) -from reactpy.utils import render_mount_template +from reactpy.utils import html_to_vdom, import_dotted_path _logger = getLogger(__name__) @@ -39,6 +43,8 @@ def __init__( http_headers: dict[str, str] | None = None, html_head: VdomDict | None = None, html_lang: str = "en", + pyscript_setup: bool = False, + pyscript_options: PyScriptOptions | None = None, **settings: Unpack[ReactPyConfig], ) -> None: """ReactPy's standalone ASGI application. @@ -48,6 +54,8 @@ def __init__( 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. + pyscript_setup: Whether to automatically load PyScript within your HTML head. + pyscript_options: Options to configure PyScript behavior. settings: Global ReactPy configuration settings that affect behavior and performance. """ super().__init__(app=ReactPyApp(self), root_components=[], **settings) @@ -57,6 +65,18 @@ def __init__( self.html_head = html_head or html.head() self.html_lang = html_lang + if pyscript_setup: + self.html_head.setdefault("children", []) + pyscript_options = pyscript_options or {} + extra_py = pyscript_options.get("extra_py", []) + extra_js = pyscript_options.get("extra_js", {}) + config = pyscript_options.get("config", {}) + pyscript_head_vdom = html_to_vdom( + pyscript_setup_html(extra_py, extra_js, config) + ) + pyscript_head_vdom["tagName"] = "" + self.html_head["children"].append(pyscript_head_vdom) # type: ignore + def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: """Method override to remove `dotted_path` from the dispatcher URL.""" return str(scope["path"]) == self.dispatcher_path @@ -151,7 +171,7 @@ class ReactPyApp: to a user provided ASGI app.""" parent: ReactPy - _cached_index_html = "" + _index_html = "" _etag = "" _last_modified = "" @@ -173,8 +193,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"]) @@ -183,7 +203,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,22 +223,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 = ( "" f'' f"{vdom_head_to_html(self.parent.html_head)}" "" - f"{render_mount_template('app', '', '')}" + f"{server_side_component_html(element_id='app', class_='', component_path='')}" "" "" ) - - 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/executors/asgi/types.py b/src/reactpy/executors/asgi/types.py new file mode 100644 index 000000000..bff5e0ca7 --- /dev/null +++ b/src/reactpy/executors/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/asgi/utils.py b/src/reactpy/executors/utils.py similarity index 60% rename from src/reactpy/asgi/utils.py rename to src/reactpy/executors/utils.py index 85ad56056..e29cdf5c6 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/executors/utils.py @@ -2,36 +2,22 @@ import logging from collections.abc import Iterable -from importlib import import_module 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 +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 { @@ -73,3 +59,23 @@ def process_settings(settings: ReactPyConfig) -> None: config_object.set_current(settings[setting]) # type: ignore else: raise ValueError(f'Unknown ReactPy setting "{setting}".') + + +def server_side_component_html( + element_id: str, class_: str, component_path: str +) -> str: + return ( + f'
' + '" + ) diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py deleted file mode 100644 index 77d1570f1..000000000 --- a/src/reactpy/jinja.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import ClassVar -from uuid import uuid4 - -from jinja2_simple_tags import StandaloneTag - -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"} - - def render(self, dotted_path: str, **kwargs: str) -> str: - return render_mount_template( - element_id=uuid4().hex, - class_=kwargs.pop("class", ""), - append_component_path=f"{dotted_path}/", - ) diff --git a/src/reactpy/logging.py b/src/reactpy/logging.py index 62b507db8..160141c09 100644 --- a/src/reactpy/logging.py +++ b/src/reactpy/logging.py @@ -18,13 +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", - "class": "colorlog.ColoredFormatter", - } - }, + "formatters": {"generic": {"datefmt": r"%Y-%m-%dT%H:%M:%S%z"}}, } ) 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..47bf4d6a3 --- /dev/null +++ b/src/reactpy/pyscript/component_template.py @@ -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)) diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py new file mode 100644 index 000000000..e51cc0766 --- /dev/null +++ b/src/reactpy/pyscript/components.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from reactpy import component, hooks +from reactpy.pyscript.utils import pyscript_component_html +from reactpy.types import ComponentType, Key +from reactpy.utils import html_to_vdom + +if TYPE_CHECKING: + from reactpy.types import VdomDict + + +@component +def _pyscript_component( + *file_paths: str | Path, + initial: str | VdomDict = "", + root: str = "root", +) -> None | VdomDict: + if not file_paths: + raise ValueError("At least one file path must be provided.") + + rendered, set_rendered = hooks.use_state(False) + initial = html_to_vdom(initial) if isinstance(initial, str) else initial + + 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 + + component_vdom = html_to_vdom( + pyscript_component_html(tuple(str(fp) for fp in file_paths), initial, root) + ) + component_vdom["tagName"] = "" + return component_vdom + + +def pyscript_component( + *file_paths: str | Path, + initial: str | VdomDict | ComponentType = "", + root: str = "root", + key: Key | None = None, +) -> 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, + key=key, + ) diff --git a/src/reactpy/pyscript/layout_handler.py b/src/reactpy/pyscript/layout_handler.py new file mode 100644 index 000000000..733ab064f --- /dev/null +++ b/src/reactpy/pyscript/layout_handler.py @@ -0,0 +1,159 @@ +# type: ignore +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 + +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) + + # 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 + 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..b867d05f1 --- /dev/null +++ b/src/reactpy/pyscript/utils.py @@ -0,0 +1,236 @@ +# ruff: noqa: S603, S607 +from __future__ import annotations + +import functools +import json +import re +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 + +import jsonpointer + +import reactpy +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 + +if TYPE_CHECKING: + from collections.abc import Sequence + +_logger = getLogger(__name__) + + +def minify_python(source: str) -> str: + """Minify Python source code.""" + # Remove comments + source = re.sub(r"#.*\n", "\n", source) + # Remove docstrings + source = re.sub(r'\n\s*""".*?"""', "", source, flags=re.DOTALL) + # Remove excess 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 = 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") +) + + +def pyscript_executor_html(file_paths: Sequence[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 + + # Ensure the root component exists + if f"def {root}():" not in user_code: + raise ValueError( + f"Could not find the root component function '{root}' in your PyScript file(s)." + ) + + # Insert the user code into the PyScript template + return executor.replace(" def root(): ...", user_code) + + +def pyscript_component_html( + file_paths: Sequence[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 = pyscript_executor_html(file_paths=file_paths, uuid=uuid, root=root) + + return ( + f'
' + f"{_initial}" + "
" + f"" + ) + + +def pyscript_setup_html( + 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'' + pyscript_config = extend_pyscript_config(extra_py, extra_js, config) + + return ( + f'' + f"{'' if REACTPY_DEBUG.current else hide_pyscript_debugger}" + f'" + f"" + ) + + +def extend_pyscript_config( + extra_py: Sequence[str], + 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": [ + reactpy_version_string(), + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + ], + "js_modules": { + "main": { + f"{REACTPY_PATH_PREFIX.current}static/morphdom/morphdom-esm.js": "morphdom" + } + }, + "packages_cache": "never", + } + 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_js) + + # 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") + + +def reactpy_version_string() -> str: # pragma: no cover + local_version = reactpy.__version__ + + # Get a list of all versions via `pip index versions` + result = cached_pip_index_versions("reactpy") + + # 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 + + 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, try our best to give the user any possible version. + 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 local 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(): + _logger.warning( + "'reactpy==%s' is not available on PyPi. " + "PyScript will utilize a local wheel of ReactPy instead.", + local_version, + ) + shutil.copy(wheel_file, new_path) + return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" + + +@functools.cache +def cached_pip_index_versions(package_name: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["pip", "index", "versions", package_name], + capture_output=True, + text=True, + check=False, + ) + + +@functools.cache +def cached_file_read(file_path: str, minifiy: bool = True) -> str: + content = Path(file_path).read_text(encoding="utf-8").strip() + return minify_python(content) if minifiy else content 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; +} 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..672089752 --- /dev/null +++ b/src/reactpy/templatetags/jinja.py @@ -0,0 +1,42 @@ +from typing import ClassVar +from uuid import uuid4 + +from jinja2_simple_tags import StandaloneTag + +from reactpy.executors.utils import server_side_component_html +from reactpy.pyscript.utils import pyscript_component_html, pyscript_setup_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) + + # This should never happen, but we validate it for safety. + raise ValueError(f"Unknown tag: {self.tag_name}") # pragma: no cover + + +def component(dotted_path: str, **kwargs: str) -> str: + class_ = kwargs.pop("class", "") + if kwargs: + raise ValueError(f"Unexpected keyword arguments: {', '.join(kwargs)}") + return server_side_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) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 94f85687c..a16196b8e 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -4,17 +4,16 @@ 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.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 +from reactpy.executors.asgi.middleware import ReactPyMiddleware +from reactpy.executors.asgi.standalone import ReactPy from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, @@ -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/testing/display.py b/src/reactpy/testing/display.py index cc429c059..e3aced083 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -7,7 +7,6 @@ from playwright.async_api import ( Browser, BrowserContext, - ElementHandle, Page, async_playwright, ) @@ -41,18 +40,10 @@ async def show( ) -> None: self.backend.mount(component) await self.goto("/") - await self.root_element() # check that root element is attached async def goto(self, path: str, query: Any | None = None) -> None: await self.page.goto(self.backend.url(path, query)) - async def root_element(self) -> ElementHandle: - element = await self.page.wait_for_selector("#app", state="attached") - if element is None: # nocov - msg = "Root element not attached" - raise RuntimeError(msg) - return element - async def __aenter__(self) -> DisplayFixture: es = self._exit_stack = AsyncExitStack() diff --git a/src/reactpy/types.py b/src/reactpy/types.py index ee4e67776..89e7c4458 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 @@ -299,71 +297,7 @@ class ReactPyConfig(TypedDict, total=False): 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] +class PyScriptOptions(TypedDict, total=False): + extra_py: Sequence[str] + extra_js: dict[str, Any] | str + config: dict[str, Any] | str diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 30495d6c1..a7fcda926 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -2,13 +2,13 @@ import re from collections.abc import Iterable +from importlib import import_module from itertools import chain from typing import Any, Callable, Generic, TypeVar, Union, cast 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 @@ -316,21 +316,21 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str: - return ( - f'
' - '" - ) +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 diff --git a/tests/templates/index.html b/tests/templates/index.html index 8238b6b09..f7c6e28fb 100644 --- a/tests/templates/index.html +++ b/tests/templates/index.html @@ -4,7 +4,6 @@ -
{% component "reactpy.testing.backend.root_hotswap_component" %} diff --git a/tests/templates/jinja_bad_kwargs.html b/tests/templates/jinja_bad_kwargs.html new file mode 100644 index 000000000..4ef75647c --- /dev/null +++ b/tests/templates/jinja_bad_kwargs.html @@ -0,0 +1,10 @@ + + + + + + + {% component "this.doesnt.matter", bad_kwarg='foo-bar' %} + + + diff --git a/tests/templates/pyscript.html b/tests/templates/pyscript.html new file mode 100644 index 000000000..26f4192d9 --- /dev/null +++ b/tests/templates/pyscript.html @@ -0,0 +1,12 @@ + + + + + {% pyscript_setup %} + + + + {% pyscript_component "tests/test_asgi/pyscript_components/root.py", initial='
Loading...
' %} + + + diff --git a/tests/test_asgi/pyscript_components/load_first.py b/tests/test_asgi/pyscript_components/load_first.py new file mode 100644 index 000000000..dcb6a877d --- /dev/null +++ b/tests/test_asgi/pyscript_components/load_first.py @@ -0,0 +1,11 @@ +from typing import TYPE_CHECKING + +from reactpy import component + +if TYPE_CHECKING: + from .load_second import child + + +@component +def root(): + return child() diff --git a/tests/test_asgi/pyscript_components/load_second.py b/tests/test_asgi/pyscript_components/load_second.py new file mode 100644 index 000000000..c640209a5 --- /dev/null +++ b/tests/test_asgi/pyscript_components/load_second.py @@ -0,0 +1,16 @@ +from reactpy import component, hooks, html + + +@component +def child(): + count, set_count = hooks.use_state(0) + + def increment(event): + set_count(count + 1) + + return html.div( + html.button( + {"onClick": increment, "id": "incr", "data-count": count}, "Increment" + ), + html.p(f"PyScript Count: {count}"), + ) diff --git a/tests/test_asgi/pyscript_components/root.py b/tests/test_asgi/pyscript_components/root.py new file mode 100644 index 000000000..caa9a7c9d --- /dev/null +++ b/tests/test_asgi/pyscript_components/root.py @@ -0,0 +1,16 @@ +from reactpy import component, hooks, html + + +@component +def root(): + count, set_count = hooks.use_state(0) + + def increment(event): + set_count(count + 1) + + return html.div( + html.button( + {"onClick": increment, "id": "incr", "data-count": count}, "Increment" + ), + html.p(f"PyScript Count: {count}"), + ) diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py index 84dc545b8..2ed2a3878 100644 --- a/tests/test_asgi/test_middleware.py +++ b/tests/test_asgi/test_middleware.py @@ -5,21 +5,24 @@ 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.executors.asgi.middleware import ReactPyMiddleware 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"), - extensions=["reactpy.jinja.Component"], + extensions=["reactpy.templatetags.Jinja"], ) ) @@ -39,7 +42,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,16 +53,14 @@ 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(): templates = Jinja2Templates( env=JinjaEnvironment( loader=JinjaFileSystemLoader("tests/templates"), - extensions=["reactpy.jinja.Component"], + extensions=["reactpy.templatetags.Jinja"], ) ) @@ -77,7 +78,7 @@ def Stub(): async with DisplayFixture(backend=server) as new_display: await new_display.show(Stub) - # Wait for the log record to be popualted + # Wait for the log record to be populated for _ in range(10): if len(server.log_records) > 0: break @@ -103,3 +104,39 @@ 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 + + +async def test_templatetag_bad_kwargs(page, caplog): + """Override for the display fixture that uses ReactPyMiddleware.""" + templates = Jinja2Templates( + env=JinjaEnvironment( + loader=JinjaFileSystemLoader("tests/templates"), + extensions=["reactpy.templatetags.Jinja"], + ) + ) + + async def homepage(request): + return templates.TemplateResponse(request, "jinja_bad_kwargs.html") + + app = Starlette(routes=[Route("/", homepage)]) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + await new_display.goto("/") + + # This test could be improved by actually checking if `bad kwargs` error message is shown in + # `stderr`, but I was struggling to get that to work. + assert "internal server error" in (await new_display.page.content()).lower() diff --git a/tests/test_asgi/test_pyscript.py b/tests/test_asgi/test_pyscript.py new file mode 100644 index 000000000..c9315e4fe --- /dev/null +++ b/tests/test_asgi/test_pyscript.py @@ -0,0 +1,112 @@ +# ruff: noqa: S701 +from pathlib import Path + +import pytest +from jinja2 import Environment as JinjaEnvironment +from jinja2 import FileSystemLoader as JinjaFileSystemLoader +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from reactpy import html +from reactpy.executors.asgi.pyscript import ReactPyPyscript +from reactpy.testing import BackendFixture, DisplayFixture + + +@pytest.fixture() +async def display(page): + """Override for the display fixture that uses ReactPyMiddleware.""" + app = ReactPyPyscript( + Path(__file__).parent / "pyscript_components" / "root.py", + initial=html.div({"id": "loading"}, "Loading..."), + ) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + yield new_display + + +@pytest.fixture() +async def multi_file_display(page): + """Override for the display fixture that uses ReactPyMiddleware.""" + app = ReactPyPyscript( + Path(__file__).parent / "pyscript_components" / "load_first.py", + Path(__file__).parent / "pyscript_components" / "load_second.py", + initial=html.div({"id": "loading"}, "Loading..."), + ) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + yield new_display + + +@pytest.fixture() +async def jinja_display(page): + """Override for the display fixture that uses ReactPyMiddleware.""" + templates = Jinja2Templates( + env=JinjaEnvironment( + loader=JinjaFileSystemLoader("tests/templates"), + extensions=["reactpy.templatetags.Jinja"], + ) + ) + + async def homepage(request): + return templates.TemplateResponse(request, "pyscript.html") + + app = Starlette(routes=[Route("/", homepage)]) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + yield new_display + + +async def test_root_component(display: DisplayFixture): + await display.goto("/") + + await display.page.wait_for_selector("#loading") + await display.page.wait_for_selector("#incr") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='1']") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='2']") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='3']") + + +async def test_multi_file_components(multi_file_display: DisplayFixture): + await multi_file_display.goto("/") + + await multi_file_display.page.wait_for_selector("#incr") + + await multi_file_display.page.click("#incr") + await multi_file_display.page.wait_for_selector("#incr[data-count='1']") + + await multi_file_display.page.click("#incr") + await multi_file_display.page.wait_for_selector("#incr[data-count='2']") + + await multi_file_display.page.click("#incr") + await multi_file_display.page.wait_for_selector("#incr[data-count='3']") + + +def test_bad_file_path(): + with pytest.raises(ValueError): + ReactPyPyscript() + + +async def test_jinja_template_tag(jinja_display: DisplayFixture): + await jinja_display.goto("/") + + await jinja_display.page.wait_for_selector("#loading") + await jinja_display.page.wait_for_selector("#incr") + + await jinja_display.page.click("#incr") + await jinja_display.page.wait_for_selector("#incr[data-count='1']") + + await jinja_display.page.click("#incr") + await jinja_display.page.wait_for_selector("#incr[data-count='2']") + + await jinja_display.page.click("#incr") + await jinja_display.page.wait_for_selector("#incr[data-count='3']") diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 8d5fdee45..c4a42dcf3 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -8,19 +8,12 @@ import reactpy from reactpy import html -from reactpy.asgi.standalone import ReactPy +from reactpy.executors.asgi.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 -@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(): diff --git a/tests/test_asgi/test_utils.py b/tests/test_asgi/test_utils.py index ff3019c27..f0ffc5a73 100644 --- a/tests/test_asgi/test_utils.py +++ b/tests/test_asgi/test_utils.py @@ -1,24 +1,7 @@ import pytest from reactpy import config -from reactpy.asgi import utils - - -def test_invalid_dotted_path(): - with pytest.raises(ValueError, match='"abc" is not a valid dotted path.'): - utils.import_dotted_path("abc") - - -def test_invalid_component(): - with pytest.raises( - AttributeError, match='ReactPy failed to import "foobar" from "reactpy"' - ): - utils.import_dotted_path("reactpy.foobar") - - -def test_invalid_module(): - with pytest.raises(ImportError, match='ReactPy failed to import "foo"'): - utils.import_dotted_path("foo.bar") +from reactpy.executors import utils def test_invalid_vdom_head(): diff --git a/tests/test_pyscript/__init__.py b/tests/test_pyscript/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_pyscript/pyscript_components/custom_root_name.py b/tests/test_pyscript/pyscript_components/custom_root_name.py new file mode 100644 index 000000000..f2609c80c --- /dev/null +++ b/tests/test_pyscript/pyscript_components/custom_root_name.py @@ -0,0 +1,16 @@ +from reactpy import component, hooks, html + + +@component +def custom(): + count, set_count = hooks.use_state(0) + + def increment(event): + set_count(count + 1) + + return html.div( + html.button( + {"onClick": increment, "id": "incr", "data-count": count}, "Increment" + ), + html.p(f"PyScript Count: {count}"), + ) diff --git a/tests/test_pyscript/pyscript_components/root.py b/tests/test_pyscript/pyscript_components/root.py new file mode 100644 index 000000000..caa9a7c9d --- /dev/null +++ b/tests/test_pyscript/pyscript_components/root.py @@ -0,0 +1,16 @@ +from reactpy import component, hooks, html + + +@component +def root(): + count, set_count = hooks.use_state(0) + + def increment(event): + set_count(count + 1) + + return html.div( + html.button( + {"onClick": increment, "id": "incr", "data-count": count}, "Increment" + ), + html.p(f"PyScript Count: {count}"), + ) diff --git a/tests/test_pyscript/test_components.py b/tests/test_pyscript/test_components.py new file mode 100644 index 000000000..51fe59f50 --- /dev/null +++ b/tests/test_pyscript/test_components.py @@ -0,0 +1,71 @@ +from pathlib import Path + +import pytest + +import reactpy +from reactpy import html, pyscript_component +from reactpy.executors.asgi import ReactPy +from reactpy.testing import BackendFixture, DisplayFixture +from reactpy.testing.backend import root_hotswap_component + + +@pytest.fixture() +async def display(page): + """Override for the display fixture that uses ReactPyMiddleware.""" + app = ReactPy(root_hotswap_component, pyscript_setup=True) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + yield new_display + + +async def test_pyscript_component(display: DisplayFixture): + @reactpy.component + def Counter(): + return pyscript_component( + Path(__file__).parent / "pyscript_components" / "root.py", + initial=html.div({"id": "loading"}, "Loading..."), + ) + + await display.show(Counter) + + await display.page.wait_for_selector("#loading") + await display.page.wait_for_selector("#incr") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='1']") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='2']") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='3']") + + +async def test_custom_root_name(display: DisplayFixture): + @reactpy.component + def CustomRootName(): + return pyscript_component( + Path(__file__).parent / "pyscript_components" / "custom_root_name.py", + initial=html.div({"id": "loading"}, "Loading..."), + root="custom", + ) + + await display.show(CustomRootName) + + await display.page.wait_for_selector("#loading") + await display.page.wait_for_selector("#incr") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='1']") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='2']") + + await display.page.click("#incr") + await display.page.wait_for_selector("#incr[data-count='3']") + + +def test_bad_file_path(): + with pytest.raises(ValueError): + pyscript_component(initial=html.div({"id": "loading"}, "Loading...")).render() diff --git a/tests/test_pyscript/test_utils.py b/tests/test_pyscript/test_utils.py new file mode 100644 index 000000000..768067094 --- /dev/null +++ b/tests/test_pyscript/test_utils.py @@ -0,0 +1,59 @@ +from pathlib import Path +from uuid import uuid4 + +import orjson +import pytest + +from reactpy.pyscript import utils + + +def test_bad_root_name(): + file_path = str( + Path(__file__).parent / "pyscript_components" / "custom_root_name.py" + ) + + with pytest.raises(ValueError): + utils.pyscript_executor_html((file_path,), uuid4().hex, "bad") + + +def test_extend_pyscript_config(): + extra_py = ["orjson", "tabulate"] + extra_js = {"/static/foo.js": "bar"} + config = {"packages_cache": "always"} + + result = utils.extend_pyscript_config(extra_py, extra_js, config) + result = orjson.loads(result) + + # Check whether `packages` have been combined + assert "orjson" in result["packages"] + assert "tabulate" in result["packages"] + assert any("reactpy" in package for package in result["packages"]) + + # Check whether `js_modules` have been combined + assert "/static/foo.js" in result["js_modules"]["main"] + assert any("morphdom" in module for module in result["js_modules"]["main"]) + + # Check whether `packages_cache` has been overridden + assert result["packages_cache"] == "always" + + +def test_extend_pyscript_config_string_values(): + extra_py = [] + extra_js = {"/static/foo.js": "bar"} + config = {"packages_cache": "always"} + + # Try using string based `extra_js` and `config` + extra_js_string = orjson.dumps(extra_js).decode() + config_string = orjson.dumps(config).decode() + result = utils.extend_pyscript_config(extra_py, extra_js_string, config_string) + result = orjson.loads(result) + + # Make sure `packages` is unmangled + assert any("reactpy" in package for package in result["packages"]) + + # Check whether `js_modules` have been combined + assert "/static/foo.js" in result["js_modules"]["main"] + assert any("morphdom" in module for module in result["js_modules"]["main"]) + + # Check whether `packages_cache` has been overridden + assert result["packages_cache"] == "always" diff --git a/tests/test_utils.py b/tests/test_utils.py index c79a9d1f3..fbc1b7112 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,13 +3,7 @@ import pytest import reactpy -from reactpy import component, html -from reactpy.utils import ( - HTMLParseError, - del_html_head_body_transform, - html_to_vdom, - vdom_to_html, -) +from reactpy import component, html, utils def test_basic_ref_behavior(): @@ -68,7 +62,7 @@ def test_ref_repr(): ], ) def test_html_to_vdom(case): - assert html_to_vdom(case["source"]) == case["model"] + assert utils.html_to_vdom(case["source"]) == case["model"] def test_html_to_vdom_transform(): @@ -98,7 +92,7 @@ def make_links_blue(node): ], } - assert html_to_vdom(source, make_links_blue) == expected + assert utils.html_to_vdom(source, make_links_blue) == expected def test_non_html_tag_behavior(): @@ -112,10 +106,10 @@ def test_non_html_tag_behavior(): ], } - assert html_to_vdom(source, strict=False) == expected + assert utils.html_to_vdom(source, strict=False) == expected - with pytest.raises(HTMLParseError): - html_to_vdom(source, strict=True) + with pytest.raises(utils.HTMLParseError): + utils.html_to_vdom(source, strict=True) def test_html_to_vdom_with_null_tag(): @@ -130,7 +124,7 @@ def test_html_to_vdom_with_null_tag(): ], } - assert html_to_vdom(source) == expected + assert utils.html_to_vdom(source) == expected def test_html_to_vdom_with_style_attr(): @@ -142,7 +136,7 @@ def test_html_to_vdom_with_style_attr(): "tagName": "p", } - assert html_to_vdom(source) == expected + assert utils.html_to_vdom(source) == expected def test_html_to_vdom_with_no_parent_node(): @@ -156,7 +150,7 @@ def test_html_to_vdom_with_no_parent_node(): ], } - assert html_to_vdom(source) == expected + assert utils.html_to_vdom(source) == expected def test_del_html_body_transform(): @@ -187,7 +181,7 @@ def test_del_html_body_transform(): ], } - assert html_to_vdom(source, del_html_head_body_transform) == expected + assert utils.html_to_vdom(source, utils.del_html_head_body_transform) == expected SOME_OBJECT = object() @@ -275,9 +269,26 @@ def example_child(): ], ) def test_vdom_to_html(vdom_in, html_out): - assert vdom_to_html(vdom_in) == html_out + assert utils.vdom_to_html(vdom_in) == html_out def test_vdom_to_html_error(): with pytest.raises(TypeError, match="Expected a VDOM dict"): - vdom_to_html({"notVdom": True}) + utils.vdom_to_html({"notVdom": True}) + + +def test_invalid_dotted_path(): + with pytest.raises(ValueError, match='"abc" is not a valid dotted path.'): + utils.import_dotted_path("abc") + + +def test_invalid_component(): + with pytest.raises( + AttributeError, match='ReactPy failed to import "foobar" from "reactpy"' + ): + utils.import_dotted_path("reactpy.foobar") + + +def test_invalid_module(): + with pytest.raises(ImportError, match='ReactPy failed to import "foo"'): + utils.import_dotted_path("foo.bar") diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 6693a5301..8cd487c0c 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.executors.asgi.standalone import ReactPy from reactpy.testing import ( BackendFixture, DisplayFixture,