diff --git a/README.md b/README.md index 3ebd3faf..75c88223 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,8 @@ Additionally, you can pass in `args` and `kwargs` into your component function. ```jinja -{% load reactpy %} +{% load reactpy %} {% component "example_project.my_app.components.hello_world" recipient="World" %} diff --git a/docs/examples/html/pyscript_component.html b/docs/examples/html/pyscript_component.html index 3f21e3fa..ab047c2c 100644 --- a/docs/examples/html/pyscript_component.html +++ b/docs/examples/html/pyscript_component.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/html/pyscript_local_import.html b/docs/examples/html/pyscript_local_import.html index 2d0130fb..cf275ef2 100644 --- a/docs/examples/html/pyscript_local_import.html +++ b/docs/examples/html/pyscript_local_import.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/html/pyscript_multiple_files.html b/docs/examples/html/pyscript_multiple_files.html index 1f9267a8..a343b35f 100644 --- a/docs/examples/html/pyscript_multiple_files.html +++ b/docs/examples/html/pyscript_multiple_files.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/html/pyscript_setup_extra_js_object.html b/docs/examples/html/pyscript_setup_extra_js_object.html index 815cb040..06cfd51f 100644 --- a/docs/examples/html/pyscript_setup_extra_js_object.html +++ b/docs/examples/html/pyscript_setup_extra_js_object.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/html/pyscript_setup_extra_js_string.html b/docs/examples/html/pyscript_setup_extra_js_string.html index 2d0130fb..cf275ef2 100644 --- a/docs/examples/html/pyscript_setup_extra_js_string.html +++ b/docs/examples/html/pyscript_setup_extra_js_string.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/html/pyscript_ssr_parent.html b/docs/examples/html/pyscript_ssr_parent.html index bf0f47ae..e9a24f09 100644 --- a/docs/examples/html/pyscript_ssr_parent.html +++ b/docs/examples/html/pyscript_ssr_parent.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/html/pyscript_tag.html b/docs/examples/html/pyscript_tag.html index 6ca71085..f6fde929 100644 --- a/docs/examples/html/pyscript_tag.html +++ b/docs/examples/html/pyscript_tag.html @@ -1,5 +1,5 @@ -{% load reactpy %} +{% load reactpy %} diff --git a/docs/examples/python/pyscript_component_initial_object.py b/docs/examples/python/pyscript_component_initial_object.py index d84328a4..a5156d1b 100644 --- a/docs/examples/python/pyscript_component_initial_object.py +++ b/docs/examples/python/pyscript_component_initial_object.py @@ -1,6 +1,4 @@ -from reactpy import component, html - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component @component diff --git a/docs/examples/python/pyscript_component_initial_string.py b/docs/examples/python/pyscript_component_initial_string.py index bb8f9d17..075f44e0 100644 --- a/docs/examples/python/pyscript_component_initial_string.py +++ b/docs/examples/python/pyscript_component_initial_string.py @@ -1,6 +1,4 @@ -from reactpy import component, html - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component @component diff --git a/docs/examples/python/pyscript_component_multiple_files_root.py b/docs/examples/python/pyscript_component_multiple_files_root.py index fd826137..03bce026 100644 --- a/docs/examples/python/pyscript_component_multiple_files_root.py +++ b/docs/examples/python/pyscript_component_multiple_files_root.py @@ -1,6 +1,4 @@ -from reactpy import component, html - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component @component diff --git a/docs/examples/python/pyscript_component_root.py b/docs/examples/python/pyscript_component_root.py index 3d795247..f41d8780 100644 --- a/docs/examples/python/pyscript_component_root.py +++ b/docs/examples/python/pyscript_component_root.py @@ -1,6 +1,4 @@ -from reactpy import component, html - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component @component diff --git a/docs/examples/python/pyscript_ssr_parent.py b/docs/examples/python/pyscript_ssr_parent.py index 524cdc52..84cc9b5e 100644 --- a/docs/examples/python/pyscript_ssr_parent.py +++ b/docs/examples/python/pyscript_ssr_parent.py @@ -1,11 +1,11 @@ -from reactpy import component, html - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component @component def server_side_component(): return html.div( "This text is from my server-side component", - pyscript_component("./example_project/my_app/components/root.py"), + pyscript_component( + "./example_project/my_app/components/root.py", + ), ) diff --git a/docs/examples/python/pyscript_tag.py b/docs/examples/python/pyscript_tag.py index a038b267..bb63e601 100644 --- a/docs/examples/python/pyscript_tag.py +++ b/docs/examples/python/pyscript_tag.py @@ -1,7 +1,5 @@ from reactpy import component, html -from reactpy_django.html import pyscript - example_source_code = """ import js @@ -11,6 +9,4 @@ @component def server_side_component(): - return html.div( - pyscript(example_source_code.strip()), - ) + return html.py_script(example_source_code.strip()) diff --git a/docs/examples/python/use_location.py b/docs/examples/python/use_location.py index 454da7f6..f7190a7f 100644 --- a/docs/examples/python/use_location.py +++ b/docs/examples/python/use_location.py @@ -7,4 +7,4 @@ def my_component(): location = use_location() - return html.div(location.pathname + location.search) + return html.div(location.path + location.query_string) diff --git a/docs/overrides/homepage_examples/add_interactivity.py b/docs/overrides/homepage_examples/add_interactivity.py index 9a7bf76f..d183719b 100644 --- a/docs/overrides/homepage_examples/add_interactivity.py +++ b/docs/overrides/homepage_examples/add_interactivity.py @@ -17,7 +17,7 @@ def searchable_video_list(videos): search_text, set_search_text = use_state("") found_videos = filter_videos(videos, search_text) - return html._( + return html( search_input( {"onChange": lambda event: set_search_text(event["target"]["value"])}, value=search_text, diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 448af463..7fb681fa 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -41,7 +41,7 @@ This allows you to embedded any number of client-side PyScript components within | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python *file_paths` | `#!python str` | File path to your client-side component. If multiple paths are provided, the contents are automatically merged. | N/A | - | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | + | `#!python initial` | `#!python str | VdomDict | Component` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | | `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` | diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index b29990d5..e8c3d8fb 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -591,7 +591,7 @@ Shortcut that returns the browser's current `#!python Location`. | Type | Description | | --- | --- | - | `#!python Location` | An object containing the current URL's `#!python pathname` and `#!python search` query. | + | `#!python Location` | An object containing the current URL's `#!python path` and `#!python query_string` query. | --- diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 146dae4c..1fbf989f 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -72,8 +72,8 @@ Each component loaded via this template tag will receive a dedicated WebSocket c === "my_template.html" ```jinja - {% load reactpy %} + {% load reactpy %}

{% component "example_project.my_app.components.my_title" %}

