Skip to content

Cross-process communication via use_channel_layer hook #221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions docs/examples/python/use-channel-layer-group.py
Original file line number Diff line number Diff line change
@@ -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}")
14 changes: 14 additions & 0 deletions docs/examples/python/use-channel-layer-signal-receiver.py
Original file line number Diff line number Diff line change
@@ -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}")
20 changes: 20 additions & 0 deletions docs/examples/python/use-channel-layer-signal-sender.py
Original file line number Diff line number Diff line change
@@ -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!"})
28 changes: 28 additions & 0 deletions docs/examples/python/use-channel-layer.py
Original file line number Diff line number Diff line change
@@ -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}")
6 changes: 6 additions & 0 deletions docs/src/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ hide:
- toc
---

<!--

If you see this page, you probably meant to visit the other CHANGELOG.md (all caps).

-->

<p class="intro" markdown>

{% include-markdown "../../../CHANGELOG.md" start="<!--attr-start-->" end="<!--attr-end-->" %}
Expand Down
1 change: 1 addition & 0 deletions docs/src/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ misconfiguration
misconfigurations
backhaul
sublicense
broadcasted
103 changes: 102 additions & 1 deletion docs/src/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

<font size="4">**Parameters**</font>

| 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'` |

<font size="4">**Returns**</font>

| 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

---
Expand Down
2 changes: 1 addition & 1 deletion docs/src/reference/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p class="intro" markdown>

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.

</p>

Expand Down
1 change: 1 addition & 0 deletions requirements/check-types.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mypy
django-stubs[compatible-mypy]
channels-redis
67 changes: 66 additions & 1 deletion src/reactpy_django/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,22 @@
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
from reactpy.backend.types import Location

from reactpy_django.exceptions import UserNotFoundError
from reactpy_django.types import (
AsyncMessageReceiver,
AsyncMessageSender,
ConnectionType,
FuncParams,
Inferred,
Expand All @@ -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


Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/reactpy_django/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
...
Empty file.
Loading