broadcaster-ng 0.4.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- broadcaster_ng-0.4.0.dev0/.gitignore +15 -0
- broadcaster_ng-0.4.0.dev0/LICENSE.md +28 -0
- broadcaster_ng-0.4.0.dev0/PKG-INFO +172 -0
- broadcaster_ng-0.4.0.dev0/README.md +137 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/__init__.py +5 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_backends/__init__.py +0 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_backends/base.py +3 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_backends/kafka.py +3 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_backends/memory.py +3 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_backends/postgres.py +3 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_backends/redis.py +3 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/_base.py +125 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/backends/__init__.py +0 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/backends/base.py +26 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/backends/kafka.py +57 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/backends/memory.py +34 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/backends/postgres.py +36 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/backends/redis.py +123 -0
- broadcaster_ng-0.4.0.dev0/broadcaster/py.typed +0 -0
- broadcaster_ng-0.4.0.dev0/pyproject.toml +90 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Copyright © 2020, [Encode OSS Ltd](https://www.encode.io/).
|
|
2
|
+
Copyright © 2026, Qing Wan.
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
* Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: broadcaster-ng
|
|
3
|
+
Version: 0.4.0.dev0
|
|
4
|
+
Summary: Simple broadcast channels. Maintained fork of encode/broadcaster with connection-loss fixes.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Redevil10/broadcaster-ng
|
|
6
|
+
Project-URL: Issues, https://github.com/Redevil10/broadcaster-ng/issues
|
|
7
|
+
Project-URL: Upstream, https://github.com/encode/broadcaster
|
|
8
|
+
Author-email: Tom Christie <tom@tomchristie.com>, Qing Wan <juveseason@gmail.com>
|
|
9
|
+
Maintainer-email: Qing Wan <juveseason@gmail.com>
|
|
10
|
+
License-Expression: BSD-3-Clause
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: anyio<5,>=3.4.0
|
|
25
|
+
Provides-Extra: kafka
|
|
26
|
+
Requires-Dist: aiokafka; extra == 'kafka'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: asyncpg; extra == 'postgres'
|
|
29
|
+
Provides-Extra: redis
|
|
30
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# Broadcaster (broadcaster-ng)
|
|
37
|
+
|
|
38
|
+
> **Maintained fork of [encode/broadcaster](https://github.com/encode/broadcaster)**,
|
|
39
|
+
> which is archived. Same API, same import name: replace `broadcaster` with
|
|
40
|
+
> `broadcaster-ng` in your requirements and your code keeps working.
|
|
41
|
+
>
|
|
42
|
+
> - Install: `pip install "broadcaster-ng[redis]"` (or `[postgres]`, `[kafka]`).
|
|
43
|
+
> - `broadcaster-ng` and the original `broadcaster` both install the `broadcaster`
|
|
44
|
+
> package, so they **cannot be installed together**. Uninstall `broadcaster`
|
|
45
|
+
> first, and check that none of your dependencies pulls it back in.
|
|
46
|
+
> - Focus: reliable connection-loss handling (reconnect, re-subscribe,
|
|
47
|
+
> half-open detection) for the Redis pub/sub and Postgres backends. Redis
|
|
48
|
+
> Streams and Kafka are kept as is.
|
|
49
|
+
> - Status: in development, not yet released.
|
|
50
|
+
|
|
51
|
+
Broadcaster helps you develop realtime streaming functionality by providing
|
|
52
|
+
a simple broadcast API onto a number of different backend services.
|
|
53
|
+
|
|
54
|
+
It currently supports [Redis PUB/SUB](https://redis.io/topics/pubsub), [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/), [Apache Kafka](https://kafka.apache.org/), and [Postgres LISTEN/NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html), plus a simple in-memory backend, that you can use for local development or during testing.
|
|
55
|
+
|
|
56
|
+
<img src="https://raw.githubusercontent.com/encode/broadcaster/master/docs/demo.gif" alt='WebSockets Demo'>
|
|
57
|
+
|
|
58
|
+
Here's a complete example of the backend code for a simple websocket chat app:
|
|
59
|
+
|
|
60
|
+
**app.py**
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# Requires: `starlette`, `uvicorn`, `jinja2`
|
|
64
|
+
# Run with `uvicorn example:app`
|
|
65
|
+
import anyio
|
|
66
|
+
from broadcaster import Broadcast
|
|
67
|
+
from starlette.applications import Starlette
|
|
68
|
+
from starlette.routing import Route, WebSocketRoute
|
|
69
|
+
from starlette.templating import Jinja2Templates
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
broadcast = Broadcast("redis://localhost:6379")
|
|
73
|
+
templates = Jinja2Templates("templates")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def homepage(request):
|
|
77
|
+
template = "index.html"
|
|
78
|
+
context = {"request": request}
|
|
79
|
+
return templates.TemplateResponse(template, context)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def chatroom_ws(websocket):
|
|
83
|
+
await websocket.accept()
|
|
84
|
+
|
|
85
|
+
async with anyio.create_task_group() as task_group:
|
|
86
|
+
# run until first is complete
|
|
87
|
+
async def run_chatroom_ws_receiver() -> None:
|
|
88
|
+
await chatroom_ws_receiver(websocket=websocket)
|
|
89
|
+
task_group.cancel_scope.cancel()
|
|
90
|
+
|
|
91
|
+
task_group.start_soon(run_chatroom_ws_receiver)
|
|
92
|
+
await chatroom_ws_sender(websocket)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def chatroom_ws_receiver(websocket):
|
|
96
|
+
async for message in websocket.iter_text():
|
|
97
|
+
await broadcast.publish(channel="chatroom", message=message)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def chatroom_ws_sender(websocket):
|
|
101
|
+
async with broadcast.subscribe(channel="chatroom") as subscriber:
|
|
102
|
+
async for event in subscriber:
|
|
103
|
+
await websocket.send_text(event.message)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
routes = [
|
|
107
|
+
Route("/", homepage),
|
|
108
|
+
WebSocketRoute("/", chatroom_ws, name='chatroom_ws'),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
app = Starlette(
|
|
113
|
+
routes=routes, on_startup=[broadcast.connect], on_shutdown=[broadcast.disconnect],
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The HTML template for the front end [is available here](https://github.com/encode/broadcaster/blob/master/example/templates/index.html), and is adapted from [Pieter Noordhuis's PUB/SUB demo](https://gist.github.com/pietern/348262).
|
|
118
|
+
|
|
119
|
+
## Requirements
|
|
120
|
+
|
|
121
|
+
Python 3.8+
|
|
122
|
+
|
|
123
|
+
## Installation
|
|
124
|
+
|
|
125
|
+
* `pip install broadcaster`
|
|
126
|
+
* `pip install broadcaster[redis]`
|
|
127
|
+
* `pip install broadcaster[postgres]`
|
|
128
|
+
* `pip install broadcaster[kafka]`
|
|
129
|
+
|
|
130
|
+
## Available backends
|
|
131
|
+
|
|
132
|
+
* `Broadcast('memory://')`
|
|
133
|
+
* `Broadcast("redis://localhost:6379")`
|
|
134
|
+
* `Broadcast("redis-stream://localhost:6379")`
|
|
135
|
+
* `Broadcast("postgres://localhost:5432/broadcaster")`
|
|
136
|
+
* `Broadcast("kafka://localhost:9092")`
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
### Using custom backends
|
|
140
|
+
|
|
141
|
+
You can create your own backend and use it with `broadcaster`.
|
|
142
|
+
To do that you need to create a class which extends from `BroadcastBackend`
|
|
143
|
+
and pass it to the `broadcaster` via `backend` argument.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from broadcaster import Broadcaster, BroadcastBackend
|
|
147
|
+
|
|
148
|
+
class MyBackend(BroadcastBackend):
|
|
149
|
+
|
|
150
|
+
broadcaster = Broadcaster(backend=MyBackend())
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Where next?
|
|
154
|
+
|
|
155
|
+
At the moment `broadcaster` is in Alpha, and should be considered a working design document.
|
|
156
|
+
|
|
157
|
+
The API should be considered subject to change. If you *do* want to use Broadcaster in its current
|
|
158
|
+
state, make sure to strictly pin your requirements to `broadcaster==0.3.0`.
|
|
159
|
+
|
|
160
|
+
To be more capable we'd really want to add some additional backends, provide API support for reading recent event history from persistent stores, and provide a serialization/deserialization API...
|
|
161
|
+
|
|
162
|
+
* Serialization / deserialization to support broadcasting structured data.
|
|
163
|
+
* A backend for RabbitMQ.
|
|
164
|
+
* Add support for `subscribe('chatroom', history=100)` for backends which provide persistence. (Redis Streams, Apache Kafka) This will allow applications to subscribe to channel updates, while also being given an initial window onto the most recent events. We *might* also want to support some basic paging operations, to allow applications to scan back in the event history.
|
|
165
|
+
* Support for pattern subscribes in backends that support it.
|
|
166
|
+
|
|
167
|
+
## Third Party Packages
|
|
168
|
+
|
|
169
|
+
### MQTT backend
|
|
170
|
+
[Gist](https://gist.github.com/alex-oleshkevich/68411a0e7ad24d53afd28c3fa5da468c)
|
|
171
|
+
|
|
172
|
+
Integrates MQTT with Broadcaster
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Broadcaster (broadcaster-ng)
|
|
2
|
+
|
|
3
|
+
> **Maintained fork of [encode/broadcaster](https://github.com/encode/broadcaster)**,
|
|
4
|
+
> which is archived. Same API, same import name: replace `broadcaster` with
|
|
5
|
+
> `broadcaster-ng` in your requirements and your code keeps working.
|
|
6
|
+
>
|
|
7
|
+
> - Install: `pip install "broadcaster-ng[redis]"` (or `[postgres]`, `[kafka]`).
|
|
8
|
+
> - `broadcaster-ng` and the original `broadcaster` both install the `broadcaster`
|
|
9
|
+
> package, so they **cannot be installed together**. Uninstall `broadcaster`
|
|
10
|
+
> first, and check that none of your dependencies pulls it back in.
|
|
11
|
+
> - Focus: reliable connection-loss handling (reconnect, re-subscribe,
|
|
12
|
+
> half-open detection) for the Redis pub/sub and Postgres backends. Redis
|
|
13
|
+
> Streams and Kafka are kept as is.
|
|
14
|
+
> - Status: in development, not yet released.
|
|
15
|
+
|
|
16
|
+
Broadcaster helps you develop realtime streaming functionality by providing
|
|
17
|
+
a simple broadcast API onto a number of different backend services.
|
|
18
|
+
|
|
19
|
+
It currently supports [Redis PUB/SUB](https://redis.io/topics/pubsub), [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/), [Apache Kafka](https://kafka.apache.org/), and [Postgres LISTEN/NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html), plus a simple in-memory backend, that you can use for local development or during testing.
|
|
20
|
+
|
|
21
|
+
<img src="https://raw.githubusercontent.com/encode/broadcaster/master/docs/demo.gif" alt='WebSockets Demo'>
|
|
22
|
+
|
|
23
|
+
Here's a complete example of the backend code for a simple websocket chat app:
|
|
24
|
+
|
|
25
|
+
**app.py**
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# Requires: `starlette`, `uvicorn`, `jinja2`
|
|
29
|
+
# Run with `uvicorn example:app`
|
|
30
|
+
import anyio
|
|
31
|
+
from broadcaster import Broadcast
|
|
32
|
+
from starlette.applications import Starlette
|
|
33
|
+
from starlette.routing import Route, WebSocketRoute
|
|
34
|
+
from starlette.templating import Jinja2Templates
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
broadcast = Broadcast("redis://localhost:6379")
|
|
38
|
+
templates = Jinja2Templates("templates")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def homepage(request):
|
|
42
|
+
template = "index.html"
|
|
43
|
+
context = {"request": request}
|
|
44
|
+
return templates.TemplateResponse(template, context)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def chatroom_ws(websocket):
|
|
48
|
+
await websocket.accept()
|
|
49
|
+
|
|
50
|
+
async with anyio.create_task_group() as task_group:
|
|
51
|
+
# run until first is complete
|
|
52
|
+
async def run_chatroom_ws_receiver() -> None:
|
|
53
|
+
await chatroom_ws_receiver(websocket=websocket)
|
|
54
|
+
task_group.cancel_scope.cancel()
|
|
55
|
+
|
|
56
|
+
task_group.start_soon(run_chatroom_ws_receiver)
|
|
57
|
+
await chatroom_ws_sender(websocket)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def chatroom_ws_receiver(websocket):
|
|
61
|
+
async for message in websocket.iter_text():
|
|
62
|
+
await broadcast.publish(channel="chatroom", message=message)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def chatroom_ws_sender(websocket):
|
|
66
|
+
async with broadcast.subscribe(channel="chatroom") as subscriber:
|
|
67
|
+
async for event in subscriber:
|
|
68
|
+
await websocket.send_text(event.message)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
routes = [
|
|
72
|
+
Route("/", homepage),
|
|
73
|
+
WebSocketRoute("/", chatroom_ws, name='chatroom_ws'),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
app = Starlette(
|
|
78
|
+
routes=routes, on_startup=[broadcast.connect], on_shutdown=[broadcast.disconnect],
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The HTML template for the front end [is available here](https://github.com/encode/broadcaster/blob/master/example/templates/index.html), and is adapted from [Pieter Noordhuis's PUB/SUB demo](https://gist.github.com/pietern/348262).
|
|
83
|
+
|
|
84
|
+
## Requirements
|
|
85
|
+
|
|
86
|
+
Python 3.8+
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
* `pip install broadcaster`
|
|
91
|
+
* `pip install broadcaster[redis]`
|
|
92
|
+
* `pip install broadcaster[postgres]`
|
|
93
|
+
* `pip install broadcaster[kafka]`
|
|
94
|
+
|
|
95
|
+
## Available backends
|
|
96
|
+
|
|
97
|
+
* `Broadcast('memory://')`
|
|
98
|
+
* `Broadcast("redis://localhost:6379")`
|
|
99
|
+
* `Broadcast("redis-stream://localhost:6379")`
|
|
100
|
+
* `Broadcast("postgres://localhost:5432/broadcaster")`
|
|
101
|
+
* `Broadcast("kafka://localhost:9092")`
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
### Using custom backends
|
|
105
|
+
|
|
106
|
+
You can create your own backend and use it with `broadcaster`.
|
|
107
|
+
To do that you need to create a class which extends from `BroadcastBackend`
|
|
108
|
+
and pass it to the `broadcaster` via `backend` argument.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from broadcaster import Broadcaster, BroadcastBackend
|
|
112
|
+
|
|
113
|
+
class MyBackend(BroadcastBackend):
|
|
114
|
+
|
|
115
|
+
broadcaster = Broadcaster(backend=MyBackend())
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Where next?
|
|
119
|
+
|
|
120
|
+
At the moment `broadcaster` is in Alpha, and should be considered a working design document.
|
|
121
|
+
|
|
122
|
+
The API should be considered subject to change. If you *do* want to use Broadcaster in its current
|
|
123
|
+
state, make sure to strictly pin your requirements to `broadcaster==0.3.0`.
|
|
124
|
+
|
|
125
|
+
To be more capable we'd really want to add some additional backends, provide API support for reading recent event history from persistent stores, and provide a serialization/deserialization API...
|
|
126
|
+
|
|
127
|
+
* Serialization / deserialization to support broadcasting structured data.
|
|
128
|
+
* A backend for RabbitMQ.
|
|
129
|
+
* Add support for `subscribe('chatroom', history=100)` for backends which provide persistence. (Redis Streams, Apache Kafka) This will allow applications to subscribe to channel updates, while also being given an initial window onto the most recent events. We *might* also want to support some basic paging operations, to allow applications to scan back in the event history.
|
|
130
|
+
* Support for pattern subscribes in backends that support it.
|
|
131
|
+
|
|
132
|
+
## Third Party Packages
|
|
133
|
+
|
|
134
|
+
### MQTT backend
|
|
135
|
+
[Gist](https://gist.github.com/alex-oleshkevich/68411a0e7ad24d53afd28c3fa5da468c)
|
|
136
|
+
|
|
137
|
+
Integrates MQTT with Broadcaster
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
10
|
+
from broadcaster.backends.base import BroadcastBackend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Event:
|
|
14
|
+
def __init__(self, channel: str, message: str) -> None:
|
|
15
|
+
self.channel = channel
|
|
16
|
+
self.message = message
|
|
17
|
+
|
|
18
|
+
def __eq__(self, other: object) -> bool:
|
|
19
|
+
return isinstance(other, Event) and self.channel == other.channel and self.message == other.message
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"Event(channel={self.channel!r}, message={self.message!r})"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Unsubscribed(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Broadcast:
|
|
30
|
+
def __init__(self, url: str | None = None, *, backend: BroadcastBackend | None = None) -> None:
|
|
31
|
+
assert url or backend, "Either `url` or `backend` must be provided."
|
|
32
|
+
self._backend = backend or self._create_backend(cast(str, url))
|
|
33
|
+
self._subscribers: dict[str, set[asyncio.Queue[Event | None]]] = {}
|
|
34
|
+
|
|
35
|
+
def _create_backend(self, url: str) -> BroadcastBackend:
|
|
36
|
+
parsed_url = urlparse(url)
|
|
37
|
+
if parsed_url.scheme in ("redis", "rediss"):
|
|
38
|
+
from broadcaster.backends.redis import RedisBackend
|
|
39
|
+
|
|
40
|
+
return RedisBackend(url)
|
|
41
|
+
|
|
42
|
+
elif parsed_url.scheme == "redis-stream":
|
|
43
|
+
from broadcaster.backends.redis import RedisStreamBackend
|
|
44
|
+
|
|
45
|
+
return RedisStreamBackend(url)
|
|
46
|
+
|
|
47
|
+
elif parsed_url.scheme in ("postgres", "postgresql"):
|
|
48
|
+
from broadcaster.backends.postgres import PostgresBackend
|
|
49
|
+
|
|
50
|
+
return PostgresBackend(url)
|
|
51
|
+
|
|
52
|
+
if parsed_url.scheme == "kafka":
|
|
53
|
+
from broadcaster.backends.kafka import KafkaBackend
|
|
54
|
+
|
|
55
|
+
return KafkaBackend(url)
|
|
56
|
+
|
|
57
|
+
elif parsed_url.scheme == "memory":
|
|
58
|
+
from broadcaster.backends.memory import MemoryBackend
|
|
59
|
+
|
|
60
|
+
return MemoryBackend(url)
|
|
61
|
+
raise ValueError(f"Unsupported backend: {parsed_url.scheme}")
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> Broadcast:
|
|
64
|
+
await self.connect()
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
|
|
68
|
+
await self.disconnect()
|
|
69
|
+
|
|
70
|
+
async def connect(self) -> None:
|
|
71
|
+
await self._backend.connect()
|
|
72
|
+
self._listener_task = asyncio.create_task(self._listener())
|
|
73
|
+
|
|
74
|
+
async def disconnect(self) -> None:
|
|
75
|
+
if self._listener_task.done():
|
|
76
|
+
self._listener_task.result()
|
|
77
|
+
else:
|
|
78
|
+
self._listener_task.cancel()
|
|
79
|
+
await self._backend.disconnect()
|
|
80
|
+
|
|
81
|
+
async def _listener(self) -> None:
|
|
82
|
+
while True:
|
|
83
|
+
event = await self._backend.next_published()
|
|
84
|
+
for queue in list(self._subscribers.get(event.channel, [])):
|
|
85
|
+
await queue.put(event)
|
|
86
|
+
|
|
87
|
+
async def publish(self, channel: str, message: Any) -> None:
|
|
88
|
+
await self._backend.publish(channel, message)
|
|
89
|
+
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def subscribe(self, channel: str) -> AsyncIterator[Subscriber]:
|
|
92
|
+
queue: asyncio.Queue[Event | None] = asyncio.Queue()
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
if not self._subscribers.get(channel):
|
|
96
|
+
await self._backend.subscribe(channel)
|
|
97
|
+
self._subscribers[channel] = {queue}
|
|
98
|
+
else:
|
|
99
|
+
self._subscribers[channel].add(queue)
|
|
100
|
+
|
|
101
|
+
yield Subscriber(queue)
|
|
102
|
+
finally:
|
|
103
|
+
self._subscribers[channel].remove(queue)
|
|
104
|
+
if not self._subscribers.get(channel):
|
|
105
|
+
del self._subscribers[channel]
|
|
106
|
+
await self._backend.unsubscribe(channel)
|
|
107
|
+
await queue.put(None)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Subscriber:
|
|
111
|
+
def __init__(self, queue: asyncio.Queue[Event | None]) -> None:
|
|
112
|
+
self._queue = queue
|
|
113
|
+
|
|
114
|
+
async def __aiter__(self) -> AsyncGenerator[Event | None, None]:
|
|
115
|
+
try:
|
|
116
|
+
while True:
|
|
117
|
+
yield await self.get()
|
|
118
|
+
except Unsubscribed:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
async def get(self) -> Event:
|
|
122
|
+
item = await self._queue.get()
|
|
123
|
+
if item is None:
|
|
124
|
+
raise Unsubscribed()
|
|
125
|
+
return item
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .._base import Event
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BroadcastBackend:
|
|
7
|
+
def __init__(self, url: str) -> None:
|
|
8
|
+
raise NotImplementedError()
|
|
9
|
+
|
|
10
|
+
async def connect(self) -> None:
|
|
11
|
+
raise NotImplementedError()
|
|
12
|
+
|
|
13
|
+
async def disconnect(self) -> None:
|
|
14
|
+
raise NotImplementedError()
|
|
15
|
+
|
|
16
|
+
async def subscribe(self, channel: str) -> None:
|
|
17
|
+
raise NotImplementedError()
|
|
18
|
+
|
|
19
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
20
|
+
raise NotImplementedError()
|
|
21
|
+
|
|
22
|
+
async def publish(self, channel: str, message: Any) -> None:
|
|
23
|
+
raise NotImplementedError()
|
|
24
|
+
|
|
25
|
+
async def next_published(self) -> Event:
|
|
26
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import typing
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
|
|
8
|
+
|
|
9
|
+
from .._base import Event
|
|
10
|
+
from .base import BroadcastBackend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KafkaBackend(BroadcastBackend):
|
|
14
|
+
def __init__(self, urls: str | list[str]) -> None:
|
|
15
|
+
urls = [urls] if isinstance(urls, str) else urls
|
|
16
|
+
self._servers = [urlparse(url).netloc for url in urls]
|
|
17
|
+
self._consumer_channels: set[str] = set()
|
|
18
|
+
self._ready = asyncio.Event()
|
|
19
|
+
|
|
20
|
+
async def connect(self) -> None:
|
|
21
|
+
self._producer = AIOKafkaProducer(bootstrap_servers=self._servers) # pyright: ignore
|
|
22
|
+
self._consumer = AIOKafkaConsumer(bootstrap_servers=self._servers) # pyright: ignore
|
|
23
|
+
await self._producer.start()
|
|
24
|
+
await self._consumer.start()
|
|
25
|
+
|
|
26
|
+
async def disconnect(self) -> None:
|
|
27
|
+
await self._producer.stop()
|
|
28
|
+
await self._consumer.stop()
|
|
29
|
+
|
|
30
|
+
async def subscribe(self, channel: str) -> None:
|
|
31
|
+
self._consumer_channels.add(channel)
|
|
32
|
+
self._consumer.subscribe(topics=self._consumer_channels)
|
|
33
|
+
await self._wait_for_assignment()
|
|
34
|
+
|
|
35
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
36
|
+
self._consumer.unsubscribe()
|
|
37
|
+
|
|
38
|
+
async def publish(self, channel: str, message: typing.Any) -> None:
|
|
39
|
+
await self._producer.send_and_wait(channel, message.encode("utf8"))
|
|
40
|
+
|
|
41
|
+
async def next_published(self) -> Event:
|
|
42
|
+
await self._ready.wait()
|
|
43
|
+
message = await self._consumer.getone()
|
|
44
|
+
value = message.value
|
|
45
|
+
|
|
46
|
+
# for type compatibility:
|
|
47
|
+
# we declare Event.message as str, so convert None to empty string
|
|
48
|
+
if value is None:
|
|
49
|
+
value = b""
|
|
50
|
+
return Event(channel=message.topic, message=value.decode("utf8"))
|
|
51
|
+
|
|
52
|
+
async def _wait_for_assignment(self) -> None:
|
|
53
|
+
"""Wait for the consumer to be assigned to the partition."""
|
|
54
|
+
while not self._consumer.assignment():
|
|
55
|
+
await asyncio.sleep(0.001)
|
|
56
|
+
|
|
57
|
+
self._ready.set()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from .._base import Event
|
|
7
|
+
from .base import BroadcastBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MemoryBackend(BroadcastBackend):
|
|
11
|
+
def __init__(self, url: str):
|
|
12
|
+
self._subscribed: set[str] = set()
|
|
13
|
+
|
|
14
|
+
async def connect(self) -> None:
|
|
15
|
+
self._published: asyncio.Queue[Event] = asyncio.Queue()
|
|
16
|
+
|
|
17
|
+
async def disconnect(self) -> None:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
async def subscribe(self, channel: str) -> None:
|
|
21
|
+
self._subscribed.add(channel)
|
|
22
|
+
|
|
23
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
24
|
+
self._subscribed.remove(channel)
|
|
25
|
+
|
|
26
|
+
async def publish(self, channel: str, message: typing.Any) -> None:
|
|
27
|
+
event = Event(channel=channel, message=message)
|
|
28
|
+
await self._published.put(event)
|
|
29
|
+
|
|
30
|
+
async def next_published(self) -> Event:
|
|
31
|
+
while True:
|
|
32
|
+
event = await self._published.get()
|
|
33
|
+
if event.channel in self._subscribed:
|
|
34
|
+
return event
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import asyncpg
|
|
5
|
+
|
|
6
|
+
from .._base import Event
|
|
7
|
+
from .base import BroadcastBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostgresBackend(BroadcastBackend):
|
|
11
|
+
def __init__(self, url: str):
|
|
12
|
+
self._url = url
|
|
13
|
+
|
|
14
|
+
async def connect(self) -> None:
|
|
15
|
+
self._conn = await asyncpg.connect(self._url)
|
|
16
|
+
self._listen_queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
17
|
+
|
|
18
|
+
async def disconnect(self) -> None:
|
|
19
|
+
await self._conn.close()
|
|
20
|
+
|
|
21
|
+
async def subscribe(self, channel: str) -> None:
|
|
22
|
+
await self._conn.add_listener(channel, self._listener)
|
|
23
|
+
|
|
24
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
25
|
+
await self._conn.remove_listener(channel, self._listener)
|
|
26
|
+
|
|
27
|
+
async def publish(self, channel: str, message: str) -> None:
|
|
28
|
+
await self._conn.execute("SELECT pg_notify($1, $2);", channel, message)
|
|
29
|
+
|
|
30
|
+
def _listener(self, *args: Any) -> None:
|
|
31
|
+
connection, pid, channel, payload = args
|
|
32
|
+
event = Event(channel=channel, message=payload)
|
|
33
|
+
self._listen_queue.put_nowait(event)
|
|
34
|
+
|
|
35
|
+
async def next_published(self) -> Event:
|
|
36
|
+
return await self._listen_queue.get()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from redis import asyncio as redis
|
|
7
|
+
|
|
8
|
+
from .._base import Event
|
|
9
|
+
from .base import BroadcastBackend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisBackend(BroadcastBackend):
|
|
13
|
+
_conn: redis.Redis
|
|
14
|
+
|
|
15
|
+
def __init__(self, url: str | None = None, *, conn: redis.Redis | None = None):
|
|
16
|
+
if url is None:
|
|
17
|
+
assert conn is not None, "conn must be provided if url is not"
|
|
18
|
+
self._conn = conn
|
|
19
|
+
else:
|
|
20
|
+
self._conn = redis.Redis.from_url(url)
|
|
21
|
+
|
|
22
|
+
self._pubsub = self._conn.pubsub()
|
|
23
|
+
self._ready = asyncio.Event()
|
|
24
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
25
|
+
self._listener: asyncio.Task[None] | None = None
|
|
26
|
+
|
|
27
|
+
async def connect(self) -> None:
|
|
28
|
+
self._listener = asyncio.create_task(self._pubsub_listener())
|
|
29
|
+
await self._pubsub.connect() # type: ignore[no-untyped-call]
|
|
30
|
+
|
|
31
|
+
async def disconnect(self) -> None:
|
|
32
|
+
await self._pubsub.aclose() # type: ignore[no-untyped-call]
|
|
33
|
+
await self._conn.aclose()
|
|
34
|
+
if self._listener is not None:
|
|
35
|
+
self._listener.cancel()
|
|
36
|
+
|
|
37
|
+
async def subscribe(self, channel: str) -> None:
|
|
38
|
+
await self._pubsub.subscribe(channel)
|
|
39
|
+
# Wake the listener only after redis-py has registered the channel:
|
|
40
|
+
# `PubSub.listen()` returns at once while `pubsub.subscribed` is False,
|
|
41
|
+
# and redis-py adds the channel only after sending SUBSCRIBE. Setting
|
|
42
|
+
# the event first let the listener exit and wait forever whenever the
|
|
43
|
+
# subscribe call yielded (seen with redis-py 8 on Python 3.10/3.11).
|
|
44
|
+
self._ready.set()
|
|
45
|
+
|
|
46
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
47
|
+
await self._pubsub.unsubscribe(channel)
|
|
48
|
+
|
|
49
|
+
async def publish(self, channel: str, message: typing.Any) -> None:
|
|
50
|
+
await self._conn.publish(channel, message)
|
|
51
|
+
|
|
52
|
+
async def next_published(self) -> Event:
|
|
53
|
+
return await self._queue.get()
|
|
54
|
+
|
|
55
|
+
async def _pubsub_listener(self) -> None:
|
|
56
|
+
# redis-py does not listen to the pubsub connection if there are no channels subscribed
|
|
57
|
+
# so we need to wait until the first channel is subscribed to start listening
|
|
58
|
+
while True:
|
|
59
|
+
await self._ready.wait()
|
|
60
|
+
async for message in self._pubsub.listen():
|
|
61
|
+
if message["type"] == "message":
|
|
62
|
+
event = Event(
|
|
63
|
+
channel=message["channel"].decode(),
|
|
64
|
+
message=message["data"].decode(),
|
|
65
|
+
)
|
|
66
|
+
await self._queue.put(event)
|
|
67
|
+
|
|
68
|
+
# when no channel subscribed, clear the event.
|
|
69
|
+
# And then in next loop, event will blocked again until
|
|
70
|
+
# the new channel subscribed.Now asyncio.Task will not exit again.
|
|
71
|
+
self._ready.clear()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
StreamMessageType = tuple[bytes, tuple[tuple[bytes, dict[bytes, bytes]]]]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RedisStreamBackend(BroadcastBackend):
|
|
78
|
+
def __init__(self, url: str):
|
|
79
|
+
url = url.replace("redis-stream", "redis", 1)
|
|
80
|
+
self.streams: dict[bytes | str | memoryview, int | bytes | str | memoryview] = {}
|
|
81
|
+
self._ready = asyncio.Event()
|
|
82
|
+
self._producer = redis.Redis.from_url(url)
|
|
83
|
+
self._consumer = redis.Redis.from_url(url)
|
|
84
|
+
|
|
85
|
+
async def connect(self) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
async def disconnect(self) -> None:
|
|
89
|
+
await self._producer.aclose()
|
|
90
|
+
await self._consumer.aclose()
|
|
91
|
+
|
|
92
|
+
async def subscribe(self, channel: str) -> None:
|
|
93
|
+
try:
|
|
94
|
+
info = await self._consumer.xinfo_stream(channel)
|
|
95
|
+
last_id = info["last-generated-id"]
|
|
96
|
+
except redis.ResponseError:
|
|
97
|
+
last_id = "0"
|
|
98
|
+
self.streams[channel] = last_id
|
|
99
|
+
self._ready.set()
|
|
100
|
+
|
|
101
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
102
|
+
self.streams.pop(channel, None)
|
|
103
|
+
|
|
104
|
+
async def publish(self, channel: str, message: typing.Any) -> None:
|
|
105
|
+
await self._producer.xadd(channel, {"message": message})
|
|
106
|
+
|
|
107
|
+
async def wait_for_messages(self) -> list[StreamMessageType]:
|
|
108
|
+
await self._ready.wait()
|
|
109
|
+
messages = None
|
|
110
|
+
while not messages:
|
|
111
|
+
messages = await self._consumer.xread(self.streams, count=1, block=100)
|
|
112
|
+
# redis-py >= 6 types xread() as a RESP2/RESP3 union; with RESP2 it is this list.
|
|
113
|
+
return typing.cast(list[StreamMessageType], messages)
|
|
114
|
+
|
|
115
|
+
async def next_published(self) -> Event:
|
|
116
|
+
messages = await self.wait_for_messages()
|
|
117
|
+
stream, events = messages[0]
|
|
118
|
+
_msg_id, message = events[0]
|
|
119
|
+
self.streams[stream.decode("utf-8")] = _msg_id.decode("utf-8")
|
|
120
|
+
return Event(
|
|
121
|
+
channel=stream.decode("utf-8"),
|
|
122
|
+
message=message.get(b"message", b"").decode("utf-8"),
|
|
123
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "broadcaster-ng"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Simple broadcast channels. Maintained fork of encode/broadcaster with connection-loss fixes."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "BSD-3-Clause"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Tom Christie", email = "tom@tomchristie.com" },
|
|
14
|
+
{ name = "Qing Wan", email = "juveseason@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
maintainers = [
|
|
17
|
+
{ name = "Qing Wan", email = "juveseason@gmail.com" },
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Web Environment",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Programming Language :: Python :: 3.14",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"anyio>=3.4.0,<5",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
redis = ["redis>=5"]
|
|
38
|
+
postgres = ["asyncpg"]
|
|
39
|
+
kafka = ["aiokafka"]
|
|
40
|
+
test = ["pytest", "pytest-asyncio"]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/Redevil10/broadcaster-ng"
|
|
44
|
+
Issues = "https://github.com/Redevil10/broadcaster-ng/issues"
|
|
45
|
+
Upstream = "https://github.com/encode/broadcaster"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.version]
|
|
48
|
+
path = "broadcaster/__init__.py"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
# PyPI name is broadcaster-ng, import name stays `broadcaster`.
|
|
52
|
+
packages = ["broadcaster"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = [
|
|
56
|
+
"/broadcaster",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 120
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "FA", "UP"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.isort]
|
|
66
|
+
combine-as-imports = true
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
strict = true
|
|
70
|
+
python_version = "3.10"
|
|
71
|
+
disallow_untyped_defs = true
|
|
72
|
+
ignore_missing_imports = true
|
|
73
|
+
|
|
74
|
+
[[tool.mypy.overrides]]
|
|
75
|
+
module = "tests.*"
|
|
76
|
+
disallow_untyped_defs = false
|
|
77
|
+
check_untyped_defs = true
|
|
78
|
+
|
|
79
|
+
[tool.pytest.ini_options]
|
|
80
|
+
addopts = "-rxXs"
|
|
81
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
82
|
+
markers = "copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup"
|
|
83
|
+
|
|
84
|
+
[tool.coverage.run]
|
|
85
|
+
source_pkgs = ["broadcaster", "tests"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
fail_under = 78
|
|
89
|
+
show_missing = true
|
|
90
|
+
skip_covered = true
|