From d906dc8c9e022f60f0f9c787475ed96633bbba75 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Fri, 31 Jan 2025 18:02:34 -0800 Subject: [PATCH 01/17] ReactPy ASGI Middleware and standalone ReactPy ASGI App (#1113) - `ReactPy` class has been created as the standalone API for ReactPy. - `ReactPyMiddleware` has been created as the version of ReactPy that can wrap other ASGI web frameworks. - Added a template tag to use alongside our middleware. - `@reactpy/client` has been rewritten to be more modular. - `reactpy.backends.*` is removed. --- .github/workflows/.hatch-run.yml | 2 +- .github/workflows/check.yml | 6 +- .gitignore | 6 +- docs/Dockerfile | 2 +- docs/docs_app/app.py | 2 +- docs/source/about/changelog.rst | 16 + .../distributing-javascript.rst | 2 +- .../getting-started/running-reactpy.rst | 8 +- pyproject.toml | 73 ++-- src/js/packages/@reactpy/app/package.json | 2 +- src/js/packages/@reactpy/app/src/index.ts | 20 +- src/js/packages/@reactpy/client/src/client.ts | 83 +++++ .../@reactpy/client/src/components.tsx | 31 +- src/js/packages/@reactpy/client/src/index.ts | 9 +- src/js/packages/@reactpy/client/src/logger.ts | 1 + .../packages/@reactpy/client/src/messages.ts | 17 - src/js/packages/@reactpy/client/src/mount.tsx | 43 ++- .../@reactpy/client/src/reactpy-client.ts | 264 -------------- src/js/packages/@reactpy/client/src/types.ts | 151 ++++++++ .../client/src/{reactpy-vdom.tsx => vdom.tsx} | 83 +---- .../packages/@reactpy/client/src/websocket.ts | 75 ++++ src/reactpy/__init__.py | 12 +- src/reactpy/_html.py | 2 +- .../reactpy/asgi}/__init__.py | 0 src/reactpy/asgi/middleware.py | 254 +++++++++++++ src/reactpy/asgi/standalone.py | 142 +++++++ src/reactpy/asgi/utils.py | 118 ++++++ src/reactpy/backend/__init__.py | 22 -- src/reactpy/backend/_common.py | 135 ------- src/reactpy/backend/default.py | 78 ---- src/reactpy/backend/fastapi.py | 25 -- src/reactpy/backend/flask.py | 303 --------------- src/reactpy/backend/hooks.py | 45 --- src/reactpy/backend/sanic.py | 231 ------------ src/reactpy/backend/starlette.py | 185 ---------- src/reactpy/backend/tornado.py | 235 ------------ src/reactpy/backend/types.py | 76 ---- src/reactpy/backend/utils.py | 87 ----- src/reactpy/config.py | 52 ++- src/reactpy/core/_life_cycle_hook.py | 2 +- src/reactpy/core/component.py | 2 +- src/reactpy/core/events.py | 2 +- src/reactpy/core/hooks.py | 22 +- src/reactpy/core/layout.py | 13 +- src/reactpy/core/serve.py | 8 +- src/reactpy/core/types.py | 248 ------------- src/reactpy/core/vdom.py | 6 +- src/reactpy/jinja.py | 21 ++ src/reactpy/logging.py | 4 +- src/reactpy/static/index.html | 14 - src/reactpy/testing/__init__.py | 12 +- src/reactpy/testing/backend.py | 98 +++-- src/reactpy/testing/common.py | 8 +- src/reactpy/testing/display.py | 18 +- src/reactpy/testing/utils.py | 27 ++ src/reactpy/types.py | 345 +++++++++++++++--- src/reactpy/utils.py | 23 +- src/reactpy/web/__init__.py | 4 +- src/reactpy/web/module.py | 97 +---- src/reactpy/widgets.py | 2 +- tests/conftest.py | 30 +- tests/sample.py | 2 +- tests/templates/index.html | 11 + .../future.py => tests/test_asgi/__init__.py | 0 tests/test_asgi/test_middleware.py | 105 ++++++ .../test_standalone.py} | 83 ++--- tests/test_asgi/test_utils.py | 38 ++ tests/test_backend/test_common.py | 70 ---- tests/test_backend/test_utils.py | 46 --- tests/test_client.py | 60 ++- tests/test_config.py | 6 +- tests/test_core/test_hooks.py | 8 +- tests/test_core/test_layout.py | 8 +- tests/test_core/test_serve.py | 2 +- tests/test_core/test_vdom.py | 10 +- tests/test_html.py | 62 +++- tests/test_testing.py | 14 - tests/test_web/test_module.py | 21 +- tests/test_web/test_utils.py | 13 + tests/tooling/aio.py | 4 +- tests/tooling/common.py | 4 +- tests/tooling/hooks.py | 1 + tests/tooling/layout.py | 2 +- tests/tooling/select.py | 2 +- 84 files changed, 1811 insertions(+), 2665 deletions(-) create mode 100644 src/js/packages/@reactpy/client/src/client.ts delete mode 100644 src/js/packages/@reactpy/client/src/messages.ts delete mode 100644 src/js/packages/@reactpy/client/src/reactpy-client.ts create mode 100644 src/js/packages/@reactpy/client/src/types.ts rename src/js/packages/@reactpy/client/src/{reactpy-vdom.tsx => vdom.tsx} (75%) create mode 100644 src/js/packages/@reactpy/client/src/websocket.ts rename {tests/test_backend => src/reactpy/asgi}/__init__.py (100%) create mode 100644 src/reactpy/asgi/middleware.py create mode 100644 src/reactpy/asgi/standalone.py create mode 100644 src/reactpy/asgi/utils.py delete mode 100644 src/reactpy/backend/__init__.py delete mode 100644 src/reactpy/backend/_common.py delete mode 100644 src/reactpy/backend/default.py delete mode 100644 src/reactpy/backend/fastapi.py delete mode 100644 src/reactpy/backend/flask.py delete mode 100644 src/reactpy/backend/hooks.py delete mode 100644 src/reactpy/backend/sanic.py delete mode 100644 src/reactpy/backend/starlette.py delete mode 100644 src/reactpy/backend/tornado.py delete mode 100644 src/reactpy/backend/types.py delete mode 100644 src/reactpy/backend/utils.py delete mode 100644 src/reactpy/core/types.py create mode 100644 src/reactpy/jinja.py delete mode 100644 src/reactpy/static/index.html create mode 100644 src/reactpy/testing/utils.py create mode 100644 tests/templates/index.html rename src/reactpy/future.py => tests/test_asgi/__init__.py (100%) create mode 100644 tests/test_asgi/test_middleware.py rename tests/{test_backend/test_all.py => test_asgi/test_standalone.py} (66%) create mode 100644 tests/test_asgi/test_utils.py delete mode 100644 tests/test_backend/test_common.py delete mode 100644 tests/test_backend/test_utils.py diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index 0a5579d77..c8770d184 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -43,7 +43,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install --upgrade pip hatch uv + run: pip install --upgrade hatch uv - name: Run Scripts env: NPM_CONFIG_TOKEN: ${{ secrets.node-auth-token }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9fd513e89..86a457136 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - main + - "*" schedule: - cron: "0 0 * * 0" @@ -27,8 +27,10 @@ jobs: job-name: "python-{0} {1}" run-cmd: "hatch test" runs-on: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version: '["3.9", "3.10", "3.11"]' + python-version: '["3.10", "3.11", "3.12", "3.13"]' test-documentation: + # Temporarily disabled + if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "python-{0}" diff --git a/.gitignore b/.gitignore index 6cc8e33ca..c5f91d024 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # --- Build Artifacts --- -src/reactpy/static/**/index.js* +src/reactpy/static/* # --- Jupyter --- *.ipynb_checkpoints @@ -15,8 +15,8 @@ src/reactpy/static/**/index.js* # --- Python --- .hatch -.venv -venv +.venv* +venv* MANIFEST build dist diff --git a/docs/Dockerfile b/docs/Dockerfile index 1f8bd0aaf..fad5643c3 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -33,6 +33,6 @@ RUN sphinx-build -v -W -b html source build # Define Entrypoint # ----------------- ENV PORT=5000 -ENV REACTPY_DEBUG_MODE=1 +ENV REACTPY_DEBUG=1 ENV REACTPY_CHECK_VDOM_SPEC=0 CMD ["python", "main.py"] diff --git a/docs/docs_app/app.py b/docs/docs_app/app.py index 3fe4669ff..393b68439 100644 --- a/docs/docs_app/app.py +++ b/docs/docs_app/app.py @@ -6,7 +6,7 @@ from docs_app.examples import get_normalized_example_name, load_examples from reactpy import component from reactpy.backend.sanic import Options, configure, use_request -from reactpy.core.types import ComponentConstructor +from reactpy.types import ComponentConstructor THIS_DIR = Path(__file__).parent DOCS_DIR = THIS_DIR.parent diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 178fbba19..9f833d28f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -15,6 +15,13 @@ Changelog 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 support for Python 3.12 and 3.13. + **Changed** - :pull:`1251` - Substitute client-side usage of ``react`` with ``preact``. @@ -22,6 +29,9 @@ Unreleased - :pull:`1255` - The ``reactpy.html`` module has been modified to allow for auto-creation of any HTML nodes. For example, you can create a ```` element by calling ``html.data_table()``. - :pull:`1256` - Change ``set_state`` comparison method to check equality with ``==`` more consistently. - :pull:`1257` - Add support for rendering ``@component`` children within ``vdom_to_html``. +- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. +- :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``. +- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. **Removed** @@ -29,6 +39,12 @@ Unreleased - :pull:`1255` - Removed ``reactpy.sample`` module. - :pull:`1255` - Removed ``reactpy.svg`` module. Contents previously within ``reactpy.svg.*`` can now be accessed via ``html.svg.*``. - :pull:`1255` - Removed ``reactpy.html._`` function. Use ``html.fragment`` instead. +- :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications. +- :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead. +- :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. +- :pull:`1113` - Removed deprecated function ``module_from_template``. +- :pull:`1113` - Removed support for Python 3.9. **Fixed** diff --git a/docs/source/guides/escape-hatches/distributing-javascript.rst b/docs/source/guides/escape-hatches/distributing-javascript.rst index 9eb478965..5333742ce 100644 --- a/docs/source/guides/escape-hatches/distributing-javascript.rst +++ b/docs/source/guides/escape-hatches/distributing-javascript.rst @@ -188,7 +188,7 @@ loaded with :func:`~reactpy.web.module.export`. .. note:: - When :data:`reactpy.config.REACTPY_DEBUG_MODE` is active, named exports will be validated. + When :data:`reactpy.config.REACTPY_DEBUG` is active, named exports will be validated. The remaining files that we need to create are concerned with creating a Python package. We won't cover all the details here, so refer to the Setuptools_ documentation for diff --git a/docs/source/guides/getting-started/running-reactpy.rst b/docs/source/guides/getting-started/running-reactpy.rst index 8abbd574f..90a03cbc3 100644 --- a/docs/source/guides/getting-started/running-reactpy.rst +++ b/docs/source/guides/getting-started/running-reactpy.rst @@ -103,7 +103,7 @@ Running ReactPy in Debug Mode ----------------------------- ReactPy provides a debug mode that is turned off by default. This can be enabled when you -run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. +run your application by setting the ``REACTPY_DEBUG`` environment variable. .. tab-set:: @@ -111,21 +111,21 @@ run your application by setting the ``REACTPY_DEBUG_MODE`` environment variable. .. code-block:: - export REACTPY_DEBUG_MODE=1 + export REACTPY_DEBUG=1 python my_reactpy_app.py .. tab-item:: Command Prompt .. code-block:: text - set REACTPY_DEBUG_MODE=1 + set REACTPY_DEBUG=1 python my_reactpy_app.py .. tab-item:: PowerShell .. code-block:: powershell - $env:REACTPY_DEBUG_MODE = "1" + $env:REACTPY_DEBUG = "1" python my_reactpy_app.py .. danger:: diff --git a/pyproject.toml b/pyproject.toml index 8c348f1e9..92430e71b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ classifiers = [ dependencies = [ "exceptiongroup >=1.0", "typing-extensions >=3.10", - "mypy-extensions >=0.4.3", "anyio >=3", "jsonpatch >=1.32", "fastjsonschema >=2.14.5", @@ -37,6 +36,8 @@ dependencies = [ "colorlog >=6", "asgiref >=3", "lxml >=4", + "servestatic >=3.0.0", + "orjson >=3", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" @@ -69,24 +70,15 @@ 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/assets"', + 'python "src/build_scripts/copy_dir.py" "src/js/packages/@reactpy/app/dist" "src/reactpy/static"', ] artifacts = [] [project.optional-dependencies] -# TODO: Nuke backends from the optional deps -all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"] -starlette = ["starlette >=0.13.6", "uvicorn[standard] >=0.19.0"] -sanic = [ - "sanic>=21", - "sanic-cors", - "tracerite>=1.1.1", - "setuptools", - "uvicorn[standard]>=0.19.0", -] -fastapi = ["fastapi >=0.63.0", "uvicorn[standard] >=0.19.0"] -flask = ["flask", "markupsafe>=1.1.1,<2.1", "flask-cors", "flask-sock"] -tornado = ["tornado"] +all = ["reactpy[jinja,uvicorn,testing]"] +standard = ["reactpy[jinja,uvicorn]"] +jinja = ["jinja2-simple-tags", "jinja2 >=3"] +uvicorn = ["uvicorn[standard]"] testing = ["playwright"] @@ -103,45 +95,31 @@ extra-dependencies = [ "responses", "playwright", "jsonpointer", - # TODO: Nuke everything past this point after removing backends from deps - "starlette >=0.13.6", - "uvicorn[standard] >=0.19.0", - "sanic>=21", - "sanic-cors", - "sanic-testing", - "tracerite>=1.1.1", - "setuptools", - "uvicorn[standard]>=0.19.0", - "fastapi >=0.63.0", - "uvicorn[standard] >=0.19.0", - "flask", - "markupsafe>=1.1.1,<2.1", - "flask-cors", - "flask-sock", - "tornado", + "uvicorn[standard]", + "jinja2-simple-tags", + "jinja2 >=3", + "starlette", ] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.9", "3.10", "3.11", "3.12"] +python = ["3.10", "3.11", "3.12", "3.13"] [tool.pytest.ini_options] addopts = """\ - --strict-config - --strict-markers - """ + --strict-config + --strict-markers +""" +filterwarnings = """ + ignore::DeprecationWarning:uvicorn.* + ignore::DeprecationWarning:websockets.* + ignore::UserWarning:tests.test_core.test_vdom +""" testpaths = "tests" xfail_strict = true asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" log_cli_level = "INFO" -[tool.hatch.envs.default.scripts] -test-cov = "playwright install && coverage run -m pytest {args:tests}" -cov-report = ["coverage report"] -cov = ["test-cov {args}", "cov-report"] - -[tool.hatch.envs.default.env-vars] -REACTPY_DEBUG_MODE = "1" - ####################################### # >>> Hatch Documentation Scripts <<< # ####################################### @@ -256,10 +234,14 @@ warn_unused_ignores = true source_pkgs = ["reactpy"] branch = false parallel = false -omit = ["reactpy/__init__.py"] +omit = [ + "src/reactpy/__init__.py", + "src/reactpy/_console/*", + "src/reactpy/__main__.py", +] [tool.coverage.report] -fail_under = 98 +fail_under = 100 show_missing = true skip_covered = true sort = "Name" @@ -269,7 +251,6 @@ exclude_also = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] -omit = ["**/reactpy/__main__.py"] [tool.ruff] target-version = "py39" diff --git a/src/js/packages/@reactpy/app/package.json b/src/js/packages/@reactpy/app/package.json index 21e3bcd96..5efc163c3 100644 --- a/src/js/packages/@reactpy/app/package.json +++ b/src/js/packages/@reactpy/app/package.json @@ -11,7 +11,7 @@ "typescript": "^5.7.3" }, "scripts": { - "build": "bun build \"src/index.ts\" --outdir \"dist\" --minify --sourcemap=linked", + "build": "bun build \"src/index.ts\" --outdir=\"dist\" --minify --sourcemap=\"linked\"", "checkTypes": "tsc --noEmit" } } diff --git a/src/js/packages/@reactpy/app/src/index.ts b/src/js/packages/@reactpy/app/src/index.ts index 9a86fe811..55ebf2c10 100644 --- a/src/js/packages/@reactpy/app/src/index.ts +++ b/src/js/packages/@reactpy/app/src/index.ts @@ -1,19 +1 @@ -import { mount, SimpleReactPyClient } from "@reactpy/client"; - -function app(element: HTMLElement) { - const client = new SimpleReactPyClient({ - serverLocation: { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - }); - mount(element, client); -} - -const element = document.getElementById("app"); -if (element) { - app(element); -} else { - console.error("Element with id 'app' not found"); -} +export { mountReactPy } from "@reactpy/client"; diff --git a/src/js/packages/@reactpy/client/src/client.ts b/src/js/packages/@reactpy/client/src/client.ts new file mode 100644 index 000000000..ea4e1aed5 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/client.ts @@ -0,0 +1,83 @@ +import logger from "./logger"; +import { + ReactPyClientInterface, + ReactPyModule, + GenericReactPyClientProps, + ReactPyUrls, +} from "./types"; +import { createReconnectingWebSocket } from "./websocket"; + +export abstract class BaseReactPyClient implements ReactPyClientInterface { + private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; + protected readonly ready: Promise; + private resolveReady: (value: undefined) => void; + + constructor() { + this.resolveReady = () => {}; + this.ready = new Promise((resolve) => (this.resolveReady = resolve)); + } + + onMessage(type: string, handler: (message: any) => void): () => void { + (this.handlers[type] || (this.handlers[type] = [])).push(handler); + this.resolveReady(undefined); + return () => { + this.handlers[type] = this.handlers[type].filter((h) => h !== handler); + }; + } + + abstract sendMessage(message: any): void; + abstract loadModule(moduleName: string): Promise; + + /** + * Handle an incoming message. + * + * This should be called by subclasses when a message is received. + * + * @param message The message to handle. The message must have a `type` property. + */ + protected handleIncoming(message: any): void { + if (!message.type) { + logger.warn("Received message without type", message); + return; + } + + const messageHandlers: ((m: any) => void)[] | undefined = + this.handlers[message.type]; + if (!messageHandlers) { + logger.warn("Received message without handler", message); + return; + } + + messageHandlers.forEach((h) => h(message)); + } +} + +export class ReactPyClient + extends BaseReactPyClient + implements ReactPyClientInterface +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + mountElement: HTMLElement; + + constructor(props: GenericReactPyClientProps) { + super(); + + this.urls = props.urls; + this.mountElement = props.mountElement; + this.socket = createReconnectingWebSocket({ + url: this.urls.componentUrl, + readyPromise: this.ready, + ...props.reconnectOptions, + onMessage: (event) => this.handleIncoming(JSON.parse(event.data)), + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string): Promise { + return import(`${this.urls.jsModulesPath}${moduleName}`); + } +} diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index efaa7a759..42f303198 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -1,29 +1,26 @@ +import { set as setJsonPointer } from "json-pointer"; import React, { - createElement, + ChangeEvent, createContext, - useState, - useRef, - useContext, - useEffect, + createElement, Fragment, MutableRefObject, - ChangeEvent, + useContext, + useEffect, + useRef, + useState, } from "preact/compat"; -// @ts-ignore -import { set as setJsonPointer } from "json-pointer"; import { - ReactPyVdom, - ReactPyComponent, - createChildren, - createAttributes, - loadImportSource, ImportSourceBinding, -} from "./reactpy-vdom"; -import { ReactPyClient } from "./reactpy-client"; + ReactPyComponent, + ReactPyVdom, + ReactPyClientInterface, +} from "./types"; +import { createAttributes, createChildren, loadImportSource } from "./vdom"; -const ClientContext = createContext(null as any); +const ClientContext = createContext(null as any); -export function Layout(props: { client: ReactPyClient }): JSX.Element { +export function Layout(props: { client: ReactPyClientInterface }): JSX.Element { const currentModel: ReactPyVdom = useState({ tagName: "" })[0]; const forceUpdate = useForceUpdate(); diff --git a/src/js/packages/@reactpy/client/src/index.ts b/src/js/packages/@reactpy/client/src/index.ts index 548fcbfc7..15192823d 100644 --- a/src/js/packages/@reactpy/client/src/index.ts +++ b/src/js/packages/@reactpy/client/src/index.ts @@ -1,5 +1,8 @@ +export * from "./client"; export * from "./components"; -export * from "./messages"; export * from "./mount"; -export * from "./reactpy-client"; -export * from "./reactpy-vdom"; +export * from "./types"; +export * from "./vdom"; +export * from "./websocket"; +export { default as React } from "preact/compat"; +export { default as ReactDOM } from "preact/compat"; diff --git a/src/js/packages/@reactpy/client/src/logger.ts b/src/js/packages/@reactpy/client/src/logger.ts index 4c4cdd264..436e74be1 100644 --- a/src/js/packages/@reactpy/client/src/logger.ts +++ b/src/js/packages/@reactpy/client/src/logger.ts @@ -1,5 +1,6 @@ export default { log: (...args: any[]): void => console.log("[ReactPy]", ...args), + info: (...args: any[]): void => console.info("[ReactPy]", ...args), warn: (...args: any[]): void => console.warn("[ReactPy]", ...args), error: (...args: any[]): void => console.error("[ReactPy]", ...args), }; diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts deleted file mode 100644 index 34001dcb0..000000000 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ReactPyVdom } from "./reactpy-vdom"; - -export type LayoutUpdateMessage = { - type: "layout-update"; - path: string; - model: ReactPyVdom; -}; - -export type LayoutEventMessage = { - type: "layout-event"; - target: string; - data: any; -}; - -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; -export type Message = IncomingMessage | OutgoingMessage; diff --git a/src/js/packages/@reactpy/client/src/mount.tsx b/src/js/packages/@reactpy/client/src/mount.tsx index 059dcec1a..820bc0631 100644 --- a/src/js/packages/@reactpy/client/src/mount.tsx +++ b/src/js/packages/@reactpy/client/src/mount.tsx @@ -1,8 +1,41 @@ -import React from "preact/compat"; -import { render } from "preact/compat"; +import { default as React, default as ReactDOM } from "preact/compat"; +import { ReactPyClient } from "./client"; import { Layout } from "./components"; -import { ReactPyClient } from "./reactpy-client"; +import { MountProps } from "./types"; -export function mount(element: HTMLElement, client: ReactPyClient): void { - render(, element); +export function mountReactPy(props: MountProps) { + // WebSocket route for component rendering + const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; + const wsOrigin = `${wsProtocol}//${window.location.host}`; + const componentUrl = new URL( + `${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`, + ); + + // Embed the initial HTTP path into the WebSocket URL + componentUrl.searchParams.append("http_pathname", window.location.pathname); + if (window.location.search) { + componentUrl.searchParams.append( + "http_query_string", + window.location.search, + ); + } + + // Configure a new ReactPy client + const client = new ReactPyClient({ + urls: { + componentUrl: componentUrl, + jsModulesPath: `${window.location.origin}${props.pathPrefix}modules/`, + }, + reconnectOptions: { + interval: props.reconnectInterval || 750, + maxInterval: props.reconnectMaxInterval || 60000, + maxRetries: props.reconnectMaxRetries || 150, + backoffMultiplier: props.reconnectBackoffMultiplier || 1.25, + }, + mountElement: props.mountElement, + }); + + // Start rendering the component + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(, props.mountElement); } diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts deleted file mode 100644 index 6f37b55a1..000000000 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { ReactPyModule } from "./reactpy-vdom"; -import logger from "./logger"; - -/** - * A client for communicating with a ReactPy server. - */ -export interface ReactPyClient { - /** - * Register a handler for a message type. - * - * The first time this is called, the client will be considered ready. - * - * @param type The type of message to handle. - * @param handler The handler to call when a message of the given type is received. - * @returns A function to unregister the handler. - */ - onMessage(type: string, handler: (message: any) => void): () => void; - - /** - * Send a message to the server. - * - * @param message The message to send. Messages must have a `type` property. - */ - sendMessage(message: any): void; - - /** - * Load a module from the server. - * @param moduleName The name of the module to load. - * @returns A promise that resolves to the module. - */ - loadModule(moduleName: string): Promise; -} - -export abstract class BaseReactPyClient implements ReactPyClient { - private readonly handlers: { [key: string]: ((message: any) => void)[] } = {}; - protected readonly ready: Promise; - private resolveReady: (value: undefined) => void; - - constructor() { - this.resolveReady = () => {}; - this.ready = new Promise((resolve) => (this.resolveReady = resolve)); - } - - onMessage(type: string, handler: (message: any) => void): () => void { - (this.handlers[type] || (this.handlers[type] = [])).push(handler); - this.resolveReady(undefined); - return () => { - this.handlers[type] = this.handlers[type].filter((h) => h !== handler); - }; - } - - abstract sendMessage(message: any): void; - abstract loadModule(moduleName: string): Promise; - - /** - * Handle an incoming message. - * - * This should be called by subclasses when a message is received. - * - * @param message The message to handle. The message must have a `type` property. - */ - protected handleIncoming(message: any): void { - if (!message.type) { - logger.warn("Received message without type", message); - return; - } - - const messageHandlers: ((m: any) => void)[] | undefined = - this.handlers[message.type]; - if (!messageHandlers) { - logger.warn("Received message without handler", message); - return; - } - - messageHandlers.forEach((h) => h(message)); - } -} - -export type SimpleReactPyClientProps = { - serverLocation?: LocationProps; - reconnectOptions?: ReconnectProps; -}; - -/** - * The location of the server. - * - * This is used to determine the location of the server's API endpoints. All endpoints - * are expected to be found at the base URL, with the following paths: - * - * - `_reactpy/stream/${route}${query}`: The websocket endpoint for the stream. - * - `_reactpy/modules`: The directory containing the dynamically loaded modules. - * - `_reactpy/assets`: The directory containing the static assets. - */ -type LocationProps = { - /** - * The base URL of the server. - * - * @default - document.location.origin - */ - url: string; - /** - * The route to the page being rendered. - * - * @default - document.location.pathname - */ - route: string; - /** - * The query string of the page being rendered. - * - * @default - document.location.search - */ - query: string; -}; - -type ReconnectProps = { - maxInterval?: number; - maxRetries?: number; - backoffRate?: number; - intervalJitter?: number; -}; - -export class SimpleReactPyClient - extends BaseReactPyClient - implements ReactPyClient -{ - private readonly urls: ServerUrls; - private readonly socket: { current?: WebSocket }; - - constructor(props: SimpleReactPyClientProps) { - super(); - - this.urls = getServerUrls( - props.serverLocation || { - url: document.location.origin, - route: document.location.pathname, - query: document.location.search, - }, - ); - - this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, - url: this.urls.stream, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, - }); - } - - sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); - } - - loadModule(moduleName: string): Promise { - return import(`${this.urls.modules}/${moduleName}`); - } -} - -type ServerUrls = { - base: URL; - stream: string; - modules: string; - assets: string; -}; - -function getServerUrls(props: LocationProps): ServerUrls { - const base = new URL(`${props.url || document.location.origin}/_reactpy`); - const modules = `${base}/modules`; - const assets = `${base}/assets`; - - const streamProtocol = `ws${base.protocol === "https:" ? "s" : ""}`; - const streamPath = rtrim(`${base.pathname}/stream${props.route || ""}`, "/"); - const stream = `${streamProtocol}://${base.host}${streamPath}${props.query}`; - - return { base, modules, assets, stream }; -} - -function createReconnectingWebSocket( - props: { - url: string; - readyPromise: Promise; - onOpen?: () => void; - onMessage: (message: MessageEvent) => void; - onClose?: () => void; - } & ReconnectProps, -) { - const { - maxInterval = 60000, - maxRetries = 50, - backoffRate = 1.1, - intervalJitter = 0.1, - } = props; - - const startInterval = 750; - let retries = 0; - let interval = startInterval; - const closed = false; - let everConnected = false; - const socket: { current?: WebSocket } = {}; - - const connect = () => { - if (closed) { - return; - } - socket.current = new WebSocket(props.url); - socket.current.onopen = () => { - everConnected = true; - logger.log("client connected"); - interval = startInterval; - retries = 0; - if (props.onOpen) { - props.onOpen(); - } - }; - socket.current.onmessage = props.onMessage; - socket.current.onclose = () => { - if (!everConnected) { - logger.log("failed to connect"); - return; - } - - logger.log("client disconnected"); - if (props.onClose) { - props.onClose(); - } - - if (retries >= maxRetries) { - return; - } - - const thisInterval = addJitter(interval, intervalJitter); - logger.log( - `reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`, - ); - setTimeout(connect, thisInterval); - interval = nextInterval(interval, backoffRate, maxInterval); - retries++; - }; - }; - - props.readyPromise.then(() => logger.log("starting client...")).then(connect); - - return socket; -} - -function nextInterval( - currentInterval: number, - backoffRate: number, - maxInterval: number, -): number { - return Math.min( - currentInterval * - // increase interval by backoff rate - backoffRate, - // don't exceed max interval - maxInterval, - ); -} - -function addJitter(interval: number, jitter: number): number { - return interval + (Math.random() * jitter * interval * 2 - jitter * interval); -} - -function rtrim(text: string, trim: string): string { - return text.replace(new RegExp(`${trim}+$`), ""); -} diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts new file mode 100644 index 000000000..0792b3586 --- /dev/null +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -0,0 +1,151 @@ +import { ComponentType } from "react"; + +// #### CONNECTION TYPES #### + +export type ReconnectOptions = { + interval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; + +export type CreateReconnectingWebSocketProps = { + url: URL; + readyPromise: Promise; + onMessage: (message: MessageEvent) => void; + onOpen?: () => void; + onClose?: () => void; + interval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}; + +export type ReactPyUrls = { + componentUrl: URL; + jsModulesPath: string; +}; + +export type GenericReactPyClientProps = { + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; + mountElement: HTMLElement; +}; + +export type MountProps = { + mountElement: HTMLElement; + pathPrefix: string; + appendComponentPath?: string; + reconnectInterval?: number; + reconnectMaxInterval?: number; + reconnectMaxRetries?: number; + reconnectBackoffMultiplier?: number; +}; + +// #### COMPONENT TYPES #### + +export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; + +export type ReactPyVdom = { + tagName: string; + key?: string; + attributes?: { [key: string]: string }; + children?: (ReactPyVdom | string)[]; + error?: string; + eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; + importSource?: ReactPyVdomImportSource; +}; + +export type ReactPyVdomEventHandler = { + target: string; + preventDefault?: boolean; + stopPropagation?: boolean; +}; + +export type ReactPyVdomImportSource = { + source: string; + sourceType?: "URL" | "NAME"; + fallback?: string | ReactPyVdom; + unmountBeforeUpdate?: boolean; +}; + +export type ReactPyModule = { + bind: ( + node: HTMLElement, + context: ReactPyModuleBindingContext, + ) => ReactPyModuleBinding; +} & { [key: string]: any }; + +export type ReactPyModuleBindingContext = { + sendMessage: ReactPyClientInterface["sendMessage"]; + onMessage: ReactPyClientInterface["onMessage"]; +}; + +export type ReactPyModuleBinding = { + create: ( + type: any, + props?: any, + children?: (any | string | ReactPyVdom)[], + ) => any; + render: (element: any) => void; + unmount: () => void; +}; + +export type BindImportSource = ( + node: HTMLElement, +) => ImportSourceBinding | null; + +export type ImportSourceBinding = { + render: (model: ReactPyVdom) => void; + unmount: () => void; +}; + +// #### MESSAGE TYPES #### + +export type LayoutUpdateMessage = { + type: "layout-update"; + path: string; + model: ReactPyVdom; +}; + +export type LayoutEventMessage = { + type: "layout-event"; + target: string; + data: any; +}; + +export type IncomingMessage = LayoutUpdateMessage; +export type OutgoingMessage = LayoutEventMessage; +export type Message = IncomingMessage | OutgoingMessage; + +// #### INTERFACES #### + +/** + * A client for communicating with a ReactPy server. + */ +export interface ReactPyClientInterface { + /** + * Register a handler for a message type. + * + * The first time this is called, the client will be considered ready. + * + * @param type The type of message to handle. + * @param handler The handler to call when a message of the given type is received. + * @returns A function to unregister the handler. + */ + onMessage(type: string, handler: (message: any) => void): () => void; + + /** + * Send a message to the server. + * + * @param message The message to send. Messages must have a `type` property. + */ + sendMessage(message: any): void; + + /** + * Load a module from the server. + * @param moduleName The name of the module to load. + * @returns A promise that resolves to the module. + */ + loadModule(moduleName: string): Promise; +} diff --git a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx similarity index 75% rename from src/js/packages/@reactpy/client/src/reactpy-vdom.tsx rename to src/js/packages/@reactpy/client/src/vdom.tsx index 22fa3e61d..d86d9232a 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -1,10 +1,19 @@ -import React, { ComponentType } from "react"; -import { ReactPyClient } from "./reactpy-client"; +import React from "react"; +import { ReactPyClientInterface } from "./types"; import serializeEvent from "event-to-object"; +import { + ReactPyVdom, + ReactPyVdomImportSource, + ReactPyVdomEventHandler, + ReactPyModule, + BindImportSource, + ReactPyModuleBinding, +} from "./types"; +import log from "./logger"; export async function loadImportSource( vdomImportSource: ReactPyVdomImportSource, - client: ReactPyClient, + client: ReactPyClientInterface, ): Promise { let module: ReactPyModule; if (vdomImportSource.sourceType === "URL") { @@ -30,7 +39,7 @@ export async function loadImportSource( typeof binding.unmount === "function" ) ) { - console.error(`${vdomImportSource.source} returned an impropper binding`); + log.error(`${vdomImportSource.source} returned an impropper binding`); return null; } @@ -51,7 +60,7 @@ export async function loadImportSource( } function createImportSourceElement(props: { - client: ReactPyClient; + client: ReactPyClientInterface; module: ReactPyModule; binding: ReactPyModuleBinding; model: ReactPyVdom; @@ -62,7 +71,7 @@ function createImportSourceElement(props: { if ( !isImportSourceEqual(props.currentImportSource, props.model.importSource) ) { - console.error( + log.error( "Parent element import source " + stringifyImportSource(props.currentImportSource) + " does not match child's import source " + @@ -70,7 +79,7 @@ function createImportSourceElement(props: { ); return null; } else if (!props.module[props.model.tagName]) { - console.error( + log.error( "Module from source " + stringifyImportSource(props.currentImportSource) + ` does not export ${props.model.tagName}`, @@ -131,7 +140,7 @@ export function createChildren( export function createAttributes( model: ReactPyVdom, - client: ReactPyClient, + client: ReactPyClientInterface, ): { [key: string]: any } { return Object.fromEntries( Object.entries({ @@ -149,7 +158,7 @@ export function createAttributes( } function createEventHandler( - client: ReactPyClient, + client: ReactPyClientInterface, name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { @@ -203,59 +212,3 @@ function snakeToCamel(str: string): string { // see list of HTML attributes with dashes in them: // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"]; - -export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>; - -export type ReactPyVdom = { - tagName: string; - key?: string; - attributes?: { [key: string]: string }; - children?: (ReactPyVdom | string)[]; - error?: string; - eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; - importSource?: ReactPyVdomImportSource; -}; - -export type ReactPyVdomEventHandler = { - target: string; - preventDefault?: boolean; - stopPropagation?: boolean; -}; - -export type ReactPyVdomImportSource = { - source: string; - sourceType?: "URL" | "NAME"; - fallback?: string | ReactPyVdom; - unmountBeforeUpdate?: boolean; -}; - -export type ReactPyModule = { - bind: ( - node: HTMLElement, - context: ReactPyModuleBindingContext, - ) => ReactPyModuleBinding; -} & { [key: string]: any }; - -export type ReactPyModuleBindingContext = { - sendMessage: ReactPyClient["sendMessage"]; - onMessage: ReactPyClient["onMessage"]; -}; - -export type ReactPyModuleBinding = { - create: ( - type: any, - props?: any, - children?: (any | string | ReactPyVdom)[], - ) => any; - render: (element: any) => void; - unmount: () => void; -}; - -export type BindImportSource = ( - node: HTMLElement, -) => ImportSourceBinding | null; - -export type ImportSourceBinding = { - render: (model: ReactPyVdom) => void; - unmount: () => void; -}; diff --git a/src/js/packages/@reactpy/client/src/websocket.ts b/src/js/packages/@reactpy/client/src/websocket.ts new file mode 100644 index 000000000..ba3fdc09f --- /dev/null +++ b/src/js/packages/@reactpy/client/src/websocket.ts @@ -0,0 +1,75 @@ +import { CreateReconnectingWebSocketProps } from "./types"; +import log from "./logger"; + +export function createReconnectingWebSocket( + props: CreateReconnectingWebSocketProps, +) { + const { interval, maxInterval, maxRetries, backoffMultiplier } = props; + let retries = 0; + let currentInterval = interval; + let everConnected = false; + const closed = false; + const socket: { current?: WebSocket } = {}; + + const connect = () => { + if (closed) { + return; + } + socket.current = new WebSocket(props.url); + socket.current.onopen = () => { + everConnected = true; + log.info("Connected!"); + currentInterval = interval; + retries = 0; + if (props.onOpen) { + props.onOpen(); + } + }; + socket.current.onmessage = (event) => { + if (props.onMessage) { + props.onMessage(event); + } + }; + socket.current.onclose = () => { + if (props.onClose) { + props.onClose(); + } + if (!everConnected) { + log.info("Failed to connect!"); + return; + } + log.info("Disconnected!"); + if (retries >= maxRetries) { + log.info("Connection max retries exhausted!"); + return; + } + log.info( + `Reconnecting in ${(currentInterval / 1000).toPrecision(4)} seconds...`, + ); + setTimeout(connect, currentInterval); + currentInterval = nextInterval( + currentInterval, + backoffMultiplier, + maxInterval, + ); + retries++; + }; + }; + + props.readyPromise.then(() => log.info("Starting client...")).then(connect); + + return socket; +} + +export function nextInterval( + currentInterval: number, + backoffMultiplier: number, + maxInterval: number, +): number { + return Math.min( + // increase interval by backoff multiplier + currentInterval * backoffMultiplier, + // don't exceed max interval + maxInterval, + ); +} diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index f22aa5832..a184905a6 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -1,6 +1,7 @@ -from reactpy import backend, config, logging, types, web, widgets +from reactpy import asgi, config, logging, types, web, widgets from reactpy._html import html -from reactpy.backend.utils import run +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 @@ -23,12 +24,14 @@ from reactpy.utils import Ref, html_to_vdom, vdom_to_html __author__ = "The Reactive Python Team" -__version__ = "1.1.0" +__version__ = "2.0.0a0" __all__ = [ "Layout", + "ReactPy", + "ReactPyMiddleware", "Ref", - "backend", + "asgi", "component", "config", "create_context", @@ -37,7 +40,6 @@ "html", "html_to_vdom", "logging", - "run", "types", "use_callback", "use_connection", diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index e2d4f096a..61c6ae77f 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -6,7 +6,7 @@ from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor if TYPE_CHECKING: - from reactpy.core.types import ( + from reactpy.types import ( EventHandlerDict, Key, VdomAttributes, diff --git a/tests/test_backend/__init__.py b/src/reactpy/asgi/__init__.py similarity index 100% rename from tests/test_backend/__init__.py rename to src/reactpy/asgi/__init__.py diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py new file mode 100644 index 000000000..ef108b3f4 --- /dev/null +++ b/src/reactpy/asgi/middleware.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import logging +import re +import traceback +import urllib.parse +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import orjson +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 Connection, Location, ReactPyConfig, RootComponentConstructor + +_logger = logging.getLogger(__name__) + + +class ReactPyMiddleware: + _asgi_single_callable: bool = True + root_component: RootComponentConstructor | None = None + root_components: dict[str, RootComponentConstructor] + multiple_root_components: bool = True + + def __init__( + self, + app: asgi_types.ASGIApplication, + root_components: Iterable[str], + **settings: Unpack[ReactPyConfig], + ) -> None: + """Configure the ASGI app. Anything initialized in this method will be shared across all future requests. + + Parameters: + app: The ASGI application to serve when the request does not match a ReactPy route. + root_components: + A list, set, or tuple containing the dotted path of your root components. This dotted path + must be valid to Python's import system. + settings: Global ReactPy configuration settings that affect behavior and performance. + """ + # Validate the configuration + if "path_prefix" in settings: + reason = check_path(settings["path_prefix"]) + if reason: + raise ValueError( + f'Invalid `path_prefix` of "{settings["path_prefix"]}". {reason}' + ) + if "web_modules_dir" in settings and not settings["web_modules_dir"].exists(): + raise ValueError( + f'Web modules directory "{settings["web_modules_dir"]}" does not exist.' + ) + + # Process global settings + process_settings(settings) + + # URL path attributes + self.path_prefix = config.REACTPY_PATH_PREFIX.current + self.dispatcher_path = self.path_prefix + self.web_modules_path = f"{self.path_prefix}modules/" + self.static_path = f"{self.path_prefix}static/" + 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}.*") + + # Component attributes + self.user_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore + self.root_components = import_components(root_components) + + # Directory attributes + self.web_modules_dir = config.REACTPY_WEB_MODULES_DIR.current + self.static_dir = Path(__file__).parent.parent / "static" + + # Initialize the sub-applications + self.component_dispatch_app = ComponentDispatchApp(parent=self) + self.static_file_app = StaticFileApp(parent=self) + self.web_modules_app = WebModuleApp(parent=self) + + async def __call__( + self, + scope: asgi_types.Scope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + ) -> None: + """The ASGI entrypoint that determines whether ReactPy should route the + request to ourselves or to the user application.""" + # URL routing for the ReactPy renderer + if scope["type"] == "websocket" and self.match_dispatch_path(scope): + return await self.component_dispatch_app(scope, receive, send) + + # URL routing for ReactPy static files + if scope["type"] == "http" and self.match_static_path(scope): + return await self.static_file_app(scope, receive, send) + + # URL routing for ReactPy web modules + if scope["type"] == "http" and self.match_web_modules_path(scope): + return await self.web_modules_app(scope, receive, send) + + # Serve the user's application + await self.user_app(scope, receive, send) + + 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"])) + + def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool: + return bool(re.match(self.js_modules_pattern, scope["path"])) + + +@dataclass +class ComponentDispatchApp: + parent: ReactPyMiddleware + + async def __call__( + self, + scope: asgi_types.WebSocketScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + ) -> None: + """ASGI app for rendering ReactPy Python components.""" + dispatcher: asyncio.Task[Any] | None = None + recv_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + # Start a loop that handles ASGI websocket events + while True: + event = await receive() + if event["type"] == "websocket.connect": + await send( + {"type": "websocket.accept", "subprotocol": None, "headers": []} + ) + dispatcher = asyncio.create_task( + self.run_dispatcher(scope, receive, send, recv_queue) + ) + + elif event["type"] == "websocket.disconnect": + if dispatcher: + dispatcher.cancel() + break + + elif event["type"] == "websocket.receive" and event["text"]: + queue_put_func = recv_queue.put(orjson.loads(event["text"])) + await queue_put_func + + async def run_dispatcher( + self, + scope: asgi_types.WebSocketScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + recv_queue: asyncio.Queue[dict[str, Any]], + ) -> None: + """Asyncio background task that renders and transmits layout updates of ReactPy components.""" + try: + # Determine component to serve by analyzing the URL and/or class parameters. + if self.parent.multiple_root_components: + url_match = re.match(self.parent.dispatcher_pattern, scope["path"]) + if not url_match: # pragma: no cover + raise RuntimeError("Could not find component in URL path.") + dotted_path = url_match["dotted_path"] + if dotted_path not in self.parent.root_components: + raise RuntimeError( + f"Attempting to use an unregistered root component {dotted_path}." + ) + component = self.parent.root_components[dotted_path] + elif self.parent.root_component: + component = self.parent.root_component + else: # pragma: no cover + raise RuntimeError("No root component provided.") + + # Create a connection object by analyzing the websocket's query string. + ws_query_string = urllib.parse.parse_qs( + scope["query_string"].decode(), strict_parsing=True + ) + connection = Connection( + scope=scope, + location=Location( + path=ws_query_string.get("http_pathname", [""])[0], + query_string=ws_query_string.get("http_query_string", [""])[0], + ), + carrier=self, + ) + + # Start the ReactPy component rendering loop + await serve_layout( + Layout(ConnectionContext(component(), value=connection)), + lambda msg: send( + { + "type": "websocket.send", + "text": orjson.dumps(msg).decode(), + "bytes": None, + } + ), + recv_queue.get, # type: ignore + ) + + # Manually log exceptions since this function is running in a separate asyncio task. + except Exception as error: + await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") + + +@dataclass +class StaticFileApp: + parent: ReactPyMiddleware + _static_file_server: ServeStaticASGI | None = None + + async def __call__( + self, + scope: asgi_types.HTTPScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + ) -> None: + """ASGI app for ReactPy static files.""" + if not self._static_file_server: + self._static_file_server = ServeStaticASGI( + self.parent.user_app, + root=self.parent.static_dir, + prefix=self.parent.static_path, + ) + + await self._static_file_server(scope, receive, send) + + +@dataclass +class WebModuleApp: + parent: ReactPyMiddleware + _static_file_server: ServeStaticASGI | None = None + + async def __call__( + self, + scope: asgi_types.HTTPScope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + ) -> None: + """ASGI app for ReactPy web modules.""" + if not self._static_file_server: + self._static_file_server = ServeStaticASGI( + self.parent.user_app, + root=self.parent.web_modules_dir, + prefix=self.parent.web_modules_path, + autorefresh=True, + ) + + await self._static_file_server(scope, receive, send) diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py new file mode 100644 index 000000000..3f7692045 --- /dev/null +++ b/src/reactpy/asgi/standalone.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import formatdate +from logging import getLogger + +from asgiref import typing as asgi_types +from typing_extensions import Unpack + +from reactpy import html +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.asgi.utils import dict_to_byte_list, http_response, vdom_head_to_html +from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict +from reactpy.utils import render_mount_template + +_logger = getLogger(__name__) + + +class ReactPy(ReactPyMiddleware): + multiple_root_components = False + + def __init__( + self, + root_component: RootComponentConstructor, + *, + http_headers: dict[str, str | int] | None = None, + html_head: VdomDict | None = None, + html_lang: str = "en", + **settings: Unpack[ReactPyConfig], + ) -> None: + """ReactPy's standalone ASGI application. + + Parameters: + root_component: The root component to render. This component is assumed to be a single page application. + 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. + """ + super().__init__(app=ReactPyApp(self), root_components=[], **settings) + self.root_component = root_component + 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: asgi_types.WebSocketScope) -> bool: + """Method override to remove `dotted_path` from the dispatcher URL.""" + return str(scope["path"]) == self.dispatcher_path + + +@dataclass +class ReactPyApp: + """ASGI app for ReactPy's standalone mode. This is utilized by `ReactPyMiddleware` as an alternative + to a user provided ASGI app.""" + + parent: ReactPy + _cached_index_html = "" + _etag = "" + _last_modified = "" + + async def __call__( + self, + scope: asgi_types.Scope, + receive: asgi_types.ASGIReceiveCallable, + send: asgi_types.ASGISendCallable, + ) -> None: + if scope["type"] != "http": # pragma: no cover + if scope["type"] != "lifespan": + msg = ( + "ReactPy app received unsupported request of type '%s' at path '%s'", + scope["type"], + scope["path"], + ) + _logger.warning(msg) + raise NotImplementedError(msg) + return + + # Store the HTTP response in memory for performance + if not self._cached_index_html: + self.process_index_html() + + # Response headers for `index.html` responses + request_headers = dict(scope["headers"]) + response_headers: dict[str, str | int] = { + "etag": self._etag, + "last-modified": self._last_modified, + "access-control-allow-origin": "*", + "cache-control": "max-age=60, public", + "content-length": len(self._cached_index_html), + "content-type": "text/html; charset=utf-8", + **self.parent.extra_headers, + } + + # Browser is asking for the headers + if scope["method"] == "HEAD": + return await http_response( + send=send, + method=scope["method"], + headers=dict_to_byte_list(response_headers), + ) + + # Browser already has the content cached + if ( + request_headers.get(b"if-none-match") == self._etag.encode() + or request_headers.get(b"if-modified-since") == self._last_modified.encode() + ): + response_headers.pop("content-length") + return await http_response( + send=send, + method=scope["method"], + code=304, + headers=dict_to_byte_list(response_headers), + ) + + # Send the index.html + await http_response( + send=send, + method=scope["method"], + message=self._cached_index_html, + headers=dict_to_byte_list(response_headers), + ) + + def process_index_html(self) -> None: + """Process the index.html and store the results in memory.""" + self._cached_index_html = ( + "" + f'' + f"{vdom_head_to_html(self.parent.html_head)}" + "" + f"{render_mount_template('app', '', '')}" + "" + "" + ) + + self._etag = f'"{hashlib.md5(self._cached_index_html.encode(), usedforsecurity=False).hexdigest()}"' + self._last_modified = formatdate( + datetime.now(tz=timezone.utc).timestamp(), usegmt=True + ) diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py new file mode 100644 index 000000000..fe4f1ef64 --- /dev/null +++ b/src/reactpy/asgi/utils.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterable +from importlib import import_module +from typing import Any + +from asgiref import typing as asgi_types + +from reactpy._option import Option +from reactpy.types import ReactPyConfig, VdomDict +from reactpy.utils import 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 { + dotted_path: import_dotted_path(dotted_path) for dotted_path in dotted_paths + } + + +def check_path(url_path: str) -> str: # pragma: no cover + """Check that a path is valid URL path.""" + if not url_path: + return "URL path must not be empty." + if not isinstance(url_path, str): + return "URL path is must be a string." + if not url_path.startswith("/"): + return "URL path must start with a forward slash." + if not url_path.endswith("/"): + return "URL path must end with a forward slash." + + return "" + + +def dict_to_byte_list( + data: dict[str, str | int], +) -> list[tuple[bytes, bytes]]: + """Convert a dictionary to a list of byte tuples.""" + result: list[tuple[bytes, bytes]] = [] + for key, value in data.items(): + new_key = key.encode() + new_value = value.encode() if isinstance(value, str) else str(value).encode() + result.append((new_key, new_value)) + return result + + +def vdom_head_to_html(head: VdomDict) -> str: + if isinstance(head, dict) and head.get("tagName") == "head": + return vdom_to_html(head) + + raise ValueError( + "Invalid head element! Element must be either `html.head` or a string." + ) + + +async def http_response( + *, + send: asgi_types.ASGISendCallable, + method: str, + code: int = 200, + message: str = "", + headers: Iterable[tuple[bytes, bytes]] = (), +) -> None: + """Sends a HTTP response using the ASGI `send` API.""" + start_msg: asgi_types.HTTPResponseStartEvent = { + "type": "http.response.start", + "status": code, + "headers": [*headers], + "trailers": False, + } + body_msg: asgi_types.HTTPResponseBodyEvent = { + "type": "http.response.body", + "body": b"", + "more_body": False, + } + + # Add the content type and body to everything other than a HEAD request + if method != "HEAD": + body_msg["body"] = message.encode() + + await send(start_msg) + await send(body_msg) + + +def process_settings(settings: ReactPyConfig) -> None: + """Process the settings and return the final configuration.""" + from reactpy import config + + for setting in settings: + config_name = f"REACTPY_{setting.upper()}" + config_object: Option[Any] | None = getattr(config, config_name, None) + if config_object: + config_object.set_current(settings[setting]) # type: ignore + else: + raise ValueError(f'Unknown ReactPy setting "{setting}".') diff --git a/src/reactpy/backend/__init__.py b/src/reactpy/backend/__init__.py deleted file mode 100644 index e08e50649..000000000 --- a/src/reactpy/backend/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import mimetypes -from logging import getLogger - -_logger = getLogger(__name__) - -# Fix for missing mime types due to OS corruption/misconfiguration -# Example: https://github.com/encode/starlette/issues/829 -if not mimetypes.inited: - mimetypes.init() -for extension, mime_type in { - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", -}.items(): - if not mimetypes.types_map.get(extension): # pragma: no cover - _logger.warning( - "Mime type '%s = %s' is missing. Please research how to " - "fix missing mime types on your operating system.", - extension, - mime_type, - ) - mimetypes.add_type(mime_type, extension) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py deleted file mode 100644 index 1e369a26b..000000000 --- a/src/reactpy/backend/_common.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from collections.abc import Awaitable, Sequence -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any, cast - -from reactpy import __file__ as _reactpy_file_path -from reactpy import html -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.types import VdomDict -from reactpy.utils import vdom_to_html - -if TYPE_CHECKING: - import uvicorn - from asgiref.typing import ASGIApplication - -PATH_PREFIX = PurePosixPath("/_reactpy") -MODULES_PATH = PATH_PREFIX / "modules" -ASSETS_PATH = PATH_PREFIX / "assets" -STREAM_PATH = PATH_PREFIX / "stream" -CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "static" - - -async def serve_with_uvicorn( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, -) -> None: - """Run a development server for an ASGI application""" - import uvicorn - - server = uvicorn.Server( - uvicorn.Config( - app, - host=host, - port=port, - loop="asyncio", - ) - ) - server.config.setup_event_loop() - coros: list[Awaitable[Any]] = [server.serve()] - - # If a started event is provided, then use it signal based on `server.started` - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's - # order of operations. So we need to make sure `shutdown()` always has an initialized - # list of `self.servers` to use. - if not hasattr(server, "servers"): # nocov - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) - - -async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() - - -def safe_client_build_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") - ) - - -def safe_web_modules_dir_path(path: str) -> Path: - """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) - - -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: - """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" - root = os.path.abspath(root) - - # Resolve relative paths but not symlinks - symlinks should be ok since their - # presence and where they point is under the control of the developer. - path = os.path.abspath(os.path.join(root, *unsafe)) - - if os.path.commonprefix([root, path]) != root: - # If the common prefix is not root directory we resolved outside the root dir - msg = "Unsafe path" - raise ValueError(msg) - - return Path(path) - - -def read_client_index_html(options: CommonOptions) -> str: - return ( - (CLIENT_BUILD_DIR / "index.html") - .read_text() - .format(__head__=vdom_head_elements_to_html(options.head)) - ) - - -def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str: - if isinstance(head, str): - return head - elif isinstance(head, dict): - if head.get("tagName") == "head": - head = cast(VdomDict, {**head, "tagName": ""}) - return vdom_to_html(head) - else: - return vdom_to_html(html.fragment(*head)) - - -@dataclass -class CommonOptions: - """Options for ReactPy's built-in backed server implementations""" - - head: Sequence[VdomDict] | VdomDict | str = (html.title("ReactPy"),) - """Add elements to the ```` of the application. - - For example, this can be used to customize the title of the page, link extra - scripts, or load stylesheets. - """ - - url_prefix: str = "" - """The URL prefix where ReactPy resources will be served from""" - - serve_index_route: bool = True - """Automatically generate and serve the index route (``/``)""" - - def __post_init__(self) -> None: - if self.url_prefix and not self.url_prefix.startswith("/"): - msg = "Expected 'url_prefix' to start with '/'" - raise ValueError(msg) diff --git a/src/reactpy/backend/default.py b/src/reactpy/backend/default.py deleted file mode 100644 index 37aad31af..000000000 --- a/src/reactpy/backend/default.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import asyncio -from logging import getLogger -from sys import exc_info -from typing import Any, NoReturn - -from reactpy.backend.types import BackendType -from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations -from reactpy.types import RootComponentConstructor - -logger = getLogger(__name__) -_DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None - - -# BackendType.Options -class Options: # nocov - """Configuration options that can be provided to the backend. - This definition should not be used/instantiated. It exists only for - type hinting purposes.""" - - def __init__(self, *args: Any, **kwds: Any) -> NoReturn: - msg = "Default implementation has no options." - raise ValueError(msg) - - -# BackendType.configure -def configure( - app: Any, component: RootComponentConstructor, options: None = None -) -> None: - """Configure the given app instance to display the given component""" - if options is not None: # nocov - msg = "Default implementation cannot be configured with options" - raise ValueError(msg) - return _default_implementation().configure(app, component) - - -# BackendType.create_development_app -def create_development_app() -> Any: - """Create an application instance for development purposes""" - return _default_implementation().create_development_app() - - -# BackendType.serve_development_app -async def serve_development_app( - app: Any, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run an application using a development server""" - return await _default_implementation().serve_development_app( - app, host, port, started - ) - - -def _default_implementation() -> BackendType[Any]: - """Get the first available server implementation""" - global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 - - if _DEFAULT_IMPLEMENTATION is not None: - return _DEFAULT_IMPLEMENTATION - - try: - implementation = next(all_implementations()) - except StopIteration: # nocov - logger.debug("Backend implementation import failed", exc_info=exc_info()) - supported_backends = ", ".join(SUPPORTED_BACKENDS) - msg = ( - "It seems you haven't installed a backend. To resolve this issue, " - "you can install a backend by running:\n\n" - '\033[1mpip install "reactpy[starlette]"\033[0m\n\n' - f"Other supported backends include: {supported_backends}." - ) - raise RuntimeError(msg) from None - else: - _DEFAULT_IMPLEMENTATION = implementation - return implementation diff --git a/src/reactpy/backend/fastapi.py b/src/reactpy/backend/fastapi.py deleted file mode 100644 index a0137a3dc..000000000 --- a/src/reactpy/backend/fastapi.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from fastapi import FastAPI - -from reactpy.backend import starlette - -# BackendType.Options -Options = starlette.Options - -# BackendType.configure -configure = starlette.configure - - -# BackendType.create_development_app -def create_development_app() -> FastAPI: - """Create a development ``FastAPI`` application instance.""" - return FastAPI(debug=True) - - -# BackendType.serve_development_app -serve_development_app = starlette.serve_development_app - -use_connection = starlette.use_connection - -use_websocket = starlette.use_websocket diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py deleted file mode 100644 index 4401fb6f7..000000000 --- a/src/reactpy/backend/flask.py +++ /dev/null @@ -1,303 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -from asyncio import Queue as AsyncQueue -from dataclasses import dataclass -from queue import Queue as ThreadQueue -from threading import Event as ThreadEvent -from threading import Thread -from typing import Any, Callable, NamedTuple, NoReturn, cast - -from flask import ( - Blueprint, - Flask, - Request, - copy_current_request_context, - request, - send_file, -) -from flask_cors import CORS -from flask_sock import Sock -from simple_websocket import Server as WebSocket -from werkzeug.serving import BaseWSGIServer, make_server - -import reactpy -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, -) -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentType, RootComponentConstructor -from reactpy.utils import Ref - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.flask.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - -# BackendType.configure -def configure( - app: Flask, component: RootComponentConstructor, options: Options | None = None -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - api_bp = Blueprint(f"reactpy_api_{id(app)}", __name__, url_prefix=str(PATH_PREFIX)) - spa_bp = Blueprint( - f"reactpy_spa_{id(app)}", __name__, url_prefix=options.url_prefix - ) - - _setup_single_view_dispatcher_route(api_bp, options, component) - _setup_common_routes(api_bp, spa_bp, options) - - app.register_blueprint(api_bp) - app.register_blueprint(spa_bp) - - -# BackendType.create_development_app -def create_development_app() -> Flask: - """Create an application instance for development purposes""" - os.environ["FLASK_DEBUG"] = "true" - return Flask(__name__) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Flask, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for FastAPI""" - loop = asyncio.get_running_loop() - stopped = asyncio.Event() - - server: Ref[BaseWSGIServer] = Ref() - - def run_server() -> None: - server.current = make_server(host, port, app, threaded=True) - if started: - loop.call_soon_threadsafe(started.set) - try: - server.current.serve_forever() # type: ignore - finally: - loop.call_soon_threadsafe(stopped.set) - - thread = Thread(target=run_server, daemon=True) - thread.start() - - if started: - await started.wait() - - try: - await stopped.wait() - finally: - # we may have exited because this task was cancelled - server.current.shutdown() - # the thread should eventually join - thread.join(timeout=3) - # just double check it happened - if thread.is_alive(): # nocov - msg = "Failed to shutdown server." - raise RuntimeError(msg) - - -def use_websocket() -> WebSocket: - """A handle to the current websocket""" - return use_connection().carrier.websocket - - -def use_request() -> Request: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_connection() -> Connection[_FlaskCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _FlaskCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - @api_blueprint.route(f"/{ASSETS_PATH.name}/") - def send_assets_dir(path: str = "") -> Any: - return send_file(safe_client_build_dir_path(f"assets/{path}")) - - @api_blueprint.route(f"/{MODULES_PATH.name}/") - def send_modules_dir(path: str = "") -> Any: - return send_file(safe_web_modules_dir_path(path), mimetype="text/javascript") - - index_html = read_client_index_html(options) - - if options.serve_index_route: - - @spa_blueprint.route("/") - @spa_blueprint.route("/") - def send_client_dir(_: str = "") -> Any: - return index_html - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, options: Options, constructor: RootComponentConstructor -) -> None: - sock = Sock(api_blueprint) - - def model_stream(ws: WebSocket, path: str = "") -> None: - def send(value: Any) -> None: - ws.send(json.dumps(value)) - - def recv() -> Any: - return json.loads(ws.receive()) - - _dispatch_in_thread( - ws, - # remove any url prefix from path - path[len(options.url_prefix) :], - constructor(), - send, - recv, - ) - - sock.route(STREAM_PATH.name, endpoint="without_path")(model_stream) - sock.route(f"{STREAM_PATH.name}/", endpoint="with_path")(model_stream) - - -def _dispatch_in_thread( - websocket: WebSocket, - path: str, - component: ComponentType, - send: Callable[[Any], None], - recv: Callable[[], Any | None], -) -> NoReturn: - dispatch_thread_info_created = ThreadEvent() - dispatch_thread_info_ref: reactpy.Ref[_DispatcherThreadInfo | None] = reactpy.Ref( - None - ) - - @copy_current_request_context - def run_dispatcher() -> None: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - thread_send_queue: ThreadQueue[Any] = ThreadQueue() - async_recv_queue: AsyncQueue[Any] = AsyncQueue() - - async def send_coro(value: Any) -> None: - thread_send_queue.put(value) - - async def main() -> None: - search = request.query_string.decode() - await serve_layout( - reactpy.Layout( - ConnectionContext( - component, - value=Connection( - scope=request.environ, - location=Location( - pathname=f"/{path}", - search=f"?{search}" if search else "", - ), - carrier=_FlaskCarrier(request, websocket), - ), - ), - ), - send_coro, - async_recv_queue.get, - ) - - main_future = asyncio.ensure_future(main(), loop=loop) - - dispatch_thread_info_ref.current = _DispatcherThreadInfo( - dispatch_loop=loop, - dispatch_future=main_future, - thread_send_queue=thread_send_queue, - async_recv_queue=async_recv_queue, - ) - dispatch_thread_info_created.set() - - loop.run_until_complete(main_future) - - Thread(target=run_dispatcher, daemon=True).start() - - dispatch_thread_info_created.wait() - dispatch_thread_info = cast(_DispatcherThreadInfo, dispatch_thread_info_ref.current) - - if dispatch_thread_info is None: - raise RuntimeError("Failed to create dispatcher thread") # nocov - - stop = ThreadEvent() - - def run_send() -> None: - while not stop.is_set(): - send(dispatch_thread_info.thread_send_queue.get()) - - Thread(target=run_send, daemon=True).start() - - try: - while True: - value = recv() - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.async_recv_queue.put_nowait, value - ) - finally: # nocov - dispatch_thread_info.dispatch_loop.call_soon_threadsafe( - dispatch_thread_info.dispatch_future.cancel - ) - - -class _DispatcherThreadInfo(NamedTuple): - dispatch_loop: asyncio.AbstractEventLoop - dispatch_future: asyncio.Future[Any] - thread_send_queue: ThreadQueue[Any] - async_recv_queue: AsyncQueue[Any] - - -@dataclass -class _FlaskCarrier: - """A simple wrapper for holding a Flask request and WebSocket""" - - request: Request - """The current request object""" - - websocket: WebSocket - """A handle to the current websocket""" diff --git a/src/reactpy/backend/hooks.py b/src/reactpy/backend/hooks.py deleted file mode 100644 index ec761ef0f..000000000 --- a/src/reactpy/backend/hooks.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations # nocov - -from collections.abc import MutableMapping # nocov -from typing import Any # nocov - -from reactpy._warnings import warn # nocov -from reactpy.backend.types import Connection, Location # nocov -from reactpy.core.hooks import ConnectionContext, use_context # nocov - - -def use_connection() -> Connection[Any]: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_connection instead.", - DeprecationWarning, - ) - - conn = use_context(ConnectionContext) - if conn is None: - msg = "No backend established a connection." - raise RuntimeError(msg) - return conn - - -def use_scope() -> MutableMapping[str, Any]: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_scope instead.", - DeprecationWarning, - ) - - return use_connection().scope - - -def use_location() -> Location: # nocov - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" - warn( - "The module reactpy.backend.hooks has been deprecated and will be deleted in the future. " - "Call reactpy.use_location instead.", - DeprecationWarning, - ) - - return use_connection().location diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py deleted file mode 100644 index d272fb4cf..000000000 --- a/src/reactpy/backend/sanic.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from dataclasses import dataclass -from typing import Any -from urllib import parse as urllib_parse -from uuid import uuid4 - -from sanic import Blueprint, Sanic, request, response -from sanic.config import Config -from sanic.server.websockets.connection import WebSocketConnection -from sanic_cors import CORS - -from reactpy.backend._common import ( - ASSETS_PATH, - MODULES_PATH, - PATH_PREFIX, - STREAM_PATH, - CommonOptions, - read_client_index_html, - safe_client_build_dir_path, - safe_web_modules_dir_path, - serve_with_uvicorn, -) -from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, Stop, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.sanic.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``sanic_cors.CORS`` - """ - - -# BackendType.configure -def configure( - app: Sanic[Any, Any], - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure an application instance to display the given component""" - options = options or Options() - - spa_bp = Blueprint(f"reactpy_spa_{id(app)}", url_prefix=options.url_prefix) - api_bp = Blueprint(f"reactpy_api_{id(app)}", url_prefix=str(PATH_PREFIX)) - - _setup_common_routes(api_bp, spa_bp, options) - _setup_single_view_dispatcher_route(api_bp, component, options) - - app.blueprint([spa_bp, api_bp]) - - -# BackendType.create_development_app -def create_development_app() -> Sanic[Any, Any]: - """Return a :class:`Sanic` app instance in test mode""" - Sanic.test_mode = True - logger.warning("Sanic.test_mode is now active") - return Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Sanic[Any, Any], - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for :mod:`sanic`""" - await serve_with_uvicorn(app, host, port, started) - - -def use_request() -> request.Request[Any, Any]: - """Get the current ``Request``""" - return use_connection().carrier.request - - -def use_websocket() -> WebSocketConnection: - """Get the current websocket""" - return use_connection().carrier.websocket - - -def use_connection() -> Connection[_SanicCarrier]: - """Get the current :class:`Connection`""" - conn = _use_connection() - if not isinstance(conn.carrier, _SanicCarrier): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Sanic server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes( - api_blueprint: Blueprint, - spa_blueprint: Blueprint, - options: Options, -) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = cors_options if isinstance(cors_options, dict) else {} - CORS(api_blueprint, **cors_params) - - index_html = read_client_index_html(options) - - async def single_page_app_files( - request: request.Request[Any, Any], - _: str = "", - ) -> response.HTTPResponse: - return response.html(index_html) - - if options.serve_index_route: - spa_blueprint.add_route( - single_page_app_files, - "/", - name="single_page_app_files_root", - ) - spa_blueprint.add_route( - single_page_app_files, - "/<_:path>", - name="single_page_app_files_path", - ) - - async def asset_files( - request: request.Request[Any, Any], - path: str = "", - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file(safe_client_build_dir_path(f"assets/{path}")) - - api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") - - async def web_module_files( - request: request.Request[Any, Any], - path: str, - _: str = "", # this is not used - ) -> response.HTTPResponse: - path = urllib_parse.unquote(path) - return await response.file( - safe_web_modules_dir_path(path), - mime_type="text/javascript", - ) - - api_blueprint.add_route(web_module_files, f"/{MODULES_PATH.name}/") - - -def _setup_single_view_dispatcher_route( - api_blueprint: Blueprint, - constructor: RootComponentConstructor, - options: Options, -) -> None: - async def model_stream( - request: request.Request[Any, Any], - socket: WebSocketConnection, - path: str = "", - ) -> None: - asgi_app = getattr(request.app, "_asgi_app", None) - scope = asgi_app.transport.scope if asgi_app else {} - if not scope: # nocov - logger.warning("No scope. Sanic may not be running with an ASGI server") - - send, recv = _make_send_recv_callbacks(socket) - await serve_layout( - Layout( - ConnectionContext( - constructor(), - value=Connection( - scope=scope, - location=Location( - pathname=f"/{path[len(options.url_prefix):]}", - search=( - f"?{request.query_string}" - if request.query_string - else "" - ), - ), - carrier=_SanicCarrier(request, socket), - ), - ) - ), - send, - recv, - ) - - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}", - name="model_stream_root", - ) - api_blueprint.add_websocket_route( - model_stream, - f"/{STREAM_PATH.name}//", - name="model_stream_path", - ) - - -def _make_send_recv_callbacks( - socket: WebSocketConnection, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send(json.dumps(value)) - - async def sock_recv() -> Any: - data = await socket.recv() - if data is None: - raise Stop() - return json.loads(data) - - return sock_send, sock_recv - - -@dataclass -class _SanicCarrier: - """A simple wrapper for holding connection information""" - - request: request.Request[Sanic[Any, Any], Any] - """The current request object""" - - websocket: WebSocketConnection - """A handle to the current websocket""" diff --git a/src/reactpy/backend/starlette.py b/src/reactpy/backend/starlette.py deleted file mode 100644 index 20e2b4478..000000000 --- a/src/reactpy/backend/starlette.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Any, Callable - -from exceptiongroup import BaseExceptionGroup -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request -from starlette.responses import HTMLResponse -from starlette.staticfiles import StaticFiles -from starlette.websockets import WebSocket, WebSocketDisconnect - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, - serve_with_uvicorn, -) -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import RecvCoroutine, SendCoroutine, serve_layout -from reactpy.core.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - - -# BackendType.Options -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.starlette.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - -# BackendType.configure -def configure( - app: Starlette, - component: RootComponentConstructor, - options: Options | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(options, app, component) - - _setup_common_routes(options, app) - - -# BackendType.create_development_app -def create_development_app() -> Starlette: - """Return a :class:`Starlette` app instance in debug mode""" - return Starlette(debug=True) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Starlette, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - """Run a development server for starlette""" - await serve_with_uvicorn(app, host, port, started) - - -def use_websocket() -> WebSocket: - """Get the current WebSocket object""" - return use_connection().carrier - - -def use_connection() -> Connection[WebSocket]: - conn = _use_connection() - if not isinstance(conn.carrier, WebSocket): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -def _setup_common_routes(options: Options, app: Starlette) -> None: - cors_options = options.cors - if cors_options: # nocov - cors_params = ( - cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} - ) - app.add_middleware(CORSMiddleware, **cors_params) - - # This really should be added to the APIRouter, but there's a bug in Starlette - # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = options.url_prefix - - app.mount( - str(MODULES_PATH), - StaticFiles(directory=REACTPY_WEB_MODULES_DIR.current, check_dir=False), - ) - app.mount( - str(ASSETS_PATH), - StaticFiles(directory=CLIENT_BUILD_DIR / "assets", check_dir=False), - ) - # register this last so it takes least priority - index_route = _make_index_route(options) - - if options.serve_index_route: - app.add_route(f"{url_prefix}/", index_route) - app.add_route(url_prefix + "/{path:path}", index_route) - - -def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: - index_html = read_client_index_html(options) - - async def serve_index(request: Request) -> HTMLResponse: - return HTMLResponse(index_html) - - return serve_index - - -def _setup_single_view_dispatcher_route( - options: Options, app: Starlette, component: RootComponentConstructor -) -> None: - async def model_stream(socket: WebSocket) -> None: - await socket.accept() - send, recv = _make_send_recv_callbacks(socket) - - pathname = "/" + socket.scope["path_params"].get("path", "") - pathname = pathname[len(options.url_prefix) :] or "/" - search = socket.scope["query_string"].decode() - - try: - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=socket.scope, - location=Location(pathname, f"?{search}" if search else ""), - carrier=socket, - ), - ) - ), - send, - recv, - ) - except BaseExceptionGroup as egroup: - for e in egroup.exceptions: - if isinstance(e, WebSocketDisconnect): - logger.info(f"WebSocket disconnect: {e.code}") - break - else: # nocov - raise - - app.add_websocket_route(str(STREAM_PATH), model_stream) - app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream) - - -def _make_send_recv_callbacks( - socket: WebSocket, -) -> tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: Any) -> None: - await socket.send_text(json.dumps(value)) - - async def sock_recv() -> Any: - return json.loads(await socket.receive_text()) - - return sock_send, sock_recv diff --git a/src/reactpy/backend/tornado.py b/src/reactpy/backend/tornado.py deleted file mode 100644 index e585553e8..000000000 --- a/src/reactpy/backend/tornado.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from asyncio import Queue as AsyncQueue -from asyncio.futures import Future -from typing import Any -from urllib.parse import urljoin - -from tornado.httpserver import HTTPServer -from tornado.httputil import HTTPServerRequest -from tornado.log import enable_pretty_logging -from tornado.platform.asyncio import AsyncIOMainLoop -from tornado.web import Application, RequestHandler, StaticFileHandler -from tornado.websocket import WebSocketHandler -from tornado.wsgi import WSGIContainer -from typing_extensions import TypeAlias - -from reactpy.backend._common import ( - ASSETS_PATH, - CLIENT_BUILD_DIR, - MODULES_PATH, - STREAM_PATH, - CommonOptions, - read_client_index_html, -) -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy.core.hooks import ConnectionContext -from reactpy.core.hooks import use_connection as _use_connection -from reactpy.core.layout import Layout -from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentConstructor - -# BackendType.Options -Options = CommonOptions - - -# BackendType.configure -def configure( - app: Application, - component: ComponentConstructor, - options: CommonOptions | None = None, -) -> None: - """Configure the necessary ReactPy routes on the given app. - - Parameters: - app: An application instance - component: A component constructor - options: Options for configuring server behavior - """ - options = options or Options() - _add_handler( - app, - options, - ( - # this route should take priority so set up it up first - _setup_single_view_dispatcher_route(component, options) - + _setup_common_routes(options) - ), - ) - - -# BackendType.create_development_app -def create_development_app() -> Application: - return Application(debug=True) - - -# BackendType.serve_development_app -async def serve_development_app( - app: Application, - host: str, - port: int, - started: asyncio.Event | None = None, -) -> None: - enable_pretty_logging() - - AsyncIOMainLoop.current().install() - - server = HTTPServer(app) - server.listen(port, host) - - if started: - # at this point the server is accepting connection - started.set() - - try: - # block forever - tornado has already set up its own background tasks - await asyncio.get_running_loop().create_future() - finally: - # stop accepting new connections - server.stop() - # wait for existing connections to complete - await server.close_all_connections() - - -def use_request() -> HTTPServerRequest: - """Get the current ``HTTPServerRequest``""" - return use_connection().carrier - - -def use_connection() -> Connection[HTTPServerRequest]: - conn = _use_connection() - if not isinstance(conn.carrier, HTTPServerRequest): # nocov - msg = f"Connection has unexpected carrier {conn.carrier}. Are you running with a Flask server?" - raise TypeError(msg) - return conn - - -_RouteHandlerSpecs: TypeAlias = "list[tuple[str, type[RequestHandler], Any]]" - - -def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: - return [ - ( - rf"{MODULES_PATH}/(.*)", - StaticFileHandler, - {"path": str(REACTPY_WEB_MODULES_DIR.current)}, - ), - ( - rf"{ASSETS_PATH}/(.*)", - StaticFileHandler, - {"path": str(CLIENT_BUILD_DIR / "assets")}, - ), - ] + ( - [ - ( - r"/(.*)", - IndexHandler, - {"index_html": read_client_index_html(options)}, - ), - ] - if options.serve_index_route - else [] - ) - - -def _add_handler( - app: Application, options: Options, handlers: _RouteHandlerSpecs -) -> None: - prefixed_handlers: list[Any] = [ - (urljoin(options.url_prefix, route_pattern), *tuple(handler_info)) - for route_pattern, *handler_info in handlers - ] - app.add_handlers(r".*", prefixed_handlers) - - -def _setup_single_view_dispatcher_route( - constructor: ComponentConstructor, options: Options -) -> _RouteHandlerSpecs: - return [ - ( - rf"{STREAM_PATH}/(.*)", - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ( - str(STREAM_PATH), - ModelStreamHandler, - {"component_constructor": constructor, "url_prefix": options.url_prefix}, - ), - ] - - -class IndexHandler(RequestHandler): # type: ignore - _index_html: str - - def initialize(self, index_html: str) -> None: - self._index_html = index_html - - async def get(self, _: str) -> None: - self.finish(self._index_html) - - -class ModelStreamHandler(WebSocketHandler): # type: ignore - """A web-socket handler that serves up a new model stream to each new client""" - - _dispatch_future: Future[None] - _message_queue: AsyncQueue[str] - - def initialize( - self, component_constructor: ComponentConstructor, url_prefix: str - ) -> None: - self._component_constructor = component_constructor - self._url_prefix = url_prefix - - async def open(self, path: str = "", *args: Any, **kwargs: Any) -> None: - message_queue: AsyncQueue[str] = AsyncQueue() - - async def send(value: Any) -> None: - await self.write_message(json.dumps(value)) - - async def recv() -> Any: - return json.loads(await message_queue.get()) - - self._message_queue = message_queue - self._dispatch_future = asyncio.ensure_future( - serve_layout( - Layout( - ConnectionContext( - self._component_constructor(), - value=Connection( - scope=_FAKE_WSGI_CONTAINER.environ(self.request), - location=Location( - pathname=f"/{path[len(self._url_prefix) :]}", - search=( - f"?{self.request.query}" - if self.request.query - else "" - ), - ), - carrier=self.request, - ), - ) - ), - send, - recv, - ) - ) - - async def on_message(self, message: str | bytes) -> None: - await self._message_queue.put( - message if isinstance(message, str) else message.decode() - ) - - def on_close(self) -> None: - if not self._dispatch_future.done(): - self._dispatch_future.cancel() - - -# The interface for WSGIContainer.environ changed in Tornado version 6.3 from -# a staticmethod to an instance method. Since we're not that concerned with -# the details of the WSGI app itself, we can just use a fake one. -# see: https://github.com/tornadoweb/tornado/pull/3231#issuecomment-1518957578 -_FAKE_WSGI_CONTAINER = WSGIContainer(lambda *a, **kw: iter([])) diff --git a/src/reactpy/backend/types.py b/src/reactpy/backend/types.py deleted file mode 100644 index 51e7bef04..000000000 --- a/src/reactpy/backend/types.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import MutableMapping -from dataclasses import dataclass -from typing import Any, Callable, Generic, Protocol, TypeVar, runtime_checkable - -from reactpy.core.types import RootComponentConstructor - -_App = TypeVar("_App") - - -@runtime_checkable -class BackendType(Protocol[_App]): - """Common interface for built-in web server/framework integrations""" - - Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendType.configure`""" - - def configure( - self, - app: _App, - component: RootComponentConstructor, - options: Any | None = None, - ) -> None: - """Configure the given app instance to display the given component""" - - def create_development_app(self) -> _App: - """Create an application instance for development purposes""" - - async def serve_development_app( - self, - app: _App, - host: str, - port: int, - started: asyncio.Event | None = None, - ) -> None: - """Run an application using a development server""" - - -_Carrier = TypeVar("_Carrier") - - -@dataclass -class Connection(Generic[_Carrier]): - """Represents a connection with a client""" - - scope: MutableMapping[str, Any] - """An ASGI scope or WSGI environment dictionary""" - - location: Location - """The current location (URL)""" - - carrier: _Carrier - """How the connection is mediated. For example, a request or websocket. - - This typically depends on the backend implementation. - """ - - -@dataclass -class Location: - """Represents the current location (URL) - - Analogous to, but not necessarily identical to, the client-side - ``document.location`` object. - """ - - pathname: str - """the path of the URL for the location""" - - search: str - """A search or query string - a '?' followed by the parameters of the URL. - - If there are no search parameters this should be an empty string - """ diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py deleted file mode 100644 index 74e87bb7b..000000000 --- a/src/reactpy/backend/utils.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import socket -import sys -from collections.abc import Iterator -from contextlib import closing -from importlib import import_module -from typing import Any - -from reactpy.backend.types import BackendType -from reactpy.types import RootComponentConstructor - -logger = logging.getLogger(__name__) - -SUPPORTED_BACKENDS = ( - "fastapi", - "sanic", - "tornado", - "flask", - "starlette", -) - - -def run( - component: RootComponentConstructor, - host: str = "127.0.0.1", - port: int | None = None, - implementation: BackendType[Any] | None = None, -) -> None: - """Run a component with a development server""" - logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) - - implementation = implementation or import_module("reactpy.backend.default") - app = implementation.create_development_app() - implementation.configure(app, component) - port = port or find_available_port(host) - app_cls = type(app) - - logger.info( - "ReactPy is running with '%s.%s' at http://%s:%s", - app_cls.__module__, - app_cls.__name__, - host, - port, - ) - asyncio.run(implementation.serve_development_app(app, host, port)) - - -def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: - """Get a port that's available for the given host and port range""" - for port in range(port_min, port_max): - with closing(socket.socket()) as sock: - try: - if sys.platform in ("linux", "darwin"): - # Fixes bug on Unix-like systems where every time you restart the - # server you'll get a different port on Linux. This cannot be set - # on Windows otherwise address will always be reused. - # Ref: https://stackoverflow.com/a/19247688/3159288 - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((host, port)) - except OSError: - pass - else: - return port - msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" - raise RuntimeError(msg) - - -def all_implementations() -> Iterator[BackendType[Any]]: - """Yield all available server implementations""" - for name in SUPPORTED_BACKENDS: - try: - import_module(name) - except ImportError: # nocov - logger.debug("Failed to import %s", name, exc_info=True) - continue - - reactpy_backend_name = f"{__name__.rsplit('.', 1)[0]}.{name}" - yield import_module(reactpy_backend_name) - - -_DEVELOPMENT_RUN_FUNC_WARNING = """\ -The `run()` function is only intended for testing during development! To run \ -in production, refer to the docs on how to use reactpy.backend.*.configure.\ -""" diff --git a/src/reactpy/config.py b/src/reactpy/config.py index 426398208..be6ceb3da 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -33,9 +33,7 @@ def boolean(value: str | bool | int) -> bool: ) -REACTPY_DEBUG_MODE = Option( - "REACTPY_DEBUG_MODE", default=False, validator=boolean, mutable=True -) +REACTPY_DEBUG = Option("REACTPY_DEBUG", default=False, validator=boolean, mutable=True) """Get extra logs and validation checks at the cost of performance. This will enable the following: @@ -44,13 +42,13 @@ def boolean(value: str | bool | int) -> bool: - :data:`REACTPY_CHECK_JSON_ATTRS` """ -REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG_MODE) +REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG) """Checks which ensure VDOM is rendered to spec For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema` """ -REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG_MODE) +REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG) """Checks that all VDOM attributes are JSON serializable The VDOM spec is not able to enforce this on its own since attributes could anything. @@ -73,8 +71,8 @@ def boolean(value: str | bool | int) -> bool: set of publicly available APIs for working with the client. """ -REACTPY_TESTING_DEFAULT_TIMEOUT = Option( - "REACTPY_TESTING_DEFAULT_TIMEOUT", +REACTPY_TESTS_DEFAULT_TIMEOUT = Option( + "REACTPY_TESTS_DEFAULT_TIMEOUT", 10.0, mutable=False, validator=float, @@ -88,3 +86,43 @@ def boolean(value: str | bool | int) -> bool: validator=boolean, ) """Whether to render components asynchronously. This is currently an experimental feature.""" + +REACTPY_RECONNECT_INTERVAL = Option( + "REACTPY_RECONNECT_INTERVAL", + default=750, + mutable=True, + validator=int, +) +"""The interval in milliseconds between reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_MAX_INTERVAL = Option( + "REACTPY_RECONNECT_MAX_INTERVAL", + default=60000, + mutable=True, + validator=int, +) +"""The maximum interval in milliseconds between reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_MAX_RETRIES = Option( + "REACTPY_RECONNECT_MAX_RETRIES", + default=150, + mutable=True, + validator=int, +) +"""The maximum number of reconnection attempts for the websocket server""" + +REACTPY_RECONNECT_BACKOFF_MULTIPLIER = Option( + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", + default=1.25, + mutable=True, + validator=float, +) +"""The multiplier for exponential backoff between reconnection attempts for the websocket server""" + +REACTPY_PATH_PREFIX = Option( + "REACTPY_PATH_PREFIX", + default="/reactpy/", + mutable=True, + validator=str, +) +"""The prefix for all ReactPy routes""" diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 88d3386a8..0b69702f3 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -7,7 +7,7 @@ from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Context, ContextProviderType +from reactpy.types import ComponentType, Context, ContextProviderType T = TypeVar("T") diff --git a/src/reactpy/core/component.py b/src/reactpy/core/component.py index 19eb99a94..d2cfcfe31 100644 --- a/src/reactpy/core/component.py +++ b/src/reactpy/core/component.py @@ -4,7 +4,7 @@ from functools import wraps from typing import Any, Callable -from reactpy.core.types import ComponentType, VdomDict +from reactpy.types import ComponentType, VdomDict def component( diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index e906cefe8..fc6eca04f 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -6,7 +6,7 @@ from anyio import create_task_group -from reactpy.core.types import EventHandlerFunc, EventHandlerType +from reactpy.types import EventHandlerFunc, EventHandlerType @overload diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 5a3c9fd13..f7321ef58 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -2,7 +2,7 @@ import asyncio import contextlib -from collections.abc import Coroutine, MutableMapping, Sequence +from collections.abc import Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -16,12 +16,12 @@ overload, ) +from asgiref import typing as asgi_types from typing_extensions import TypeAlias -from reactpy.backend.types import Connection, Location -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import current_hook -from reactpy.core.types import Context, Key, State, VdomDict +from reactpy.types import Connection, Context, Key, Location, State, VdomDict from reactpy.utils import Ref if not TYPE_CHECKING: @@ -185,7 +185,7 @@ def use_debug_value( """Log debug information when the given message changes. .. note:: - This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG_MODE` is active. + This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG` is active. Unlike other hooks, a message is considered to have changed if the old and new values are ``!=``. Because this comparison is performed on every render of the @@ -204,7 +204,7 @@ def use_debug_value( memo_func = message if callable(message) else lambda: message new = use_memo(memo_func, dependencies) - if REACTPY_DEBUG_MODE.current and old.current != new: + if REACTPY_DEBUG.current and old.current != new: old.current = new logger.debug(f"{current_hook().component} {new}") @@ -263,13 +263,13 @@ def use_connection() -> Connection[Any]: return conn -def use_scope() -> MutableMapping[str, Any]: - """Get the current :class:`~reactpy.backend.types.Connection`'s scope.""" +def use_scope() -> asgi_types.HTTPScope | asgi_types.WebSocketScope: + """Get the current :class:`~reactpy.types.Connection`'s scope.""" return use_connection().scope def use_location() -> Location: - """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" + """Get the current :class:`~reactpy.types.Connection`'s location.""" return use_connection().location @@ -535,7 +535,7 @@ def strictly_equal(x: Any, y: Any) -> bool: getattr(x.__code__, attr) == getattr(y.__code__, attr) for attr in dir(x.__code__) if attr.startswith("co_") - and attr not in {"co_positions", "co_linetable", "co_lines"} + and attr not in {"co_positions", "co_linetable", "co_lines", "co_lnotab"} ) # Check via the `==` operator if possible @@ -544,4 +544,4 @@ def strictly_equal(x: Any, y: Any) -> bool: return x == y # type: ignore # Fallback to identity check - return x is y + return x is y # pragma: no cover diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 88cb2fa35..309644b24 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -32,11 +32,13 @@ from reactpy.config import ( REACTPY_ASYNC_RENDERING, REACTPY_CHECK_VDOM_SPEC, - REACTPY_DEBUG_MODE, + REACTPY_DEBUG, ) from reactpy.core._life_cycle_hook import LifeCycleHook -from reactpy.core.types import ( +from reactpy.core.vdom import validate_vdom_json +from reactpy.types import ( ComponentType, + Context, EventHandlerDict, Key, LayoutEventMessage, @@ -45,7 +47,6 @@ VdomDict, VdomJson, ) -from reactpy.core.vdom import validate_vdom_json from reactpy.utils import Ref logger = getLogger(__name__) @@ -67,7 +68,7 @@ class Layout: if not hasattr(abc.ABC, "__weakref__"): # nocov __slots__ += ("__weakref__",) - def __init__(self, root: ComponentType) -> None: + def __init__(self, root: ComponentType | Context[Any]) -> None: super().__init__() if not isinstance(root, ComponentType): msg = f"Expected a ComponentType, not {type(root)!r}." @@ -201,9 +202,7 @@ async def _render_component( new_state.model.current = { "tagName": "", "error": ( - f"{type(error).__name__}: {error}" - if REACTPY_DEBUG_MODE.current - else "" + f"{type(error).__name__}: {error}" if REACTPY_DEBUG.current else "" ), } finally: diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 3a540af59..40a5761cf 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -8,8 +8,8 @@ from anyio import create_task_group from anyio.abc import TaskGroup -from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage +from reactpy.config import REACTPY_DEBUG +from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) @@ -62,11 +62,11 @@ async def _single_outgoing_loop( try: await send(update) except Exception: # nocov - if not REACTPY_DEBUG_MODE.current: + if not REACTPY_DEBUG.current: msg = ( "Failed to send update. More info may be available " "if you enabling debug mode by setting " - "`reactpy.config.REACTPY_DEBUG_MODE.current = True`." + "`reactpy.config.REACTPY_DEBUG.current = True`." ) logger.error(msg) raise diff --git a/src/reactpy/core/types.py b/src/reactpy/core/types.py deleted file mode 100644 index b451be30a..000000000 --- a/src/reactpy/core/types.py +++ /dev/null @@ -1,248 +0,0 @@ -from __future__ import annotations - -import sys -from collections import namedtuple -from collections.abc import Mapping, Sequence -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Generic, - Literal, - NamedTuple, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -from typing_extensions import TypeAlias, TypedDict - -_Type = TypeVar("_Type") - - -if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): - - class State(NamedTuple, Generic[_Type]): - value: _Type - set_value: Callable[[_Type | Callable[[_Type], _Type]], None] - -else: # nocov - State = namedtuple("State", ("value", "set_value")) - - -ComponentConstructor = Callable[..., "ComponentType"] -"""Simple function returning a new component""" - -RootComponentConstructor = Callable[[], "ComponentType"] -"""The root component should be constructed by a function accepting no arguments.""" - - -Key: TypeAlias = "str | int" - - -_OwnType = TypeVar("_OwnType") - - -@runtime_checkable -class ComponentType(Protocol): - """The expected interface for all component-like objects""" - - key: Key | None - """An identifier which is unique amongst a component's immediate siblings""" - - type: Any - """The function or class defining the behavior of this component - - This is used to see if two component instances share the same definition. - """ - - def render(self) -> VdomDict | ComponentType | str | None: - """Render the component's view model.""" - - -_Render_co = TypeVar("_Render_co", covariant=True) -_Event_contra = TypeVar("_Event_contra", contravariant=True) - - -@runtime_checkable -class LayoutType(Protocol[_Render_co, _Event_contra]): - """Renders and delivers, updates to views and events to handlers, respectively""" - - async def render(self) -> _Render_co: - """Render an update to a view""" - - async def deliver(self, event: _Event_contra) -> None: - """Relay an event to its respective handler""" - - async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: - """Prepare the layout for its first render""" - - async def __aexit__( - self, - exc_type: type[Exception], - exc_value: Exception, - traceback: TracebackType, - ) -> bool | None: - """Clean up the view after its final render""" - - -VdomAttributes = Mapping[str, Any] -"""Describes the attributes of a :class:`VdomDict`""" - -VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" -"""A single child element of a :class:`VdomDict`""" - -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" -"""Describes a series of :class:`VdomChild` elements""" - - -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ComponentType | VdomChild] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict - - -class _VdomDictRequired(TypedDict, total=True): - tagName: str - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" - - -class ImportSourceDict(TypedDict): - source: str - fallback: Any - sourceType: str - unmountBeforeUpdate: bool - - -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: dict[str, Any] - eventHandlers: dict[str, _JsonEventTarget] - importSource: _JsonImportSource - - -class _RequiredVdomJson(TypedDict, total=True): - tagName: str - - -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): - target: str - preventDefault: bool - stopPropagation: bool - - -class _JsonImportSource(TypedDict): - source: str - fallback: Any - - -EventHandlerMapping = Mapping[str, "EventHandlerType"] -"""A generic mapping between event names to their handlers""" - -EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" -"""A dict mapping between event names to their handlers""" - - -class EventHandlerFunc(Protocol): - """A coroutine which can handle event data""" - - async def __call__(self, data: Sequence[Any]) -> None: ... - - -@runtime_checkable -class EventHandlerType(Protocol): - """Defines a handler for some event""" - - prevent_default: bool - """Whether to block the event from propagating further up the DOM""" - - stop_propagation: bool - """Stops the default action associate with the event from taking place.""" - - function: EventHandlerFunc - """A coroutine which can respond to an event and its data""" - - target: str | None - """Typically left as ``None`` except when a static target is useful. - - When testing, it may be useful to specify a static target ID so events can be - triggered programmatically. - - .. note:: - - When ``None``, it is left to a :class:`LayoutType` to auto generate a unique ID. - """ - - -class VdomDictConstructor(Protocol): - """Standard function for constructing a :class:`VdomDict`""" - - @overload - def __call__( - self, attributes: VdomAttributes, *children: VdomChildren - ) -> VdomDict: ... - - @overload - def __call__(self, *children: VdomChildren) -> VdomDict: ... - - @overload - def __call__( - self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict: ... - - -class LayoutUpdateMessage(TypedDict): - """A message describing an update to a layout""" - - type: Literal["layout-update"] - """The type of message""" - path: str - """JSON Pointer path to the model element being updated""" - model: VdomJson - """The model to assign at the given JSON Pointer path""" - - -class LayoutEventMessage(TypedDict): - """Message describing an event originating from an element in the layout""" - - type: Literal["layout-event"] - """The type of message""" - target: str - """The ID of the event handler.""" - data: Sequence[Any] - """A list of event data passed to the event handler.""" - - -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProviderType[_Type]: ... - - -class ContextProviderType(ComponentType, Protocol[_Type]): - """A component which provides a context value to its children""" - - type: Context[_Type] - """The context type""" - - @property - def value(self) -> _Type: - "Current context value" diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index dfff32805..77b173f8f 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -8,10 +8,10 @@ from fastjsonschema import compile as compile_json_schema from reactpy._warnings import warn -from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_CHECK_JSON_ATTRS, REACTPY_DEBUG from reactpy.core._f_back import f_module_name from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.types import ( +from reactpy.types import ( ComponentType, EventHandlerDict, EventHandlerType, @@ -314,7 +314,7 @@ def _is_attributes(value: Any) -> bool: def _is_single_child(value: Any) -> bool: if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): return True - if REACTPY_DEBUG_MODE.current: + if REACTPY_DEBUG.current: _validate_child_key_integrity(value) return False diff --git a/src/reactpy/jinja.py b/src/reactpy/jinja.py new file mode 100644 index 000000000..77d1570f1 --- /dev/null +++ b/src/reactpy/jinja.py @@ -0,0 +1,21 @@ +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 f10414cb6..62b507db8 100644 --- a/src/reactpy/logging.py +++ b/src/reactpy/logging.py @@ -2,7 +2,7 @@ import sys from logging.config import dictConfig -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG dictConfig( { @@ -33,7 +33,7 @@ """ReactPy's root logger instance""" -@REACTPY_DEBUG_MODE.subscribe +@REACTPY_DEBUG.subscribe def _set_debug_level(debug: bool) -> None: if debug: ROOT_LOGGER.setLevel("DEBUG") diff --git a/src/reactpy/static/index.html b/src/reactpy/static/index.html deleted file mode 100644 index 77d008332..000000000 --- a/src/reactpy/static/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - {__head__} - - - -
- - - diff --git a/src/reactpy/testing/__init__.py b/src/reactpy/testing/__init__.py index 9f61cec57..27247a88f 100644 --- a/src/reactpy/testing/__init__.py +++ b/src/reactpy/testing/__init__.py @@ -14,14 +14,14 @@ ) __all__ = [ - "assert_reactpy_did_not_log", - "assert_reactpy_did_log", - "capture_reactpy_logs", - "clear_reactpy_web_modules_dir", + "BackendFixture", "DisplayFixture", "HookCatcher", "LogAssertionError", - "poll", - "BackendFixture", "StaticEventHandler", + "assert_reactpy_did_log", + "assert_reactpy_did_not_log", + "capture_reactpy_logs", + "clear_reactpy_web_modules_dir", + "poll", ] diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 3f56a5ecb..9ebd15f3a 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -2,23 +2,27 @@ import asyncio import logging -from contextlib import AsyncExitStack, suppress +from contextlib import AsyncExitStack +from threading import Thread from types import TracebackType from typing import Any, Callable from urllib.parse import urlencode, urlunparse -from reactpy.backend import default as default_server -from reactpy.backend.types import BackendType -from reactpy.backend.utils import find_available_port -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +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.core.types import ComponentConstructor from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, list_logged_exceptions, ) +from reactpy.testing.utils import find_available_port +from reactpy.types import ComponentConstructor, ReactPyConfig from reactpy.utils import Ref @@ -34,38 +38,42 @@ class BackendFixture: server.mount(MyComponent) """ - _records: list[logging.LogRecord] + log_records: list[logging.LogRecord] _server_future: asyncio.Task[Any] _exit_stack = AsyncExitStack() def __init__( self, + app: asgi_types.ASGIApplication | None = None, host: str = "127.0.0.1", port: int | None = None, - app: Any | None = None, - implementation: BackendType[Any] | None = None, - options: Any | None = None, timeout: float | None = None, + reactpy_config: ReactPyConfig | None = None, ) -> None: self.host = host self.port = port or find_available_port(host) - self.mount, self._root_component = _hotswap() + self.mount = mount_to_hotswap self.timeout = ( - REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout + REACTPY_TESTS_DEFAULT_TIMEOUT.current if timeout is None else timeout + ) + if isinstance(app, (ReactPyMiddleware, ReactPy)): + self._app = app + elif app: + self._app = ReactPyMiddleware( + app, + root_components=["reactpy.testing.backend.root_hotswap_component"], + **(reactpy_config or {}), + ) + else: + self._app = ReactPy( + root_hotswap_component, + **(reactpy_config or {}), + ) + self.webserver = uvicorn.Server( + uvicorn.Config( + app=self._app, host=self.host, port=self.port, loop="asyncio" + ) ) - - if app is not None and implementation is None: - msg = "If an application instance its corresponding server implementation must be provided too." - raise ValueError(msg) - - self._app = app - self.implementation = implementation or default_server - self._options = options - - @property - def log_records(self) -> list[logging.LogRecord]: - """A list of captured log records""" - return self._records def url(self, path: str = "", query: Any | None = None) -> str: """Return a URL string pointing to the host and point of the server @@ -109,31 +117,11 @@ def list_logged_exceptions( async def __aenter__(self) -> BackendFixture: self._exit_stack = AsyncExitStack() - self._records = self._exit_stack.enter_context(capture_reactpy_logs()) + self.log_records = self._exit_stack.enter_context(capture_reactpy_logs()) - app = self._app or self.implementation.create_development_app() - self.implementation.configure(app, self._root_component, self._options) - - started = asyncio.Event() - server_future = asyncio.create_task( - self.implementation.serve_development_app( - app, self.host, self.port, started - ) - ) - - async def stop_server() -> None: - server_future.cancel() - with suppress(asyncio.CancelledError): - await asyncio.wait_for(server_future, timeout=self.timeout) - - self._exit_stack.push_async_callback(stop_server) - - try: - await asyncio.wait_for(started.wait(), timeout=self.timeout) - except Exception: # nocov - # see if we can await the future for a more helpful error - await asyncio.wait_for(server_future, timeout=self.timeout) - raise + # Wait for the server to start + Thread(target=self.webserver.run, daemon=True).start() + await asyncio.sleep(1) return self @@ -145,13 +133,18 @@ async def __aexit__( ) -> None: await self._exit_stack.aclose() - self.mount(None) # reset the view - logged_errors = self.list_logged_exceptions(del_log_records=False) if logged_errors: # nocov msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] + await asyncio.wait_for(self.webserver.shutdown(), timeout=60) + + async def restart(self) -> None: + """Restart the server""" + await self.__aexit__(None, None, None) + await self.__aenter__() + _MountFunc = Callable[["Callable[[], Any] | None"], None] @@ -229,3 +222,6 @@ def swap(constructor: Callable[[], Any] | None) -> None: constructor_ref.current = constructor or (lambda: None) return swap, HotSwap + + +mount_to_hotswap, root_hotswap_component = _hotswap() diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index c1eb18ba5..de5afaba7 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -12,7 +12,7 @@ from typing_extensions import ParamSpec -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook from reactpy.core.events import EventHandler, to_event_handler_function @@ -54,7 +54,7 @@ async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: async def until( self, condition: Callable[[_R], bool], - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, description: str = "condition to be true", ) -> None: @@ -72,7 +72,7 @@ async def until( async def until_is( self, right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is identical to the given value""" @@ -86,7 +86,7 @@ async def until_is( async def until_equals( self, right: _R, - timeout: float = REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout: float = REACTPY_TESTS_DEFAULT_TIMEOUT.current, delay: float = _DEFAULT_POLL_DELAY, ) -> None: """Wait until the result is equal to the given value""" diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index bb0d8351d..cc429c059 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -12,7 +12,7 @@ async_playwright, ) -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.testing.backend import BackendFixture from reactpy.types import RootComponentConstructor @@ -26,7 +26,6 @@ def __init__( self, backend: BackendFixture | None = None, driver: Browser | BrowserContext | Page | None = None, - url_prefix: str = "", ) -> None: if backend is not None: self.backend = backend @@ -35,7 +34,6 @@ def __init__( self.page = driver else: self._browser = driver - self.url_prefix = url_prefix async def show( self, @@ -45,14 +43,8 @@ async def show( await self.goto("/") await self.root_element() # check that root element is attached - async def goto( - self, path: str, query: Any | None = None, add_url_prefix: bool = True - ) -> None: - await self.page.goto( - self.backend.url( - f"{self.url_prefix}{path}" if add_url_prefix else path, query - ) - ) + 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") @@ -73,9 +65,9 @@ async def __aenter__(self) -> DisplayFixture: browser = self._browser self.page = await browser.new_page() - self.page.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) + self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) - if not hasattr(self, "backend"): + if not hasattr(self, "backend"): # pragma: no cover self.backend = BackendFixture() await es.enter_async_context(self.backend) diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py new file mode 100644 index 000000000..f1808022c --- /dev/null +++ b/src/reactpy/testing/utils.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import socket +import sys +from contextlib import closing + + +def find_available_port( + host: str, port_min: int = 8000, port_max: int = 9000 +) -> int: # pragma: no cover + """Get a port that's available for the given host and port range""" + for port in range(port_min, port_max): + with closing(socket.socket()) as sock: + try: + if sys.platform in ("linux", "darwin"): + # Fixes bug on Unix-like systems where every time you restart the + # server you'll get a different port on Linux. This cannot be set + # on Windows otherwise address will always be reused. + # Ref: https://stackoverflow.com/a/19247688/3159288 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((host, port)) + except OSError: + pass + else: + return port + msg = f"Host {host!r} has no available port in range {port_max}-{port_max}" + raise RuntimeError(msg) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 1ac04395a..986ac36b7 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,51 +1,298 @@ -"""Exports common types from: - -- :mod:`reactpy.core.types` -- :mod:`reactpy.backend.types` -""" - -from reactpy.backend.types import BackendType, Connection, Location -from reactpy.core.component import Component -from reactpy.core.types import ( - ComponentConstructor, - ComponentType, - Context, - EventHandlerDict, - EventHandlerFunc, - EventHandlerMapping, - EventHandlerType, - ImportSourceDict, - Key, - LayoutType, - RootComponentConstructor, - State, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomJson, +from __future__ import annotations + +import sys +from collections import namedtuple +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Generic, + Literal, + NamedTuple, + Protocol, + TypeVar, + overload, + runtime_checkable, ) -__all__ = [ - "BackendType", - "Component", - "ComponentConstructor", - "ComponentType", - "Connection", - "Context", - "EventHandlerDict", - "EventHandlerFunc", - "EventHandlerMapping", - "EventHandlerType", - "ImportSourceDict", - "Key", - "LayoutType", - "Location", - "RootComponentConstructor", - "State", - "VdomAttributes", - "VdomChild", - "VdomChildren", - "VdomDict", - "VdomJson", -] +from asgiref import typing as asgi_types +from typing_extensions import TypeAlias, TypedDict + +CarrierType = TypeVar("CarrierType") + +_Type = TypeVar("_Type") + + +if TYPE_CHECKING or sys.version_info >= (3, 11): + + class State(NamedTuple, Generic[_Type]): + value: _Type + set_value: Callable[[_Type | Callable[[_Type], _Type]], None] + +else: # nocov + State = namedtuple("State", ("value", "set_value")) + + +ComponentConstructor = Callable[..., "ComponentType"] +"""Simple function returning a new component""" + +RootComponentConstructor = Callable[[], "ComponentType"] +"""The root component should be constructed by a function accepting no arguments.""" + + +Key: TypeAlias = "str | int" + + +@runtime_checkable +class ComponentType(Protocol): + """The expected interface for all component-like objects""" + + key: Key | None + """An identifier which is unique amongst a component's immediate siblings""" + + type: Any + """The function or class defining the behavior of this component + + This is used to see if two component instances share the same definition. + """ + + def render(self) -> VdomDict | ComponentType | str | None: + """Render the component's view model.""" + + +_Render_co = TypeVar("_Render_co", covariant=True) +_Event_contra = TypeVar("_Event_contra", contravariant=True) + + +@runtime_checkable +class LayoutType(Protocol[_Render_co, _Event_contra]): + """Renders and delivers, updates to views and events to handlers, respectively""" + + async def render(self) -> _Render_co: + """Render an update to a view""" + + async def deliver(self, event: _Event_contra) -> None: + """Relay an event to its respective handler""" + + async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: + """Prepare the layout for its first render""" + + async def __aexit__( + self, + exc_type: type[Exception], + exc_value: Exception, + traceback: TracebackType, + ) -> bool | None: + """Clean up the view after its final render""" + + +VdomAttributes = Mapping[str, Any] +"""Describes the attributes of a :class:`VdomDict`""" + +VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" +"""A single child element of a :class:`VdomDict`""" + +VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" +"""Describes a series of :class:`VdomChild` elements""" + + +class _VdomDictOptional(TypedDict, total=False): + key: Key | None + children: Sequence[ComponentType | VdomChild] + attributes: VdomAttributes + eventHandlers: EventHandlerDict + importSource: ImportSourceDict + + +class _VdomDictRequired(TypedDict, total=True): + tagName: str + + +class VdomDict(_VdomDictRequired, _VdomDictOptional): + """A :ref:`VDOM` dictionary""" + + +class ImportSourceDict(TypedDict): + source: str + fallback: Any + sourceType: str + unmountBeforeUpdate: bool + + +class _OptionalVdomJson(TypedDict, total=False): + key: Key + error: str + children: list[Any] + attributes: dict[str, Any] + eventHandlers: dict[str, _JsonEventTarget] + importSource: _JsonImportSource + + +class _RequiredVdomJson(TypedDict, total=True): + tagName: str + + +class VdomJson(_RequiredVdomJson, _OptionalVdomJson): + """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" + + +class _JsonEventTarget(TypedDict): + target: str + preventDefault: bool + stopPropagation: bool + + +class _JsonImportSource(TypedDict): + source: str + fallback: Any + + +EventHandlerMapping = Mapping[str, "EventHandlerType"] +"""A generic mapping between event names to their handlers""" + +EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" +"""A dict mapping between event names to their handlers""" + + +class EventHandlerFunc(Protocol): + """A coroutine which can handle event data""" + + async def __call__(self, data: Sequence[Any]) -> None: ... + + +@runtime_checkable +class EventHandlerType(Protocol): + """Defines a handler for some event""" + + prevent_default: bool + """Whether to block the event from propagating further up the DOM""" + + stop_propagation: bool + """Stops the default action associate with the event from taking place.""" + + function: EventHandlerFunc + """A coroutine which can respond to an event and its data""" + + target: str | None + """Typically left as ``None`` except when a static target is useful. + + When testing, it may be useful to specify a static target ID so events can be + triggered programmatically. + + .. note:: + + When ``None``, it is left to a :class:`LayoutType` to auto generate a unique ID. + """ + + +class VdomDictConstructor(Protocol): + """Standard function for constructing a :class:`VdomDict`""" + + @overload + def __call__( + self, attributes: VdomAttributes, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... + + @overload + def __call__( + self, *attributes_and_children: VdomAttributes | VdomChildren + ) -> VdomDict: ... + + +class LayoutUpdateMessage(TypedDict): + """A message describing an update to a layout""" + + type: Literal["layout-update"] + """The type of message""" + path: str + """JSON Pointer path to the model element being updated""" + model: VdomJson + """The model to assign at the given JSON Pointer path""" + + +class LayoutEventMessage(TypedDict): + """Message describing an event originating from an element in the layout""" + + type: Literal["layout-event"] + """The type of message""" + target: str + """The ID of the event handler.""" + data: Sequence[Any] + """A list of event data passed to the event handler.""" + + +class Context(Protocol[_Type]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: _Type = ..., + key: Key | None = ..., + ) -> ContextProviderType[_Type]: ... + + +class ContextProviderType(ComponentType, Protocol[_Type]): + """A component which provides a context value to its children""" + + type: Context[_Type] + """The context type""" + + @property + def value(self) -> _Type: + "Current context value" + + +@dataclass +class Connection(Generic[CarrierType]): + """Represents a connection with a client""" + + scope: asgi_types.HTTPScope | asgi_types.WebSocketScope + """A scope dictionary related to the current connection.""" + + location: Location + """The current location (URL)""" + + carrier: CarrierType + """How the connection is mediated. For example, a request or websocket. + + This typically depends on the backend implementation. + """ + + +@dataclass +class Location: + """Represents the current location (URL) + + Analogous to, but not necessarily identical to, the client-side + ``document.location`` object. + """ + + path: str + """The URL's path segment. This typically represents the current + HTTP request's path.""" + + query_string: str + """HTTP query string - a '?' followed by the parameters of the URL. + + If there are no search parameters this should be an empty string + """ + + +class ReactPyConfig(TypedDict, total=False): + path_prefix: str + web_modules_dir: Path + reconnect_interval: int + reconnect_max_interval: int + reconnect_max_retries: int + reconnect_backoff_multiplier: float + async_rendering: bool + debug: bool + tests_default_timeout: int diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 77df473fb..30495d6c1 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -8,8 +8,9 @@ from lxml import etree from lxml.html import fromstring, tostring -from reactpy.core.types import ComponentType, VdomDict +from reactpy import config from reactpy.core.vdom import vdom as make_vdom +from reactpy.types import ComponentType, VdomDict _RefValue = TypeVar("_RefValue") _ModelTransform = Callable[[VdomDict], Any] @@ -313,3 +314,23 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: # Pattern for delimitting camelCase names (e.g. camelCase to camel-case) _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str: + return ( + f'
' + '" + ) diff --git a/src/reactpy/web/__init__.py b/src/reactpy/web/__init__.py index 308429dbb..f27d58ff9 100644 --- a/src/reactpy/web/__init__.py +++ b/src/reactpy/web/__init__.py @@ -2,14 +2,12 @@ export, module_from_file, module_from_string, - module_from_template, module_from_url, ) __all__ = [ + "export", "module_from_file", "module_from_string", - "module_from_template", "module_from_url", - "export", ] diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index e1a5db82f..5148c9669 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -5,14 +5,11 @@ import shutil from dataclasses import dataclass from pathlib import Path -from string import Template from typing import Any, NewType, overload -from urllib.parse import urlparse -from reactpy._warnings import warn -from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_WEB_MODULES_DIR -from reactpy.core.types import ImportSourceDict, VdomDictConstructor +from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR from reactpy.core.vdom import make_vdom_constructor +from reactpy.types import ImportSourceDict, VdomDictConstructor from reactpy.web.utils import ( module_name_suffix, resolve_module_exports_from_file, @@ -65,7 +62,7 @@ def module_from_url( if ( resolve_exports if resolve_exports is not None - else REACTPY_DEBUG_MODE.current + else REACTPY_DEBUG.current ) else None ), @@ -73,90 +70,6 @@ def module_from_url( ) -_FROM_TEMPLATE_DIR = "__from_template__" - - -def module_from_template( - template: str, - package: str, - cdn: str = "https://esm.sh", - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, -) -> WebModule: - """Create a :class:`WebModule` from a framework template - - This is useful for experimenting with component libraries that do not already - support ReactPy's :ref:`Custom Javascript Component` interface. - - .. warning:: - - This approach is not recommended for use in a production setting because the - framework templates may use unpinned dependencies that could change without - warning. It's best to author a module adhering to the - :ref:`Custom Javascript Component` interface instead. - - **Templates** - - - ``react``: for modules exporting React components - - Parameters: - template: - The name of the framework template to use with the given ``package``. - package: - The name of a package to load. May include a file extension (defaults to - ``.js`` if not given) - cdn: - Where the package should be loaded from. The CDN must distribute ESM modules - fallback: - What to temporarily display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package fails to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ - warn( - "module_from_template() is deprecated due to instability - use the Javascript " - "Components API instead. This function will be removed in a future release.", - DeprecationWarning, - ) - template_name, _, template_version = template.partition("@") - template_version = "@" + template_version if template_version else "" - - # We do this since the package may be any valid URL path. Thus we may need to strip - # object parameters or query information so we save the resulting template under the - # correct file name. - package_name = urlparse(package).path - - # downstream code assumes no trailing slash - cdn = cdn.rstrip("/") - - template_file_name = template_name + module_name_suffix(package_name) - - template_file = Path(__file__).parent / "templates" / template_file_name - if not template_file.exists(): - msg = f"No template for {template_file_name!r} exists" - raise ValueError(msg) - - variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version} - content = Template(template_file.read_text(encoding="utf-8")).substitute(variables) - - return module_from_string( - _FROM_TEMPLATE_DIR + "/" + package_name, - content, - fallback, - resolve_exports, - resolve_exports_depth, - unmount_before_update=unmount_before_update, - ) - - def module_from_file( name: str, file: str | Path, @@ -215,7 +128,7 @@ def module_from_file( if ( resolve_exports if resolve_exports is not None - else REACTPY_DEBUG_MODE.current + else REACTPY_DEBUG.current ) else None ), @@ -290,7 +203,7 @@ def module_from_string( if ( resolve_exports if resolve_exports is not None - else REACTPY_DEBUG_MODE.current + else REACTPY_DEBUG.current ) else None ), diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index 92676b92f..bc559c15d 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -7,7 +7,7 @@ import reactpy from reactpy._html import html from reactpy._warnings import warn -from reactpy.core.types import ComponentConstructor, VdomDict +from reactpy.types import ComponentConstructor, VdomDict def image( diff --git a/tests/conftest.py b/tests/conftest.py index 17231a2ac..119e7571d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,8 @@ from reactpy.config import ( REACTPY_ASYNC_RENDERING, - REACTPY_TESTING_DEFAULT_TIMEOUT, + REACTPY_DEBUG, + REACTPY_TESTS_DEFAULT_TIMEOUT, ) from reactpy.testing import ( BackendFixture, @@ -19,15 +20,24 @@ clear_reactpy_web_modules_dir, ) -REACTPY_ASYNC_RENDERING.current = True +REACTPY_ASYNC_RENDERING.set_current(True) +REACTPY_DEBUG.set_current(True) +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in { + "y", + "yes", + "t", + "true", + "on", + "1", +} def pytest_addoption(parser: Parser) -> None: parser.addoption( - "--headed", - dest="headed", + "--headless", + dest="headless", action="store_true", - help="Open a browser window when running web-based tests", + help="Don't open a browser window when running web-based tests", ) @@ -37,8 +47,8 @@ def install_playwright(): @pytest.fixture(autouse=True, scope="session") -def rebuild_javascript(): - subprocess.run(["hatch", "run", "javascript:build"], check=True) # noqa: S607, S603 +def rebuild(): + subprocess.run(["hatch", "build", "-t", "wheel"], check=True) # noqa: S607, S603 @pytest.fixture @@ -56,7 +66,7 @@ async def server(): @pytest.fixture async def page(browser): pg = await browser.new_page() - pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) + pg.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) try: yield pg finally: @@ -68,7 +78,9 @@ async def browser(pytestconfig: Config): from playwright.async_api import async_playwright async with async_playwright() as pw: - yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) + yield await pw.chromium.launch( + headless=bool(pytestconfig.option.headless) or GITHUB_ACTIONS + ) @pytest.fixture(scope="session") diff --git a/tests/sample.py b/tests/sample.py index 8509c773d..fe5dfde07 100644 --- a/tests/sample.py +++ b/tests/sample.py @@ -2,7 +2,7 @@ from reactpy import html from reactpy.core.component import component -from reactpy.core.types import VdomDict +from reactpy.types import VdomDict @component diff --git a/tests/templates/index.html b/tests/templates/index.html new file mode 100644 index 000000000..8238b6b09 --- /dev/null +++ b/tests/templates/index.html @@ -0,0 +1,11 @@ + + + + + + +
+ {% component "reactpy.testing.backend.root_hotswap_component" %} + + + diff --git a/src/reactpy/future.py b/tests/test_asgi/__init__.py similarity index 100% rename from src/reactpy/future.py rename to tests/test_asgi/__init__.py diff --git a/tests/test_asgi/test_middleware.py b/tests/test_asgi/test_middleware.py new file mode 100644 index 000000000..84dc545b8 --- /dev/null +++ b/tests/test_asgi/test_middleware.py @@ -0,0 +1,105 @@ +# ruff: noqa: S701 +import asyncio +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 + +import reactpy +from reactpy.asgi.middleware import ReactPyMiddleware +from reactpy.testing import BackendFixture, DisplayFixture + + +@pytest.fixture() +async def display(page): + templates = Jinja2Templates( + env=JinjaEnvironment( + loader=JinjaFileSystemLoader("tests/templates"), + extensions=["reactpy.jinja.Component"], + ) + ) + + async def homepage(request): + return templates.TemplateResponse(request, "index.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 + + +def test_invalid_path_prefix(): + with pytest.raises(ValueError, match="Invalid `path_prefix`*"): + + async def app(scope, receive, send): + pass + + reactpy.ReactPyMiddleware(app, root_components=["abc"], path_prefix="invalid") + + +def test_invalid_web_modules_dir(): + with pytest.raises( + ValueError, match='Web modules directory "invalid" does not exist.' + ): + + async def app(scope, receive, send): + pass + + reactpy.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"], + ) + ) + + async def homepage(request): + return templates.TemplateResponse(request, "index.html") + + @reactpy.component + def Stub(): + return reactpy.html.p("Hello") + + app = Starlette(routes=[Route("/", homepage)]) + app = ReactPyMiddleware(app, root_components=["tests.sample.SampleApp"]) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server) as new_display: + await new_display.show(Stub) + + # Wait for the log record to be popualted + for _ in range(10): + if len(server.log_records) > 0: + break + await asyncio.sleep(0.25) + + # Check that the log record was populated with the "unregistered component" message + assert ( + "Attempting to use an unregistered root component" + in server.log_records[-1].message + ) + + +async def test_display_simple_hello_world(display: DisplayFixture): + @reactpy.component + def Hello(): + return reactpy.html.p({"id": "hello"}, ["Hello World"]) + + await display.show(Hello) + + await display.page.wait_for_selector("#hello") + + # test that we can reconnect successfully + await display.page.reload() + + await display.page.wait_for_selector("#hello") diff --git a/tests/test_backend/test_all.py b/tests/test_asgi/test_standalone.py similarity index 66% rename from tests/test_backend/test_all.py rename to tests/test_asgi/test_standalone.py index 62aa2bca0..8c477b21d 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_asgi/test_standalone.py @@ -1,37 +1,20 @@ from collections.abc import MutableMapping import pytest +from requests import request import reactpy from reactpy import html -from reactpy.backend import default as default_implementation -from reactpy.backend._common import PATH_PREFIX -from reactpy.backend.types import BackendType, Connection, Location -from reactpy.backend.utils import all_implementations +from reactpy.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( - params=[*list(all_implementations()), default_implementation], - ids=lambda imp: imp.__name__, -) -async def display(page, request): - imp: BackendType = request.param - - # we do this to check that route priorities for each backend are correct - if imp is default_implementation: - url_prefix = "" - opts = None - else: - url_prefix = str(PATH_PREFIX) - opts = imp.Options(url_prefix=url_prefix) - - async with BackendFixture(implementation=imp, options=opts) as server: - async with DisplayFixture( - backend=server, - driver=page, - url_prefix=url_prefix, - ) as display: +@pytest.fixture() +async def display(page): + async with BackendFixture() as server: + async with DisplayFixture(backend=server, driver=page) as display: yield display @@ -124,21 +107,16 @@ def ShowRoute(): Location("/another/something/file.txt", "?key=value"), Location("/another/something/file.txt", "?key1=value1&key2=value2"), ]: - await display.goto(loc.pathname + loc.search) + await display.goto(loc.path + loc.query_string) await poll_location.until_equals(loc) -@pytest.mark.parametrize("hook_name", ["use_request", "use_websocket"]) -async def test_use_request(display: DisplayFixture, hook_name): - hook = getattr(display.backend.implementation, hook_name, None) - if hook is None: - pytest.skip(f"{display.backend.implementation} has no '{hook_name}' hook") - +async def test_carrier(display: DisplayFixture): hook_val = reactpy.Ref() @reactpy.component def ShowRoute(): - hook_val.current = hook() + hook_val.current = reactpy.hooks.use_connection().carrier return html.pre({"id": "hook"}, str(hook_val.current)) await display.show(ShowRoute) @@ -149,18 +127,37 @@ def ShowRoute(): assert hook_val.current is not None -@pytest.mark.parametrize("imp", all_implementations()) -async def test_customized_head(imp: BackendType, page): - custom_title = f"Custom Title for {imp.__name__}" +async def test_customized_head(page): + custom_title = "Custom Title for ReactPy" @reactpy.component def sample(): return html.h1(f"^ Page title is customized to: '{custom_title}'") - async with BackendFixture( - implementation=imp, - options=imp.Options(head=html.title(custom_title)), - ) as server: - async with DisplayFixture(backend=server, driver=page) as display: - await display.show(sample) - assert (await display.page.title()) == custom_title + app = ReactPy(sample, html_head=html.head(html.title(custom_title))) + + async with BackendFixture(app) as server: + async with DisplayFixture(backend=server, driver=page) as new_display: + await new_display.show(sample) + assert (await new_display.page.title()) == custom_title + + +async def test_head_request(page): + @reactpy.component + def sample(): + return html.h1("Hello World") + + 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 = 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"" diff --git a/tests/test_asgi/test_utils.py b/tests/test_asgi/test_utils.py new file mode 100644 index 000000000..ff3019c27 --- /dev/null +++ b/tests/test_asgi/test_utils.py @@ -0,0 +1,38 @@ +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") + + +def test_invalid_vdom_head(): + with pytest.raises(ValueError, match="Invalid head element!*"): + utils.vdom_head_to_html({"tagName": "invalid"}) + + +def test_process_settings(): + utils.process_settings({"async_rendering": False}) + assert config.REACTPY_ASYNC_RENDERING.current is False + utils.process_settings({"async_rendering": True}) + assert config.REACTPY_ASYNC_RENDERING.current is True + + +def test_invalid_setting(): + with pytest.raises(ValueError, match='Unknown ReactPy setting "foobar".'): + utils.process_settings({"foobar": True}) diff --git a/tests/test_backend/test_common.py b/tests/test_backend/test_common.py deleted file mode 100644 index 1f40c96cf..000000000 --- a/tests/test_backend/test_common.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest - -from reactpy import html -from reactpy.backend._common import ( - CommonOptions, - traversal_safe_path, - vdom_head_elements_to_html, -) - - -def test_common_options_url_prefix_starts_with_slash(): - # no prefix specified - CommonOptions(url_prefix="") - - with pytest.raises(ValueError, match="start with '/'"): - CommonOptions(url_prefix="not-start-withslash") - - -@pytest.mark.parametrize( - "bad_path", - [ - "../escaped", - "ok/../../escaped", - "ok/ok-again/../../ok-yet-again/../../../escaped", - ], -) -def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError, match="Unsafe path"): - traversal_safe_path(tmp_path, *bad_path.split("/")) - - -@pytest.mark.parametrize( - "vdom_in, html_out", - [ - ( - "example", - "example", - ), - ( - # We do not modify strings given by user. If given as VDOM we would have - # striped this head element, but since provided as string, we leav as-is. - "", - "", - ), - ( - html.head( - html.meta({"charset": "utf-8"}), - html.title("example"), - ), - # we strip the head element - 'example', - ), - ( - html.fragment( - html.meta({"charset": "utf-8"}), - html.title("example"), - ), - 'example', - ), - ( - [ - html.meta({"charset": "utf-8"}), - html.title("example"), - ], - 'example', - ), - ], -) -def test_vdom_head_elements_to_html(vdom_in, html_out): - assert vdom_head_elements_to_html(vdom_in) == html_out diff --git a/tests/test_backend/test_utils.py b/tests/test_backend/test_utils.py deleted file mode 100644 index 319dd816f..000000000 --- a/tests/test_backend/test_utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import threading -import time -from contextlib import ExitStack - -import pytest -from playwright.async_api import Page - -from reactpy.backend import flask as flask_implementation -from reactpy.backend.utils import find_available_port -from reactpy.backend.utils import run as sync_run -from tests.sample import SampleApp - - -@pytest.fixture -def exit_stack(): - with ExitStack() as es: - yield es - - -def test_find_available_port(): - assert find_available_port("localhost", port_min=5000, port_max=6000) - with pytest.raises(RuntimeError, match="no available port"): - # check that if port range is exhausted we raise - find_available_port("localhost", port_min=0, port_max=0) - - -async def test_run(page: Page): - host = "127.0.0.1" - port = find_available_port(host) - url = f"http://{host}:{port}" - - threading.Thread( - target=lambda: sync_run( - SampleApp, - host, - port, - implementation=flask_implementation, - ), - daemon=True, - ).start() - - # give the server a moment to start - time.sleep(0.5) - - await page.goto(url) - await page.wait_for_selector("#sample") diff --git a/tests/test_client.py b/tests/test_client.py index ea7ebcb6b..7d1da4007 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,11 +1,9 @@ import asyncio -from contextlib import AsyncExitStack from pathlib import Path -from playwright.async_api import Browser +from playwright.async_api import Page import reactpy -from reactpy.backend.utils import find_available_port from reactpy.testing import BackendFixture, DisplayFixture, poll from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter @@ -13,13 +11,9 @@ JS_DIR = Path(__file__).parent / "js" -async def test_automatic_reconnect(browser: Browser): - port = find_available_port("localhost") - page = await browser.new_page() - - # we need to wait longer here because the automatic reconnect is not instant - page.set_default_timeout(10000) - +async def test_automatic_reconnect( + display: DisplayFixture, page: Page, server: BackendFixture +): @reactpy.component def SomeComponent(): count, incr_count = use_counter(0) @@ -35,39 +29,33 @@ async def get_count(): count = await page.wait_for_selector("#count") return await count.get_attribute("data-count") - async with AsyncExitStack() as exit_stack: - server = await exit_stack.enter_async_context(BackendFixture(port=port)) - display = await exit_stack.enter_async_context( - DisplayFixture(server, driver=page) - ) - - await display.show(SomeComponent) - - incr = await page.wait_for_selector("#incr") + await display.show(SomeComponent) - for i in range(3): - await poll(get_count).until_equals(str(i)) - await incr.click() + await poll(get_count).until_equals("0") + incr = await page.wait_for_selector("#incr") + await incr.click() - # the server is disconnected but the last view state is still shown - await page.wait_for_selector("#count") + await poll(get_count).until_equals("1") + incr = await page.wait_for_selector("#incr") + await incr.click() - async with AsyncExitStack() as exit_stack: - server = await exit_stack.enter_async_context(BackendFixture(port=port)) - display = await exit_stack.enter_async_context( - DisplayFixture(server, driver=page) - ) + await poll(get_count).until_equals("2") + incr = await page.wait_for_selector("#incr") + await incr.click() - # use mount instead of show to avoid a page refresh - display.backend.mount(SomeComponent) + await server.restart() - for i in range(3): - await poll(get_count).until_equals(str(i)) + await poll(get_count).until_equals("0") + incr = await page.wait_for_selector("#incr") + await incr.click() - # need to refetch element because may unmount on reconnect - incr = await page.wait_for_selector("#incr") + await poll(get_count).until_equals("1") + incr = await page.wait_for_selector("#incr") + await incr.click() - await incr.click() + await poll(get_count).until_equals("2") + incr = await page.wait_for_selector("#incr") + await incr.click() async def test_style_can_be_changed(display: DisplayFixture): diff --git a/tests/test_config.py b/tests/test_config.py index 3428c3e28..e5c6457c5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,10 +23,10 @@ def reset_options(): opt.current = val -def test_reactpy_debug_mode_toggle(): +def test_reactpy_debug_toggle(): # just check that nothing breaks - config.REACTPY_DEBUG_MODE.current = True - config.REACTPY_DEBUG_MODE.current = False + config.REACTPY_DEBUG.current = True + config.REACTPY_DEBUG.current = False def test_boolean(): diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 1f444cb68..8fe5fdab1 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -4,7 +4,7 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.hooks import strictly_equal, use_effect from reactpy.core.layout import Layout @@ -1044,7 +1044,7 @@ def SetStateDuringRender(): assert render_count.current == 2 -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only logs in debug mode") async def test_use_debug_mode(): set_message = reactpy.Ref() component_hook = HookCatcher() @@ -1071,7 +1071,7 @@ def SomeComponent(): await layout.render() -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only logs in debug mode") async def test_use_debug_mode_with_factory(): set_message = reactpy.Ref() component_hook = HookCatcher() @@ -1098,7 +1098,7 @@ def SomeComponent(): await layout.render() -@pytest.mark.skipif(REACTPY_DEBUG_MODE.current, reason="logs in debug mode") +@pytest.mark.skipif(REACTPY_DEBUG.current, reason="logs in debug mode") async def test_use_debug_mode_does_not_log_if_not_in_debug_mode(): set_message = reactpy.Ref() diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index f86a80cd2..01472edd2 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -10,11 +10,10 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout -from reactpy.core.types import State from reactpy.testing import ( HookCatcher, StaticEventHandler, @@ -22,6 +21,7 @@ capture_reactpy_logs, ) from reactpy.testing.common import poll +from reactpy.types import State from reactpy.utils import Ref from tests.tooling import select from tests.tooling.aio import Event @@ -156,7 +156,7 @@ def make_child_model(state): @pytest.mark.skipif( - not REACTPY_DEBUG_MODE.current, + not REACTPY_DEBUG.current, reason="errors only reported in debug mode", ) async def test_layout_render_error_has_partial_update_with_error_message(): @@ -207,7 +207,7 @@ def BadChild(): @pytest.mark.skipif( - REACTPY_DEBUG_MODE.current, + REACTPY_DEBUG.current, reason="errors only reported in debug mode", ) async def test_layout_render_error_has_partial_update_without_error_message(): diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index bae3c1e01..8dee3e19e 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -10,8 +10,8 @@ from reactpy.core.hooks import use_effect from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import LayoutUpdateMessage from reactpy.testing import StaticEventHandler +from reactpy.types import LayoutUpdateMessage from tests.tooling.aio import Event from tests.tooling.common import event_message diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 37abad1d2..0f3cdafc4 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -4,10 +4,10 @@ from fastjsonschema import JsonSchemaException import reactpy -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG from reactpy.core.events import EventHandler -from reactpy.core.types import VdomDict from reactpy.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json +from reactpy.types import VdomDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"on_event": FAKE_EVENT_HANDLER} @@ -280,7 +280,7 @@ def test_invalid_vdom(value, error_message_pattern): validate_vdom_json(value) -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="Only warns in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: reactpy.vdom("div", (1 for i in range(10))) @@ -292,7 +292,7 @@ def test_warn_cannot_verify_keypath_for_genereators(): ) -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="Only warns in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_dynamic_children_must_have_keys(): with pytest.warns(UserWarning) as record: reactpy.vdom("div", [reactpy.vdom("div")]) @@ -309,7 +309,7 @@ def MyComponent(): assert record[0].message.args[0].startswith("Key not specified for child") -@pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only checked in debug mode") +@pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only checked in debug mode") def test_raise_for_non_json_attrs(): with pytest.raises(TypeError, match="JSON serializable"): reactpy.html.div({"non_json_serializable_object": object()}) diff --git a/tests/test_html.py b/tests/test_html.py index aa541dedf..68e353681 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,34 +1,48 @@ import pytest +from playwright.async_api import expect -from reactpy import component, config, html +from reactpy import component, config, hooks, html from reactpy.testing import DisplayFixture, poll from reactpy.utils import Ref +from tests.tooling.common import DEFAULT_TYPE_DELAY from tests.tooling.hooks import use_counter async def test_script_re_run_on_content_change(display: DisplayFixture): - incr_count = Ref() - @component def HasScript(): - count, incr_count.current = use_counter(1) + count, set_count = hooks.use_state(0) + + def on_click(event): + set_count(count + 1) + return html.div( html.div({"id": "mount-count", "data_value": 0}), html.script( f'document.getElementById("mount-count").setAttribute("data-value", {count});' ), + html.button({"onClick": on_click, "id": "incr"}, "Increment"), ) await display.show(HasScript) - mount_count = await display.page.wait_for_selector("#mount-count", state="attached") - poll_mount_count = poll(mount_count.get_attribute, "data-value") + await display.page.wait_for_selector("#mount-count", state="attached") + button = await display.page.wait_for_selector("#incr", state="attached") - await poll_mount_count.until_equals("1") - incr_count.current() - await poll_mount_count.until_equals("2") - incr_count.current() - await poll_mount_count.until_equals("3") + await button.click(delay=DEFAULT_TYPE_DELAY) + await expect(display.page.locator("#mount-count")).to_have_attribute( + "data-value", "1" + ) + + await button.click(delay=DEFAULT_TYPE_DELAY) + await expect(display.page.locator("#mount-count")).to_have_attribute( + "data-value", "2" + ) + + await button.click(delay=DEFAULT_TYPE_DELAY) + await expect(display.page.locator("#mount-count")).to_have_attribute( + "data-value", "3", timeout=100000 + ) async def test_script_from_src(display: DisplayFixture): @@ -46,7 +60,7 @@ def HasScript(): html.div({"id": "run-count", "data_value": 0}), html.script( { - "src": f"/_reactpy/modules/{file_name_template.format(src_id=src_id)}" + "src": f"/reactpy/modules/{file_name_template.format(src_id=src_id)}" } ), ) @@ -101,3 +115,27 @@ def test_simple_fragment(): def test_fragment_can_have_no_attributes(): with pytest.raises(TypeError, match="Fragments cannot have attributes"): html.fragment({"some_attribute": 1}) + + +async def test_svg(display: DisplayFixture): + @component + def SvgComponent(): + return html.svg( + {"width": 100, "height": 100}, + html.svg.circle( + {"cx": 50, "cy": 50, "r": 40, "fill": "red"}, + ), + html.svg.circle( + {"cx": 50, "cy": 50, "r": 40, "fill": "red"}, + ), + ) + + await display.show(SvgComponent) + svg = await display.page.wait_for_selector("svg", state="attached") + assert await svg.get_attribute("width") == "100" + assert await svg.get_attribute("height") == "100" + circle = await display.page.wait_for_selector("circle", state="attached") + assert await circle.get_attribute("cx") == "50" + assert await circle.get_attribute("cy") == "50" + assert await circle.get_attribute("r") == "40" + assert await circle.get_attribute("fill") == "red" diff --git a/tests/test_testing.py b/tests/test_testing.py index a6517abc0..e2c227d61 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -4,7 +4,6 @@ import pytest from reactpy import Ref, component, html, testing -from reactpy.backend import starlette as starlette_implementation from reactpy.logging import ROOT_LOGGER from reactpy.testing.backend import _hotswap from reactpy.testing.display import DisplayFixture @@ -144,19 +143,6 @@ async def test_simple_display_fixture(): await display.page.wait_for_selector("#sample") -def test_if_app_is_given_implementation_must_be_too(): - with pytest.raises( - ValueError, - match=r"If an application instance its corresponding server implementation must be provided too", - ): - testing.BackendFixture(app=starlette_implementation.create_development_app()) - - testing.BackendFixture( - app=starlette_implementation.create_development_app(), - implementation=starlette_implementation, - ) - - def test_list_logged_excptions(): the_error = None with testing.capture_reactpy_logs() as records: diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 388794741..6693a5301 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,10 +1,10 @@ from pathlib import Path import pytest -from sanic import Sanic +from servestatic import ServeStaticASGI import reactpy -from reactpy.backend import sanic as sanic_implementation +from reactpy.asgi.standalone import ReactPy from reactpy.testing import ( BackendFixture, DisplayFixture, @@ -50,19 +50,9 @@ def ShowCurrentComponent(): await display.page.wait_for_selector("#unmount-flag", state="attached") -@pytest.mark.flaky(reruns=3) async def test_module_from_url(browser): - app = Sanic("test_module_from_url") - - # instead of directing the URL to a CDN, we just point it to this static file - app.static( - "/simple-button.js", - str(JS_FIXTURES_DIR / "simple-button.js"), - content_type="text/javascript", - ) - SimpleButton = reactpy.web.export( - reactpy.web.module_from_url("/simple-button.js", resolve_exports=False), + reactpy.web.module_from_url("/static/simple-button.js", resolve_exports=False), "SimpleButton", ) @@ -70,7 +60,10 @@ async def test_module_from_url(browser): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - async with BackendFixture(app=app, implementation=sanic_implementation) as server: + app = ReactPy(ShowSimpleButton) + app = ServeStaticASGI(app, JS_FIXTURES_DIR, "/static/") + + async with BackendFixture(app) as server: async with DisplayFixture(server, browser) as display: await display.show(ShowSimpleButton) diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 14c3e2e13..2f9d72618 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -5,6 +5,7 @@ from reactpy.testing import assert_reactpy_did_log from reactpy.web.utils import ( + _resolve_relative_url, module_name_suffix, resolve_module_exports_from_file, resolve_module_exports_from_source, @@ -150,3 +151,15 @@ def test_log_on_unknown_export_type(): assert resolve_module_exports_from_source( "export something unknown;", exclude_default=False ) == (set(), set()) + + +def test_resolve_relative_url(): + assert ( + _resolve_relative_url("https://some.url", "path/to/another.js") + == "path/to/another.js" + ) + assert ( + _resolve_relative_url("https://some.url", "/path/to/another.js") + == "https://some.url/path/to/another.js" + ) + assert _resolve_relative_url("/some/path", "to/another.js") == "to/another.js" diff --git a/tests/tooling/aio.py b/tests/tooling/aio.py index b0f719400..7fe8f03b2 100644 --- a/tests/tooling/aio.py +++ b/tests/tooling/aio.py @@ -3,7 +3,7 @@ from asyncio import Event as _Event from asyncio import wait_for -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT class Event(_Event): @@ -12,5 +12,5 @@ class Event(_Event): async def wait(self, timeout: float | None = None): return await wait_for( super().wait(), - timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current, + timeout=timeout or REACTPY_TESTS_DEFAULT_TIMEOUT.current, ) diff --git a/tests/tooling/common.py b/tests/tooling/common.py index 1803b8aed..c850d714b 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,11 +1,11 @@ import os from typing import Any -from reactpy.core.types import LayoutEventMessage, LayoutUpdateMessage +from reactpy.types import LayoutEventMessage, LayoutUpdateMessage GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") DEFAULT_TYPE_DELAY = ( - 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 25 + 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 50 ) diff --git a/tests/tooling/hooks.py b/tests/tooling/hooks.py index 1926a93bc..e5a4b6fb1 100644 --- a/tests/tooling/hooks.py +++ b/tests/tooling/hooks.py @@ -10,6 +10,7 @@ def use_toggle(init=False): return state, lambda: set_state(lambda old: not old) +# TODO: Remove this def use_counter(initial_value): state, set_state = use_state(initial_value) return state, lambda: set_state(lambda old: old + 1) diff --git a/tests/tooling/layout.py b/tests/tooling/layout.py index fe78684fe..034770bf6 100644 --- a/tests/tooling/layout.py +++ b/tests/tooling/layout.py @@ -8,7 +8,7 @@ from jsonpointer import set_pointer from reactpy.core.layout import Layout -from reactpy.core.types import VdomJson +from reactpy.types import VdomJson from tests.tooling.common import event_message logger = logging.getLogger(__name__) diff --git a/tests/tooling/select.py b/tests/tooling/select.py index cf7a9c004..2a0f170b8 100644 --- a/tests/tooling/select.py +++ b/tests/tooling/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Callable -from reactpy.core.types import VdomJson +from reactpy.types import VdomJson Selector = Callable[[VdomJson, "ElementInfo"], bool] From b1e66e26c50e34a16683ae98cf9731d52bced3be Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 1 Feb 2025 21:20:26 -0800 Subject: [PATCH 02/17] Remove `snake_case` -> `camelCase` prop conversion (#1263) --- docs/source/about/changelog.rst | 1 + pyproject.toml | 11 ++-- src/js/packages/@reactpy/client/package.json | 2 +- src/js/packages/@reactpy/client/src/vdom.tsx | 33 +---------- src/reactpy/__main__.py | 19 ------- src/reactpy/_console/cli.py | 19 +++++++ ...e_camel_case_props.py => rewrite_props.py} | 57 +++++++++++++------ src/reactpy/testing/backend.py | 5 +- src/reactpy/testing/common.py | 9 +++ src/reactpy/widgets.py | 2 +- tests/conftest.py | 9 +-- tests/test_asgi/test_standalone.py | 2 +- tests/test_client.py | 34 ++--------- ...el_case_props.py => test_rewrite_props.py} | 32 +++++------ tests/test_core/test_events.py | 12 ++-- tests/test_core/test_hooks.py | 8 +-- tests/test_core/test_layout.py | 32 +++++------ tests/test_core/test_serve.py | 6 +- tests/test_core/test_vdom.py | 28 ++++----- tests/test_html.py | 8 +-- tests/test_testing.py | 2 +- tests/test_utils.py | 4 +- tests/tooling/common.py | 7 +-- 23 files changed, 159 insertions(+), 183 deletions(-) delete mode 100644 src/reactpy/__main__.py create mode 100644 src/reactpy/_console/cli.py rename src/reactpy/_console/{rewrite_camel_case_props.py => rewrite_props.py} (58%) rename tests/test_console/{test_rewrite_camel_case_props.py => test_rewrite_props.py} (87%) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 9f833d28f..4e9d753d2 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -32,6 +32,7 @@ Unreleased - :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. - :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``. - :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. +- :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format. **Removed** diff --git a/pyproject.toml b/pyproject.toml index 92430e71b..4ca1a411a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,12 @@ authors = [ ] requires-python = ">=3.9" classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] @@ -60,6 +61,9 @@ license-files = { paths = ["LICENSE"] } [tool.hatch.envs.default] installer = "uv" +[project.scripts] +reactpy = "reactpy._console.cli:entry_point" + [[tool.hatch.build.hooks.build-scripts.scripts]] # Note: `hatch` can't be called within `build-scripts` when installing packages in editable mode, so we have to write the commands long-form commands = [ @@ -162,8 +166,6 @@ extra-dependencies = [ "mypy==1.8", "types-toml", "types-click", - "types-tornado", - "types-flask", "types-requests", ] @@ -194,6 +196,7 @@ test = [ ] build = [ 'hatch run "src/build_scripts/clean_js_dir.py"', + 'bun install --cwd "src/js"', 'hatch run javascript:build_event_to_object', 'hatch run javascript:build_client', 'hatch run javascript:build_app', diff --git a/src/js/packages/@reactpy/client/package.json b/src/js/packages/@reactpy/client/package.json index b6b12830f..95e545eb4 100644 --- a/src/js/packages/@reactpy/client/package.json +++ b/src/js/packages/@reactpy/client/package.json @@ -1,6 +1,6 @@ { "name": "@reactpy/client", - "version": "0.3.2", + "version": "1.0.0", "description": "A client for ReactPy implemented in React", "author": "Ryan Morshead", "license": "MIT", diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index d86d9232a..25eb9f3e7 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -152,8 +152,7 @@ export function createAttributes( createEventHandler(client, name, handler), ), ), - // Convert snake_case to camelCase names - }).map(normalizeAttribute), + }), ); } @@ -182,33 +181,3 @@ function createEventHandler( }, ]; } - -function normalizeAttribute([key, value]: [string, any]): [string, any] { - let normKey = key; - let normValue = value; - - if (key === "style" && typeof value === "object") { - normValue = Object.fromEntries( - Object.entries(value).map(([k, v]) => [snakeToCamel(k), v]), - ); - } else if ( - key.startsWith("data_") || - key.startsWith("aria_") || - DASHED_HTML_ATTRS.includes(key) - ) { - normKey = key.split("_").join("-"); - } else { - normKey = snakeToCamel(key); - } - return [normKey, normValue]; -} - -function snakeToCamel(str: string): string { - return str.replace(/([_][a-z])/g, (group) => - group.toUpperCase().replace("_", ""), - ); -} - -// see list of HTML attributes with dashes in them: -// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list -const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"]; diff --git a/src/reactpy/__main__.py b/src/reactpy/__main__.py deleted file mode 100644 index d70ddf684..000000000 --- a/src/reactpy/__main__.py +++ /dev/null @@ -1,19 +0,0 @@ -import click - -import reactpy -from reactpy._console.rewrite_camel_case_props import rewrite_camel_case_props -from reactpy._console.rewrite_keys import rewrite_keys - - -@click.group() -@click.version_option(reactpy.__version__, prog_name=reactpy.__name__) -def app() -> None: - pass - - -app.add_command(rewrite_keys) -app.add_command(rewrite_camel_case_props) - - -if __name__ == "__main__": - app() diff --git a/src/reactpy/_console/cli.py b/src/reactpy/_console/cli.py new file mode 100644 index 000000000..720583002 --- /dev/null +++ b/src/reactpy/_console/cli.py @@ -0,0 +1,19 @@ +"""Entry point for the ReactPy CLI.""" + +import click + +import reactpy +from reactpy._console.rewrite_props import rewrite_props + + +@click.group() +@click.version_option(version=reactpy.__version__, prog_name=reactpy.__name__) +def entry_point() -> None: + pass + + +entry_point.add_command(rewrite_props) + + +if __name__ == "__main__": + entry_point() diff --git a/src/reactpy/_console/rewrite_camel_case_props.py b/src/reactpy/_console/rewrite_props.py similarity index 58% rename from src/reactpy/_console/rewrite_camel_case_props.py rename to src/reactpy/_console/rewrite_props.py index 12c96c4f3..f7ae7c656 100644 --- a/src/reactpy/_console/rewrite_camel_case_props.py +++ b/src/reactpy/_console/rewrite_props.py @@ -1,7 +1,6 @@ from __future__ import annotations import ast -import re from copy import copy from keyword import kwlist from pathlib import Path @@ -15,15 +14,13 @@ rewrite_changed_nodes, ) -CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: - """Rewrite camelCase props to snake_case""" - +def rewrite_props(paths: list[str]) -> None: + """Rewrite snake_case props to camelCase within .""" for p in map(Path, paths): + # Process each file or recursively process each Python file in directories for f in [p] if p.is_file() else p.rglob("*.py"): result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8")) if result is not None: @@ -31,43 +28,66 @@ def rewrite_camel_case_props(paths: list[str]) -> None: def generate_rewrite(file: Path, source: str) -> str | None: - tree = ast.parse(source) + """Generate the rewritten source code if changes are detected""" + tree = ast.parse(source) # Parse the source code into an AST - changed = find_nodes_to_change(tree) + changed = find_nodes_to_change(tree) # Find nodes that need to be changed if not changed: - return None + return None # Return None if no changes are needed - new = rewrite_changed_nodes(file, source, tree, changed) + new = rewrite_changed_nodes( + file, source, tree, changed + ) # Rewrite the changed nodes return new def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: + """Find nodes in the AST that need to be changed""" changed: list[ChangedNode] = [] for el_info in find_element_constructor_usages(tree): + # Check if the props need to be rewritten if _rewrite_props(el_info.props, _construct_prop_item): + # Add the changed node to the list changed.append(ChangedNode(el_info.call, el_info.parents)) return changed def conv_attr_name(name: str) -> str: - new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).lower() - return f"{new_name}_" if new_name in kwlist else new_name + """Convert snake_case attribute name to camelCase""" + # Return early if the value is a Python keyword + if name in kwlist: + return name + + # Return early if the value is not snake_case + if "_" not in name: + return name + + # Split the string by underscores + components = name.split("_") + + # Capitalize the first letter of each component except the first one + # and join them together + return components[0] + "".join(x.title() for x in components[1:]) def _construct_prop_item(key: str, value: ast.expr) -> tuple[str, ast.expr]: + """Construct a new prop item with the converted key and possibly modified value""" if key == "style" and isinstance(value, (ast.Dict, ast.Call)): + # Create a copy of the value to avoid modifying the original new_value = copy(value) if _rewrite_props( new_value, lambda k, v: ( (k, v) - # avoid infinite recursion + # Avoid infinite recursion if k == "style" else _construct_prop_item(k, v) ), ): + # Update the value if changes were made value = new_value else: + # Convert the key to camelCase key = conv_attr_name(key) return key, value @@ -76,12 +96,15 @@ def _rewrite_props( props_node: ast.Dict | ast.Call, constructor: Callable[[str, ast.expr], tuple[str, ast.expr]], ) -> bool: + """Rewrite the props in the given AST node using the provided constructor""" + did_change = False if isinstance(props_node, ast.Dict): - did_change = False keys: list[ast.expr | None] = [] values: list[ast.expr] = [] + # Iterate over the keys and values in the dictionary for k, v in zip(props_node.keys, props_node.values): if isinstance(k, ast.Constant) and isinstance(k.value, str): + # Construct the new key and value k_value, new_v = constructor(k.value, v) if k_value != k.value or new_v is not v: did_change = True @@ -90,20 +113,22 @@ def _rewrite_props( keys.append(k) values.append(v) if not did_change: - return False + return False # Return False if no changes were made props_node.keys = keys props_node.values = values else: did_change = False keywords: list[ast.keyword] = [] + # Iterate over the keywords in the call for kw in props_node.keywords: if kw.arg is not None: + # Construct the new keyword argument and value kw_arg, kw_value = constructor(kw.arg, kw.value) if kw_arg != kw.arg or kw_value is not kw.value: did_change = True kw = ast.keyword(arg=kw_arg, value=kw_value) keywords.append(kw) if not did_change: - return False + return False # Return False if no changes were made props_node.keywords = keywords return True diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 9ebd15f3a..1f6521e92 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -16,6 +16,7 @@ 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.testing.common import GITHUB_ACTIONS from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, @@ -138,7 +139,9 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] - await asyncio.wait_for(self.webserver.shutdown(), timeout=60) + await asyncio.wait_for( + self.webserver.shutdown(), timeout=60 if GITHUB_ACTIONS else 5 + ) async def restart(self) -> None: """Restart the server""" diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index de5afaba7..6921bb8da 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -2,6 +2,7 @@ import asyncio import inspect +import os import shutil import time from collections.abc import Awaitable @@ -28,6 +29,14 @@ def clear_reactpy_web_modules_dir() -> None: _DEFAULT_POLL_DELAY = 0.1 +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in { + "y", + "yes", + "t", + "true", + "on", + "1", +} class poll(Generic[_R]): # noqa: N801 diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index bc559c15d..01532b277 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -73,7 +73,7 @@ def sync_inputs(event: dict[str, Any]) -> None: inputs: list[VdomDict] = [] for attrs in attributes: - inputs.append(html.input({**attrs, "on_change": sync_inputs, "value": value})) + inputs.append(html.input({**attrs, "onChange": sync_inputs, "value": value})) return inputs diff --git a/tests/conftest.py b/tests/conftest.py index 119e7571d..2bcd5d3ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,17 +19,10 @@ capture_reactpy_logs, clear_reactpy_web_modules_dir, ) +from reactpy.testing.common import GITHUB_ACTIONS REACTPY_ASYNC_RENDERING.set_current(True) REACTPY_DEBUG.set_current(True) -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in { - "y", - "yes", - "t", - "true", - "on", - "1", -} def pytest_addoption(parser: Parser) -> None: diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 8c477b21d..c94ee96c1 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -40,7 +40,7 @@ def Counter(): return reactpy.html.button( { "id": "counter", - "on_click": lambda event: set_count(lambda old_count: old_count + 1), + "onClick": lambda event: set_count(lambda old_count: old_count + 1), }, f"Count: {count}", ) diff --git a/tests/test_client.py b/tests/test_client.py index 7d1da4007..7815dcce8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,9 +18,9 @@ async def test_automatic_reconnect( def SomeComponent(): count, incr_count = use_counter(0) return reactpy.html.fragment( - reactpy.html.p({"data_count": count, "id": "count"}, "count", count), + reactpy.html.p({"data-count": count, "id": "count"}, "count", count), reactpy.html.button( - {"on_click": lambda e: incr_count(), "id": "incr"}, "incr" + {"onClick": lambda e: incr_count(), "id": "incr"}, "incr" ), ) @@ -74,8 +74,8 @@ def ButtonWithChangingColor(): return reactpy.html.button( { "id": "my-button", - "on_click": lambda event: set_color_toggle(not color_toggle), - "style": {"background_color": color, "color": "white"}, + "onClick": lambda event: set_color_toggle(not color_toggle), + "style": {"backgroundColor": color, "color": "white"}, }, f"color: {color}", ) @@ -117,7 +117,7 @@ async def handle_change(event): await asyncio.sleep(delay) set_value(event["target"]["value"]) - return reactpy.html.input({"on_change": handle_change, "id": "test-input"}) + return reactpy.html.input({"onChange": handle_change, "id": "test-input"}) await display.show(SomeComponent) @@ -125,27 +125,3 @@ async def handle_change(event): await inp.type("hello", delay=DEFAULT_TYPE_DELAY) assert (await inp.evaluate("node => node.value")) == "hello" - - -async def test_snake_case_attributes(display: DisplayFixture): - @reactpy.component - def SomeComponent(): - return reactpy.html.h1( - { - "id": "my-title", - "style": {"background_color": "blue"}, - "class_name": "hello", - "data_some_thing": "some-data", - "aria_some_thing": "some-aria", - }, - "title with some attributes", - ) - - await display.show(SomeComponent) - - title = await display.page.wait_for_selector("#my-title") - - assert await title.get_attribute("class") == "hello" - assert await title.get_attribute("style") == "background-color: blue;" - assert await title.get_attribute("data-some-thing") == "some-data" - assert await title.get_attribute("aria-some-thing") == "some-aria" diff --git a/tests/test_console/test_rewrite_camel_case_props.py b/tests/test_console/test_rewrite_props.py similarity index 87% rename from tests/test_console/test_rewrite_camel_case_props.py rename to tests/test_console/test_rewrite_props.py index af3a5dd4b..26b88f072 100644 --- a/tests/test_console/test_rewrite_camel_case_props.py +++ b/tests/test_console/test_rewrite_props.py @@ -4,9 +4,9 @@ import pytest from click.testing import CliRunner -from reactpy._console.rewrite_camel_case_props import ( +from reactpy._console.rewrite_props import ( generate_rewrite, - rewrite_camel_case_props, + rewrite_props, ) @@ -14,22 +14,22 @@ def test_rewrite_camel_case_props_declarations(tmp_path): runner = CliRunner() tempfile: Path = tmp_path / "temp.py" - tempfile.write_text("html.div(dict(camelCase='test'))") + tempfile.write_text("html.div(dict(example_attribute='test'))") result = runner.invoke( - rewrite_camel_case_props, + rewrite_props, args=[str(tmp_path)], catch_exceptions=False, ) assert result.exit_code == 0 - assert tempfile.read_text() == "html.div(dict(camel_case='test'))" + assert tempfile.read_text() == "html.div(dict(exampleAttribute='test'))" def test_rewrite_camel_case_props_declarations_no_files(): runner = CliRunner() result = runner.invoke( - rewrite_camel_case_props, + rewrite_props, args=["directory-does-no-exist"], catch_exceptions=False, ) @@ -41,40 +41,40 @@ def test_rewrite_camel_case_props_declarations_no_files(): "source, expected", [ ( - "html.div(dict(camelCase='test'))", "html.div(dict(camel_case='test'))", + "html.div(dict(camelCase='test'))", ), ( - "reactpy.html.button({'onClick': block_forever})", "reactpy.html.button({'on_click': block_forever})", + "reactpy.html.button({'onClick': block_forever})", ), ( - "html.div(dict(style={'testThing': test}))", "html.div(dict(style={'test_thing': test}))", + "html.div(dict(style={'testThing': test}))", ), ( - "html.div(dict(style=dict(testThing=test)))", "html.div(dict(style=dict(test_thing=test)))", + "html.div(dict(style=dict(testThing=test)))", ), ( - "vdom('tag', dict(camelCase='test'))", "vdom('tag', dict(camel_case='test'))", + "vdom('tag', dict(camelCase='test'))", ), ( - "vdom('tag', dict(camelCase='test', **props))", "vdom('tag', dict(camel_case='test', **props))", + "vdom('tag', dict(camelCase='test', **props))", ), ( - "html.div({'camelCase': test, 'data-thing': test})", "html.div({'camel_case': test, 'data-thing': test})", + "html.div({'camelCase': test, 'data-thing': test})", ), ( - "html.div({'camelCase': test, ignore: this})", "html.div({'camel_case': test, ignore: this})", + "html.div({'camelCase': test, ignore: this})", ), # no rewrite ( - "html.div({'snake_case': test})", + "html.div({'camelCase': test})", None, ), ( @@ -82,7 +82,7 @@ def test_rewrite_camel_case_props_declarations_no_files(): None, ), ( - "html.div(dict(snake_case='test'))", + "html.div(dict(camelCase='test'))", None, ), ( diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index b6fea346a..310ddc880 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -151,7 +151,7 @@ def Input(): async def on_key_down(value): pass - return reactpy.html.input({"on_key_down": on_key_down, "id": "input"}) + return reactpy.html.input({"onKeyDown": on_key_down, "id": "input"}) await display.show(Input) @@ -171,7 +171,7 @@ async def on_click(event): if not clicked: return reactpy.html.button( - {"on_click": on_click, "id": "click"}, ["Click Me!"] + {"onClick": on_click, "id": "click"}, ["Click Me!"] ) else: return reactpy.html.p({"id": "complete"}, ["Complete"]) @@ -197,8 +197,8 @@ def outer_click_is_not_triggered(event): outer = reactpy.html.div( { - "style": {"height": "35px", "width": "35px", "background_color": "red"}, - "on_click": outer_click_is_not_triggered, + "style": {"height": "35px", "width": "35px", "backgroundColor": "red"}, + "onClick": outer_click_is_not_triggered, "id": "outer", }, reactpy.html.div( @@ -206,9 +206,9 @@ def outer_click_is_not_triggered(event): "style": { "height": "30px", "width": "30px", - "background_color": "blue", + "backgroundColor": "blue", }, - "on_click": inner_click_no_op, + "onClick": inner_click_no_op, "id": "inner", } ), diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 8fe5fdab1..30ad878bb 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -186,14 +186,14 @@ def TestComponent(): reactpy.html.button( { "id": "r_1", - "on_click": event_count_tracker(lambda event: set_state(r_1)), + "onClick": event_count_tracker(lambda event: set_state(r_1)), }, "r_1", ), reactpy.html.button( { "id": "r_2", - "on_click": event_count_tracker(lambda event: set_state(r_2)), + "onClick": event_count_tracker(lambda event: set_state(r_2)), }, "r_2", ), @@ -240,7 +240,7 @@ async def on_change(event): set_message(event["target"]["value"]) if message is None: - return reactpy.html.input({"id": "input", "on_change": on_change}) + return reactpy.html.input({"id": "input", "onChange": on_change}) else: return reactpy.html.p({"id": "complete"}, ["Complete"]) @@ -271,7 +271,7 @@ def double_set_state(event): {"id": "second", "data-value": state_2}, f"value is: {state_2}" ), reactpy.html.button( - {"id": "button", "on_click": double_set_state}, "click me" + {"id": "button", "onClick": double_set_state}, "click me" ), ) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 01472edd2..234b00e9c 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -510,10 +510,10 @@ def bad_trigger(): children = [ reactpy.html.button( - {"on_click": good_trigger, "id": "good", "key": "good"}, "good" + {"onClick": good_trigger, "id": "good", "key": "good"}, "good" ), reactpy.html.button( - {"on_click": bad_trigger, "id": "bad", "key": "bad"}, "bad" + {"onClick": bad_trigger, "id": "bad", "key": "bad"}, "bad" ), ] @@ -572,7 +572,7 @@ def callback(): msg = "Called bad trigger" raise ValueError(msg) - return reactpy.html.button({"on_click": callback, "id": "good"}, "good") + return reactpy.html.button({"onClick": callback, "id": "good"}, "good") async with reactpy.Layout(RootComponent()) as layout: await layout.render() @@ -654,8 +654,8 @@ def HasEventHandlerAtRoot(): value, set_value = reactpy.hooks.use_state(False) set_value(not value) # trigger renders forever event_handler.current = weakref(set_value) - button = reactpy.html.button({"on_click": set_value}, "state is: ", value) - event_handler.current = weakref(button["eventHandlers"]["on_click"].function) + button = reactpy.html.button({"onClick": set_value}, "state is: ", value) + event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return button async with reactpy.Layout(HasEventHandlerAtRoot()) as layout: @@ -676,8 +676,8 @@ def HasNestedEventHandler(): value, set_value = reactpy.hooks.use_state(False) set_value(not value) # trigger renders forever event_handler.current = weakref(set_value) - button = reactpy.html.button({"on_click": set_value}, "state is: ", value) - event_handler.current = weakref(button["eventHandlers"]["on_click"].function) + button = reactpy.html.button({"onClick": set_value}, "state is: ", value) + event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return reactpy.html.div(reactpy.html.div(button)) async with reactpy.Layout(HasNestedEventHandler()) as layout: @@ -759,7 +759,7 @@ def raise_error(): msg = "bad event handler" raise Exception(msg) - return reactpy.html.button({"on_click": raise_error}) + return reactpy.html.button({"onClick": raise_error}) with assert_reactpy_did_log(match_error="bad event handler"): async with reactpy.Layout(ComponentWithBadEventHandler()) as layout: @@ -857,7 +857,7 @@ def SomeComponent(): [ reactpy.html.div( {"key": i}, - reactpy.html.input({"on_change": lambda event: None}), + reactpy.html.input({"onChange": lambda event: None}), ) for i in items ] @@ -915,14 +915,14 @@ def Root(): toggle, toggle_type.current = use_toggle(True) handler = element_static_handler.use(lambda: None) if toggle: - return html.div(html.button({"on_event": handler})) + return html.div(html.button({"onEvent": handler})) else: return html.div(SomeComponent()) @reactpy.component def SomeComponent(): handler = component_static_handler.use(lambda: None) - return html.button({"on_another_event": handler}) + return html.button({"onAnotherEvent": handler}) async with reactpy.Layout(Root()) as layout: await layout.render() @@ -1005,7 +1005,7 @@ def Parent(): state, set_state = use_state(0) return html.div( html.button( - {"on_click": set_child_key_num.use(lambda: set_state(state + 1))}, + {"onClick": set_child_key_num.use(lambda: set_state(state + 1))}, "click me", ), Child("some-key"), @@ -1217,8 +1217,8 @@ def colorize(event): return html.div( {"id": item, "color": color.value}, - html.button({"on_click": colorize}, f"Color {item}"), - html.button({"on_click": deleteme}, f"Delete {item}"), + html.button({"onClick": colorize}, f"Color {item}"), + html.button({"onClick": deleteme}, f"Delete {item}"), ) @component @@ -1233,7 +1233,7 @@ def App(): b, b_info = find_element(tree, select.id_equals("B")) assert b_info.path == (0, 1, 0) b_delete, _ = find_element(b, select.text_equals("Delete B")) - await runner.trigger(b_delete, "on_click", {}) + await runner.trigger(b_delete, "onClick", {}) tree = await runner.render() @@ -1242,7 +1242,7 @@ def App(): c, c_info = find_element(tree, select.id_equals("C")) assert c_info.path == (0, 1, 0) c_color, _ = find_element(c, select.text_equals("Color C")) - await runner.trigger(c_color, "on_click", {}) + await runner.trigger(c_color, "onClick", {}) tree = await runner.render() diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 8dee3e19e..df92b8091 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -15,7 +15,7 @@ from tests.tooling.aio import Event from tests.tooling.common import event_message -EVENT_NAME = "on_event" +EVENT_NAME = "onEvent" STATIC_EVENT_HANDLER = StaticEventHandler() @@ -126,8 +126,8 @@ def set_did_render(): did_render.set() return reactpy.html.div( - reactpy.html.button({"on_click": block_forever}), - reactpy.html.button({"on_click": handle_event}), + reactpy.html.button({"onClick": block_forever}), + reactpy.html.button({"onClick": handle_event}), ) send_queue = asyncio.Queue() diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 0f3cdafc4..8e349fcc4 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -10,7 +10,7 @@ from reactpy.types import VdomDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) -FAKE_EVENT_HANDLER_DICT = {"on_event": FAKE_EVENT_HANDLER} +FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} @pytest.mark.parametrize( @@ -47,7 +47,7 @@ def test_is_vdom(result, value): }, ), ( - reactpy.vdom("div", {"on_event": FAKE_EVENT_HANDLER}), + reactpy.vdom("div", {"onEvent": FAKE_EVENT_HANDLER}), {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, ), ( @@ -82,14 +82,14 @@ async def test_callable_attributes_are_cast_to_event_handlers(): params_from_calls = [] node = reactpy.vdom( - "div", {"on_event": lambda *args: params_from_calls.append(args)} + "div", {"onEvent": lambda *args: params_from_calls.append(args)} ) event_handlers = node.pop("eventHandlers") assert node == {"tagName": "div"} - handler = event_handlers["on_event"] - assert event_handlers == {"on_event": EventHandler(handler.function)} + handler = event_handlers["onEvent"] + assert event_handlers == {"onEvent": EventHandler(handler.function)} await handler.function([1, 2]) await handler.function([3, 4, 5]) @@ -217,39 +217,39 @@ def test_valid_vdom(value): r"data\.eventHandlers must be object", ), ( - {"tagName": "tag", "eventHandlers": {"on_event": None}}, - r"data\.eventHandlers\.on_event must be object", + {"tagName": "tag", "eventHandlers": {"onEvent": None}}, + r"data\.eventHandlers\.onEvent must be object", ), ( { "tagName": "tag", - "eventHandlers": {"on_event": {}}, + "eventHandlers": {"onEvent": {}}, }, - r"data\.eventHandlers\.on_event\ must contain \['target'\] properties", + r"data\.eventHandlers\.onEvent\ must contain \['target'\] properties", ), ( { "tagName": "tag", "eventHandlers": { - "on_event": { + "onEvent": { "target": "something", "preventDefault": None, } }, }, - r"data\.eventHandlers\.on_event\.preventDefault must be boolean", + r"data\.eventHandlers\.onEvent\.preventDefault must be boolean", ), ( { "tagName": "tag", "eventHandlers": { - "on_event": { + "onEvent": { "target": "something", "stopPropagation": None, } }, }, - r"data\.eventHandlers\.on_event\.stopPropagation must be boolean", + r"data\.eventHandlers\.onEvent\.stopPropagation must be boolean", ), ( {"tagName": "tag", "importSource": None}, @@ -312,4 +312,4 @@ def MyComponent(): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="only checked in debug mode") def test_raise_for_non_json_attrs(): with pytest.raises(TypeError, match="JSON serializable"): - reactpy.html.div({"non_json_serializable_object": object()}) + reactpy.html.div({"nonJsonSerializableObject": object()}) diff --git a/tests/test_html.py b/tests/test_html.py index 68e353681..fe046c49e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -17,7 +17,7 @@ def on_click(event): set_count(count + 1) return html.div( - html.div({"id": "mount-count", "data_value": 0}), + html.div({"id": "mount-count", "dataValue": 0}), html.script( f'document.getElementById("mount-count").setAttribute("data-value", {count});' ), @@ -57,7 +57,7 @@ def HasScript(): return html.div() else: return html.div( - html.div({"id": "run-count", "data_value": 0}), + html.div({"id": "run-count", "dataValue": 0}), html.script( { "src": f"/reactpy/modules/{file_name_template.format(src_id=src_id)}" @@ -98,7 +98,7 @@ def test_child_of_script_must_be_string(): def test_script_has_no_event_handlers(): with pytest.raises(ValueError, match="do not support event handlers"): - html.script({"on_event": lambda: None}) + html.script({"onEvent": lambda: None}) def test_simple_fragment(): @@ -114,7 +114,7 @@ def test_simple_fragment(): def test_fragment_can_have_no_attributes(): with pytest.raises(TypeError, match="Fragments cannot have attributes"): - html.fragment({"some_attribute": 1}) + html.fragment({"someAttribute": 1}) async def test_svg(display: DisplayFixture): diff --git a/tests/test_testing.py b/tests/test_testing.py index e2c227d61..ad7a9af48 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -193,7 +193,7 @@ async def on_click(event): mount(hotswap_3) return html.div( - html.button({"on_click": on_click, "id": "incr-button"}, "incr"), + html.button({"onClick": on_click, "id": "incr-button"}, "incr"), hostswap(), ) diff --git a/tests/test_utils.py b/tests/test_utils.py index ef67766e5..c79a9d1f3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -226,7 +226,7 @@ def example_child(): '
helloexampleworld
', ), ( - html.button({"on_click": lambda event: None}), + html.button({"onClick": lambda event: None}), "", ), ( @@ -264,7 +264,7 @@ def example_child(): ), ( html.div( - {"data_something": 1, "data_something_else": 2, "dataisnotdashed": 3} + {"data_Something": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} ), '
', ), diff --git a/tests/tooling/common.py b/tests/tooling/common.py index c850d714b..75495db0c 100644 --- a/tests/tooling/common.py +++ b/tests/tooling/common.py @@ -1,12 +1,9 @@ -import os from typing import Any +from reactpy.testing.common import GITHUB_ACTIONS from reactpy.types import LayoutEventMessage, LayoutUpdateMessage -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") -DEFAULT_TYPE_DELAY = ( - 250 if GITHUB_ACTIONS.lower() in {"y", "yes", "t", "true", "on", "1"} else 50 -) +DEFAULT_TYPE_DELAY = 250 if GITHUB_ACTIONS else 50 def event_message(target: str, *data: Any) -> LayoutEventMessage: From 6de65eff6b9bebafdf11532d61f7a684efe4ff61 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 2 Feb 2025 00:43:41 -0800 Subject: [PATCH 03/17] Create separate `use_async_effect` hook (#1264) --- docs/source/about/changelog.rst | 2 + .../reference/_examples/simple_dashboard.py | 2 +- docs/source/reference/_examples/snake_game.py | 2 +- src/reactpy/core/hooks.py | 104 +++++++++++++----- tests/test_core/test_hooks.py | 8 +- tests/test_core/test_layout.py | 4 +- tests/test_html.py | 4 +- 7 files changed, 90 insertions(+), 36 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 4e9d753d2..7e4119f0b 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -21,6 +21,7 @@ Unreleased - :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 support for Python 3.12 and 3.13. +- :pull:`1264` - Added ``reactpy.use_async_effect`` hook. **Changed** @@ -46,6 +47,7 @@ Unreleased - :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. - :pull:`1113` - Removed deprecated function ``module_from_template``. - :pull:`1113` - Removed support for Python 3.9. +- :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` instead. **Fixed** diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index 66913fc84..58a3e6fff 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -49,7 +49,7 @@ def RandomWalkGraph(mu, sigma): interval = use_interval(0.5) data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50) - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def animate(): await interval last_data_point = data[-1] diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index 36916410e..bb4bbb541 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -90,7 +90,7 @@ def on_direction_change(event): interval = use_interval(0.5) - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def animate(): if new_game_state is not None: await asyncio.sleep(1) diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index f7321ef58..5a7cf0460 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -30,12 +30,12 @@ __all__ = [ - "use_state", + "use_callback", "use_effect", + "use_memo", "use_reducer", - "use_callback", "use_ref", - "use_memo", + "use_state", ] logger = getLogger(__name__) @@ -110,15 +110,15 @@ def use_effect( @overload def use_effect( - function: _EffectApplyFunc, + function: _SyncEffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> None: ... def use_effect( - function: _EffectApplyFunc | None = None, + function: _SyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None] | None: +) -> Callable[[_SyncEffectFunc], None] | None: """See the full :ref:`Use Effect` docs for details Parameters: @@ -134,37 +134,87 @@ def use_effect( If not function is provided, a decorator. Otherwise ``None``. """ hook = current_hook() - dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _EffectApplyFunc) -> None: - if not asyncio.iscoroutinefunction(function): - sync_function = cast(_SyncEffectFunc, function) - else: - async_function = cast(_AsyncEffectFunc, function) + def add_effect(function: _SyncEffectFunc) -> None: + async def effect(stop: asyncio.Event) -> None: + if last_clean_callback.current is not None: + last_clean_callback.current() + last_clean_callback.current = None + clean = last_clean_callback.current = function() + await stop.wait() + if clean is not None: + clean() + + return memoize(lambda: hook.add_effect(effect)) + + if function is not None: + add_effect(function) + return None + + return add_effect + + +@overload +def use_async_effect( + function: None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., +) -> Callable[[_EffectApplyFunc], None]: ... - def sync_function() -> _EffectCleanFunc | None: - task = asyncio.create_task(async_function()) - def clean_future() -> None: - if not task.cancel(): - try: - clean = task.result() - except asyncio.CancelledError: - pass - else: - if clean is not None: - clean() +@overload +def use_async_effect( + function: _AsyncEffectFunc, + dependencies: Sequence[Any] | ellipsis | None = ..., +) -> None: ... + + +def use_async_effect( + function: _AsyncEffectFunc | None = None, + dependencies: Sequence[Any] | ellipsis | None = ..., +) -> Callable[[_AsyncEffectFunc], None] | None: + """See the full :ref:`Use Effect` docs for details + + Parameters: + function: + Applies the effect and can return a clean-up function + dependencies: + Dependencies for the effect. The effect will only trigger if the identity + of any value in the given sequence changes (i.e. their :func:`id` is + different). By default these are inferred based on local variables that are + referenced by the given function. + + Returns: + If not function is provided, a decorator. Otherwise ``None``. + """ + hook = current_hook() + dependencies = _try_to_infer_closure_values(function, dependencies) + memoize = use_memo(dependencies=dependencies) + last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) + + def add_effect(function: _AsyncEffectFunc) -> None: + def sync_executor() -> _EffectCleanFunc | None: + task = asyncio.create_task(function()) - return clean_future + def clean_future() -> None: + if not task.cancel(): + try: + clean = task.result() + except asyncio.CancelledError: + pass + else: + if clean is not None: + clean() + + return clean_future async def effect(stop: asyncio.Event) -> None: if last_clean_callback.current is not None: last_clean_callback.current() last_clean_callback.current = None - clean = last_clean_callback.current = sync_function() + clean = last_clean_callback.current = sync_executor() await stop.wait() if clean is not None: clean() @@ -174,8 +224,8 @@ async def effect(stop: asyncio.Event) -> None: if function is not None: add_effect(function) return None - else: - return add_effect + + return add_effect def use_debug_value( diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 30ad878bb..2bd4da81e 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -481,7 +481,7 @@ async def test_use_async_effect(): @reactpy.component def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect + @reactpy.hooks.use_async_effect async def effect(): effect_ran.set() @@ -500,7 +500,8 @@ async def test_use_async_effect_cleanup(): @reactpy.component @component_hook.capture def ComponentWithAsyncEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time + # force this to run every time + @reactpy.hooks.use_async_effect(dependencies=None) async def effect(): effect_ran.set() return cleanup_ran.set @@ -527,7 +528,8 @@ async def test_use_async_effect_cancel(caplog): @reactpy.component @component_hook.capture def ComponentWithLongWaitingEffect(): - @reactpy.hooks.use_effect(dependencies=None) # force this to run every time + # force this to run every time + @reactpy.hooks.use_async_effect(dependencies=None) async def effect(): effect_ran.set() try: diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 234b00e9c..8b38bc825 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -12,7 +12,7 @@ from reactpy import html from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG from reactpy.core.component import component -from reactpy.core.hooks import use_effect, use_state +from reactpy.core.hooks import use_async_effect, use_effect, use_state from reactpy.core.layout import Layout from reactpy.testing import ( HookCatcher, @@ -1016,7 +1016,7 @@ def Parent(): def Child(child_key): state, set_state = use_state(0) - @use_effect + @use_async_effect async def record_if_state_is_reset(): if state: return diff --git a/tests/test_html.py b/tests/test_html.py index fe046c49e..151857a57 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -17,7 +17,7 @@ def on_click(event): set_count(count + 1) return html.div( - html.div({"id": "mount-count", "dataValue": 0}), + html.div({"id": "mount-count", "data-value": 0}), html.script( f'document.getElementById("mount-count").setAttribute("data-value", {count});' ), @@ -57,7 +57,7 @@ def HasScript(): return html.div() else: return html.div( - html.div({"id": "run-count", "dataValue": 0}), + html.div({"id": "run-count", "data-value": 0}), html.script( { "src": f"/reactpy/modules/{file_name_template.format(src_id=src_id)}" From ee2d44f62bbce8f50f1f411b6ea74834fe83568c Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 2 Feb 2025 16:21:05 -0800 Subject: [PATCH 04/17] Allow user defined routes in `ReactPy()` (#1265) --- pyproject.toml | 12 ---- src/reactpy/asgi/middleware.py | 35 +++++++-- src/reactpy/asgi/standalone.py | 103 ++++++++++++++++++++++++++- src/reactpy/testing/backend.py | 2 +- src/reactpy/types.py | 73 ++++++++++++++++++- tests/test_asgi/test_standalone.py | 109 +++++++++++++++++++++++++++++ 6 files changed, 311 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4ca1a411a..7794b65d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -256,7 +256,6 @@ exclude_also = [ ] [tool.ruff] -target-version = "py39" line-length = 88 lint.select = [ "A", @@ -328,13 +327,6 @@ lint.unfixable = [ [tool.ruff.lint.isort] known-first-party = ["reactpy"] -[tool.ruff.lint.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.flake8] -select = ["RPY"] # only need to check with reactpy-flake8 -exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"] - [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports "**/tests/**/*" = ["PLR2004", "S101", "TID252"] @@ -350,7 +342,3 @@ exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"] # Allow print "T201", ] - -[tool.black] -target-version = ["py39"] -line-length = 88 diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index ef108b3f4..5cce555d1 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -21,13 +21,21 @@ from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor +from reactpy.types import ( + AsgiApp, + AsgiHttpApp, + AsgiLifespanApp, + AsgiWebsocketApp, + Connection, + Location, + ReactPyConfig, + RootComponentConstructor, +) _logger = logging.getLogger(__name__) class ReactPyMiddleware: - _asgi_single_callable: bool = True root_component: RootComponentConstructor | None = None root_components: dict[str, RootComponentConstructor] multiple_root_components: bool = True @@ -73,8 +81,13 @@ def __init__( 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] = {} + self.extra_ws_routes: dict[str, AsgiWebsocketApp] = {} + self.extra_lifespan_app: AsgiLifespanApp | None = None + # Component attributes - self.user_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore + self.asgi_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore self.root_components = import_components(root_components) # Directory attributes @@ -106,8 +119,13 @@ async def __call__( if scope["type"] == "http" and self.match_web_modules_path(scope): return await self.web_modules_app(scope, receive, send) + # URL routing for user-defined routes + matched_app = self.match_extra_paths(scope) + if matched_app: + return await matched_app(scope, receive, send) # type: ignore + # Serve the user's application - await self.user_app(scope, receive, send) + await self.asgi_app(scope, receive, send) def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: return bool(re.match(self.dispatcher_pattern, scope["path"])) @@ -118,6 +136,11 @@ def match_static_path(self, scope: asgi_types.HTTPScope) -> bool: def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool: return bool(re.match(self.js_modules_pattern, scope["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. + return None + @dataclass class ComponentDispatchApp: @@ -223,7 +246,7 @@ async def __call__( """ASGI app for ReactPy static files.""" if not self._static_file_server: self._static_file_server = ServeStaticASGI( - self.parent.user_app, + self.parent.asgi_app, root=self.parent.static_dir, prefix=self.parent.static_path, ) @@ -245,7 +268,7 @@ async def __call__( """ASGI app for ReactPy web modules.""" if not self._static_file_server: self._static_file_server = ServeStaticASGI( - self.parent.user_app, + self.parent.asgi_app, root=self.parent.web_modules_dir, prefix=self.parent.web_modules_path, autorefresh=True, diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index 3f7692045..2ff1eb289 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -6,14 +6,28 @@ from datetime import datetime, timezone from email.utils import formatdate from logging import getLogger +from typing import Callable, Literal, cast, overload from asgiref import typing as asgi_types from typing_extensions import Unpack from reactpy import html from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.utils import dict_to_byte_list, http_response, vdom_head_to_html -from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict +from reactpy.asgi.utils import ( + dict_to_byte_list, + http_response, + import_dotted_path, + vdom_head_to_html, +) +from reactpy.types import ( + AsgiApp, + AsgiHttpApp, + AsgiLifespanApp, + AsgiWebsocketApp, + ReactPyConfig, + RootComponentConstructor, + VdomDict, +) from reactpy.utils import render_mount_template _logger = getLogger(__name__) @@ -34,7 +48,7 @@ def __init__( """ReactPy's standalone ASGI application. Parameters: - root_component: The root component to render. This component is assumed to be a single page application. + root_component: The root component to render. This app is typically a single page application. 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. @@ -51,6 +65,89 @@ 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 + def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None: + """Method override to match user-provided HTTP/Websocket routes.""" + if scope["type"] == "lifespan": + return self.extra_lifespan_app + + if scope["type"] == "http": + routing_dictionary = self.extra_http_routes.items() + + if scope["type"] == "websocket": + routing_dictionary = self.extra_ws_routes.items() # type: ignore + + return next( + ( + app + for route, app in routing_dictionary + if re.match(route, scope["path"]) + ), + None, + ) + + @overload + def route( + self, + path: str, + type: Literal["http"] = "http", + ) -> Callable[[AsgiHttpApp | str], AsgiApp]: ... + + @overload + def route( + self, + path: str, + type: Literal["websocket"], + ) -> Callable[[AsgiWebsocketApp | str], AsgiApp]: ... + + def route( + self, + path: str, + type: Literal["http", "websocket"] = "http", + ) -> ( + Callable[[AsgiHttpApp | str], AsgiApp] + | Callable[[AsgiWebsocketApp | str], AsgiApp] + ): + """Interface that allows user to define their own HTTP/Websocket routes + within the current ReactPy application. + + Parameters: + path: The URL route to match, using regex format. + type: The protocol to route for. Can be 'http' or 'websocket'. + """ + + def decorator( + app: AsgiApp | str, + ) -> AsgiApp: + re_path = path + if not re_path.startswith("^"): + re_path = f"^{re_path}" + if not re_path.endswith("$"): + re_path = f"{re_path}$" + + asgi_app: AsgiApp = import_dotted_path(app) if isinstance(app, str) else app + if type == "http": + self.extra_http_routes[re_path] = cast(AsgiHttpApp, asgi_app) + elif type == "websocket": + self.extra_ws_routes[re_path] = cast(AsgiWebsocketApp, asgi_app) + + return asgi_app + + return decorator + + def lifespan(self, app: AsgiLifespanApp | str) -> None: + """Interface that allows user to define their own lifespan app + within the current ReactPy application. + + Parameters: + app: The ASGI application to route to. + """ + if self.extra_lifespan_app: + raise ValueError("Only one lifespan app can be defined.") + + self.extra_lifespan_app = ( + import_dotted_path(app) if isinstance(app, str) else app + ) + @dataclass class ReactPyApp: diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 1f6521e92..439513755 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -140,7 +140,7 @@ async def __aexit__( raise LogAssertionError(msg) from logged_errors[0] await asyncio.wait_for( - self.webserver.shutdown(), timeout=60 if GITHUB_ACTIONS else 5 + self.webserver.shutdown(), timeout=90 if GITHUB_ACTIONS else 5 ) async def restart(self) -> None: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 986ac36b7..ee4e67776 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 Mapping, Sequence +from collections.abc import Awaitable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType @@ -15,6 +15,7 @@ NamedTuple, Protocol, TypeVar, + Union, overload, runtime_checkable, ) @@ -296,3 +297,73 @@ class ReactPyConfig(TypedDict, total=False): async_rendering: bool debug: bool tests_default_timeout: int + + +AsgiHttpReceive = Callable[ + [], + Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent], +] + +AsgiHttpSend = Callable[ + [ + asgi_types.HTTPResponseStartEvent + | asgi_types.HTTPResponseBodyEvent + | asgi_types.HTTPResponseTrailersEvent + | asgi_types.HTTPServerPushEvent + | asgi_types.HTTPDisconnectEvent + ], + Awaitable[None], +] + +AsgiWebsocketReceive = Callable[ + [], + Awaitable[ + asgi_types.WebSocketConnectEvent + | asgi_types.WebSocketDisconnectEvent + | asgi_types.WebSocketReceiveEvent + ], +] + +AsgiWebsocketSend = Callable[ + [ + asgi_types.WebSocketAcceptEvent + | asgi_types.WebSocketSendEvent + | asgi_types.WebSocketResponseStartEvent + | asgi_types.WebSocketResponseBodyEvent + | asgi_types.WebSocketCloseEvent + ], + Awaitable[None], +] + +AsgiLifespanReceive = Callable[ + [], + Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], +] + +AsgiLifespanSend = Callable[ + [ + asgi_types.LifespanStartupCompleteEvent + | asgi_types.LifespanStartupFailedEvent + | asgi_types.LifespanShutdownCompleteEvent + | asgi_types.LifespanShutdownFailedEvent + ], + Awaitable[None], +] + +AsgiHttpApp = Callable[ + [asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend], + Awaitable[None], +] + +AsgiWebsocketApp = Callable[ + [asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], + Awaitable[None], +] + +AsgiLifespanApp = Callable[ + [asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend], + Awaitable[None], +] + + +AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp] diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index c94ee96c1..47b0ae492 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -1,11 +1,13 @@ from collections.abc import MutableMapping import pytest +from asgiref.testing import ApplicationCommunicator from requests import request import reactpy from reactpy import html from reactpy.asgi.standalone import ReactPy +from reactpy.asgi.utils import http_response from reactpy.testing import BackendFixture, DisplayFixture, poll from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.types import Connection, Location @@ -161,3 +163,110 @@ def sample(): 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(): + @reactpy.component + def sample(): + return html.h1("Hello World") + + app = ReactPy(sample) + rendered = reactpy.Ref(False) + + @app.route("/example/") + async def custom_http_app(scope, receive, send) -> None: + if scope["type"] != "http": + raise ValueError("Custom HTTP app received a non-HTTP scope") + + rendered.current = True + await http_response(send=send, method=scope["method"], message="Hello World") + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": "/example/", + "raw_path": b"/example/", + "query_string": b"", + "root_path": "", + "headers": [], + } + + # Test that the custom HTTP app is called + communicator = ApplicationCommunicator(app, scope) + await communicator.send_input(scope) + await communicator.receive_output() + assert rendered.current + + +async def test_custom_websocket_app(): + @reactpy.component + def sample(): + return html.h1("Hello World") + + app = ReactPy(sample) + rendered = reactpy.Ref(False) + + @app.route("/example/", type="websocket") + async def custom_websocket_app(scope, receive, send) -> None: + if scope["type"] != "websocket": + raise ValueError("Custom WebSocket app received a non-WebSocket scope") + + rendered.current = True + await send({"type": "websocket.accept"}) + + scope = { + "type": "websocket", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "scheme": "ws", + "path": "/example/", + "raw_path": b"/example/", + "query_string": b"", + "root_path": "", + "headers": [], + "subprotocols": [], + } + + # Test that the WebSocket app is called + communicator = ApplicationCommunicator(app, scope) + await communicator.send_input(scope) + await communicator.receive_output() + assert rendered.current + + +async def test_custom_lifespan_app(): + @reactpy.component + def sample(): + return html.h1("Hello World") + + app = ReactPy(sample) + rendered = reactpy.Ref(False) + + @app.lifespan + async def custom_lifespan_app(scope, receive, send) -> None: + if scope["type"] != "lifespan": + raise ValueError("Custom Lifespan app received a non-Lifespan scope") + + rendered.current = True + await send({"type": "lifespan.startup.complete"}) + + scope = { + "type": "lifespan", + "asgi": {"version": "3.0"}, + } + + # Test that the lifespan app is called + communicator = ApplicationCommunicator(app, scope) + await communicator.send_input(scope) + await communicator.receive_output() + assert rendered.current + + # Test if error is raised when re-registering a lifespan app + with pytest.raises(ValueError): + + @app.lifespan + async def custom_lifespan_app2(scope, receive, send) -> None: + pass From 1069928ec3c9acb1d3d3906eb2c4e8a4a36b9003 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 2 Feb 2025 17:20:44 -0800 Subject: [PATCH 05/17] Run test webserver within main async loop (#1266) --- src/reactpy/testing/backend.py | 10 ++++------ tests/test_asgi/test_standalone.py | 5 +++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index 439513755..94f85687c 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -3,7 +3,6 @@ import asyncio import logging from contextlib import AsyncExitStack -from threading import Thread from types import TracebackType from typing import Any, Callable from urllib.parse import urlencode, urlunparse @@ -16,7 +15,6 @@ 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.testing.common import GITHUB_ACTIONS from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, @@ -121,7 +119,8 @@ async def __aenter__(self) -> BackendFixture: self.log_records = self._exit_stack.enter_context(capture_reactpy_logs()) # Wait for the server to start - Thread(target=self.webserver.run, daemon=True).start() + self.webserver.config.setup_event_loop() + self.webserver_task = asyncio.create_task(self.webserver.serve()) await asyncio.sleep(1) return self @@ -139,9 +138,8 @@ async def __aexit__( msg = "Unexpected logged exception" raise LogAssertionError(msg) from logged_errors[0] - await asyncio.wait_for( - self.webserver.shutdown(), timeout=90 if GITHUB_ACTIONS else 5 - ) + await self.webserver.shutdown() + self.webserver_task.cancel() async def restart(self) -> None: """Restart the server""" diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 47b0ae492..954a33470 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import MutableMapping import pytest @@ -155,8 +156,8 @@ def sample(): async with DisplayFixture(backend=server, driver=page) as new_display: await new_display.show(sample) url = f"http://{server.host}:{server.port}" - response = request( - "HEAD", url, timeout=REACTPY_TESTS_DEFAULT_TIMEOUT.current + 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" From 1a221c8e846e8146ad7af02705a091c9d70a5df5 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 3 Feb 2025 03:22:48 -0800 Subject: [PATCH 06/17] Better async effect shutdown behavior (#1267) --- docs/source/about/changelog.rst | 1 + pyproject.toml | 6 +- src/reactpy/__init__.py | 4 +- src/reactpy/core/hooks.py | 112 ++++++++++++++++++++------------ 4 files changed, 77 insertions(+), 46 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 7e4119f0b..9870c2b01 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -22,6 +22,7 @@ Unreleased - :pull:`1113` - Added ``standard``, ``uvicorn``, ``jinja`` installation extras (for example ``pip install reactpy[standard]``). - :pull:`1113` - Added support for Python 3.12 and 3.13. - :pull:`1264` - Added ``reactpy.use_async_effect`` hook. +- :pull:`1267` - Added ``shutdown_timeout`` parameter to the ``reactpy.use_async_effect`` hook. **Changed** diff --git a/pyproject.toml b/pyproject.toml index 7794b65d7..3ba74163f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,15 +93,13 @@ testing = ["playwright"] [tool.hatch.envs.hatch-test] extra-dependencies = [ "pytest-sugar", - "pytest-asyncio>=0.23", - "pytest-timeout", - "coverage[toml]>=6.5", + "pytest-asyncio", "responses", "playwright", "jsonpointer", "uvicorn[standard]", "jinja2-simple-tags", - "jinja2 >=3", + "jinja2", "starlette", ] diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index a184905a6..258cd5053 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -7,6 +7,7 @@ from reactpy.core.events import event from reactpy.core.hooks import ( create_context, + use_async_effect, use_callback, use_connection, use_context, @@ -24,7 +25,7 @@ from reactpy.utils import Ref, html_to_vdom, vdom_to_html __author__ = "The Reactive Python Team" -__version__ = "2.0.0a0" +__version__ = "2.0.0a1" __all__ = [ "Layout", @@ -41,6 +42,7 @@ "html_to_vdom", "logging", "types", + "use_async_effect", "use_callback", "use_connection", "use_context", diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 5a7cf0460..70f72268d 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -30,6 +30,7 @@ __all__ = [ + "use_async_effect", "use_callback", "use_effect", "use_memo", @@ -119,7 +120,12 @@ def use_effect( function: _SyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., ) -> Callable[[_SyncEffectFunc], None] | None: - """See the full :ref:`Use Effect` docs for details + """ + A hook that manages an synchronous side effect in a React-like component. + + This hook allows you to run a synchronous function as a side effect and + ensures that the effect is properly cleaned up when the component is + re-rendered or unmounted. Parameters: function: @@ -136,31 +142,38 @@ def use_effect( hook = current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) + cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _SyncEffectFunc) -> None: + def decorator(func: _SyncEffectFunc) -> None: async def effect(stop: asyncio.Event) -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() - last_clean_callback.current = None - clean = last_clean_callback.current = function() + # Since the effect is asynchronous, we need to make sure we + # always clean up the previous effect's resources + run_effect_cleanup(cleanup_func) + + # Execute the effect and store the clean-up function + cleanup_func.current = func() + + # Wait until we get the signal to stop this effect await stop.wait() - if clean is not None: - clean() + + # Run the clean-up function when the effect is stopped, + # if it hasn't been run already by a new effect + run_effect_cleanup(cleanup_func) return memoize(lambda: hook.add_effect(effect)) - if function is not None: - add_effect(function) + # Handle decorator usage + if function: + decorator(function) return None - - return add_effect + return decorator @overload def use_async_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, ) -> Callable[[_EffectApplyFunc], None]: ... @@ -168,16 +181,23 @@ def use_async_effect( def use_async_effect( function: _AsyncEffectFunc, dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, ) -> None: ... def use_async_effect( function: _AsyncEffectFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., + shutdown_timeout: float = 0.1, ) -> Callable[[_AsyncEffectFunc], None] | None: - """See the full :ref:`Use Effect` docs for details + """ + A hook that manages an asynchronous side effect in a React-like component. - Parameters: + This hook allows you to run an asynchronous function as a side effect and + ensures that the effect is properly cleaned up when the component is + re-rendered or unmounted. + + Args: function: Applies the effect and can return a clean-up function dependencies: @@ -185,6 +205,9 @@ def use_async_effect( of any value in the given sequence changes (i.e. their :func:`id` is different). By default these are inferred based on local variables that are referenced by the given function. + shutdown_timeout: + The amount of time (in seconds) to wait for the effect to complete before + forcing a shutdown. Returns: If not function is provided, a decorator. Otherwise ``None``. @@ -192,40 +215,41 @@ def use_async_effect( hook = current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) + cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) - def add_effect(function: _AsyncEffectFunc) -> None: - def sync_executor() -> _EffectCleanFunc | None: - task = asyncio.create_task(function()) - - def clean_future() -> None: - if not task.cancel(): - try: - clean = task.result() - except asyncio.CancelledError: - pass - else: - if clean is not None: - clean() + def decorator(func: _AsyncEffectFunc) -> None: + async def effect(stop: asyncio.Event) -> None: + # Since the effect is asynchronous, we need to make sure we + # always clean up the previous effect's resources + run_effect_cleanup(cleanup_func) - return clean_future + # Execute the effect in a background task + task = asyncio.create_task(func()) - async def effect(stop: asyncio.Event) -> None: - if last_clean_callback.current is not None: - last_clean_callback.current() - last_clean_callback.current = None - clean = last_clean_callback.current = sync_executor() + # Wait until we get the signal to stop this effect await stop.wait() - if clean is not None: - clean() + + # If renders are queued back-to-back, the effect might not have + # completed. So, we give the task a small amount of time to finish. + # If it manages to finish, we can obtain a clean-up function. + results, _ = await asyncio.wait([task], timeout=shutdown_timeout) + if results: + cleanup_func.current = results.pop().result() + + # Run the clean-up function when the effect is stopped, + # if it hasn't been run already by a new effect + run_effect_cleanup(cleanup_func) + + # Cancel the task if it's still running + task.cancel() return memoize(lambda: hook.add_effect(effect)) - if function is not None: - add_effect(function) + # Handle decorator usage + if function: + decorator(function) return None - - return add_effect + return decorator def use_debug_value( @@ -595,3 +619,9 @@ def strictly_equal(x: Any, y: Any) -> bool: # Fallback to identity check return x is y # pragma: no cover + + +def run_effect_cleanup(cleanup_func: Ref[_EffectCleanFunc | None]) -> None: + if cleanup_func.current: + cleanup_func.current() + cleanup_func.current = None From 49bdda1afaa9d036161a1993c30c3d2b2908a8fb Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 4 Feb 2025 16:41:53 -0800 Subject: [PATCH 07/17] Use ASGI-Tools for HTTP/WS handling (#1268) --- docs/source/about/changelog.rst | 1 + pyproject.toml | 1 + src/reactpy/asgi/middleware.py | 93 ++++++++++++++++++------------ src/reactpy/asgi/standalone.py | 37 ++++-------- src/reactpy/asgi/utils.py | 43 -------------- tests/test_asgi/test_standalone.py | 5 +- 6 files changed, 71 insertions(+), 109 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 9870c2b01..848153251 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -34,6 +34,7 @@ Unreleased - :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``. - :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``. - :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. +- :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``. - :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format. **Removed** diff --git a/pyproject.toml b/pyproject.toml index 3ba74163f..c485dce2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "lxml >=4", "servestatic >=3.0.0", "orjson >=3", + "asgi-tools", ] dynamic = ["version"] urls.Changelog = "https://reactpy.dev/docs/about/changelog.html" diff --git a/src/reactpy/asgi/middleware.py b/src/reactpy/asgi/middleware.py index 5cce555d1..b61df48a7 100644 --- a/src/reactpy/asgi/middleware.py +++ b/src/reactpy/asgi/middleware.py @@ -11,6 +11,7 @@ from typing import Any import orjson +from asgi_tools import ResponseWebSocket from asgiref import typing as asgi_types from asgiref.compatibility import guarantee_single_callable from servestatic import ServeStaticASGI @@ -26,6 +27,8 @@ AsgiHttpApp, AsgiLifespanApp, AsgiWebsocketApp, + AsgiWebsocketReceive, + AsgiWebsocketSend, Connection, Location, ReactPyConfig, @@ -153,41 +156,56 @@ async def __call__( send: asgi_types.ASGISendCallable, ) -> None: """ASGI app for rendering ReactPy Python components.""" - dispatcher: asyncio.Task[Any] | None = None - recv_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - # Start a loop that handles ASGI websocket events - while True: - event = await receive() - if event["type"] == "websocket.connect": - await send( - {"type": "websocket.accept", "subprotocol": None, "headers": []} - ) - dispatcher = asyncio.create_task( - self.run_dispatcher(scope, receive, send, recv_queue) - ) - - elif event["type"] == "websocket.disconnect": - if dispatcher: - dispatcher.cancel() - break - - elif event["type"] == "websocket.receive" and event["text"]: - queue_put_func = recv_queue.put(orjson.loads(event["text"])) - await queue_put_func - - async def run_dispatcher( + async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: # type: ignore + while True: + # Wait for the webserver to notify us of a new event + event: dict[str, Any] = await ws.receive(raw=True) # type: ignore + + # If the event is a `receive` event, parse the message and send it to the rendering queue + if event["type"] == "websocket.receive": + msg: dict[str, str] = orjson.loads(event["text"]) + if msg.get("type") == "layout-event": + await ws.rendering_queue.put(msg) + else: # pragma: no cover + await asyncio.to_thread( + _logger.warning, f"Unknown message type: {msg.get('type')}" + ) + + # If the event is a `disconnect` event, break the rendering loop and close the connection + elif event["type"] == "websocket.disconnect": + break + + +class ReactPyWebsocket(ResponseWebSocket): + def __init__( self, scope: asgi_types.WebSocketScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, - recv_queue: asyncio.Queue[dict[str, Any]], + receive: AsgiWebsocketReceive, + send: AsgiWebsocketSend, + parent: ReactPyMiddleware, ) -> None: - """Asyncio background task that renders and transmits layout updates of ReactPy components.""" + super().__init__(scope=scope, receive=receive, send=send) # type: ignore + self.scope = scope + self.parent = parent + self.rendering_queue: asyncio.Queue[dict[str, str]] = asyncio.Queue() + self.dispatcher: asyncio.Task[Any] | None = None + + async def __aenter__(self) -> ReactPyWebsocket: + self.dispatcher = asyncio.create_task(self.run_dispatcher()) + return await super().__aenter__() # type: ignore + + async def __aexit__(self, *_: Any) -> None: + if self.dispatcher: + self.dispatcher.cancel() + await super().__aexit__() # type: ignore + + async def run_dispatcher(self) -> None: + """Async background task that renders ReactPy components over a websocket.""" try: # Determine component to serve by analyzing the URL and/or class parameters. if self.parent.multiple_root_components: - url_match = re.match(self.parent.dispatcher_pattern, scope["path"]) + url_match = re.match(self.parent.dispatcher_pattern, self.scope["path"]) if not url_match: # pragma: no cover raise RuntimeError("Could not find component in URL path.") dotted_path = url_match["dotted_path"] @@ -203,10 +221,10 @@ async def run_dispatcher( # Create a connection object by analyzing the websocket's query string. ws_query_string = urllib.parse.parse_qs( - scope["query_string"].decode(), strict_parsing=True + self.scope["query_string"].decode(), strict_parsing=True ) connection = Connection( - scope=scope, + scope=self.scope, location=Location( path=ws_query_string.get("http_pathname", [""])[0], query_string=ws_query_string.get("http_query_string", [""])[0], @@ -217,20 +235,19 @@ async def run_dispatcher( # Start the ReactPy component rendering loop await serve_layout( Layout(ConnectionContext(component(), value=connection)), - lambda msg: send( - { - "type": "websocket.send", - "text": orjson.dumps(msg).decode(), - "bytes": None, - } - ), - recv_queue.get, # type: ignore + self.send_json, + self.rendering_queue.get, # type: ignore ) # Manually log exceptions since this function is running in a separate asyncio task. except Exception as error: await asyncio.to_thread(_logger.error, f"{error}\n{traceback.format_exc()}") + async def send_json(self, data: Any) -> None: + return await self._send( + {"type": "websocket.send", "text": orjson.dumps(data).decode()} + ) + @dataclass class StaticFileApp: diff --git a/src/reactpy/asgi/standalone.py b/src/reactpy/asgi/standalone.py index 2ff1eb289..1f1298396 100644 --- a/src/reactpy/asgi/standalone.py +++ b/src/reactpy/asgi/standalone.py @@ -8,17 +8,13 @@ from logging import getLogger from typing import Callable, Literal, cast, overload +from asgi_tools import ResponseHTML from asgiref import typing as asgi_types from typing_extensions import Unpack from reactpy import html from reactpy.asgi.middleware import ReactPyMiddleware -from reactpy.asgi.utils import ( - dict_to_byte_list, - http_response, - import_dotted_path, - vdom_head_to_html, -) +from reactpy.asgi.utils import import_dotted_path, vdom_head_to_html from reactpy.types import ( AsgiApp, AsgiHttpApp, @@ -40,7 +36,7 @@ def __init__( self, root_component: RootComponentConstructor, *, - http_headers: dict[str, str | int] | None = None, + http_headers: dict[str, str] | None = None, html_head: VdomDict | None = None, html_lang: str = "en", **settings: Unpack[ReactPyConfig], @@ -182,23 +178,20 @@ async def __call__( # Response headers for `index.html` responses request_headers = dict(scope["headers"]) - response_headers: dict[str, str | int] = { + response_headers: dict[str, str] = { "etag": self._etag, "last-modified": self._last_modified, "access-control-allow-origin": "*", "cache-control": "max-age=60, public", - "content-length": len(self._cached_index_html), + "content-length": str(len(self._cached_index_html)), "content-type": "text/html; charset=utf-8", **self.parent.extra_headers, } # Browser is asking for the headers if scope["method"] == "HEAD": - return await http_response( - send=send, - method=scope["method"], - headers=dict_to_byte_list(response_headers), - ) + response = ResponseHTML("", headers=response_headers) + return await response(scope, receive, send) # type: ignore # Browser already has the content cached if ( @@ -206,20 +199,12 @@ async def __call__( or request_headers.get(b"if-modified-since") == self._last_modified.encode() ): response_headers.pop("content-length") - return await http_response( - send=send, - method=scope["method"], - code=304, - headers=dict_to_byte_list(response_headers), - ) + response = ResponseHTML("", headers=response_headers, status_code=304) + return await response(scope, receive, send) # type: ignore # Send the index.html - await http_response( - send=send, - method=scope["method"], - message=self._cached_index_html, - headers=dict_to_byte_list(response_headers), - ) + response = ResponseHTML(self._cached_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.""" diff --git a/src/reactpy/asgi/utils.py b/src/reactpy/asgi/utils.py index fe4f1ef64..85ad56056 100644 --- a/src/reactpy/asgi/utils.py +++ b/src/reactpy/asgi/utils.py @@ -5,8 +5,6 @@ from importlib import import_module from typing import Any -from asgiref import typing as asgi_types - from reactpy._option import Option from reactpy.types import ReactPyConfig, VdomDict from reactpy.utils import vdom_to_html @@ -55,18 +53,6 @@ def check_path(url_path: str) -> str: # pragma: no cover return "" -def dict_to_byte_list( - data: dict[str, str | int], -) -> list[tuple[bytes, bytes]]: - """Convert a dictionary to a list of byte tuples.""" - result: list[tuple[bytes, bytes]] = [] - for key, value in data.items(): - new_key = key.encode() - new_value = value.encode() if isinstance(value, str) else str(value).encode() - result.append((new_key, new_value)) - return result - - def vdom_head_to_html(head: VdomDict) -> str: if isinstance(head, dict) and head.get("tagName") == "head": return vdom_to_html(head) @@ -76,35 +62,6 @@ def vdom_head_to_html(head: VdomDict) -> str: ) -async def http_response( - *, - send: asgi_types.ASGISendCallable, - method: str, - code: int = 200, - message: str = "", - headers: Iterable[tuple[bytes, bytes]] = (), -) -> None: - """Sends a HTTP response using the ASGI `send` API.""" - start_msg: asgi_types.HTTPResponseStartEvent = { - "type": "http.response.start", - "status": code, - "headers": [*headers], - "trailers": False, - } - body_msg: asgi_types.HTTPResponseBodyEvent = { - "type": "http.response.body", - "body": b"", - "more_body": False, - } - - # Add the content type and body to everything other than a HEAD request - if method != "HEAD": - body_msg["body"] = message.encode() - - await send(start_msg) - await send(body_msg) - - def process_settings(settings: ReactPyConfig) -> None: """Process the settings and return the final configuration.""" from reactpy import config diff --git a/tests/test_asgi/test_standalone.py b/tests/test_asgi/test_standalone.py index 954a33470..8d5fdee45 100644 --- a/tests/test_asgi/test_standalone.py +++ b/tests/test_asgi/test_standalone.py @@ -2,13 +2,13 @@ from collections.abc import MutableMapping import pytest +from asgi_tools import ResponseText from asgiref.testing import ApplicationCommunicator from requests import request import reactpy from reactpy import html from reactpy.asgi.standalone import ReactPy -from reactpy.asgi.utils import http_response from reactpy.testing import BackendFixture, DisplayFixture, poll from reactpy.testing.common import REACTPY_TESTS_DEFAULT_TIMEOUT from reactpy.types import Connection, Location @@ -180,7 +180,8 @@ async def custom_http_app(scope, receive, send) -> None: raise ValueError("Custom HTTP app received a non-HTTP scope") rendered.current = True - await http_response(send=send, method=scope["method"], message="Hello World") + response = ResponseText("Hello World") + await response(scope, receive, send) scope = { "type": "http", From babc2de2d1e96f3688da9a37c1e37327bd86d7ae Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 9 Feb 2025 20:21:55 -0800 Subject: [PATCH 08/17] 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 --- .gitignore | 4 +- docs/source/about/changelog.rst | 10 +- pyproject.toml | 40 ++- src/js/packages/@reactpy/app/bun.lockb | Bin 17643 -> 26230 bytes src/js/packages/@reactpy/app/package.json | 6 +- src/js/packages/@reactpy/client/src/mount.tsx | 2 +- src/js/packages/@reactpy/client/src/types.ts | 2 +- src/reactpy/__init__.py | 9 +- src/reactpy/core/hooks.py | 7 +- src/reactpy/{asgi => executors}/__init__.py | 0 src/reactpy/executors/asgi/__init__.py | 5 + .../{ => executors}/asgi/middleware.py | 40 +-- src/reactpy/executors/asgi/pyscript.py | 123 +++++++++ .../{ => executors}/asgi/standalone.py | 49 ++-- src/reactpy/executors/asgi/types.py | 77 ++++++ src/reactpy/{asgi => executors}/utils.py | 50 ++-- src/reactpy/jinja.py | 21 -- src/reactpy/logging.py | 8 +- src/reactpy/pyscript/__init__.py | 0 src/reactpy/pyscript/component_template.py | 28 +++ src/reactpy/pyscript/components.py | 62 +++++ src/reactpy/pyscript/layout_handler.py | 159 ++++++++++++ src/reactpy/pyscript/utils.py | 236 ++++++++++++++++++ src/reactpy/static/pyscript-hide-debug.css | 3 + src/reactpy/templatetags/__init__.py | 3 + src/reactpy/templatetags/jinja.py | 42 ++++ src/reactpy/testing/backend.py | 10 +- src/reactpy/testing/display.py | 9 - src/reactpy/types.py | 78 +----- src/reactpy/utils.py | 38 +-- tests/templates/index.html | 1 - tests/templates/jinja_bad_kwargs.html | 10 + tests/templates/pyscript.html | 12 + .../pyscript_components/load_first.py | 11 + .../pyscript_components/load_second.py | 16 ++ tests/test_asgi/pyscript_components/root.py | 16 ++ tests/test_asgi/test_middleware.py | 53 +++- tests/test_asgi/test_pyscript.py | 112 +++++++++ tests/test_asgi/test_standalone.py | 29 +-- tests/test_asgi/test_utils.py | 19 +- tests/test_pyscript/__init__.py | 0 .../pyscript_components/custom_root_name.py | 16 ++ .../test_pyscript/pyscript_components/root.py | 16 ++ tests/test_pyscript/test_components.py | 71 ++++++ tests/test_pyscript/test_utils.py | 59 +++++ tests/test_utils.py | 47 ++-- tests/test_web/test_module.py | 2 +- 47 files changed, 1320 insertions(+), 291 deletions(-) rename src/reactpy/{asgi => executors}/__init__.py (100%) create mode 100644 src/reactpy/executors/asgi/__init__.py rename src/reactpy/{ => executors}/asgi/middleware.py (90%) create mode 100644 src/reactpy/executors/asgi/pyscript.py rename src/reactpy/{ => executors}/asgi/standalone.py (80%) create mode 100644 src/reactpy/executors/asgi/types.py rename src/reactpy/{asgi => executors}/utils.py (60%) delete mode 100644 src/reactpy/jinja.py create mode 100644 src/reactpy/pyscript/__init__.py create mode 100644 src/reactpy/pyscript/component_template.py create mode 100644 src/reactpy/pyscript/components.py create mode 100644 src/reactpy/pyscript/layout_handler.py create mode 100644 src/reactpy/pyscript/utils.py create mode 100644 src/reactpy/static/pyscript-hide-debug.css create mode 100644 src/reactpy/templatetags/__init__.py create mode 100644 src/reactpy/templatetags/jinja.py create mode 100644 tests/templates/jinja_bad_kwargs.html create mode 100644 tests/templates/pyscript.html create mode 100644 tests/test_asgi/pyscript_components/load_first.py create mode 100644 tests/test_asgi/pyscript_components/load_second.py create mode 100644 tests/test_asgi/pyscript_components/root.py create mode 100644 tests/test_asgi/test_pyscript.py create mode 100644 tests/test_pyscript/__init__.py create mode 100644 tests/test_pyscript/pyscript_components/custom_root_name.py create mode 100644 tests/test_pyscript/pyscript_components/root.py create mode 100644 tests/test_pyscript/test_components.py create mode 100644 tests/test_pyscript/test_utils.py 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 b32e03eae6a437553d4641f596bf6f470dc78bb9..bd09c30d60ad03ffc5377a84c0cafdda0a5ca59e 100644 GIT binary patch delta 8178 zcmd@(X;@UpvgZuLGRi7D1G0&LFvBtoh{&cWDiA=4xXU;U0s@1~prD3T8$gI zay3CsG%8+=Brd24F1W9_;(`&yxL{o3l2>)+92mX&eeb>Re(%rgZ>p=itGc?ns=KGV z&VxDh^G14+*!$USt223twwhM{L7CiQYo3``Q%$H()ncbQ{kf7VpQ^hGib(?&r#ky= zi_uoJxiLkR<`*bray3QeDhu*6)0MfSfn)~l&lj&u)m-CKlnqoGSiBvuHPowET)|=$ zi^s8e2w*GV3udu9U`wc*v-q7JMRkLE8;dWq_%MriYO$vDTPRon<5CvSWU&gcIkd}J zJdDLT%5<43lcFBNECfK0f+^x`#DH1+ygAquv1N;&yIPe3&Afr?zn;n?Og!tgVrec6|^IsC>Lcam6NDP zP)9n~FIy$bFHja0b9A{fU6HQHQ{Mn>1#!T576ihtbR%TwiYp)hO}iB8=!!#7w*|Zp z>e!xSvVOLt!nJhp$%m&GZH;Yq>tem&<=`VW+j(iU=4o5906vlFd6H&5Bcn@DLu){TKUu41#N*MV7e9j6k0#mt z2)c-D5~Bc5ITdj>LZ)CK_q>PWEn*AgnDF? zL4f@-(v}EQ5()b9p)iCYdUxeYDy~HD zAz8+ebUWE-94SbIg*p(7p+}zRh0x20w@IYYO=tt@TEnJw5g5Uu?+0~D@&pVvnzWik zBByC2eT-z8M)E98$tKfabb-Z4Sdn4C3a+8R29&+lTlxZX*eD}d0u2O1h5%j6i-0mn zhXU^RLRseffq=sTH-%j|f&~xOM6k{v2AnLDSfE6*6h64uhf%5=S|iw2C{kTI_$^?^ zMgav*5|GdG3zE53oUi#}sBxpglIT#wxB&=#p))KeisT#k5*I^(F>GOu8P^x1fO#mi z;u;1<3f4`B2=mE0?5G1bMg1D=K{lI8Z~}sRQ0s&JNq}jHg&Q1JF|0JSOXu`6fHHuk z=y9V9zK7OmruSt15aTwe;TZ!RaX4RQfDkxE5}}bMlMO-yVr6A{RUI z&^eCwA)ziX4;Pq+OB^jCtuCpwm_)lKYB&q|X}1lN^V9c^&p5}wJ-7JB+;6Ycgp^q= z4qi~6JatUr7x8cV&+tFn>)nt^imeHug})wd+-~?bZ12Iy-@X_+?MnUeIdA?M9#f-Z zd4FBGGqwj#8CuX*wr9*<|4K)G{)Wk#h8OEaMawIWU3I9G=HJ$TB`IlHwQBzHjF@w( zrrLyg!`uJ)aC`Mjd3uc7AA>xe=?D(c6>KQ^X0H0|)k}w74xLirbHT9w(b2ZD6J;+( zrPO#f+ogy_%Wgkaq_3C~V`SBAF{;;&T_u`v_7!LD_-brCd)wLInIn= z~9%+?60EN7R&eR2<8rN6wWW%dpC2`=ltUhWV^BvNpeMTb<_CwNJQN`-!18 zsxvK;)Ax$=-~OJPa^I|5iQA?WFJ+DGZ|&u^b9LmxWr9l@if@%H9dp%o{F8*Pnv;)< z=4LM4{#MZEiQQ)5Gk*Rr`*KH~rCz1Q>n}{-pkB|9<4qesR`2MMjnU-^LC?+&`|?QN zJvxFzII)2F18+Oe@C)a+EUK=w@yZ@lUXnJxsN(mTbyem;hecDGFIeyDeb-u(HmD+X zh9tg3asFB4xuT_1^My>a$KsQR zxi9PC0jt;-TTCBWA9Vlb%&J}M@6J&^TDJOeQ+sRT&vQ~vkK3;}RO6c&bRwX zHVjLhgO?CBRYdsEchA~{dPOM`$WbaUE$n# zaG56TGr#A{{idzCJ%2lX>L2iNg4jjY*H*c5lLx)~=Q98Ch;=+cGX%XmaY#T$_NY!qGpC zkecpzU}M?6@`{dN?oNdA`Nuq>dJiSk;4y7Aal4dDy<`E>`F6_Zks}f(yiTnZbQM@0 z7J7SKum3A~!{(ZP21QdVWE8#D*j=;6b=idF)uBl`g1Ji-BY5W3BM&BjR~Y+4oaWa9 zZ|Rnvw)fS=twFXcvfu6hEOCIO-a> zReY!a)`alv;q`R|F-wlE2%kA&`OwOKA#Z9li?WBzI{MkI??)G>tX7#+pXc4u(YT+k z;F-Igx%C?xp1W=Kf`(hJVVC@U-97)}DIrY2i{rMpmACE3xSS~R>zDskWUH|d*=Q@Jn3Dc4IvrQ{X zCrl51-4b5@xUki=aEzI9mZaP2go&$P8_quM6Y*=^05d1ko0@%hip`VvZ_cV-wZ-YR z@R@L7eap`~?Y(OMq{%k7Y4Dcelb5a=jLhNw+!bChwe=pSc-U&j#a`q&Sz>v%3b>0u z(f(?Qh%6C3uRZ0FWT3^t{4Ot9ZG#3Z5%-N{^nUlZHem(53RAKu-XC7bXDUvu4FWR2|aQ zSTKfL0xE~MD^w7lKxjX-8TLcDs4Mc|#5u!(pP9+y8;mms&Zh@i>6fZOt#B4-GBlkJ z08SmHpahhTZN6+By%Yt2Cd9)9-GXMrn+{&1(Cm1Lz-@uN(Ew2Z$crvQx1sycjo?n7 zj*i%fzC@p*Khf8CESjTpAPq?(V*i*7v@&R4%0=yX^f-Sp@-l+JN2M9(r{~Suw|O0p zkJd)s(SVUsXi)uS|JfTVd3@9g{8Y*Xc)5TeNMo*S#8K)jqo7Z7$2AMDmOB6ve5rH#A|v=8P2$T@`; zRLhhdH#_NEh*_28nKJJgb+(h@&L{B_J0Ta7K5Uru^}u_%2Ra#s5RJrM%Ehqz88nq_ zGn{VON#~;6r=p*BkJF!F-ASn+$0hbcE-L={pB2TI-|8*wq;Ds5kUiZ%ghBR>TueRo zL_xV}`=XLgF0nC53bK<%!Fog&wyj(5UzPA?9XLuN_7}ry`_Q@OxF7<=;Kuu;3Pf|! zYzJ!(i!%3NjYjmifKccfLJ3UBnV>+{!QG_}^ji`tm1yf4E|km#VVQLnfCF+tXD(dJ zP$W1Y7jEVPwQL{Q9$dhh3-z)TShrkkoQo=JDZv_XfIoL6bAe=r6pV|V3$$|~YE~TV zKQ0u{1+hCRT(F)CbF&oKhg_JR3veSvBM!nD9fAW7&aYA~1PvrGEn4Bz_Thr@To{`j z2o8!*TkhZNqtSX1=E?>Cxv)7a4wU0Q8*l-4pag<)U~cZi0T*&-`UHu=KHR4VE@%%F zTu0D{`#7Nu>uZ)aup}7he`D@XtH6ET;Jynm%E2*0Q92Hv$b52ONua_<#YwQ!`+DoD z&3rBa=dm9eApFnl44CVRBsl*dT}mR0q)~XxQ{JexUXqcez8hkkd?3YlPe1pWO(ol@@J&bmODiKM1EXR6etdDKV3i%{Ox=60fVFyx?=qO?6r--2Yr${iL0C5O;66x&y z;ZWG?gV7o80OWS>P!qKT*7xe2e7)GkfgIY;Ym5bhbqrV_o65`)r{KaGm1~1;ToL-g z-o~*toyG!sX9Y(Mh66}Pwn|-4C|4I2ps{k4d5RC*lc7+{Gv&&xJb4y=sLNF6=7ic9#GHTVm$Q*x9L5J0N#fV5P#5!Sw!^g{9i@?YT`0eYqmYhF$x~aba^JttWSW zF{5g~W1@XwR9O1?IkK!gnC(RLnXsb8`Ny}xA{et+cm)AE0~XrRZ3O`-tM#2*b6@UguO PJwDdVj3gn6F>C${x=n|c delta 2779 zcmcguYiv|S6rS1cqg%RtQrf%Q?Sr;#7iej_wB2Iq0@l*UE^Gz8AVP~g6s=Gx2n}kt z6-bGY77qp*Fxs>cMVkQSp^+fU4*^jyhJ>O-z+mt}0!E1#1i!g^Zx^B@O*Bq&&Uen7 zxifRloI7)Foa8@k;GMb7K;qi{yKY4njVU<)+L02UVPkvU#`V{3jy9ccbNrIrQ8%ZY zDJ^%ioSJbXC#VE@Bx8Zb<%{MoSizVNUD42YZ|;^`w(1!h0i{*N24DhoS;Z+T=BSvY zq8>OLT5 z`AttTwi8~MVgF-T(3>kjv0$d^-BGwKzCQ>Q-Y;lsTG8CNi1k7d8x2Mwjs^Ud-gU0_ z^gz|At6z5Ro_eAGh_0T~X?-PsoFqdrbsEAAuVT^!n(T5>j3Hcih|xJineG-NtFeq1 z&}^fJ*HagGCk+@qy2G5TrZV$2&R8B+GSEe1F;Al|lNdGtzJsK2kKv4tF*n&GOGPXn zrMck`7{UxV9!2s)nyE;=gVOJVlGmtN-yD>Fh4irUMTLp6Dn;sm_&SFiUz%(ElwPoTc^DnH$#l$Sp`7FzZlLC5nN}xT=sYwNIqfoK+by)oUc=2a z0PQTai77RF7;Q+AsXfI)SD{&`Bvs~7v>7~_egKc5$!Ri=r7rL|x(*&sRp~MxPTk-M zbQ638)sB>TBE18iL_9<063qs;(jIUdnKNacO!L9*^Z|GZ#b(Jom6m{~(Z}HFB#n~! zNO}%DgN}n|Qo2LtS=8)+9}f88sNoKBX2Xx{NX26(4M01a4L_VUd<<=H!Vf3>fR;lg zqo>mh*Fx$ncu#ji*I(v3pd*j_iGGi}G(5tc8$1ASE`3;DYQ23Y*hJ@8&}!A3J&wvA zDrgyvt}&`fSVSV@?o=)bTy;$R0OGO``IHS3c_}UnTtCbLiUOs8vOtNTG!Py;mI_J& zrGv!f5C_6z$&x{%KqEmJVre@BadSu@JT}Y^!_weU#nL_Gc?Fd`C8keEfxApSZn zIzpc%?gX^;h4lT@^6~2a0*lYAS-SS4z8;-kc!7!4=aL0h8oS#bT=(`So!$it{0dNn z&&qdFUu7%bP1n5=RaPB9THdLW3bnUeT50StPOuf|d0uXQVQ#*gX|H?tfp2{g%OaXW zmWkx6mTX!z5O$<;N#)av{ULiDwN^{Kk+x%$R!pp~AG^zTDCJUU)D}8bEfs0S$KJ?& zZFltU&X8RzPa0Onvdho!yBM;Z2&0smq(ZHp`LUj_(!KJ_lU>?a|M zo}Q_ZY+8jh#W?WN9@ComkUfXqsF4b_imLzX2?^ofxZ^^0t>${4yA@03F84TggzQ@>-7D#?45LzSl1;0Ke(vebd);@dN5|*6a`SR=(mq<|m5Q_q z=*-L8b$dIO_J#9#`LMgjGp$y#CS=97X3RJ#EF~}%v!ju8*ju0+u!|q0ET1cQ*b%1- zMf%_uDx#VGNwm$MXPt>OM&<_Ax(iS8)ls)66$OXPD5p#QQW{(PLjQ%@Rtwd-w*3Y2 Cch 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, From d5a897ebfc5338844073073e0af71849442065d7 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 10 Feb 2025 18:46:28 -0800 Subject: [PATCH 09/17] V2-migrate-mypy-to-pyright (#1274) - Change our preferred type checker from MyPy to Pyright - Don't rely on `asgiref.types` as much, since they're a bit too strict and cause a lot of type errors in use code. --- pyproject.toml | 17 +-- src/reactpy/_console/ast_utils.py | 1 + src/reactpy/_warnings.py | 4 +- src/reactpy/config.py | 8 +- src/reactpy/core/hooks.py | 20 ++- src/reactpy/core/layout.py | 28 +++-- src/reactpy/core/serve.py | 19 ++- src/reactpy/core/vdom.py | 45 +------ src/reactpy/executors/asgi/middleware.py | 65 +++++----- src/reactpy/executors/asgi/pyscript.py | 6 +- src/reactpy/executors/asgi/standalone.py | 37 +++--- src/reactpy/executors/asgi/types.py | 150 ++++++++++++++--------- src/reactpy/testing/backend.py | 8 +- src/reactpy/testing/common.py | 7 +- src/reactpy/types.py | 33 ++--- src/reactpy/utils.py | 6 +- 16 files changed, 221 insertions(+), 233 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc9804508..5725bce3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,17 +153,16 @@ serve = [ [tool.hatch.envs.python] extra-dependencies = [ "reactpy[all]", - "ruff", - "toml", - "mypy==1.8", + "pyright", "types-toml", "types-click", "types-requests", + "types-lxml", + "jsonpointer", ] [tool.hatch.envs.python.scripts] -# TODO: Replace mypy with pyright -type_check = ["mypy --strict src/reactpy"] +type_check = ["pyright src/reactpy"] ############################ # >>> Hatch JS Scripts <<< # @@ -218,12 +217,8 @@ publish_client = [ # >>> Generic Tools <<< # ######################### -[tool.mypy] -incremental = false -ignore_missing_imports = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true +[tool.pyright] +reportIncompatibleVariableOverride = false [tool.coverage.run] source_pkgs = ["reactpy"] diff --git a/src/reactpy/_console/ast_utils.py b/src/reactpy/_console/ast_utils.py index 220751119..c0ce6b224 100644 --- a/src/reactpy/_console/ast_utils.py +++ b/src/reactpy/_console/ast_utils.py @@ -1,3 +1,4 @@ +# pyright: reportAttributeAccessIssue=false from __future__ import annotations import ast diff --git a/src/reactpy/_warnings.py b/src/reactpy/_warnings.py index dc6d2fa1f..6a515e0a6 100644 --- a/src/reactpy/_warnings.py +++ b/src/reactpy/_warnings.py @@ -2,7 +2,7 @@ from functools import wraps from inspect import currentframe from types import FrameType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from warnings import warn as _warn @@ -13,7 +13,7 @@ def warn(*args: Any, **kwargs: Any) -> Any: if TYPE_CHECKING: - warn = _warn + warn = cast(Any, _warn) def _frame_depth_in_module() -> int: diff --git a/src/reactpy/config.py b/src/reactpy/config.py index be6ceb3da..993e6d8b4 100644 --- a/src/reactpy/config.py +++ b/src/reactpy/config.py @@ -42,13 +42,17 @@ def boolean(value: str | bool | int) -> bool: - :data:`REACTPY_CHECK_JSON_ATTRS` """ -REACTPY_CHECK_VDOM_SPEC = Option("REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG) +REACTPY_CHECK_VDOM_SPEC = Option( + "REACTPY_CHECK_VDOM_SPEC", parent=REACTPY_DEBUG, validator=boolean +) """Checks which ensure VDOM is rendered to spec For more info on the VDOM spec, see here: :ref:`VDOM JSON Schema` """ -REACTPY_CHECK_JSON_ATTRS = Option("REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG) +REACTPY_CHECK_JSON_ATTRS = Option( + "REACTPY_CHECK_JSON_ATTRS", parent=REACTPY_DEBUG, validator=boolean +) """Checks that all VDOM attributes are JSON serializable The VDOM spec is not able to enforce this on its own since attributes could anything. diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 8420ba1fe..8adc2a9e9 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -66,7 +66,9 @@ def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: A tuple containing the current state and a function to update it. """ current_state = _use_const(lambda: _CurrentState(initial_value)) - return State(current_state.value, current_state.dispatch) + + # FIXME: Not sure why this type hint is not being inferred correctly when using pyright + return State(current_state.value, current_state.dispatch) # type: ignore class _CurrentState(Generic[_Type]): @@ -84,10 +86,7 @@ def __init__( hook = current_hook() def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: - if callable(new): - next_value = new(self.value) - else: - next_value = new + next_value = new(self.value) if callable(new) else new # type: ignore if not strictly_equal(next_value, self.value): self.value = next_value hook.schedule_render() @@ -338,9 +337,9 @@ def use_connection() -> Connection[Any]: return conn -def use_scope() -> asgi_types.HTTPScope | asgi_types.WebSocketScope: +def use_scope() -> dict[str, Any] | asgi_types.HTTPScope | asgi_types.WebSocketScope: """Get the current :class:`~reactpy.types.Connection`'s scope.""" - return use_connection().scope # type: ignore + return use_connection().scope def use_location() -> Location: @@ -511,8 +510,6 @@ def use_memo( else: changed = False - setup: Callable[[Callable[[], _Type]], _Type] - if changed: def setup(function: Callable[[], _Type]) -> _Type: @@ -524,10 +521,7 @@ def setup(function: Callable[[], _Type]) -> _Type: def setup(function: Callable[[], _Type]) -> _Type: return memo.value - if function is not None: - return setup(function) - else: - return setup + return setup(function) if function is not None else setup class _Memo(Generic[_Type]): diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 309644b24..5115120de 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -14,6 +14,7 @@ from collections.abc import Sequence from contextlib import AsyncExitStack from logging import getLogger +from types import TracebackType from typing import ( Any, Callable, @@ -56,13 +57,13 @@ class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" __slots__: tuple[str, ...] = ( - "root", "_event_handlers", - "_rendering_queue", + "_model_states_by_life_cycle_state_id", "_render_tasks", "_render_tasks_ready", + "_rendering_queue", "_root_life_cycle_state_id", - "_model_states_by_life_cycle_state_id", + "root", ) if not hasattr(abc.ABC, "__weakref__"): # nocov @@ -80,17 +81,17 @@ async def __aenter__(self) -> Layout: self._event_handlers: EventHandlerDict = {} self._render_tasks: set[Task[LayoutUpdateMessage]] = set() self._render_tasks_ready: Semaphore = Semaphore(0) - self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() root_model_state = _new_root_model_state(self.root, self._schedule_render_task) - self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id self._model_states_by_life_cycle_state_id = {root_id: root_model_state} self._schedule_render_task(root_id) return self - async def __aexit__(self, *exc: object) -> None: + async def __aexit__( + self, exc_type: type[Exception], exc_value: Exception, traceback: TracebackType + ) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] @@ -109,7 +110,7 @@ async def __aexit__(self, *exc: object) -> None: del self._root_life_cycle_state_id del self._model_states_by_life_cycle_state_id - async def deliver(self, event: LayoutEventMessage) -> None: + async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle @@ -217,7 +218,7 @@ async def _render_component( parent.children_by_key[key] = new_state # need to add this model to parent's children without mutating parent model old_parent_model = parent.model.current - old_parent_children = old_parent_model["children"] + old_parent_children = old_parent_model.setdefault("children", []) parent.model.current = { **old_parent_model, "children": [ @@ -318,8 +319,11 @@ async def _render_model_children( new_state: _ModelState, raw_children: Any, ) -> None: - if not isinstance(raw_children, (list, tuple)): - raw_children = [raw_children] + if not isinstance(raw_children, list): + if isinstance(raw_children, tuple): + raw_children = list(raw_children) + else: + raw_children = [raw_children] if old_state is None: if raw_children: @@ -609,7 +613,7 @@ def __init__( parent: _ModelState | None, index: int, key: Any, - model: Ref[VdomJson], + model: Ref[VdomJson | dict[str, Any]], patch_path: str, children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], @@ -656,7 +660,7 @@ def parent(self) -> _ModelState: return parent def append_child(self, child: Any) -> None: - self.model.current["children"].append(child) + self.model.current.setdefault("children", []).append(child) def __repr__(self) -> str: # nocov return f"ModelState({ {s: getattr(self, s, None) for s in self.__slots__} })" diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 40a5761cf..03006a0c6 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable from logging import getLogger -from typing import Callable +from typing import Any, Callable from warnings import warn from anyio import create_task_group @@ -14,10 +14,10 @@ logger = getLogger(__name__) -SendCoroutine = Callable[[LayoutUpdateMessage], Awaitable[None]] +SendCoroutine = Callable[[LayoutUpdateMessage | dict[str, Any]], Awaitable[None]] """Send model patches given by a dispatcher""" -RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage]] +RecvCoroutine = Callable[[], Awaitable[LayoutEventMessage | dict[str, Any]]] """Called by a dispatcher to return a :class:`reactpy.core.layout.LayoutEventMessage` The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a layout. @@ -35,7 +35,9 @@ class Stop(BaseException): async def serve_layout( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], send: SendCoroutine, recv: RecvCoroutine, ) -> None: @@ -55,7 +57,10 @@ async def serve_layout( async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], + send: SendCoroutine, ) -> None: while True: update = await layout.render() @@ -74,7 +79,9 @@ async def _single_outgoing_loop( async def _single_incoming_loop( task_group: TaskGroup, - layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + layout: LayoutType[ + LayoutUpdateMessage | dict[str, Any], LayoutEventMessage | dict[str, Any] + ], recv: RecvCoroutine, ) -> None: while True: diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 77b173f8f..0e6e825a4 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -3,7 +3,7 @@ import json from collections.abc import Mapping, Sequence from functools import wraps -from typing import Any, Protocol, cast, overload +from typing import Any, Callable, Protocol, cast from fastjsonschema import compile as compile_json_schema @@ -92,7 +92,7 @@ # we can't add a docstring to this because Sphinx doesn't know how to find its source -_COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA) +_COMPILED_VDOM_VALIDATOR: Callable = compile_json_schema(VDOM_JSON_SCHEMA) # type: ignore def validate_vdom_json(value: Any) -> VdomJson: @@ -124,19 +124,7 @@ def is_vdom(value: Any) -> bool: ) -@overload -def vdom(tag: str, *children: VdomChildren) -> VdomDict: ... - - -@overload -def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ... - - -def vdom( - tag: str, - *attributes_and_children: Any, - **kwargs: Any, -) -> VdomDict: +def vdom(tag: str, *attributes_and_children: VdomAttributes | VdomChildren) -> VdomDict: """A helper function for creating VDOM elements. Parameters: @@ -157,33 +145,6 @@ def vdom( (subject to change) specifies javascript that, when evaluated returns a React component. """ - if kwargs: # nocov - if "key" in kwargs: - if attributes_and_children: - maybe_attributes, *children = attributes_and_children - if _is_attributes(maybe_attributes): - attributes_and_children = ( - {**maybe_attributes, "key": kwargs.pop("key")}, - *children, - ) - else: - attributes_and_children = ( - {"key": kwargs.pop("key")}, - maybe_attributes, - *children, - ) - else: - attributes_and_children = ({"key": kwargs.pop("key")},) - warn( - "An element's 'key' must be declared in an attribute dict instead " - "of as a keyword argument. This will error in a future version.", - DeprecationWarning, - ) - - if kwargs: - msg = f"Extra keyword arguments {kwargs}" - raise ValueError(msg) - model: VdomDict = {"tagName": tag} if not attributes_and_children: diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 58dcdc8c6..54b8df511 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -12,7 +12,6 @@ import orjson 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 @@ -23,10 +22,18 @@ from reactpy.core.serve import serve_layout from reactpy.executors.asgi.types import ( AsgiApp, - AsgiHttpApp, - AsgiLifespanApp, - AsgiWebsocketApp, + AsgiHttpReceive, + AsgiHttpScope, + AsgiHttpSend, + AsgiReceive, + AsgiScope, + AsgiSend, + AsgiV3App, + AsgiV3HttpApp, + AsgiV3LifespanApp, + AsgiV3WebsocketApp, AsgiWebsocketReceive, + AsgiWebsocketScope, AsgiWebsocketSend, ) from reactpy.executors.utils import check_path, import_components, process_settings @@ -42,7 +49,7 @@ class ReactPyMiddleware: def __init__( self, - app: asgi_types.ASGIApplication, + app: AsgiApp, root_components: Iterable[str], **settings: Unpack[ReactPyConfig], ) -> None: @@ -80,12 +87,12 @@ def __init__( ) # User defined ASGI apps - self.extra_http_routes: dict[str, AsgiHttpApp] = {} - self.extra_ws_routes: dict[str, AsgiWebsocketApp] = {} - self.extra_lifespan_app: AsgiLifespanApp | None = None + self.extra_http_routes: dict[str, AsgiV3HttpApp] = {} + self.extra_ws_routes: dict[str, AsgiV3WebsocketApp] = {} + self.extra_lifespan_app: AsgiV3LifespanApp | None = None # Component attributes - self.asgi_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore + self.asgi_app: AsgiV3App = guarantee_single_callable(app) # type: ignore self.root_components = import_components(root_components) # Directory attributes @@ -98,10 +105,7 @@ def __init__( self.web_modules_app = WebModuleApp(parent=self) async def __call__( - self, - scope: asgi_types.Scope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> None: """The ASGI entrypoint that determines whether ReactPy should route the request to ourselves or to the user application.""" @@ -125,16 +129,16 @@ async def __call__( # Serve the user's application await self.asgi_app(scope, receive, send) - def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: return bool(re.match(self.dispatcher_pattern, scope["path"])) - def match_static_path(self, scope: asgi_types.HTTPScope) -> bool: + def match_static_path(self, scope: AsgiHttpScope) -> bool: return scope["path"].startswith(self.static_path) - def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool: + def match_web_modules_path(self, scope: AsgiHttpScope) -> bool: return scope["path"].startswith(self.web_modules_path) - def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None: + def match_extra_paths(self, scope: AsgiScope) -> AsgiApp | None: # Custom defined routes are unused by default to encourage users to handle # routing within their ASGI framework of choice. return None @@ -146,13 +150,13 @@ class ComponentDispatchApp: async def __call__( self, - scope: asgi_types.WebSocketScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + scope: AsgiWebsocketScope, + receive: AsgiWebsocketReceive, + send: AsgiWebsocketSend, ) -> None: """ASGI app for rendering ReactPy Python components.""" # Start a loop that handles ASGI websocket events - async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: # type: ignore + async with ReactPyWebsocket(scope, receive, send, parent=self.parent) as ws: while True: # Wait for the webserver to notify us of a new event event: dict[str, Any] = await ws.receive(raw=True) # type: ignore @@ -175,7 +179,7 @@ async def __call__( class ReactPyWebsocket(ResponseWebSocket): def __init__( self, - scope: asgi_types.WebSocketScope, + scope: AsgiWebsocketScope, receive: AsgiWebsocketReceive, send: AsgiWebsocketSend, parent: ReactPyMiddleware, @@ -231,7 +235,7 @@ async def run_dispatcher(self) -> None: await serve_layout( Layout(ConnectionContext(component(), value=connection)), self.send_json, - self.rendering_queue.get, # type: ignore + self.rendering_queue.get, ) # Manually log exceptions since this function is running in a separate asyncio task. @@ -250,10 +254,7 @@ class StaticFileApp: _static_file_server: ServeStaticASGI | None = None async def __call__( - self, - scope: asgi_types.HTTPScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiHttpScope, receive: AsgiHttpReceive, send: AsgiHttpSend ) -> None: """ASGI app for ReactPy static files.""" if not self._static_file_server: @@ -272,10 +273,7 @@ class WebModuleApp: _static_file_server: ServeStaticASGI | None = None async def __call__( - self, - scope: asgi_types.HTTPScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiHttpScope, receive: AsgiHttpReceive, send: AsgiHttpSend ) -> None: """ASGI app for ReactPy web modules.""" if not self._static_file_server: @@ -291,10 +289,7 @@ async def __call__( class Error404App: async def __call__( - self, - scope: asgi_types.HTTPScope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> 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 index af2b6fafd..79ccfb2ad 100644 --- a/src/reactpy/executors/asgi/pyscript.py +++ b/src/reactpy/executors/asgi/pyscript.py @@ -9,12 +9,12 @@ 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.asgi.types import AsgiWebsocketScope 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 @@ -79,7 +79,9 @@ def __init__( self.html_head = html_head or html.head() self.html_lang = html_lang - def match_dispatch_path(self, scope: WebSocketScope) -> bool: # pragma: no cover + def match_dispatch_path( + self, scope: AsgiWebsocketScope + ) -> bool: # pragma: no cover """We do not use a WebSocket dispatcher for Client-Side Rendering (CSR).""" return False diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py index 41fb050ff..56c7f6367 100644 --- a/src/reactpy/executors/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -9,16 +9,19 @@ from typing import Callable, Literal, cast, overload from asgi_tools import ResponseHTML -from asgiref import typing as asgi_types from typing_extensions import Unpack from reactpy import html from reactpy.executors.asgi.middleware import ReactPyMiddleware from reactpy.executors.asgi.types import ( AsgiApp, - AsgiHttpApp, - AsgiLifespanApp, - AsgiWebsocketApp, + AsgiReceive, + AsgiScope, + AsgiSend, + AsgiV3HttpApp, + AsgiV3LifespanApp, + AsgiV3WebsocketApp, + AsgiWebsocketScope, ) from reactpy.executors.utils import server_side_component_html, vdom_head_to_html from reactpy.pyscript.utils import pyscript_setup_html @@ -77,20 +80,21 @@ def __init__( pyscript_head_vdom["tagName"] = "" self.html_head["children"].append(pyscript_head_vdom) # type: ignore - def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool: + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: """Method override to remove `dotted_path` from the dispatcher URL.""" return str(scope["path"]) == self.dispatcher_path - def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None: + def match_extra_paths(self, scope: AsgiScope) -> AsgiApp | None: """Method override to match user-provided HTTP/Websocket routes.""" if scope["type"] == "lifespan": return self.extra_lifespan_app + routing_dictionary = {} if scope["type"] == "http": routing_dictionary = self.extra_http_routes.items() if scope["type"] == "websocket": - routing_dictionary = self.extra_ws_routes.items() # type: ignore + routing_dictionary = self.extra_ws_routes.items() return next( ( @@ -106,22 +110,22 @@ def route( self, path: str, type: Literal["http"] = "http", - ) -> Callable[[AsgiHttpApp | str], AsgiApp]: ... + ) -> Callable[[AsgiV3HttpApp | str], AsgiApp]: ... @overload def route( self, path: str, type: Literal["websocket"], - ) -> Callable[[AsgiWebsocketApp | str], AsgiApp]: ... + ) -> Callable[[AsgiV3WebsocketApp | str], AsgiApp]: ... def route( self, path: str, type: Literal["http", "websocket"] = "http", ) -> ( - Callable[[AsgiHttpApp | str], AsgiApp] - | Callable[[AsgiWebsocketApp | str], AsgiApp] + Callable[[AsgiV3HttpApp | str], AsgiApp] + | Callable[[AsgiV3WebsocketApp | str], AsgiApp] ): """Interface that allows user to define their own HTTP/Websocket routes within the current ReactPy application. @@ -142,15 +146,15 @@ def decorator( asgi_app: AsgiApp = import_dotted_path(app) if isinstance(app, str) else app if type == "http": - self.extra_http_routes[re_path] = cast(AsgiHttpApp, asgi_app) + self.extra_http_routes[re_path] = cast(AsgiV3HttpApp, asgi_app) elif type == "websocket": - self.extra_ws_routes[re_path] = cast(AsgiWebsocketApp, asgi_app) + self.extra_ws_routes[re_path] = cast(AsgiV3WebsocketApp, asgi_app) return asgi_app return decorator - def lifespan(self, app: AsgiLifespanApp | str) -> None: + def lifespan(self, app: AsgiV3LifespanApp | str) -> None: """Interface that allows user to define their own lifespan app within the current ReactPy application. @@ -176,10 +180,7 @@ class ReactPyApp: _last_modified = "" async def __call__( - self, - scope: asgi_types.Scope, - receive: asgi_types.ASGIReceiveCallable, - send: asgi_types.ASGISendCallable, + self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> None: if scope["type"] != "http": # pragma: no cover if scope["type"] != "lifespan": diff --git a/src/reactpy/executors/asgi/types.py b/src/reactpy/executors/asgi/types.py index bff5e0ca7..82a87d4f8 100644 --- a/src/reactpy/executors/asgi/types.py +++ b/src/reactpy/executors/asgi/types.py @@ -2,76 +2,108 @@ from __future__ import annotations -from collections.abc import Awaitable -from typing import Callable, Union +from collections.abc import Awaitable, MutableMapping +from typing import Any, Callable, Protocol from asgiref import typing as asgi_types -AsgiHttpReceive = Callable[ - [], - Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent], -] +# Type hints for `receive` within `asgi_app(scope, receive, send)` +AsgiReceive = Callable[[], Awaitable[dict[str, Any] | MutableMapping[str, Any]]] +AsgiHttpReceive = ( + Callable[ + [], Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent] + ] + | AsgiReceive +) +AsgiWebsocketReceive = ( + Callable[ + [], + Awaitable[ + asgi_types.WebSocketConnectEvent + | asgi_types.WebSocketDisconnectEvent + | asgi_types.WebSocketReceiveEvent + ], + ] + | AsgiReceive +) +AsgiLifespanReceive = ( + Callable[ + [], + Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent], + ] + | AsgiReceive +) -AsgiHttpSend = Callable[ - [ - asgi_types.HTTPResponseStartEvent - | asgi_types.HTTPResponseBodyEvent - | asgi_types.HTTPResponseTrailersEvent - | asgi_types.HTTPServerPushEvent - | asgi_types.HTTPDisconnectEvent - ], - Awaitable[None], -] +# Type hints for `send` within `asgi_app(scope, receive, send)` +AsgiSend = Callable[[dict[str, Any] | MutableMapping[str, Any]], Awaitable[None]] +AsgiHttpSend = ( + Callable[ + [ + asgi_types.HTTPResponseStartEvent + | asgi_types.HTTPResponseBodyEvent + | asgi_types.HTTPResponseTrailersEvent + | asgi_types.HTTPServerPushEvent + | asgi_types.HTTPDisconnectEvent + ], + Awaitable[None], + ] + | AsgiSend +) +AsgiWebsocketSend = ( + Callable[ + [ + asgi_types.WebSocketAcceptEvent + | asgi_types.WebSocketSendEvent + | asgi_types.WebSocketResponseStartEvent + | asgi_types.WebSocketResponseBodyEvent + | asgi_types.WebSocketCloseEvent + ], + Awaitable[None], + ] + | AsgiSend +) +AsgiLifespanSend = ( + Callable[ + [ + asgi_types.LifespanStartupCompleteEvent + | asgi_types.LifespanStartupFailedEvent + | asgi_types.LifespanShutdownCompleteEvent + | asgi_types.LifespanShutdownFailedEvent + ], + Awaitable[None], + ] + | AsgiSend +) -AsgiWebsocketReceive = Callable[ - [], - Awaitable[ - asgi_types.WebSocketConnectEvent - | asgi_types.WebSocketDisconnectEvent - | asgi_types.WebSocketReceiveEvent - ], -] +# Type hints for `scope` within `asgi_app(scope, receive, send)` +AsgiScope = dict[str, Any] | MutableMapping[str, Any] +AsgiHttpScope = asgi_types.HTTPScope | AsgiScope +AsgiWebsocketScope = asgi_types.WebSocketScope | AsgiScope +AsgiLifespanScope = asgi_types.LifespanScope | AsgiScope -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], +# Type hints for the ASGI app interface +AsgiV3App = Callable[[AsgiScope, AsgiReceive, AsgiSend], Awaitable[None]] +AsgiV3HttpApp = Callable[ + [AsgiHttpScope, AsgiHttpReceive, AsgiHttpSend], Awaitable[None] ] - -AsgiLifespanSend = Callable[ - [ - asgi_types.LifespanStartupCompleteEvent - | asgi_types.LifespanStartupFailedEvent - | asgi_types.LifespanShutdownCompleteEvent - | asgi_types.LifespanShutdownFailedEvent - ], - Awaitable[None], +AsgiV3WebsocketApp = Callable[ + [AsgiWebsocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], Awaitable[None] ] - -AsgiHttpApp = Callable[ - [asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend], - Awaitable[None], +AsgiV3LifespanApp = Callable[ + [AsgiLifespanScope, AsgiLifespanReceive, AsgiLifespanSend], Awaitable[None] ] -AsgiWebsocketApp = Callable[ - [asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend], - Awaitable[None], -] -AsgiLifespanApp = Callable[ - [asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend], - Awaitable[None], -] +class AsgiV2Protocol(Protocol): + """The ASGI 2.0 protocol for ASGI applications. Type hints for parameters are not provided since + type checkers tend to be too strict with protocol method types matching up perfectly.""" + + def __init__(self, scope: Any) -> None: ... + + async def __call__(self, receive: Any, send: Any) -> None: ... -AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp] +AsgiV2App = type[AsgiV2Protocol] +AsgiApp = AsgiV3App | AsgiV2App +"""The type hint for any ASGI application. This was written to be as generic as possible to avoid type checking issues.""" diff --git a/src/reactpy/testing/backend.py b/src/reactpy/testing/backend.py index a16196b8e..f41563489 100644 --- a/src/reactpy/testing/backend.py +++ b/src/reactpy/testing/backend.py @@ -4,7 +4,7 @@ import logging from contextlib import AsyncExitStack from types import TracebackType -from typing import TYPE_CHECKING, Any, Callable +from typing import Any, Callable from urllib.parse import urlencode, urlunparse import uvicorn @@ -14,6 +14,7 @@ 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.executors.asgi.types import AsgiApp from reactpy.testing.logs import ( LogAssertionError, capture_reactpy_logs, @@ -23,9 +24,6 @@ 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 @@ -45,7 +43,7 @@ class BackendFixture: def __init__( self, - app: asgi_types.ASGIApplication | None = None, + app: AsgiApp | None = None, host: str = "127.0.0.1", port: int | None = None, timeout: float | None = None, diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index 6921bb8da..a71277747 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -5,7 +5,7 @@ import os import shutil import time -from collections.abc import Awaitable +from collections.abc import Awaitable, Coroutine from functools import wraps from typing import Any, Callable, Generic, TypeVar, cast from uuid import uuid4 @@ -51,11 +51,12 @@ def __init__( coro: Callable[_P, Awaitable[_R]] if not inspect.iscoroutinefunction(function): - async def coro(*args: _P.args, **kwargs: _P.kwargs) -> _R: + async def async_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: return cast(_R, function(*args, **kwargs)) + coro = async_func else: - coro = cast(Callable[_P, Awaitable[_R]], function) + coro = cast(Callable[_P, Coroutine[Any, Any, _R]], function) self._func = coro self._args = args self._kwargs = kwargs diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 89e7c4458..483f139e5 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -15,14 +15,12 @@ NamedTuple, Protocol, TypeVar, - overload, runtime_checkable, ) from typing_extensions import TypeAlias, TypedDict CarrierType = TypeVar("CarrierType") - _Type = TypeVar("_Type") @@ -71,14 +69,19 @@ def render(self) -> VdomDict | ComponentType | str | None: class LayoutType(Protocol[_Render_co, _Event_contra]): """Renders and delivers, updates to views and events to handlers, respectively""" - async def render(self) -> _Render_co: - """Render an update to a view""" + async def render( + self, + ) -> _Render_co: ... # Render an update to a view - async def deliver(self, event: _Event_contra) -> None: - """Relay an event to its respective handler""" + async def deliver( + self, event: _Event_contra + ) -> None: ... # Relay an event to its respective handler - async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: - """Prepare the layout for its first render""" + async def __aenter__( + self, + ) -> LayoutType[ + _Render_co, _Event_contra + ]: ... # Prepare the layout for its first render async def __aexit__( self, @@ -191,15 +194,6 @@ class EventHandlerType(Protocol): class VdomDictConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" - @overload - def __call__( - self, attributes: VdomAttributes, *children: VdomChildren - ) -> VdomDict: ... - - @overload - def __call__(self, *children: VdomChildren) -> VdomDict: ... - - @overload def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: ... @@ -212,7 +206,7 @@ class LayoutUpdateMessage(TypedDict): """The type of message""" path: str """JSON Pointer path to the model element being updated""" - model: VdomJson + model: VdomJson | dict[str, Any] """The model to assign at the given JSON Pointer path""" @@ -245,8 +239,7 @@ class ContextProviderType(ComponentType, Protocol[_Type]): """The context type""" @property - def value(self) -> _Type: - "Current context value" + def value(self) -> _Type: ... # Current context value @dataclass diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index a7fcda926..a8f3fd60f 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -74,7 +74,7 @@ def vdom_to_html(vdom: VdomDict) -> str: """ temp_root = etree.Element("__temp__") _add_vdom_to_etree(temp_root, vdom) - html = cast(bytes, tostring(temp_root)).decode() + html = cast(bytes, tostring(temp_root)).decode() # type: ignore # strip out temp root <__temp__> element return html[10:-11] @@ -145,7 +145,7 @@ def _etree_to_vdom( children = _generate_vdom_children(node, transforms) # Convert the lxml node to a VDOM dict - el = make_vdom(node.tag, dict(node.items()), *children) + el = make_vdom(str(node.tag), dict(node.items()), *children) # Perform any necessary mutations on the VDOM attributes to meet VDOM spec _mutate_vdom(el) @@ -268,7 +268,7 @@ def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: The VDOM dictionary to transform. """ if vdom["tagName"] in {"html", "body", "head"}: - return {"tagName": "", "children": vdom["children"]} + return {"tagName": "", "children": vdom.setdefault("children", [])} return vdom From e5e2661ad3ced7df3ae0325c4da42660197601f1 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 11 Feb 2025 14:47:26 -0800 Subject: [PATCH 10/17] Bug fix for corrupted hook state (#1254) * Bug fix for corrupted hook state - change hook state to a contextvar --------- Co-authored-by: James Hutchison <122519877+JamesHutchison@users.noreply.github.com> --- docs/source/about/changelog.rst | 1 + pyproject.toml | 1 - src/reactpy/core/_life_cycle_hook.py | 60 ++++++++++++++++++++-------- src/reactpy/core/_thread_local.py | 6 ++- src/reactpy/core/hooks.py | 16 ++++---- src/reactpy/core/serve.py | 27 ++++++++----- src/reactpy/pyscript/utils.py | 25 +++++++----- src/reactpy/testing/common.py | 4 +- src/reactpy/utils.py | 10 +++++ tests/conftest.py | 17 ++++++++ tests/test_core/test_layout.py | 6 +-- tests/tooling/hooks.py | 4 +- 12 files changed, 120 insertions(+), 57 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index c90a8dcff..6be65b7e7 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -56,6 +56,7 @@ Unreleased **Fixed** - :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text. +- :pull:`1254` - Fixed a bug where ``RuntimeError("Hook stack is in an invalid state")`` errors would be provided when using a webserver that reuses threads. v1.1.0 ------ diff --git a/pyproject.toml b/pyproject.toml index 5725bce3f..4c1dee04a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ commands = [ ] artifacts = [] - ############################# # >>> Hatch Test Runner <<< # ############################# diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 0b69702f3..8600b3f01 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -1,13 +1,16 @@ from __future__ import annotations import logging +import sys from asyncio import Event, Task, create_task, gather +from contextvars import ContextVar, Token from typing import Any, Callable, Protocol, TypeVar from anyio import Semaphore from reactpy.core._thread_local import ThreadLocal from reactpy.types import ComponentType, Context, ContextProviderType +from reactpy.utils import Singleton T = TypeVar("T") @@ -18,16 +21,39 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) +class _HookStack(Singleton): # pragma: no cover + """A singleton object which manages the current component tree's hooks. + Life cycle hooks can be stored in a thread local or context variable depending + on the platform.""" -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _HOOK_STATE.get() - if not hook_stack: - msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) - return hook_stack[-1] + _state: ThreadLocal[list[LifeCycleHook]] | ContextVar[list[LifeCycleHook]] = ( + ThreadLocal(list) if sys.platform == "emscripten" else ContextVar("hook_state") + ) + + def get(self) -> list[LifeCycleHook]: + return self._state.get() + + def initialize(self) -> Token[list[LifeCycleHook]] | None: + return None if isinstance(self._state, ThreadLocal) else self._state.set([]) + + def reset(self, token: Token[list[LifeCycleHook]] | None) -> None: + if isinstance(self._state, ThreadLocal): + self._state.get().clear() + elif token: + self._state.reset(token) + else: + raise RuntimeError("Hook stack is an ContextVar but no token was provided") + + def current_hook(self) -> LifeCycleHook: + hook_stack = self.get() + if not hook_stack: + msg = "No life cycle hook is active. Are you rendering in a layout?" + raise RuntimeError(msg) + return hook_stack[-1] + + +HOOK_STACK = _HookStack() class LifeCycleHook: @@ -37,7 +63,7 @@ class LifeCycleHook: a component is first rendered until it is removed from the layout. The life cycle is ultimately driven by the layout itself, but components can "hook" into those events to perform actions. Components gain access to their own life cycle hook - by calling :func:`current_hook`. They can then perform actions such as: + by calling :func:`HOOK_STACK.current_hook`. They can then perform actions such as: 1. Adding state via :meth:`use_state` 2. Adding effects via :meth:`add_effect` @@ -57,7 +83,7 @@ class LifeCycleHook: .. testcode:: from reactpy.core._life_cycle_hook import LifeCycleHook - from reactpy.core.hooks import current_hook + from reactpy.core.hooks import HOOK_STACK # this function will come from a layout implementation schedule_render = lambda: ... @@ -75,15 +101,15 @@ class LifeCycleHook: ... # the component may access the current hook - assert current_hook() is hook + assert HOOK_STACK.current_hook() is hook # and save state or add effects - current_hook().use_state(lambda: ...) + HOOK_STACK.current_hook().use_state(lambda: ...) async def my_effect(stop_event): ... - current_hook().add_effect(my_effect) + HOOK_STACK.current_hook().add_effect(my_effect) finally: await hook.affect_component_did_render() @@ -130,7 +156,7 @@ def __init__( self._scheduled_render = False self._rendered_atleast_once = False self._current_state_index = 0 - self._state: tuple[Any, ...] = () + self._state: list = [] self._effect_funcs: list[EffectFunc] = [] self._effect_tasks: list[Task[None]] = [] self._effect_stops: list[Event] = [] @@ -157,7 +183,7 @@ def use_state(self, function: Callable[[], T]) -> T: if not self._rendered_atleast_once: # since we're not initialized yet we're just appending state result = function() - self._state += (result,) + self._state.append(result) else: # once finalized we iterate over each succesively used piece of state result = self._state[self._current_state_index] @@ -232,7 +258,7 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - hook_stack = _HOOK_STATE.get() + hook_stack = HOOK_STACK.get() if hook_stack: parent = hook_stack[-1] self._context_providers.update(parent._context_providers) @@ -240,5 +266,5 @@ def set_current(self) -> None: def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - if _HOOK_STATE.get().pop() is not self: + if HOOK_STACK.get().pop() is not self: raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/reactpy/core/_thread_local.py b/src/reactpy/core/_thread_local.py index b3d6a14b0..0d83f7e41 100644 --- a/src/reactpy/core/_thread_local.py +++ b/src/reactpy/core/_thread_local.py @@ -5,8 +5,10 @@ _StateType = TypeVar("_StateType") -class ThreadLocal(Generic[_StateType]): - """Utility for managing per-thread state information""" +class ThreadLocal(Generic[_StateType]): # pragma: no cover + """Utility for managing per-thread state information. This is only used in + environments where ContextVars are not available, such as the `pyodide` + executor.""" def __init__(self, default: Callable[[], _StateType]): self._default = default diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index 8adc2a9e9..a0a4e161c 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -19,7 +19,7 @@ from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG -from reactpy.core._life_cycle_hook import current_hook +from reactpy.core._life_cycle_hook import HOOK_STACK from reactpy.types import Connection, Context, Key, Location, State, VdomDict from reactpy.utils import Ref @@ -83,7 +83,7 @@ def __init__( else: self.value = initial_value - hook = current_hook() + hook = HOOK_STACK.current_hook() def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: next_value = new(self.value) if callable(new) else new # type: ignore @@ -139,7 +139,7 @@ def use_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) @@ -212,7 +212,7 @@ def use_async_effect( Returns: If not function is provided, a decorator. Otherwise ``None``. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None) @@ -280,7 +280,7 @@ def use_debug_value( if REACTPY_DEBUG.current and old.current != new: old.current = new - logger.debug(f"{current_hook().component} {new}") + logger.debug(f"{HOOK_STACK.current_hook().component} {new}") def create_context(default_value: _Type) -> Context[_Type]: @@ -308,7 +308,7 @@ def use_context(context: Context[_Type]) -> _Type: See the full :ref:`Use Context` docs for more information. """ - hook = current_hook() + hook = HOOK_STACK.current_hook() provider = hook.get_context_provider(context) if provider is None: @@ -361,7 +361,7 @@ def __init__( self.value = value def render(self) -> VdomDict: - current_hook().set_context_provider(self) + HOOK_STACK.current_hook().set_context_provider(self) return {"tagName": "", "children": self.children} def __repr__(self) -> str: @@ -554,7 +554,7 @@ def use_ref(initial_value: _Type) -> Ref[_Type]: def _use_const(function: Callable[[], _Type]) -> _Type: - return current_hook().use_state(function) + return HOOK_STACK.current_hook().use_state(function) def _try_to_infer_closure_values( diff --git a/src/reactpy/core/serve.py b/src/reactpy/core/serve.py index 03006a0c6..a6397eee8 100644 --- a/src/reactpy/core/serve.py +++ b/src/reactpy/core/serve.py @@ -9,6 +9,7 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG +from reactpy.core._life_cycle_hook import HOOK_STACK from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage logger = getLogger(__name__) @@ -63,18 +64,22 @@ async def _single_outgoing_loop( send: SendCoroutine, ) -> None: while True: - update = await layout.render() + token = HOOK_STACK.initialize() try: - await send(update) - except Exception: # nocov - if not REACTPY_DEBUG.current: - msg = ( - "Failed to send update. More info may be available " - "if you enabling debug mode by setting " - "`reactpy.config.REACTPY_DEBUG.current = True`." - ) - logger.error(msg) - raise + update = await layout.render() + try: + await send(update) + except Exception: # nocov + if not REACTPY_DEBUG.current: + msg = ( + "Failed to send update. More info may be available " + "if you enabling debug mode by setting " + "`reactpy.config.REACTPY_DEBUG.current = True`." + ) + logger.error(msg) + raise + finally: + HOOK_STACK.reset(token) async def _single_incoming_loop( diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index b867d05f1..eb277cfb5 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -145,6 +145,8 @@ def extend_pyscript_config( def reactpy_version_string() -> str: # pragma: no cover + from reactpy.testing.common import GITHUB_ACTIONS + local_version = reactpy.__version__ # Get a list of all versions via `pip index versions` @@ -170,14 +172,16 @@ def reactpy_version_string() -> str: # pragma: no cover 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 early if the version is available on PyPi and we're not in a CI environment + if local_version in known_versions and not GITHUB_ACTIONS: 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. ") + # We are now determining an alternative method of installing ReactPy for PyScript + if not GITHUB_ACTIONS: + _logger.warning( + "Your current version of ReactPy isn't available on PyPi. Since a packaged version " + "of ReactPy is required for PyScript, we are attempting to find an alternative method..." + ) # Build a local wheel for ReactPy, if needed dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist" @@ -202,19 +206,18 @@ def reactpy_version_string() -> str: # pragma: no cover ) return f"reactpy=={latest_version}" _logger.error( - "Failed to build a local wheel for ReactPy and could not determine the latest version on PyPi. " + "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 + # Move the local wheel file to the web modules directory, if needed wheel_file = Path(wheel_glob[0]) new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name if not new_path.exists(): _logger.warning( - "'reactpy==%s' is not available on PyPi. " - "PyScript will utilize a local wheel of ReactPy instead.", - local_version, + "PyScript will utilize local wheel '%s'.", + wheel_file.name, ) shutil.copy(wheel_file, new_path) return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}" diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index a71277747..cb015a672 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -14,7 +14,7 @@ from typing_extensions import ParamSpec from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR -from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook +from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function @@ -153,7 +153,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if self is None: raise RuntimeError("Hook catcher has been garbage collected") - hook = current_hook() + hook = HOOK_STACK.current_hook() if self.index_by_kwarg is not None: self.index[kwargs[self.index_by_kwarg]] = hook self.latest = hook diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index a8f3fd60f..bc79cc723 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -334,3 +334,13 @@ def import_dotted_path(dotted_path: str) -> Any: except AttributeError as error: msg = f'ReactPy failed to import "{component_name}" from "{module_name}"' raise AttributeError(msg) from error + + +class Singleton: + """A class that only allows one instance to be created.""" + + def __new__(cls, *args, **kw): + if not hasattr(cls, "_instance"): + orig = super() + cls._instance = orig.__new__(cls, *args, **kw) + return cls._instance diff --git a/tests/conftest.py b/tests/conftest.py index 2bcd5d3ea..d12706641 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,23 @@ def rebuild(): subprocess.run(["hatch", "build", "-t", "wheel"], check=True) # noqa: S607, S603 +@pytest.fixture(autouse=True, scope="function") +def create_hook_state(): + """This fixture is a bug fix related to `pytest_asyncio`. + + Usually the hook stack is created automatically within the display fixture, but context + variables aren't retained within `pytest_asyncio` async fixtures. As a workaround, + this fixture ensures that the hook stack is created before each test is run. + + Ref: https://github.com/pytest-dev/pytest-asyncio/issues/127 + """ + from reactpy.core._life_cycle_hook import HOOK_STACK + + token = HOOK_STACK.initialize() + yield token + HOOK_STACK.reset(token) + + @pytest.fixture async def display(server, page): async with DisplayFixture(server, page) as display: diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 8b38bc825..b4de2e7e9 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -343,7 +343,7 @@ async def test_root_component_life_cycle_hook_is_garbage_collected(): def add_to_live_hooks(constructor): def wrapper(*args, **kwargs): result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.HOOK_STACK.current_hook() hook_id = id(hook) live_hooks.add(hook_id) finalize(hook, live_hooks.discard, hook_id) @@ -375,7 +375,7 @@ async def test_life_cycle_hooks_are_garbage_collected(): def add_to_live_hooks(constructor): def wrapper(*args, **kwargs): result = constructor(*args, **kwargs) - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.HOOK_STACK.current_hook() hook_id = id(hook) live_hooks.add(hook_id) finalize(hook, live_hooks.discard, hook_id) @@ -625,7 +625,7 @@ def Outer(): @reactpy.component def Inner(finalizer_id): if finalizer_id not in registered_finalizers: - hook = reactpy.hooks.current_hook() + hook = reactpy.hooks.HOOK_STACK.current_hook() finalize(hook, lambda: garbage_collect_items.append(finalizer_id)) registered_finalizers.add(finalizer_id) return reactpy.html.div(finalizer_id) diff --git a/tests/tooling/hooks.py b/tests/tooling/hooks.py index e5a4b6fb1..bb33172ed 100644 --- a/tests/tooling/hooks.py +++ b/tests/tooling/hooks.py @@ -1,8 +1,8 @@ -from reactpy.core.hooks import current_hook, use_state +from reactpy.core.hooks import HOOK_STACK, use_state def use_force_render(): - return current_hook().schedule_render + return HOOK_STACK.current_hook().schedule_render def use_toggle(init=False): From 3c6abe603600013cbdd540b712618a6ef8e374e8 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 13 Feb 2025 22:28:48 -0800 Subject: [PATCH 11/17] Improve and rename `vdom_to_html` -> `reactpy_to_html` (#1278) - Renamed `reactpy.utils.html_to_vdom` to `reactpy.utils.string_to_reactpy`. - Renamed `reactpy.utils.vdom_to_html` to `reactpy.utils.reactpy_to_string`. - `reactpy.utils.string_to_reactpy` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. - `reactpy.utils.reactpy_to_string` will now retain the user's original casing for element `data-*` and `aria-*` attributes. - Convert `pragma: no cover` comments to `nocov` --- docs/source/about/changelog.rst | 4 + src/reactpy/__init__.py | 6 +- src/reactpy/core/_life_cycle_hook.py | 2 +- src/reactpy/core/_thread_local.py | 2 +- src/reactpy/core/hooks.py | 2 +- src/reactpy/executors/asgi/middleware.py | 6 +- src/reactpy/executors/asgi/pyscript.py | 4 +- src/reactpy/executors/asgi/standalone.py | 6 +- src/reactpy/executors/utils.py | 6 +- src/reactpy/pyscript/components.py | 6 +- src/reactpy/pyscript/utils.py | 6 +- src/reactpy/templatetags/jinja.py | 2 +- src/reactpy/testing/common.py | 10 +- src/reactpy/testing/display.py | 2 +- src/reactpy/testing/utils.py | 2 +- src/reactpy/transforms.py | 408 +++++++++++++++++++++++ src/reactpy/types.py | 2 +- src/reactpy/utils.py | 188 ++++------- tests/test_utils.py | 281 +++++++++++----- 19 files changed, 706 insertions(+), 239 deletions(-) create mode 100644 src/reactpy/transforms.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 6be65b7e7..8586f2325 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -38,6 +38,8 @@ Unreleased - :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``. - :pull:`1113` - ``@reactpy/client`` now exports ``React`` and ``ReactDOM``. - :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format. +- :pull:`1278` - ``reactpy.utils.reactpy_to_string`` will now retain the user's original casing for ``data-*`` and ``aria-*`` attributes. +- :pull:`1278` - ``reactpy.utils.string_to_reactpy`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. **Removed** @@ -48,6 +50,8 @@ Unreleased - :pull:`1113` - Removed ``reactpy.run``. See the documentation for the new method to run ReactPy applications. - :pull:`1113` - Removed ``reactpy.backend.*``. See the documentation for the new method to run ReactPy applications. - :pull:`1113` - Removed ``reactpy.core.types`` module. Use ``reactpy.types`` instead. +- :pull:`1278` - Removed ``reactpy.utils.html_to_vdom``. Use ``reactpy.utils.string_to_reactpy`` instead. +- :pull:`1278` - Removed ``reactpy.utils.vdom_to_html``. Use ``reactpy.utils.reactpy_to_string`` instead. - :pull:`1113` - All backend related installation extras (such as ``pip install reactpy[starlette]``) have been removed. - :pull:`1113` - Removed deprecated function ``module_from_template``. - :pull:`1113` - Removed support for Python 3.9. diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 5413d0b07..3c496d974 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -21,7 +21,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 +from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy __author__ = "The Reactive Python Team" __version__ = "2.0.0a1" @@ -35,9 +35,10 @@ "event", "hooks", "html", - "html_to_vdom", "logging", "pyscript_component", + "reactpy_to_string", + "string_to_reactpy", "types", "use_async_effect", "use_callback", @@ -52,7 +53,6 @@ "use_scope", "use_state", "vdom", - "vdom_to_html", "web", "widgets", ] diff --git a/src/reactpy/core/_life_cycle_hook.py b/src/reactpy/core/_life_cycle_hook.py index 8600b3f01..c940bf01b 100644 --- a/src/reactpy/core/_life_cycle_hook.py +++ b/src/reactpy/core/_life_cycle_hook.py @@ -22,7 +22,7 @@ async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) -class _HookStack(Singleton): # pragma: no cover +class _HookStack(Singleton): # nocov """A singleton object which manages the current component tree's hooks. Life cycle hooks can be stored in a thread local or context variable depending on the platform.""" diff --git a/src/reactpy/core/_thread_local.py b/src/reactpy/core/_thread_local.py index 0d83f7e41..eb582e8e8 100644 --- a/src/reactpy/core/_thread_local.py +++ b/src/reactpy/core/_thread_local.py @@ -5,7 +5,7 @@ _StateType = TypeVar("_StateType") -class ThreadLocal(Generic[_StateType]): # pragma: no cover +class ThreadLocal(Generic[_StateType]): # nocov """Utility for managing per-thread state information. This is only used in environments where ContextVars are not available, such as the `pyodide` executor.""" diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index a0a4e161c..d2dcea8e7 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -613,7 +613,7 @@ def strictly_equal(x: Any, y: Any) -> bool: return x == y # type: ignore # Fallback to identity check - return x is y # pragma: no cover + return x is y # nocov def run_effect_cleanup(cleanup_func: Ref[_EffectCleanFunc | None]) -> None: diff --git a/src/reactpy/executors/asgi/middleware.py b/src/reactpy/executors/asgi/middleware.py index 54b8df511..976119c8f 100644 --- a/src/reactpy/executors/asgi/middleware.py +++ b/src/reactpy/executors/asgi/middleware.py @@ -166,7 +166,7 @@ async def __call__( msg: dict[str, str] = orjson.loads(event["text"]) if msg.get("type") == "layout-event": await ws.rendering_queue.put(msg) - else: # pragma: no cover + else: # nocov await asyncio.to_thread( _logger.warning, f"Unknown message type: {msg.get('type')}" ) @@ -205,7 +205,7 @@ async def run_dispatcher(self) -> None: # Determine component to serve by analyzing the URL and/or class parameters. if self.parent.multiple_root_components: url_match = re.match(self.parent.dispatcher_pattern, self.scope["path"]) - if not url_match: # pragma: no cover + if not url_match: # nocov raise RuntimeError("Could not find component in URL path.") dotted_path = url_match["dotted_path"] if dotted_path not in self.parent.root_components: @@ -215,7 +215,7 @@ async def run_dispatcher(self) -> None: component = self.parent.root_components[dotted_path] elif self.parent.root_component: component = self.parent.root_component - else: # pragma: no cover + else: # nocov raise RuntimeError("No root component provided.") # Create a connection object by analyzing the websocket's query string. diff --git a/src/reactpy/executors/asgi/pyscript.py b/src/reactpy/executors/asgi/pyscript.py index 79ccfb2ad..b3f2cd38f 100644 --- a/src/reactpy/executors/asgi/pyscript.py +++ b/src/reactpy/executors/asgi/pyscript.py @@ -79,9 +79,7 @@ def __init__( self.html_head = html_head or html.head() self.html_lang = html_lang - def match_dispatch_path( - self, scope: AsgiWebsocketScope - ) -> bool: # pragma: no cover + def match_dispatch_path(self, scope: AsgiWebsocketScope) -> bool: # nocov """We do not use a WebSocket dispatcher for Client-Side Rendering (CSR).""" return False diff --git a/src/reactpy/executors/asgi/standalone.py b/src/reactpy/executors/asgi/standalone.py index 56c7f6367..fac9e7ce6 100644 --- a/src/reactpy/executors/asgi/standalone.py +++ b/src/reactpy/executors/asgi/standalone.py @@ -31,7 +31,7 @@ RootComponentConstructor, VdomDict, ) -from reactpy.utils import html_to_vdom, import_dotted_path +from reactpy.utils import import_dotted_path, string_to_reactpy _logger = getLogger(__name__) @@ -74,7 +74,7 @@ def __init__( 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_head_vdom = string_to_reactpy( pyscript_setup_html(extra_py, extra_js, config) ) pyscript_head_vdom["tagName"] = "" @@ -182,7 +182,7 @@ class ReactPyApp: async def __call__( self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend ) -> None: - if scope["type"] != "http": # pragma: no cover + if scope["type"] != "http": # nocov if scope["type"] != "lifespan": msg = ( "ReactPy app received unsupported request of type '%s' at path '%s'", diff --git a/src/reactpy/executors/utils.py b/src/reactpy/executors/utils.py index e29cdf5c6..e0008df9a 100644 --- a/src/reactpy/executors/utils.py +++ b/src/reactpy/executors/utils.py @@ -13,7 +13,7 @@ REACTPY_RECONNECT_MAX_RETRIES, ) from reactpy.types import ReactPyConfig, VdomDict -from reactpy.utils import import_dotted_path, vdom_to_html +from reactpy.utils import import_dotted_path, reactpy_to_string logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def import_components(dotted_paths: Iterable[str]) -> dict[str, Any]: } -def check_path(url_path: str) -> str: # pragma: no cover +def check_path(url_path: str) -> str: # nocov """Check that a path is valid URL path.""" if not url_path: return "URL path must not be empty." @@ -41,7 +41,7 @@ def check_path(url_path: str) -> str: # pragma: no cover def vdom_head_to_html(head: VdomDict) -> str: if isinstance(head, dict) and head.get("tagName") == "head": - return vdom_to_html(head) + return reactpy_to_string(head) raise ValueError( "Invalid head element! Element must be either `html.head` or a string." diff --git a/src/reactpy/pyscript/components.py b/src/reactpy/pyscript/components.py index e51cc0766..5709bd7ca 100644 --- a/src/reactpy/pyscript/components.py +++ b/src/reactpy/pyscript/components.py @@ -6,7 +6,7 @@ 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 +from reactpy.utils import string_to_reactpy if TYPE_CHECKING: from reactpy.types import VdomDict @@ -22,7 +22,7 @@ def _pyscript_component( 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 + initial = string_to_reactpy(initial) if isinstance(initial, str) else initial if not rendered: # FIXME: This is needed to properly re-render PyScript during a WebSocket @@ -30,7 +30,7 @@ def _pyscript_component( set_rendered(True) return None - component_vdom = html_to_vdom( + component_vdom = string_to_reactpy( pyscript_component_html(tuple(str(fp) for fp in file_paths), initial, root) ) component_vdom["tagName"] = "" diff --git a/src/reactpy/pyscript/utils.py b/src/reactpy/pyscript/utils.py index eb277cfb5..34b54576d 100644 --- a/src/reactpy/pyscript/utils.py +++ b/src/reactpy/pyscript/utils.py @@ -18,7 +18,7 @@ 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 +from reactpy.utils import reactpy_to_string if TYPE_CHECKING: from collections.abc import Sequence @@ -77,7 +77,7 @@ 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) + _initial = initial if isinstance(initial, str) else reactpy_to_string(initial) uuid = uuid4().hex executor_code = pyscript_executor_html(file_paths=file_paths, uuid=uuid, root=root) @@ -144,7 +144,7 @@ def extend_pyscript_config( return orjson.dumps(pyscript_config).decode("utf-8") -def reactpy_version_string() -> str: # pragma: no cover +def reactpy_version_string() -> str: # nocov from reactpy.testing.common import GITHUB_ACTIONS local_version = reactpy.__version__ diff --git a/src/reactpy/templatetags/jinja.py b/src/reactpy/templatetags/jinja.py index 672089752..c4256b525 100644 --- a/src/reactpy/templatetags/jinja.py +++ b/src/reactpy/templatetags/jinja.py @@ -22,7 +22,7 @@ def render(self, *args: str, **kwargs: str) -> str: 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 + raise ValueError(f"Unknown tag: {self.tag_name}") # nocov def component(dotted_path: str, **kwargs: str) -> str: diff --git a/src/reactpy/testing/common.py b/src/reactpy/testing/common.py index cb015a672..a0aec3527 100644 --- a/src/reactpy/testing/common.py +++ b/src/reactpy/testing/common.py @@ -16,6 +16,7 @@ from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.utils import str_to_bool def clear_reactpy_web_modules_dir() -> None: @@ -29,14 +30,7 @@ def clear_reactpy_web_modules_dir() -> None: _DEFAULT_POLL_DELAY = 0.1 -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in { - "y", - "yes", - "t", - "true", - "on", - "1", -} +GITHUB_ACTIONS = str_to_bool(os.getenv("GITHUB_ACTIONS", "")) class poll(Generic[_R]): # noqa: N801 diff --git a/src/reactpy/testing/display.py b/src/reactpy/testing/display.py index e3aced083..aeaf6a34d 100644 --- a/src/reactpy/testing/display.py +++ b/src/reactpy/testing/display.py @@ -58,7 +58,7 @@ async def __aenter__(self) -> DisplayFixture: self.page.set_default_timeout(REACTPY_TESTS_DEFAULT_TIMEOUT.current * 1000) - if not hasattr(self, "backend"): # pragma: no cover + if not hasattr(self, "backend"): # nocov self.backend = BackendFixture() await es.enter_async_context(self.backend) diff --git a/src/reactpy/testing/utils.py b/src/reactpy/testing/utils.py index f1808022c..6a48516ed 100644 --- a/src/reactpy/testing/utils.py +++ b/src/reactpy/testing/utils.py @@ -7,7 +7,7 @@ def find_available_port( host: str, port_min: int = 8000, port_max: int = 9000 -) -> int: # pragma: no cover +) -> int: # nocov """Get a port that's available for the given host and port range""" for port in range(port_min, port_max): with closing(socket.socket()) as sock: diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py new file mode 100644 index 000000000..74c3f3f92 --- /dev/null +++ b/src/reactpy/transforms.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from typing import Any + +from reactpy.core.events import EventHandler, to_event_handler_function +from reactpy.types import VdomDict + + +class RequiredTransforms: + """Performs any necessary transformations related to `string_to_reactpy` to automatically prevent + issues with React's rendering engine. + """ + + def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None: + self._intercept_links = intercept_links + + # Run every transform in this class. + for name in dir(self): + # Any method that doesn't start with an underscore is assumed to be a transform. + if not name.startswith("_"): + getattr(self, name)(vdom) + + def normalize_style_attributes(self, vdom: VdomDict) -> None: + """Convert style attribute from str -> dict with camelCase keys""" + if ( + "attributes" in vdom + and "style" in vdom["attributes"] + and isinstance(vdom["attributes"]["style"], str) + ): + vdom["attributes"]["style"] = { + self._kebab_to_camel_case(key.strip()): value.strip() + for key, value in ( + part.split(":", 1) + for part in vdom["attributes"]["style"].split(";") + if ":" in part + ) + } + + @staticmethod + def html_props_to_reactjs(vdom: VdomDict) -> None: + """Convert HTML prop names to their ReactJS equivalents.""" + if "attributes" in vdom: + vdom["attributes"] = { + REACT_PROP_SUBSTITUTIONS.get(k, k): v + for k, v in vdom["attributes"].items() + } + + @staticmethod + def textarea_children_to_prop(vdom: VdomDict) -> None: + """Transformation that converts the text content of a ", + "model": { + "tagName": "textarea", + "attributes": {"defaultValue": "Hello World."}, + }, + }, + # 3: Convert ", + "model": { + "tagName": "select", + "attributes": {"defaultValue": "Option 1"}, + "children": [ + { + "children": ["Option 1"], + "tagName": "option", + "attributes": {"value": "Option 1"}, + } + ], + }, + }, + # 4: Convert ", + "model": { + "tagName": "select", + "attributes": { + "defaultValue": ["Option 1", "Option 2"], + "multiple": True, + }, + "children": [ + { + "children": ["Option 1"], + "tagName": "option", + "attributes": {"value": "Option 1"}, + }, + { + "children": ["Option 2"], + "tagName": "option", + "attributes": {"value": "Option 2"}, + }, + ], + }, + }, + # 5: Convert value attribute into `defaultValue` + { + "source": '', + "model": { + "tagName": "input", + "attributes": {"defaultValue": "Hello World.", "type": "text"}, + }, + }, + # 6: Infer ReactJS `key` from the `id` attribute + { + "source": '
', + "model": { + "tagName": "div", + "key": "my-key", + "attributes": {"id": "my-key"}, + }, + }, + # 7: Infer ReactJS `key` from the `name` attribute + { + "source": '', + "model": { + "tagName": "input", + "key": "my-input", + "attributes": {"type": "text", "name": "my-input"}, + }, + }, + # 8: Infer ReactJS `key` from the `key` attribute + { + "source": '
', + "model": {"tagName": "div", "key": "my-key"}, + }, ], ) -def test_html_to_vdom(case): - assert utils.html_to_vdom(case["source"]) == case["model"] +def test_string_to_reactpy_default_transforms(case): + assert utils.string_to_reactpy(case["source"]) == case["model"] + + +def test_string_to_reactpy_intercept_links(): + source = 'Hello World' + expected = { + "tagName": "a", + "children": ["Hello World"], + "attributes": {"href": "https://example.com"}, + } + result = utils.string_to_reactpy(source, intercept_links=True) + + # Check if the result equals expected when removing `eventHandlers` from the result dict + event_handlers = result.pop("eventHandlers", {}) + assert result == expected + # Make sure the event handlers dict contains an `onClick` key + assert "onClick" in event_handlers -def test_html_to_vdom_transform(): + +def test_string_to_reactpy_custom_transform(): source = "

hello world and universelmao

" def make_links_blue(node): @@ -92,7 +240,10 @@ def make_links_blue(node): ], } - assert utils.html_to_vdom(source, make_links_blue) == expected + assert ( + utils.string_to_reactpy(source, make_links_blue, intercept_links=False) + == expected + ) def test_non_html_tag_behavior(): @@ -106,82 +257,10 @@ def test_non_html_tag_behavior(): ], } - assert utils.html_to_vdom(source, strict=False) == expected + assert utils.string_to_reactpy(source, strict=False) == expected with pytest.raises(utils.HTMLParseError): - utils.html_to_vdom(source, strict=True) - - -def test_html_to_vdom_with_null_tag(): - source = "

hello
world

" - - expected = { - "tagName": "p", - "children": [ - "hello", - {"tagName": "br"}, - "world", - ], - } - - assert utils.html_to_vdom(source) == expected - - -def test_html_to_vdom_with_style_attr(): - source = '

Hello World.

' - - expected = { - "attributes": {"style": {"background_color": "green", "color": "red"}}, - "children": ["Hello World."], - "tagName": "p", - } - - assert utils.html_to_vdom(source) == expected - - -def test_html_to_vdom_with_no_parent_node(): - source = "

Hello

World
" - - expected = { - "tagName": "div", - "children": [ - {"tagName": "p", "children": ["Hello"]}, - {"tagName": "div", "children": ["World"]}, - ], - } - - assert utils.html_to_vdom(source) == expected - - -def test_del_html_body_transform(): - source = """ - - - - - My Title - - -

Hello World

- - - """ - - expected = { - "tagName": "", - "children": [ - { - "tagName": "", - "children": [{"tagName": "title", "children": ["My Title"]}], - }, - { - "tagName": "", - "children": [{"tagName": "h1", "children": ["Hello World"]}], - }, - ], - } - - assert utils.html_to_vdom(source, utils.del_html_head_body_transform) == expected + utils.string_to_reactpy(source, strict=True) SOME_OBJECT = object() @@ -199,7 +278,17 @@ def example_middle(): @component def example_child(): - return html.h1("Sample Application") + return html.h1("Example") + + +@component +def example_str_return(): + return "Example" + + +@component +def example_none_return(): + return None @pytest.mark.parametrize( @@ -257,24 +346,38 @@ def example_child(): '
hello
example
', ), ( - html.div( - {"data_Something": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} - ), - '
', + html.div({"data-Something": 1, "dataCamelCase": 2, "datalowercase": 3}), + '
', ), ( html.div(example_parent()), - '

Sample Application

', + '

Example

', + ), + ( + example_parent(), + '

Example

', + ), + ( + html.form({"acceptCharset": "utf-8"}), + '
', + ), + ( + example_str_return(), + "
Example
", + ), + ( + example_none_return(), + "", ), ], ) -def test_vdom_to_html(vdom_in, html_out): - assert utils.vdom_to_html(vdom_in) == html_out +def test_reactpy_to_string(vdom_in, html_out): + assert utils.reactpy_to_string(vdom_in) == html_out -def test_vdom_to_html_error(): +def test_reactpy_to_string_error(): with pytest.raises(TypeError, match="Expected a VDOM dict"): - utils.vdom_to_html({"notVdom": True}) + utils.reactpy_to_string({"notVdom": True}) def test_invalid_dotted_path(): From fa48bbcd49610fefa9295abadeab625a1d0a2fb7 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 27 Feb 2025 04:42:41 -0800 Subject: [PATCH 12/17] Rework VDOM construction and type hints (#1281) - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead. - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead. - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead. - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) - Added type hints to ``reactpy.html`` attributes. --- docs/source/about/changelog.rst | 11 +- pyproject.toml | 2 + src/reactpy/__init__.py | 4 +- src/reactpy/_html.py | 418 +++++++------- src/reactpy/core/hooks.py | 11 +- src/reactpy/core/layout.py | 2 +- src/reactpy/core/vdom.py | 248 ++++----- src/reactpy/transforms.py | 15 +- src/reactpy/types.py | 871 ++++++++++++++++++++++++++++-- src/reactpy/utils.py | 13 +- src/reactpy/web/module.py | 14 +- src/reactpy/widgets.py | 6 +- tests/sample.py | 3 +- tests/test_core/test_component.py | 2 +- tests/test_core/test_layout.py | 2 +- tests/test_core/test_vdom.py | 65 ++- 16 files changed, 1218 insertions(+), 469 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 8586f2325..b2d605890 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -19,12 +19,15 @@ Unreleased - :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.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. +- :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) +- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. +- :pull:`1281` - Added type hints to ``reactpy.html`` attributes. **Changed** @@ -40,6 +43,9 @@ Unreleased - :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format. - :pull:`1278` - ``reactpy.utils.reactpy_to_string`` will now retain the user's original casing for ``data-*`` and ``aria-*`` attributes. - :pull:`1278` - ``reactpy.utils.string_to_reactpy`` has been upgraded to handle more complex scenarios without causing ReactJS rendering errors. +- :pull:`1281` - ``reactpy.core.vdom._CustomVdomDictConstructor`` has been moved to ``reactpy.types.CustomVdomConstructor``. +- :pull:`1281` - ``reactpy.core.vdom._EllipsisRepr`` has been moved to ``reactpy.types.EllipsisRepr``. +- :pull:`1281` - ``reactpy.types.VdomDictConstructor`` has been renamed to ``reactpy.types.VdomConstructor``. **Removed** @@ -56,6 +62,9 @@ Unreleased - :pull:`1113` - Removed deprecated function ``module_from_template``. - :pull:`1113` - Removed support for Python 3.9. - :pull:`1264` - Removed support for async functions within ``reactpy.use_effect`` hook. Use ``reactpy.use_async_effect`` instead. +- :pull:`1281` - Removed ``reactpy.vdom``. Use ``reactpy.Vdom`` instead. +- :pull:`1281` - Removed ``reactpy.core.make_vdom_constructor``. Use ``reactpy.Vdom`` instead. +- :pull:`1281` - Removed ``reactpy.core.custom_vdom_constructor``. Use ``reactpy.Vdom`` instead. **Fixed** diff --git a/pyproject.toml b/pyproject.toml index 4c1dee04a..173fdb173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,8 @@ filterwarnings = """ ignore::DeprecationWarning:uvicorn.* ignore::DeprecationWarning:websockets.* ignore::UserWarning:tests.test_core.test_vdom + ignore::UserWarning:tests.test_pyscript.test_components + ignore::UserWarning:tests.test_utils """ testpaths = "tests" xfail_strict = true diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 3c496d974..7408f57df 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -19,7 +19,7 @@ use_state, ) from reactpy.core.layout import Layout -from reactpy.core.vdom import vdom +from reactpy.core.vdom import Vdom from reactpy.pyscript.components import pyscript_component from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy @@ -29,6 +29,7 @@ __all__ = [ "Layout", "Ref", + "Vdom", "component", "config", "create_context", @@ -52,7 +53,6 @@ "use_ref", "use_scope", "use_state", - "vdom", "web", "widgets", ] diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 61c6ae77f..9f160b403 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -1,20 +1,18 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, ClassVar - -from reactpy.core.vdom import custom_vdom_constructor, make_vdom_constructor - -if TYPE_CHECKING: - from reactpy.types import ( - EventHandlerDict, - Key, - VdomAttributes, - VdomChild, - VdomChildren, - VdomDict, - VdomDictConstructor, - ) +from typing import ClassVar, overload + +from reactpy.core.vdom import Vdom +from reactpy.types import ( + EventHandlerDict, + Key, + VdomAttributes, + VdomChild, + VdomChildren, + VdomConstructor, + VdomDict, +) __all__ = ["html"] @@ -109,7 +107,7 @@ def _fragment( if attributes or event_handlers: msg = "Fragments cannot have attributes besides 'key'" raise TypeError(msg) - model: VdomDict = {"tagName": ""} + model = VdomDict(tagName="") if children: model["children"] = children @@ -143,7 +141,7 @@ def _script( Doing so may allow for malicious code injection (`XSS `__`). """ - model: VdomDict = {"tagName": "script"} + model = VdomDict(tagName="script") if event_handlers: msg = "'script' elements do not support event handlers" @@ -174,20 +172,28 @@ def _script( class SvgConstructor: """Constructor specifically for SVG children.""" - __cache__: ClassVar[dict[str, VdomDictConstructor]] = {} + __cache__: ClassVar[dict[str, VdomConstructor]] = {} + + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: return self.svg(*attributes_and_children) - def __getattr__(self, value: str) -> VdomDictConstructor: + def __getattr__(self, value: str) -> VdomConstructor: value = value.rstrip("_").replace("_", "-") if value in self.__cache__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( + self.__cache__[value] = Vdom( value, allow_children=value not in NO_CHILDREN_ALLOWED_SVG ) @@ -196,71 +202,72 @@ def __getattr__(self, value: str) -> VdomDictConstructor: # SVG child elements, written out here for auto-complete purposes # The actual elements are created dynamically in the __getattr__ method. # Elements other than these can still be created. - a: VdomDictConstructor - animate: VdomDictConstructor - animateMotion: VdomDictConstructor - animateTransform: VdomDictConstructor - circle: VdomDictConstructor - clipPath: VdomDictConstructor - defs: VdomDictConstructor - desc: VdomDictConstructor - discard: VdomDictConstructor - ellipse: VdomDictConstructor - feBlend: VdomDictConstructor - feColorMatrix: VdomDictConstructor - feComponentTransfer: VdomDictConstructor - feComposite: VdomDictConstructor - feConvolveMatrix: VdomDictConstructor - feDiffuseLighting: VdomDictConstructor - feDisplacementMap: VdomDictConstructor - feDistantLight: VdomDictConstructor - feDropShadow: VdomDictConstructor - feFlood: VdomDictConstructor - feFuncA: VdomDictConstructor - feFuncB: VdomDictConstructor - feFuncG: VdomDictConstructor - feFuncR: VdomDictConstructor - feGaussianBlur: VdomDictConstructor - feImage: VdomDictConstructor - feMerge: VdomDictConstructor - feMergeNode: VdomDictConstructor - feMorphology: VdomDictConstructor - feOffset: VdomDictConstructor - fePointLight: VdomDictConstructor - feSpecularLighting: VdomDictConstructor - feSpotLight: VdomDictConstructor - feTile: VdomDictConstructor - feTurbulence: VdomDictConstructor - filter: VdomDictConstructor - foreignObject: VdomDictConstructor - g: VdomDictConstructor - hatch: VdomDictConstructor - hatchpath: VdomDictConstructor - image: VdomDictConstructor - line: VdomDictConstructor - linearGradient: VdomDictConstructor - marker: VdomDictConstructor - mask: VdomDictConstructor - metadata: VdomDictConstructor - mpath: VdomDictConstructor - path: VdomDictConstructor - pattern: VdomDictConstructor - polygon: VdomDictConstructor - polyline: VdomDictConstructor - radialGradient: VdomDictConstructor - rect: VdomDictConstructor - script: VdomDictConstructor - set: VdomDictConstructor - stop: VdomDictConstructor - style: VdomDictConstructor - switch: VdomDictConstructor - symbol: VdomDictConstructor - text: VdomDictConstructor - textPath: VdomDictConstructor - title: VdomDictConstructor - tspan: VdomDictConstructor - use: VdomDictConstructor - view: VdomDictConstructor + a: VdomConstructor + animate: VdomConstructor + animateMotion: VdomConstructor + animateTransform: VdomConstructor + circle: VdomConstructor + clipPath: VdomConstructor + defs: VdomConstructor + desc: VdomConstructor + discard: VdomConstructor + ellipse: VdomConstructor + feBlend: VdomConstructor + feColorMatrix: VdomConstructor + feComponentTransfer: VdomConstructor + feComposite: VdomConstructor + feConvolveMatrix: VdomConstructor + feDiffuseLighting: VdomConstructor + feDisplacementMap: VdomConstructor + feDistantLight: VdomConstructor + feDropShadow: VdomConstructor + feFlood: VdomConstructor + feFuncA: VdomConstructor + feFuncB: VdomConstructor + feFuncG: VdomConstructor + feFuncR: VdomConstructor + feGaussianBlur: VdomConstructor + feImage: VdomConstructor + feMerge: VdomConstructor + feMergeNode: VdomConstructor + feMorphology: VdomConstructor + feOffset: VdomConstructor + fePointLight: VdomConstructor + feSpecularLighting: VdomConstructor + feSpotLight: VdomConstructor + feTile: VdomConstructor + feTurbulence: VdomConstructor + filter: VdomConstructor + foreignObject: VdomConstructor + g: VdomConstructor + hatch: VdomConstructor + hatchpath: VdomConstructor + image: VdomConstructor + line: VdomConstructor + linearGradient: VdomConstructor + marker: VdomConstructor + mask: VdomConstructor + metadata: VdomConstructor + mpath: VdomConstructor + path: VdomConstructor + pattern: VdomConstructor + polygon: VdomConstructor + polyline: VdomConstructor + radialGradient: VdomConstructor + rect: VdomConstructor + script: VdomConstructor + set: VdomConstructor + stop: VdomConstructor + style: VdomConstructor + switch: VdomConstructor + symbol: VdomConstructor + text: VdomConstructor + textPath: VdomConstructor + title: VdomConstructor + tspan: VdomConstructor + use: VdomConstructor + view: VdomConstructor + svg: VdomConstructor class HtmlConstructor: @@ -274,143 +281,144 @@ class HtmlConstructor: with underscores (eg. `html.data_table` for ``).""" # ruff: noqa: N815 - __cache__: ClassVar[dict[str, VdomDictConstructor]] = { - "script": custom_vdom_constructor(_script), - "fragment": custom_vdom_constructor(_fragment), + __cache__: ClassVar[dict[str, VdomConstructor]] = { + "script": Vdom("script", custom_constructor=_script), + "fragment": Vdom("", custom_constructor=_fragment), + "svg": SvgConstructor(), } - def __getattr__(self, value: str) -> VdomDictConstructor: + def __getattr__(self, value: str) -> VdomConstructor: value = value.rstrip("_").replace("_", "-") if value in self.__cache__: return self.__cache__[value] - self.__cache__[value] = make_vdom_constructor( + self.__cache__[value] = Vdom( value, allow_children=value not in NO_CHILDREN_ALLOWED_HTML_BODY ) return self.__cache__[value] - # HTML elements, written out here for auto-complete purposes - # The actual elements are created dynamically in the __getattr__ method. - # Elements other than these can still be created. - a: VdomDictConstructor - abbr: VdomDictConstructor - address: VdomDictConstructor - area: VdomDictConstructor - article: VdomDictConstructor - aside: VdomDictConstructor - audio: VdomDictConstructor - b: VdomDictConstructor - body: VdomDictConstructor - base: VdomDictConstructor - bdi: VdomDictConstructor - bdo: VdomDictConstructor - blockquote: VdomDictConstructor - br: VdomDictConstructor - button: VdomDictConstructor - canvas: VdomDictConstructor - caption: VdomDictConstructor - cite: VdomDictConstructor - code: VdomDictConstructor - col: VdomDictConstructor - colgroup: VdomDictConstructor - data: VdomDictConstructor - dd: VdomDictConstructor - del_: VdomDictConstructor - details: VdomDictConstructor - dialog: VdomDictConstructor - div: VdomDictConstructor - dl: VdomDictConstructor - dt: VdomDictConstructor - em: VdomDictConstructor - embed: VdomDictConstructor - fieldset: VdomDictConstructor - figcaption: VdomDictConstructor - figure: VdomDictConstructor - footer: VdomDictConstructor - form: VdomDictConstructor - h1: VdomDictConstructor - h2: VdomDictConstructor - h3: VdomDictConstructor - h4: VdomDictConstructor - h5: VdomDictConstructor - h6: VdomDictConstructor - head: VdomDictConstructor - header: VdomDictConstructor - hr: VdomDictConstructor - html: VdomDictConstructor - i: VdomDictConstructor - iframe: VdomDictConstructor - img: VdomDictConstructor - input: VdomDictConstructor - ins: VdomDictConstructor - kbd: VdomDictConstructor - label: VdomDictConstructor - legend: VdomDictConstructor - li: VdomDictConstructor - link: VdomDictConstructor - main: VdomDictConstructor - map: VdomDictConstructor - mark: VdomDictConstructor - math: VdomDictConstructor - menu: VdomDictConstructor - menuitem: VdomDictConstructor - meta: VdomDictConstructor - meter: VdomDictConstructor - nav: VdomDictConstructor - noscript: VdomDictConstructor - object: VdomDictConstructor - ol: VdomDictConstructor - option: VdomDictConstructor - output: VdomDictConstructor - p: VdomDictConstructor - param: VdomDictConstructor - picture: VdomDictConstructor - portal: VdomDictConstructor - pre: VdomDictConstructor - progress: VdomDictConstructor - q: VdomDictConstructor - rp: VdomDictConstructor - rt: VdomDictConstructor - ruby: VdomDictConstructor - s: VdomDictConstructor - samp: VdomDictConstructor - script: VdomDictConstructor - section: VdomDictConstructor - select: VdomDictConstructor - slot: VdomDictConstructor - small: VdomDictConstructor - source: VdomDictConstructor - span: VdomDictConstructor - strong: VdomDictConstructor - style: VdomDictConstructor - sub: VdomDictConstructor - summary: VdomDictConstructor - sup: VdomDictConstructor - table: VdomDictConstructor - tbody: VdomDictConstructor - td: VdomDictConstructor - template: VdomDictConstructor - textarea: VdomDictConstructor - tfoot: VdomDictConstructor - th: VdomDictConstructor - thead: VdomDictConstructor - time: VdomDictConstructor - title: VdomDictConstructor - tr: VdomDictConstructor - track: VdomDictConstructor - u: VdomDictConstructor - ul: VdomDictConstructor - var: VdomDictConstructor - video: VdomDictConstructor - wbr: VdomDictConstructor - fragment: VdomDictConstructor + # Standard HTML elements are written below for auto-complete purposes + # The actual elements are created dynamically when __getattr__ is called. + # Elements other than those type-hinted below can still be created. + a: VdomConstructor + abbr: VdomConstructor + address: VdomConstructor + area: VdomConstructor + article: VdomConstructor + aside: VdomConstructor + audio: VdomConstructor + b: VdomConstructor + body: VdomConstructor + base: VdomConstructor + bdi: VdomConstructor + bdo: VdomConstructor + blockquote: VdomConstructor + br: VdomConstructor + button: VdomConstructor + canvas: VdomConstructor + caption: VdomConstructor + cite: VdomConstructor + code: VdomConstructor + col: VdomConstructor + colgroup: VdomConstructor + data: VdomConstructor + dd: VdomConstructor + del_: VdomConstructor + details: VdomConstructor + dialog: VdomConstructor + div: VdomConstructor + dl: VdomConstructor + dt: VdomConstructor + em: VdomConstructor + embed: VdomConstructor + fieldset: VdomConstructor + figcaption: VdomConstructor + figure: VdomConstructor + footer: VdomConstructor + form: VdomConstructor + h1: VdomConstructor + h2: VdomConstructor + h3: VdomConstructor + h4: VdomConstructor + h5: VdomConstructor + h6: VdomConstructor + head: VdomConstructor + header: VdomConstructor + hr: VdomConstructor + html: VdomConstructor + i: VdomConstructor + iframe: VdomConstructor + img: VdomConstructor + input: VdomConstructor + ins: VdomConstructor + kbd: VdomConstructor + label: VdomConstructor + legend: VdomConstructor + li: VdomConstructor + link: VdomConstructor + main: VdomConstructor + map: VdomConstructor + mark: VdomConstructor + math: VdomConstructor + menu: VdomConstructor + menuitem: VdomConstructor + meta: VdomConstructor + meter: VdomConstructor + nav: VdomConstructor + noscript: VdomConstructor + object: VdomConstructor + ol: VdomConstructor + option: VdomConstructor + output: VdomConstructor + p: VdomConstructor + param: VdomConstructor + picture: VdomConstructor + portal: VdomConstructor + pre: VdomConstructor + progress: VdomConstructor + q: VdomConstructor + rp: VdomConstructor + rt: VdomConstructor + ruby: VdomConstructor + s: VdomConstructor + samp: VdomConstructor + script: VdomConstructor + section: VdomConstructor + select: VdomConstructor + slot: VdomConstructor + small: VdomConstructor + source: VdomConstructor + span: VdomConstructor + strong: VdomConstructor + style: VdomConstructor + sub: VdomConstructor + summary: VdomConstructor + sup: VdomConstructor + table: VdomConstructor + tbody: VdomConstructor + td: VdomConstructor + template: VdomConstructor + textarea: VdomConstructor + tfoot: VdomConstructor + th: VdomConstructor + thead: VdomConstructor + time: VdomConstructor + title: VdomConstructor + tr: VdomConstructor + track: VdomConstructor + u: VdomConstructor + ul: VdomConstructor + var: VdomConstructor + video: VdomConstructor + wbr: VdomConstructor + fragment: VdomConstructor # Special Case: SVG elements # Since SVG elements have a different set of allowed children, they are # separated into a different constructor, and are accessed via `html.svg.example()` - svg: SvgConstructor = SvgConstructor() + svg: SvgConstructor html = HtmlConstructor() diff --git a/src/reactpy/core/hooks.py b/src/reactpy/core/hooks.py index d2dcea8e7..8fc7db703 100644 --- a/src/reactpy/core/hooks.py +++ b/src/reactpy/core/hooks.py @@ -20,7 +20,14 @@ from reactpy.config import REACTPY_DEBUG from reactpy.core._life_cycle_hook import HOOK_STACK -from reactpy.types import Connection, Context, Key, Location, State, VdomDict +from reactpy.types import ( + Connection, + Context, + Key, + Location, + State, + VdomDict, +) from reactpy.utils import Ref if not TYPE_CHECKING: @@ -362,7 +369,7 @@ def __init__( def render(self) -> VdomDict: HOOK_STACK.current_hook().set_context_provider(self) - return {"tagName": "", "children": self.children} + return VdomDict(tagName="", children=self.children) def __repr__(self) -> str: return f"ContextProvider({self.type})" diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 5115120de..a32f97083 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -196,7 +196,7 @@ async def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]} + wrapper_model = VdomDict(tagName="", children=[raw_model]) await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 0e6e825a4..4186ab5a6 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -1,9 +1,14 @@ +# pyright: reportIncompatibleMethodOverride=false from __future__ import annotations import json from collections.abc import Mapping, Sequence -from functools import wraps -from typing import Any, Callable, Protocol, cast +from typing import ( + Any, + Callable, + cast, + overload, +) from fastjsonschema import compile as compile_json_schema @@ -13,15 +18,14 @@ from reactpy.core.events import EventHandler, to_event_handler_function from reactpy.types import ( ComponentType, + CustomVdomConstructor, + EllipsisRepr, EventHandlerDict, EventHandlerType, ImportSourceDict, - Key, VdomAttributes, - VdomChild, VdomChildren, VdomDict, - VdomDictConstructor, VdomJson, ) @@ -102,174 +106,131 @@ def validate_vdom_json(value: Any) -> VdomJson: def is_vdom(value: Any) -> bool: - """Return whether a value is a :class:`VdomDict` - - This employs a very simple heuristic - something is VDOM if: - - 1. It is a ``dict`` instance - 2. It contains the key ``"tagName"`` - 3. The value of the key ``"tagName"`` is a string - - .. note:: - - Performing an ``isinstance(value, VdomDict)`` check is too restrictive since the - user would be forced to import ``VdomDict`` every time they needed to declare a - VDOM element. Giving the user more flexibility, at the cost of this check's - accuracy, is worth it. - """ - return ( - isinstance(value, dict) - and "tagName" in value - and isinstance(value["tagName"], str) - ) - - -def vdom(tag: str, *attributes_and_children: VdomAttributes | VdomChildren) -> VdomDict: - """A helper function for creating VDOM elements. - - Parameters: - tag: - The type of element (e.g. 'div', 'h1', 'img') - attributes_and_children: - An optional attribute mapping followed by any number of children or - iterables of children. The attribute mapping **must** precede the children, - or children which will be merged into their respective parts of the model. - key: - A string indicating the identity of a particular element. This is significant - to preserve event handlers across updates - without a key, a re-render would - cause these handlers to be deleted, but with a key, they would be redirected - to any newly defined handlers. - event_handlers: - Maps event types to coroutines that are responsible for handling those events. - import_source: - (subject to change) specifies javascript that, when evaluated returns a - React component. - """ - model: VdomDict = {"tagName": tag} - - if not attributes_and_children: - return model - - attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - - if attributes: - if REACTPY_CHECK_JSON_ATTRS.current: - json.dumps(attributes) - model["attributes"] = attributes - - if children: - model["children"] = children - - if key is not None: - model["key"] = key + """Return whether a value is a :class:`VdomDict`""" + return isinstance(value, VdomDict) - if event_handlers: - model["eventHandlers"] = event_handlers - return model - - -def make_vdom_constructor( - tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None -) -> VdomDictConstructor: - """Return a constructor for VDOM dictionaries with the given tag name. - - The resulting callable will have the same interface as :func:`vdom` but without its - first ``tag`` argument. - """ - - def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict: - model = vdom(tag, *attributes_and_children, **kwargs) - if not allow_children and "children" in model: - msg = f"{tag!r} nodes cannot have children." - raise TypeError(msg) - if import_source: - model["importSource"] = import_source - return model - - # replicate common function attributes - constructor.__name__ = tag - constructor.__doc__ = ( - "Return a new " - f"`<{tag}> `__ " - "element represented by a :class:`VdomDict`." - ) - - module_name = f_module_name(1) - if module_name: - constructor.__module__ = module_name - constructor.__qualname__ = f"{module_name}.{tag}" - - return cast(VdomDictConstructor, constructor) +class Vdom: + """Class-based constructor for VDOM dictionaries. + Once initialized, the `__call__` method on this class is used as the user API + for `reactpy.html`.""" + def __init__( + self, + tag_name: str, + /, + allow_children: bool = True, + custom_constructor: CustomVdomConstructor | None = None, + import_source: ImportSourceDict | None = None, + ) -> None: + """Initialize a VDOM constructor for the provided `tag_name`.""" + self.allow_children = allow_children + self.custom_constructor = custom_constructor + self.import_source = import_source + + # Configure Python debugger attributes + self.__name__ = tag_name + module_name = f_module_name(1) + if module_name: + self.__module__ = module_name + self.__qualname__ = f"{module_name}.{tag_name}" + + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... -def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor: - """Cast function to VdomDictConstructor""" + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... - @wraps(func) - def wrapper(*attributes_and_children: Any) -> VdomDict: + def __call__( + self, *attributes_and_children: VdomAttributes | VdomChildren + ) -> VdomDict: + """The entry point for the VDOM API, for example reactpy.html().""" attributes, children = separate_attributes_and_children(attributes_and_children) key = attributes.pop("key", None) attributes, event_handlers = separate_attributes_and_event_handlers(attributes) - return func(attributes, children, key, event_handlers) + if REACTPY_CHECK_JSON_ATTRS.current: + json.dumps(attributes) + + # Run custom constructor, if defined + if self.custom_constructor: + result = self.custom_constructor( + key=key, + children=children, + attributes=attributes, + event_handlers=event_handlers, + ) - return cast(VdomDictConstructor, wrapper) + # Otherwise, use the default constructor + else: + result = { + **({"key": key} if key is not None else {}), + **({"children": children} if children else {}), + **({"attributes": attributes} if attributes else {}), + **({"eventHandlers": event_handlers} if event_handlers else {}), + **({"importSource": self.import_source} if self.import_source else {}), + } + + # Validate the result + result = result | {"tagName": self.__name__} + if children and not self.allow_children: + msg = f"{self.__name__!r} nodes cannot have children." + raise TypeError(msg) + + return VdomDict(**result) # type: ignore def separate_attributes_and_children( values: Sequence[Any], -) -> tuple[dict[str, Any], list[Any]]: +) -> tuple[VdomAttributes, list[Any]]: if not values: return {}, [] - attributes: dict[str, Any] + _attributes: VdomAttributes children_or_iterables: Sequence[Any] - if _is_attributes(values[0]): - attributes, *children_or_iterables = values + # ruff: noqa: E721 + if type(values[0]) is dict: + _attributes, *children_or_iterables = values else: - attributes = {} + _attributes = {} children_or_iterables = values - children: list[Any] = [] - for child in children_or_iterables: - if _is_single_child(child): - children.append(child) - else: - children.extend(child) + _children: list[Any] = _flatten_children(children_or_iterables) - return attributes, children + return _attributes, _children def separate_attributes_and_event_handlers( attributes: Mapping[str, Any], -) -> tuple[dict[str, Any], EventHandlerDict]: - separated_attributes = {} - separated_event_handlers: dict[str, EventHandlerType] = {} +) -> tuple[VdomAttributes, EventHandlerDict]: + _attributes: VdomAttributes = {} + _event_handlers: dict[str, EventHandlerType] = {} for k, v in attributes.items(): handler: EventHandlerType if callable(v): handler = EventHandler(to_event_handler_function(v)) - elif ( - # isinstance check on protocols is slow - use function attr pre-check as a - # quick filter before actually performing slow EventHandlerType type check - hasattr(v, "function") and isinstance(v, EventHandlerType) - ): + elif isinstance(v, EventHandler): handler = v else: - separated_attributes[k] = v + _attributes[k] = v continue - separated_event_handlers[k] = handler + _event_handlers[k] = handler - return separated_attributes, dict(separated_event_handlers.items()) + return _attributes, _event_handlers -def _is_attributes(value: Any) -> bool: - return isinstance(value, Mapping) and "tagName" not in value +def _flatten_children(children: Sequence[Any]) -> list[Any]: + _children: list[VdomChildren] = [] + for child in children: + if _is_single_child(child): + _children.append(child) + else: + _children.extend(_flatten_children(child)) + return _children def _is_single_child(value: Any) -> bool: @@ -292,20 +253,5 @@ def _validate_child_key_integrity(value: Any) -> None: warn(f"Key not specified for child in list {child}", UserWarning) elif isinstance(child, Mapping) and "key" not in child: # remove 'children' to reduce log spam - child_copy = {**child, "children": _EllipsisRepr()} + child_copy = {**child, "children": EllipsisRepr()} warn(f"Key not specified for child in list {child_copy}", UserWarning) - - -class _CustomVdomDictConstructor(Protocol): - def __call__( - self, - attributes: VdomAttributes, - children: Sequence[VdomChild], - key: Key | None, - event_handlers: EventHandlerDict, - ) -> VdomDict: ... - - -class _EllipsisRepr: - def __repr__(self) -> str: - return "..." diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index 74c3f3f92..027fb3e3b 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.types import VdomDict +from reactpy.types import VdomAttributes, VdomDict class RequiredTransforms: @@ -20,7 +20,7 @@ def __init__(self, vdom: VdomDict, intercept_links: bool = True) -> None: if not name.startswith("_"): getattr(self, name)(vdom) - def normalize_style_attributes(self, vdom: VdomDict) -> None: + def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: """Convert style attribute from str -> dict with camelCase keys""" if ( "attributes" in vdom @@ -40,10 +40,11 @@ def normalize_style_attributes(self, vdom: VdomDict) -> None: def html_props_to_reactjs(vdom: VdomDict) -> None: """Convert HTML prop names to their ReactJS equivalents.""" if "attributes" in vdom: - vdom["attributes"] = { - REACT_PROP_SUBSTITUTIONS.get(k, k): v - for k, v in vdom["attributes"].items() - } + items = cast(VdomAttributes, vdom["attributes"].items()) + vdom["attributes"] = cast( + VdomAttributes, + {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in items}, + ) @staticmethod def textarea_children_to_prop(vdom: VdomDict) -> None: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 189915873..ba8ce31f0 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -1,37 +1,29 @@ from __future__ import annotations -import sys -from collections import namedtuple -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from types import TracebackType from typing import ( - TYPE_CHECKING, Any, Callable, Generic, Literal, - NamedTuple, Protocol, TypeVar, + overload, runtime_checkable, ) -from typing_extensions import TypeAlias, TypedDict +from typing_extensions import NamedTuple, NotRequired, TypeAlias, TypedDict, Unpack CarrierType = TypeVar("CarrierType") _Type = TypeVar("_Type") -if TYPE_CHECKING or sys.version_info >= (3, 11): - - class State(NamedTuple, Generic[_Type]): - value: _Type - set_value: Callable[[_Type | Callable[[_Type], _Type]], None] - -else: # nocov - State = namedtuple("State", ("value", "set_value")) +class State(NamedTuple, Generic[_Type]): + value: _Type + set_value: Callable[[_Type | Callable[[_Type], _Type]], None] ComponentConstructor = Callable[..., "ComponentType"] @@ -41,7 +33,7 @@ class State(NamedTuple, Generic[_Type]): """The root component should be constructed by a function accepting no arguments.""" -Key: TypeAlias = "str | int" +Key: TypeAlias = str | int @runtime_checkable @@ -92,30 +84,775 @@ async def __aexit__( """Clean up the view after its final render""" -VdomAttributes = dict[str, Any] -"""Describes the attributes of a :class:`VdomDict`""" - -VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" -"""A single child element of a :class:`VdomDict`""" - -VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" -"""Describes a series of :class:`VdomChild` elements""" - - -class _VdomDictOptional(TypedDict, total=False): - key: Key | None - children: Sequence[ComponentType | VdomChild] - attributes: VdomAttributes - eventHandlers: EventHandlerDict - importSource: ImportSourceDict +class CssStyleTypeDict(TypedDict, total=False): + # TODO: This could generated by parsing from `csstype` in the future + # https://www.npmjs.com/package/csstype + accentColor: str | int + alignContent: str | int + alignItems: str | int + alignSelf: str | int + alignTracks: str | int + all: str | int + animation: str | int + animationComposition: str | int + animationDelay: str | int + animationDirection: str | int + animationDuration: str | int + animationFillMode: str | int + animationIterationCount: str | int + animationName: str | int + animationPlayState: str | int + animationTimeline: str | int + animationTimingFunction: str | int + appearance: str | int + aspectRatio: str | int + backdropFilter: str | int + backfaceVisibility: str | int + background: str | int + backgroundAttachment: str | int + backgroundBlendMode: str | int + backgroundClip: str | int + backgroundColor: str | int + backgroundImage: str | int + backgroundOrigin: str | int + backgroundPosition: str | int + backgroundPositionX: str | int + backgroundPositionY: str | int + backgroundRepeat: str | int + backgroundSize: str | int + blockOverflow: str | int + blockSize: str | int + border: str | int + borderBlock: str | int + borderBlockColor: str | int + borderBlockEnd: str | int + borderBlockEndColor: str | int + borderBlockEndStyle: str | int + borderBlockEndWidth: str | int + borderBlockStart: str | int + borderBlockStartColor: str | int + borderBlockStartStyle: str | int + borderBlockStartWidth: str | int + borderBlockStyle: str | int + borderBlockWidth: str | int + borderBottom: str | int + borderBottomColor: str | int + borderBottomLeftRadius: str | int + borderBottomRightRadius: str | int + borderBottomStyle: str | int + borderBottomWidth: str | int + borderCollapse: str | int + borderColor: str | int + borderEndEndRadius: str | int + borderEndStartRadius: str | int + borderImage: str | int + borderImageOutset: str | int + borderImageRepeat: str | int + borderImageSlice: str | int + borderImageSource: str | int + borderImageWidth: str | int + borderInline: str | int + borderInlineColor: str | int + borderInlineEnd: str | int + borderInlineEndColor: str | int + borderInlineEndStyle: str | int + borderInlineEndWidth: str | int + borderInlineStart: str | int + borderInlineStartColor: str | int + borderInlineStartStyle: str | int + borderInlineStartWidth: str | int + borderInlineStyle: str | int + borderInlineWidth: str | int + borderLeft: str | int + borderLeftColor: str | int + borderLeftStyle: str | int + borderLeftWidth: str | int + borderRadius: str | int + borderRight: str | int + borderRightColor: str | int + borderRightStyle: str | int + borderRightWidth: str | int + borderSpacing: str | int + borderStartEndRadius: str | int + borderStartStartRadius: str | int + borderStyle: str | int + borderTop: str | int + borderTopColor: str | int + borderTopLeftRadius: str | int + borderTopRightRadius: str | int + borderTopStyle: str | int + borderTopWidth: str | int + borderWidth: str | int + bottom: str | int + boxDecorationBreak: str | int + boxShadow: str | int + boxSizing: str | int + breakAfter: str | int + breakBefore: str | int + breakInside: str | int + captionSide: str | int + caret: str | int + caretColor: str | int + caretShape: str | int + clear: str | int + clip: str | int + clipPath: str | int + color: str | int + colorScheme: str | int + columnCount: str | int + columnFill: str | int + columnGap: str | int + columnRule: str | int + columnRuleColor: str | int + columnRuleStyle: str | int + columnRuleWidth: str | int + columnSpan: str | int + columnWidth: str | int + columns: str | int + contain: str | int + containIntrinsicBlockSize: str | int + containIntrinsicHeight: str | int + containIntrinsicInlineSize: str | int + containIntrinsicSize: str | int + containIntrinsicWidth: str | int + content: str | int + contentVisibility: str | int + counterIncrement: str | int + counterReset: str | int + counterSet: str | int + cursor: str | int + direction: str | int + display: str | int + emptyCells: str | int + filter: str | int + flex: str | int + flexBasis: str | int + flexDirection: str | int + flexFlow: str | int + flexGrow: str | int + flexShrink: str | int + flexWrap: str | int + float: str | int + font: str | int + fontFamily: str | int + fontFeatureSettings: str | int + fontKerning: str | int + fontLanguageOverride: str | int + fontOpticalSizing: str | int + fontSize: str | int + fontSizeAdjust: str | int + fontStretch: str | int + fontStyle: str | int + fontSynthesis: str | int + fontVariant: str | int + fontVariantAlternates: str | int + fontVariantCaps: str | int + fontVariantEastAsian: str | int + fontVariantLigatures: str | int + fontVariantNumeric: str | int + fontVariantPosition: str | int + fontVariationSettings: str | int + fontWeight: str | int + forcedColorAdjust: str | int + gap: str | int + grid: str | int + gridArea: str | int + gridAutoColumns: str | int + gridAutoFlow: str | int + gridAutoRows: str | int + gridColumn: str | int + gridColumnEnd: str | int + gridColumnStart: str | int + gridRow: str | int + gridRowEnd: str | int + gridRowStart: str | int + gridTemplate: str | int + gridTemplateAreas: str | int + gridTemplateColumns: str | int + gridTemplateRows: str | int + hangingPunctuation: str | int + height: str | int + hyphenateCharacter: str | int + hyphenateLimitChars: str | int + hyphens: str | int + imageOrientation: str | int + imageRendering: str | int + imageResolution: str | int + inherit: str | int + initial: str | int + initialLetter: str | int + initialLetterAlign: str | int + inlineSize: str | int + inputSecurity: str | int + inset: str | int + insetBlock: str | int + insetBlockEnd: str | int + insetBlockStart: str | int + insetInline: str | int + insetInlineEnd: str | int + insetInlineStart: str | int + isolation: str | int + justifyContent: str | int + justifyItems: str | int + justifySelf: str | int + justifyTracks: str | int + left: str | int + letterSpacing: str | int + lineBreak: str | int + lineClamp: str | int + lineHeight: str | int + lineHeightStep: str | int + listStyle: str | int + listStyleImage: str | int + listStylePosition: str | int + listStyleType: str | int + margin: str | int + marginBlock: str | int + marginBlockEnd: str | int + marginBlockStart: str | int + marginBottom: str | int + marginInline: str | int + marginInlineEnd: str | int + marginInlineStart: str | int + marginLeft: str | int + marginRight: str | int + marginTop: str | int + marginTrim: str | int + mask: str | int + maskBorder: str | int + maskBorderMode: str | int + maskBorderOutset: str | int + maskBorderRepeat: str | int + maskBorderSlice: str | int + maskBorderSource: str | int + maskBorderWidth: str | int + maskClip: str | int + maskComposite: str | int + maskImage: str | int + maskMode: str | int + maskOrigin: str | int + maskPosition: str | int + maskRepeat: str | int + maskSize: str | int + maskType: str | int + masonryAutoFlow: str | int + mathDepth: str | int + mathShift: str | int + mathStyle: str | int + maxBlockSize: str | int + maxHeight: str | int + maxInlineSize: str | int + maxLines: str | int + maxWidth: str | int + minBlockSize: str | int + minHeight: str | int + minInlineSize: str | int + minWidth: str | int + mixBlendMode: str | int + objectFit: str | int + objectPosition: str | int + offset: str | int + offsetAnchor: str | int + offsetDistance: str | int + offsetPath: str | int + offsetPosition: str | int + offsetRotate: str | int + opacity: str | int + order: str | int + orphans: str | int + outline: str | int + outlineColor: str | int + outlineOffset: str | int + outlineStyle: str | int + outlineWidth: str | int + overflow: str | int + overflowAnchor: str | int + overflowBlock: str | int + overflowClipMargin: str | int + overflowInline: str | int + overflowWrap: str | int + overflowX: str | int + overflowY: str | int + overscrollBehavior: str | int + overscrollBehaviorBlock: str | int + overscrollBehaviorInline: str | int + overscrollBehaviorX: str | int + overscrollBehaviorY: str | int + padding: str | int + paddingBlock: str | int + paddingBlockEnd: str | int + paddingBlockStart: str | int + paddingBottom: str | int + paddingInline: str | int + paddingInlineEnd: str | int + paddingInlineStart: str | int + paddingLeft: str | int + paddingRight: str | int + paddingTop: str | int + pageBreakAfter: str | int + pageBreakBefore: str | int + pageBreakInside: str | int + paintOrder: str | int + perspective: str | int + perspectiveOrigin: str | int + placeContent: str | int + placeItems: str | int + placeSelf: str | int + pointerEvents: str | int + position: str | int + printColorAdjust: str | int + quotes: str | int + resize: str | int + revert: str | int + right: str | int + rotate: str | int + rowGap: str | int + rubyAlign: str | int + rubyMerge: str | int + rubyPosition: str | int + scale: str | int + scrollBehavior: str | int + scrollMargin: str | int + scrollMarginBlock: str | int + scrollMarginBlockEnd: str | int + scrollMarginBlockStart: str | int + scrollMarginBottom: str | int + scrollMarginInline: str | int + scrollMarginInlineEnd: str | int + scrollMarginInlineStart: str | int + scrollMarginLeft: str | int + scrollMarginRight: str | int + scrollMarginTop: str | int + scrollPadding: str | int + scrollPaddingBlock: str | int + scrollPaddingBlockEnd: str | int + scrollPaddingBlockStart: str | int + scrollPaddingBottom: str | int + scrollPaddingInline: str | int + scrollPaddingInlineEnd: str | int + scrollPaddingInlineStart: str | int + scrollPaddingLeft: str | int + scrollPaddingRight: str | int + scrollPaddingTop: str | int + scrollSnapAlign: str | int + scrollSnapStop: str | int + scrollSnapType: str | int + scrollTimeline: str | int + scrollTimelineAxis: str | int + scrollTimelineName: str | int + scrollbarColor: str | int + scrollbarGutter: str | int + scrollbarWidth: str | int + shapeImageThreshold: str | int + shapeMargin: str | int + shapeOutside: str | int + tabSize: str | int + tableLayout: str | int + textAlign: str | int + textAlignLast: str | int + textCombineUpright: str | int + textDecoration: str | int + textDecorationColor: str | int + textDecorationLine: str | int + textDecorationSkip: str | int + textDecorationSkipInk: str | int + textDecorationStyle: str | int + textDecorationThickness: str | int + textEmphasis: str | int + textEmphasisColor: str | int + textEmphasisPosition: str | int + textEmphasisStyle: str | int + textIndent: str | int + textJustify: str | int + textOrientation: str | int + textOverflow: str | int + textRendering: str | int + textShadow: str | int + textSizeAdjust: str | int + textTransform: str | int + textUnderlineOffset: str | int + textUnderlinePosition: str | int + top: str | int + touchAction: str | int + transform: str | int + transformBox: str | int + transformOrigin: str | int + transformStyle: str | int + transition: str | int + transitionDelay: str | int + transitionDuration: str | int + transitionProperty: str | int + transitionTimingFunction: str | int + translate: str | int + unicodeBidi: str | int + unset: str | int + userSelect: str | int + verticalAlign: str | int + visibility: str | int + whiteSpace: str | int + widows: str | int + width: str | int + willChange: str | int + wordBreak: str | int + wordSpacing: str | int + wordWrap: str | int + writingMode: str | int + zIndex: str | int + + +# TODO: Enable `extra_items` on `CssStyleDict` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +CssStyleDict = CssStyleTypeDict | dict[str, Any] + +EventFunc = Callable[[dict[str, Any]], Awaitable[None] | None] + + +class DangerouslySetInnerHTML(TypedDict): + __html: str + + +# TODO: It's probably better to break this one attributes dict down into what each specific +# HTML node's attributes can be, and make sure those types are resolved correctly within `HtmlConstructor` +# TODO: This could be generated by parsing from `@types/react` in the future +# https://www.npmjs.com/package/@types/react?activeTab=code +VdomAttributesTypeDict = TypedDict( + "VdomAttributesTypeDict", + { + "key": Key, + "value": Any, + "defaultValue": Any, + "dangerouslySetInnerHTML": DangerouslySetInnerHTML, + "suppressContentEditableWarning": bool, + "suppressHydrationWarning": bool, + "style": CssStyleDict, + "accessKey": str, + "aria-": None, + "autoCapitalize": str, + "className": str, + "contentEditable": bool, + "data-": None, + "dir": Literal["ltr", "rtl"], + "draggable": bool, + "enterKeyHint": str, + "htmlFor": str, + "hidden": bool | str, + "id": str, + "is": str, + "inputMode": str, + "itemProp": str, + "lang": str, + "onAnimationEnd": EventFunc, + "onAnimationEndCapture": EventFunc, + "onAnimationIteration": EventFunc, + "onAnimationIterationCapture": EventFunc, + "onAnimationStart": EventFunc, + "onAnimationStartCapture": EventFunc, + "onAuxClick": EventFunc, + "onAuxClickCapture": EventFunc, + "onBeforeInput": EventFunc, + "onBeforeInputCapture": EventFunc, + "onBlur": EventFunc, + "onBlurCapture": EventFunc, + "onClick": EventFunc, + "onClickCapture": EventFunc, + "onCompositionStart": EventFunc, + "onCompositionStartCapture": EventFunc, + "onCompositionEnd": EventFunc, + "onCompositionEndCapture": EventFunc, + "onCompositionUpdate": EventFunc, + "onCompositionUpdateCapture": EventFunc, + "onContextMenu": EventFunc, + "onContextMenuCapture": EventFunc, + "onCopy": EventFunc, + "onCopyCapture": EventFunc, + "onCut": EventFunc, + "onCutCapture": EventFunc, + "onDoubleClick": EventFunc, + "onDoubleClickCapture": EventFunc, + "onDrag": EventFunc, + "onDragCapture": EventFunc, + "onDragEnd": EventFunc, + "onDragEndCapture": EventFunc, + "onDragEnter": EventFunc, + "onDragEnterCapture": EventFunc, + "onDragOver": EventFunc, + "onDragOverCapture": EventFunc, + "onDragStart": EventFunc, + "onDragStartCapture": EventFunc, + "onDrop": EventFunc, + "onDropCapture": EventFunc, + "onFocus": EventFunc, + "onFocusCapture": EventFunc, + "onGotPointerCapture": EventFunc, + "onGotPointerCaptureCapture": EventFunc, + "onKeyDown": EventFunc, + "onKeyDownCapture": EventFunc, + "onKeyPress": EventFunc, + "onKeyPressCapture": EventFunc, + "onKeyUp": EventFunc, + "onKeyUpCapture": EventFunc, + "onLostPointerCapture": EventFunc, + "onLostPointerCaptureCapture": EventFunc, + "onMouseDown": EventFunc, + "onMouseDownCapture": EventFunc, + "onMouseEnter": EventFunc, + "onMouseLeave": EventFunc, + "onMouseMove": EventFunc, + "onMouseMoveCapture": EventFunc, + "onMouseOut": EventFunc, + "onMouseOutCapture": EventFunc, + "onMouseUp": EventFunc, + "onMouseUpCapture": EventFunc, + "onPointerCancel": EventFunc, + "onPointerCancelCapture": EventFunc, + "onPointerDown": EventFunc, + "onPointerDownCapture": EventFunc, + "onPointerEnter": EventFunc, + "onPointerLeave": EventFunc, + "onPointerMove": EventFunc, + "onPointerMoveCapture": EventFunc, + "onPointerOut": EventFunc, + "onPointerOutCapture": EventFunc, + "onPointerUp": EventFunc, + "onPointerUpCapture": EventFunc, + "onPaste": EventFunc, + "onPasteCapture": EventFunc, + "onScroll": EventFunc, + "onScrollCapture": EventFunc, + "onSelect": EventFunc, + "onSelectCapture": EventFunc, + "onTouchCancel": EventFunc, + "onTouchCancelCapture": EventFunc, + "onTouchEnd": EventFunc, + "onTouchEndCapture": EventFunc, + "onTouchMove": EventFunc, + "onTouchMoveCapture": EventFunc, + "onTouchStart": EventFunc, + "onTouchStartCapture": EventFunc, + "onTransitionEnd": EventFunc, + "onTransitionEndCapture": EventFunc, + "onWheel": EventFunc, + "onWheelCapture": EventFunc, + "role": str, + "slot": str, + "spellCheck": bool | None, + "tabIndex": int, + "title": str, + "translate": Literal["yes", "no"], + "onReset": EventFunc, + "onResetCapture": EventFunc, + "onSubmit": EventFunc, + "onSubmitCapture": EventFunc, + "formAction": str | Callable, + "checked": bool, + "defaultChecked": bool, + "accept": str, + "alt": str, + "capture": str, + "autoComplete": str, + "autoFocus": bool, + "dirname": str, + "disabled": bool, + "form": str, + "formEnctype": str, + "formMethod": str, + "formNoValidate": str, + "formTarget": str, + "height": str, + "list": str, + "max": int, + "maxLength": int, + "min": int, + "minLength": int, + "multiple": bool, + "name": str, + "onChange": EventFunc, + "onChangeCapture": EventFunc, + "onInput": EventFunc, + "onInputCapture": EventFunc, + "onInvalid": EventFunc, + "onInvalidCapture": EventFunc, + "pattern": str, + "placeholder": str, + "readOnly": bool, + "required": bool, + "size": int, + "src": str, + "step": int | Literal["any"], + "type": str, + "width": str, + "label": str, + "cols": int, + "rows": int, + "wrap": Literal["hard", "soft", "off"], + "rel": str, + "precedence": str, + "media": str, + "onError": EventFunc, + "onLoad": EventFunc, + "as": str, + "imageSrcSet": str, + "imageSizes": str, + "sizes": str, + "href": str, + "crossOrigin": str, + "referrerPolicy": str, + "fetchPriority": str, + "hrefLang": str, + "integrity": str, + "blocking": str, + "async": bool, + "noModule": bool, + "nonce": str, + "referrer": str, + "defer": str, + "onToggle": EventFunc, + "onToggleCapture": EventFunc, + "onLoadCapture": EventFunc, + "onErrorCapture": EventFunc, + "onAbort": EventFunc, + "onAbortCapture": EventFunc, + "onCanPlay": EventFunc, + "onCanPlayCapture": EventFunc, + "onCanPlayThrough": EventFunc, + "onCanPlayThroughCapture": EventFunc, + "onDurationChange": EventFunc, + "onDurationChangeCapture": EventFunc, + "onEmptied": EventFunc, + "onEmptiedCapture": EventFunc, + "onEncrypted": EventFunc, + "onEncryptedCapture": EventFunc, + "onEnded": EventFunc, + "onEndedCapture": EventFunc, + "onLoadedData": EventFunc, + "onLoadedDataCapture": EventFunc, + "onLoadedMetadata": EventFunc, + "onLoadedMetadataCapture": EventFunc, + "onLoadStart": EventFunc, + "onLoadStartCapture": EventFunc, + "onPause": EventFunc, + "onPauseCapture": EventFunc, + "onPlay": EventFunc, + "onPlayCapture": EventFunc, + "onPlaying": EventFunc, + "onPlayingCapture": EventFunc, + "onProgress": EventFunc, + "onProgressCapture": EventFunc, + "onRateChange": EventFunc, + "onRateChangeCapture": EventFunc, + "onResize": EventFunc, + "onResizeCapture": EventFunc, + "onSeeked": EventFunc, + "onSeekedCapture": EventFunc, + "onSeeking": EventFunc, + "onSeekingCapture": EventFunc, + "onStalled": EventFunc, + "onStalledCapture": EventFunc, + "onSuspend": EventFunc, + "onSuspendCapture": EventFunc, + "onTimeUpdate": EventFunc, + "onTimeUpdateCapture": EventFunc, + "onVolumeChange": EventFunc, + "onVolumeChangeCapture": EventFunc, + "onWaiting": EventFunc, + "onWaitingCapture": EventFunc, + }, + total=False, +) +# TODO: Enable `extra_items` on `VdomAttributes` when PEP 728 is merged, likely in Python 3.14. Ref: https://peps.python.org/pep-0728/ +VdomAttributes = VdomAttributesTypeDict | dict[str, Any] + +VdomDictKeys = Literal[ + "tagName", + "key", + "children", + "attributes", + "eventHandlers", + "importSource", +] +ALLOWED_VDOM_KEYS = { + "tagName", + "key", + "children", + "attributes", + "eventHandlers", + "importSource", +} + + +class VdomTypeDict(TypedDict): + """TypedDict representation of what the `VdomDict` should look like.""" -class _VdomDictRequired(TypedDict, total=True): tagName: str + key: NotRequired[Key | None] + children: NotRequired[Sequence[ComponentType | VdomChild]] + attributes: NotRequired[VdomAttributes] + eventHandlers: NotRequired[EventHandlerDict] + importSource: NotRequired[ImportSourceDict] + + +class VdomDict(dict): + """A light wrapper around Python `dict` that represents a Virtual DOM element.""" + + def __init__(self, **kwargs: Unpack[VdomTypeDict]) -> None: + if "tagName" not in kwargs: + msg = "VdomDict requires a 'tagName' key." + raise ValueError(msg) + invalid_keys = set(kwargs) - ALLOWED_VDOM_KEYS + if invalid_keys: + msg = f"Invalid keys: {invalid_keys}." + raise ValueError(msg) + + super().__init__(**kwargs) + + @overload + def __getitem__(self, key: Literal["tagName"]) -> str: ... + @overload + def __getitem__(self, key: Literal["key"]) -> Key | None: ... + @overload + def __getitem__( + self, key: Literal["children"] + ) -> Sequence[ComponentType | VdomChild]: ... + @overload + def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ... + @overload + def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ... + @overload + def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ... + def __getitem__(self, key: VdomDictKeys) -> Any: + return super().__getitem__(key) + + @overload + def __setitem__(self, key: Literal["tagName"], value: str) -> None: ... + @overload + def __setitem__(self, key: Literal["key"], value: Key | None) -> None: ... + @overload + def __setitem__( + self, key: Literal["children"], value: Sequence[ComponentType | VdomChild] + ) -> None: ... + @overload + def __setitem__( + self, key: Literal["attributes"], value: VdomAttributes + ) -> None: ... + @overload + def __setitem__( + self, key: Literal["eventHandlers"], value: EventHandlerDict + ) -> None: ... + @overload + def __setitem__( + self, key: Literal["importSource"], value: ImportSourceDict + ) -> None: ... + def __setitem__(self, key: VdomDictKeys, value: Any) -> None: + if key not in ALLOWED_VDOM_KEYS: + raise KeyError(f"Invalid key: {key}") + super().__setitem__(key, value) + + +VdomChild: TypeAlias = ComponentType | VdomDict | str | None | Any +"""A single child element of a :class:`VdomDict`""" - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A :ref:`VDOM` dictionary""" +VdomChildren: TypeAlias = Sequence[VdomChild] | VdomChild +"""Describes a series of :class:`VdomChild` elements""" class ImportSourceDict(TypedDict): @@ -125,41 +862,29 @@ class ImportSourceDict(TypedDict): unmountBeforeUpdate: bool -class _OptionalVdomJson(TypedDict, total=False): - key: Key - error: str - children: list[Any] - attributes: dict[str, Any] - eventHandlers: dict[str, _JsonEventTarget] - importSource: _JsonImportSource - +class VdomJson(TypedDict): + """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" -class _RequiredVdomJson(TypedDict, total=True): tagName: str + key: NotRequired[Key] + error: NotRequired[str] + children: NotRequired[list[Any]] + attributes: NotRequired[VdomAttributes] + eventHandlers: NotRequired[dict[str, JsonEventTarget]] + importSource: NotRequired[JsonImportSource] -class VdomJson(_RequiredVdomJson, _OptionalVdomJson): - """A JSON serializable form of :class:`VdomDict` matching the :data:`VDOM_JSON_SCHEMA`""" - - -class _JsonEventTarget(TypedDict): +class JsonEventTarget(TypedDict): target: str preventDefault: bool stopPropagation: bool -class _JsonImportSource(TypedDict): +class JsonImportSource(TypedDict): source: str fallback: Any -EventHandlerMapping = Mapping[str, "EventHandlerType"] -"""A generic mapping between event names to their handlers""" - -EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" -"""A dict mapping between event names to their handlers""" - - class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" @@ -191,9 +916,24 @@ class EventHandlerType(Protocol): """ -class VdomDictConstructor(Protocol): +EventHandlerMapping = Mapping[str, EventHandlerType] +"""A generic mapping between event names to their handlers""" + +EventHandlerDict: TypeAlias = dict[str, EventHandlerType] +"""A dict mapping between event names to their handlers""" + + +class VdomConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" + @overload + def __call__( + self, attributes: VdomAttributes, /, *children: VdomChildren + ) -> VdomDict: ... + + @overload + def __call__(self, *children: VdomChildren) -> VdomDict: ... + def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: ... @@ -294,3 +1034,18 @@ class PyScriptOptions(TypedDict, total=False): extra_py: Sequence[str] extra_js: dict[str, Any] | str config: dict[str, Any] | str + + +class CustomVdomConstructor(Protocol): + def __call__( + self, + attributes: VdomAttributes, + children: Sequence[VdomChildren], + key: Key | None, + event_handlers: EventHandlerDict, + ) -> VdomDict: ... + + +class EllipsisRepr: + def __repr__(self) -> str: + return "..." diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 666b97241..2bbe675ac 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -7,9 +7,9 @@ from typing import Any, Callable, Generic, TypeVar, cast from lxml import etree -from lxml.html import fromstring, tostring +from lxml.html import fromstring -from reactpy.core.vdom import vdom as make_vdom +from reactpy import html from reactpy.transforms import RequiredTransforms from reactpy.types import ComponentType, VdomDict @@ -73,7 +73,7 @@ def reactpy_to_string(root: VdomDict | ComponentType) -> str: root = component_to_vdom(root) _add_vdom_to_etree(temp_container, root) - html = cast(bytes, tostring(temp_container)).decode() # type: ignore + html = etree.tostring(temp_container, method="html").decode() # Strip out temp root <__temp__> element return html[10:-11] @@ -149,7 +149,8 @@ def _etree_to_vdom( children = _generate_vdom_children(node, transforms, intercept_links) # Convert the lxml node to a VDOM dict - el = make_vdom(str(node.tag), dict(node.items()), *children) + constructor = getattr(html, str(node.tag)) + el = constructor(dict(node.items()), children) # Perform necessary transformations on the VDOM attributes to meet VDOM spec RequiredTransforms(el, intercept_links) @@ -236,8 +237,8 @@ def component_to_vdom(component: ComponentType) -> VdomDict: if hasattr(result, "render"): return component_to_vdom(cast(ComponentType, result)) elif isinstance(result, str): - return make_vdom("div", {}, result) - return make_vdom("") + return html.div(result) + return html.fragment() def _react_attribute_to_html(key: str, value: Any) -> tuple[str, str]: diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 5148c9669..04c898338 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -8,8 +8,8 @@ from typing import Any, NewType, overload from reactpy.config import REACTPY_DEBUG, REACTPY_WEB_MODULES_DIR -from reactpy.core.vdom import make_vdom_constructor -from reactpy.types import ImportSourceDict, VdomDictConstructor +from reactpy.core.vdom import Vdom +from reactpy.types import ImportSourceDict, VdomConstructor from reactpy.web.utils import ( module_name_suffix, resolve_module_exports_from_file, @@ -227,7 +227,7 @@ def export( export_names: str, fallback: Any | None = ..., allow_children: bool = ..., -) -> VdomDictConstructor: ... +) -> VdomConstructor: ... @overload @@ -236,7 +236,7 @@ def export( export_names: list[str] | tuple[str, ...], fallback: Any | None = ..., allow_children: bool = ..., -) -> list[VdomDictConstructor]: ... +) -> list[VdomConstructor]: ... def export( @@ -244,7 +244,7 @@ def export( export_names: str | list[str] | tuple[str, ...], fallback: Any | None = None, allow_children: bool = True, -) -> VdomDictConstructor | list[VdomDictConstructor]: +) -> VdomConstructor | list[VdomConstructor]: """Return one or more VDOM constructors from a :class:`WebModule` Parameters: @@ -282,8 +282,8 @@ def _make_export( name: str, fallback: Any | None, allow_children: bool, -) -> VdomDictConstructor: - return make_vdom_constructor( +) -> VdomConstructor: + return Vdom( name, allow_children=allow_children, import_source=ImportSourceDict( diff --git a/src/reactpy/widgets.py b/src/reactpy/widgets.py index 01532b277..34242a189 100644 --- a/src/reactpy/widgets.py +++ b/src/reactpy/widgets.py @@ -7,13 +7,13 @@ import reactpy from reactpy._html import html from reactpy._warnings import warn -from reactpy.types import ComponentConstructor, VdomDict +from reactpy.types import ComponentConstructor, VdomAttributes, VdomDict def image( format: str, value: str | bytes = "", - attributes: dict[str, Any] | None = None, + attributes: VdomAttributes | None = None, ) -> VdomDict: """Utility for constructing an image from a string or bytes @@ -30,7 +30,7 @@ def image( base64_value = b64encode(bytes_value).decode() src = f"data:image/{format};base64,{base64_value}" - return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} + return VdomDict(tagName="img", attributes={"src": src, **(attributes or {})}) _Value = TypeVar("_Value") diff --git a/tests/sample.py b/tests/sample.py index fe5dfde07..0c24144c7 100644 --- a/tests/sample.py +++ b/tests/sample.py @@ -2,11 +2,10 @@ from reactpy import html from reactpy.core.component import component -from reactpy.types import VdomDict @component -def SampleApp() -> VdomDict: +def SampleApp(): return html.div( {"id": "sample", "style": {"padding": "15px"}}, html.h1("Sample Application"), diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index aa8996d4e..4cbfebd54 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -27,7 +27,7 @@ def SimpleDiv(): async def test_simple_parameterized_component(): @reactpy.component def SimpleParamComponent(tag): - return reactpy.vdom(tag) + return reactpy.Vdom(tag)() assert SimpleParamComponent("div").render() == {"tagName": "div"} diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index b4de2e7e9..eb292a1f0 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -82,7 +82,7 @@ async def test_simple_layout(): @reactpy.component def SimpleComponent(): tag, set_state_hook.current = reactpy.hooks.use_state("div") - return reactpy.vdom(tag) + return reactpy.Vdom(tag)() async with reactpy.Layout(SimpleComponent()) as layout: update_1 = await layout.render() diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 8e349fcc4..2bbbf442f 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -6,8 +6,8 @@ import reactpy from reactpy.config import REACTPY_DEBUG from reactpy.core.events import EventHandler -from reactpy.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json -from reactpy.types import VdomDict +from reactpy.core.vdom import Vdom, is_vdom, validate_vdom_json +from reactpy.types import VdomDict, VdomTypeDict FAKE_EVENT_HANDLER = EventHandler(lambda data: None) FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} @@ -18,40 +18,46 @@ [ (False, {}), (False, {"tagName": None}), - (False, VdomDict()), - (True, {"tagName": ""}), + (False, {"tagName": ""}), + (False, VdomTypeDict(tagName="div")), (True, VdomDict(tagName="")), + (True, VdomDict(tagName="div")), ], ) def test_is_vdom(result, value): - assert is_vdom(value) == result + assert result == is_vdom(value) @pytest.mark.parametrize( "actual, expected", [ ( - reactpy.vdom("div", [reactpy.vdom("div")]), + reactpy.Vdom("div")([reactpy.Vdom("div")()]), {"tagName": "div", "children": [{"tagName": "div"}]}, ), ( - reactpy.vdom("div", {"style": {"backgroundColor": "red"}}), + reactpy.Vdom("div")({"style": {"backgroundColor": "red"}}), {"tagName": "div", "attributes": {"style": {"backgroundColor": "red"}}}, ), ( # multiple iterables of children are merged - reactpy.vdom("div", [reactpy.vdom("div"), 1], (reactpy.vdom("div"), 2)), + reactpy.Vdom("div")( + ( + [reactpy.Vdom("div")(), 1], + (reactpy.Vdom("div")(), 2), + ) + ), { "tagName": "div", "children": [{"tagName": "div"}, 1, {"tagName": "div"}, 2], }, ), ( - reactpy.vdom("div", {"onEvent": FAKE_EVENT_HANDLER}), + reactpy.Vdom("div")({"onEvent": FAKE_EVENT_HANDLER}), {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, ), ( - reactpy.vdom("div", reactpy.html.h1("hello"), reactpy.html.h2("world")), + reactpy.Vdom("div")((reactpy.html.h1("hello"), reactpy.html.h2("world"))), { "tagName": "div", "children": [ @@ -61,17 +67,21 @@ def test_is_vdom(result, value): }, ), ( - reactpy.vdom("div", {"tagName": "div"}), - {"tagName": "div", "children": [{"tagName": "div"}]}, + reactpy.Vdom("div")({"tagName": "div"}), + {"tagName": "div", "attributes": {"tagName": "div"}}, ), ( - reactpy.vdom("div", (i for i in range(3))), + reactpy.Vdom("div")((i for i in range(3))), {"tagName": "div", "children": [0, 1, 2]}, ), ( - reactpy.vdom("div", (x**2 for x in [1, 2, 3])), + reactpy.Vdom("div")((x**2 for x in [1, 2, 3])), {"tagName": "div", "children": [1, 4, 9]}, ), + ( + reactpy.Vdom("div")(["child_1", ["child_2"]]), + {"tagName": "div", "children": ["child_1", "child_2"]}, + ), ], ) def test_simple_node_construction(actual, expected): @@ -81,8 +91,8 @@ def test_simple_node_construction(actual, expected): async def test_callable_attributes_are_cast_to_event_handlers(): params_from_calls = [] - node = reactpy.vdom( - "div", {"onEvent": lambda *args: params_from_calls.append(args)} + node = reactpy.Vdom("div")( + {"onEvent": lambda *args: params_from_calls.append(args)} ) event_handlers = node.pop("eventHandlers") @@ -97,7 +107,7 @@ async def test_callable_attributes_are_cast_to_event_handlers(): def test_make_vdom_constructor(): - elmt = make_vdom_constructor("some-tag") + elmt = Vdom("some-tag") assert elmt({"data": 1}, [elmt()]) == { "tagName": "some-tag", @@ -105,7 +115,7 @@ def test_make_vdom_constructor(): "attributes": {"data": 1}, } - no_children = make_vdom_constructor("no-children", allow_children=False) + no_children = Vdom("no-children", allow_children=False) with pytest.raises(TypeError, match="cannot have children"): no_children([1, 2, 3]) @@ -283,7 +293,7 @@ def test_invalid_vdom(value, error_message_pattern): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: - reactpy.vdom("div", (1 for i in range(10))) + reactpy.Vdom("div")((1 for i in range(10))) assert len(record) == 1 assert ( record[0] @@ -295,16 +305,16 @@ def test_warn_cannot_verify_keypath_for_genereators(): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_dynamic_children_must_have_keys(): with pytest.warns(UserWarning) as record: - reactpy.vdom("div", [reactpy.vdom("div")]) + reactpy.Vdom("div")([reactpy.Vdom("div")()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @reactpy.component def MyComponent(): - return reactpy.vdom("div") + return reactpy.Vdom("div")() with pytest.warns(UserWarning) as record: - reactpy.vdom("div", [MyComponent()]) + reactpy.Vdom("div")([MyComponent()]) assert len(record) == 1 assert record[0].message.args[0].startswith("Key not specified for child") @@ -313,3 +323,14 @@ def MyComponent(): def test_raise_for_non_json_attrs(): with pytest.raises(TypeError, match="JSON serializable"): reactpy.html.div({"nonJsonSerializableObject": object()}) + + +def test_invalid_vdom_keys(): + with pytest.raises(ValueError, match="Invalid keys:*"): + reactpy.types.VdomDict(tagName="test", foo="bar") + + with pytest.raises(KeyError, match="Invalid key:*"): + reactpy.types.VdomDict(tagName="test")["foo"] = "bar" + + with pytest.raises(ValueError, match="VdomDict requires a 'tagName' key."): + reactpy.types.VdomDict(foo="bar") From 9c32dc142721b9fd12dd0aecaa5b43074602a8d5 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Wed, 12 Mar 2025 15:31:11 -0600 Subject: [PATCH 13/17] Ensures key is properly propagated to importedElements (#1271) --- docs/source/about/changelog.rst | 2 + src/reactpy/_html.py | 1 + src/reactpy/core/vdom.py | 2 +- src/reactpy/transforms.py | 2 +- tests/test_utils.py | 4 +- .../js_fixtures/keys-properly-propagated.js | 14 +++++ tests/test_web/test_module.py | 60 +++++++++++++++++++ 7 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 tests/test_web/js_fixtures/keys-properly-propagated.js diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index b2d605890..0db168ba2 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -16,6 +16,7 @@ Unreleased ---------- **Added** + - :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. @@ -69,6 +70,7 @@ Unreleased **Fixed** - :pull:`1239` - Fixed a bug where script elements would not render to the DOM as plain text. +- :pull:`1271` - Fixed a bug where the ``key`` property provided via server-side ReactPy code was failing to propagate to the front-end JavaScript component. - :pull:`1254` - Fixed a bug where ``RuntimeError("Hook stack is in an invalid state")`` errors would be provided when using a webserver that reuses threads. v1.1.0 diff --git a/src/reactpy/_html.py b/src/reactpy/_html.py index 9f160b403..ffeee7072 100644 --- a/src/reactpy/_html.py +++ b/src/reactpy/_html.py @@ -104,6 +104,7 @@ def _fragment( event_handlers: EventHandlerDict, ) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" + attributes.pop("key", None) if attributes or event_handlers: msg = "Fragments cannot have attributes besides 'key'" raise TypeError(msg) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 4186ab5a6..6bc28dfd4 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -148,7 +148,7 @@ def __call__( ) -> VdomDict: """The entry point for the VDOM API, for example reactpy.html().""" attributes, children = separate_attributes_and_children(attributes_and_children) - key = attributes.pop("key", None) + key = attributes.get("key", None) attributes, event_handlers = separate_attributes_and_event_handlers(attributes) if REACTPY_CHECK_JSON_ATTRS.current: json.dumps(attributes) diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index 027fb3e3b..cdac48c7e 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -103,7 +103,7 @@ def infer_key_from_attributes(vdom: VdomDict) -> None: return # Infer 'key' from 'attributes.key' - key = attributes.pop("key", None) + key = attributes.get("key", None) # Infer 'key' from 'attributes.id' if key is None: diff --git a/tests/test_utils.py b/tests/test_utils.py index 7e334dda5..e494c29b3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -188,7 +188,7 @@ def test_string_to_reactpy(case): # 8: Infer ReactJS `key` from the `key` attribute { "source": '
', - "model": {"tagName": "div", "key": "my-key"}, + "model": {"tagName": "div", "attributes": {"key": "my-key"}, "key": "my-key"}, }, ], ) @@ -253,7 +253,7 @@ def test_non_html_tag_behavior(): "tagName": "my-tag", "attributes": {"data-x": "something"}, "children": [ - {"tagName": "my-other-tag", "key": "a-key"}, + {"tagName": "my-other-tag", "attributes": {"key": "a-key"}, "key": "a-key"}, ], } diff --git a/tests/test_web/js_fixtures/keys-properly-propagated.js b/tests/test_web/js_fixtures/keys-properly-propagated.js new file mode 100644 index 000000000..8d700397e --- /dev/null +++ b/tests/test_web/js_fixtures/keys-properly-propagated.js @@ -0,0 +1,14 @@ +import React from "https://esm.sh/react@19.0" +import ReactDOM from "https://esm.sh/react-dom@19.0/client" +import GridLayout from "https://esm.sh/react-grid-layout@1.5.0"; +export {GridLayout}; + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (type, props, children) => + React.createElement(type, props, children), + render: (element) => root.render(element, node), + unmount: () => root.unmount() + }; +} diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 8cd487c0c..4b5f980c4 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -208,6 +208,66 @@ async def test_imported_components_can_render_children(display: DisplayFixture): assert (await child.get_attribute("id")) == f"child-{index + 1}" +async def test_keys_properly_propagated(display: DisplayFixture): + """ + Fix https://github.com/reactive-python/reactpy/issues/1275 + + The `key` property was being lost in its propagation from the server-side ReactPy + definition to the front-end JavaScript. + + This property is required for certain JS components, such as the GridLayout from + react-grid-layout. + """ + module = reactpy.web.module_from_file( + "keys-properly-propagated", JS_FIXTURES_DIR / "keys-properly-propagated.js" + ) + GridLayout = reactpy.web.export(module, "GridLayout") + + await display.show( + lambda: GridLayout({ + "layout": [ + { + "i": "a", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "static": True, + }, + { + "i": "b", + "x": 1, + "y": 0, + "w": 3, + "h": 2, + "minW": 2, + "maxW": 4, + }, + { + "i": "c", + "x": 4, + "y": 0, + "w": 1, + "h": 2, + } + ], + "cols": 12, + "rowHeight": 30, + "width": 1200, + }, + reactpy.html.div({"key": "a"}, "a"), + reactpy.html.div({"key": "b"}, "b"), + reactpy.html.div({"key": "c"}, "c"), + ) + ) + + parent = await display.page.wait_for_selector(".react-grid-layout", state="attached") + children = await parent.query_selector_all("div") + + # The children simply will not render unless they receive the key prop + assert len(children) == 3 + + def test_module_from_string(): reactpy.web.module_from_string("temp", "old") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): From c890965d1c8189d7d8ef04cdf6554de80f0251b7 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Wed, 26 Mar 2025 03:03:05 -0600 Subject: [PATCH 14/17] Support subcomponent notation in `export` (#1285) --- .github/workflows/check.yml | 3 + docs/source/about/changelog.rst | 1 + src/js/packages/@reactpy/client/src/vdom.tsx | 53 ++++- src/reactpy/core/vdom.py | 11 + src/reactpy/web/module.py | 8 +- tests/test_core/test_vdom.py | 15 +- tests/test_utils.py | 6 +- .../js_fixtures/subcomponent-notation.js | 14 ++ tests/test_web/test_module.py | 189 ++++++++++++++---- 9 files changed, 252 insertions(+), 48 deletions(-) create mode 100644 tests/test_web/js_fixtures/subcomponent-notation.js diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 86a457136..4a8e58774 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -37,6 +37,9 @@ jobs: run-cmd: "hatch run docs:check" python-version: '["3.11"]' test-javascript: + # Temporarily disabled, tests are broken but a rewrite is intended + # https://github.com/reactive-python/reactpy/issues/1196 + if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "{1}" diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 0db168ba2..261d948c0 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -29,6 +29,7 @@ Unreleased - :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) - :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. - :pull:`1281` - Added type hints to ``reactpy.html`` attributes. +- :pull:`1285` - Added support for nested components in web modules **Changed** diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 25eb9f3e7..cae706787 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -78,15 +78,16 @@ function createImportSourceElement(props: { stringifyImportSource(props.model.importSource), ); return null; - } else if (!props.module[props.model.tagName]) { - log.error( - "Module from source " + - stringifyImportSource(props.currentImportSource) + - ` does not export ${props.model.tagName}`, - ); - return null; } else { - type = props.module[props.model.tagName]; + type = getComponentFromModule( + props.module, + props.model.tagName, + props.model.importSource, + ); + if (!type) { + // Error message logged within getComponentFromModule + return null; + } } } else { type = props.model.tagName; @@ -103,6 +104,42 @@ function createImportSourceElement(props: { ); } +function getComponentFromModule( + module: ReactPyModule, + componentName: string, + importSource: ReactPyVdomImportSource, +): any { + /* Gets the component with the provided name from the provided module. + + Built specifically to work on inifinitely deep nested components. + For example, component "My.Nested.Component" is accessed from + ModuleA like so: ModuleA["My"]["Nested"]["Component"]. + */ + const componentParts: string[] = componentName.split("."); + let Component: any = null; + for (let i = 0; i < componentParts.length; i++) { + const iterAttr = componentParts[i]; + Component = i == 0 ? module[iterAttr] : Component[iterAttr]; + if (!Component) { + if (i == 0) { + log.error( + "Module from source " + + stringifyImportSource(importSource) + + ` does not export ${iterAttr}`, + ); + } else { + console.error( + `Component ${componentParts.slice(0, i).join(".")} from source ` + + stringifyImportSource(importSource) + + ` does not have subcomponent ${iterAttr}`, + ); + } + break; + } + } + return Component; +} + function isImportSourceEqual( source1: ReactPyVdomImportSource, source2: ReactPyVdomImportSource, diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 6bc28dfd4..7ecddcf0e 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -135,6 +135,17 @@ def __init__( self.__module__ = module_name self.__qualname__ = f"{module_name}.{tag_name}" + def __getattr__(self, attr: str) -> Vdom: + """Supports accessing nested web module components""" + if not self.import_source: + msg = "Nested components can only be accessed on web module components." + raise AttributeError(msg) + return Vdom( + f"{self.__name__}.{attr}", + allow_children=self.allow_children, + import_source=self.import_source, + ) + @overload def __call__( self, attributes: VdomAttributes, /, *children: VdomChildren diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 04c898338..bd35f92cb 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -260,14 +260,18 @@ def export( if isinstance(export_names, str): if ( web_module.export_names is not None - and export_names not in web_module.export_names + and export_names.split(".")[0] not in web_module.export_names ): msg = f"{web_module.source!r} does not export {export_names!r}" raise ValueError(msg) return _make_export(web_module, export_names, fallback, allow_children) else: if web_module.export_names is not None: - missing = sorted(set(export_names).difference(web_module.export_names)) + missing = sorted( + {e.split(".")[0] for e in export_names}.difference( + web_module.export_names + ) + ) if missing: msg = f"{web_module.source!r} does not export {missing!r}" raise ValueError(msg) diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 2bbbf442f..68d27e6fa 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -71,11 +71,11 @@ def test_is_vdom(result, value): {"tagName": "div", "attributes": {"tagName": "div"}}, ), ( - reactpy.Vdom("div")((i for i in range(3))), + reactpy.Vdom("div")(i for i in range(3)), {"tagName": "div", "children": [0, 1, 2]}, ), ( - reactpy.Vdom("div")((x**2 for x in [1, 2, 3])), + reactpy.Vdom("div")(x**2 for x in [1, 2, 3]), {"tagName": "div", "children": [1, 4, 9]}, ), ( @@ -123,6 +123,15 @@ def test_make_vdom_constructor(): assert no_children() == {"tagName": "no-children"} +def test_nested_html_access_raises_error(): + elmt = Vdom("div") + + with pytest.raises( + AttributeError, match="can only be accessed on web module components" + ): + elmt.fails() + + @pytest.mark.parametrize( "value", [ @@ -293,7 +302,7 @@ def test_invalid_vdom(value, error_message_pattern): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: - reactpy.Vdom("div")((1 for i in range(10))) + reactpy.Vdom("div")(1 for i in range(10)) assert len(record) == 1 assert ( record[0] diff --git a/tests/test_utils.py b/tests/test_utils.py index e494c29b3..aa2905c05 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -188,7 +188,11 @@ def test_string_to_reactpy(case): # 8: Infer ReactJS `key` from the `key` attribute { "source": '
', - "model": {"tagName": "div", "attributes": {"key": "my-key"}, "key": "my-key"}, + "model": { + "tagName": "div", + "attributes": {"key": "my-key"}, + "key": "my-key", + }, }, ], ) diff --git a/tests/test_web/js_fixtures/subcomponent-notation.js b/tests/test_web/js_fixtures/subcomponent-notation.js new file mode 100644 index 000000000..73527c667 --- /dev/null +++ b/tests/test_web/js_fixtures/subcomponent-notation.js @@ -0,0 +1,14 @@ +import React from "https://esm.sh/react@19.0" +import ReactDOM from "https://esm.sh/react-dom@19.0/client" +import {InputGroup, Form} from "https://esm.sh/react-bootstrap@2.10.2?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=InputGroup,Form"; +export {InputGroup, Form}; + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => root.render(element), + unmount: () => root.unmount() + }; +} \ No newline at end of file diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 4b5f980c4..9594be4ae 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -214,8 +214,8 @@ async def test_keys_properly_propagated(display: DisplayFixture): The `key` property was being lost in its propagation from the server-side ReactPy definition to the front-end JavaScript. - - This property is required for certain JS components, such as the GridLayout from + + This property is required for certain JS components, such as the GridLayout from react-grid-layout. """ module = reactpy.web.module_from_file( @@ -224,50 +224,171 @@ async def test_keys_properly_propagated(display: DisplayFixture): GridLayout = reactpy.web.export(module, "GridLayout") await display.show( - lambda: GridLayout({ - "layout": [ - { - "i": "a", - "x": 0, - "y": 0, - "w": 1, - "h": 2, - "static": True, - }, - { - "i": "b", - "x": 1, - "y": 0, - "w": 3, - "h": 2, - "minW": 2, - "maxW": 4, - }, - { - "i": "c", - "x": 4, - "y": 0, - "w": 1, - "h": 2, - } - ], - "cols": 12, - "rowHeight": 30, - "width": 1200, - }, + lambda: GridLayout( + { + "layout": [ + { + "i": "a", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "static": True, + }, + { + "i": "b", + "x": 1, + "y": 0, + "w": 3, + "h": 2, + "minW": 2, + "maxW": 4, + }, + { + "i": "c", + "x": 4, + "y": 0, + "w": 1, + "h": 2, + }, + ], + "cols": 12, + "rowHeight": 30, + "width": 1200, + }, reactpy.html.div({"key": "a"}, "a"), reactpy.html.div({"key": "b"}, "b"), reactpy.html.div({"key": "c"}, "c"), ) ) - parent = await display.page.wait_for_selector(".react-grid-layout", state="attached") + parent = await display.page.wait_for_selector( + ".react-grid-layout", state="attached" + ) children = await parent.query_selector_all("div") # The children simply will not render unless they receive the key prop assert len(children) == 3 +async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export( + module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] + ) + + content = reactpy.html.div( + {"id": "the-parent"}, + InputGroup( + InputGroupText({"id": "basic-addon1"}, "@"), + FormControl( + { + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + } + ), + ), + InputGroup( + FormControl( + { + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + } + ), + InputGroupText({"id": "basic-addon2"}, "@example.com"), + ), + FormLabel({"htmlFor": "basic-url"}, "Your vanity URL"), + InputGroup( + InputGroupText({"id": "basic-addon3"}, "https://example.com/users/"), + FormControl({"id": "basic-url", "aria-describedby": "basic-addon3"}), + ), + InputGroup( + InputGroupText("$"), + FormControl({"aria-label": "Amount (to the nearest dollar)"}), + InputGroupText(".00"), + ), + InputGroup( + InputGroupText("With textarea"), + FormControl({"as": "textarea", "aria-label": "With textarea"}), + ), + ) + + await display.show(lambda: content) + + await display.page.wait_for_selector("#basic-addon3", state="attached") + parent = await display.page.wait_for_selector("#the-parent", state="attached") + input_group_text = await parent.query_selector_all(".input-group-text") + form_control = await parent.query_selector_all(".form-control") + form_label = await parent.query_selector_all(".form-label") + + assert len(input_group_text) == 6 + assert len(form_control) == 5 + assert len(form_label) == 1 + + +async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"]) + + content = reactpy.html.div( + {"id": "the-parent"}, + InputGroup( + InputGroup.Text({"id": "basic-addon1"}, "@"), + Form.Control( + { + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + } + ), + ), + InputGroup( + Form.Control( + { + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + } + ), + InputGroup.Text({"id": "basic-addon2"}, "@example.com"), + ), + Form.Label({"htmlFor": "basic-url"}, "Your vanity URL"), + InputGroup( + InputGroup.Text({"id": "basic-addon3"}, "https://example.com/users/"), + Form.Control({"id": "basic-url", "aria-describedby": "basic-addon3"}), + ), + InputGroup( + InputGroup.Text("$"), + Form.Control({"aria-label": "Amount (to the nearest dollar)"}), + InputGroup.Text(".00"), + ), + InputGroup( + InputGroup.Text("With textarea"), + Form.Control({"as": "textarea", "aria-label": "With textarea"}), + ), + ) + + await display.show(lambda: content) + + await display.page.wait_for_selector("#basic-addon3", state="attached") + parent = await display.page.wait_for_selector("#the-parent", state="attached") + input_group_text = await parent.query_selector_all(".input-group-text") + form_control = await parent.query_selector_all(".form-control") + form_label = await parent.query_selector_all(".form-label") + + assert len(input_group_text) == 6 + assert len(form_control) == 5 + assert len(form_label) == 1 + + def test_module_from_string(): reactpy.web.module_from_string("temp", "old") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): From bf06b60763724603e2163ec8bf546a856f163c8d Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Sat, 19 Apr 2025 17:18:49 -0600 Subject: [PATCH 15/17] Support inline JavaScript events (#1290) --- docs/source/about/changelog.rst | 1 + src/js/packages/@reactpy/client/src/types.ts | 1 + src/js/packages/@reactpy/client/src/vdom.tsx | 72 +++++++++++---- src/reactpy/core/layout.py | 4 + src/reactpy/core/vdom.py | 39 +++++--- src/reactpy/transforms.py | 20 ++--- src/reactpy/types.py | 22 +++++ src/reactpy/utils.py | 8 +- src/reactpy/web/templates/react.js | 2 +- tests/test_core/test_events.py | 94 ++++++++++++++++++++ tests/test_utils.py | 9 ++ tests/test_web/js_fixtures/callable-prop.js | 24 +++++ tests/test_web/test_module.py | 22 +++++ 13 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 tests/test_web/js_fixtures/callable-prop.js diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 261d948c0..bb4ea5e7f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -30,6 +30,7 @@ Unreleased - :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. - :pull:`1281` - Added type hints to ``reactpy.html`` attributes. - :pull:`1285` - Added support for nested components in web modules +- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript`` **Changed** diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 3c0330a07..148a3486c 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -53,6 +53,7 @@ export type ReactPyVdom = { children?: (ReactPyVdom | string)[]; error?: string; eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; + inlineJavaScript?: { [key: string]: string }; importSource?: ReactPyVdomImportSource; }; diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index cae706787..4bd882ff4 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -189,6 +189,12 @@ export function createAttributes( createEventHandler(client, name, handler), ), ), + ...Object.fromEntries( + Object.entries(model.inlineJavaScript || {}).map( + ([name, inlineJavaScript]) => + createInlineJavaScript(name, inlineJavaScript), + ), + ), }), ); } @@ -198,23 +204,51 @@ function createEventHandler( name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { - return [ - name, - function (...args: any[]) { - const data = Array.from(args).map((value) => { - if (!(typeof value === "object" && value.nativeEvent)) { - return value; - } - const event = value as React.SyntheticEvent; - if (preventDefault) { - event.preventDefault(); - } - if (stopPropagation) { - event.stopPropagation(); - } - return serializeEvent(event.nativeEvent); - }); - client.sendMessage({ type: "layout-event", data, target }); - }, - ]; + const eventHandler = function (...args: any[]) { + const data = Array.from(args).map((value) => { + if (!(typeof value === "object" && value.nativeEvent)) { + return value; + } + const event = value as React.SyntheticEvent; + if (preventDefault) { + event.preventDefault(); + } + if (stopPropagation) { + event.stopPropagation(); + } + return serializeEvent(event.nativeEvent); + }); + client.sendMessage({ type: "layout-event", data, target }); + }; + eventHandler.isHandler = true; + return [name, eventHandler]; +} + +function createInlineJavaScript( + name: string, + inlineJavaScript: string, +): [string, () => void] { + /* Function that will execute the string-like InlineJavaScript + via eval in the most appropriate way */ + const wrappedExecutable = function (...args: any[]) { + function handleExecution(...args: any[]) { + const evalResult = eval(inlineJavaScript); + if (typeof evalResult == "function") { + return evalResult(...args); + } + } + if (args.length > 0 && args[0] instanceof Event) { + /* If being triggered by an event, set the event's current + target to "this". This ensures that inline + javascript statements such as the following work: + html.button({"onclick": 'this.value = "Clicked!"'}, "Click Me")*/ + return handleExecution.call(args[0].currentTarget, ...args); + } else { + /* If not being triggered by an event, do not set "this" and + just call normally */ + return handleExecution(...args); + } + }; + wrappedExecutable.isHandler = false; + return [name, wrappedExecutable]; } diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index a32f97083..a81ecc6d7 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -262,6 +262,10 @@ def _render_model_attributes( attrs = raw_model["attributes"].copy() new_state.model.current["attributes"] = attrs + if "inlineJavaScript" in raw_model: + inline_javascript = raw_model["inlineJavaScript"].copy() + new_state.model.current["inlineJavaScript"] = inline_javascript + if old_state is None: self._render_model_event_handlers_without_old_state( new_state, handlers_by_event diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 7ecddcf0e..8d70af53d 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import re from collections.abc import Mapping, Sequence from typing import ( Any, @@ -23,12 +24,16 @@ EventHandlerDict, EventHandlerType, ImportSourceDict, + InlineJavaScript, + InlineJavaScriptDict, VdomAttributes, VdomChildren, VdomDict, VdomJson, ) +EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]\w+") + VDOM_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema", "$ref": "#/definitions/element", @@ -42,6 +47,7 @@ "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, + "inlineJavaScript": {"$ref": "#/definitions/elementInlineJavaScripts"}, "importSource": {"$ref": "#/definitions/importSource"}, }, # The 'tagName' is required because its presence is a useful indicator of @@ -71,6 +77,12 @@ }, "required": ["target"], }, + "elementInlineJavaScripts": { + "type": "object", + "patternProperties": { + ".*": "str", + }, + }, "importSource": { "type": "object", "properties": { @@ -160,7 +172,9 @@ def __call__( """The entry point for the VDOM API, for example reactpy.html().""" attributes, children = separate_attributes_and_children(attributes_and_children) key = attributes.get("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) + attributes, event_handlers, inline_javascript = ( + separate_attributes_handlers_and_inline_javascript(attributes) + ) if REACTPY_CHECK_JSON_ATTRS.current: json.dumps(attributes) @@ -180,6 +194,9 @@ def __call__( **({"children": children} if children else {}), **({"attributes": attributes} if attributes else {}), **({"eventHandlers": event_handlers} if event_handlers else {}), + **( + {"inlineJavaScript": inline_javascript} if inline_javascript else {} + ), **({"importSource": self.import_source} if self.import_source else {}), } @@ -212,26 +229,26 @@ def separate_attributes_and_children( return _attributes, _children -def separate_attributes_and_event_handlers( +def separate_attributes_handlers_and_inline_javascript( attributes: Mapping[str, Any], -) -> tuple[VdomAttributes, EventHandlerDict]: +) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]: _attributes: VdomAttributes = {} _event_handlers: dict[str, EventHandlerType] = {} + _inline_javascript: dict[str, InlineJavaScript] = {} for k, v in attributes.items(): - handler: EventHandlerType - if callable(v): - handler = EventHandler(to_event_handler_function(v)) + _event_handlers[k] = EventHandler(to_event_handler_function(v)) elif isinstance(v, EventHandler): - handler = v + _event_handlers[k] = v + elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): + _inline_javascript[k] = InlineJavaScript(v) + elif isinstance(v, InlineJavaScript): + _inline_javascript[k] = v else: _attributes[k] = v - continue - - _event_handlers[k] = handler - return _attributes, _event_handlers + return _attributes, _event_handlers, _inline_javascript def _flatten_children(children: Sequence[Any]) -> list[Any]: diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index cdac48c7e..072653c95 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -6,6 +6,16 @@ from reactpy.types import VdomAttributes, VdomDict +def attributes_to_reactjs(attributes: VdomAttributes): + """Convert HTML attribute names to their ReactJS equivalents.""" + attrs = cast(VdomAttributes, attributes.items()) + attrs = cast( + VdomAttributes, + {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs}, + ) + return attrs + + class RequiredTransforms: """Performs any necessary transformations related to `string_to_reactpy` to automatically prevent issues with React's rendering engine. @@ -36,16 +46,6 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: ) } - @staticmethod - def html_props_to_reactjs(vdom: VdomDict) -> None: - """Convert HTML prop names to their ReactJS equivalents.""" - if "attributes" in vdom: - items = cast(VdomAttributes, vdom["attributes"].items()) - vdom["attributes"] = cast( - VdomAttributes, - {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in items}, - ) - @staticmethod def textarea_children_to_prop(vdom: VdomDict) -> None: """Transformation that converts the text content of a