Skip to content

Commit 96a9f93

Browse files
authored
Cross-process communication via use_channel_layer hook (#221)
This PR develops a `use_channel_layer` hook which provides simplified Django channel layers support.
1 parent 9cacfc3 commit 96a9f93

21 files changed

+434
-4
lines changed

CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ Using the following categories, list your changes in this order:
3434

3535
## [Unreleased]
3636

37-
- Nothing (yet)!
37+
### Added
38+
39+
- Built-in cross-process communication mechanism via the `reactpy_django.hooks.use_channel_layer` hook.
3840

3941
## [3.7.0] - 2024-01-30
4042

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from reactpy import component, hooks, html
2+
from reactpy_django.hooks import use_channel_layer
3+
4+
5+
@component
6+
def my_sender_component():
7+
sender = use_channel_layer("my-channel-name", group=True)
8+
9+
async def submit_event(event):
10+
if event["key"] == "Enter":
11+
await sender({"text": event["target"]["value"]})
12+
13+
return html.div(
14+
"Message Sender: ",
15+
html.input({"type": "text", "onKeyDown": submit_event}),
16+
)
17+
18+
19+
@component
20+
def my_receiver_component_1():
21+
message, set_message = hooks.use_state("")
22+
23+
async def receive_event(message):
24+
set_message(message["text"])
25+
26+
use_channel_layer("my-channel-name", receiver=receive_event, group=True)
27+
28+
return html.div(f"Message Receiver 1: {message}")
29+
30+
31+
@component
32+
def my_receiver_component_2():
33+
message, set_message = hooks.use_state("")
34+
35+
async def receive_event(message):
36+
set_message(message["text"])
37+
38+
use_channel_layer("my-channel-name", receiver=receive_event, group=True)
39+
40+
return html.div(f"Message Receiver 2: {message}")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from reactpy import component, hooks, html
2+
from reactpy_django.hooks import use_channel_layer
3+
4+
5+
@component
6+
def my_receiver_component():
7+
message, set_message = hooks.use_state("")
8+
9+
async def receive_event(message):
10+
set_message(message["text"])
11+
12+
use_channel_layer("my-channel-name", receiver=receive_event)
13+
14+
return html.div(f"Message Receiver: {message}")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from asgiref.sync import async_to_sync
2+
from channels.layers import get_channel_layer
3+
from django.db.models import Model
4+
from django.db.models.signals import pre_save
5+
from django.dispatch import receiver
6+
7+
8+
class ExampleModel(Model):
9+
...
10+
11+
12+
@receiver(pre_save, sender=ExampleModel)
13+
def my_sender_signal(sender, instance, **kwargs):
14+
layer = get_channel_layer()
15+
16+
# Example of sending a message to a channel
17+
async_to_sync(layer.send)("my-channel-name", {"text": "Hello World!"})
18+
19+
# Example of sending a message to a group channel
20+
async_to_sync(layer.group_send)("my-channel-name", {"text": "Hello World!"})
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from reactpy import component, hooks, html
2+
from reactpy_django.hooks import use_channel_layer
3+
4+
5+
@component
6+
def my_sender_component():
7+
sender = use_channel_layer("my-channel-name")
8+
9+
async def submit_event(event):
10+
if event["key"] == "Enter":
11+
await sender({"text": event["target"]["value"]})
12+
13+
return html.div(
14+
"Message Sender: ",
15+
html.input({"type": "text", "onKeyDown": submit_event}),
16+
)
17+
18+
19+
@component
20+
def my_receiver_component():
21+
message, set_message = hooks.use_state("")
22+
23+
async def receive_event(message):
24+
set_message(message["text"])
25+
26+
use_channel_layer("my-channel-name", receiver=receive_event)
27+
28+
return html.div(f"Message Receiver: {message}")

docs/src/about/changelog.md

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ hide:
33
- toc
44
---
55

6+
<!--
7+
8+
If you see this page, you probably meant to visit the other CHANGELOG.md (all caps).
9+
10+
-->
11+
612
<p class="intro" markdown>
713

814
{% include-markdown "../../../CHANGELOG.md" start="<!--attr-start-->" end="<!--attr-end-->" %}

docs/src/dictionary.txt

+1
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ misconfiguration
3838
misconfigurations
3939
backhaul
4040
sublicense
41+
broadcasted

docs/src/reference/hooks.md

+102-1
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ Mutation functions can be sync or async.
275275

276276
### Use User Data
277277

278-
Store or retrieve data (`#!python dict`) specific to the connection's `#!python User`. This data is stored in the `#!python REACTPY_DATABASE`.
278+
Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`.
279+
280+
This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs.
281+
282+
User data saved with this hook is stored within the `#!python REACTPY_DATABASE`.
279283

280284
=== "components.py"
281285

@@ -312,6 +316,103 @@ Store or retrieve data (`#!python dict`) specific to the connection's `#!python
312316

313317
---
314318

319+
## Communication Hooks
320+
321+
---
322+
323+
### Use Channel Layer
324+
325+
Subscribe to a [Django Channels layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) to send/receive messages.
326+
327+
Layers are a multiprocessing-safe communication system that allows you to send/receive messages between different parts of your application.
328+
329+
This is often used to create chat systems, synchronize data between components, or signal re-renders from outside your components.
330+
331+
=== "components.py"
332+
333+
```python
334+
{% include "../../examples/python/use-channel-layer.py" %}
335+
```
336+
337+
??? example "See Interface"
338+
339+
<font size="4">**Parameters**</font>
340+
341+
| Name | Type | Description | Default |
342+
| --- | --- | --- | --- |
343+
| `#!python name` | `#!python str` | The name of the channel to subscribe to. | N/A |
344+
| `#!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` |
345+
| `#!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` |
346+
| `#!python layer` | `#!python str` | The channel layer to use. These layers must be defined in `#!python settings.py:CHANNEL_LAYERS`. | `#!python 'default'` |
347+
348+
<font size="4">**Returns**</font>
349+
350+
| Type | Description |
351+
| --- | --- |
352+
| `#!python AsyncMessageSender` | An async callable that can send a `#!python message: dict` |
353+
354+
??? warning "Extra Django configuration required"
355+
356+
In order to use this hook, you will need to configure Django to enable channel layers.
357+
358+
The [Django Channels documentation](https://channels.readthedocs.io/en/latest/topics/channel_layers.html#configuration) has information on what steps you need to take.
359+
360+
In summary, you will need to:
361+
362+
1. Run the following command to install `channels-redis` in your Python environment.
363+
364+
```bash linenums="0"
365+
pip install channels-redis
366+
```
367+
368+
2. Configure your `settings.py` to use `RedisChannelLayer` as your layer backend.
369+
370+
```python linenums="0"
371+
CHANNEL_LAYERS = {
372+
"default": {
373+
"BACKEND": "channels_redis.core.RedisChannelLayer",
374+
"CONFIG": {
375+
"hosts": [("127.0.0.1", 6379)],
376+
},
377+
},
378+
}
379+
```
380+
381+
??? question "How do I broadcast a message to multiple components?"
382+
383+
By default, if more than one receiver waits on the same channel, a random one will get the result.
384+
385+
However, by defining `#!python group=True` you can configure a "group channel", which will broadcast messages to all receivers.
386+
387+
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).
388+
389+
=== "components.py"
390+
391+
```python
392+
{% include "../../examples/python/use-channel-layer-group.py" %}
393+
```
394+
395+
??? question "How do I signal a re-render from something that isn't a component?"
396+
397+
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.
398+
399+
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.
400+
401+
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.
402+
403+
=== "components.py"
404+
405+
```python
406+
{% include "../../examples/python/use-channel-layer-signal-receiver.py" %}
407+
```
408+
=== "signals.py"
409+
410+
```python
411+
{% include "../../examples/python/use-channel-layer-signal-sender.py" %}
412+
```
413+
414+
---
415+
315416
## Connection Hooks
316417

317418
---

docs/src/reference/router.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<p class="intro" markdown>
44

5-
A variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that utilizes Django conventions.
5+
A Single Page Application URL router, which is a variant of [`reactpy-router`](https://github.com/reactive-python/reactpy-router) that uses Django conventions.
66

77
</p>
88

requirements/check-types.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
mypy
22
django-stubs[compatible-mypy]
3+
channels-redis

src/reactpy_django/hooks.py

+66-1
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,22 @@
1313
cast,
1414
overload,
1515
)
16+
from uuid import uuid4
1617

1718
import orjson as pickle
19+
from channels import DEFAULT_CHANNEL_LAYER
1820
from channels.db import database_sync_to_async
19-
from reactpy import use_callback, use_effect, use_ref, use_state
21+
from channels.layers import InMemoryChannelLayer, get_channel_layer
22+
from reactpy import use_callback, use_effect, use_memo, use_ref, use_state
2023
from reactpy import use_connection as _use_connection
2124
from reactpy import use_location as _use_location
2225
from reactpy import use_scope as _use_scope
2326
from reactpy.backend.types import Location
2427

2528
from reactpy_django.exceptions import UserNotFoundError
2629
from reactpy_django.types import (
30+
AsyncMessageReceiver,
31+
AsyncMessageSender,
2732
ConnectionType,
2833
FuncParams,
2934
Inferred,
@@ -36,6 +41,7 @@
3641
from reactpy_django.utils import generate_obj_name, get_user_pk
3742

3843
if TYPE_CHECKING:
44+
from channels_redis.core import RedisChannelLayer
3945
from django.contrib.auth.models import AbstractUser
4046

4147

@@ -361,6 +367,65 @@ async def _set_user_data(data: dict):
361367
return UserData(query, mutation)
362368

363369

370+
def use_channel_layer(
371+
name: str,
372+
receiver: AsyncMessageReceiver | None = None,
373+
group: bool = False,
374+
layer: str = DEFAULT_CHANNEL_LAYER,
375+
) -> AsyncMessageSender:
376+
"""
377+
Subscribe to a Django Channels layer to send/receive messages.
378+
379+
Args:
380+
name: The name of the channel to subscribe to.
381+
receiver: An async function that receives a `message: dict` from the channel layer. \
382+
If more than one receiver waits on the same channel, a random one \
383+
will get the result (unless `group=True` is defined).
384+
group: If `True`, a "group channel" will be used. Messages sent within a \
385+
group are broadcasted to all receivers on that channel.
386+
layer: The channel layer to use. These layers must be defined in \
387+
`settings.py:CHANNEL_LAYERS`.
388+
"""
389+
channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer)
390+
channel_name = use_memo(lambda: str(uuid4() if group else name))
391+
group_name = name if group else ""
392+
393+
if not channel_layer:
394+
raise ValueError(
395+
f"Channel layer '{layer}' is not available. Are you sure you"
396+
" configured settings.py:CHANNEL_LAYERS properly?"
397+
)
398+
399+
# Add/remove a group's channel during component mount/dismount respectively.
400+
@use_effect(dependencies=[])
401+
async def group_manager():
402+
if group:
403+
await channel_layer.group_add(group_name, channel_name)
404+
405+
return lambda: asyncio.run(
406+
channel_layer.group_discard(group_name, channel_name)
407+
)
408+
409+
# Listen for messages on the channel using the provided `receiver` function.
410+
@use_effect
411+
async def message_receiver():
412+
if not receiver or not channel_name:
413+
return
414+
415+
while True:
416+
message = await channel_layer.receive(channel_name)
417+
await receiver(message)
418+
419+
# User interface for sending messages to the channel
420+
async def message_sender(message: dict):
421+
if group:
422+
await channel_layer.group_send(group_name, message)
423+
else:
424+
await channel_layer.send(channel_name, message)
425+
426+
return message_sender
427+
428+
364429
def _use_query_args_1(options: QueryOptions, /, query: Query, *args, **kwargs):
365430
return options, query, args, kwargs
366431

src/reactpy_django/types.py

+10
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,13 @@ class ComponentParams:
109109
class UserData(NamedTuple):
110110
query: Query[dict | None]
111111
mutation: Mutation[dict]
112+
113+
114+
class AsyncMessageReceiver(Protocol):
115+
async def __call__(self, message: dict) -> None:
116+
...
117+
118+
119+
class AsyncMessageSender(Protocol):
120+
async def __call__(self, message: dict) -> None:
121+
...

tests/test_app/channel_layers/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)