diff --git a/.gitignore b/.gitignore
index b80e2910..a59a51e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,8 @@ logs
*.pyc
.dccachea
__pycache__
-db.sqlite3
+*.sqlite3
+*.sqlite3-journal
media
cache
static-deploy
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c8cfd43..f651748f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,9 +34,22 @@ Using the following categories, list your changes in this order:
## [Unreleased]
-- Nothing (yet)!
+### Added
+
+- `use_query` now supports async functions.
+- `use_mutation` now supports async functions.
+- `reactpy_django.types.QueryOptions.thread_sensitive` option to customize how sync queries are executed.
+- `reactpy_django.hooks.use_mutation` now accepts `reactpy_django.types.MutationOptions` option to customize how mutations are executed.
+
+### Changed
+
+- The `mutate` argument on `reactpy_django.hooks.use_mutation` has been renamed to `mutation`.
+
+### Fixed
+
+- Fix bug where ReactPy utilizes Django's default cache timeout, which can prematurely expire the component cache.
-## [3.0.1] - 2023-03-31
+## [3.0.1] - 2023-04-06
### Changed
diff --git a/README.md b/README.md
index 15228f75..90ec4754 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@
Django,
- Jupyter,
+ Jupyter,
Plotly-Dash
|
diff --git a/docs/python/use-query-async.py b/docs/python/use-query-async.py
new file mode 100644
index 00000000..3b532fc5
--- /dev/null
+++ b/docs/python/use-query-async.py
@@ -0,0 +1,23 @@
+from channels.db import database_sync_to_async
+from example.models import TodoItem
+from reactpy import component, html
+
+from reactpy_django.hooks import use_query
+
+
+async def get_items():
+ return await database_sync_to_async(TodoItem.objects.all)()
+
+
+@component
+def todo_list():
+ item_query = use_query(get_items)
+
+ if item_query.loading:
+ rendered_items = html.h2("Loading...")
+ elif item_query.error or not item_query.data:
+ rendered_items = html.h2("Error when loading!")
+ else:
+ rendered_items = html.ul([html.li(item, key=item) for item in item_query.data])
+
+ return html.div("Rendered items: ", rendered_items)
diff --git a/docs/python/use-query-postprocessor-change.py b/docs/python/use-query-postprocessor-change.py
index 4112ef5d..0c3f552a 100644
--- a/docs/python/use-query-postprocessor-change.py
+++ b/docs/python/use-query-postprocessor-change.py
@@ -17,7 +17,7 @@ def execute_io_intensive_operation():
@component
-def todo_list():
+def my_component():
query = use_query(
QueryOptions(
postprocessor=my_postprocessor,
diff --git a/docs/python/use-query-postprocessor-disable.py b/docs/python/use-query-postprocessor-disable.py
index 33e5dc6d..722366f8 100644
--- a/docs/python/use-query-postprocessor-disable.py
+++ b/docs/python/use-query-postprocessor-disable.py
@@ -10,7 +10,7 @@ def execute_io_intensive_operation():
@component
-def todo_list():
+def my_component():
query = use_query(
QueryOptions(postprocessor=None),
execute_io_intensive_operation,
diff --git a/docs/python/use-query-postprocessor-kwargs.py b/docs/python/use-query-postprocessor-kwargs.py
index 831799bf..39f0f965 100644
--- a/docs/python/use-query-postprocessor-kwargs.py
+++ b/docs/python/use-query-postprocessor-kwargs.py
@@ -16,7 +16,7 @@ def get_model_with_relationships():
@component
-def todo_list():
+def my_component():
query = use_query(
QueryOptions(
postprocessor_kwargs={"many_to_many": False, "many_to_one": False}
diff --git a/docs/python/use-query-thread-sensitive.py b/docs/python/use-query-thread-sensitive.py
new file mode 100644
index 00000000..ab806bf4
--- /dev/null
+++ b/docs/python/use-query-thread-sensitive.py
@@ -0,0 +1,22 @@
+from reactpy import component
+
+from reactpy_django.hooks import use_query
+from reactpy_django.types import QueryOptions
+
+
+def execute_thread_safe_operation():
+ """This is an example query function that does some thread-safe operation."""
+ pass
+
+
+@component
+def my_component():
+ query = use_query(
+ QueryOptions(thread_sensitive=False),
+ execute_thread_safe_operation,
+ )
+
+ if query.loading or query.error:
+ return None
+
+ return str(query.data)
diff --git a/docs/python/use-query.py b/docs/python/use-query.py
index 6056773d..9a15c525 100644
--- a/docs/python/use-query.py
+++ b/docs/python/use-query.py
@@ -17,6 +17,6 @@ def todo_list():
elif item_query.error or not item_query.data:
rendered_items = html.h2("Error when loading!")
else:
- rendered_items = html.ul(html.li(item, key=item) for item in item_query.data)
+ rendered_items = html.ul([html.li(item, key=item) for item in item_query.data])
return html.div("Rendered items: ", rendered_items)
diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt
index d29d8a64..d4f5b4b8 100644
--- a/docs/src/dictionary.txt
+++ b/docs/src/dictionary.txt
@@ -30,6 +30,7 @@ postprocessing
serializable
postprocessor
preprocessor
+middleware
backends
backend
frontend
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index 7ab8f377..dd53ac61 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -57,13 +57,29 @@ The function you provide into this hook must return either a `Model` or `QuerySe
{% include "../../python/use-query-args.py" %}
```
-??? question "Why does the example `get_items` function return `TodoItem.objects.all()`?"
+??? question "Why does `get_items` in the example return `TodoItem.objects.all()`?"
This was a technical design decision to based on [Apollo's `useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions.
The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components.
-??? question "Can this hook be used for things other than the Django ORM?"
+??? question "How can I use `QueryOptions` to customize fetching behavior?"
+
+ **`thread_sensitive`**
+
+ Whether to run your synchronous query function in thread-sensitive mode. Thread-sensitive mode is turned on by default due to Django ORM limitations. See Django's [`sync_to_async` docs](https://docs.djangoproject.com/en/dev/topics/async/#sync-to-async) docs for more information.
+
+ This setting only applies to sync query functions, and will be ignored for async functions.
+
+ === "components.py"
+
+ ```python
+ {% include "../../python/use-query-thread-sensitive.py" %}
+ ```
+
+ ---
+
+ **`postprocessor`**
{% include-markdown "../../includes/orm.md" start="" end="" %}
@@ -72,7 +88,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
1. Want to use this hook to defer IO intensive tasks to be computed in the background
2. Want to to utilize `use_query` with a different ORM
- ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None` to disable postprocessing behavior.
+ ... then you can either set a custom `postprocessor`, or disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None` to disable postprocessing behavior.
=== "components.py"
@@ -92,7 +108,9 @@ The function you provide into this hook must return either a `Model` or `QuerySe
{% include "../../python/use-query-postprocessor-change.py" %}
```
-??? question "How can I prevent this hook from recursively fetching `ManyToMany` fields or `ForeignKey` relationships?"
+ ---
+
+ **`postprocessor_kwargs`**
{% include-markdown "../../includes/orm.md" start="" end="" %}
@@ -108,6 +126,18 @@ The function you provide into this hook must return either a `Model` or `QuerySe
_Note: In Django's ORM design, the field name to access foreign keys is [postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/) by default._
+??? question "Can I define async query functions?"
+
+ Async functions are supported by `use_query`. You can use them in the same way as a sync query function.
+
+ However, be mindful of Django async ORM restrictions.
+
+ === "components.py"
+
+ ```python
+ {% include "../../python/use-query-async.py" %}
+ ```
+
??? question "Can I make ORM calls without hooks?"
{% include-markdown "../../includes/orm.md" start="" end="" %}
diff --git a/docs/src/get-started/render-view.md b/docs/src/get-started/register-view.md
similarity index 88%
rename from docs/src/get-started/render-view.md
rename to docs/src/get-started/register-view.md
index be3fd746..1c97d089 100644
--- a/docs/src/get-started/render-view.md
+++ b/docs/src/get-started/register-view.md
@@ -6,7 +6,7 @@
---
-## Render Your View
+## Register a View
We will assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but here's a simple example below.
@@ -28,8 +28,6 @@ We will add this new view into your [`urls.py`](https://docs.djangoproject.com/e
{% include "../../python/example/urls.py" %}
```
-Now, navigate to `http://127.0.0.1:8000/example/`. If you copy-pasted the component from the previous example, you will now see your component display "Hello World".
-
??? question "Which urls.py do I add my views to?"
For simple **Django projects**, you can easily add all of your views directly into the **Django project's** `urls.py`. However, as you start increase your project's complexity you might end up with way too much within one file.
diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md
new file mode 100644
index 00000000..1c532558
--- /dev/null
+++ b/docs/src/get-started/run-webserver.md
@@ -0,0 +1,23 @@
+## Overview
+
+!!! summary
+
+ Run a webserver to display your Django view.
+
+---
+
+## Run the Webserver
+
+To test your new Django view, run the following command to start up a development webserver.
+
+```bash linenums="0"
+python manage.py runserver
+```
+
+Now you can navigate to your **Django project** URL that contains an ReactPy component, such as `http://127.0.0.1:8000/example/` (_from the previous step_).
+
+If you copy-pasted our example component, you will now see your component display "Hello World".
+
+??? warning "Do not use `manage.py runserver` for production."
+
+ The webserver contained within `manage.py runserver` is only intended for development and testing purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/).
diff --git a/mkdocs.yml b/mkdocs.yml
index a0addc8f..bca3df26 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -6,7 +6,8 @@ nav:
- Choose a Django App: get-started/choose-django-app.md
- Create a Component: get-started/create-component.md
- Use the Template Tag: get-started/use-template-tag.md
- - Render Your View: get-started/render-view.md
+ - Register a View: get-started/register-view.md
+ - Run the Webserver: get-started/run-webserver.md
- Learn More: get-started/learn-more.md
- Reference:
- Components: features/components.md
diff --git a/noxfile.py b/noxfile.py
index 2aa03270..36497d08 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -56,7 +56,7 @@ def test_suite(session: Session) -> None:
posargs.append("--debug-mode")
session.run("playwright", "install", "chromium")
- session.run("python", "manage.py", "test", *posargs)
+ session.run("python", "manage.py", "test", *posargs, "-v 2")
@nox.session
diff --git a/requirements/test-env.txt b/requirements/test-env.txt
index 5cad346d..f7552f1d 100644
--- a/requirements/test-env.txt
+++ b/requirements/test-env.txt
@@ -2,3 +2,4 @@ django
playwright
twisted
channels[daphne]>=4.0.0
+tblib
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index c5c88d5e..33950b1e 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -6,7 +6,11 @@
from reactpy.config import REACTPY_DEBUG_MODE
from reactpy.core.types import ComponentConstructor
-from reactpy_django.types import Postprocessor, ViewComponentIframe
+from reactpy_django.types import (
+ AsyncPostprocessor,
+ SyncPostprocessor,
+ ViewComponentIframe,
+)
from reactpy_django.utils import import_dotted_path
@@ -37,10 +41,12 @@
"REACTPY_DATABASE",
DEFAULT_DB_ALIAS,
)
-REACTPY_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = import_dotted_path(
- getattr(
- settings,
- "REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
- "reactpy_django.utils.django_query_postprocessor",
+REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None = (
+ import_dotted_path(
+ getattr(
+ settings,
+ "REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
+ "reactpy_django.utils.django_query_postprocessor",
+ )
)
)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 4bb3dc68..00e22bd9 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -13,7 +13,7 @@
overload,
)
-from channels.db import database_sync_to_async as _database_sync_to_async
+from channels.db import database_sync_to_async
from reactpy import use_callback, use_ref
from reactpy.backend.hooks import use_connection as _use_connection
from reactpy.backend.hooks import use_location as _use_location
@@ -24,6 +24,7 @@
from reactpy_django.types import (
Connection,
Mutation,
+ MutationOptions,
Query,
QueryOptions,
_Params,
@@ -33,10 +34,6 @@
_logger = logging.getLogger(__name__)
-database_sync_to_async = cast(
- Callable[..., Callable[..., Awaitable[Any]]],
- _database_sync_to_async,
-)
_REFETCH_CALLBACKS: DefaultDict[
Callable[..., Any], set[Callable[[], None]]
] = DefaultDict(set)
@@ -82,8 +79,9 @@ def use_connection() -> Connection:
@overload
def use_query(
options: QueryOptions,
- query: Callable[_Params, _Result | None],
/,
+ query: Callable[_Params, _Result | None]
+ | Callable[_Params, Awaitable[_Result | None]],
*args: _Params.args,
**kwargs: _Params.kwargs,
) -> Query[_Result | None]:
@@ -92,8 +90,8 @@ def use_query(
@overload
def use_query(
- query: Callable[_Params, _Result | None],
- /,
+ query: Callable[_Params, _Result | None]
+ | Callable[_Params, Awaitable[_Result | None]],
*args: _Params.args,
**kwargs: _Params.kwargs,
) -> Query[_Result | None]:
@@ -114,31 +112,81 @@ def use_query(
Keyword Args:
**kwargs: Keyword arguments to pass into `query`."""
+ should_execute, set_should_execute = use_state(True)
+ data, set_data = use_state(cast(Union[_Result, None], None))
+ loading, set_loading = use_state(True)
+ error, set_error = use_state(cast(Union[Exception, None], None))
if isinstance(args[0], QueryOptions):
- query_options = args[0]
- query = args[1]
- args = args[2:]
-
+ query_options, query, query_args, query_kwargs = _use_query_args_1(
+ *args, **kwargs
+ )
else:
- query_options = QueryOptions()
- query = args[0]
- args = args[1:]
-
+ query_options, query, query_args, query_kwargs = _use_query_args_2(
+ *args, **kwargs
+ )
query_ref = use_ref(query)
if query_ref.current is not query:
raise ValueError(f"Query function changed from {query_ref.current} to {query}.")
- should_execute, set_should_execute = use_state(True)
- data, set_data = use_state(cast(Union[_Result, None], None))
- loading, set_loading = use_state(True)
- error, set_error = use_state(cast(Union[Exception, None], None))
+ # The main "running" function for `use_query`
+ async def execute_query() -> None:
+ try:
+ # Run the query
+ if asyncio.iscoroutinefunction(query):
+ new_data = await query(*query_args, **query_kwargs)
+ else:
+ new_data = await database_sync_to_async(
+ query,
+ thread_sensitive=query_options.thread_sensitive,
+ )(*query_args, **query_kwargs)
+
+ # Run the postprocessor
+ if query_options.postprocessor:
+ if asyncio.iscoroutinefunction(query_options.postprocessor):
+ new_data = await query_options.postprocessor(
+ new_data, **query_options.postprocessor_kwargs
+ )
+ else:
+ new_data = await database_sync_to_async(
+ query_options.postprocessor,
+ thread_sensitive=query_options.thread_sensitive,
+ )(new_data, **query_options.postprocessor_kwargs)
+
+ # Log any errors and set the error state
+ except Exception as e:
+ set_data(None)
+ set_loading(False)
+ set_error(e)
+ _logger.exception(
+ f"Failed to execute query: {generate_obj_name(query) or query}"
+ )
+ return
+
+ # Query was successful
+ else:
+ set_data(new_data)
+ set_loading(False)
+ set_error(None)
+
+ # Schedule the query to be run when needed
+ @use_effect(dependencies=None)
+ def schedule_query() -> None:
+ # 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
+ asyncio.create_task(execute_query())
+ # Used when the user has told us to refetch this query
@use_callback
def refetch() -> None:
set_should_execute(True)
set_loading(True)
set_error(None)
+ # Track the refetch callback so we can re-execute the query
@use_effect(dependencies=[])
def add_refetch_callback() -> Callable[[], None]:
# By tracking callbacks globally, any usage of the query function will be re-run
@@ -146,43 +194,30 @@ def add_refetch_callback() -> Callable[[], None]:
_REFETCH_CALLBACKS[query].add(refetch)
return lambda: _REFETCH_CALLBACKS[query].remove(refetch)
- @use_effect(dependencies=None)
- @database_sync_to_async
- def execute_query() -> None:
- if not should_execute:
- return
-
- try:
- # Run the initial query
- new_data = query(*args, **kwargs)
-
- if query_options.postprocessor:
- new_data = query_options.postprocessor(
- new_data, **query_options.postprocessor_kwargs
- )
-
- except Exception as e:
- set_data(None)
- set_loading(False)
- set_error(e)
- _logger.exception(
- f"Failed to execute query: {generate_obj_name(query) or query}"
- )
- return
- finally:
- set_should_execute(False)
+ # The query's user API
+ return Query(data, loading, error, refetch)
- set_data(new_data)
- set_loading(False)
- set_error(None)
- return Query(data, loading, error, refetch)
+@overload
+def use_mutation(
+ options: MutationOptions,
+ mutation: Callable[_Params, bool | None]
+ | Callable[_Params, Awaitable[bool | None]],
+ refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None,
+) -> Mutation[_Params]:
+ ...
+@overload
def use_mutation(
- mutate: Callable[_Params, bool | None],
+ mutation: Callable[_Params, bool | None]
+ | Callable[_Params, Awaitable[bool | None]],
refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None,
) -> Mutation[_Params]:
+ ...
+
+
+def use_mutation(*args: Any, **kwargs: Any) -> Mutation[_Params]:
"""Hook to create, update, or delete Django ORM objects.
Args:
@@ -196,37 +231,99 @@ def use_mutation(
loading, set_loading = use_state(False)
error, set_error = use_state(cast(Union[Exception, None], None))
+ if isinstance(args[0], MutationOptions):
+ mutation_options, mutation, refetch = _use_mutation_args_1(*args, **kwargs)
+ else:
+ mutation_options, mutation, refetch = _use_mutation_args_2(*args, **kwargs)
- @use_callback
- def call(*args: _Params.args, **kwargs: _Params.kwargs) -> None:
- set_loading(True)
-
- @database_sync_to_async
- def execute_mutation() -> None:
- try:
- should_refetch = mutate(*args, **kwargs)
- except Exception as e:
- set_loading(False)
- set_error(e)
- _logger.exception(
- f"Failed to execute mutation: {generate_obj_name(mutate) or mutate}"
- )
+ # The main "running" function for `use_mutation`
+ async def execute_mutation(exec_args, exec_kwargs) -> None:
+ # Run the mutation
+ try:
+ if asyncio.iscoroutinefunction(mutation):
+ should_refetch = await mutation(*exec_args, **exec_kwargs)
else:
- set_loading(False)
- set_error(None)
+ should_refetch = await database_sync_to_async(
+ mutation, thread_sensitive=mutation_options.thread_sensitive
+ )(*exec_args, **exec_kwargs)
- # `refetch` will execute unless explicitly told not to
- # or if `refetch` was not defined.
- if should_refetch is not False and refetch:
- for query in (refetch,) if callable(refetch) else refetch:
- for callback in _REFETCH_CALLBACKS.get(query) or ():
- callback()
+ # Log any errors and set the error state
+ except Exception as e:
+ set_loading(False)
+ set_error(e)
+ _logger.exception(
+ f"Failed to execute mutation: {generate_obj_name(mutation) or mutation}"
+ )
- asyncio.ensure_future(execute_mutation())
+ # Mutation was successful
+ else:
+ set_loading(False)
+ set_error(None)
+
+ # `refetch` will execute unless explicitly told not to
+ # or if `refetch` was not defined.
+ if should_refetch is not False and refetch:
+ for query in (refetch,) if callable(refetch) else refetch:
+ for callback in _REFETCH_CALLBACKS.get(query) or ():
+ callback()
+
+ # Schedule the mutation to be run when needed
+ @use_callback
+ def schedule_mutation(
+ *exec_args: _Params.args, **exec_kwargs: _Params.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.
+ set_loading(True)
+
+ # Execute the mutation in the background
+ asyncio.ensure_future(
+ execute_mutation(exec_args=exec_args, exec_kwargs=exec_kwargs)
+ )
+ # Used when the user has told us to reset this mutation
@use_callback
def reset() -> None:
set_loading(False)
set_error(None)
- return Mutation(call, loading, error, reset)
+ # The mutation's user API
+ return Mutation(schedule_mutation, loading, error, reset)
+
+
+def _use_query_args_1(
+ options: QueryOptions,
+ /,
+ query: Callable[_Params, _Result | None]
+ | Callable[_Params, Awaitable[_Result | None]],
+ *args: _Params.args,
+ **kwargs: _Params.kwargs,
+):
+ return options, query, args, kwargs
+
+
+def _use_query_args_2(
+ query: Callable[_Params, _Result | None]
+ | Callable[_Params, Awaitable[_Result | None]],
+ *args: _Params.args,
+ **kwargs: _Params.kwargs,
+):
+ return QueryOptions(), query, args, kwargs
+
+
+def _use_mutation_args_1(
+ options: MutationOptions,
+ mutation: Callable[_Params, bool | None]
+ | Callable[_Params, Awaitable[bool | None]],
+ refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None,
+):
+ return options, mutation, refetch
+
+
+def _use_mutation_args_2(
+ mutation: Callable[_Params, bool | None]
+ | Callable[_Params, Awaitable[bool | None]],
+ refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None,
+):
+ return MutationOptions(), mutation, refetch
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 91cb6660..79d22072 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -2,7 +2,7 @@
class ComponentSession(models.Model):
- """A model for storing component parameters.
+ """A model for storing component sessions.
All queries must be routed through `reactpy_django.config.REACTPY_DATABASE`.
"""
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index 52c905ed..b508d0e9 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -5,7 +5,11 @@
from django.urls import reverse
from reactpy_django import models
-from reactpy_django.config import REACTPY_RECONNECT_MAX, REACTPY_WEBSOCKET_URL
+from reactpy_django.config import (
+ REACTPY_DATABASE,
+ REACTPY_RECONNECT_MAX,
+ REACTPY_WEBSOCKET_URL,
+)
from reactpy_django.types import ComponentParamData
from reactpy_django.utils import _register_component, func_has_params
@@ -47,7 +51,7 @@ def component(dotted_path: str, *args, **kwargs):
params = ComponentParamData(args, kwargs)
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
model.full_clean()
- model.save()
+ model.save(using=REACTPY_DATABASE)
except TypeError as e:
raise TypeError(
f"The provided parameters are incompatible with component '{dotted_path}'."
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index e0c60f2b..b02b5161 100644
--- a/src/reactpy_django/types.py
+++ b/src/reactpy_django/types.py
@@ -30,8 +30,10 @@
"Mutation",
"Connection",
"ViewComponentIframe",
- "Postprocessor",
+ "AsyncPostprocessor",
+ "SyncPostprocessor",
"QueryOptions",
+ "MutationOptions",
"ComponentParamData",
]
@@ -79,7 +81,12 @@ class ViewComponentIframe:
kwargs: dict
-class Postprocessor(Protocol):
+class AsyncPostprocessor(Protocol):
+ async def __call__(self, data: Any) -> Any:
+ ...
+
+
+class SyncPostprocessor(Protocol):
def __call__(self, data: Any) -> Any:
...
@@ -90,7 +97,9 @@ class QueryOptions:
from reactpy_django.config import REACTPY_DEFAULT_QUERY_POSTPROCESSOR
- postprocessor: Postprocessor | None = REACTPY_DEFAULT_QUERY_POSTPROCESSOR
+ postprocessor: AsyncPostprocessor | SyncPostprocessor | None = (
+ REACTPY_DEFAULT_QUERY_POSTPROCESSOR
+ )
"""A callable that can modify the query `data` after the query has been executed.
The first argument of postprocessor must be the query `data`. All proceeding arguments
@@ -106,6 +115,17 @@ class QueryOptions:
postprocessor_kwargs: MutableMapping[str, Any] = field(default_factory=lambda: {})
"""Keyworded arguments directly passed into the `postprocessor` for configuration."""
+ thread_sensitive: bool = True
+ """Whether to run the query in thread-sensitive mode. This setting only applies to sync query functions."""
+
+
+@dataclass
+class MutationOptions:
+ """Configuration options that can be provided to `use_mutation`."""
+
+ thread_sensitive: bool = True
+ """Whether to run the mutation in thread-sensitive mode. This setting only applies to sync mutation functions."""
+
@dataclass
class ComponentParamData:
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 33e34038..86425552 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -13,9 +13,8 @@
from channels.db import database_sync_to_async
from django.core.cache import caches
-from django.db.models import ManyToManyField, prefetch_related_objects
+from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects
from django.db.models.base import Model
-from django.db.models.fields.reverse_related import ManyToOneRel
from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse
from django.template import engines
@@ -65,16 +64,20 @@ async def render_view(
elif getattr(view, "as_view", None):
# MyPy does not know how to properly interpret this as a `View` type
# And `isinstance(view, View)` does not work due to some weird Django internal shenanigans
- async_cbv = database_sync_to_async(view.as_view()) # type: ignore
+ async_cbv = database_sync_to_async(view.as_view(), thread_sensitive=False) # type: ignore
view_or_template_view = await async_cbv(request, *args, **kwargs)
if getattr(view_or_template_view, "render", None): # TemplateView
- response = await database_sync_to_async(view_or_template_view.render)()
+ response = await database_sync_to_async(
+ view_or_template_view.render, thread_sensitive=False
+ )()
else: # View
response = view_or_template_view
# Render Check 4: Sync function view
else:
- response = await database_sync_to_async(view)(request, *args, **kwargs)
+ response = await database_sync_to_async(view, thread_sensitive=False)(
+ request, *args, **kwargs
+ )
return response
@@ -250,10 +253,8 @@ def django_query_postprocessor(
elif isinstance(data, Model):
prefetch_fields: list[str] = []
for field in data._meta.get_fields():
- # `ForeignKey` relationships will cause an `AttributeError`
- # This is handled within the `ManyToOneRel` conditional below.
- with contextlib.suppress(AttributeError):
- getattr(data, field.name)
+ # Force the query to execute
+ getattr(data, field.name, None)
if many_to_one and type(field) == ManyToOneRel:
prefetch_fields.append(field.related_name or f"{field.name}_set")
@@ -310,7 +311,7 @@ def create_cache_key(*args):
def db_cleanup(immediate: bool = False):
- """Deletes expired component parameters from the database.
+ """Deletes expired component sessions from the database.
This function may be expanded in the future to include additional cleanup tasks."""
from .config import REACTPY_CACHE, REACTPY_DATABASE, REACTPY_RECONNECT_MAX
from .models import ComponentSession
@@ -338,4 +339,4 @@ def db_cleanup(immediate: bool = False):
ComponentSession.objects.using(REACTPY_DATABASE).filter(
last_accessed__lte=expires_by
).delete()
- caches[REACTPY_CACHE].set(cache_key, now_str)
+ caches[REACTPY_CACHE].set(cache_key, now_str, timeout=None)
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 9bdabf84..3679f00a 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -8,7 +8,7 @@
import dill as pickle
from channels.auth import login
-from channels.db import database_sync_to_async as convert_to_async
+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
@@ -35,7 +35,7 @@ async def connect(self) -> None:
if user and user.is_authenticated:
try:
await login(self.scope, user)
- await convert_to_async(self.scope["session"].save)()
+ await database_sync_to_async(self.scope["session"].save)()
except Exception:
_logger.exception("ReactPy websocket authentication has failed!")
elif user is None:
@@ -94,7 +94,7 @@ async def _run_dispatch_loop(self):
if func_has_params(component_constructor):
try:
# Always clean up expired entries first
- await convert_to_async(db_cleanup)()
+ await database_sync_to_async(db_cleanup, thread_sensitive=False)()
# Get the queries from a DB
params_query = await models.ComponentSession.objects.using(
@@ -105,7 +105,9 @@ async def _run_dispatch_loop(self):
- timedelta(seconds=REACTPY_RECONNECT_MAX),
)
params_query.last_accessed = timezone.now()
- await convert_to_async(params_query.save)()
+ await database_sync_to_async(
+ params_query.save, thread_sensitive=False
+ )()
except models.ComponentSession.DoesNotExist:
_logger.warning(
f"Browser has attempted to access '{dotted_path}', "
diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py
index 2a512168..99f40e46 100644
--- a/tests/test_app/admin.py
+++ b/tests/test_app/admin.py
@@ -1,5 +1,14 @@
from django.contrib import admin
-from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem
+from test_app.models import (
+ AsyncForiegnChild,
+ AsyncRelationalChild,
+ AsyncRelationalParent,
+ AsyncTodoItem,
+ ForiegnChild,
+ RelationalChild,
+ RelationalParent,
+ TodoItem,
+)
from reactpy_django.models import ComponentSession
@@ -24,6 +33,26 @@ class ForiegnChildAdmin(admin.ModelAdmin):
pass
+@admin.register(AsyncTodoItem)
+class AsyncTodoItemAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(AsyncRelationalChild)
+class AsyncRelationalChildAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(AsyncRelationalParent)
+class AsyncRelationalParentAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(AsyncForiegnChild)
+class AsyncForiegnChildAdmin(admin.ModelAdmin):
+ pass
+
+
@admin.register(ComponentSession)
-class ComponentParamsAdmin(admin.ModelAdmin):
+class ComponentSessionAdmin(admin.ModelAdmin):
list_display = ("uuid", "last_accessed")
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 9e1b633e..6dddefaf 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -1,10 +1,21 @@
+import asyncio
import inspect
from pathlib import Path
+from channels.db import database_sync_to_async
from django.http import HttpRequest
from django.shortcuts import render
from reactpy import component, hooks, html, web
-from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem
+from test_app.models import (
+ AsyncForiegnChild,
+ AsyncRelationalChild,
+ AsyncRelationalParent,
+ AsyncTodoItem,
+ ForiegnChild,
+ RelationalChild,
+ RelationalParent,
+ TodoItem,
+)
import reactpy_django
from reactpy_django.components import view_to_component
@@ -201,10 +212,9 @@ def get_relational_parent_query():
def get_foriegn_child_query():
child = ForiegnChild.objects.first()
if not child:
- parent = RelationalParent.objects.first()
- if not parent:
- parent = get_relational_parent_query()
- child = ForiegnChild.objects.create(parent=parent, text="Foriegn Child")
+ child = ForiegnChild.objects.create(
+ parent=get_relational_parent_query(), text="Foriegn Child"
+ )
child.save()
return child
@@ -227,6 +237,69 @@ def relational_query():
"id": "relational-query",
"data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk),
},
+ html.p(inspect.currentframe().f_code.co_name),
+ html.div(f"Relational Parent Many To Many: {mtm}"),
+ html.div(f"Relational Parent One To One: {oto}"),
+ html.div(f"Relational Parent Many to One: {mto}"),
+ html.div(f"Relational Child Foreign Key: {fk}"),
+ html.hr(),
+ )
+
+
+async def async_get_or_create_relational_parent():
+ parent = await AsyncRelationalParent.objects.afirst()
+ if parent:
+ return parent
+
+ child_1 = await AsyncRelationalChild.objects.acreate(text="ManyToMany Child 1")
+ child_2 = await AsyncRelationalChild.objects.acreate(text="ManyToMany Child 2")
+ child_3 = await AsyncRelationalChild.objects.acreate(text="ManyToMany Child 3")
+ child_4 = await AsyncRelationalChild.objects.acreate(text="OneToOne Child")
+ parent = await AsyncRelationalParent.objects.acreate(one_to_one=child_4)
+ await parent.many_to_many.aset((child_1, child_2, child_3))
+ await parent.asave()
+ return parent
+
+
+async def async_get_relational_parent_query():
+ # Sleep to avoid race conditions in the test
+ # Also serves as a good way of testing whether things are truly async
+ await asyncio.sleep(2)
+ return await async_get_or_create_relational_parent()
+
+
+async def async_get_foriegn_child_query():
+ child = await AsyncForiegnChild.objects.afirst()
+ if not child:
+ parent = await async_get_or_create_relational_parent()
+ child = await AsyncForiegnChild.objects.acreate(
+ parent=parent, text="Foriegn Child"
+ )
+ await child.asave()
+ return child
+
+
+@component
+def async_relational_query():
+ foriegn_child = reactpy_django.hooks.use_query(async_get_foriegn_child_query)
+ relational_parent = reactpy_django.hooks.use_query(
+ async_get_relational_parent_query
+ )
+
+ if not relational_parent.data or not foriegn_child.data:
+ return
+
+ mtm = relational_parent.data.many_to_many.all()
+ oto = relational_parent.data.one_to_one
+ mto = relational_parent.data.many_to_one.all()
+ fk = foriegn_child.data.parent
+
+ return html.div(
+ {
+ "id": "async-relational-query",
+ "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk),
+ },
+ html.p(inspect.currentframe().f_code.co_name),
html.div(f"Relational Parent Many To Many: {mtm}"),
html.div(f"Relational Parent One To One: {oto}"),
html.div(f"Relational Parent Many to One: {mto}"),
@@ -256,11 +329,42 @@ def toggle_todo_mutation(item: TodoItem):
item.save()
+def _render_todo_items(items, toggle_item):
+ return html.ul(
+ [
+ html.li(
+ {"id": f"todo-item-{item.text}", "key": item.text},
+ item.text,
+ html.input(
+ {
+ "id": f"todo-item-{item.text}-checkbox",
+ "type": "checkbox",
+ "checked": item.done,
+ "on_change": lambda event, i=item: toggle_item.execute(i),
+ }
+ ),
+ )
+ for item in items
+ ]
+ )
+
+
@component
def todo_list():
input_value, set_input_value = hooks.use_state("")
items = reactpy_django.hooks.use_query(get_todo_query)
toggle_item = reactpy_django.hooks.use_mutation(toggle_todo_mutation)
+ add_item = reactpy_django.hooks.use_mutation(
+ add_todo_mutation, refetch=get_todo_query
+ )
+
+ def on_submit(event):
+ if event["key"] == "Enter":
+ add_item.execute(text=event["target"]["value"])
+ set_input_value("")
+
+ def on_change(event):
+ set_input_value(event["target"]["value"])
if items.error:
rendered_items = html.h2(f"Error when loading - {items.error}")
@@ -274,10 +378,6 @@ def todo_list():
_render_todo_items([i for i in items.data if i.done], toggle_item),
)
- add_item = reactpy_django.hooks.use_mutation(
- add_todo_mutation, refetch=get_todo_query
- )
-
if add_item.loading:
mutation_status = html.h2("Working...")
elif add_item.error:
@@ -285,20 +385,90 @@ def todo_list():
else:
mutation_status = "" # type: ignore
- def on_submit(event):
+ return html.div(
+ {"id": "todo-list"},
+ html.p(inspect.currentframe().f_code.co_name), # type: ignore
+ html.label("Add an item:"),
+ html.input(
+ {
+ "type": "text",
+ "id": "todo-input",
+ "value": input_value,
+ "on_key_press": on_submit,
+ "on_change": on_change,
+ }
+ ),
+ mutation_status,
+ rendered_items,
+ html.hr(),
+ )
+
+
+async def async_get_todo_query():
+ return await database_sync_to_async(AsyncTodoItem.objects.all)()
+
+
+async def async_add_todo_mutation(text: str):
+ existing = await AsyncTodoItem.objects.filter(text=text).afirst()
+ if existing:
+ if existing.done:
+ existing.done = False
+ await existing.asave()
+ else:
+ return False
+ else:
+ await AsyncTodoItem(text=text, done=False).asave()
+
+
+async def async_toggle_todo_mutation(item: AsyncTodoItem):
+ item.done = not item.done
+ await item.asave()
+
+
+@component
+def async_todo_list():
+ input_value, set_input_value = hooks.use_state("")
+ items = reactpy_django.hooks.use_query(async_get_todo_query)
+ toggle_item = reactpy_django.hooks.use_mutation(async_toggle_todo_mutation)
+ add_item = reactpy_django.hooks.use_mutation(
+ async_add_todo_mutation, refetch=async_get_todo_query
+ )
+
+ async def on_submit(event):
if event["key"] == "Enter":
add_item.execute(text=event["target"]["value"])
set_input_value("")
- def on_change(event):
+ async def on_change(event):
set_input_value(event["target"]["value"])
+ if items.error:
+ rendered_items = html.h2(f"Error when loading - {items.error}")
+ elif items.data is None:
+ rendered_items = html.h2("Loading...")
+ else:
+ 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"),
+ _render_todo_items([i for i in items.data if i.done], toggle_item),
+ )
+
+ if add_item.loading:
+ mutation_status = html.h2("Working...")
+ elif add_item.error:
+ mutation_status = html.h2(f"Error when adding - {add_item.error}")
+ else:
+ mutation_status = "" # type: ignore
+
return html.div(
+ {"id": "async-todo-list"},
+ html.p(inspect.currentframe().f_code.co_name), # type: ignore
html.label("Add an item:"),
html.input(
{
"type": "text",
- "id": "todo-input",
+ "id": "async-todo-input",
"value": input_value,
"on_key_press": on_submit,
"on_change": on_change,
@@ -310,26 +480,6 @@ def on_change(event):
)
-def _render_todo_items(items, toggle_item):
- return html.ul(
- [
- html.li(
- {"id": f"todo-item-{item.text}", "key": item.text},
- item.text,
- html.input(
- {
- "id": f"todo-item-{item.text}-checkbox",
- "type": "checkbox",
- "checked": item.done,
- "on_change": lambda event, i=item: toggle_item.execute(i),
- }
- ),
- )
- for item in items
- ]
- )
-
-
view_to_component_sync_func = view_to_component(views.view_to_component_sync_func)
view_to_component_async_func = view_to_component(views.view_to_component_async_func)
view_to_component_sync_class = view_to_component(views.ViewToComponentSyncClass)
diff --git a/tests/test_app/migrations/0006_asynctodoitem.py b/tests/test_app/migrations/0006_asynctodoitem.py
new file mode 100644
index 00000000..7c6f8e83
--- /dev/null
+++ b/tests/test_app/migrations/0006_asynctodoitem.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.1.7 on 2023-03-15 01:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("test_app", "0005_alter_foriegnchild_parent"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AsyncTodoItem",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("done", models.BooleanField()),
+ ("text", models.CharField(max_length=1000)),
+ ],
+ ),
+ ]
diff --git a/tests/test_app/migrations/0007_asyncrelationalchild_asyncrelationalparent_and_more.py b/tests/test_app/migrations/0007_asyncrelationalchild_asyncrelationalparent_and_more.py
new file mode 100644
index 00000000..348f4586
--- /dev/null
+++ b/tests/test_app/migrations/0007_asyncrelationalchild_asyncrelationalparent_and_more.py
@@ -0,0 +1,81 @@
+# Generated by Django 4.1.7 on 2023-03-16 07:24
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("test_app", "0006_asynctodoitem"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AsyncRelationalChild",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("text", models.CharField(max_length=1000)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="AsyncRelationalParent",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("done", models.BooleanField(default=True)),
+ (
+ "many_to_many",
+ models.ManyToManyField(
+ related_name="many_to_many", to="test_app.asyncrelationalchild"
+ ),
+ ),
+ (
+ "one_to_one",
+ models.OneToOneField(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="one_to_one",
+ to="test_app.asyncrelationalchild",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="AsyncForiegnChild",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("text", models.CharField(max_length=1000)),
+ (
+ "parent",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="many_to_one",
+ to="test_app.asyncrelationalparent",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/tests/test_app/models.py b/tests/test_app/models.py
index ba62938b..4196864a 100644
--- a/tests/test_app/models.py
+++ b/tests/test_app/models.py
@@ -6,10 +6,19 @@ class TodoItem(models.Model):
text = models.CharField(max_length=1000) # type: ignore
+class AsyncTodoItem(models.Model):
+ done = models.BooleanField() # type: ignore
+ text = models.CharField(max_length=1000) # type: ignore
+
+
class RelationalChild(models.Model):
text = models.CharField(max_length=1000) # type: ignore
+class AsyncRelationalChild(models.Model):
+ text = models.CharField(max_length=1000) # type: ignore
+
+
class RelationalParent(models.Model):
done = models.BooleanField(default=True) # type: ignore
many_to_many = models.ManyToManyField(RelationalChild, related_name="many_to_many") # type: ignore
@@ -18,6 +27,22 @@ class RelationalParent(models.Model):
)
+class AsyncRelationalParent(models.Model):
+ done = models.BooleanField(default=True) # type: ignore
+ many_to_many = models.ManyToManyField(AsyncRelationalChild, related_name="many_to_many") # type: ignore
+ one_to_one = models.OneToOneField( # type: ignore
+ AsyncRelationalChild,
+ related_name="one_to_one",
+ on_delete=models.SET_NULL,
+ null=True,
+ )
+
+
class ForiegnChild(models.Model):
text = models.CharField(max_length=1000) # type: ignore
parent = models.ForeignKey(RelationalParent, related_name="many_to_one", on_delete=models.CASCADE) # type: ignore
+
+
+class AsyncForiegnChild(models.Model):
+ text = models.CharField(max_length=1000) # type: ignore
+ parent = models.ForeignKey(AsyncRelationalParent, related_name="many_to_one", on_delete=models.CASCADE) # type: ignore
diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py
index a83ca2b6..aa2c5196 100644
--- a/tests/test_app/settings.py
+++ b/tests/test_app/settings.py
@@ -25,7 +25,7 @@
SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c"
# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+DEBUG = "test" not in sys.argv
ALLOWED_HOSTS = ["*"]
# Application definition
@@ -71,14 +71,38 @@
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
+# WARNING: There are overrides in `test_components.py` that require no in-memory
+# databases are used for testing. Make sure all SQLite databases are on disk.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
- "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
- "TEST": {"NAME": os.path.join(BASE_DIR, "db_test.sqlite3")},
- "OPTIONS": {"timeout": 5},
+ # Changing NAME is needed due to a bug related to `manage.py test` migrations
+ "NAME": os.path.join(BASE_DIR, "test_db.sqlite3")
+ if "test" in sys.argv
+ else os.path.join(BASE_DIR, "db.sqlite3"),
+ "TEST": {
+ "NAME": os.path.join(BASE_DIR, "test_db.sqlite3"),
+ "OPTIONS": {"timeout": 20},
+ "DEPENDENCIES": [],
+ },
+ "OPTIONS": {"timeout": 20},
},
}
+if "test" in sys.argv:
+ DATABASES["reactpy"] = {
+ "ENGINE": "django.db.backends.sqlite3",
+ # Changing NAME is needed due to a bug related to `manage.py test` migrations
+ "NAME": os.path.join(BASE_DIR, "test_db_2.sqlite3")
+ if "test" in sys.argv
+ else os.path.join(BASE_DIR, "db_2.sqlite3"),
+ "TEST": {
+ "NAME": os.path.join(BASE_DIR, "test_db_2.sqlite3"),
+ "OPTIONS": {"timeout": 20},
+ "DEPENDENCIES": [],
+ },
+ "OPTIONS": {"timeout": 20},
+ }
+ REACTPY_DATABASE = "reactpy"
# Cache
CACHES = {
@@ -142,7 +166,7 @@
"loggers": {
"reactpy_django": {
"handlers": ["console"],
- "level": "DEBUG",
+ "level": "DEBUG" if DEBUG else "WARNING",
},
},
}
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index 4d22cc1d..ba69185c 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -34,7 +34,9 @@ ReactPy Test Page
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
{% component "test_app.components.relational_query" %}
+ {% component "test_app.components.async_relational_query" %}
{% component "test_app.components.todo_list" %}
+ {% component "test_app.components.async_todo_list" %}
{% component "test_app.components.view_to_component_sync_func" %}
{% component "test_app.components.view_to_component_async_func" %}
{% component "test_app.components.view_to_component_sync_class" %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py
index 359b7dbe..2b378e37 100644
--- a/tests/test_app/tests/test_components.py
+++ b/tests/test_app/tests/test_components.py
@@ -1,25 +1,47 @@
import asyncio
import os
import sys
+from functools import partial
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 TimeoutError, sync_playwright
-CLICK_DELAY = 250 # Delay in miliseconds. Needed for GitHub Actions.
+CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds.
class ComponentTests(ChannelsLiveServerTestCase):
+ databases = {"default"}
+
@classmethod
def setUpClass(cls):
+ # Repurposed from ChannelsLiveServerTestCase._pre_setup
+ for connection in connections.all():
+ if cls._is_in_memory_db(cls, connection):
+ raise ImproperlyConfigured(
+ "ChannelLiveServerTestCase can not be used with in memory databases"
+ )
+ cls._live_server_modified_settings = modify_settings(
+ ALLOWED_HOSTS={"append": cls.host}
+ )
+ cls._live_server_modified_settings.enable()
+ get_application = partial(
+ make_application,
+ static_wrapper=cls.static_wrapper if cls.serve_static else None,
+ )
+ cls._server_process = cls.ProtocolServerProcess(cls.host, get_application)
+ cls._server_process.start()
+ cls._server_process.ready.wait()
+ cls._port = cls._server_process.port.value
+
+ # Open a Playwright browser window
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
-
- # FIXME: This is required otherwise the tests will throw a `SynchronousOnlyOperation`
- # error when discarding the test datatabase. Potentially a `ChannelsLiveServerTestCase` bug.
- os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
-
- super().setUpClass()
cls.playwright = sync_playwright().start()
headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0)))
cls.browser = cls.playwright.chromium.launch(headless=not headed)
@@ -27,14 +49,35 @@ def setUpClass(cls):
@classmethod
def tearDownClass(cls):
- super().tearDownClass()
- cls.page.close()
- cls.browser.close()
+ # Close the Playwright browser
cls.playwright.stop()
+ # Repurposed from ChannelsLiveServerTestCase._post_teardown
+ cls._server_process.terminate()
+ cls._server_process.join()
+ cls._live_server_modified_settings.disable()
+ for db_name in {"default", "reactpy"}:
+ call_command(
+ "flush",
+ verbosity=0,
+ interactive=False,
+ database=db_name,
+ reset_sequences=False,
+ )
+
+ def _pre_setup(self):
+ """Handled manually in `setUpClass` to speed things up."""
+ pass
+
+ def _post_teardown(self):
+ """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing
+ database flushing. This is needed to prevent a `SynchronousOnlyOperation` from
+ occuring due to a bug within `ChannelsLiveServerTestCase`."""
+ pass
+
def setUp(self):
- super().setUp()
- self.page.goto(self.live_server_url)
+ if self.page.url == "about:blank":
+ self.page.goto(self.live_server_url)
def test_hello_world(self):
self.page.wait_for_selector("#hello-world")
@@ -97,6 +140,9 @@ def test_authorized_user(self):
def test_relational_query(self):
self.page.locator("#relational-query[data-success=true]").wait_for()
+ def test_async_relational_query(self):
+ self.page.locator("#async-relational-query[data-success=true]").wait_for()
+
def test_use_query_and_mutation(self):
todo_input = self.page.wait_for_selector("#todo-input")
@@ -105,12 +151,33 @@ def test_use_query_and_mutation(self):
for i in item_ids:
todo_input.type(f"sample-{i}", delay=CLICK_DELAY)
todo_input.press("Enter", delay=CLICK_DELAY)
- self.page.wait_for_selector(f"#todo-item-sample-{i}")
- self.page.wait_for_selector(f"#todo-item-sample-{i}-checkbox").click()
+ 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()
+ self.assertRaises(
+ TimeoutError,
+ self.page.wait_for_selector,
+ f"#todo-list #todo-item-sample-{i}",
+ timeout=1,
+ )
+
+ def test_async_use_query_and_mutation(self):
+ todo_input = self.page.wait_for_selector("#async-todo-input")
+
+ 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)
+ 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()
self.assertRaises(
TimeoutError,
self.page.wait_for_selector,
- f"#todo-item-sample-{i}",
+ f"#async-todo-list #todo-item-sample-{i}",
timeout=1,
)
diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py
index 2ea278dc..4b30d58a 100644
--- a/tests/test_app/tests/test_database.py
+++ b/tests/test_app/tests/test_database.py
@@ -10,15 +10,19 @@
from reactpy_django.types import ComponentParamData
-class DatabaseTests(TransactionTestCase):
+class RoutedDatabaseTests(TransactionTestCase):
+ databases = {"reactpy"}
+
def test_component_params(self):
+ db = list(self.databases)[0]
+
# Make sure the ComponentParams table is empty
- self.assertEqual(ComponentSession.objects.count(), 0)
+ self.assertEqual(ComponentSession.objects.using(db).count(), 0)
params_1 = self._save_params_to_db(1)
# Check if a component params are in the database
- self.assertEqual(ComponentSession.objects.count(), 1)
- self.assertEqual(pickle.loads(ComponentSession.objects.first().params), params_1) # type: ignore
+ self.assertEqual(ComponentSession.objects.using(db).count(), 1)
+ self.assertEqual(pickle.loads(ComponentSession.objects.using(db).first().params), params_1) # type: ignore
# Force `params_1` to expire
from reactpy_django import config
@@ -28,19 +32,37 @@ def test_component_params(self):
# Create a new, non-expired component params
params_2 = self._save_params_to_db(2)
- self.assertEqual(ComponentSession.objects.count(), 2)
+ self.assertEqual(ComponentSession.objects.using(db).count(), 2)
# Delete the first component params based on expiration time
utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic
# Make sure `params_1` has expired
- self.assertEqual(ComponentSession.objects.count(), 1)
- self.assertEqual(pickle.loads(ComponentSession.objects.first().params), params_2) # type: ignore
+ self.assertEqual(ComponentSession.objects.using(db).count(), 1)
+ self.assertEqual(pickle.loads(ComponentSession.objects.using(db).first().params), params_2) # type: ignore
def _save_params_to_db(self, value: Any) -> ComponentParamData:
+ db = list(self.databases)[0]
param_data = ComponentParamData((value,), {"test_value": value})
model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data))
- model.full_clean()
- model.save()
+ model.clean_fields()
+ model.clean()
+ model.save(using=db)
return param_data
+
+
+class DefaultDatabaseTests(RoutedDatabaseTests):
+ databases = {"default"}
+
+ def setUp(self) -> None:
+ from reactpy_django import config
+
+ config.REACTPY_DATABASE = "default"
+ return super().setUp()
+
+ def tearDown(self) -> None:
+ from reactpy_django import config
+
+ config.REACTPY_DATABASE = "reactpy"
+ return super().tearDown()
diff --git a/tests/test_app/views.py b/tests/test_app/views.py
index b172132a..0d013b1a 100644
--- a/tests/test_app/views.py
+++ b/tests/test_app/views.py
@@ -38,7 +38,7 @@ def get(self, request, *args, **kwargs):
class ViewToComponentAsyncClass(View):
async def get(self, request, *args, **kwargs):
- return await database_sync_to_async(render)(
+ return await database_sync_to_async(render, thread_sensitive=False)(
request,
"view_to_component.html",
{"test_name": self.__class__.__name__},
@@ -61,7 +61,7 @@ def view_to_component_sync_func_compatibility(request):
async def view_to_component_async_func_compatibility(request):
- return await database_sync_to_async(render)(
+ return await database_sync_to_async(render, thread_sensitive=False)(
request,
"view_to_component.html",
{"test_name": inspect.currentframe().f_code.co_name}, # type:ignore
@@ -79,7 +79,7 @@ def get(self, request, *args, **kwargs):
class ViewToComponentAsyncClassCompatibility(View):
async def get(self, request, *args, **kwargs):
- return await database_sync_to_async(render)(
+ return await database_sync_to_async(render, thread_sensitive=False)(
request,
"view_to_component.html",
{"test_name": self.__class__.__name__},