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

ReactPy Channel Layers Test Page

+
+ {% component "test_app.channel_layers.components.receiver" %} +
+ {% component "test_app.channel_layers.components.sender" %} +
+ + + 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), ] From b4b705e0b546407fbd1ce99fa5d2f95194971755 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 00:20:14 -0800 Subject: [PATCH 03/19] group send/receive (broken) --- src/reactpy_django/hooks.py | 12 ++++- tests/test_app/channel_layers/components.py | 55 ++++++++++++++++++-- tests/test_app/templates/channel_layers.html | 8 +++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index e8e62e4b..5a53dde5 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -396,15 +396,25 @@ def use_channel_layer( @use_effect async def channel_listener(): + # Add the channel to the group, if defined + if group_name: + await layer.group_add(group_name, channel_name) + + # Only receive messages if a `receiver` is defined if not receiver: return + # Listen for messages on the channel while True: message = await layer.receive(channel_name) await receiver(message) async def send(message): - await layer.send(channel_name, message) + """Send a message to the channel layer.""" + if group_name: + await layer.group_send(group_name, message) + else: + await layer.send(channel_name, message) return send diff --git a/tests/test_app/channel_layers/components.py b/tests/test_app/channel_layers/components.py index f9165e5b..a1480653 100644 --- a/tests/test_app/channel_layers/components.py +++ b/tests/test_app/channel_layers/components.py @@ -1,3 +1,5 @@ +import asyncio + from reactpy import component, hooks, html from reactpy_django.hooks import use_channel_layer @@ -9,14 +11,16 @@ def receiver(): async def receiver(message): set_state(message["text"]) - use_channel_layer(channel_name="test_channel", receiver=receiver) + use_channel_layer("simple-messenger", receiver=receiver) - return html.div(f"Message Receiver: {state}") + return html.div( + {"id": "receiver", "data-message": state}, f"Message Receiver: {state}" + ) @component def sender(): - sender = use_channel_layer(channel_name="test_channel") + sender = use_channel_layer("simple-messenger") async def submit_event(event): if event["key"] == "Enter": @@ -25,6 +29,49 @@ async def submit_event(event): return html.div( "Message Sender: ", html.input( - {"type": "text", "onKeyDown": submit_event}, + {"type": "text", "id": "sender", "onKeyDown": submit_event}, + ), + ) + + +@component +def group_receiver(): + state, set_state = hooks.use_state("None") + + async def receiver(message): + set_state(message["text"]) + + use_channel_layer("group-messenger", group_name="1", receiver=receiver) + + return html.div(f"Group Message Receiver: {state}") + + +@component +def group_receiver_load_late(): + ready, set_ready = hooks.use_state(False) + + @hooks.use_effect + async def load_late(): + await asyncio.sleep(10) + set_ready(True) + + if ready: + return group_receiver() + + return html.div("Group Message Receiver (Late Load): Loading...") + + +@component +def group_sender(): + sender = use_channel_layer("group-messenger", group_name="1") + + 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/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html index c3697e2e..fd9e0fa9 100644 --- a/tests/test_app/templates/channel_layers.html +++ b/tests/test_app/templates/channel_layers.html @@ -17,6 +17,14 @@

ReactPy Channel Layers Test Page


{% component "test_app.channel_layers.components.sender" %}
+ {% component "test_app.channel_layers.components.group_receiver_load_late" %} +
+ {% component "test_app.channel_layers.components.group_receiver" %} +
+ {% component "test_app.channel_layers.components.group_receiver" %} +
+ {% component "test_app.channel_layers.components.group_sender" %} +
From a41abccb0a70add8e9a5c5bc6481cb72c0b333ad Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 00:32:32 -0800 Subject: [PATCH 04/19] register group within a separate effect --- src/reactpy_django/hooks.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 5a53dde5..abadb709 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -394,17 +394,18 @@ def use_channel_layer( " have settings.CHANNEL_LAYERS configured properly?" ) - @use_effect - async def channel_listener(): - # Add the channel to the group, if defined + @use_effect(dependencies=[]) + async def register_group(): + """Add the channel to the group, if defined""" if group_name: await layer.group_add(group_name, channel_name) - # Only receive messages if a `receiver` is defined + @use_effect + async def channel_listener(): + """Listen for messages on the channel""" if not receiver: return - # Listen for messages on the channel while True: message = await layer.receive(channel_name) await receiver(message) From a029d612db66014df235b10b3ac0e5d181526df8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 01:37:13 -0800 Subject: [PATCH 05/19] Functional group messaging capabilities --- src/reactpy_django/hooks.py | 38 +++++++++++--------- tests/test_app/channel_layers/components.py | 35 ++++++------------ tests/test_app/templates/channel_layers.html | 6 ++-- 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index abadb709..390f7977 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -13,12 +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 channels.layers import InMemoryChannelLayer, get_channel_layer -from reactpy import use_callback, use_effect, use_ref, use_state +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 @@ -367,26 +368,28 @@ async def _set_user_data(data: dict): def use_channel_layer( - channel_name: str, + name: str, receiver: AsyncMessageReceiver | None = None, - group_name: str | None = None, + group: bool = False, layer_name: str = DEFAULT_CHANNEL_LAYER, ) -> AsyncMessageSender: """ - Subscribe to a channel layer to send/receive messages. + 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 messages from the channel layer. \ 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. + will get the result (unless you configured `group=True`) + group: If True, a group channel will be used instead of a single channel. \ + This means that all subscribers to the group will receive the message. 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) + channel_name = use_memo(lambda: str(uuid4() if group else name)) + group_name = name if group else None if not layer: raise ValueError( @@ -395,14 +398,17 @@ def use_channel_layer( ) @use_effect(dependencies=[]) - async def register_group(): - """Add the channel to the group, if defined""" - if group_name: + async def group_manager(): + """Add/remove a group's channel during component mount/dismount respectively.""" + if group: await layer.group_add(group_name, channel_name) + # Group cleanup function + return lambda: asyncio.run(layer.group_discard(group_name, channel_name)) + @use_effect - async def channel_listener(): - """Listen for messages on the channel""" + async def message_receiver(): + """Listen for messages on the channel using the provided `receiver` function.""" if not receiver: return @@ -410,14 +416,14 @@ async def channel_listener(): message = await layer.receive(channel_name) await receiver(message) - async def send(message): + async def message_sender(message): """Send a message to the channel layer.""" - if group_name: + if group: await layer.group_send(group_name, message) else: await layer.send(channel_name, message) - return send + return message_sender def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs): diff --git a/tests/test_app/channel_layers/components.py b/tests/test_app/channel_layers/components.py index a1480653..5a0699ea 100644 --- a/tests/test_app/channel_layers/components.py +++ b/tests/test_app/channel_layers/components.py @@ -1,5 +1,3 @@ -import asyncio - from reactpy import component, hooks, html from reactpy_django.hooks import use_channel_layer @@ -11,16 +9,17 @@ def receiver(): async def receiver(message): set_state(message["text"]) - use_channel_layer("simple-messenger", receiver=receiver) + use_channel_layer("channel-messenger", receiver=receiver) return html.div( - {"id": "receiver", "data-message": state}, f"Message Receiver: {state}" + {"id": "receiver", "data-message": state}, + f"Message Receiver: {state}", ) @component def sender(): - sender = use_channel_layer("simple-messenger") + sender = use_channel_layer("channel-messenger") async def submit_event(event): if event["key"] == "Enter": @@ -35,35 +34,23 @@ async def submit_event(event): @component -def group_receiver(): +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", group_name="1", receiver=receiver) - - return html.div(f"Group Message Receiver: {state}") - - -@component -def group_receiver_load_late(): - ready, set_ready = hooks.use_state(False) + use_channel_layer("group-messenger", receiver=receiver, group=True) - @hooks.use_effect - async def load_late(): - await asyncio.sleep(10) - set_ready(True) - - if ready: - return group_receiver() - - return html.div("Group Message Receiver (Late Load): Loading...") + return html.div( + {"id": f"group-receiver-{id}", "data-message": state}, + f"Group Message Receiver: {state}", + ) @component def group_sender(): - sender = use_channel_layer("group-messenger", group_name="1") + sender = use_channel_layer("group-messenger", group=True) async def submit_event(event): if event["key"] == "Enter": diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html index fd9e0fa9..af3db04b 100644 --- a/tests/test_app/templates/channel_layers.html +++ b/tests/test_app/templates/channel_layers.html @@ -17,11 +17,11 @@

ReactPy Channel Layers Test Page


{% component "test_app.channel_layers.components.sender" %}
- {% component "test_app.channel_layers.components.group_receiver_load_late" %} + {% component "test_app.channel_layers.components.group_receiver" id=1 %}
- {% component "test_app.channel_layers.components.group_receiver" %} + {% component "test_app.channel_layers.components.group_receiver" id=2 %}
- {% component "test_app.channel_layers.components.group_receiver" %} + {% component "test_app.channel_layers.components.group_receiver" id=3 %}
{% component "test_app.channel_layers.components.group_sender" %}
From eb1b2c017154092ba188ab71a0c865ddde414b1a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 01:44:23 -0800 Subject: [PATCH 06/19] cleanup after self review --- src/reactpy_django/hooks.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 390f7977..f45221ba 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -371,7 +371,7 @@ def use_channel_layer( name: str, receiver: AsyncMessageReceiver | None = None, group: bool = False, - layer_name: str = DEFAULT_CHANNEL_LAYER, + layer: str = DEFAULT_CHANNEL_LAYER, ) -> AsyncMessageSender: """ Subscribe to a Django Channels layer to send/receive messages. @@ -383,28 +383,30 @@ def use_channel_layer( will get the result (unless you configured `group=True`) group: If True, a group channel will be used instead of a single channel. \ This means that all subscribers to the group will receive the message. - layer_name: The channel layer to use. These layers must be defined in \ + layer: 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) + 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 None - if not layer: + if not channel_layer: raise ValueError( - f"Channel layer {layer_name} is not available. Are you sure you" - " have settings.CHANNEL_LAYERS configured properly?" + f"Channel layer {layer} is not available. Are you sure you" + " have settings.py:CHANNEL_LAYERS configured properly?" ) @use_effect(dependencies=[]) async def group_manager(): """Add/remove a group's channel during component mount/dismount respectively.""" if group: - await layer.group_add(group_name, channel_name) + await channel_layer.group_add(group_name, channel_name) # Group cleanup function - return lambda: asyncio.run(layer.group_discard(group_name, channel_name)) + return lambda: asyncio.run( + channel_layer.group_discard(group_name, channel_name) + ) @use_effect async def message_receiver(): @@ -413,15 +415,15 @@ async def message_receiver(): return while True: - message = await layer.receive(channel_name) + message = await channel_layer.receive(channel_name) await receiver(message) async def message_sender(message): - """Send a message to the channel layer.""" + """Send a message to the channel or group.""" if group: - await layer.group_send(group_name, message) + await channel_layer.group_send(group_name, message) else: - await layer.send(channel_name, message) + await channel_layer.send(channel_name, message) return message_sender From dc4b4a8f2043365fb91cd40be8a1f775b65c6c15 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 01:45:45 -0800 Subject: [PATCH 07/19] more self-review cleanup --- src/reactpy_django/hooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index f45221ba..50e51e60 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -393,8 +393,8 @@ def use_channel_layer( if not channel_layer: raise ValueError( - f"Channel layer {layer} is not available. Are you sure you" - " have settings.py:CHANNEL_LAYERS configured properly?" + f"Channel layer '{layer}' is not available. Are you sure you" + " configured settings.py:CHANNEL_LAYERS properly?" ) @use_effect(dependencies=[]) From f0fd3c158344674b669b72f639c41fd0da9563b8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 02:10:29 -0800 Subject: [PATCH 08/19] add changelog item --- CHANGELOG.md | 4 +++- docs/src/about/changelog.md | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/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="" %} From 81041c70c47c4417de47d62d69726ae20ea5803d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 03:05:44 -0800 Subject: [PATCH 09/19] Remove todo --- src/reactpy_django/hooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 50e51e60..02ff936a 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -386,7 +386,6 @@ def use_channel_layer( layer: 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 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 None From d5a2ef8d9257c4b8440aac5b1cfa457603b377c4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:14:59 -0800 Subject: [PATCH 10/19] random docs cleanup --- docs/src/reference/hooks.md | 6 +++++- docs/src/reference/router.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 197b59be..a24f8f08 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" 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.

From cb9cf984f3df36ddc48ac9591b91f66f18070856 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:15:55 -0800 Subject: [PATCH 11/19] clean up comments and docstrings --- src/reactpy_django/hooks.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 02ff936a..9f1d20c9 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -380,15 +380,15 @@ def use_channel_layer( name: The name of the channel to subscribe to. receiver: An async function that receives messages from the channel layer. \ If more than one receiver waits on the same channel, a random one \ - will get the result (unless you configured `group=True`) - group: If True, a group channel will be used instead of a single channel. \ - This means that all subscribers to the group will receive the message. + 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 channel subscribers. layer: The channel layer to use. These layers must be defined in \ `settings.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 None + group_name = name if group else "" if not channel_layer: raise ValueError( @@ -396,20 +396,19 @@ def use_channel_layer( " configured settings.py:CHANNEL_LAYERS properly?" ) + # Add/remove a group's channel during component mount/dismount respectively. @use_effect(dependencies=[]) async def group_manager(): - """Add/remove a group's channel during component mount/dismount respectively.""" if group: await channel_layer.group_add(group_name, channel_name) - # Group cleanup function 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(): - """Listen for messages on the channel using the provided `receiver` function.""" if not receiver: return @@ -417,8 +416,8 @@ async def message_receiver(): message = await channel_layer.receive(channel_name) await receiver(message) + # User interface for sending messages to the channel async def message_sender(message): - """Send a message to the channel or group.""" if group: await channel_layer.group_send(group_name, message) else: From f0619a2a6a05b0db0494f57995b62498350ec144 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 20:45:56 -0800 Subject: [PATCH 12/19] explicitly define message type as dict --- src/reactpy_django/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 35d336e5..8efda72f 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -112,10 +112,10 @@ class UserData(NamedTuple): class AsyncMessageReceiver(Protocol): - async def __call__(self, message: Any) -> None: + async def __call__(self, message: dict) -> None: ... class AsyncMessageSender(Protocol): - async def __call__(self, message: Any) -> None: + async def __call__(self, message: dict) -> None: ... From 3bf8916380810ceb05a8ae5443eed8236a6f26d6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 20:58:38 -0800 Subject: [PATCH 13/19] add docs --- .../python/use-channel-layer-group.py | 40 ++++++++ .../use-channel-layer-signal-receiver.py | 14 +++ .../python/use-channel-layer-signal-sender.py | 20 ++++ docs/examples/python/use-channel-layer.py | 28 ++++++ docs/src/reference/hooks.md | 97 +++++++++++++++++++ src/reactpy_django/hooks.py | 8 +- 6 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 docs/examples/python/use-channel-layer-group.py create mode 100644 docs/examples/python/use-channel-layer-signal-receiver.py create mode 100644 docs/examples/python/use-channel-layer-signal-sender.py create mode 100644 docs/examples/python/use-channel-layer.py 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/reference/hooks.md b/docs/src/reference/hooks.md index a24f8f08..39e17e07 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -316,6 +316,103 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. --- +## 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 cross-process 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 to 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. + + Note that in the example below, the sender will signal on every time `#!python ExampleModel` is saved. Then, our receiver component must explicitly call `#!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/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 9f1d20c9..9aa5f2df 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -378,13 +378,13 @@ def use_channel_layer( Args: name: The name of the channel to subscribe to. - receiver: An async function that receives messages from the channel layer. \ + 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 channel subscribers. + group are broadcasted to all receivers on that channel. layer: The channel layer to use. These layers must be defined in \ - `settings.CHANNEL_LAYERS`. + `settings.py:CHANNEL_LAYERS`. """ channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer) channel_name = use_memo(lambda: str(uuid4() if group else name)) @@ -417,7 +417,7 @@ async def message_receiver(): await receiver(message) # User interface for sending messages to the channel - async def message_sender(message): + async def message_sender(message: dict): if group: await channel_layer.group_send(group_name, message) else: From 6fb0e10d230825badcb703b439a6d1a341dacd3a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 20:59:01 -0800 Subject: [PATCH 14/19] don't listen to empty channel names --- src/reactpy_django/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 9aa5f2df..96da2d10 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -409,7 +409,7 @@ async def group_manager(): # Listen for messages on the channel using the provided `receiver` function. @use_effect async def message_receiver(): - if not receiver: + if not receiver or not channel_name: return while True: From 2de7ecbc4023bec733282b664edf46b830c6f3c5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:01:34 -0800 Subject: [PATCH 15/19] add broadcasted to dictionary --- docs/src/dictionary.txt | 1 + 1 file changed, 1 insertion(+) 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 From 32d080c64283a8193971eb69aedbd8e100239ded Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:27:32 -0800 Subject: [PATCH 16/19] add ID to test receiver --- tests/test_app/channel_layers/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app/channel_layers/components.py b/tests/test_app/channel_layers/components.py index 5a0699ea..d9174b47 100644 --- a/tests/test_app/channel_layers/components.py +++ b/tests/test_app/channel_layers/components.py @@ -44,7 +44,7 @@ async def receiver(message): return html.div( {"id": f"group-receiver-{id}", "data-message": state}, - f"Group Message Receiver: {state}", + f"Group Message Receiver #{id}: {state}", ) From 54ce967eeb8a86ae7cf676dadc08343e673fb8ac Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 22:02:09 -0800 Subject: [PATCH 17/19] channel layer tests --- tests/test_app/tests/test_components.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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() From 4c4a6e50f196ec721beabd23ec33431875d9fa4f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 22:09:33 -0800 Subject: [PATCH 18/19] changes after self review --- README.md | 1 + docs/src/reference/hooks.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c640cc3..4c3d2dbd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - [Customizable reconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#stability-settings) - [Customizable disconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag) - [Multiple root components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/) +- [Cross-process communication/signaling (Channel Layers)](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) - [Django view to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#view-to-component) - [Django static file access](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-css) - [Django database access](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-query) diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 39e17e07..96fe0616 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -324,9 +324,9 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. Subscribe to a [Django Channels layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) to send/receive messages. -Layers are a cross-process communication system that allows you to send/receive messages between different parts of your application. +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 to signal re-renders from outside your components. +This is often used to create chat systems, synchronize data between components, or signal re-renders from outside your components. === "components.py" @@ -398,7 +398,7 @@ This is often used to create chat systems, synchronize data between components, 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. - Note that in the example below, the sender will signal on every time `#!python ExampleModel` is saved. Then, our receiver component must explicitly call `#!python set_message(...)` to trigger a re-render. + 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" From b5210504ab3d65a13d2d5c7b7ecea355e1680b82 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 31 Jan 2024 22:22:25 -0800 Subject: [PATCH 19/19] remove readme line item until features is released --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 4c3d2dbd..1c640cc3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,6 @@ - [Customizable reconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#stability-settings) - [Customizable disconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag) - [Multiple root components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/) -- [Cross-process communication/signaling (Channel Layers)](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) - [Django view to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#view-to-component) - [Django static file access](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-css) - [Django database access](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-query)