@@ -191,7 +191,7 @@ Your Python component file will be directly loaded into the browser. It must hav | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python *file_paths` | `#!python str` | File path to your client-side component. If multiple paths are provided, the contents are automatically merged. | N/A | - | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | + | `#!python initial` | `#!python str | VdomDict | Component` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | | `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` | diff --git a/pyproject.toml b/pyproject.toml index 64d530e7..bb023b79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,11 +44,10 @@ classifiers = [ dependencies = [ "channels>=4.0.0", "django>=4.2.0", - "reactpy>=1.1.0, <2.0.0", - "reactpy-router>=1.0.3, <2.0.0", + "reactpy>=2.0.0b10, <3.0.0", + "reactpy-router>2.0.0", "dill>=0.3.5", "orjson>=3.6.0", - "nest_asyncio>=1.5.0", "typing_extensions", ] dynamic = ["version"] @@ -68,6 +67,7 @@ artifacts = ["/src/reactpy_django/static/"] [tool.hatch.metadata] license-files = { paths = ["LICENSE.md"] } +allow-direct-references = true [tool.hatch.envs.default] installer = "uv" @@ -76,8 +76,8 @@ installer = "uv" commands = [ "bun install --cwd src/js", 'bun build src/js/src/index.ts --outdir="src/reactpy_django/static/reactpy_django/" --minify --sourcemap=linked', - 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"', - 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"', + 'cd src/scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"', + 'cd src/scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"', ] artifacts = [] @@ -96,18 +96,18 @@ extra-dependencies = [ "servestatic", "django-bootstrap5", "decorator", - + "uvicorn[standard]", ] matrix-name-format = "{variable}-{value}" # Django 4.2 [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.9", "3.10", "3.11", "3.12"] +python = ["3.11", "3.12"] django = ["4.2"] # Django 5.0 [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.10", "3.11", "3.12"] +python = ["3.11", "3.12"] django = ["5.0"] # Django 5.1 @@ -245,6 +245,9 @@ lint.extend-ignore = [ "S403", # `dill` module is possibly insecure "S301", # `dill` deserialization is possibly insecure unless using trusted data "RUF029", # Function is declared async but doesn't contain await expression + "DOC201", # `return` is not documented in docstring + "DOC501", # `Exception` is not documented in docstring + "S308", # Django `mark_safe` may expose cross-site scripting vulnerabilities ] lint.preview = true lint.isort.known-first-party = ["reactpy_django", "test_app", "example"] diff --git a/src/js/bun.lockb b/src/js/bun.lockb index 75a86944..da65f8af 100644 Binary files a/src/js/bun.lockb and b/src/js/bun.lockb differ diff --git a/src/js/package.json b/src/js/package.json index 4dbb3b6f..3b9ceb1c 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,26 +1,22 @@ { "dependencies": { - "@pyscript/core": "^0.6", - "@reactpy/client": "^0.3.2", - "event-to-object": "^0.1.2", - "morphdom": "^2.7.4", - "preact": "^10.26.9", - "react": "npm:@preact/compat@17.1.2", - "react-dom": "npm:@preact/compat@17.1.2" + "@pyscript/core": "^0.7.17", + "@reactpy/client": "^1.1.0", + "morphdom": "^2.7.8" }, "devDependencies": { - "@eslint/js": "^9.29.0", - "bun-types": "^0.5.0", - "eslint": "^9.13.0", - "globals": "^16.2.0", - "prettier": "^3.3.3", - "typescript": "^5.8.3", - "typescript-eslint": "^8.34.0" + "@eslint/js": "^10.0.1", + "bun-types": "^1.3.9", + "eslint": "^10.0.0", + "globals": "^17.3.0", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.55.0" }, "license": "MIT", + "name": "@reactpy-django/app", "scripts": { "check": "prettier --check . && eslint", "format": "prettier --write . && eslint --fix" - }, - "type": "module" + } } diff --git a/src/js/src/client.ts b/src/js/src/client.ts index 4a5cfceb..1c637cd1 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -1,34 +1,43 @@ import { BaseReactPyClient, - type ReactPyClient, + createReconnectingWebSocket, + type GenericReactPyClientProps, + type ReactPyClientInterface, type ReactPyModule, + type ReactPyUrls, } from "@reactpy/client"; -import { createReconnectingWebSocket } from "./utils"; -import type { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; export class ReactPyDjangoClient extends BaseReactPyClient - implements ReactPyClient + implements ReactPyClientInterface { urls: ReactPyUrls; socket: { current?: WebSocket }; - mountElement: HTMLElement | null = null; + mountElement: HTMLElement; prerenderElement: HTMLElement | null = null; offlineElement: HTMLElement | null = null; + private readonly messageQueue: any[] = []; - constructor(props: ReactPyDjangoClientProps) { + constructor(props: GenericReactPyClientProps) { super(); + this.urls = props.urls; this.mountElement = props.mountElement; - this.prerenderElement = props.prerenderElement; - this.offlineElement = props.offlineElement; + this.prerenderElement = document.getElementById( + props.mountElement.id + "-prerender", + ); + this.offlineElement = document.getElementById( + props.mountElement.id + "-offline", + ); + this.socket = createReconnectingWebSocket({ url: this.urls.componentUrl, readyPromise: this.ready, ...props.reconnectOptions, + // onMessage: Use standard ReactPy message routing onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), + // onClose: If offlineElement exists, show it and hide the mountElement/prerenderElement onClose: () => { - // If offlineElement exists, show it and hide the mountElement/prerenderElement if (this.prerenderElement) { this.prerenderElement.remove(); this.prerenderElement = null; @@ -38,8 +47,8 @@ export class ReactPyDjangoClient this.offlineElement.hidden = false; } }, + // onOpen: If offlineElement exists, hide it and show the mountElement onOpen: () => { - // If offlineElement exists, hide it and show the mountElement if (this.offlineElement && this.mountElement) { this.offlineElement.hidden = true; this.mountElement.hidden = false; @@ -49,10 +58,17 @@ export class ReactPyDjangoClient } sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); + if ( + this.socket.current && + this.socket.current.readyState === WebSocket.OPEN + ) { + this.socket.current.send(JSON.stringify(message)); + } else { + this.messageQueue.push(message); + } } loadModule(moduleName: string): Promise { - return import(`${this.urls.jsModules}/${moduleName}`); + return import(`${this.urls.jsModulesPath}${moduleName}`); } } diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 6c265ac2..f77e41a5 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -1,76 +1,44 @@ +import { React } from "@reactpy/client"; import type { DjangoFormProps, HttpRequestProps } from "./types"; -import { useEffect } from "preact/hooks"; -import { type ComponentChildren, render, createElement } from "preact"; -/** - * Interface used to bind a ReactPy node to React. - */ -export function bind(node: HTMLElement | Element | Node) { - return { - create: ( - type: string, - props: Record, - children: ComponentChildren[], - ) => createElement(type, props, ...children), - render: (element: HTMLElement | Element | Node) => { - render(element, node); - }, - unmount: () => render(null, node), - }; -} -export function DjangoForm({ - onSubmitCallback, - formId, -}: DjangoFormProps): null { - useEffect(() => { +export class DjangoForm extends React.Component { + componentDidMount() { + const { onSubmitCallback, formId } = this.props; const form = document.getElementById(formId) as HTMLFormElement; - // Submission event function const onSubmitEvent = (event: Event) => { event.preventDefault(); const formData = new FormData(form); - // Convert the FormData object to a plain object by iterating through it - // If duplicate keys are present, convert the value into an array of values - const entries = formData.entries(); - const formDataArray = Array.from(entries); - const formDataObject = formDataArray.reduce>( - (acc, [key, value]) => { - if (acc[key]) { - if (Array.isArray(acc[key])) { - acc[key].push(value); - } else { - acc[key] = [acc[key], value]; - } - } else { - acc[key] = value; - } - return acc; - }, - {}, - ); + // Convert the FormData object to a plain object + const formObject = Object.fromEntries(formData.entries()); - onSubmitCallback(formDataObject); + onSubmitCallback(formObject); }; - // Bind the event listener if (form) { form.addEventListener("submit", onSubmitEvent); + // Store cleanup function in instance + (this as any)._cleanup = () => { + form.removeEventListener("submit", onSubmitEvent); + }; } + } - // Unbind the event listener when the component dismounts - return () => { - if (form) { - form.removeEventListener("submit", onSubmitEvent); - } - }; - }, []); + componentWillUnmount() { + if ((this as any)._cleanup) { + (this as any)._cleanup(); + } + } - return null; + render() { + return null; + } } -export function HttpRequest({ method, url, body, callback }: HttpRequestProps) { - useEffect(() => { +export class HttpRequest extends React.Component { + componentDidMount() { + const { method, url, body, callback } = this.props; fetch(url, { method: method, body: body, @@ -88,7 +56,9 @@ export function HttpRequest({ method, url, body, callback }: HttpRequestProps) { .catch(() => { callback(520, ""); }); - }, []); + } - return null; + render() { + return null; + } } diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 01856c7d..b9d59c1e 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,2 +1,2 @@ -export { HttpRequest, DjangoForm, bind } from "./components"; +export { HttpRequest, DjangoForm } from "./components"; export { mountComponent } from "./mount"; diff --git a/src/js/src/mount.tsx b/src/js/src/mount.tsx index 9d2e9a3f..26a02211 100644 --- a/src/js/src/mount.tsx +++ b/src/js/src/mount.tsx @@ -1,6 +1,5 @@ import { ReactPyDjangoClient } from "./client"; -import { render } from "preact"; -import { Layout } from "@reactpy/client/src/components"; +import { Layout, React } from "@reactpy/client"; export function mountComponent( mountElement: HTMLElement, @@ -8,60 +7,45 @@ export function mountComponent( urlPrefix: string, componentPath: string, resolvedJsModulesPath: string, - reconnectStartInterval: number, + reconnectInterval: number, reconnectMaxInterval: number, reconnectMaxRetries: number, reconnectBackoffMultiplier: number, ) { - // Protocols - const httpProtocol = window.location.protocol; - const wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`; - - // WebSocket route (for Python components) - let wsOrigin: string; - if (host) { - wsOrigin = `${wsProtocol}//${host}`; - } else { - wsOrigin = `${wsProtocol}//${window.location.host}`; - } - - // HTTP route (for JavaScript modules) - let httpOrigin: string; - let jsModulesPath: string; - if (host) { - httpOrigin = `${httpProtocol}//${host}`; - jsModulesPath = `${urlPrefix}/web_module`; - } else { - httpOrigin = `${httpProtocol}//${window.location.host}`; - if (resolvedJsModulesPath) { - jsModulesPath = resolvedJsModulesPath; - } else { - jsModulesPath = `${urlPrefix}/web_module`; - } - } + // WebSocket route for component rendering + const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`; + const wsOrigin = host + ? `${wsProtocol}//${host}` + : `${wsProtocol}//${window.location.host}`; + const componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`); // Embed the initial HTTP path into the WebSocket URL - const componentUrl = new URL(`${wsOrigin}/${urlPrefix}/${componentPath}`); - componentUrl.searchParams.append("http_pathname", window.location.pathname); + componentUrl.searchParams.append("path", window.location.pathname); if (window.location.search) { - componentUrl.searchParams.append("http_search", window.location.search); + componentUrl.searchParams.append("qs", window.location.search); } + // HTTP route for JavaScript modules + const httpProtocol = window.location.protocol; + const httpOrigin: string = host + ? `${httpProtocol}//${host}` + : `${httpProtocol}//${window.location.host}`; + const jsModulesPath: string = + resolvedJsModulesPath || `${urlPrefix}/web_module/`; + // Configure a new ReactPy client const client = new ReactPyDjangoClient({ urls: { componentUrl: componentUrl, - jsModules: `${httpOrigin}/${jsModulesPath}`, + jsModulesPath: `${httpOrigin}${jsModulesPath.startsWith("/") ? "" : "/"}${jsModulesPath}`, }, reconnectOptions: { - startInterval: reconnectStartInterval, + interval: reconnectInterval, maxInterval: reconnectMaxInterval, - backoffMultiplier: reconnectBackoffMultiplier, maxRetries: reconnectMaxRetries, + backoffMultiplier: reconnectBackoffMultiplier, }, mountElement: mountElement, - prerenderElement: document.getElementById(mountElement.id + "-prerender"), - offlineElement: document.getElementById(mountElement.id + "-offline"), }); // Replace the prerender element with the real element on the first layout update @@ -76,7 +60,7 @@ export function mountComponent( // Start rendering the component if (client.mountElement) { - render(, client.mountElement); + React.render(, client.mountElement); } else { console.error( "ReactPy mount element is undefined, cannot render the component!", diff --git a/src/js/src/types.ts b/src/js/src/types.ts index ff930df4..211db4c1 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -1,23 +1,3 @@ -export type ReconnectOptions = { - startInterval: number; - maxInterval: number; - maxRetries: number; - backoffMultiplier: number; -}; - -export type ReactPyUrls = { - componentUrl: URL; - jsModules: string; -}; - -export type ReactPyDjangoClientProps = { - urls: ReactPyUrls; - reconnectOptions: ReconnectOptions; - mountElement: HTMLElement | null; - prerenderElement: HTMLElement | null; - offlineElement: HTMLElement | null; -}; - export interface DjangoFormProps { onSubmitCallback: (data: object) => void; formId: string; diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts deleted file mode 100644 index f0a31b84..00000000 --- a/src/js/src/utils.ts +++ /dev/null @@ -1,74 +0,0 @@ -export function createReconnectingWebSocket(props: { - url: URL; - readyPromise: Promise; - onOpen?: () => void; - onMessage: (message: MessageEvent) => void; - onClose?: () => void; - startInterval: number; - maxInterval: number; - maxRetries: number; - backoffMultiplier: number; -}) { - const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; - let retries = 0; - let interval = startInterval; - 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; - console.info("ReactPy connected!"); - interval = startInterval; - retries = 0; - if (props.onOpen) { - props.onOpen(); - } - }; - socket.current.onmessage = props.onMessage; - socket.current.onclose = () => { - if (props.onClose) { - props.onClose(); - } - if (!everConnected) { - console.info("ReactPy failed to connect!"); - return; - } - console.info("ReactPy disconnected!"); - if (retries >= maxRetries) { - console.info("ReactPy connection max retries exhausted!"); - return; - } - console.info( - `ReactPy reconnecting in ${(interval / 1000).toPrecision(4)} seconds...`, - ); - setTimeout(connect, interval); - interval = nextInterval(interval, backoffMultiplier, maxInterval); - retries++; - }; - }; - - props.readyPromise - .then(() => console.info("Starting ReactPy 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/js/tsconfig.json b/src/js/tsconfig.json index aa44c202..2ff015f9 100644 --- a/src/js/tsconfig.json +++ b/src/js/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "composite": true, "allowJs": true, "allowSyntheticDefaultImports": true, "declaration": true, @@ -13,19 +14,18 @@ "module": "Preserve", "moduleDetection": "force", "moduleResolution": "bundler", - "noEmitOnError": true, - "noUnusedLocals": true, "paths": { "react": ["./node_modules/preact/compat/"], - "react-dom": ["./node_modules/preact/compat/"], - "react-dom/*": ["./node_modules/preact/compat/*"], - "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"] + "react-dom": ["./node_modules/preact/compat/"] }, + "noEmit": false, + "noUnusedLocals": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "target": "ESNext", "verbatimModuleSyntax": true - } + }, + "exclude": ["node_modules", "eslint.config.mjs"] } diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index c272c9a8..e13b3024 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -1,12 +1,7 @@ -import contextlib - -import nest_asyncio - from reactpy_django import ( components, decorators, hooks, - html, router, types, utils, @@ -19,14 +14,7 @@ "components", "decorators", "hooks", - "html", "router", "types", "utils", ] - -# Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops. -# Previously, Uvicorn could generate `assert f is self._write_fut` exceptions, and Daphne -# had jittery rendering behaviors. Demonstrated using our "Renders Per Second" test page. -with contextlib.suppress(ValueError): - nest_asyncio.apply() diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py index e0a1e065..3aec12c4 100644 --- a/src/reactpy_django/auth/components.py +++ b/src/reactpy_django/auth/components.py @@ -56,9 +56,9 @@ def setup_asgi_scope(): any relevant actions.""" scope["reactpy"]["synchronize_auth"] = synchronize_auth - @hooks.use_effect(dependencies=[sync_needed]) + @hooks.use_async_effect(dependencies=[sync_needed]) async def synchronize_auth_watchdog(): - """Detect if the client has taken too long to request a auth session synchronization. + """Detect if the client has taken too long to synchronize WebSocket->HTTP auth sessions. This effect will automatically be cancelled if the session is successfully synchronized (via effect dependencies).""" diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 88001c77..d778e531 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -4,12 +4,12 @@ from uuid import uuid4 from django.contrib.staticfiles.finders import find -from django.core.checks import Error, Tags, Warning, register +from django.core import checks from django.template import loader from django.urls import NoReverseMatch -@register(Tags.compatibility) +@checks.register(checks.Tags.compatibility) def reactpy_warnings(app_configs, **kwargs): from django.conf import settings from django.urls import reverse @@ -26,7 +26,7 @@ def reactpy_warnings(app_configs, **kwargs): == ":memory:" ): warnings.append( - Warning( + checks.Warning( "Using ReactPy with an in-memory database can cause unexpected behaviors.", hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a " "multiprocessing and thread safe database.", @@ -41,7 +41,7 @@ def reactpy_warnings(app_configs, **kwargs): reverse("reactpy:session_manager", args=[str(uuid4())]) except Exception: warnings.append( - Warning( + checks.Warning( "ReactPy URLs have not been registered.", hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """ "to your application's urlpatterns. If this application does not need " @@ -55,7 +55,7 @@ def reactpy_warnings(app_configs, **kwargs): settings, "REACTPY_BACKHAUL_THREAD", False ): warnings.append( - Warning( + checks.Warning( "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled and you running with Daphne.", hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different web server.", id="reactpy_django.W003", @@ -65,7 +65,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if reactpy_django/index.js is available if not find("reactpy_django/index.js"): warnings.append( - Warning( + checks.Warning( "ReactPy index.js could not be found within Django static files!", hint="Check all static files related Django settings and INSTALLED_APPS.", id="reactpy_django.W004", @@ -75,7 +75,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if any components failed to be registered if REACTPY_FAILED_COMPONENTS: warnings.append( - Warning( + checks.Warning( "ReactPy failed to register the following components:\n\t+ " + "\n\t+ ".join(REACTPY_FAILED_COMPONENTS), hint="Check if these paths are valid, or if an exception is being raised during import.", id="reactpy_django.W005", @@ -87,7 +87,7 @@ def reactpy_warnings(app_configs, **kwargs): loader.get_template("reactpy/component.html") except Exception: warnings.append( - Warning( + checks.Warning( "ReactPy HTML templates could not be found!", hint="Check your settings.py:TEMPLATES configuration and make sure " "ReactPy-Django is installed properly.", @@ -105,7 +105,7 @@ def reactpy_warnings(app_configs, **kwargs): reactpy_http_prefix = f"{full_path[: full_path.find('web_module/')].strip('/')}" if reactpy_http_prefix != config.REACTPY_URL_PREFIX: warnings.append( - Warning( + checks.Warning( "HTTP paths are not prefixed with REACTPY_URL_PREFIX. " "Some ReactPy features may not work as expected.", hint="Use one of the following solutions.\n" @@ -123,7 +123,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if REACTPY_URL_PREFIX is empty if not getattr(settings, "REACTPY_URL_PREFIX", "reactpy/"): warnings.append( - Warning( + checks.Warning( "REACTPY_URL_PREFIX should not be empty!", hint="Change your REACTPY_URL_PREFIX to be written in the following format: '/example_url/'", id="reactpy_django.W011", @@ -133,7 +133,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if `daphne` is not in installed apps when using `runserver` if "runserver" in sys.argv and "daphne" not in getattr(settings, "INSTALLED_APPS", []): warnings.append( - Warning( + checks.Warning( "You have not configured the `runserver` command to use ASGI. " "ReactPy will work properly in this configuration.", hint="Add daphne to settings.py:INSTALLED_APPS.", @@ -146,7 +146,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if REACTPY_RECONNECT_INTERVAL is set to a large value if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL > 30000: warnings.append( - Warning( + checks.Warning( "REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? " "This may cause unexpected delays between reconnection.", hint="Check your value for REACTPY_RECONNECT_INTERVAL or suppress this warning.", @@ -157,7 +157,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if REACTPY_RECONNECT_MAX_RETRIES is set to a large value if isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES > 5000: warnings.append( - Warning( + checks.Warning( "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value " f"{config.REACTPY_RECONNECT_MAX_RETRIES}. Are you sure this is intentional? " "This may leave your clients attempting reconnections for a long time.", @@ -172,7 +172,7 @@ def reactpy_warnings(app_configs, **kwargs): and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100 ): warnings.append( - Warning( + checks.Warning( "REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a very large value. Are you sure this is intentional?", hint="Check your value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER or suppress this warning.", id="reactpy_django.W016", @@ -198,7 +198,7 @@ def reactpy_warnings(app_configs, **kwargs): * config.REACTPY_RECONNECT_INTERVAL ) warnings.append( - Warning( + checks.Warning( "Your current ReactPy configuration can never reach REACTPY_RECONNECT_MAX_INTERVAL. At most you will reach " f"{max_value} miliseconds, which is less than {config.REACTPY_RECONNECT_MAX_INTERVAL} (REACTPY_RECONNECT_MAX_INTERVAL).", hint="Check your ReactPy REACTPY_RECONNECT_* settings.", @@ -213,7 +213,7 @@ def reactpy_warnings(app_configs, **kwargs): position_to_beat = installed_apps.index(app) if "reactpy_django" in installed_apps and installed_apps.index("reactpy_django") < position_to_beat: warnings.append( - Warning( + checks.Warning( "The position of 'reactpy_django' in INSTALLED_APPS is suspicious.", hint="Move 'reactpy_django' below all 'django.contrib.*' apps, or suppress this warning.", id="reactpy_django.W018", @@ -223,7 +223,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if user misspelled REACTPY_CLEAN_SESSIONS if getattr(settings, "REACTPY_CLEAN_SESSION", None): warnings.append( - Warning( + checks.Warning( "REACTPY_CLEAN_SESSION is not a valid property value.", hint="Did you mean to use REACTPY_CLEAN_SESSIONS instead?", id="reactpy_django.W019", @@ -234,7 +234,7 @@ def reactpy_warnings(app_configs, **kwargs): auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE if isinstance(auth_token_timeout, int) and auth_token_timeout > 120: warnings.append( - Warning( + checks.Warning( "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.", hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.", id="reactpy_django.W020", @@ -244,7 +244,7 @@ def reactpy_warnings(app_configs, **kwargs): # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a small value if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2: warnings.append( - Warning( + checks.Warning( "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.", hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.", id="reactpy_django.W021", @@ -254,7 +254,7 @@ def reactpy_warnings(app_configs, **kwargs): return warnings -@register(Tags.compatibility) +@checks.register(checks.Tags.compatibility) def reactpy_errors(app_configs, **kwargs): from django.conf import settings @@ -265,7 +265,7 @@ def reactpy_errors(app_configs, **kwargs): # Make sure ASGI is enabled if not getattr(settings, "ASGI_APPLICATION", None): errors.append( - Error( + checks.Error( "ASGI_APPLICATION is not defined, but ReactPy requires ASGI.", hint="Add ASGI_APPLICATION to settings.py.", id="reactpy_django.E001", @@ -277,7 +277,7 @@ def reactpy_errors(app_configs, **kwargs): settings, "DATABASE_ROUTERS", [] ): errors.append( - Error( + checks.Error( "ReactPy database has been changed but the database router is not configured.", hint="Set settings.py:DATABASE_ROUTERS to ['reactpy_django.database.Router', ...]", id="reactpy_django.E002", @@ -287,7 +287,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_URL_PREFIX is a valid data type if not isinstance(getattr(settings, "REACTPY_URL_PREFIX", ""), str): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_URL_PREFIX.", hint="REACTPY_URL_PREFIX should be a string.", obj=settings.REACTPY_URL_PREFIX, @@ -298,7 +298,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_SESSION_MAX_AGE is a valid data type if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_SESSION_MAX_AGE.", hint="REACTPY_SESSION_MAX_AGE should be an integer.", obj=settings.REACTPY_SESSION_MAX_AGE, @@ -309,7 +309,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_CACHE is a valid data type if not isinstance(getattr(settings, "REACTPY_CACHE", ""), str): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_CACHE.", hint="REACTPY_CACHE should be a string.", obj=settings.REACTPY_CACHE, @@ -320,7 +320,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_DATABASE is a valid data type if not isinstance(getattr(settings, "REACTPY_DATABASE", ""), str): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_DATABASE.", hint="REACTPY_DATABASE should be a string.", obj=settings.REACTPY_DATABASE, @@ -331,7 +331,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_DEFAULT_QUERY_POSTPROCESSOR is a valid data type if not isinstance(getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None))): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.", hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string or None.", obj=settings.REACTPY_DEFAULT_QUERY_POSTPROCESSOR, @@ -342,7 +342,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_AUTH_BACKEND is a valid data type if not isinstance(getattr(settings, "REACTPY_AUTH_BACKEND", ""), str): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_AUTH_BACKEND.", hint="REACTPY_AUTH_BACKEND should be a string.", obj=settings.REACTPY_AUTH_BACKEND, @@ -355,7 +355,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_DEFAULT_HOSTS is a valid data type if not isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", []), list): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_DEFAULT_HOSTS.", hint="REACTPY_DEFAULT_HOSTS should be a list.", obj=settings.REACTPY_DEFAULT_HOSTS, @@ -368,7 +368,7 @@ def reactpy_errors(app_configs, **kwargs): for host in settings.REACTPY_DEFAULT_HOSTS: if not isinstance(host, str): errors.append( - Error( + checks.Error( f"Invalid type {type(host)} within REACTPY_DEFAULT_HOSTS.", hint="REACTPY_DEFAULT_HOSTS should be a list of strings.", obj=settings.REACTPY_DEFAULT_HOSTS, @@ -380,7 +380,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_INTERVAL is a valid data type if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_RECONNECT_INTERVAL.", hint="REACTPY_RECONNECT_INTERVAL should be an integer.", id="reactpy_django.E012", @@ -390,7 +390,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_INTERVAL is a positive integer if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL < 0: errors.append( - Error( + checks.Error( "Invalid value for REACTPY_RECONNECT_INTERVAL.", hint="REACTPY_RECONNECT_INTERVAL should be a positive integer.", id="reactpy_django.E013", @@ -400,7 +400,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_MAX_INTERVAL is a valid data type if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_RECONNECT_MAX_INTERVAL.", hint="REACTPY_RECONNECT_MAX_INTERVAL should be an integer.", id="reactpy_django.E014", @@ -410,7 +410,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_MAX_INTERVAL is a positive integer if isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) and config.REACTPY_RECONNECT_MAX_INTERVAL < 0: errors.append( - Error( + checks.Error( "Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.", hint="REACTPY_RECONNECT_MAX_INTERVAL should be a positive integer.", id="reactpy_django.E015", @@ -424,7 +424,7 @@ def reactpy_errors(app_configs, **kwargs): and config.REACTPY_RECONNECT_MAX_INTERVAL < config.REACTPY_RECONNECT_INTERVAL ): errors.append( - Error( + checks.Error( "REACTPY_RECONNECT_MAX_INTERVAL is less than REACTPY_RECONNECT_INTERVAL.", hint="REACTPY_RECONNECT_MAX_INTERVAL should be greater than or equal to REACTPY_RECONNECT_INTERVAL.", id="reactpy_django.E016", @@ -434,7 +434,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_MAX_RETRIES is a valid data type if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_RECONNECT_MAX_RETRIES.", hint="REACTPY_RECONNECT_MAX_RETRIES should be an integer.", id="reactpy_django.E017", @@ -444,7 +444,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_MAX_RETRIES is a positive integer if isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES < 0: errors.append( - Error( + checks.Error( "Invalid value for REACTPY_RECONNECT_MAX_RETRIES.", hint="REACTPY_RECONNECT_MAX_RETRIES should be a positive integer.", id="reactpy_django.E018", @@ -454,7 +454,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_RECONNECT_BACKOFF_MULTIPLIER is a valid data type if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be an integer or float.", id="reactpy_django.E019", @@ -467,7 +467,7 @@ def reactpy_errors(app_configs, **kwargs): and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1 ): errors.append( - Error( + checks.Error( "Invalid value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be greater than or equal to 1.", id="reactpy_django.E020", @@ -477,7 +477,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_PRERENDER is a valid data type if not isinstance(config.REACTPY_PRERENDER, bool): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_PRERENDER.", hint="REACTPY_PRERENDER should be a boolean.", id="reactpy_django.E021", @@ -487,7 +487,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_AUTO_RELOGIN is a valid data type if not isinstance(config.REACTPY_AUTO_RELOGIN, bool): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_AUTO_RELOGIN.", hint="REACTPY_AUTO_RELOGIN should be a boolean.", id="reactpy_django.E022", @@ -497,7 +497,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_CLEAN_INTERVAL is a valid data type if not isinstance(config.REACTPY_CLEAN_INTERVAL, (int, type(None))): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_CLEAN_INTERVAL.", hint="REACTPY_CLEAN_INTERVAL should be an integer or None.", id="reactpy_django.E023", @@ -507,7 +507,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_CLEAN_INTERVAL is a positive integer if isinstance(config.REACTPY_CLEAN_INTERVAL, int) and config.REACTPY_CLEAN_INTERVAL < 0: errors.append( - Error( + checks.Error( "Invalid value for REACTPY_CLEAN_INTERVAL.", hint="REACTPY_CLEAN_INTERVAL should be a positive integer or None.", id="reactpy_django.E024", @@ -517,7 +517,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_CLEAN_SESSIONS is a valid data type if not isinstance(config.REACTPY_CLEAN_SESSIONS, bool): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_CLEAN_SESSIONS.", hint="REACTPY_CLEAN_SESSIONS should be a boolean.", id="reactpy_django.E025", @@ -527,7 +527,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_CLEAN_USER_DATA is a valid data type if not isinstance(config.REACTPY_CLEAN_USER_DATA, bool): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_CLEAN_USER_DATA.", hint="REACTPY_CLEAN_USER_DATA should be a boolean.", id="reactpy_django.E026", @@ -537,7 +537,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_CLEAN_AUTH_TOKENS.", hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.", id="reactpy_django.E027", @@ -547,7 +547,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int): errors.append( - Error( + checks.Error( "Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.", hint="REACTPY_AUTH_TOKEN_MAX_AGE should be an integer.", id="reactpy_django.E028", @@ -557,7 +557,7 @@ def reactpy_errors(app_configs, **kwargs): # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0: errors.append( - Error( + checks.Error( "Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.", hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.", id="reactpy_django.E029", diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 9234c42e..a25a5adf 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -9,11 +9,9 @@ from django.http import HttpRequest from django.urls import reverse from reactpy import component, hooks, html, utils -from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError from reactpy_django.forms.components import _django_form -from reactpy_django.pyscript.components import _pyscript_component from reactpy_django.utils import ( cached_static_file, del_html_head_body_transform, @@ -27,6 +25,7 @@ from django.forms import Form, ModelForm from django.views import View + from reactpy.types import Component, Key, VdomDict from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor @@ -54,7 +53,7 @@ def constructor( *args, key: Key | None = None, **kwargs, - ) -> ComponentType: + ) -> Component: return _view_to_component( view=view, transforms=transforms, @@ -84,13 +83,13 @@ def constructor( *args, key: Key | None = None, **kwargs, - ) -> ComponentType: + ) -> Component: return _view_to_iframe(view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key) return constructor -def django_css(static_path: str, key: Key | None = None) -> ComponentType: +def django_css(static_path: str, key: Key | None = None) -> Component: """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -103,7 +102,7 @@ def django_css(static_path: str, key: Key | None = None) -> ComponentType: return _django_css(static_path=static_path, key=key) -def django_js(static_path: str, key: Key | None = None) -> ComponentType: +def django_js(static_path: str, key: Key | None = None) -> Component: """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -131,7 +130,7 @@ def django_form( top_children: Sequence[Any] = (), bottom_children: Sequence[Any] = (), key: Key | None = None, -) -> ComponentType: +) -> Component: """Converts a Django form to a ReactPy component. Args: @@ -174,29 +173,6 @@ def django_form( ) -def pyscript_component( - *file_paths: str, - initial: str | VdomDict | ComponentType = "", - root: str = "root", -) -> ComponentType: - """ - Args: - file_paths: File path to your client-side component. If multiple paths are \ - provided, the contents are automatically merged. - - Kwargs: - initial: The initial HTML that is displayed prior to the PyScript component \ - loads. This can either be a string containing raw HTML, a \ - `#!python reactpy.html` snippet, or a non-interactive component. - root: The name of the root component function. - """ - return _pyscript_component( - *file_paths, - initial=initial, - root=root, - ) - - @component def _view_to_component( view: Callable | View | str, @@ -206,29 +182,29 @@ def _view_to_component( args: Sequence | None, kwargs: dict | None, ): - converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None)) - _args: Sequence = args or () - _kwargs: dict = kwargs or {} + converted_view, set_converted_view = hooks.use_state(cast("Union[VdomDict, None]", None)) + args_: Sequence = args or () + kwargs_: dict = kwargs or {} if request: - _request: HttpRequest = request + request_: HttpRequest = request else: - _request = HttpRequest() - _request.method = "GET" + request_ = HttpRequest() + request_.method = "GET" resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore # Render the view render within a hook - @hooks.use_effect( + @hooks.use_async_effect( dependencies=[ - json.dumps(vars(_request), default=generate_obj_name), - json.dumps([_args, _kwargs], default=generate_obj_name), + json.dumps(vars(request_), default=generate_obj_name), + json.dumps([args_, kwargs_], default=generate_obj_name), ] ) async def _render_view(): """Render the view in an async hook to avoid blocking the main thread.""" # Render the view - response = await render_view(resolved_view, _request, _args, _kwargs) + response = await render_view(resolved_view, request_, args_, kwargs_) set_converted_view( - utils.html_to_vdom( + utils.string_to_reactpy( response.content.decode("utf-8").strip(), del_html_head_body_transform, *transforms, diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index cc3ca2fc..0b71ec17 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -7,13 +7,13 @@ from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS from reactpy.config import REACTPY_ASYNC_RENDERING as _REACTPY_ASYNC_RENDERING -from reactpy.config import REACTPY_DEBUG_MODE as _REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_DEBUG as _REACTPY_DEBUG from reactpy_django.utils import import_dotted_path if TYPE_CHECKING: from django.views import View - from reactpy.core.types import ComponentConstructor + from reactpy.types import ComponentConstructor from reactpy_django.types import ( AsyncPostprocessor, @@ -27,7 +27,7 @@ # Configurable through Django settings.py DJANGO_DEBUG = settings.DEBUG # Snapshot of Django's DEBUG setting -_REACTPY_DEBUG_MODE.set_current(settings.DEBUG) +_REACTPY_DEBUG.set_current(settings.DEBUG) _REACTPY_ASYNC_RENDERING.set_current(getattr(settings, "REACTPY_ASYNC_RENDERING", _REACTPY_ASYNC_RENDERING.current)) REACTPY_URL_PREFIX: str = getattr( settings, diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 6b3d220e..0454f5df 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser - from reactpy.core.types import ComponentConstructor + from reactpy.types import ComponentConstructor def user_passes_test( diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py index a10d5c92..6fcf07a2 100644 --- a/src/reactpy_django/forms/components.py +++ b/src/reactpy_django/forms/components.py @@ -1,5 +1,6 @@ from __future__ import annotations +from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Union, cast from uuid import uuid4 @@ -7,7 +8,7 @@ from django.forms import Form, ModelForm from reactpy import component, hooks, html, utils from reactpy.core.events import event -from reactpy.web import export, module_from_file +from reactpy.reactjs import component_from_file from reactpy_django.forms.transforms import ( convert_html_props_to_reactjs, @@ -24,11 +25,13 @@ if TYPE_CHECKING: from collections.abc import Sequence - from reactpy.core.types import VdomDict + from reactpy.types import VdomDict -DjangoForm = export( - module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "index.js"), - ("DjangoForm"), +_logger = getLogger(__name__) +DjangoForm = component_from_file( + Path(__file__).parent.parent / "static" / "reactpy_django" / "index.js", + import_names=("DjangoForm"), + name="reactpy-django", ) @@ -53,7 +56,7 @@ def _django_form( top_children_count = hooks.use_ref(len(top_children)) bottom_children_count = hooks.use_ref(len(bottom_children)) submitted_data, set_submitted_data = hooks.use_state({} or None) - rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None)) + rendered_form, set_rendered_form = hooks.use_state(cast("Union[str, None]", None)) # Initialize the form with the provided data validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form) @@ -63,27 +66,35 @@ def _django_form( ) # Validate and render the form - @hooks.use_effect(dependencies=[str(submitted_data)]) + @hooks.use_async_effect(dependencies=[str(submitted_data)]) async def render_form(): """Forms must be rendered in an async loop to allow database fields to execute.""" - if submitted_data: - await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() - success = not initialized_form.errors.as_data() - if success and on_success: - await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) - if not success and on_error: - await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event) - if success and auto_save and isinstance(initialized_form, ModelForm): - await ensure_async(initialized_form.save)() - set_submitted_data(None) - - set_rendered_form( - await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE) - ) + + _logger.debug("Rendering form with submitted data: %s", submitted_data) + try: + if submitted_data: + await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() + success = not initialized_form.errors.as_data() + if success and on_success: + await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) + if not success and on_error: + await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event) + if success and auto_save and isinstance(initialized_form, ModelForm): + await ensure_async(initialized_form.save)() + set_submitted_data(None) + + set_rendered_form( + await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE) + ) + except Exception: + _logger.exception("Error during form processing") async def on_submit_callback(new_data: dict[str, Any]): """Callback function provided directly to the client side listener. This is responsible for transmitting the submitted form data to the server for processing.""" + # The client side listener passes a ReactPy Event object which needs to be + # converted to a standard dictionary. + new_data = dict(new_data) convert_form_fields(new_data, initialized_form) if on_receive_data: @@ -113,7 +124,7 @@ async def _on_change(_event): }, DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}), *top_children, - utils.html_to_vdom( + utils.string_to_reactpy( rendered_form, convert_html_props_to_reactjs, convert_textarea_children_to_prop, diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py index 1a757b77..141edc37 100644 --- a/src/reactpy_django/forms/transforms.py +++ b/src/reactpy_django/forms/transforms.py @@ -7,7 +7,7 @@ from reactpy.core.events import EventHandler, to_event_handler_function if TYPE_CHECKING: - from reactpy.core.types import VdomDict + from reactpy.types import VdomDict def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict: @@ -81,7 +81,8 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict: key = attributes.get("name") if key: - vdom_tree["key"] = key + attributes["key"] = key + vdom_tree["attributes"] = attributes return vdom_tree diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index de18c190..4ba11df7 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -3,7 +3,6 @@ import asyncio import logging from collections import defaultdict -from collections.abc import Awaitable from typing import ( TYPE_CHECKING, Any, @@ -17,7 +16,7 @@ from channels import DEFAULT_CHANNEL_LAYER from channels import auth as channels_auth from channels.layers import InMemoryChannelLayer, get_channel_layer -from reactpy import use_callback, use_effect, use_memo, use_ref, use_state +from reactpy import use_async_effect, use_callback, use_effect, use_memo, use_ref, use_state from reactpy import use_connection as _use_connection from reactpy import use_location as _use_location from reactpy import use_scope as _use_scope @@ -39,11 +38,11 @@ from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Awaitable, Sequence from channels_redis.core import RedisChannelLayer from django.contrib.auth.models import AbstractUser - from reactpy.backend.types import Location + from reactpy.types import Location _logger = logging.getLogger(__name__) @@ -124,11 +123,10 @@ def use_query( """ should_execute, set_should_execute = use_state(True) - data, set_data = use_state(cast(Inferred, None)) + data, set_data = use_state(cast("Inferred", None)) loading, set_loading = use_state(True) - error, set_error = use_state(cast(Union[Exception, None], None)) + error, set_error = use_state(cast("Union[Exception, None]", None)) query_ref = use_ref(query) - async_task_refs = use_ref(set()) kwargs = kwargs or {} postprocessor_kwargs = postprocessor_kwargs or {} @@ -141,23 +139,22 @@ async def execute_query() -> None: try: # Run the query query_async = cast( - Callable[..., Awaitable[Inferred]], ensure_async(query, thread_sensitive=thread_sensitive) + "Callable[..., Awaitable[Inferred]]", ensure_async(query, thread_sensitive=thread_sensitive) ) new_data = await query_async(**kwargs) # Run the postprocessor if postprocessor: async_postprocessor = cast( - Callable[..., Awaitable[Any]], ensure_async(postprocessor, thread_sensitive=thread_sensitive) + "Callable[..., Awaitable[Any]]", ensure_async(postprocessor, thread_sensitive=thread_sensitive) ) new_data = await async_postprocessor(new_data, **postprocessor_kwargs) # Log any errors and set the error state except Exception as e: - set_data(cast(Inferred, None)) set_loading(False) set_error(e) - _logger.exception("Failed to execute query: %s", generate_obj_name(query)) + _logger.exception("Failed to execute query '%s'", generate_obj_name(query)) return # Query was successful @@ -166,20 +163,16 @@ async def execute_query() -> None: set_loading(False) set_error(None) - @use_effect(dependencies=None) - def schedule_query() -> None: - """Schedule the query to be run""" + @use_async_effect(dependencies=[should_execute]) + async def schedule_query() -> None: + """Execute a query when needed.""" # Make sure we don't re-execute the query unless we're told to if not should_execute: return - set_should_execute(False) - - # Execute the query in the background - task = asyncio.create_task(execute_query()) - # Add the task to a set to prevent it from being garbage collected - async_task_refs.current.add(task) - task.add_done_callback(async_task_refs.current.remove) + # Execute the query + await execute_query() + set_should_execute(False) @use_callback def refetch() -> None: @@ -233,7 +226,7 @@ def use_mutation( """ loading, set_loading = use_state(False) - error, set_error = use_state(cast(Union[Exception, None], None)) + error, set_error = use_state(cast("Union[Exception, None]", None)) async_task_refs = use_ref(set()) # The main "running" function for `use_mutation` @@ -261,11 +254,10 @@ async def execute_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParam callback() # Schedule the mutation to be run when needed - @use_callback def schedule_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs) -> None: # Set the loading state. - # It's okay to re-execute the mutation if we're told to. The user - # can use the `loading` state to prevent this. + # It's okay to re-execute the mutation if we're told to. If desired, + # The user could use the `loading` state to prevent double execution. set_loading(True) # Execute the mutation in the background @@ -296,7 +288,7 @@ def use_user() -> AbstractUser: def use_user_data( - default_data: (None | dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any]) = None, + default_data: (dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any] | None) = None, save_default_data: bool = False, ) -> UserData: """Get or set user data stored within the REACTPY_DATABASE. @@ -378,7 +370,7 @@ def use_channel_layer( raise ValueError(msg) # Add/remove a group's channel during component mount/dismount respectively. - @use_effect(dependencies=[]) + @use_async_effect(dependencies=[]) async def group_manager(): if group: await channel_layer.group_add(group, channel_name) @@ -386,7 +378,7 @@ async def group_manager(): return None # Listen for messages on the channel using the provided `receiver` function. - @use_effect + @use_async_effect async def message_receiver(): if not receiver: return @@ -448,7 +440,7 @@ async def logout(rerender: bool = True) -> None: return UseAuthTuple(login=login, logout=logout) -async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None: +async def _get_user_data(user: AbstractUser, default_data: dict | None, save_default_data: bool) -> dict | None: """The mutation function for `use_user_data`""" from reactpy_django.models import UserDataModel diff --git a/src/reactpy_django/html.py b/src/reactpy_django/html.py deleted file mode 100644 index d35daf43..00000000 --- a/src/reactpy_django/html.py +++ /dev/null @@ -1,3 +0,0 @@ -from reactpy.core.vdom import make_vdom_constructor - -pyscript = make_vdom_constructor("py-script") diff --git a/src/reactpy_django/javascript_components.py b/src/reactpy_django/javascript_components.py index 80072211..15d12583 100644 --- a/src/reactpy_django/javascript_components.py +++ b/src/reactpy_django/javascript_components.py @@ -2,9 +2,10 @@ from pathlib import Path -from reactpy import web +from reactpy import reactjs -HttpRequest = web.export( - web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "index.js"), - ("HttpRequest"), +HttpRequest = reactjs.component_from_file( + Path(__file__).parent / "static" / "reactpy_django" / "index.js", + "HttpRequest", + name="reactpy-django", ) diff --git a/src/reactpy_django/pyscript/__init__.py b/src/reactpy_django/pyscript/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py deleted file mode 100644 index 0dfb27b7..00000000 --- a/src/reactpy_django/pyscript/component_template.py +++ /dev/null @@ -1,27 +0,0 @@ -# ruff: noqa: TCH004, N802, N816, RUF006 -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import asyncio - - from reactpy_django.pyscript.layout_handler import ReactPyLayoutHandler - - -# User component is inserted below by regex replacement -def user_workspace_UUID(): - """Encapsulate the user's code with a completely unique function (workspace) - to prevent overlapping imports and variable names between different components. - - This code is designed to be run directly by PyScript, and is not intended to be run - in a normal Python environment. - - ReactPy-Django performs string substitutions to turn this file into valid PyScript. - """ - - def root(): ... - - return root() - - -# Create a task to run the user's component workspace -task_UUID = asyncio.create_task(ReactPyLayoutHandler("UUID").run(user_workspace_UUID)) diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py deleted file mode 100644 index 255b6354..00000000 --- a/src/reactpy_django/pyscript/components.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING -from uuid import uuid4 - -from reactpy import component, hooks, html - -from reactpy_django.html import pyscript -from reactpy_django.pyscript.utils import render_pyscript_template -from reactpy_django.utils import reactpy_to_string - -if TYPE_CHECKING: - from reactpy.types import ComponentType, VdomDict - - -@component -def _pyscript_component( - *file_paths: str, - initial: str | VdomDict | ComponentType = "", - root: str = "root", -): - rendered, set_rendered = hooks.use_state(False) - uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current - initial = reactpy_to_string(initial, uuid=uuid) - executor = render_pyscript_template(file_paths, uuid, root) - - if not rendered: - # FIXME: This is needed to properly re-render PyScript during a WebSocket - # disconnection / reconnection. There may be a better way to do this in the future. - set_rendered(True) - return None - - return html._( - html.div( - {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, - initial, - ), - pyscript({"async": ""}, executor), - ) diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py deleted file mode 100644 index 6a7a430b..00000000 --- a/src/reactpy_django/pyscript/layout_handler.py +++ /dev/null @@ -1,127 +0,0 @@ -# type: ignore -import asyncio -import logging - -import js -from jsonpointer import set_pointer -from pyodide.ffi.wrappers import add_event_listener -from pyscript.js_modules import morphdom -from reactpy.core.layout import Layout - - -class ReactPyLayoutHandler: - """Encapsulate the entire layout handler with a class to prevent overlapping - variable names between user code. - - This code is designed to be run directly by PyScript, and is not intended to be run - in a normal Python environment. - """ - - def __init__(self, uuid): - self.uuid = uuid - self.running_tasks = set() - - @staticmethod - def update_model(update, root_model): - """Apply an update ReactPy's internal DOM model.""" - - if update["path"]: - set_pointer(root_model, update["path"], update["model"]) - else: - root_model.update(update["model"]) - - def render_html(self, layout, model): - """Submit ReactPy's internal DOM model into the HTML DOM.""" - - # Create a new container to render the layout into - container = js.document.getElementById(f"pyscript-{self.uuid}") - temp_root_container = container.cloneNode(False) - self.build_element_tree(layout, temp_root_container, model) - - # Use morphdom to update the DOM - morphdom.default(container, temp_root_container) - - # Remove the cloned container to prevent memory leaks - temp_root_container.remove() - - def build_element_tree(self, layout, parent, model): - """Recursively build an element tree, starting from the root component.""" - if isinstance(model, str): - parent.appendChild(js.document.createTextNode(model)) - elif isinstance(model, dict): - if not model["tagName"]: - for child in model.get("children", []): - self.build_element_tree(layout, parent, child) - return - tag = model["tagName"] - attributes = model.get("attributes", {}) - children = model.get("children", []) - element = js.document.createElement(tag) - for key, value in attributes.items(): - if key == "style": - for style_key, style_value in value.items(): - setattr(element.style, style_key, style_value) - elif key == "className": - element.className = value - else: - element.setAttribute(key, value) - for event_name, event_handler_model in model.get("eventHandlers", {}).items(): - self.create_event_handler(layout, element, event_name, event_handler_model) - for child in children: - self.build_element_tree(layout, element, child) - parent.appendChild(element) - else: - msg = f"Unknown model type: {type(model)}" - raise TypeError(msg) - - def create_event_handler(self, layout, element, event_name, event_handler_model): - """Create an event handler for an element. This function is used as an - adapter between ReactPy and browser events.""" - target = event_handler_model["target"] - - def event_handler(*args): - task = asyncio.create_task(layout.deliver({"type": "layout-event", "target": target, "data": args})) - - # Add the task to a set to prevent it from being garbage collected - self.running_tasks.add(task) - task.add_done_callback(self.running_tasks.remove) - - event_name = event_name.lstrip("on_").lower().replace("_", "") - add_event_listener(element, event_name, event_handler) - - @staticmethod - def delete_old_workspaces(): - """To prevent memory leaks, we must delete all user generated Python code when - it is no longer in use (removed from the page). To do this, we compare what - UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global - interpreter.""" - dom_workspaces = js.document.querySelectorAll(".pyscript") - dom_uuids = {element.dataset.uuid for element in dom_workspaces} - python_uuids = {value.split("_")[-1] for value in globals() if value.startswith("user_workspace_")} - - # Delete any workspaces that are not being used - for uuid in python_uuids - dom_uuids: - task_name = f"task_{uuid}" - if task_name in globals(): - task: asyncio.Task = globals()[task_name] - task.cancel() - del globals()[task_name] - else: - logging.error("Could not auto delete PyScript task %s", task_name) - - workspace_name = f"user_workspace_{uuid}" - if workspace_name in globals(): - del globals()[workspace_name] - else: - logging.error("Could not auto delete PyScript workspace %s", workspace_name) - - async def run(self, workspace_function): - """Run the layout handler. This function is main executor for all user generated code.""" - self.delete_old_workspaces() - root_model: dict = {} - - async with Layout(workspace_function()) as root_layout: - while True: - update = await root_layout.render() - self.update_model(update, root_model) - self.render_html(root_layout, root_model) diff --git a/src/reactpy_django/pyscript/utils.py b/src/reactpy_django/pyscript/utils.py deleted file mode 100644 index 6484a5dc..00000000 --- a/src/reactpy_django/pyscript/utils.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import json -import os -import textwrap -from copy import deepcopy -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import jsonpointer -import orjson -import reactpy -from django.templatetags.static import static - -from reactpy_django.utils import create_cache_key - -if TYPE_CHECKING: - from collections.abc import Sequence - - -PYSCRIPT_COMPONENT_TEMPLATE = (Path(__file__).parent / "component_template.py").read_text(encoding="utf-8") -PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "layout_handler.py").read_text(encoding="utf-8") -PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {} - - -def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): - """Inserts the user's code into the PyScript template using pattern matching.""" - from django.core.cache import caches - - from reactpy_django.config import REACTPY_CACHE - - # Create a valid PyScript executor by replacing the template values - executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) - executor = executor.replace("return root()", f"return {root}()") - - # Fetch the user's PyScript code - all_file_contents: list[str] = [] - for file_path in file_paths: - # Try to get user code from cache - cache_key = create_cache_key("pyscript", file_path) - last_modified_time = os.stat(file_path).st_mtime - file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) - if file_contents: - all_file_contents.append(file_contents) - - # If not cached, read from file system - else: - file_contents = Path(file_path).read_text(encoding="utf-8").strip() - all_file_contents.append(file_contents) - caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) - - # Prepare the PyScript code block - user_code = "\n".join(all_file_contents) # Combine all user code - user_code = user_code.replace("\t", " ") # Normalize the text - user_code = textwrap.indent(user_code, " ") # Add indentation to match template - - # Insert the user code into the PyScript template - return executor.replace(" def root(): ...", user_code) - - -def extend_pyscript_config(extra_py: Sequence, extra_js: dict | str, config: dict | str) -> str: - """Extends ReactPy's default PyScript config with user provided values.""" - # Lazily set up the initial config in to wait for Django's static file system - if not PYSCRIPT_DEFAULT_CONFIG: - PYSCRIPT_DEFAULT_CONFIG.update({ - "packages": [ - f"reactpy=={reactpy.__version__}", - f"jsonpointer=={jsonpointer.__version__}", - "ssl", - ], - "js_modules": {"main": {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}}, - }) - - # Extend the Python dependency list - pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) - pyscript_config["packages"].extend(extra_py) - - # Extend the JavaScript dependency list - if extra_js and isinstance(extra_js, str): - pyscript_config["js_modules"]["main"].update(json.loads(extra_js)) - elif extra_js and isinstance(extra_js, dict): - pyscript_config["js_modules"]["main"].update(extra_py) - - # Update the config - if config and isinstance(config, str): - pyscript_config.update(json.loads(config)) - elif config and isinstance(config, dict): - pyscript_config.update(config) - return orjson.dumps(pyscript_config).decode("utf-8") diff --git a/src/reactpy_django/router/resolvers.py b/src/reactpy_django/router/resolvers.py index 30bb3f46..891d63dd 100644 --- a/src/reactpy_django/router/resolvers.py +++ b/src/reactpy_django/router/resolvers.py @@ -1,26 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from reactpy_router.resolvers import StarletteResolver +from reactpy_router.resolvers import ReactPyResolver from reactpy_django.router.converters import CONVERTERS -if TYPE_CHECKING: - from reactpy_router.types import ConversionInfo, Route - - -class DjangoResolver(StarletteResolver): - """A simple route resolver that uses regex to match paths""" - def __init__( - self, - route: Route, - param_pattern=r"<(?P\w+:)?(?P\w+)>", - converters: dict[str, ConversionInfo] | None = None, - ) -> None: - super().__init__( - route=route, - param_pattern=param_pattern, - converters=converters or CONVERTERS, - ) +class DjangoResolver(ReactPyResolver): + param_pattern = r"<(?P\w+:)?(?P\w+)>" + converters = CONVERTERS diff --git a/src/reactpy_django/templates/reactpy/pyscript_component.html b/src/reactpy_django/templates/reactpy/pyscript_component.html index a4767040..2b0fdda2 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_component.html +++ b/src/reactpy_django/templates/reactpy/pyscript_component.html @@ -1,2 +1,2 @@
{{pyscript_initial_html}}
-{{pyscript_executor}} + diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html index 547a672a..85b26c81 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_setup.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -5,4 +5,4 @@ {% endif %} -{{pyscript_layout_handler}} + diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index f74b1ffa..f277fda1 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -5,7 +5,10 @@ from uuid import uuid4 from django import template +from django.templatetags.static import static from django.urls import NoReverseMatch, reverse +from django.utils.safestring import mark_safe +from reactpy.executors.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, pyscript_executor_html from reactpy_django import config as reactpy_config from reactpy_django.exceptions import ( @@ -15,8 +18,8 @@ InvalidHostError, OfflineComponentMissingError, ) -from reactpy_django.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, render_pyscript_template from reactpy_django.utils import ( + fetch_cached_python_file, prerender_component, reactpy_to_string, save_component_params, @@ -27,7 +30,7 @@ if TYPE_CHECKING: from django.http import HttpRequest - from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict + from reactpy.types import Component, ComponentConstructor, VdomDict register = template.Library() @@ -91,8 +94,8 @@ def component( class_ = kwargs.pop("class", "") has_args = bool(args or kwargs) user_component: ComponentConstructor | None = None - _prerender_html = "" - _offline_html = "" + prerender_html = "" + offline_html = "" # Validate the host if host and DJANGO_DEBUG: @@ -148,7 +151,7 @@ def component( ) _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) - _prerender_html = prerender_component(user_component, args, kwargs, uuid, request) + prerender_html = prerender_component(user_component, args, kwargs, uuid, request) # Fetch the offline component's HTML, if requested if offline: @@ -164,7 +167,7 @@ def component( ) _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) - _offline_html = prerender_component(offline_component, [], {}, uuid, request) + offline_html = prerender_component(offline_component, [], {}, uuid, request) # Return the template rendering context return { @@ -173,13 +176,13 @@ def component( "reactpy_host": host or perceived_host, "reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX, "reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/", - "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, + "reactpy_resolved_web_modules_path": f"/{RESOLVED_WEB_MODULES_PATH.strip('/')}/", "reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL, "reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL, "reactpy_reconnect_backoff_multiplier": reactpy_config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, "reactpy_reconnect_max_retries": reactpy_config.REACTPY_RECONNECT_MAX_RETRIES, - "reactpy_prerender_html": _prerender_html, - "reactpy_offline_html": _offline_html, + "reactpy_prerender_html": mark_safe(prerender_html), + "reactpy_offline_html": mark_safe(offline_html), } @@ -187,7 +190,7 @@ def component( def pyscript_component( context: template.RequestContext, *file_paths: str, - initial: str | VdomDict | ComponentType = "", + initial: str | VdomDict | Component = "", root: str = "root", ): """ @@ -208,12 +211,12 @@ def pyscript_component( uuid = uuid4().hex request: HttpRequest | None = context.get("request") initial = reactpy_to_string(initial, request=request, uuid=uuid) - executor = render_pyscript_template(file_paths, uuid, root) + executor = pyscript_executor_html(file_paths, uuid, root, fetch_cached_python_file) return { - "pyscript_executor": executor, + "pyscript_executor": mark_safe(executor), "pyscript_uuid": uuid, - "pyscript_initial_html": initial, + "pyscript_initial_html": mark_safe(initial), } @@ -239,8 +242,12 @@ def pyscript_setup( from reactpy_django.config import DJANGO_DEBUG return { - "pyscript_config": extend_pyscript_config(extra_py, extra_js, config), - "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, + "pyscript_config": mark_safe( + extend_pyscript_config( + extra_py, extra_js, config, {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"} + ) + ), + "pyscript_layout_handler": mark_safe(PYSCRIPT_LAYOUT_HANDLER), "django_debug": DJANGO_DEBUG, } diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 2703523d..e71f9c63 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -13,7 +13,7 @@ ) from django.http import HttpRequest -from reactpy.types import ComponentType, Connection, Key +from reactpy.types import Component, Connection, Key from typing_extensions import ParamSpec if TYPE_CHECKING: @@ -104,11 +104,11 @@ async def __call__(self, message: dict) -> None: ... class ViewToComponentConstructor(Protocol): def __call__( self, request: HttpRequest | None = None, *args: Any, key: Key | None = None, **kwargs: Any - ) -> ComponentType: ... + ) -> Component: ... class ViewToIframeConstructor(Protocol): - def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ... + def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> Component: ... class UseAuthLogin(Protocol): diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 775ba0f9..7b19ee12 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -13,6 +13,7 @@ from fnmatch import fnmatch from functools import wraps from importlib import import_module +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable from uuid import UUID, uuid4 @@ -26,10 +27,10 @@ from django.http import HttpRequest, HttpResponse from django.template import engines from django.utils.encoding import smart_str -from reactpy import vdom_to_html -from reactpy.backend.types import Connection, Location +from reactpy import reactpy_to_string as _reactpy_to_string from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout +from reactpy.types import Connection, Location from reactpy_django.exceptions import ( ComponentDoesNotExistError, @@ -359,7 +360,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__()).result() + SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__(exc_type, exc_val, exc_tb)).result() self.loop.close() def sync_render(self): @@ -405,21 +406,21 @@ def prerender_component( user_component(*args, **kwargs), value=Connection( scope=scope, - location=Location(pathname=request.path, search=f"?{search}" if search else ""), + location=Location(path=request.path, query_string=f"?{search}" if search else ""), carrier=request, ), ) ) as layout: vdom_tree = layout.sync_render()["model"] - return vdom_to_html(vdom_tree) # type: ignore + return _reactpy_to_string(vdom_tree) # type: ignore def reactpy_to_string(vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None) -> str: """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be automatically returned.""" if isinstance(vdom_or_component, dict): - return vdom_to_html(vdom_or_component) # type: ignore + return _reactpy_to_string(vdom_or_component) # type: ignore if hasattr(vdom_or_component, "render"): if not request: @@ -452,7 +453,7 @@ def save_component_params(args, kwargs, uuid) -> None: def validate_host(host: str) -> None: """Validates the host string to ensure it does not contain a protocol.""" if "://" in host: - protocol = host.split("://")[0] + protocol = host.split("://", maxsplit=1)[0] msg = f"Invalid host provided to component. Contains a protocol '{protocol}://'." _logger.error(msg) raise InvalidHostError(msg) @@ -520,7 +521,7 @@ def cached_static_file(static_path: str) -> str: def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: - """Transform intended for use with `html_to_vdom`. + """Transform intended for use with `string_to_reactpy `. Removes ``, ``, and `` while preserving their children. @@ -531,3 +532,22 @@ def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: if vdom["tagName"] in {"html", "body", "head"}: return {"tagName": "", "children": vdom.setdefault("children", [])} return vdom + + +def fetch_cached_python_file(file_path: str, minify: bool = True) -> str: + from reactpy.executors.pyscript.utils import minify_python + + from reactpy_django.config import REACTPY_CACHE + + # Try to get user code from cache + cache_key = create_cache_key("pyscript", file_path) + last_modified_time = os.stat(file_path).st_mtime + file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) + if file_contents: + return file_contents + + file_contents = Path(file_path).read_text(encoding="utf-8").strip() + if minify: + file_contents = minify_python(file_contents) + caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) + return file_contents diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 47ccc717..5b605f6f 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -16,10 +16,10 @@ from channels.auth import login from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.utils import timezone -from reactpy.backend.types import Connection, Location 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 from reactpy_django.tasks import clean from reactpy_django.utils import ensure_async @@ -156,12 +156,12 @@ async def run_dispatcher(self): has_args = scope["url_route"]["kwargs"].get("has_args") scope["reactpy"] = {"id": str(uuid)} query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True) - http_pathname = query_string.get("http_pathname", [""])[0] - http_search = query_string.get("http_search", [""])[0] + http_path = query_string.get("path", [""])[0] + http_query_string = query_string.get("qs", [""])[0] self.recv_queue = asyncio.Queue() connection = Connection( # For `use_connection` scope=scope, - location=Location(pathname=http_pathname, search=http_search), + location=Location(path=http_path, query_string=http_query_string), carrier=self, ) now = timezone.now() @@ -212,7 +212,7 @@ async def run_dispatcher(self): # Start the ReactPy component rendering loop with contextlib.suppress(Exception): await serve_layout( - Layout( # type: ignore + Layout( ConnectionContext( auth_manager(), root_manager(root_component), diff --git a/src/build_scripts/copy_dir.py b/src/scripts/copy_dir.py similarity index 76% rename from src/build_scripts/copy_dir.py rename to src/scripts/copy_dir.py index 0a2cafab..bfd4c2db 100644 --- a/src/build_scripts/copy_dir.py +++ b/src/scripts/copy_dir.py @@ -1,9 +1,10 @@ -# ruff: noqa: INP001 import logging import shutil import sys from pathlib import Path +logger = logging.getLogger(__name__) + def copy_files(source: Path, destination: Path) -> None: if destination.exists(): @@ -19,7 +20,7 @@ def copy_files(source: Path, destination: Path) -> None: if __name__ == "__main__": if len(sys.argv) != 3: - logging.error("Script used incorrectly!\nUsage: python copy_dir.py ") + logger.error("Script used incorrectly!\nUsage: python copy_dir.py ") sys.exit(1) root_dir = Path(__file__).parent.parent.parent @@ -27,7 +28,7 @@ def copy_files(source: Path, destination: Path) -> None: dest = Path(root_dir / sys.argv[2]) if not src.exists(): - logging.error("Source directory %s does not exist", src) + logger.error("Source directory %s does not exist", src) sys.exit(1) copy_files(src, dest) diff --git a/src/scripts/install_deps.py b/src/scripts/install_deps.py new file mode 100644 index 00000000..310283b2 --- /dev/null +++ b/src/scripts/install_deps.py @@ -0,0 +1,37 @@ +""" +Development/debug script to parse pyproject.toml to find dependecies then install them in the local +environment via `uv pip install -U ` +""" + +import subprocess +from pathlib import Path + +import toml + +DEPENDENCIES = set() + + +def find_deps(data): + """Recurse through all categories and find any list with `dependencies` in the name, then combine + all dependencies into a single list""" + if isinstance(data, dict): + for key, value in data.items(): + if "dependencies" in key and isinstance(value, list) and value and isinstance(value[0], str): + DEPENDENCIES.update(value) + else: + find_deps(value) + elif isinstance(data, list): + for item in data: + find_deps(item) + + +def install_deps(): + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + pyproject_data = toml.load(pyproject_path) + find_deps(pyproject_data) + DEPENDENCIES.remove("ruff") # ruff only exists in dev dependencies for CI purposes. + subprocess.run(["uv", "pip", "install", "-U", *DEPENDENCIES], check=False) # noqa: S607 + + +if __name__ == "__main__": + install_deps() diff --git a/src/scripts/run_django.py b/src/scripts/run_django.py new file mode 100644 index 00000000..e64b401b --- /dev/null +++ b/src/scripts/run_django.py @@ -0,0 +1,16 @@ +""" +Development/debug script to run Django's development server in the local environment. +You should run the `install_deps.py` script before this to ensure all dependencies are installed. +""" + +import subprocess +import sys +from pathlib import Path + +if __name__ == "__main__": + # Run server and pass through any additional command line arguments (e.g. for specifying a different port) + subprocess.run( + [sys.executable, "manage.py", "runserver", *sys.argv[1:]], + check=True, + cwd=Path(__file__).parent.parent.parent / "tests", + ) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index fb3ee7d4..df44ad6c 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -8,14 +8,7 @@ assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0 assert ( subprocess.run( - [ - "bun", - "build", - "./src/index.ts", - f"--outdir={static_dir}", - "--minify", - "--sourcemap=linked", - ], + ["bun", "build", "./src/index.ts", f"--outdir={static_dir}", "--sourcemap=linked"], cwd=str(js_dir), check=True, ).returncode diff --git a/tests/test_app/components.py b/tests/test_app/components.py index bcdf8276..d35cb89e 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -7,7 +7,7 @@ from channels.db import database_sync_to_async from django.contrib.auth import get_user_model from django.http import HttpRequest -from reactpy import component, hooks, html, web +from reactpy import component, hooks, html, reactjs import reactpy_django from reactpy_django.components import view_to_component, view_to_iframe @@ -28,13 +28,13 @@ @component def hello_world(): - return html._(html.div({"id": "hello-world"}, "Hello World!")) + return html(html.div({"id": "hello-world"}, "Hello World!")) @component def button(): count, set_count = hooks.use_state(0) - return html._( + return html( html.div( "button:", html.button( @@ -49,7 +49,7 @@ def button(): @component def parameterized_component(x, y): total = x + y - return html._( + return html( html.div( {"id": "parametrized-component", "data-value": total}, f"parameterized_component: {total}", @@ -61,21 +61,17 @@ def parameterized_component(x, y): def object_in_templatetag(my_object: TestObject): success = bool(my_object and my_object.value) co_name = inspect.currentframe().f_code.co_name - return html._(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object))) + return html(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object))) -SimpleButtonModule = web.module_from_file( - "SimpleButton", - Path(__file__).parent / "tests" / "js" / "button-from-js-module.js", - resolve_exports=False, - fallback="...", +SimpleButton = reactjs.component_from_file( + Path(__file__).parent / "tests" / "js" / "button-from-js-module.js", import_names="SimpleButton", fallback="..." ) -SimpleButton = web.export(SimpleButtonModule, "SimpleButton") @component def button_from_js_module(): - return html._("button_from_js_module:", SimpleButton({"id": "button-from-js-module"})) + return html("button_from_js_module:", SimpleButton({"id": "button-from-js-module"})) @component @@ -125,7 +121,7 @@ def django_css(): @component def django_js(): success = False - return html._( + return html( html.div( {"id": "django-js", "data-success": success}, f"django_js: {success}", @@ -319,7 +315,7 @@ def on_change(event): elif items.data is None: rendered_items = html.h2("Loading...") else: - rendered_items = html._( + rendered_items = html( html.h3("Not Done"), _render_todo_items([i for i in items.data if not i.done], toggle_item), html.h3("Done"), @@ -390,7 +386,7 @@ async def on_change(event): elif items.data is None: rendered_items = html.h2("Loading...") else: - rendered_items = html._( + rendered_items = html( html.h3("Not Done"), _render_todo_items([i for i in items.data if not i.done], toggle_item), html.h3("Done"), @@ -503,7 +499,7 @@ def on_click(_): post_request.method = "POST" set_request(post_request) - return html._( + return html( html.button( { "id": f"{inspect.currentframe().f_code.co_name}_btn", @@ -522,7 +518,7 @@ def view_to_component_args(): def on_click(_): set_success("") - return html._( + return html( html.button( { "id": f"{inspect.currentframe().f_code.co_name}_btn", @@ -541,7 +537,7 @@ def view_to_component_kwargs(): def on_click(_): set_success("") - return html._( + return html( html.button( { "id": f"{inspect.currentframe().f_code.co_name}_btn", @@ -696,17 +692,17 @@ async def on_submit(event): @component def use_auth(): - _login, _logout = reactpy_django.hooks.use_auth() + login_, logout_ = reactpy_django.hooks.use_auth() uuid = hooks.use_ref(str(uuid4())).current current_user = reactpy_django.hooks.use_user() connection = reactpy_django.hooks.use_connection() async def login_user(event): new_user, _created = await get_user_model().objects.aget_or_create(username="user_4") - await _login(new_user) + await login_(new_user) async def logout_user(event): - await _logout() + await logout_() async def disconnect(event): await connection.carrier.close() @@ -728,17 +724,17 @@ async def disconnect(event): @component def use_auth_no_rerender(): - _login, _logout = reactpy_django.hooks.use_auth() + login_, logout_ = reactpy_django.hooks.use_auth() uuid = hooks.use_ref(str(uuid4())).current current_user = reactpy_django.hooks.use_user() connection = reactpy_django.hooks.use_connection() async def login_user(event): new_user, _created = await get_user_model().objects.aget_or_create(username="user_5") - await _login(new_user, rerender=False) + await login_(new_user, rerender=False) async def logout_user(event): - await _logout(rerender=False) + await logout_(rerender=False) async def disconnect(event): await connection.carrier.close() diff --git a/tests/test_app/pyscript/components/multifile_parent.py b/tests/test_app/pyscript/components/multifile_parent.py index c54d7719..3773e9a4 100644 --- a/tests/test_app/pyscript/components/multifile_parent.py +++ b/tests/test_app/pyscript/components/multifile_parent.py @@ -1,4 +1,4 @@ -# ruff: noqa: TCH004 +# ruff: noqa: TC004 from typing import TYPE_CHECKING from reactpy import component, html diff --git a/tests/test_app/pyscript/components/server_side.py b/tests/test_app/pyscript/components/server_side.py index 682411d5..d5a6af4d 100644 --- a/tests/test_app/pyscript/components/server_side.py +++ b/tests/test_app/pyscript/components/server_side.py @@ -1,13 +1,13 @@ -from reactpy import component, html, use_state - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component, use_state @component def parent(): return html.div( {"id": "parent"}, - pyscript_component("./test_app/pyscript/components/child.py"), + pyscript_component( + "./test_app/pyscript/components/child.py", + ), ) @@ -30,5 +30,7 @@ def parent_toggle(): {"onClick": lambda _: set_state(not state)}, "Click to show/hide", ), - pyscript_component("./test_app/pyscript/components/child.py"), + pyscript_component( + "./test_app/pyscript/components/child.py", + ), ) diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py index ea95c5f2..81fa135e 100644 --- a/tests/test_app/router/components.py +++ b/tests/test_app/router/components.py @@ -1,5 +1,7 @@ -from reactpy import component, html, use_location -from reactpy_router import route, use_params, use_search_params +from uuid import uuid4 + +from reactpy import component, html, use_location, use_state +from reactpy_router import link, route, use_params, use_search_params from reactpy_router.types import Route from reactpy_django.router import django_router @@ -11,14 +13,14 @@ def display_params(string: str): search_params = use_search_params() url_params = use_params() - return html._( + return html( html.div({"id": "router-string"}, string), html.div( - {"id": "router-path", "data-path": location.pathname}, - f"path: {location.pathname}", + {"id": "router-path", "data-path": location.path}, + f"path: {location.path}", ), html.div(f"url_params: {url_params}"), - html.div(f"location.search: {location.search}"), + html.div(f"location.query_string: {location.query_string}"), html.div(f"search_params: {search_params}"), ) @@ -27,6 +29,21 @@ def show_route(path: str, *children: Route) -> Route: return route(path, display_params(path), *children) +@component +def next_page(): + url_params = use_params() + state, set_state = use_state(uuid4) + page = url_params.get("page", 0) + next_page = page + 1 + return html.fragment( + display_params("/router/next//"), + html.div({"id": "router-uuid", "data-uuid": state.hex}, f"UUID: {state.hex}"), + html.button( + link({"to": f"/router/next/{next_page}/"}, "Next Page"), + ), + ) + + @component def main(): return django_router( @@ -39,4 +56,5 @@ def main(): show_route("/router/uuid//"), show_route("/router/any/"), show_route("/router/two///"), + route("/router/next//", next_page()), ) diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py index 514ca1be..162a585a 100644 --- a/tests/test_app/settings_multi_db.py +++ b/tests/test_app/settings_multi_db.py @@ -132,12 +132,7 @@ "version": 1, "disable_existing_loggers": False, "handlers": { - "console": {"class": "logging.StreamHandler"}, - }, - "loggers": { - "reactpy_django": {"handlers": ["console"], "level": LOG_LEVEL}, - "reactpy": {"handlers": ["console"], "level": LOG_LEVEL}, - "django.request": {"handlers": ["console"], "level": LOG_LEVEL}, + "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL}, }, } diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py index 2f7f3e00..7a87b060 100644 --- a/tests/test_app/settings_single_db.py +++ b/tests/test_app/settings_single_db.py @@ -118,12 +118,7 @@ "version": 1, "disable_existing_loggers": False, "handlers": { - "console": {"class": "logging.StreamHandler"}, - }, - "loggers": { - "reactpy_django": {"handlers": ["console"], "level": LOG_LEVEL}, - "reactpy": {"handlers": ["console"], "level": LOG_LEVEL}, - "django.request": {"handlers": ["console"], "level": LOG_LEVEL}, + "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL}, }, } diff --git a/tests/test_app/templates/async_event_form.html b/tests/test_app/templates/async_event_form.html index af77e5c1..6a07c916 100644 --- a/tests/test_app/templates/async_event_form.html +++ b/tests/test_app/templates/async_event_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index aaef6cb7..c2c9dd45 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/bootstrap_form.html b/tests/test_app/templates/bootstrap_form.html index 0ef218db..f985bbb7 100644 --- a/tests/test_app/templates/bootstrap_form.html +++ b/tests/test_app/templates/bootstrap_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} {% load django_bootstrap5 %} +{% load static %} {% load reactpy %} {% load django_bootstrap5 %} diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html index 26361861..8720668e 100644 --- a/tests/test_app/templates/channel_layers.html +++ b/tests/test_app/templates/channel_layers.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/errors.html b/tests/test_app/templates/errors.html index 0d4ab161..67590666 100644 --- a/tests/test_app/templates/errors.html +++ b/tests/test_app/templates/errors.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/event_timing.html b/tests/test_app/templates/event_timing.html index 4f75dafb..f0dc5c3c 100644 --- a/tests/test_app/templates/event_timing.html +++ b/tests/test_app/templates/event_timing.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} @@ -74,7 +74,7 @@

ReactPy Event Timing Test Page

await new Promise((resolve) => setTimeout(resolve, 50)); } } - calculateEventTiming(); + calculateEventTiming();
diff --git a/tests/test_app/templates/events_renders_per_second.html b/tests/test_app/templates/events_renders_per_second.html index cb1ed5c8..715340d8 100644 --- a/tests/test_app/templates/events_renders_per_second.html +++ b/tests/test_app/templates/events_renders_per_second.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/form.html b/tests/test_app/templates/form.html index ecffc1ac..e6794521 100644 --- a/tests/test_app/templates/form.html +++ b/tests/test_app/templates/form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html index 1eb2be2a..17273987 100644 --- a/tests/test_app/templates/host_port.html +++ b/tests/test_app/templates/host_port.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/host_port_roundrobin.html b/tests/test_app/templates/host_port_roundrobin.html index ad2dada0..407a16ae 100644 --- a/tests/test_app/templates/host_port_roundrobin.html +++ b/tests/test_app/templates/host_port_roundrobin.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/mixed_time_to_load.html b/tests/test_app/templates/mixed_time_to_load.html index 3b23050c..5fa03438 100644 --- a/tests/test_app/templates/mixed_time_to_load.html +++ b/tests/test_app/templates/mixed_time_to_load.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/model_form.html b/tests/test_app/templates/model_form.html index 3c28eb07..ca169a9f 100644 --- a/tests/test_app/templates/model_form.html +++ b/tests/test_app/templates/model_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/net_io_time_to_load.html b/tests/test_app/templates/net_io_time_to_load.html index c5d79050..45e86dce 100644 --- a/tests/test_app/templates/net_io_time_to_load.html +++ b/tests/test_app/templates/net_io_time_to_load.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} @@ -37,7 +37,7 @@

