diff --git a/CHANGELOG.md b/CHANGELOG.md index 23612904..a52bd859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,9 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Added + +- Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook. ## [3.7.0] - 2024-01-30 diff --git a/docs/examples/python/use-channel-layer-group.py b/docs/examples/python/use-channel-layer-group.py new file mode 100644 index 00000000..2fde6bab --- /dev/null +++ b/docs/examples/python/use-channel-layer-group.py @@ -0,0 +1,40 @@ +from reactpy import component, hooks, html +from reactpy_django.hooks import use_channel_layer + + +@component +def my_sender_component(): + sender = use_channel_layer("my-channel-name", group=True) + + async def submit_event(event): + if event["key"] == "Enter": + await sender({"text": event["target"]["value"]}) + + return html.div( + "Message Sender: ", + html.input({"type": "text", "onKeyDown": submit_event}), + ) + + +@component +def my_receiver_component_1(): + message, set_message = hooks.use_state("") + + async def receive_event(message): + set_message(message["text"]) + + use_channel_layer("my-channel-name", receiver=receive_event, group=True) + + return html.div(f"Message Receiver 1: {message}") + + +@component +def my_receiver_component_2(): + message, set_message = hooks.use_state("") + + async def receive_event(message): + set_message(message["text"]) + + use_channel_layer("my-channel-name", receiver=receive_event, group=True) + + return html.div(f"Message Receiver 2: {message}") diff --git a/docs/examples/python/use-channel-layer-signal-receiver.py b/docs/examples/python/use-channel-layer-signal-receiver.py new file mode 100644 index 00000000..57a92321 --- /dev/null +++ b/docs/examples/python/use-channel-layer-signal-receiver.py @@ -0,0 +1,14 @@ +from reactpy import component, hooks, html +from reactpy_django.hooks import use_channel_layer + + +@component +def my_receiver_component(): + message, set_message = hooks.use_state("") + + async def receive_event(message): + set_message(message["text"]) + + use_channel_layer("my-channel-name", receiver=receive_event) + + return html.div(f"Message Receiver: {message}") diff --git a/docs/examples/python/use-channel-layer-signal-sender.py b/docs/examples/python/use-channel-layer-signal-sender.py new file mode 100644 index 00000000..4e12fb12 --- /dev/null +++ b/docs/examples/python/use-channel-layer-signal-sender.py @@ -0,0 +1,20 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db.models import Model +from django.db.models.signals import pre_save +from django.dispatch import receiver + + +class ExampleModel(Model): + ... + + +@receiver(pre_save, sender=ExampleModel) +def my_sender_signal(sender, instance, **kwargs): + layer = get_channel_layer() + + # Example of sending a message to a channel + async_to_sync(layer.send)("my-channel-name", {"text": "Hello World!"}) + + # Example of sending a message to a group channel + async_to_sync(layer.group_send)("my-channel-name", {"text": "Hello World!"}) diff --git a/docs/examples/python/use-channel-layer.py b/docs/examples/python/use-channel-layer.py new file mode 100644 index 00000000..36e3a40b --- /dev/null +++ b/docs/examples/python/use-channel-layer.py @@ -0,0 +1,28 @@ +from reactpy import component, hooks, html +from reactpy_django.hooks import use_channel_layer + + +@component +def my_sender_component(): + sender = use_channel_layer("my-channel-name") + + async def submit_event(event): + if event["key"] == "Enter": + await sender({"text": event["target"]["value"]}) + + return html.div( + "Message Sender: ", + html.input({"type": "text", "onKeyDown": submit_event}), + ) + + +@component +def my_receiver_component(): + message, set_message = hooks.use_state("") + + async def receive_event(message): + set_message(message["text"]) + + use_channel_layer("my-channel-name", receiver=receive_event) + + return html.div(f"Message Receiver: {message}") diff --git a/docs/src/about/changelog.md b/docs/src/about/changelog.md index 1ecf88e2..e08ee1f9 100644 --- a/docs/src/about/changelog.md +++ b/docs/src/about/changelog.md @@ -3,6 +3,12 @@ hide: - toc --- + +
{% include-markdown "../../../CHANGELOG.md" start="" end="" %} diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 58f6eeec..dee45011 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -38,3 +38,4 @@ misconfiguration misconfigurations backhaul sublicense +broadcasted diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 197b59be..96fe0616 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -275,7 +275,11 @@ Mutation functions can be sync or async. ### Use User Data -Store or retrieve data (`#!python dict`) specific to the connection's `#!python User`. This data is stored in the `#!python REACTPY_DATABASE`. +Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`. + +This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs. + +User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. === "components.py" @@ -312,6 +316,103 @@ Store or retrieve data (`#!python dict`) specific to the connection's `#!python --- +## Communication Hooks + +--- + +### Use Channel Layer + +Subscribe to a [Django Channels layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) to send/receive messages. + +Layers are a multiprocessing-safe communication system that allows you to send/receive messages between different parts of your application. + +This is often used to create chat systems, synchronize data between components, or signal re-renders from outside your components. + +=== "components.py" + + ```python + {% include "../../examples/python/use-channel-layer.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python name` | `#!python str` | The name of the channel to subscribe to. | N/A | + | `#!python receiver` | `#!python AsyncMessageReceiver | None` | An async function that receives a `#!python message: dict` from the channel layer. If more than one receiver waits on the same channel, a random one will get the result (unless `#!python group=True` is defined). | `#!python None` | + | `#!python group` | `#!python bool` | If `#!python True`, a "group channel" will be used. Messages sent within a group are broadcasted to all receivers on that channel. | `#!python False` | + | `#!python layer` | `#!python str` | The channel layer to use. These layers must be defined in `#!python settings.py:CHANNEL_LAYERS`. | `#!python 'default'` | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python AsyncMessageSender` | An async callable that can send a `#!python message: dict` | + +??? warning "Extra Django configuration required" + + In order to use this hook, you will need to configure Django to enable channel layers. + + The [Django Channels documentation](https://channels.readthedocs.io/en/latest/topics/channel_layers.html#configuration) has information on what steps you need to take. + + In summary, you will need to: + + 1. Run the following command to install `channels-redis` in your Python environment. + + ```bash linenums="0" + pip install channels-redis + ``` + + 2. Configure your `settings.py` to use `RedisChannelLayer` as your layer backend. + + ```python linenums="0" + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], + }, + }, + } + ``` + +??? question "How do I broadcast a message to multiple components?" + + By default, if more than one receiver waits on the same channel, a random one will get the result. + + However, by defining `#!python group=True` you can configure a "group channel", which will broadcast messages to all receivers. + + In the example below, all messages sent by the `#!python sender` component will be received by all `#!python receiver` components that exist (across every active client browser). + + === "components.py" + + ```python + {% include "../../examples/python/use-channel-layer-group.py" %} + ``` + +??? question "How do I signal a re-render from something that isn't a component?" + + There are occasions where you may want to signal a re-render from something that isn't a component, such as a Django model signal. + + In these cases, you can use the `#!python use_channel_layer` hook to receive a signal within your component, and then use the `#!python get_channel_layer().send(...)` to send the signal. + + In the example below, the sender will send a signal every time `#!python ExampleModel` is saved. Then, when the receiver component gets this signal, it explicitly calls `#!python set_message(...)` to trigger a re-render. + + === "components.py" + + ```python + {% include "../../examples/python/use-channel-layer-signal-receiver.py" %} + ``` + === "signals.py" + + ```python + {% include "../../examples/python/use-channel-layer-signal-sender.py" %} + ``` + +--- + ## Connection Hooks --- diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 2b56bb4d..af5353e8 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -2,7 +2,7 @@
-A variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that utilizes Django conventions. +A Single Page Application URL router, which is a variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that uses Django conventions.
diff --git a/requirements/check-types.txt b/requirements/check-types.txt index c176075a..c962b716 100644 --- a/requirements/check-types.txt +++ b/requirements/check-types.txt @@ -1,2 +1,3 @@ mypy django-stubs[compatible-mypy] +channels-redis diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 0ebf2ea8..96da2d10 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -13,10 +13,13 @@ cast, overload, ) +from uuid import uuid4 import orjson as pickle +from channels import DEFAULT_CHANNEL_LAYER from channels.db import database_sync_to_async -from reactpy import use_callback, use_effect, use_ref, use_state +from channels.layers import InMemoryChannelLayer, get_channel_layer +from reactpy import use_callback, use_effect, use_memo, use_ref, use_state from reactpy import use_connection as _use_connection from reactpy import use_location as _use_location from reactpy import use_scope as _use_scope @@ -24,6 +27,8 @@ from reactpy_django.exceptions import UserNotFoundError from reactpy_django.types import ( + AsyncMessageReceiver, + AsyncMessageSender, ConnectionType, FuncParams, Inferred, @@ -36,6 +41,7 @@ from reactpy_django.utils import generate_obj_name, get_user_pk if TYPE_CHECKING: + from channels_redis.core import RedisChannelLayer from django.contrib.auth.models import AbstractUser @@ -361,6 +367,65 @@ async def _set_user_data(data: dict): return UserData(query, mutation) +def use_channel_layer( + name: str, + receiver: AsyncMessageReceiver | None = None, + group: bool = False, + layer: str = DEFAULT_CHANNEL_LAYER, +) -> AsyncMessageSender: + """ + Subscribe to a Django Channels layer to send/receive messages. + + Args: + name: The name of the channel to subscribe to. + receiver: An async function that receives a `message: dict` from the channel layer. \ + If more than one receiver waits on the same channel, a random one \ + will get the result (unless `group=True` is defined). + group: If `True`, a "group channel" will be used. Messages sent within a \ + group are broadcasted to all receivers on that channel. + layer: The channel layer to use. These layers must be defined in \ + `settings.py:CHANNEL_LAYERS`. + """ + channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer) + channel_name = use_memo(lambda: str(uuid4() if group else name)) + group_name = name if group else "" + + if not channel_layer: + raise ValueError( + f"Channel layer '{layer}' is not available. Are you sure you" + " configured settings.py:CHANNEL_LAYERS properly?" + ) + + # Add/remove a group's channel during component mount/dismount respectively. + @use_effect(dependencies=[]) + async def group_manager(): + if group: + await channel_layer.group_add(group_name, channel_name) + + return lambda: asyncio.run( + channel_layer.group_discard(group_name, channel_name) + ) + + # Listen for messages on the channel using the provided `receiver` function. + @use_effect + async def message_receiver(): + if not receiver or not channel_name: + return + + while True: + message = await channel_layer.receive(channel_name) + await receiver(message) + + # User interface for sending messages to the channel + async def message_sender(message: dict): + if group: + await channel_layer.group_send(group_name, message) + else: + await channel_layer.send(channel_name, message) + + return message_sender + + def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs): return options, query, args, kwargs diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index e049cd26..8efda72f 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -109,3 +109,13 @@ class ComponentParams: class UserData(NamedTuple): query: Query[dict | None] mutation: Mutation[dict] + + +class AsyncMessageReceiver(Protocol): + async def __call__(self, message: dict) -> None: + ... + + +class AsyncMessageSender(Protocol): + async def __call__(self, message: dict) -> None: + ... diff --git a/tests/test_app/channel_layers/__init__.py b/tests/test_app/channel_layers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/channel_layers/components.py b/tests/test_app/channel_layers/components.py new file mode 100644 index 00000000..d9174b47 --- /dev/null +++ b/tests/test_app/channel_layers/components.py @@ -0,0 +1,64 @@ +from reactpy import component, hooks, html +from reactpy_django.hooks import use_channel_layer + + +@component +def receiver(): + state, set_state = hooks.use_state("None") + + async def receiver(message): + set_state(message["text"]) + + use_channel_layer("channel-messenger", receiver=receiver) + + return html.div( + {"id": "receiver", "data-message": state}, + f"Message Receiver: {state}", + ) + + +@component +def sender(): + sender = use_channel_layer("channel-messenger") + + async def submit_event(event): + if event["key"] == "Enter": + await sender({"text": event["target"]["value"]}) + + return html.div( + "Message Sender: ", + html.input( + {"type": "text", "id": "sender", "onKeyDown": submit_event}, + ), + ) + + +@component +def group_receiver(id: int): + state, set_state = hooks.use_state("None") + + async def receiver(message): + set_state(message["text"]) + + use_channel_layer("group-messenger", receiver=receiver, group=True) + + return html.div( + {"id": f"group-receiver-{id}", "data-message": state}, + f"Group Message Receiver #{id}: {state}", + ) + + +@component +def group_sender(): + sender = use_channel_layer("group-messenger", group=True) + + async def submit_event(event): + if event["key"] == "Enter": + await sender({"text": event["target"]["value"]}) + + return html.div( + "Group Message Sender: ", + html.input( + {"type": "text", "id": "group-sender", "onKeyDown": submit_event}, + ), + ) diff --git a/tests/test_app/channel_layers/urls.py b/tests/test_app/channel_layers/urls.py new file mode 100644 index 00000000..e9d84b4b --- /dev/null +++ b/tests/test_app/channel_layers/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from test_app.channel_layers.views import channel_layers + +urlpatterns = [ + path("channel-layers/", channel_layers), +] diff --git a/tests/test_app/channel_layers/views.py b/tests/test_app/channel_layers/views.py new file mode 100644 index 00000000..786b307f --- /dev/null +++ b/tests/test_app/channel_layers/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def channel_layers(request, path=None): + return render(request, "channel_layers.html", {}) diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py index bb1fcf5a..65e37415 100644 --- a/tests/test_app/settings_multi_db.py +++ b/tests/test_app/settings_multi_db.py @@ -155,5 +155,8 @@ }, } +# Django Channels Settings +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} + # ReactPy-Django Settings REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py index e98a63a7..2550c8d1 100644 --- a/tests/test_app/settings_single_db.py +++ b/tests/test_app/settings_single_db.py @@ -136,5 +136,8 @@ }, } +# Django Channels Settings +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} + # ReactPy-Django Settings REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html new file mode 100644 index 00000000..af3db04b --- /dev/null +++ b/tests/test_app/templates/channel_layers.html @@ -0,0 +1,30 @@ +{% load static %} {% load reactpy %} + + + + + + + + +Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: