diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml
index cdc0b89e..3e4f29b0 100644
--- a/.github/workflows/test-python.yml
+++ b/.github/workflows/test-python.yml
@@ -46,3 +46,18 @@ jobs:
run: pip install --upgrade pip hatch uv
- name: Check Python formatting
run: hatch fmt src tests --check
+
+ python-types:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - uses: actions/setup-python@v5
+ with:
+ python-version: 3.x
+ - name: Install Python Dependencies
+ run: pip install --upgrade pip hatch uv
+ - name: Check Python formatting
+ run: hatch run python:type_check
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f819750b..21a31b40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,10 @@ Don't forget to remove deprecated code on each major release!
- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component!
+### Changed
+
+- Refactoring of internal code to improve maintainability. No changes to public/documented API.
+
## [5.1.1] - 2024-12-02
### Fixed
diff --git a/docs/examples/python/example/models.py b/docs/examples/python/example/models.py
new file mode 100644
index 00000000..2bf062b9
--- /dev/null
+++ b/docs/examples/python/example/models.py
@@ -0,0 +1,4 @@
+from django.db import models
+
+
+class TodoItem(models.Model): ...
diff --git a/docs/examples/python/pyscript_ffi.py b/docs/examples/python/pyscript_ffi.py
new file mode 100644
index 00000000..d744dd88
--- /dev/null
+++ b/docs/examples/python/pyscript_ffi.py
@@ -0,0 +1,14 @@
+from pyscript import document, window
+from reactpy import component, html
+
+
+@component
+def root():
+ def on_click(event):
+ my_element = document.querySelector("#example")
+ my_element.innerText = window.location.hostname
+
+ return html.div(
+ {"id": "example"},
+ html.button({"onClick": on_click}, "Click Me!"),
+ )
diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md
index 59f4f989..b78a9508 100644
--- a/docs/src/about/contributing.md
+++ b/docs/src/about/contributing.md
@@ -62,6 +62,7 @@ By utilizing `hatch`, the following commands are available to manage the develop
| `hatch fmt --formatter` | Run only formatters |
| `hatch run javascript:check` | Run the JavaScript linter/formatter |
| `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk |
+| `hatch run python:type_check` | Run the Python type checker |
??? tip "Configure your IDE for linting"
diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md
index f969eb00..b7137f87 100644
--- a/docs/src/reference/template-tag.md
+++ b/docs/src/reference/template-tag.md
@@ -214,9 +214,17 @@ The entire file path provided is loaded directly into the browser, and must have
{% include "../../examples/python/pyodide_js_module.py" %}
```
- **PyScript FFI**
+ **PyScript Foreign Function Interface (FFI)**
- ...
+ PyScript FFI has similar functionality to Pyodide's `js` module, but utilizes a different API.
+
+ There are two importable modules available that are available within the FFI interface: `window` and `document`.
+
+ === "root.py"
+
+ ```python
+ {% include "../../examples/python/pyscript_ffi.py" %}
+ ```
**PyScript JS Modules**
diff --git a/pyproject.toml b/pyproject.toml
index c25929bf..57ee16ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,7 +75,7 @@ installer = "uv"
[[tool.hatch.build.hooks.build-scripts.scripts]]
commands = [
"bun install --cwd src/js",
- "bun build src/js/src/index.tsx --outfile src/reactpy_django/static/reactpy_django/client.js --minify",
+ "bun build src/js/src/index.ts --outfile src/reactpy_django/static/reactpy_django/client.js --minify",
'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"',
]
@@ -95,6 +95,8 @@ extra-dependencies = [
"tblib",
"servestatic",
"django-bootstrap5",
+ "decorator",
+
]
matrix-name-format = "{variable}-{value}"
@@ -185,6 +187,16 @@ linkcheck = [
deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"]
deploy_develop = ["cd docs && mike deploy --push develop"]
+################################
+# >>> Hatch Python Scripts <<< #
+################################
+
+[tool.hatch.envs.python]
+extra-dependencies = ["django-stubs", "channels-redis", "pyright"]
+
+[tool.hatch.envs.python.scripts]
+type_check = ["pyright src"]
+
############################
# >>> Hatch JS Scripts <<< #
############################
diff --git a/src/js/src/client.ts b/src/js/src/client.ts
index 93d2f00b..a856fb3a 100644
--- a/src/js/src/client.ts
+++ b/src/js/src/client.ts
@@ -30,14 +30,14 @@ export class ReactPyDjangoClient
this.prerenderElement.remove();
this.prerenderElement = null;
}
- if (this.offlineElement) {
+ if (this.offlineElement && this.mountElement) {
this.mountElement.hidden = true;
this.offlineElement.hidden = false;
}
},
onOpen: () => {
// If offlineElement exists, hide it and show the mountElement
- if (this.offlineElement) {
+ if (this.offlineElement && this.mountElement) {
this.offlineElement.hidden = true;
this.mountElement.hidden = false;
}
diff --git a/src/js/src/components.ts b/src/js/src/components.ts
new file mode 100644
index 00000000..e5c62f72
--- /dev/null
+++ b/src/js/src/components.ts
@@ -0,0 +1,64 @@
+import { DjangoFormProps } from "./types";
+import React from "react";
+import ReactDOM from "react-dom";
+/**
+ * Interface used to bind a ReactPy node to React.
+ */
+export function bind(node) {
+ return {
+ create: (type, props, children) =>
+ React.createElement(type, props, ...children),
+ render: (element) => {
+ ReactDOM.render(element, node);
+ },
+ unmount: () => ReactDOM.unmountComponentAtNode(node),
+ };
+}
+
+export function DjangoForm({
+ onSubmitCallback,
+ formId,
+}: DjangoFormProps): null {
+ React.useEffect(() => {
+ const form = document.getElementById(formId) as HTMLFormElement;
+
+ // Submission event function
+ const onSubmitEvent = (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;
+ }, {});
+
+ onSubmitCallback(formDataObject);
+ };
+
+ // Bind the event listener
+ if (form) {
+ form.addEventListener("submit", onSubmitEvent);
+ }
+
+ // Unbind the event listener when the component dismounts
+ return () => {
+ if (form) {
+ form.removeEventListener("submit", onSubmitEvent);
+ }
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/js/src/index.ts b/src/js/src/index.ts
new file mode 100644
index 00000000..1ffff551
--- /dev/null
+++ b/src/js/src/index.ts
@@ -0,0 +1,2 @@
+export { DjangoForm, bind } from "./components";
+export { mountComponent } from "./mount";
diff --git a/src/js/src/index.tsx b/src/js/src/mount.tsx
similarity index 60%
rename from src/js/src/index.tsx
rename to src/js/src/mount.tsx
index 742ca79f..81115f9e 100644
--- a/src/js/src/index.tsx
+++ b/src/js/src/mount.tsx
@@ -2,21 +2,6 @@ import { ReactPyDjangoClient } from "./client";
import React from "react";
import ReactDOM from "react-dom";
import { Layout } from "@reactpy/client/src/components";
-import { DjangoFormProps } from "./types";
-
-/**
- * Interface used to bind a ReactPy node to React.
- */
-export function bind(node) {
- return {
- create: (type, props, children) =>
- React.createElement(type, props, ...children),
- render: (element) => {
- ReactDOM.render(element, node);
- },
- unmount: () => ReactDOM.unmountComponentAtNode(node),
- };
-}
export function mountComponent(
mountElement: HTMLElement,
@@ -84,7 +69,7 @@ export function mountComponent(
// Replace the prerender element with the real element on the first layout update
if (client.prerenderElement) {
client.onMessage("layout-update", ({ path, model }) => {
- if (client.prerenderElement) {
+ if (client.prerenderElement && client.mountElement) {
client.prerenderElement.replaceWith(client.mountElement);
client.prerenderElement = null;
}
@@ -94,51 +79,3 @@ export function mountComponent(
// Start rendering the component
ReactDOM.render(, client.mountElement);
}
-
-export function DjangoForm({
- onSubmitCallback,
- formId,
-}: DjangoFormProps): null {
- React.useEffect(() => {
- const form = document.getElementById(formId) as HTMLFormElement;
-
- // Submission event function
- const onSubmitEvent = (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;
- }, {});
-
- onSubmitCallback(formDataObject);
- };
-
- // Bind the event listener
- if (form) {
- form.addEventListener("submit", onSubmitEvent);
- }
-
- // Unbind the event listener when the component dismounts
- return () => {
- if (form) {
- form.removeEventListener("submit", onSubmitEvent);
- }
- };
- }, []);
-
- return null;
-}
diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py
index 7e821c1c..f2ca561c 100644
--- a/src/reactpy_django/components.py
+++ b/src/reactpy_django/components.py
@@ -3,13 +3,9 @@
from __future__ import annotations
import json
-import os
from typing import TYPE_CHECKING, Any, Callable, Union, cast
from urllib.parse import urlencode
-from uuid import uuid4
-from django.contrib.staticfiles.finders import find
-from django.core.cache import caches
from django.http import HttpRequest
from django.urls import reverse
from reactpy import component, hooks, html, utils
@@ -17,14 +13,8 @@
from reactpy_django.exceptions import ViewNotRegisteredError
from reactpy_django.forms.components import _django_form
-from reactpy_django.html import pyscript
-from reactpy_django.utils import (
- generate_obj_name,
- import_module,
- render_pyscript_template,
- render_view,
- vdom_or_component_to_string,
-)
+from reactpy_django.pyscript.components import _pyscript_component
+from reactpy_django.utils import cached_static_file, generate_obj_name, import_module, render_view
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -32,14 +22,14 @@
from django.forms import Form, ModelForm
from django.views import View
- from reactpy_django.types import AsyncFormEvent, SyncFormEvent
+ from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor
def view_to_component(
view: Callable | View | str,
transforms: Sequence[Callable[[VdomDict], Any]] = (),
strict_parsing: bool = True,
-) -> Any:
+) -> ViewToComponentConstructor:
"""Converts a Django view to a ReactPy component.
Keyword Args:
@@ -58,7 +48,7 @@ def constructor(
*args,
key: Key | None = None,
**kwargs,
- ):
+ ) -> ComponentType:
return _view_to_component(
view=view,
transforms=transforms,
@@ -72,7 +62,7 @@ def constructor(
return constructor
-def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None):
+def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None) -> ViewToIframeConstructor:
"""
Args:
view: The view function or class to convert, or the dotted path to the view.
@@ -88,13 +78,13 @@ def constructor(
*args,
key: Key | None = None,
**kwargs,
- ):
+ ) -> ComponentType:
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):
+def django_css(static_path: str, key: Key | None = None) -> ComponentType:
"""Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading.
Args:
@@ -107,7 +97,7 @@ def django_css(static_path: str, key: Key | None = None):
return _django_css(static_path=static_path, key=key)
-def django_js(static_path: str, key: Key | None = None):
+def django_js(static_path: str, key: Key | None = None) -> ComponentType:
"""Fetches a JS static file for use within ReactPy. This allows for deferred JS loading.
Args:
@@ -135,7 +125,7 @@ def django_form(
top_children: Sequence[Any] = (),
bottom_children: Sequence[Any] = (),
key: Key | None = None,
-):
+) -> ComponentType:
"""Converts a Django form to a ReactPy component.
Args:
@@ -182,7 +172,7 @@ 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 \
@@ -210,7 +200,6 @@ def _view_to_component(
args: Sequence | None,
kwargs: dict | None,
):
- """The actual component. Used to prevent pollution of acceptable kwargs keys."""
converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None))
_args: Sequence = args or ()
_kwargs: dict = kwargs or {}
@@ -219,7 +208,7 @@ def _view_to_component(
else:
_request = HttpRequest()
_request.method = "GET"
- resolved_view: Callable = import_module(view) if isinstance(view, str) else view
+ resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore
# Render the view render within a hook
@hooks.use_effect(
@@ -228,7 +217,7 @@ def _view_to_component(
json.dumps([_args, _kwargs], default=generate_obj_name),
]
)
- async def async_render():
+ 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)
@@ -251,12 +240,11 @@ def _view_to_iframe(
extra_props: dict[str, Any] | None,
args: Sequence,
kwargs: dict,
-) -> VdomDict:
- """The actual component. Used to prevent pollution of acceptable kwargs keys."""
+):
from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS
if hasattr(view, "view_class"):
- view = view.view_class
+ view = view.view_class # type: ignore
dotted_path = view if isinstance(view, str) else generate_obj_name(view)
registered_view = REACTPY_REGISTERED_IFRAME_VIEWS.get(dotted_path)
@@ -286,60 +274,9 @@ def _view_to_iframe(
@component
def _django_css(static_path: str):
- return html.style(_cached_static_contents(static_path))
+ return html.style(cached_static_file(static_path))
@component
def _django_js(static_path: str):
- return html.script(_cached_static_contents(static_path))
-
-
-def _cached_static_contents(static_path: str) -> str:
- from reactpy_django.config import REACTPY_CACHE
-
- # Try to find the file within Django's static files
- abs_path = find(static_path)
- if not abs_path:
- msg = f"Could not find static file {static_path} within Django's static files."
- raise FileNotFoundError(msg)
- if isinstance(abs_path, (list, tuple)):
- abs_path = abs_path[0]
-
- # Fetch the file from cache, if available
- last_modified_time = os.stat(abs_path).st_mtime
- cache_key = f"reactpy_django:static_contents:{static_path}"
- file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time))
- if file_contents is None:
- with open(abs_path, encoding="utf-8") as static_file:
- file_contents = static_file.read()
- caches[REACTPY_CACHE].delete(cache_key)
- caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time))
-
- return file_contents
-
-
-@component
-def _pyscript_component(
- *file_paths: str,
- initial: str | VdomDict | ComponentType = "",
- root: str = "root",
-):
- rendered, set_rendered = hooks.use_state(False)
- uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
- uuid = uuid_ref.current
- initial = vdom_or_component_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),
- )
+ return html.script(cached_static_file(static_path))
diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py
index 804e10bb..6b3d220e 100644
--- a/src/reactpy_django/decorators.py
+++ b/src/reactpy_django/decorators.py
@@ -35,10 +35,10 @@ def _wrapper(*args, **kwargs):
return _wrapper
- return decorator
+ return decorator # type: ignore
-@component
+@component # type: ignore
def _user_passes_test(component_constructor, fallback, test_func, *args, **kwargs):
"""Dedicated component for `user_passes_test` to allow us to always have access to hooks."""
user = use_user()
diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py
index d19c0bbb..aa39cd0d 100644
--- a/src/reactpy_django/forms/components.py
+++ b/src/reactpy_django/forms/components.py
@@ -4,7 +4,6 @@
from typing import TYPE_CHECKING, Any, Callable, Union, cast
from uuid import uuid4
-from channels.db import database_sync_to_async
from django.forms import Form, ModelForm
from reactpy import component, hooks, html, utils
from reactpy.core.events import event
@@ -18,7 +17,7 @@
set_value_prop_on_select_element,
transform_value_prop_on_input_element,
)
-from reactpy_django.forms.utils import convert_boolean_fields, convert_multiple_choice_fields
+from reactpy_django.forms.utils import convert_form_fields, validate_form_args
from reactpy_django.types import AsyncFormEvent, FormEventData, SyncFormEvent
from reactpy_django.utils import ensure_async
@@ -57,18 +56,8 @@ def _django_form(
rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None))
uuid = uuid_ref.current
- # Validate the provided arguments
- if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current:
- msg = "Dynamically changing the number of top or bottom children is not allowed."
- raise ValueError(msg)
- if not isinstance(form, (type(Form), type(ModelForm))):
- msg = (
- "The provided form must be an uninitialized Django Form. "
- "Do NOT initialize your form by calling it (ex. `MyForm()`)."
- )
- raise TypeError(msg)
-
# Initialize the form with the provided data
+ validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form)
initialized_form = form(data=submitted_data)
form_event = FormEventData(
form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data
@@ -79,7 +68,7 @@ def _django_form(
async def render_form():
"""Forms must be rendered in an async loop to allow database fields to execute."""
if submitted_data:
- await database_sync_to_async(initialized_form.full_clean)()
+ 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)
@@ -96,8 +85,7 @@ async def render_form():
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."""
- convert_multiple_choice_fields(new_data, initialized_form)
- convert_boolean_fields(new_data, initialized_form)
+ convert_form_fields(new_data, initialized_form)
if on_receive_data:
new_form_event = FormEventData(
diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py
index 2d527209..1a757b77 100644
--- a/src/reactpy_django/forms/transforms.py
+++ b/src/reactpy_django/forms/transforms.py
@@ -1,3 +1,4 @@
+# type: ignore
# TODO: Almost everything in this module should be moved to `reactpy.utils._mutate_vdom()`.
from __future__ import annotations
@@ -73,14 +74,14 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict:
attributes = vdom_tree.get("attributes", {})
# Infer 'key' from 'id'
- _id = attributes.get("id")
+ key = attributes.get("id")
# Fallback: Infer 'key' from 'name'
- if not _id and vdom_tree["tagName"] in {"input", "select", "textarea"}:
- _id = attributes.get("name")
+ if not key and vdom_tree["tagName"] in {"input", "select", "textarea"}:
+ key = attributes.get("name")
- if _id:
- vdom_tree["key"] = _id
+ if key:
+ vdom_tree["key"] = key
return vdom_tree
@@ -130,8 +131,8 @@ def _do_nothing_event(*args, **kwargs):
"""A placeholder event function that does nothing."""
-# TODO: After the bulk of this file to ReactPy core, we should create some kind of script that will
-# auto-generate this into a file dump. The current implementation of manually copy-pasting it isn't ideal.
+# TODO: Create a script that will auto-generate this into a file dump.
+# The current implementation of manually copy-pasting it isn't ideal.
# https://react.dev/reference/react-dom/components/common#common-props
SPECIAL_PROPS = r"""
children: A React node (an element, a string, a number, a portal, an empty node like null, undefined and booleans, or an array of other React nodes). Specifies the content inside the component. When you use JSX, you will usually specify the children prop implicitly by nesting tags like
.
@@ -478,7 +479,8 @@ def _do_nothing_event(*args, **kwargs):
+ SCRIPT_PROPS
)
-# lowercase the prop name as the key, and have values be the original react prop name
+# Old Prop (Key) : New Prop (Value)
+# Also includes some special cases like 'class' -> 'className'
REACT_PROP_SUBSTITUTIONS = {prop.lower(): prop for prop in KNOWN_REACT_PROPS} | {
"for": "htmlFor",
"class": "className",
diff --git a/src/reactpy_django/forms/utils.py b/src/reactpy_django/forms/utils.py
index 59e43d14..6feef4b1 100644
--- a/src/reactpy_django/forms/utils.py
+++ b/src/reactpy_django/forms/utils.py
@@ -1,31 +1,40 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any
from django.forms import BooleanField, Form, ModelForm, ModelMultipleChoiceField, MultipleChoiceField, NullBooleanField
+if TYPE_CHECKING:
+ from collections.abc import Sequence
-def convert_multiple_choice_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None:
- multi_choice_fields = {
- field_name
- for field_name, field in initialized_form.fields.items()
- if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField))
- }
+ from reactpy import Ref
- # Convert multiple choice field text into a list of values
- for choice_field_name in multi_choice_fields:
- value = data.get(choice_field_name)
- if value is not None and not isinstance(value, list):
- data[choice_field_name] = [value]
+def convert_form_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None:
+ for field_name, field in initialized_form.fields.items():
+ value = data.get(field_name)
-def convert_boolean_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None:
- boolean_fields = {
- field_name
- for field_name, field in initialized_form.fields.items()
- if isinstance(field, BooleanField) and not isinstance(field, NullBooleanField)
- }
+ if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField)) and value is not None:
+ data[field_name] = value if isinstance(value, list) else [value]
- # Convert boolean field text into actual booleans
- for boolean_field_name in boolean_fields:
- data[boolean_field_name] = boolean_field_name in data
+ elif isinstance(field, BooleanField) and not isinstance(field, NullBooleanField):
+ data[field_name] = field_name in data
+
+
+def validate_form_args(
+ top_children: Sequence,
+ top_children_count: Ref[int],
+ bottom_children: Sequence,
+ bottom_children_count: Ref[int],
+ form: type[Form | ModelForm],
+) -> None:
+ # Validate the provided arguments
+ if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current:
+ msg = "Dynamically changing the number of top or bottom children is not allowed."
+ raise ValueError(msg)
+ if not isinstance(form, (type(Form), type(ModelForm))):
+ msg = (
+ "The provided form must be an uninitialized Django Form. "
+ "Do NOT initialize your form by calling it (ex. `MyForm()`)."
+ )
+ raise TypeError(msg)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index ba3642f6..c9d783af 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -3,6 +3,7 @@
import asyncio
import logging
from collections import defaultdict
+from collections.abc import Awaitable
from typing import (
TYPE_CHECKING,
Any,
@@ -14,7 +15,6 @@
import orjson
from channels import DEFAULT_CHANNEL_LAYER
-from channels.db import database_sync_to_async
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_connection as _use_connection
@@ -34,10 +34,10 @@
SyncPostprocessor,
UserData,
)
-from reactpy_django.utils import django_query_postprocessor, generate_obj_name, get_pk
+from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk
if TYPE_CHECKING:
- from collections.abc import Awaitable, Sequence
+ from collections.abc import Sequence
from channels_redis.core import RedisChannelLayer
from django.contrib.auth.models import AbstractUser
@@ -138,19 +138,17 @@ async def execute_query() -> None:
"""The main running function for `use_query`"""
try:
# Run the query
- if asyncio.iscoroutinefunction(query):
- new_data = await query(**kwargs)
- else:
- new_data = await database_sync_to_async(query, thread_sensitive=thread_sensitive)(**kwargs)
+ query_async = cast(
+ Callable[..., Awaitable[Inferred]], ensure_async(query, thread_sensitive=thread_sensitive)
+ )
+ new_data = await query_async(**kwargs)
# Run the postprocessor
if postprocessor:
- if asyncio.iscoroutinefunction(postprocessor):
- new_data = await postprocessor(new_data, **postprocessor_kwargs)
- else:
- new_data = await database_sync_to_async(postprocessor, thread_sensitive=thread_sensitive)(
- new_data, **postprocessor_kwargs
- )
+ async_postprocessor = cast(
+ 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:
@@ -237,15 +235,10 @@ def use_mutation(
async_task_refs = use_ref(set())
# The main "running" function for `use_mutation`
- async def execute_mutation(exec_args, exec_kwargs) -> None:
+ async def execute_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs) -> None:
# Run the mutation
try:
- if asyncio.iscoroutinefunction(mutation):
- should_refetch = await mutation(*exec_args, **exec_kwargs)
- else:
- should_refetch = await database_sync_to_async(mutation, thread_sensitive=thread_sensitive)(
- *exec_args, **exec_kwargs
- )
+ should_refetch = await ensure_async(mutation, thread_sensitive=thread_sensitive)(*exec_args, **exec_kwargs)
# Log any errors and set the error state
except Exception as e:
@@ -274,7 +267,7 @@ def schedule_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwa
set_loading(True)
# Execute the mutation in the background
- task = asyncio.ensure_future(execute_mutation(exec_args=exec_args, exec_kwargs=exec_kwargs))
+ task = asyncio.ensure_future(execute_mutation(*exec_args, **exec_kwargs))
# Add the task to a set to prevent it from being garbage collected
async_task_refs.current.add(task)
@@ -371,7 +364,7 @@ def use_channel_layer(
layer: The channel layer to use. This layer must be defined in \
`settings.py:CHANNEL_LAYERS`.
"""
- channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer)
+ channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer) # type: ignore
channel_name = use_memo(lambda: str(name or uuid4()))
if not name and not group_name:
@@ -444,10 +437,8 @@ async def _get_user_data(user: AbstractUser, default_data: None | dict, save_def
for key, value in default_data.items():
if key not in data:
new_value: Any = value
- if asyncio.iscoroutinefunction(value):
- new_value = await value()
- elif callable(value):
- new_value = value()
+ if callable(value):
+ new_value = await ensure_async(value)()
data[key] = new_value
changed = True
if changed:
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index 25315479..e0c5fc1d 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -8,7 +8,7 @@
from reactpy_django.utils import FileAsyncIterator, render_view
-def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
+def web_modules_file(request: HttpRequest, file: str) -> FileResponse:
"""Gets JavaScript required for ReactPy modules at runtime."""
web_modules_dir = REACTPY_WEB_MODULES_DIR.current
diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py
index 1b1fe9a2..acfd7976 100644
--- a/src/reactpy_django/management/commands/clean_reactpy.py
+++ b/src/reactpy_django/management/commands/clean_reactpy.py
@@ -10,7 +10,7 @@ class Command(BaseCommand):
help = "Manually clean ReactPy data. When using this command without args, it will perform all cleaning operations."
def handle(self, **options):
- from reactpy_django.clean import clean
+ from reactpy_django.tasks import clean
verbosity = options.get("verbosity", 1)
diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py
new file mode 100644
index 00000000..00db19e4
--- /dev/null
+++ b/src/reactpy_django/pyscript/components.py
@@ -0,0 +1,40 @@
+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_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
+ uuid = uuid_ref.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
index 77aa8c81..6a7a430b 100644
--- a/src/reactpy_django/pyscript/layout_handler.py
+++ b/src/reactpy_django/pyscript/layout_handler.py
@@ -1,3 +1,4 @@
+# type: ignore
import asyncio
import logging
diff --git a/src/reactpy_django/pyscript/utils.py b/src/reactpy_django/pyscript/utils.py
new file mode 100644
index 00000000..6484a5dc
--- /dev/null
+++ b/src/reactpy_django/pyscript/utils.py
@@ -0,0 +1,89 @@
+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/clean.py b/src/reactpy_django/tasks.py
similarity index 99%
rename from src/reactpy_django/clean.py
rename to src/reactpy_django/tasks.py
index 0a7e9017..facf3b82 100644
--- a/src/reactpy_django/clean.py
+++ b/src/reactpy_django/tasks.py
@@ -84,7 +84,7 @@ def clean_user_data(verbosity: int = 1):
start_time = timezone.now()
user_model = get_user_model()
all_users = user_model.objects.all()
- all_user_pks = all_users.values_list(user_model._meta.pk.name, flat=True)
+ all_user_pks = all_users.values_list(user_model._meta.pk.name, flat=True) # type: ignore
# Django doesn't support using QuerySets as an argument with cross-database relations.
if user_model.objects.db != UserDataModel.objects.db:
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index 70b7fa5e..029e7d8c 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -15,16 +15,14 @@
InvalidHostError,
OfflineComponentMissingError,
)
+from reactpy_django.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, render_pyscript_template
from reactpy_django.utils import (
- PYSCRIPT_LAYOUT_HANDLER,
- extend_pyscript_config,
prerender_component,
- render_pyscript_template,
+ reactpy_to_string,
save_component_params,
- strtobool,
+ str_to_bool,
validate_component_args,
validate_host,
- vdom_or_component_to_string,
)
if TYPE_CHECKING:
@@ -130,7 +128,7 @@ def component(
return failure_context(dotted_path, e)
# Pre-render the component, if requested
- if strtobool(prerender):
+ if str_to_bool(prerender):
if not is_local:
msg = "Cannot pre-render non-local components."
_logger.error(msg)
@@ -205,7 +203,7 @@ def pyscript_component(
uuid = uuid4().hex
request: HttpRequest | None = context.get("request")
- initial = vdom_or_component_to_string(initial, request=request, uuid=uuid)
+ initial = reactpy_to_string(initial, request=request, uuid=uuid)
executor = render_pyscript_template(file_paths, uuid, root)
return {
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index 53e6e9ad..83cabf5b 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 Connection
+from reactpy.types import ComponentType, Connection, Key
from typing_extensions import ParamSpec
if TYPE_CHECKING:
@@ -98,3 +98,13 @@ async def __call__(self, message: dict) -> None: ...
class AsyncMessageSender(Protocol):
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: ...
+
+
+class ViewToIframeConstructor(Protocol):
+ def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ...
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 72fa5439..1ea4f0ea 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -1,34 +1,30 @@
+"""Generic functions that are used throughout the ReactPy Django package."""
+
from __future__ import annotations
import contextlib
import inspect
-import json
import logging
import os
import re
-import textwrap
from asyncio import iscoroutinefunction
from concurrent.futures import ThreadPoolExecutor
-from copy import deepcopy
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
import dill
-import jsonpointer
-import orjson
-import reactpy
from asgiref.sync import async_to_sync
from channels.db import database_sync_to_async
+from django.contrib.staticfiles.finders import find
+from django.core.cache import caches
from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects
from django.db.models.base import Model
from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse
from django.template import engines
-from django.templatetags.static import static
from django.utils.encoding import smart_str
from reactpy import vdom_to_html
from reactpy.backend.types import Connection, Location
@@ -65,9 +61,6 @@
+ rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?"
+ r"\s*%}"
)
-PYSCRIPT_COMPONENT_TEMPLATE = (Path(__file__).parent / "pyscript" / "component_template.py").read_text(encoding="utf-8")
-PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "pyscript" / "layout_handler.py").read_text(encoding="utf-8")
-PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {}
FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator")
@@ -80,19 +73,14 @@ async def render_view(
"""Ingests a Django view (class or function) and returns an HTTP response object."""
# Convert class-based view to function-based view
if getattr(view, "as_view", None):
- view = view.as_view()
-
- # Async function view
- if iscoroutinefunction(view):
- response = await view(request, *args, **kwargs)
+ view = view.as_view() # type: ignore
- # Sync function view
- else:
- response = await database_sync_to_async(view)(request, *args, **kwargs)
+ # Sync/Async function view
+ response = await ensure_async(view)(request, *args, **kwargs) # type: ignore
- # TemplateView
+ # TemplateView needs an extra render step
if getattr(response, "render", None):
- response = await database_sync_to_async(response.render)()
+ response = await ensure_async(response.render)()
return response
@@ -127,7 +115,7 @@ def register_iframe(view: Callable | View | str):
from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS
if hasattr(view, "view_class"):
- view = view.view_class
+ view = view.view_class # type: ignore
dotted_path = view if isinstance(view, str) else generate_obj_name(view)
try:
REACTPY_REGISTERED_IFRAME_VIEWS[dotted_path] = import_dotted_path(dotted_path)
@@ -170,7 +158,7 @@ def get_loaders(self):
template_source_loaders = []
for e in engines.all():
if hasattr(e, "engine"):
- template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders))
+ template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) # type: ignore
loaders = []
for loader in template_source_loaders:
if hasattr(loader, "loaders"):
@@ -371,7 +359,7 @@ def __enter__(self):
def __exit__(self, *_):
async_to_sync(self.__aexit__)(*_)
- def render(self):
+ def sync_render(self):
return async_to_sync(super().render)()
@@ -380,7 +368,7 @@ def get_pk(model):
return getattr(model, model._meta.pk.name)
-def strtobool(val: str) -> bool:
+def str_to_bool(val: str) -> bool:
"""Convert a string representation of truth to true (1) or false (0).
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
@@ -418,18 +406,16 @@ def prerender_component(
),
)
) as layout:
- vdom_tree = layout.render()["model"]
+ vdom_tree = layout.sync_render()["model"]
- return vdom_to_html(vdom_tree)
+ return vdom_to_html(vdom_tree) # type: ignore
-def vdom_or_component_to_string(
- vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None
-) -> str:
+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)
+ return vdom_to_html(vdom_or_component) # type: ignore
if hasattr(vdom_or_component, "render"):
if not request:
@@ -446,72 +432,6 @@ def vdom_or_component_to_string(
raise ValueError(msg)
-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")
-
-
def save_component_params(args, kwargs, uuid) -> None:
"""Saves the component parameters to the database.
This is used within our template tag in order to propogate
@@ -541,17 +461,16 @@ def __init__(self, file_path: str):
self.file_path = file_path
async def __aiter__(self):
- file_opened = False
+ file_handle = None
try:
file_handle = FILE_ASYNC_ITERATOR_THREAD.submit(open, self.file_path, "rb").result()
- file_opened = True
while True:
chunk = FILE_ASYNC_ITERATOR_THREAD.submit(file_handle.read, 8192).result()
if not chunk:
break
yield chunk
finally:
- if file_opened:
+ if file_handle:
file_handle.close()
@@ -565,8 +484,32 @@ def ensure_async(
def wrapper(*args, **kwargs):
return (
func(*args, **kwargs)
- if inspect.iscoroutinefunction(func)
+ if iscoroutinefunction(func)
else database_sync_to_async(func, thread_sensitive=thread_sensitive)(*args, **kwargs)
)
return wrapper
+
+
+def cached_static_file(static_path: str) -> str:
+ from reactpy_django.config import REACTPY_CACHE
+
+ # Try to find the file within Django's static files
+ abs_path = find(static_path)
+ if not abs_path:
+ msg = f"Could not find static file {static_path} within Django's static files."
+ raise FileNotFoundError(msg)
+ if isinstance(abs_path, (list, tuple)):
+ abs_path = abs_path[0]
+
+ # Fetch the file from cache, if available
+ last_modified_time = os.stat(abs_path).st_mtime
+ cache_key = f"reactpy_django:static_contents:{static_path}"
+ file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time))
+ if file_contents is None:
+ with open(abs_path, encoding="utf-8") as static_file:
+ file_contents = static_file.read()
+ caches[REACTPY_CACHE].delete(cache_key)
+ caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, 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 d877679b..4e7f3578 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -14,15 +14,15 @@
import dill
import orjson
from channels.auth import login
-from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.utils import timezone
-from reactpy.backend.hooks import ConnectionContext
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_django.clean import clean
+from reactpy_django.tasks import clean
+from reactpy_django.utils import ensure_async
if TYPE_CHECKING:
from collections.abc import MutableMapping, Sequence
@@ -80,7 +80,7 @@ async def connect(self) -> None:
f"ReactPy websocket authentication has failed!\n{traceback.format_exc()}",
)
try:
- await database_sync_to_async(self.scope["session"].save)()
+ await ensure_async(self.scope["session"].save)()
except Exception:
await asyncio.to_thread(
_logger.error,
@@ -116,7 +116,7 @@ async def disconnect(self, code: int) -> None:
# Queue a cleanup, if needed
if REACTPY_CLEAN_INTERVAL is not None:
try:
- await database_sync_to_async(clean)()
+ await ensure_async(clean)()
except Exception:
await asyncio.to_thread(
_logger.error,
@@ -210,7 +210,7 @@ async def run_dispatcher(self):
# Start the ReactPy component rendering loop
with contextlib.suppress(Exception):
await serve_layout(
- Layout(ConnectionContext(root_component, value=connection)),
+ Layout(ConnectionContext(root_component, value=connection)), # type: ignore
self.send_json,
self.recv_queue.get,
)
diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py
index 258c58f2..7ed5d900 100644
--- a/src/reactpy_django/websocket/paths.py
+++ b/src/reactpy_django/websocket/paths.py
@@ -5,7 +5,7 @@
REACTPY_WEBSOCKET_ROUTE = path(
f"{REACTPY_URL_PREFIX}////",
- ReactpyAsyncWebsocketConsumer.as_asgi(),
+ ReactpyAsyncWebsocketConsumer.as_asgi(), # type: ignore
)
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py
index 06cebfe6..fa162b21 100644
--- a/tests/test_app/__init__.py
+++ b/tests/test_app/__init__.py
@@ -8,7 +8,7 @@
assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
assert (
subprocess.run(
- ["bun", "build", "./src/index.tsx", "--outfile", str(static_dir / "client.js")],
+ ["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js")],
cwd=str(js_dir),
check=True,
).returncode
diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py
index bc8f900b..9dbfeb8a 100644
--- a/tests/test_app/prerender/components.py
+++ b/tests/test_app/prerender/components.py
@@ -4,17 +4,20 @@
import reactpy_django
-SLEEP_TIME = 0.25
+from ..tests.utils import GITHUB_ACTIONS
+
+SLEEP_TIME = 1.5 if GITHUB_ACTIONS else 0.5
@component
def prerender_string():
scope = reactpy_django.hooks.use_scope()
- if scope.get("type") != "http":
- sleep(SLEEP_TIME)
+ if scope.get("type") == "http":
+ return "prerender_string: Prerendered"
- return "prerender_string: Fully Rendered" if scope.get("type") == "websocket" else "prerender_string: Prerendered"
+ sleep(SLEEP_TIME)
+ return "prerender_string: Fully Rendered"
@component
@@ -32,13 +35,13 @@ def prerender_component():
scope = reactpy_django.hooks.use_scope()
@component
- def inner(value):
+ def inner_component(value):
return html.div(value)
if scope.get("type") == "http":
- return inner("prerender_component: Prerendered")
+ return inner_component("prerender_component: Prerendered")
- return inner("prerender_component: Fully Rendered")
+ return inner_component("prerender_component: Fully Rendered")
@component
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py
index bd4507e1..115c904d 100644
--- a/tests/test_app/tests/test_components.py
+++ b/tests/test_app/tests/test_components.py
@@ -1,3 +1,4 @@
+# type: ignore
# ruff: noqa: RUF012, N802
import os
import socket
@@ -8,51 +9,60 @@
from playwright.sync_api import TimeoutError, expect
from reactpy_django.models import ComponentSession
-from reactpy_django.utils import strtobool
+from reactpy_django.utils import str_to_bool
from .utils import GITHUB_ACTIONS, PlaywrightTestCase, navigate_to_page
-CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds.
+CLICK_DELAY = 250 if str_to_bool(GITHUB_ACTIONS) else 25 # Delay in miliseconds.
-class GenericComponentTests(PlaywrightTestCase):
+class ComponentTests(PlaywrightTestCase):
databases = {"default"}
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.page.goto(f"http://{cls.host}:{cls._port}")
+ ###########################
+ # Generic Component Tests #
+ ###########################
- def test_hello_world(self):
+ @navigate_to_page("/")
+ def test_component_hello_world(self):
self.page.wait_for_selector("#hello-world")
- def test_counter(self):
+ @navigate_to_page("/")
+ 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()
- def test_parametrized_component(self):
+ @navigate_to_page("/")
+ def test_component_parametrized_component(self):
self.page.locator("#parametrized-component[data-value='579']").wait_for()
- def test_object_in_templatetag(self):
+ @navigate_to_page("/")
+ def test_component_object_in_templatetag(self):
self.page.locator("#object_in_templatetag[data-success=true]").wait_for()
+ @navigate_to_page("/")
def test_component_from_web_module(self):
self.page.wait_for_selector("#button-from-js-module")
- def test_use_connection(self):
+ @navigate_to_page("/")
+ def test_component_use_connection(self):
self.page.locator("#use-connection[data-success=true]").wait_for()
- def test_use_scope(self):
+ @navigate_to_page("/")
+ def test_component_use_scope(self):
self.page.locator("#use-scope[data-success=true]").wait_for()
- def test_use_location(self):
+ @navigate_to_page("/")
+ def test_component_use_location(self):
self.page.locator("#use-location[data-success=true]").wait_for()
- def test_use_origin(self):
+ @navigate_to_page("/")
+ def test_component_use_origin(self):
self.page.locator("#use-origin[data-success=true]").wait_for()
- def test_static_css(self):
+ @navigate_to_page("/")
+ def test_component_static_css(self):
assert (
self.page.wait_for_selector("#django-css button").evaluate(
"e => window.getComputedStyle(e).getPropertyValue('color')"
@@ -60,28 +70,34 @@ def test_static_css(self):
== "rgb(0, 0, 255)"
)
- def test_static_js(self):
+ @navigate_to_page("/")
+ def test_component_static_js(self):
self.page.locator("#django-js[data-success=true]").wait_for()
- def test_unauthorized_user(self):
+ @navigate_to_page("/")
+ def test_component_unauthorized_user(self):
with pytest.raises(TimeoutError):
self.page.wait_for_selector("#unauthorized-user", timeout=1)
self.page.wait_for_selector("#unauthorized-user-fallback")
- def test_authorized_user(self):
+ @navigate_to_page("/")
+ def test_component_authorized_user(self):
with pytest.raises(TimeoutError):
self.page.wait_for_selector("#authorized-user-fallback", timeout=1)
self.page.wait_for_selector("#authorized-user")
- def test_relational_query(self):
+ @navigate_to_page("/")
+ def test_component_relational_query(self):
self.page.locator("#relational-query").wait_for()
self.page.locator("#relational-query[data-success=true]").wait_for()
- def test_async_relational_query(self):
+ @navigate_to_page("/")
+ def test_component_async_relational_query(self):
self.page.locator("#async-relational-query").wait_for()
self.page.locator("#async-relational-query[data-success=true]").wait_for()
- def test_use_query_and_mutation(self):
+ @navigate_to_page("/")
+ def test_component_use_query_and_mutation(self):
todo_input = self.page.wait_for_selector("#todo-input")
item_ids = list(range(5))
@@ -94,7 +110,8 @@ def test_use_query_and_mutation(self):
with pytest.raises(TimeoutError):
self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1)
- def test_async_use_query_and_mutation(self):
+ @navigate_to_page("/")
+ def test_component_async_use_query_and_mutation(self):
todo_input = self.page.wait_for_selector("#async-todo-input")
item_ids = list(range(5))
@@ -107,68 +124,85 @@ def test_async_use_query_and_mutation(self):
with pytest.raises(TimeoutError):
self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1)
- def test_view_to_component_sync_func(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_sync_func(self):
self.page.locator("#view_to_component_sync_func[data-success=true]").wait_for()
- def test_view_to_component_async_func(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_async_func(self):
self.page.locator("#view_to_component_async_func[data-success=true]").wait_for()
- def test_view_to_component_sync_class(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_sync_class(self):
self.page.locator("#ViewToComponentSyncClass[data-success=true]").wait_for()
- def test_view_to_component_async_class(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_async_class(self):
self.page.locator("#ViewToComponentAsyncClass[data-success=true]").wait_for()
- def test_view_to_component_template_view_class(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_template_view_class(self):
self.page.locator("#ViewToComponentTemplateViewClass[data-success=true]").wait_for()
+ @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()
self.page.locator(f"#{name}[data-success=true]").wait_for()
- def test_view_to_component_script(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_script(self):
self._click_btn_and_check_success("view_to_component_script")
- def test_view_to_component_request(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_request(self):
self._click_btn_and_check_success("view_to_component_request")
- def test_view_to_component_args(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_args(self):
self._click_btn_and_check_success("view_to_component_args")
- def test_view_to_component_kwargs(self):
+ @navigate_to_page("/")
+ def test_component_view_to_component_kwargs(self):
self._click_btn_and_check_success("view_to_component_kwargs")
- def test_view_to_iframe_sync_func(self):
+ @navigate_to_page("/")
+ def test_component_view_to_iframe_sync_func(self):
self.page.frame_locator("#view_to_iframe_sync_func > iframe").locator(
"#view_to_iframe_sync_func[data-success=true]"
).wait_for()
- def test_view_to_iframe_async_func(self):
+ @navigate_to_page("/")
+ def test_component_view_to_iframe_async_func(self):
self.page.frame_locator("#view_to_iframe_async_func > iframe").locator(
"#view_to_iframe_async_func[data-success=true]"
).wait_for()
- def test_view_to_iframe_sync_class(self):
+ @navigate_to_page("/")
+ def test_component_view_to_iframe_sync_class(self):
self.page.frame_locator("#view_to_iframe_sync_class > iframe").locator(
"#ViewToIframeSyncClass[data-success=true]"
).wait_for()
- def test_view_to_iframe_async_class(self):
+ @navigate_to_page("/")
+ def test_component_view_to_iframe_async_class(self):
self.page.frame_locator("#view_to_iframe_async_class > iframe").locator(
"#ViewToIframeAsyncClass[data-success=true]"
).wait_for()
- def test_view_to_iframe_template_view_class(self):
+ @navigate_to_page("/")
+ def test_component_view_to_iframe_template_view_class(self):
self.page.frame_locator("#view_to_iframe_template_view_class > iframe").locator(
"#ViewToIframeTemplateViewClass[data-success=true]"
).wait_for()
- def test_view_to_iframe_args(self):
+ @navigate_to_page("/")
+ def test_component_view_to_iframe_args(self):
self.page.frame_locator("#view_to_iframe_args > iframe").locator(
"#view_to_iframe_args[data-success=Success]"
).wait_for()
+ @navigate_to_page("/")
def test_component_session_exists(self):
"""Session should exist for components with args/kwargs."""
component = self.page.locator("#parametrized-component")
@@ -181,6 +215,7 @@ def test_component_session_exists(self):
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")
assert query_exists
+ @navigate_to_page("/")
def test_component_session_missing(self):
"""No session should exist for components that don't have args/kwargs."""
component = self.page.locator("#button-from-js-module")
@@ -193,7 +228,8 @@ def test_component_session_missing(self):
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")
assert not query_exists
- def test_use_user_data(self):
+ @navigate_to_page("/")
+ def test_component_use_user_data(self):
text_input = self.page.wait_for_selector("#use-user-data input")
login_1 = self.page.wait_for_selector("#use-user-data .login-1")
login_2 = self.page.wait_for_selector("#use-user-data .login-2")
@@ -248,7 +284,8 @@ def test_use_user_data(self):
)
assert "Data: None" in user_data_div.text_content()
- def test_use_user_data_with_default(self):
+ @navigate_to_page("/")
+ def test_component_use_user_data_with_default(self):
text_input = self.page.wait_for_selector("#use-user-data-with-default input")
login_3 = self.page.wait_for_selector("#use-user-data-with-default .login-3")
clear = self.page.wait_for_selector("#use-user-data-with-default .clear")
@@ -283,13 +320,11 @@ def test_use_user_data_with_default(self):
)
assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content()
+ ###################
+ # Prerender Tests #
+ ###################
-class PrerenderTests(PlaywrightTestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.page.goto(f"http://{cls.host}:{cls._port}/prerender/")
-
+ @navigate_to_page("/prerender/")
def test_prerender(self):
"""Verify if round-robin host selection is working."""
string = self.page.locator("#prerender_string")
@@ -321,45 +356,50 @@ def test_prerender(self):
use_user_ws.wait_for()
assert use_root_id_ws.get_attribute("data-value") == root_id_value
+ ###############
+ # Error Tests #
+ ###############
-class ErrorTests(PlaywrightTestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.page.goto(f"http://{cls.host}:{cls._port}/errors/")
-
- def test_component_does_not_exist_error(self):
+ @navigate_to_page("/errors/")
+ def test_error_component_does_not_exist(self):
broken_component = self.page.locator("#component_does_not_exist_error")
broken_component.wait_for()
assert "ComponentDoesNotExistError:" in broken_component.text_content()
- def test_component_param_error(self):
+ @navigate_to_page("/errors/")
+ def test_error_component_param(self):
broken_component = self.page.locator("#component_param_error")
broken_component.wait_for()
assert "ComponentParamError:" in broken_component.text_content()
- def test_invalid_host_error(self):
+ @navigate_to_page("/errors/")
+ def test_error_invalid_host(self):
broken_component = self.page.locator("#invalid_host_error")
broken_component.wait_for()
assert "InvalidHostError:" in broken_component.text_content()
- def test_synchronous_only_operation_error(self):
+ @navigate_to_page("/errors/")
+ def test_error_synchronous_only_operation(self):
broken_component = self.page.locator("#broken_postprocessor_query pre")
broken_component.wait_for()
assert "SynchronousOnlyOperation:" in broken_component.text_content()
- def test_view_not_registered_error(self):
+ @navigate_to_page("/errors/")
+ def test_error_view_not_registered(self):
broken_component = self.page.locator("#view_to_iframe_not_registered pre")
broken_component.wait_for()
assert "ViewNotRegisteredError:" in broken_component.text_content()
- def test_decorator_param_error(self):
+ @navigate_to_page("/errors/")
+ def test_error_decorator_param(self):
broken_component = self.page.locator("#incorrect_user_passes_test_decorator")
broken_component.wait_for()
assert "DecoratorParamError:" in broken_component.text_content()
+ ####################
+ # URL Router Tests #
+ ####################
-class UrlRouterTests(PlaywrightTestCase):
def test_url_router(self):
self.page.goto(f"{self.live_server_url}/router/")
path = self.page.wait_for_selector("#router-path")
@@ -430,13 +470,11 @@ def test_url_router_int_and_string(self):
string = self.page.query_selector("#router-string")
assert string.text_content() == "/router/two///"
+ #######################
+ # Channel Layer Tests #
+ #######################
-class ChannelLayersTests(PlaywrightTestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.page.goto(f"http://{cls.host}:{cls._port}/channel-layers/")
-
+ @navigate_to_page("/channel-layers/")
def test_channel_layer_components(self):
sender = self.page.wait_for_selector("#sender")
sender.type("test", delay=CLICK_DELAY)
@@ -454,25 +492,27 @@ def test_channel_layer_components(self):
assert receiver_2 is not None
assert receiver_3 is not None
+ ##################
+ # PyScript Tests #
+ ##################
-class PyscriptTests(PlaywrightTestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.page.goto(f"http://{cls.host}:{cls._port}/pyscript/")
-
- def test_0_hello_world(self):
+ @navigate_to_page("/pyscript/")
+ def test_pyscript_0_hello_world(self):
+ # Use this test to wait for PyScript to fully load on the page
self.page.wait_for_selector("#hello-world-loading")
self.page.wait_for_selector("#hello-world")
- def test_1_custom_root(self):
+ @navigate_to_page("/pyscript/")
+ def test_pyscript_1_custom_root(self):
self.page.wait_for_selector("#custom-root")
- def test_1_multifile(self):
+ @navigate_to_page("/pyscript/")
+ def test_pyscript_1_multifile(self):
self.page.wait_for_selector("#multifile-parent")
self.page.wait_for_selector("#multifile-child")
- def test_1_counter(self):
+ @navigate_to_page("/pyscript/")
+ 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)
@@ -482,7 +522,8 @@ def test_1_counter(self):
self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY)
self.page.wait_for_selector("#counter pre[data-value='1']")
- def test_1_server_side_parent(self):
+ @navigate_to_page("/pyscript/")
+ 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']")
@@ -493,7 +534,8 @@ def test_1_server_side_parent(self):
self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY)
self.page.wait_for_selector("#child pre[data-value='1']")
- def test_1_server_side_parent_with_toggle(self):
+ @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")
@@ -505,28 +547,17 @@ def test_1_server_side_parent_with_toggle(self):
self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY)
self.page.wait_for_selector("#parent-toggle pre[data-value='1']")
- def test_1_javascript_module_execution_within_pyscript(self):
+ @navigate_to_page("/pyscript/")
+ def test_pyscript_1_javascript_module_execution_within_pyscript(self):
self.page.wait_for_selector("#moment[data-success=true]")
+ ###############################
+ # Distributed Computing Tests #
+ ###############################
-class DistributedComputingTests(PlaywrightTestCase):
- @classmethod
- def setUpServer(cls):
- super().setUpServer()
- cls._server_process2 = cls.ProtocolServerProcess(cls.host, cls.get_application)
- cls._server_process2.start()
- cls._server_process2.ready.wait()
- cls._port2 = cls._server_process2.port.value
-
- @classmethod
- def tearDownServer(cls):
- super().tearDownServer()
- cls._server_process2.terminate()
- cls._server_process2.join()
-
- def test_host_roundrobin(self):
+ def test_distributed_host_roundrobin(self):
"""Verify if round-robin host selection is working."""
- self.page.goto(f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8")
+ self.page.goto(f"{self.live_server_url}/roundrobin/{self._port_2}/{self._port_3}/8")
elem0 = self.page.locator(".custom_host-0")
elem1 = self.page.locator(".custom_host-1")
elem2 = self.page.locator(".custom_host-2")
@@ -544,50 +575,49 @@ def test_host_roundrobin(self):
elem3.get_attribute("data-port"),
}
correct_ports = {
- str(self._port),
- str(self._port2),
+ str(self._port_2),
+ str(self._port_3),
}
# There should only be two ports in the set
assert current_ports == correct_ports
assert len(current_ports) == 2
- def test_custom_host(self):
+ def test_distributed_custom_host(self):
"""Make sure that the component is rendered by a separate server."""
- self.page.goto(f"{self.live_server_url}/port/{self._port2}/")
+ self.page.goto(f"{self.live_server_url}/port/{self._port_2}/")
elem = self.page.locator(".custom_host-0")
elem.wait_for()
- assert f"Server Port: {self._port2}" in elem.text_content()
+ assert f"Server Port: {self._port_2}" in elem.text_content()
- def test_custom_host_wrong_port(self):
+ def test_distributed_custom_host_wrong_port(self):
"""Make sure that other ports are not rendering components."""
tmp_sock = socket.socket()
- tmp_sock.bind((self._server_process.host, 0))
+ 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):
self.page.locator(".custom_host").wait_for(timeout=1000)
+ #################
+ # Offline Tests #
+ #################
-class OfflineTests(PlaywrightTestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.page.goto(f"http://{cls.host}:{cls._port}/offline/")
-
- def test_offline_components(self):
+ @navigate_to_page("/offline/", server_num=1)
+ def test_offline_component(self):
self.page.wait_for_selector("div:not([hidden]) > #online")
assert self.page.query_selector("div[hidden] > #offline") is not None
- self._server_process.terminate()
- self._server_process.join()
+ self._server_process_1.terminate()
+ self._server_process_1.join()
self.page.wait_for_selector("div:not([hidden]) > #offline")
assert self.page.query_selector("div[hidden] > #online") is not None
+ ##############
+ # Form Tests #
+ ##############
-class FormTests(PlaywrightTestCase):
- def test_basic_form(self):
- navigate_to_page(self, "/form/")
-
+ @navigate_to_page("/form/")
+ def test_form_basic(self):
try:
from test_app.models import TodoItem
@@ -682,9 +712,8 @@ def test_basic_form(self):
# Make sure no errors remain
assert len(self.page.query_selector_all(".errorlist")) == 0
- def test_bootstrap_form(self):
- navigate_to_page(self, "/form/bootstrap/")
-
+ @navigate_to_page("/form/bootstrap/")
+ def test_form_bootstrap(self):
try:
from test_app.models import TodoItem
@@ -780,9 +809,8 @@ def test_bootstrap_form(self):
# Make sure no errors remain
assert len(self.page.query_selector_all(".invalid-feedback")) == 0
- def test_model_form(self):
- navigate_to_page(self, "/form/model/")
-
+ @navigate_to_page("/form/model/")
+ def test_form_orm_model(self):
uuid = uuid4().hex
self.page.wait_for_selector("form")
@@ -791,7 +819,8 @@ def test_model_form(self):
self.page.wait_for_selector(".errorlist")
# Submitting an empty form should result in 1 error element.
- assert len(self.page.query_selector_all(".errorlist")) == 1
+ error_list = self.page.locator(".errorlist").all()
+ assert len(error_list) == 1
# Fill out the form
self.page.locator("#id_text").type(uuid, delay=CLICK_DELAY)
@@ -800,7 +829,7 @@ def test_model_form(self):
self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY)
# Wait for the error message to disappear (indicating that the form has been re-rendered)
- expect(self.page.locator(".errorlist").all()[0]).not_to_be_attached()
+ expect(error_list[0]).not_to_be_attached()
# Make sure no errors remain
assert len(self.page.query_selector_all(".errorlist")) == 0
@@ -815,8 +844,11 @@ def test_model_form(self):
finally:
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")
- def test_sync_form_events(self):
- navigate_to_page(self, "/form/sync_event/")
+ # TODO: Remove the `reruns` value once we fix flakiness of `test_sync_form_events`
+ # https://github.com/reactive-python/reactpy-django/issues/272
+ @pytest.mark.flaky(reruns=5)
+ @navigate_to_page("/form/sync_event/")
+ def test_form_sync_events(self):
self.page.wait_for_selector("form")
# Check initial state
@@ -845,8 +877,8 @@ def test_sync_form_events(self):
self.page.wait_for_selector("#receive_data[data-value='true']")
self.page.wait_for_selector("#change[data-value='true']")
- def test_async_form_events(self):
- navigate_to_page(self, "/form/async_event/")
+ @navigate_to_page("/form/async_event/")
+ def test_form_async_events(self):
self.page.wait_for_selector("form")
# Check initial state
diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py
index 5d613ad5..78856a89 100644
--- a/tests/test_app/tests/test_database.py
+++ b/tests/test_app/tests/test_database.py
@@ -6,7 +6,7 @@
import dill
from django.test import TransactionTestCase
-from reactpy_django import clean
+from reactpy_django import tasks
from reactpy_django.models import ComponentSession, UserDataModel
from reactpy_django.types import ComponentParams
@@ -29,7 +29,7 @@ def test_component_params(self):
config.REACTPY_CLEAN_USER_DATA = False
try:
- clean.clean(immediate=True)
+ tasks.clean(immediate=True)
# Make sure the ComponentParams table is empty
assert ComponentSession.objects.count() == 0
@@ -48,7 +48,7 @@ def test_component_params(self):
# Try to delete the `params_1` via cleaning (it should be expired)
# Note: We don't use `immediate` here in order to test timestamping logic
- clean.clean()
+ tasks.clean()
# Make sure `params_1` has expired, but `params_2` is still there
assert ComponentSession.objects.count() == 1
@@ -98,7 +98,7 @@ def test_user_data_cleanup(self):
# Make sure the orphaned user data object is deleted
assert UserDataModel.objects.count() == initial_count + 1
- clean.clean_user_data()
+ tasks.clean_user_data()
assert UserDataModel.objects.count() == initial_count
# Check if deleting a user deletes the associated UserData
diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py
index c96cc23a..158f7e85 100644
--- a/tests/test_app/tests/utils.py
+++ b/tests/test_app/tests/utils.py
@@ -2,71 +2,128 @@
import asyncio
import os
import sys
+from collections.abc import Iterable
from functools import partial
+from typing import TYPE_CHECKING, Any, Callable
+import decorator
+from channels.routing import get_default_application
from channels.testing import ChannelsLiveServerTestCase
-from channels.testing.live import make_application
from django.core.exceptions import ImproperlyConfigured
from django.core.management import call_command
from django.db import connections
from django.test.utils import modify_settings
from playwright.sync_api import sync_playwright
-from reactpy_django.utils import strtobool
+from reactpy_django.utils import str_to_bool
+
+if TYPE_CHECKING:
+ from daphne.testing import DaphneProcess
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False")
class PlaywrightTestCase(ChannelsLiveServerTestCase):
- from reactpy_django import config
-
databases = {"default"}
-
+ total_servers = 4
+ _server_process_0: "DaphneProcess"
+ _server_process_1: "DaphneProcess" # For Offline Tests
+ _server_process_2: "DaphneProcess" # For Distributed Computing Tests
+ _server_process_3: "DaphneProcess" # For Distributed Computing Tests
+ _port_0: int
+ _port_1: int
+ _port_2: int
+ _port_3: int
+
+ ####################################################
+ # Overrides for ChannelsLiveServerTestCase methods #
+ ####################################################
@classmethod
def setUpClass(cls):
# Repurposed from ChannelsLiveServerTestCase._pre_setup
for connection in connections.all():
- if cls._is_in_memory_db(cls, connection):
+ if connection.vendor == "sqlite" and connection.is_in_memory_db():
msg = "ChannelLiveServerTestCase can not be used with in memory databases"
raise ImproperlyConfigured(msg)
cls._live_server_modified_settings = modify_settings(ALLOWED_HOSTS={"append": cls.host})
cls._live_server_modified_settings.enable()
- cls.get_application = partial(
- make_application,
- static_wrapper=cls.static_wrapper if cls.serve_static else None,
- )
- cls.setUpServer()
+ cls.get_application = partial(get_default_application)
+
+ # Start the Django webserver(s)
+ for i in range(cls.total_servers):
+ cls.start_django_webserver(i)
+
+ # Wipe the databases
+ from reactpy_django import config
+
+ cls.flush_databases({"default", config.REACTPY_DATABASE})
# Open a Playwright browser window
+ cls.start_playwright_client()
+
+ @classmethod
+ def tearDownClass(cls):
+ # Close the Playwright browser
+ cls.shutdown_playwright_client()
+
+ # Shutdown the Django webserver
+ for i in range(cls.total_servers):
+ cls.shutdown_django_webserver(i)
+ cls._live_server_modified_settings.disable()
+
+ # Wipe the databases
+ from reactpy_django import config
+
+ cls.flush_databases({"default", config.REACTPY_DATABASE})
+
+ def _pre_setup(self):
+ """Handled manually in `setUpClass` to speed things up."""
+
+ def _post_teardown(self):
+ """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing
+ database flushing in between tests. This also fixes a `SynchronousOnlyOperation` caused
+ by a bug within `ChannelsLiveServerTestCase`."""
+
+ @property
+ def live_server_url(self):
+ """Provides the URL to the FIRST SPAWNED Django webserver."""
+ return f"http://{self.host}:{self._port_0}"
+
+ #########################
+ # Custom helper methods #
+ #########################
+ @classmethod
+ def start_django_webserver(cls, num=0):
+ setattr(cls, f"_server_process_{num}", cls.ProtocolServerProcess(cls.host, cls.get_application))
+ server_process: DaphneProcess = getattr(cls, f"_server_process_{num}")
+ server_process.start()
+ server_process.ready.wait()
+ setattr(cls, f"_port_{num}", server_process.port.value)
+
+ @classmethod
+ def shutdown_django_webserver(cls, num=0):
+ server_process: DaphneProcess = getattr(cls, f"_server_process_{num}")
+ server_process.terminate()
+ server_process.join()
+
+ @classmethod
+ def start_playwright_client(cls):
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
cls.playwright = sync_playwright().start()
- headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS))
+ headless = str_to_bool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS))
cls.browser = cls.playwright.chromium.launch(headless=bool(headless))
cls.page = cls.browser.new_page()
cls.page.set_default_timeout(10000)
@classmethod
- def setUpServer(cls):
- cls._server_process = cls.ProtocolServerProcess(cls.host, cls.get_application)
- cls._server_process.start()
- cls._server_process.ready.wait()
- cls._port = cls._server_process.port.value
-
- @classmethod
- def tearDownClass(cls):
- from reactpy_django import config
-
- # Close the Playwright browser
+ def shutdown_playwright_client(cls):
+ cls.browser.close()
cls.playwright.stop()
- # Close the other server processes
- cls.tearDownServer()
-
- # Repurposed from ChannelsLiveServerTestCase._post_teardown
- cls._live_server_modified_settings.disable()
- # Using set to prevent duplicates
- for db_name in {"default", config.REACTPY_DATABASE}: # noqa: PLC0208
+ @staticmethod
+ def flush_databases(db_names: Iterable[Any]):
+ for db_name in db_names:
call_command(
"flush",
verbosity=0,
@@ -75,21 +132,19 @@ def tearDownClass(cls):
reset_sequences=False,
)
- @classmethod
- def tearDownServer(cls):
- cls._server_process.terminate()
- cls._server_process.join()
- def _pre_setup(self):
- """Handled manually in `setUpClass` to speed things up."""
+def navigate_to_page(path: str, *, server_num=0):
+ """Decorator to make sure the browser is on a specific page before running a test."""
- def _post_teardown(self):
- """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing
- database flushing. This is needed to prevent a `SynchronousOnlyOperation` from
- occurring due to a bug within `ChannelsLiveServerTestCase`."""
+ 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)
+ return func(self, *args, **kwargs)
+ return _wrapper(func)
-def navigate_to_page(self: PlaywrightTestCase, path: str):
- """Redirect the page's URL to the given link, if the page is not already there."""
- if self.page.url != path:
- self.page.goto(f"http://{self.host}:{self._port}/{path.lstrip('/')}")
+ return _decorator