ReactPy Network IO Intensive Time To Load Per Second Test Page

await new Promise((resolve) => setTimeout(resolve, 50)); } } - calculateTTL(); + calculateTTL();
diff --git a/tests/test_app/templates/offline.html b/tests/test_app/templates/offline.html index e7c39106..34e6d903 100644 --- a/tests/test_app/templates/offline.html +++ b/tests/test_app/templates/offline.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/prerender.html b/tests/test_app/templates/prerender.html index dab4ba01..6c59042e 100644 --- a/tests/test_app/templates/prerender.html +++ b/tests/test_app/templates/prerender.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 57a5dd15..1f77f290 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/renders_per_second.html b/tests/test_app/templates/renders_per_second.html index 2ec2f868..9253f543 100644 --- a/tests/test_app/templates/renders_per_second.html +++ b/tests/test_app/templates/renders_per_second.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/router.html b/tests/test_app/templates/router.html index ee15fb64..292da720 100644 --- a/tests/test_app/templates/router.html +++ b/tests/test_app/templates/router.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/sync_event_form.html b/tests/test_app/templates/sync_event_form.html index c22955d1..5b9d8c00 100644 --- a/tests/test_app/templates/sync_event_form.html +++ b/tests/test_app/templates/sync_event_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/time_to_load.html b/tests/test_app/templates/time_to_load.html index 4e30f3eb..411ba2df 100644 --- a/tests/test_app/templates/time_to_load.html +++ b/tests/test_app/templates/time_to_load.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} @@ -37,7 +37,7 @@

