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 %} + + + + + + + + + ReactPy + + + +

ReactPy Channel Layers Test Page

+
+ {% component "test_app.channel_layers.components.receiver" %} +
+ {% component "test_app.channel_layers.components.sender" %} +
+ {% component "test_app.channel_layers.components.group_receiver" id=1 %} +
+ {% component "test_app.channel_layers.components.group_receiver" id=2 %} +
+ {% component "test_app.channel_layers.components.group_receiver" id=3 %} +
+ {% component "test_app.channel_layers.components.group_sender" %} +
+ + + diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 15d5b553..241e5659 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -640,3 +640,32 @@ def test_offline_components(self): finally: new_page.close() + + def test_channel_layer_components(self): + new_page = self.browser.new_page() + try: + new_page.goto(f"{self.live_server_url}/channel-layers/") + sender = new_page.wait_for_selector("#sender") + sender.type("test", delay=CLICK_DELAY) + sender.press("Enter", delay=CLICK_DELAY) + receiver = new_page.wait_for_selector("#receiver[data-message='test']") + self.assertIsNotNone(receiver) + + sender = new_page.wait_for_selector("#group-sender") + sender.type("1234", delay=CLICK_DELAY) + sender.press("Enter", delay=CLICK_DELAY) + receiver_1 = new_page.wait_for_selector( + "#group-receiver-1[data-message='1234']" + ) + receiver_2 = new_page.wait_for_selector( + "#group-receiver-2[data-message='1234']" + ) + receiver_3 = new_page.wait_for_selector( + "#group-receiver-3[data-message='1234']" + ) + self.assertIsNotNone(receiver_1) + self.assertIsNotNone(receiver_2) + self.assertIsNotNone(receiver_3) + + finally: + new_page.close() diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index d15a2817..05acb163 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -31,6 +31,7 @@ path("", include("test_app.performance.urls")), path("", include("test_app.router.urls")), path("", include("test_app.offline.urls")), + path("", include("test_app.channel_layers.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), ]