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]