ReactPy Time To Load Test Page

await new Promise((resolve) => setTimeout(resolve, 50)); } } - calculateTTL(); + calculateTTL();
diff --git a/tests/test_app/templates/view_to_component_script.html b/tests/test_app/templates/view_to_component_script.html index c9c5d263..b191179a 100644 --- a/tests/test_app/templates/view_to_component_script.html +++ b/tests/test_app/templates/view_to_component_script.html @@ -1,16 +1,11 @@ -{% extends "view_to_component.html" %} - -{% block top %} +{% extends "view_to_component.html" %} {% block top %} -{% endblock %} - -{% block bottom %} +{% endblock %} {% block bottom %} {% endblock %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 9f4fc495..db8875cf 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,18 +1,17 @@ # type: ignore -# ruff: noqa: RUF012, N802 +# ruff: noqa: RUF012 import os import socket from uuid import uuid4 import pytest -from playwright.sync_api import TimeoutError, expect +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import expect +from reactpy.testing import DEFAULT_TYPE_DELAY as DELAY from reactpy_django.models import ComponentSession -from reactpy_django.utils import str_to_bool -from .utils import GITHUB_ACTIONS, PlaywrightTestCase, navigate_to_page - -CLICK_DELAY = 250 if str_to_bool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. +from .utils import PlaywrightTestCase, navigate_to_page class ComponentTests(PlaywrightTestCase): @@ -30,7 +29,7 @@ def test_component_hello_world(self): def test_component_counter(self): for i in range(5): self.page.locator(f"#counter-num[data-count={i}]") - self.page.locator("#counter-inc").click(delay=CLICK_DELAY) + self.page.locator("#counter-inc").click(delay=DELAY) @navigate_to_page("/") def test_component_parametrized_component(self): @@ -75,13 +74,13 @@ def test_component_static_js(self): @navigate_to_page("/") def test_component_unauthorized_user(self): - with pytest.raises(TimeoutError): + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector("#unauthorized-user", timeout=1) self.page.wait_for_selector("#unauthorized-user-fallback") @navigate_to_page("/") def test_component_authorized_user(self): - with pytest.raises(TimeoutError): + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector("#authorized-user-fallback", timeout=1) self.page.wait_for_selector("#authorized-user") @@ -102,11 +101,11 @@ def test_component_use_query_and_mutation(self): item_ids = list(range(5)) for i in item_ids: - todo_input.type(f"sample-{i}", delay=CLICK_DELAY) - todo_input.press("Enter", delay=CLICK_DELAY) + todo_input.type(f"sample-{i}", delay=DELAY) + todo_input.press("Enter", delay=DELAY) self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}") - self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY) - with pytest.raises(TimeoutError): + self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=DELAY) + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1) @navigate_to_page("/") @@ -116,11 +115,11 @@ def test_component_async_use_query_and_mutation(self): item_ids = list(range(5)) for i in item_ids: - todo_input.type(f"sample-{i}", delay=CLICK_DELAY) - todo_input.press("Enter", delay=CLICK_DELAY) + todo_input.type(f"sample-{i}", delay=DELAY) + todo_input.press("Enter", delay=DELAY) self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}") - self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY) - with pytest.raises(TimeoutError): + self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=DELAY) + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1) @navigate_to_page("/") @@ -146,7 +145,7 @@ def test_component_view_to_component_template_view_class(self): @navigate_to_page("/") def _click_btn_and_check_success(self, name): self.page.locator(f"#{name}:not([data-success=true])").wait_for() - self.page.wait_for_selector(f"#{name}_btn").click(delay=CLICK_DELAY) + self.page.wait_for_selector(f"#{name}_btn").click(delay=DELAY) self.page.locator(f"#{name}[data-success=true]").wait_for() @navigate_to_page("/") @@ -242,42 +241,42 @@ def test_component_use_user_data(self): assert "Data: None" in user_data_div.text_content() # Test first user's data - login_1.click(delay=CLICK_DELAY) + login_1.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) assert "Data: {}" in user_data_div.text_content() - text_input.type("test", delay=CLICK_DELAY) - text_input.press("Enter", delay=CLICK_DELAY) + text_input.type("test", delay=DELAY) + text_input.press("Enter", delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) assert "Data: {'test': 'test'}" in user_data_div.text_content() # Test second user's data - login_2.click(delay=CLICK_DELAY) + login_2.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {}" in user_data_div.text_content() - text_input.press("Control+A", delay=CLICK_DELAY) - text_input.press("Backspace", delay=CLICK_DELAY) - text_input.type("test 2", delay=CLICK_DELAY) - text_input.press("Enter", delay=CLICK_DELAY) + text_input.press("Control+A", delay=DELAY) + text_input.press("Backspace", delay=DELAY) + text_input.type("test 2", delay=DELAY) + text_input.press("Enter", delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {'test 2': 'test 2'}" in user_data_div.text_content() # Attempt to clear data - clear.click(delay=CLICK_DELAY) + clear.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {}" in user_data_div.text_content() # Attempt to logout - logout.click(delay=CLICK_DELAY) + logout.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]" ) @@ -296,13 +295,13 @@ def test_component_use_user_data_with_default(self): assert "Data: None" in user_data_div.text_content() # Test first user's data - login_3.click(delay=CLICK_DELAY) + login_3.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content() - text_input.type("test", delay=CLICK_DELAY) - text_input.press("Enter", delay=CLICK_DELAY) + text_input.type("test", delay=DELAY) + text_input.press("Enter", delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) @@ -312,7 +311,7 @@ def test_component_use_user_data_with_default(self): ) # Attempt to clear data - clear.click(delay=CLICK_DELAY) + clear.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) @@ -325,25 +324,25 @@ def test_component_use_auth(self): uuid = self.page.wait_for_selector("#use-auth").get_attribute("data-uuid") assert len(uuid) == 36 - self.page.wait_for_selector("#use-auth .login").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .login").click(delay=DELAY) # Wait for #use-auth[data-username="user_4"] to appear self.page.wait_for_selector("#use-auth[data-username='user_4']") self.page.wait_for_selector(f"#use-auth[data-uuid='{uuid}']") # Press disconnect and wait for #use-auth[data-uuid=...] to disappear - self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .disconnect").click(delay=DELAY) expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) # Double check that the same user is logged in self.page.wait_for_selector("#use-auth[data-username='user_4']") # Press logout and wait for #use-auth[data-username="AnonymousUser"] to appear - self.page.wait_for_selector("#use-auth .logout").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .logout").click(delay=DELAY) self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']") # Press disconnect and wait for #use-auth[data-uuid=...] to disappear - self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .disconnect").click(delay=DELAY) expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) # Double check that the user stayed logged out @@ -357,22 +356,22 @@ def test_component_use_auth(self): # uuid = self.page.wait_for_selector("#use-auth-no-rerender").get_attribute("data-uuid") # assert len(uuid) == 36 - # self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=DELAY) # # Make sure #use-auth[data-username="user_5"] does not appear - # with pytest.raises(TimeoutError): + # with pytest.raises(PlaywrightTimeoutError): # self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']", timeout=1) # # Press disconnect and see if #use-auth[data-username="user_5"] appears - # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=DELAY) # self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']") # # Press logout and make sure #use-auth[data-username="AnonymousUser"] does not appear - # with pytest.raises(TimeoutError): + # with pytest.raises(PlaywrightTimeoutError): # self.page.wait_for_selector("#use-auth-no-rerender[data-username='AnonymousUser']", timeout=1) # # Press disconnect and see if #use-auth[data-username="AnonymousUser"] appears - # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=DELAY) @navigate_to_page("/") def test_component_use_rerender(self): @@ -380,7 +379,7 @@ def test_component_use_rerender(self): assert len(initial_uuid) == 36 rerender_button = self.page.wait_for_selector("#use-rerender button") - rerender_button.click(delay=CLICK_DELAY) + rerender_button.click(delay=DELAY) # Wait for #use-rerender[data-uuid=...] to disappear expect(self.page.locator(f"#use-rerender[data-uuid='{initial_uuid}']")).to_have_count(0) @@ -536,6 +535,19 @@ def test_url_router_int_and_string(self): string = self.page.query_selector("#router-string") assert string.text_content() == "/router/two///" + def test_url_router_navigation_state(self): + self.page.goto(f"{self.live_server_url}/router/next/1/") + uuid1 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid") + self.page.locator("button").click() + self.page.wait_for_selector(f"#router-path[data-path='/router/next/2/']") + uuid2 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid") + assert uuid1 == uuid2 + self.page.go_back() + self.page.wait_for_selector(f"#router-path[data-path='/router/next/1/']") + uuid3 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid") + # When going back, it should also be a new mount if navigation always remounts + assert uuid1 == uuid3 + ####################### # Channel Layer Tests # ####################### @@ -543,14 +555,14 @@ def test_url_router_int_and_string(self): @navigate_to_page("/channel-layers/") def test_channel_layer_components(self): sender = self.page.wait_for_selector("#sender") - sender.type("test", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) + sender.type("test", delay=DELAY) + sender.press("Enter", delay=DELAY) receiver = self.page.wait_for_selector("#receiver[data-message='test']") assert receiver is not None sender = self.page.wait_for_selector("#group-sender") - sender.type("1234", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) + sender.type("1234", delay=DELAY) + sender.press("Enter", delay=DELAY) receiver_1 = self.page.wait_for_selector("#group-receiver-1[data-message='1234']") receiver_2 = self.page.wait_for_selector("#group-receiver-2[data-message='1234']") receiver_3 = self.page.wait_for_selector("#group-receiver-3[data-message='1234']") @@ -581,11 +593,11 @@ def test_pyscript_1_multifile(self): def test_pyscript_1_counter(self): self.page.wait_for_selector("#counter") self.page.wait_for_selector("#counter pre[data-value='0']") - self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter .plus").click(delay=DELAY) self.page.wait_for_selector("#counter pre[data-value='1']") - self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter .plus").click(delay=DELAY) self.page.wait_for_selector("#counter pre[data-value='2']") - self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter .minus").click(delay=DELAY) self.page.wait_for_selector("#counter pre[data-value='1']") @navigate_to_page("/pyscript/") @@ -593,24 +605,24 @@ def test_pyscript_1_server_side_parent(self): self.page.wait_for_selector("#parent") self.page.wait_for_selector("#child") self.page.wait_for_selector("#child pre[data-value='0']") - self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child .plus").click(delay=DELAY) self.page.wait_for_selector("#child pre[data-value='1']") - self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child .plus").click(delay=DELAY) self.page.wait_for_selector("#child pre[data-value='2']") - self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child .minus").click(delay=DELAY) self.page.wait_for_selector("#child pre[data-value='1']") @navigate_to_page("/pyscript/") def test_pyscript_1_server_side_parent_with_toggle(self): self.page.wait_for_selector("#parent-toggle") - self.page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle button").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle") self.page.wait_for_selector("#parent-toggle pre[data-value='0']") - self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle .plus").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='1']") - self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle .plus").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='2']") - self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle .minus").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='1']") @navigate_to_page("/pyscript/") @@ -662,7 +674,7 @@ def test_distributed_custom_host_wrong_port(self): tmp_sock.bind((self._server_process_0.host, 0)) random_port = tmp_sock.getsockname()[1] self.page.goto(f"{self.live_server_url}/port/{random_port}/") - with pytest.raises(TimeoutError): + with pytest.raises(PlaywrightTimeoutError): self.page.locator(".custom_host").wait_for(timeout=1000) ################# @@ -724,7 +736,7 @@ def test_form_basic(self): self.page.wait_for_selector("#id_password_field") self.page.wait_for_selector("#id_model_choice_field") self.page.wait_for_selector("#id_model_multiple_choice_field") - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) self.page.wait_for_selector(".errorlist") # Submitting an empty form should result in 22 error elements. @@ -732,35 +744,35 @@ def test_form_basic(self): assert len(self.page.query_selector_all(".errorlist")) == 22 # Fill out the form - self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#id_boolean_field").click(delay=DELAY) expect(self.page.locator("#id_boolean_field")).to_be_checked() - self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY) + self.page.locator("#id_char_field").type("test", delay=DELAY) self.page.locator("#id_choice_field").select_option("2") - self.page.locator("#id_date_field").type("2021-01-01", delay=CLICK_DELAY) - self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=CLICK_DELAY) - self.page.locator("#id_decimal_field").type("0.123", delay=CLICK_DELAY) - self.page.locator("#id_duration_field").type("1", delay=CLICK_DELAY) - self.page.locator("#id_email_field").type("test@example.com", delay=CLICK_DELAY) + self.page.locator("#id_date_field").type("2021-01-01", delay=DELAY) + self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=DELAY) + self.page.locator("#id_decimal_field").type("0.123", delay=DELAY) + self.page.locator("#id_duration_field").type("1", delay=DELAY) + self.page.locator("#id_email_field").type("test@example.com", delay=DELAY) file_path_field_options = self.page.query_selector_all("#id_file_path_field option") file_path_field_values: list[str] = [option.get_attribute("value") for option in file_path_field_options] self.page.locator("#id_file_path_field").select_option(file_path_field_values[1]) - self.page.locator("#id_float_field").type("1.2345", delay=CLICK_DELAY) - self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=CLICK_DELAY) - self.page.locator("#id_integer_field").type("123", delay=CLICK_DELAY) + self.page.locator("#id_float_field").type("1.2345", delay=DELAY) + self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=DELAY) + self.page.locator("#id_integer_field").type("123", delay=DELAY) self.page.locator("#id_json_field").clear() - self.page.locator("#id_json_field").type('{"key": "value"}', delay=CLICK_DELAY) + self.page.locator("#id_json_field").type('{"key": "value"}', delay=DELAY) self.page.locator("#id_multiple_choice_field").select_option(["2", "3"]) self.page.locator("#id_null_boolean_field").select_option("false") - self.page.locator("#id_regex_field").type("12", delay=CLICK_DELAY) - self.page.locator("#id_slug_field").type("my-slug-text", delay=CLICK_DELAY) - self.page.locator("#id_time_field").type("01:01:00", delay=CLICK_DELAY) + self.page.locator("#id_regex_field").type("12", delay=DELAY) + self.page.locator("#id_slug_field").type("my-slug-text", delay=DELAY) + self.page.locator("#id_time_field").type("01:01:00", delay=DELAY) self.page.locator("#id_typed_choice_field").select_option("2") self.page.locator("#id_typed_multiple_choice_field").select_option(["1", "2"]) - self.page.locator("#id_url_field").type("http://example.com", delay=CLICK_DELAY) - self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=CLICK_DELAY) - self.page.locator("#id_combo_field").type("test@example.com", delay=CLICK_DELAY) - self.page.locator("#id_password_field").type("password", delay=CLICK_DELAY) + self.page.locator("#id_url_field").type("http://example.com", delay=DELAY) + self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=DELAY) + self.page.locator("#id_combo_field").type("test@example.com", delay=DELAY) + self.page.locator("#id_password_field").type("password", delay=DELAY) model_choice_field_options = self.page.query_selector_all("#id_model_multiple_choice_field option") model_choice_field_values: list[str] = [option.get_attribute("value") for option in model_choice_field_options] self.page.locator("#id_model_choice_field").select_option(model_choice_field_values[0]) @@ -771,7 +783,7 @@ def test_form_basic(self): # Submit and wait for one of the error messages to disappear (indicating that the form has been re-rendered) invalid_feedback = self.page.locator(".errorlist").all()[0] - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) expect(invalid_feedback).not_to_be_attached() # Make sure no errors remain assert len(self.page.query_selector_all(".errorlist")) == 0 @@ -794,7 +806,7 @@ def test_form_bootstrap(self): self.page.wait_for_selector("#id_boolean_field") self.page.wait_for_selector("#id_char_field") self.page.wait_for_selector("#id_choice_field") - self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("button[type=submit]").click(delay=DELAY) self.page.wait_for_selector(".invalid-feedback") # Submitting an empty form should result in 2 error elements. @@ -802,14 +814,14 @@ def test_form_bootstrap(self): assert len(self.page.query_selector_all(".invalid-feedback")) == 2 # Fill out the form - self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#id_boolean_field").click(delay=DELAY) expect(self.page.locator("#id_boolean_field")).to_be_checked() - self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY) + self.page.locator("#id_char_field").type("test", delay=DELAY) self.page.locator("#id_choice_field").select_option("2") # Submit and wait for one of the error messages to disappear (indicating that the form has been re-rendered) invalid_feedback = self.page.locator(".invalid-feedback").all()[0] - self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("button[type=submit]").click(delay=DELAY) expect(invalid_feedback).not_to_be_attached() # Make sure no errors remain assert len(self.page.query_selector_all(".invalid-feedback")) == 0 @@ -818,7 +830,7 @@ def test_form_bootstrap(self): def test_form_orm_model(self): uuid = uuid4().hex self.page.wait_for_selector("form") - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) self.page.wait_for_selector(".errorlist") # Submitting an empty form should result in 1 error element. @@ -826,10 +838,10 @@ def test_form_orm_model(self): assert len(error_list) == 1 # Fill out the form - self.page.locator("#id_text").type(uuid, delay=CLICK_DELAY) + self.page.locator("#id_text").type(uuid, delay=DELAY) # Submit the form - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # Wait for the error message to disappear (indicating that the form has been re-rendered) expect(error_list[0]).not_to_be_attached() @@ -861,7 +873,7 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Submit empty the form - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # The empty form was submitted, should result in an error # self.page.wait_for_selector("#success[data-value='false']") @@ -870,8 +882,8 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Fill out the form and re-submit - # self.page.wait_for_selector("#id_char_field").type("test", delay=CLICK_DELAY) - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # Form should have been successfully submitted # self.page.wait_for_selector("#success[data-value='true']") @@ -890,7 +902,7 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Submit empty the form - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # The empty form was submitted, should result in an error # self.page.wait_for_selector("#success[data-value='false']") @@ -899,8 +911,8 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Fill out the form and re-submit - # self.page.wait_for_selector("#id_char_field").type("test", delay=CLICK_DELAY) - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # Form should have been successfully submitted # self.page.wait_for_selector("#success[data-value='true']") diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index de43d958..5a58ea64 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -1,4 +1,4 @@ -# ruff: noqa: N802, RUF012, T201 +# ruff: noqa: RUF012, T201 import asyncio import os import sys @@ -143,10 +143,10 @@ def navigate_to_page(path: str, *, server_num=0): def _decorator(func: Callable): @decorator.decorator def _wrapper(func: Callable, self: PlaywrightTestCase, *args, **kwargs): - _port = getattr(self, f"_port_{server_num}") - _path = f"http://{self.host}:{_port}/{path.lstrip('/')}" - if self.page.url != _path: - self.page.goto(_path) + port = getattr(self, f"_port_{server_num}") + path_ = f"http://{self.host}:{port}/{path.lstrip('/')}" + if self.page.url != path_: + self.page.goto(path_) return func(self, *args, **kwargs) return _wrapper(func)