diff --git a/CHANGELOG.md b/CHANGELOG.md index aa53f765..5c9b047f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,21 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Changed + +- New syntax for `use_query` and `use_mutation` hooks. Here's a quick comparison of the changes: + + ```python + query = use_query(QueryOptions(thread_sensitive=True), get_items, value=123456, foo="bar") # Old + query = use_query(get_items, {"value":12356, "foo":"bar"}, thread_sensitive=True) # New + + mutation = use_mutation(MutationOptions(thread_sensitive=True), remove_item) # Old + mutation = use_mutation(remove_item, thread_sensitive=True) # New + ``` + +### Removed + +- `QueryOptions` and `MutationOptions` have been removed. Their values are now passed direct into the hook. ## [3.8.1] - 2024-05-07 diff --git a/docs/examples/python/django-query-postprocessor.py b/docs/examples/python/django-query-postprocessor.py index ff7419b5..da33c362 100644 --- a/docs/examples/python/django-query-postprocessor.py +++ b/docs/examples/python/django-query-postprocessor.py @@ -1,7 +1,6 @@ from example.models import TodoItem from reactpy import component from reactpy_django.hooks import use_query -from reactpy_django.types import QueryOptions from reactpy_django.utils import django_query_postprocessor @@ -11,13 +10,11 @@ def get_items(): @component def todo_list(): - # These `QueryOptions` are functionally equivalent to ReactPy-Django's default values + # These postprocessor options are functionally equivalent to ReactPy-Django's default values item_query = use_query( - QueryOptions( - postprocessor=django_query_postprocessor, - postprocessor_kwargs={"many_to_many": True, "many_to_one": True}, - ), get_items, + postprocessor=django_query_postprocessor, + postprocessor_kwargs={"many_to_many": True, "many_to_one": True}, ) return item_query.data diff --git a/docs/examples/python/use-mutation-query-refetch.py b/docs/examples/python/use-mutation-query-refetch.py index b0817713..227ab1a7 100644 --- a/docs/examples/python/use-mutation-query-refetch.py +++ b/docs/examples/python/use-mutation-query-refetch.py @@ -26,7 +26,9 @@ def submit_event(event): 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.pk) for item in item_query.data) + rendered_items = html.ul( + html.li(item.text, key=item.pk) for item in item_query.data + ) # Handle all possible mutation states if item_mutation.loading: diff --git a/docs/examples/python/use-mutation-thread-sensitive.py b/docs/examples/python/use-mutation-thread-sensitive.py index 0242e41b..85046dc0 100644 --- a/docs/examples/python/use-mutation-thread-sensitive.py +++ b/docs/examples/python/use-mutation-thread-sensitive.py @@ -1,6 +1,5 @@ from reactpy import component, html from reactpy_django.hooks import use_mutation -from reactpy_django.types import MutationOptions def execute_thread_safe_mutation(text): @@ -11,8 +10,8 @@ def execute_thread_safe_mutation(text): @component def my_component(): item_mutation = use_mutation( - MutationOptions(thread_sensitive=False), execute_thread_safe_mutation, + thread_sensitive=False, ) def submit_event(event): diff --git a/docs/examples/python/use-query-args.py b/docs/examples/python/use-query-args.py index 60e8851a..8deb549a 100644 --- a/docs/examples/python/use-query-args.py +++ b/docs/examples/python/use-query-args.py @@ -2,16 +2,11 @@ from reactpy_django.hooks import use_query -def example_query(value: int, other_value: bool = False): - ... +def example_query(value: int, other_value: bool = False): ... @component def my_component(): - query = use_query( - example_query, - 123, - other_value=True, - ) + query = use_query(example_query, {"value": 123, "other_value": True}) return str(query.data) diff --git a/docs/examples/python/use-query-postprocessor-change.py b/docs/examples/python/use-query-postprocessor-change.py index c331285e..5685956d 100644 --- a/docs/examples/python/use-query-postprocessor-change.py +++ b/docs/examples/python/use-query-postprocessor-change.py @@ -1,6 +1,5 @@ from reactpy import component from reactpy_django.hooks import use_query -from reactpy_django.types import QueryOptions def my_postprocessor(data, example_kwarg=True): @@ -18,11 +17,9 @@ def execute_io_intensive_operation(): @component def my_component(): query = use_query( - QueryOptions( - postprocessor=my_postprocessor, - postprocessor_kwargs={"example_kwarg": False}, - ), execute_io_intensive_operation, + postprocessor=my_postprocessor, + postprocessor_kwargs={"example_kwarg": False}, ) if query.loading or query.error: diff --git a/docs/examples/python/use-query-postprocessor-disable.py b/docs/examples/python/use-query-postprocessor-disable.py index 32e981f1..e9541924 100644 --- a/docs/examples/python/use-query-postprocessor-disable.py +++ b/docs/examples/python/use-query-postprocessor-disable.py @@ -1,6 +1,5 @@ from reactpy import component from reactpy_django.hooks import use_query -from reactpy_django.types import QueryOptions def execute_io_intensive_operation(): @@ -11,8 +10,8 @@ def execute_io_intensive_operation(): @component def my_component(): query = use_query( - QueryOptions(postprocessor=None), execute_io_intensive_operation, + postprocessor=None, ) if query.loading or query.error: diff --git a/docs/examples/python/use-query-postprocessor-kwargs.py b/docs/examples/python/use-query-postprocessor-kwargs.py index b11eb2b5..4ed108af 100644 --- a/docs/examples/python/use-query-postprocessor-kwargs.py +++ b/docs/examples/python/use-query-postprocessor-kwargs.py @@ -1,7 +1,6 @@ from example.models import TodoItem from reactpy import component from reactpy_django.hooks import use_query -from reactpy_django.types import QueryOptions def get_model_with_relationships(): @@ -17,10 +16,8 @@ def get_model_with_relationships(): @component def my_component(): query = use_query( - QueryOptions( - postprocessor_kwargs={"many_to_many": False, "many_to_one": False} - ), get_model_with_relationships, + postprocessor_kwargs={"many_to_many": False, "many_to_one": False}, ) if query.loading or query.error or not query.data: diff --git a/docs/examples/python/use-query-thread-sensitive.py b/docs/examples/python/use-query-thread-sensitive.py index 9af19f2b..d657be6b 100644 --- a/docs/examples/python/use-query-thread-sensitive.py +++ b/docs/examples/python/use-query-thread-sensitive.py @@ -1,6 +1,5 @@ from reactpy import component from reactpy_django.hooks import use_query -from reactpy_django.types import QueryOptions def execute_thread_safe_operation(): @@ -10,10 +9,7 @@ def execute_thread_safe_operation(): @component def my_component(): - query = use_query( - QueryOptions(thread_sensitive=False), - execute_thread_safe_operation, - ) + query = use_query(execute_thread_safe_operation, thread_sensitive=False) if query.loading or query.error: return None diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-query.py index cd8d171b..5688765b 100644 --- a/docs/examples/python/use-query.py +++ b/docs/examples/python/use-query.py @@ -18,7 +18,7 @@ def todo_list(): rendered_items = html.h2("Error when loading!") else: rendered_items = html.ul( - [html.li(item, key=item.pk) for item in item_query.data] + [html.li(item.text, key=item.pk) for item in item_query.data] ) return html.div("Rendered items: ", rendered_items) diff --git a/docs/includes/orm.md b/docs/includes/orm.md index 58f45477..a2a18dfe 100644 --- a/docs/includes/orm.md +++ b/docs/includes/orm.md @@ -8,6 +8,6 @@ These `#!python SynchronousOnlyOperation` exceptions may be removed in a future -By default, automatic recursive fetching of `#!python ManyToMany` or `#!python ForeignKey` fields is enabled within the `django_query_postprocessor`. This is needed to prevent `#!python SynchronousOnlyOperation` exceptions when accessing these fields within your ReactPy components. +By default, automatic recursive fetching of `#!python ManyToMany` or `#!python ForeignKey` fields is enabled within the `#!python django_query_postprocessor`. This is needed to prevent `#!python SynchronousOnlyOperation` exceptions when accessing these fields within your ReactPy components. diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index b3ec7d6e..6d7da90f 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -44,20 +44,21 @@ Query functions can be sync or async. | Name | Type | Description | Default | | --- | --- | --- | --- | - | `#!python options` | `#!python QueryOptions | None` | An optional `#!python QueryOptions` object that can modify how the query is executed. | `#!python None` | - | `#!python query` | `#!python Callable[_Params, _Result | None]` | A callable that returns a Django `#!python Model` or `#!python QuerySet`. | N/A | - | `#!python *args` | `#!python _Params.args` | Positional arguments to pass into `#!python query`. | N/A | - | `#!python **kwargs` | `#!python _Params.kwargs` | Keyword arguments to pass into `#!python query`. | N/A | + | `#!python query` | `#!python Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred]` | A function that executes a query and returns some data. | N/A | + | `#!python kwargs` | `#!python dict[str, Any] | None` | Keyword arguments to passed into the `#!python query` function. | `#!python None` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This mode only applies to sync query functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python postprocessor` | `#!python AsyncPostprocessor | SyncPostprocessor | None` | A callable that processes the query `#!python data` before it is returned. The first argument of postprocessor function must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs`. This postprocessor function must return the modified `#!python data`. | `#!python None` | + | `#!python postprocessor_kwargs` | `#!python dict[str, Any] | None` | Keyworded arguments passed into the `#!python postprocessor` function. | `#!python None` | **Returns** | Type | Description | | --- | --- | - | `#!python Query[_Result | None]` | An object containing `#!python loading`/`#!python error` states, your `#!python data` (if the query has successfully executed), and a `#!python refetch` callable that can be used to re-run the query. | + | `#!python Query[Inferred]` | An object containing `#!python loading`/`#!python error` states, your `#!python data` (if the query has successfully executed), and a `#!python refetch` callable that can be used to re-run the query. | ??? question "How can I provide arguments to my query function?" - `#!python *args` and `#!python **kwargs` can be provided to your query function via `#!python use_query` parameters. + `#!python kwargs` can be provided to your query function via the `#!python kwargs=...` parameter. === "components.py" @@ -67,15 +68,15 @@ Query functions can be sync or async. ??? question "How can I customize this hook's behavior?" - This hook accepts a `#!python options: QueryOptions` parameter that can be used to customize behavior. + This hook has several parameters that can be used to customize behavior. - Below are the settings that can be modified via these `#!python QueryOptions`. + Below are examples of values that can be modified. --- **`#!python 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. + 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. @@ -96,7 +97,7 @@ Query functions can be sync or async. 1. Want to use this hook to defer IO intensive tasks to be computed in the background 2. Want to to utilize `#!python use_query` with a different ORM - ... then you can either set a custom `#!python postprocessor`, or disable all postprocessing behavior by modifying the `#!python QueryOptions.postprocessor` parameter. In the example below, we will set the `#!python postprocessor` to `#!python None` to disable postprocessing behavior. + ... then you can either set a custom `#!python postprocessor`, or disable all postprocessing behavior by modifying the `#!python postprocessor=...` parameter. In the example below, we will set the `#!python postprocessor` to `#!python None` to disable postprocessing behavior. === "components.py" @@ -104,11 +105,7 @@ Query functions can be sync or async. {% include "../../examples/python/use-query-postprocessor-disable.py" %} ``` - If you wish to create a custom `#!python postprocessor`, you will need to create a callable. - - The first argument of `#!python postprocessor` must be the query `#!python data`. All proceeding arguments - are optional `#!python postprocessor_kwargs` (see below). This `#!python postprocessor` must return - the modified `#!python data`. + If you wish to create a custom `#!python postprocessor`, you will need to create a function where the first must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs` (see below). This `#!python postprocessor` function must return the modified `#!python data`. === "components.py" @@ -124,7 +121,7 @@ Query functions can be sync or async. However, if you have deep nested trees of relational data, this may not be a desirable behavior. In these scenarios, you may prefer to manually fetch these relational fields using a second `#!python use_query` hook. - You can disable the prefetching behavior of the default `#!python postprocessor` (located at `#!python reactpy_django.utils.django_query_postprocessor`) via the `#!python QueryOptions.postprocessor_kwargs` parameter. + You can disable the prefetching behavior of the default `#!python postprocessor` (located at `#!python reactpy_django.utils.django_query_postprocessor`) via the `#!python postprocessor_kwargs=...` parameter. === "components.py" @@ -140,7 +137,7 @@ Query functions can be sync or async. ??? question "Can I make a failed query try again?" - Yes, a `#!python use_mutation` can be re-performed by calling `#!python reset()` on your `#!python use_mutation` instance. + Yes, `#!python use_mutation` can be re-executed by calling `#!python reset()` on your `#!python use_mutation` instance. For example, take a look at `#!python reset_event` below. @@ -190,14 +187,15 @@ Mutation functions can be sync or async. | Name | Type | Description | Default | | --- | --- | --- | --- | - | `#!python mutation` | `#!python Callable[_Params, bool | None]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | - | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function you provide to your `#!python use_query` hook) or a sequence of query functions that need a `refetch` if the mutation succeeds. This is useful for refreshing data after a mutation has been performed. | `#!python None` | + | `#!python mutation` | `#!python Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | + | `#!python thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This mode only applies to sync mutation functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function you provide to your `#!python use_query` hook) or a sequence of query functions that need a `#!python refetch` if the mutation succeeds. This is useful for refreshing data after a mutation has been performed. | `#!python None` | **Returns** | Type | Description | | --- | --- | - | `#!python Mutation[_Params]` | An object containing `#!python loading`/`#!python error` states, a `#!python reset` callable that will set `#!python loading`/`#!python error` states to defaults, and a `#!python execute` callable that will run the query. | + | `#!python Mutation[FuncParams]` | An object containing `#!python loading`/`#!python error` states, and a `#!python reset` callable that will set `#!python loading`/`#!python error` states to defaults. This object can be called to run the query. | ??? question "How can I provide arguments to my mutation function?" @@ -211,15 +209,15 @@ Mutation functions can be sync or async. ??? question "How can I customize this hook's behavior?" - This hook accepts a `#!python options: MutationOptions` parameter that can be used to customize behavior. + This hook has several parameters that can be used to customize behavior. - Below are the settings that can be modified via these `#!python MutationOptions`. + Below are examples of values that can be modified. --- **`#!python thread_sensitive`** - Whether to run your synchronous mutation 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. + Whether to run your synchronous mutation 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. @@ -235,7 +233,7 @@ Mutation functions can be sync or async. ??? question "Can I make a failed mutation try again?" - Yes, a `#!python use_mutation` can be re-performed by calling `#!python reset()` on your `#!python use_mutation` instance. + Yes, `#!python use_mutation` can be re-executed by calling `#!python reset()` on your `#!python use_mutation` instance. For example, take a look at `#!python reset_event` below. @@ -257,7 +255,7 @@ Mutation functions can be sync or async. The example below is a merge of the `#!python use_query` and `#!python use_mutation` examples above with the addition of a `#!python use_mutation(refetch=...)` argument. - Please note that `refetch` will cause all `#!python use_query` hooks that use `#!python get_items` in the current component tree will be refetched. + Please note that `#!python refetch` will cause all `#!python use_query` hooks that use `#!python get_items` in the current component tree will be refetched. === "components.py" diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 8015a4ab..e13655c2 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -11,7 +11,6 @@ Sequence, Union, cast, - overload, ) from uuid import uuid4 @@ -29,16 +28,16 @@ from reactpy_django.types import ( AsyncMessageReceiver, AsyncMessageSender, + AsyncPostprocessor, ConnectionType, FuncParams, Inferred, Mutation, - MutationOptions, Query, - QueryOptions, + SyncPostprocessor, UserData, ) -from reactpy_django.utils import generate_obj_name, get_pk +from reactpy_django.utils import django_query_postprocessor, generate_obj_name, get_pk if TYPE_CHECKING: from channels_redis.core import RedisChannelLayer @@ -51,7 +50,6 @@ ) -# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.* def use_location() -> Location: """Get the current route as a `Location` object""" return _use_location() @@ -85,7 +83,6 @@ def use_origin() -> str | None: return None -# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.* def use_scope() -> dict[str, Any]: """Get the current ASGI scope dictionary""" scope = _use_scope() @@ -96,55 +93,55 @@ def use_scope() -> dict[str, Any]: raise TypeError(f"Expected scope to be a dict, got {type(scope)}") -# TODO: Deprecate this once the equivalent hook gets moved to reactpy.hooks.* def use_connection() -> ConnectionType: """Get the current `Connection` object""" return _use_connection() -@overload def use_query( - options: QueryOptions, - /, query: Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred], - *args: FuncParams.args, - **kwargs: FuncParams.kwargs, -) -> Query[Inferred]: ... - - -@overload -def use_query( - query: Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred], - *args: FuncParams.args, - **kwargs: FuncParams.kwargs, -) -> Query[Inferred]: ... - - -def use_query(*args, **kwargs) -> Query[Inferred]: + kwargs: dict[str, Any] | None = None, + *, + thread_sensitive: bool = True, + postprocessor: ( + AsyncPostprocessor | SyncPostprocessor | None + ) = django_query_postprocessor, + postprocessor_kwargs: dict[str, Any] | None = None, +) -> Query[Inferred]: """This hook is used to execute functions in the background and return the result, \ typically to read data the Django ORM. Args: - options: An optional `QueryOptions` object that can modify how the query is executed. - query: A callable that returns a Django `Model` or `QuerySet`. - *args: Positional arguments to pass into `query`. + query: A function that executes a query and returns some data. - Keyword Args: - **kwargs: Keyword arguments to pass into `query`.""" + Kwargs: + kwargs: Keyword arguments to passed into the `query` function. + thread_sensitive: Whether to run the query in thread sensitive mode. \ + This mode only applies to sync query functions, and is turned on by default \ + due to Django ORM limitations. + postprocessor: A callable that processes the query `data` before it is returned. \ + The first argument of postprocessor function must be the query `data`. All \ + proceeding arguments are optional `postprocessor_kwargs`. This postprocessor \ + function must return the modified `data`. \ + \ + If unset, `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` is used. By default, this \ + is used to prevent Django's lazy query execution and supports `many_to_many` \ + and `many_to_one` as `postprocessor_kwargs`. + postprocessor_kwargs: Keyworded arguments passed into the `postprocessor` function. + + Returns: + An object containing `loading`/`#!python error` states, your `data` (if the query \ + has successfully executed), and a `refetch` callable that can be used to re-run the query. + """ should_execute, set_should_execute = use_state(True) data, set_data = use_state(cast(Inferred, None)) loading, set_loading = use_state(True) error, set_error = use_state(cast(Union[Exception, None], None)) - if isinstance(args[0], QueryOptions): - query_options, query, query_args, query_kwargs = _use_query_args_1( - *args, **kwargs - ) - else: - query_options, query, query_args, query_kwargs = _use_query_args_2( - *args, **kwargs - ) query_ref = use_ref(query) + kwargs = kwargs or {} + postprocessor_kwargs = postprocessor_kwargs or {} + if query_ref.current is not query: raise ValueError(f"Query function changed from {query_ref.current} to {query}.") @@ -153,31 +150,27 @@ async def execute_query() -> None: try: # Run the query if asyncio.iscoroutinefunction(query): - new_data = await query(*query_args, **query_kwargs) + new_data = await query(**kwargs) else: new_data = await database_sync_to_async( - query, - thread_sensitive=query_options.thread_sensitive, - )(*query_args, **query_kwargs) + query, thread_sensitive=thread_sensitive + )(**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 - ) + if postprocessor: + if asyncio.iscoroutinefunction(postprocessor): + new_data = await postprocessor(new_data, **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) + postprocessor, thread_sensitive=thread_sensitive + )(new_data, **postprocessor_kwargs) # Log any errors and set the error state except Exception as e: set_data(cast(Inferred, None)) set_loading(False) set_error(e) - _logger.exception(f"Failed to execute query: {generate_obj_name(query)}") + _logger.exception("Failed to execute query: %s", generate_obj_name(query)) return # Query was successful @@ -212,30 +205,18 @@ def register_refetch_callback() -> Callable[[], None]: _REFETCH_CALLBACKS[query].add(refetch) return lambda: _REFETCH_CALLBACKS[query].remove(refetch) - # The query's user API + # Return Query user API return Query(data, loading, error, refetch) -@overload -def use_mutation( - options: MutationOptions, - mutation: ( - Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]] - ), - refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, -) -> Mutation[FuncParams]: ... - - -@overload def use_mutation( mutation: ( Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]] ), + *, + thread_sensitive: bool = True, refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, -) -> Mutation[FuncParams]: ... - - -def use_mutation(*args: Any, **kwargs: Any) -> Mutation[FuncParams]: +) -> Mutation[FuncParams]: """This hook is used to modify data in the background, typically to create/update/delete \ data from the Django ORM. @@ -246,18 +227,24 @@ def use_mutation(*args: Any, **kwargs: Any) -> Mutation[FuncParams]: mutation: A callable that performs Django ORM create, update, or delete \ functionality. If this function returns `False`, then your `refetch` \ function will not be used. + + Kwargs: + thread_sensitive: Whether to run the mutation in thread sensitive mode. \ + This mode only applies to sync mutation functions, and is turned on by default \ + due to Django ORM limitations. refetch: A query function (the function you provide to your `use_query` \ hook) or a sequence of query functions that need a `refetch` if the \ mutation succeeds. This is useful for refreshing data after a mutation \ has been performed. + + Returns: + An object containing `#!python loading`/`#!python error` states, and a \ + `#!python reset` callable that will set `#!python loading`/`#!python error` \ + states to defaults. This object can be called to run the query. """ 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) # The main "running" function for `use_mutation` async def execute_mutation(exec_args, exec_kwargs) -> None: @@ -267,7 +254,7 @@ async def execute_mutation(exec_args, exec_kwargs) -> None: should_refetch = await mutation(*exec_args, **exec_kwargs) else: should_refetch = await database_sync_to_async( - mutation, thread_sensitive=mutation_options.thread_sensitive + mutation, thread_sensitive=thread_sensitive )(*exec_args, **exec_kwargs) # Log any errors and set the error state @@ -275,7 +262,7 @@ async def execute_mutation(exec_args, exec_kwargs) -> None: set_loading(False) set_error(e) _logger.exception( - f"Failed to execute mutation: {generate_obj_name(mutation)}" + "Failed to execute mutation: %s", generate_obj_name(mutation) ) # Mutation was successful @@ -311,7 +298,7 @@ def reset() -> None: set_loading(False) set_error(None) - # The mutation's user API + # Return mutation user API return Mutation(schedule_mutation, loading, error, reset) @@ -355,11 +342,13 @@ async def _set_user_data(data: dict): await model.asave() query: Query[dict | None] = use_query( - QueryOptions(postprocessor=None), _get_user_data, - user=user, - default_data=default_data, - save_default_data=save_default_data, + kwargs={ + "user": user, + "default_data": default_data, + "save_default_data": save_default_data, + }, + postprocessor=None, ) mutation = use_mutation(_set_user_data, refetch=_get_user_data) @@ -444,22 +433,6 @@ def use_root_id() -> str: return scope["reactpy"]["id"] -def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs): - return options, query, args, kwargs - - -def _use_query_args_2(query: Query, *args, **kwargs): - return QueryOptions(), query, args, kwargs - - -def _use_mutation_args_1(options: MutationOptions, mutation: Mutation, refetch=None): - return options, mutation, refetch - - -def _use_mutation_args_2(mutation, refetch=None): - return MutationOptions(), mutation, refetch - - async def _get_user_data( user: AbstractUser, default_data: None | dict, save_default_data: bool ) -> dict | None: diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 8efda72f..91ffc319 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, @@ -52,49 +52,11 @@ def __call__(self, *args: FuncParams.args, **kwargs: FuncParams.kwargs) -> None: class AsyncPostprocessor(Protocol): - async def __call__(self, data: Any) -> Any: - ... + async def __call__(self, data: Any) -> Any: ... class SyncPostprocessor(Protocol): - def __call__(self, data: Any) -> Any: - ... - - -@dataclass -class QueryOptions: - """Configuration options that can be provided to `use_query`.""" - - from reactpy_django.config import 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 - are optional `postprocessor_kwargs` (see below). This postprocessor function must return - the modified `data`. - - If unset, REACTPY_DEFAULT_QUERY_POSTPROCESSOR is used. - - ReactPy's default django_query_postprocessor prevents Django's lazy query execution, and - additionally can be configured via `postprocessor_kwargs` to recursively fetch - `many_to_many` and `many_to_one` fields.""" - - 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.""" + def __call__(self, data: Any) -> Any: ... @dataclass @@ -112,10 +74,8 @@ class UserData(NamedTuple): class AsyncMessageReceiver(Protocol): - async def __call__(self, message: dict) -> None: - ... + async def __call__(self, message: dict) -> None: ... class AsyncMessageSender(Protocol): - async def __call__(self, message: dict) -> None: - ... + async def __call__(self, message: dict) -> None: ... diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 457caf47..73538ad7 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -29,19 +29,19 @@ ) _logger = logging.getLogger(__name__) -_component_tag = r"(?Pcomponent)" -_component_path = r"""(?P"[^"'\s]+"|'[^"'\s]+')""" -_component_offline_kwarg = ( - rf"""(\s*offline\s*=\s*{_component_path.replace(r"", r"")})""" +_TAG_PATTERN = r"(?Pcomponent)" +_PATH_PATTERN = r"""(?P"[^"'\s]+"|'[^"'\s]+')""" +_OFFLINE_KWARG_PATTERN = ( + rf"""(\s*offline\s*=\s*{_PATH_PATTERN.replace(r"", r"")})""" ) -_component_generic_kwarg = r"""(\s*.*?)""" +_GENERIC_KWARG_PATTERN = r"""(\s*.*?)""" COMMENT_REGEX = re.compile(r"") COMPONENT_REGEX = re.compile( r"{%\s*" - + _component_tag + + _TAG_PATTERN + r"\s*" - + _component_path - + rf"({_component_offline_kwarg}|{_component_generic_kwarg})*?" + + _PATH_PATTERN + + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + r"\s*%}" ) @@ -262,7 +262,7 @@ def django_query_postprocessor( ) -> QuerySet | Model: """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily. - Behaviors can be modified through `QueryOptions` within your `use_query` hook. + Behavior can be modified through `postprocessor_kwargs` within your `use_query` hook. Args: data: The `Model` or `QuerySet` to recursively fetch fields from. @@ -275,8 +275,7 @@ def django_query_postprocessor( The `Model` or `QuerySet` with all fields fetched. """ - # `QuerySet`, which is an iterable of `Model`/`QuerySet` instances - # https://github.com/typeddjango/django-stubs/issues/704 + # `QuerySet`, which is an iterable containing `Model`/`QuerySet` objects. if isinstance(data, QuerySet): for model in data: django_query_postprocessor( @@ -314,7 +313,7 @@ def django_query_postprocessor( "One of the following may have occurred:\n" " - You are using a non-Django ORM.\n" " - You are attempting to use `use_query` to fetch non-ORM data.\n\n" - "If these situations seem correct, you may want to consider disabling the postprocessor via `QueryOptions`." + "If these situations seem correct, you may want to consider disabling the postprocessor." ) return data @@ -381,4 +380,4 @@ def strtobool(val): elif val in ("n", "no", "f", "false", "off", "0"): return 0 else: - raise ValueError("invalid truth value %r" % (val,)) + raise ValueError(f"invalid truth value {val}") diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 0d4c62d1..345f399e 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -29,6 +29,8 @@ if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser + from reactpy_django import models + _logger = logging.getLogger(__name__) BACKHAUL_LOOP = asyncio.new_event_loop() @@ -47,9 +49,18 @@ def start_backhaul_loop(): class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # New WebsocketConsumer attributes created by ReactPy + self.dispatcher: Future | asyncio.Task + self.threaded: bool + self.recv_queue: asyncio.Queue + self.dotted_path: str + self.component_session: "models.ComponentSession" | None = None + async def connect(self) -> None: """The browser has connected.""" - from reactpy_django import models from reactpy_django.config import ( REACTPY_AUTH_BACKEND, REACTPY_AUTO_RELOGIN, @@ -79,9 +90,7 @@ async def connect(self) -> None: ) # Start the component dispatcher - self.dispatcher: Future | asyncio.Task self.threaded = REACTPY_BACKHAUL_THREAD - self.component_session: models.ComponentSession | None = None if self.threaded: if not BACKHAUL_THREAD.is_alive(): await asyncio.to_thread( @@ -149,14 +158,14 @@ async def run_dispatcher(self): ) scope = self.scope - self.dotted_path = dotted_path = scope["url_route"]["kwargs"]["dotted_path"] + self.dotted_path = scope["url_route"]["kwargs"]["dotted_path"] uuid = scope["url_route"]["kwargs"].get("uuid") has_args = scope["url_route"]["kwargs"].get("has_args") scope["reactpy"] = {"id": str(uuid)} query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True) http_pathname = query_string.get("http_pathname", [""])[0] http_search = query_string.get("http_search", [""])[0] - self.recv_queue: asyncio.Queue = asyncio.Queue() + self.recv_queue = asyncio.Queue() connection = Connection( # For `use_connection` scope=scope, location=Location(pathname=http_pathname, search=http_search), @@ -168,11 +177,11 @@ async def run_dispatcher(self): # Verify the component has already been registered try: - root_component_constructor = REACTPY_REGISTERED_COMPONENTS[dotted_path] + root_component_constructor = REACTPY_REGISTERED_COMPONENTS[self.dotted_path] except KeyError: await asyncio.to_thread( _logger.warning, - f"Attempt to access invalid ReactPy component: {dotted_path!r}", + f"Attempt to access invalid ReactPy component: {self.dotted_path!r}", ) return @@ -194,7 +203,7 @@ async def run_dispatcher(self): except models.ComponentSession.DoesNotExist: await asyncio.to_thread( _logger.warning, - f"Component session for '{dotted_path}:{uuid}' not found. The " + f"Component session for '{self.dotted_path}:{uuid}' not found. The " "session may have already expired beyond REACTPY_SESSION_MAX_AGE. " "If you are using a custom `host`, you may have forgotten to provide " "args/kwargs.", diff --git a/tests/test_app/components.py b/tests/test_app/components.py index dbe9bd8f..fe6df2f0 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -10,7 +10,6 @@ from django.shortcuts import render from reactpy import component, hooks, html, web from reactpy_django.components import view_to_component, view_to_iframe -from reactpy_django.types import QueryOptions from test_app.models import ( AsyncForiegnChild, @@ -659,7 +658,7 @@ def custom_host(number=0): @component def broken_postprocessor_query(): relational_parent = reactpy_django.hooks.use_query( - QueryOptions(postprocessor=None), get_relational_parent_query + get_relational_parent_query, postprocessor=None ) if not relational_parent.data: @@ -720,9 +719,9 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": "AnonymousUser" - if current_user.is_anonymous - else current_user.username, + "data-username": ( + "AnonymousUser" if current_user.is_anonymous else current_user.username + ), }, html.div("use_user_data"), html.button({"class": "login-1", "on_click": login_user1}, "Login 1"), @@ -788,9 +787,9 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": "AnonymousUser" - if current_user.is_anonymous - else current_user.username, + "data-username": ( + "AnonymousUser" if current_user.is_anonymous else current_user.username + ), }, html.div("use_user_data_with_default"), html.button({"class": "login-3", "on_click": login_user3}, "Login 3"),