From bca5ac3811f0a90b1da87804ca064d869b9992bc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 30 Jan 2024 23:15:05 -0800 Subject: [PATCH 01/19] Prototype for `use_channel_layer` --- requirements/check-types.txt | 1 + src/reactpy_django/hooks.py | 41 ++++++++++++++++++++++++++++ src/reactpy_django/types.py | 10 +++++++ tests/test_app/settings_multi_db.py | 3 ++ tests/test_app/settings_single_db.py | 3 ++ 5 files changed, 58 insertions(+) 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..02449b39 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -15,7 +15,9 @@ ) import orjson as pickle +from channels import DEFAULT_CHANNEL_LAYER from channels.db import database_sync_to_async +from channels.layers import InMemoryChannelLayer, get_channel_layer from reactpy import use_callback, use_effect, use_ref, use_state from reactpy import use_connection as _use_connection from reactpy import use_location as _use_location @@ -24,6 +26,8 @@ from reactpy_django.exceptions import UserNotFoundError from reactpy_django.types import ( + AsyncMessageReceiver, + AsyncMessageSender, ConnectionType, FuncParams, Inferred, @@ -36,6 +40,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 +366,42 @@ async def _set_user_data(data: dict): return UserData(query, mutation) +def use_channel_layer( + receiver: AsyncMessageReceiver, + channel_name: str, + layer_name: str = DEFAULT_CHANNEL_LAYER, +) -> AsyncMessageSender: + """ + Subscribe to a channel layer to send/receive messages. + + Args: + receiver: An async function that receives messages from the channel layer. \ + If more than one receivers wait on the same channel, a random one \ + will get the result. + channel_name: The name of the channel to subscribe to. + layer_name: The channel layer to use. These layers must be defined in \ + `settings.CHANNEL_LAYERS`. + """ + layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer_name) + + if not layer: + raise ValueError( + f"Channel layer {layer_name} is not available. Are you sure you" + " have settings.CHANNEL_LAYERS configured properly?" + ) + + @use_effect + async def channel_listener(): + while True: + message = await layer.receive(channel_name) + await receiver(message) + + async def send(message): + await layer.send(channel_name, message) + + return send + + 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..35d336e5 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: Any) -> None: + ... + + +class AsyncMessageSender(Protocol): + async def __call__(self, message: Any) -> None: + ... 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 From 3ab3c85986ff6ef1be3fe1055291e54e88605a16 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 30 Jan 2024 23:58:01 -0800 Subject: [PATCH 02/19] functional test component --- src/reactpy_django/hooks.py | 13 +++++++-- tests/test_app/channel_layers/__init__.py | 0 tests/test_app/channel_layers/components.py | 30 ++++++++++++++++++++ tests/test_app/channel_layers/urls.py | 7 +++++ tests/test_app/channel_layers/views.py | 5 ++++ tests/test_app/templates/channel_layers.html | 22 ++++++++++++++ tests/test_app/urls.py | 1 + 7 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 tests/test_app/channel_layers/__init__.py create mode 100644 tests/test_app/channel_layers/components.py create mode 100644 tests/test_app/channel_layers/urls.py create mode 100644 tests/test_app/channel_layers/views.py create mode 100644 tests/test_app/templates/channel_layers.html diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 02449b39..e8e62e4b 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -367,8 +367,9 @@ async def _set_user_data(data: dict): def use_channel_layer( - receiver: AsyncMessageReceiver, channel_name: str, + receiver: AsyncMessageReceiver | None = None, + group_name: str | None = None, layer_name: str = DEFAULT_CHANNEL_LAYER, ) -> AsyncMessageSender: """ @@ -376,12 +377,15 @@ def use_channel_layer( Args: receiver: An async function that receives messages from the channel layer. \ - If more than one receivers wait on the same channel, a random one \ - will get the result. + If more than one receiver waits on the same channel, a random one \ + will get the result (unless a `group_name` is defined). channel_name: The name of the channel to subscribe to. + group_name: The name of the group to subscribe to. If defined, the `receiver` \ + will be called for every message in the group channel's message history. layer_name: The channel layer to use. These layers must be defined in \ `settings.CHANNEL_LAYERS`. """ + # TODO: See if it's better to get the `channel_layer` from the websocket connection layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer_name) if not layer: @@ -392,6 +396,9 @@ def use_channel_layer( @use_effect async def channel_listener(): + if not receiver: + return + while True: message = await layer.receive(channel_name) await receiver(message) 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..f9165e5b --- /dev/null +++ b/tests/test_app/channel_layers/components.py @@ -0,0 +1,30 @@ +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_name="test_channel", receiver=receiver) + + return html.div(f"Message Receiver: {state}") + + +@component +def sender(): + sender = use_channel_layer(channel_name="test_channel") + + 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}, + ), + ) 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/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html new file mode 100644 index 00000000..c3697e2e --- /dev/null +++ b/tests/test_app/templates/channel_layers.html @@ -0,0 +1,22 @@ +{% load static %} {% load reactpy %} + + + +
+ + + + +