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.
@@ -0,0 +1,15 @@
1
+ *.pyc
2
+ test.db
3
+ .coverage
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ *.egg-info/
7
+ venv/
8
+ build/
9
+ dist/
10
+
11
+ # Private design notes and work logs
12
+ notes/
13
+
14
+ # IDE
15
+ .idea/
@@ -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
@@ -0,0 +1,5 @@
1
+ from ._base import Broadcast, Event
2
+ from .backends.base import BroadcastBackend
3
+
4
+ __version__ = "0.4.0.dev0"
5
+ __all__ = ["Broadcast", "Event", "BroadcastBackend"]
@@ -0,0 +1,3 @@
1
+ """Deprecated alias for `broadcaster.backends.base` (renamed upstream after 0.3.1)."""
2
+
3
+ from broadcaster.backends.base import * # noqa: F403
@@ -0,0 +1,3 @@
1
+ """Deprecated alias for `broadcaster.backends.kafka` (renamed upstream after 0.3.1)."""
2
+
3
+ from broadcaster.backends.kafka import * # noqa: F403
@@ -0,0 +1,3 @@
1
+ """Deprecated alias for `broadcaster.backends.memory` (renamed upstream after 0.3.1)."""
2
+
3
+ from broadcaster.backends.memory import * # noqa: F403
@@ -0,0 +1,3 @@
1
+ """Deprecated alias for `broadcaster.backends.postgres` (renamed upstream after 0.3.1)."""
2
+
3
+ from broadcaster.backends.postgres import * # noqa: F403
@@ -0,0 +1,3 @@
1
+ """Deprecated alias for `broadcaster.backends.redis` (renamed upstream after 0.3.1)."""
2
+
3
+ from broadcaster.backends.redis import * # noqa: F403
@@ -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
@@ -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