hubcast 0.1.0__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.
hubcast-0.1.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Parham Roozmand
4
+
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
hubcast-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: hubcast
3
+ Version: 0.1.0
4
+ Summary: Fan-out to WebSocket subscribers across processes, on one Redis connection.
5
+ Author: Parham Roozmand
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Nappuccino-tlg/hubcast
8
+ Project-URL: Source, https://github.com/Nappuccino-tlg/hubcast
9
+ Project-URL: Issues, https://github.com/Nappuccino-tlg/hubcast/issues
10
+ Keywords: websocket,pubsub,redis,broadcast,fanout,asyncio,realtime
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: redis
23
+ Requires-Dist: redis>=5.0; extra == "redis"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.3; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.26; extra == "dev"
27
+ Requires-Dist: pytest-cov>=6.0; extra == "dev"
28
+ Requires-Dist: ruff>=0.8; extra == "dev"
29
+ Requires-Dist: redis>=5.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # hubcast
33
+
34
+ Fan-out to WebSocket subscribers across processes, on one Redis connection.
35
+
36
+ [![CI](https://github.com/Nappuccino-tlg/hubcast/actions/workflows/ci.yml/badge.svg)](https://github.com/Nappuccino-tlg/hubcast/actions/workflows/ci.yml)
37
+ ![Python](https://img.shields.io/badge/python-3.10%20--%203.13-blue)
38
+ ![License](https://img.shields.io/badge/license-MIT-green)
39
+
40
+ ```bash
41
+ pip install "hubcast[redis]"
42
+ ```
43
+
44
+ ## Two problems, and the second one is the hard one
45
+
46
+ You have a live feed — clicks arriving, prices moving, a build log — and clients watching
47
+ it over WebSockets. Two things go wrong, and only the first is obvious.
48
+
49
+ **Your app runs on more than one process.** The event arrives at instance A. The client
50
+ watching for it is connected to instance B. A knows nothing about B.
51
+
52
+ The usual fix is Redis pub/sub, and the usual implementation gives every connection its
53
+ own subscription. It works, and it stops working at exactly the scale where broadcasting
54
+ was worth doing: ten thousand clients become ten thousand Redis connections, and the
55
+ server runs out of file descriptors long before it runs out of anything interesting.
56
+
57
+ A `Hub` holds **one** Redis connection however many subscribers it has. It subscribes to a
58
+ channel when a topic gains its first local subscriber, drops it when it loses its last, and
59
+ fans out in-process. Redis sees how many *topics* a process cares about, not how many
60
+ clients it is serving.
61
+
62
+ **Someone's phone goes into a tunnel.** Their connection is open, their socket buffer is
63
+ full, and they are not reading. Now what?
64
+
65
+ That is not an edge case, and there is no answer that is right everywhere — which is why
66
+ it is an argument rather than a decision made for you.
67
+
68
+ ## Using it
69
+
70
+ ```python
71
+ from redis.asyncio import Redis
72
+ from hubcast import Hub
73
+
74
+ hub = Hub(Redis.from_url("redis://localhost"))
75
+
76
+ # once, at startup
77
+ await hub.start()
78
+ ```
79
+
80
+ Publish from anywhere, on any instance:
81
+
82
+ ```python
83
+ await hub.publish(f"link:{code}", json.dumps({"clicks": total}))
84
+ ```
85
+
86
+ Subscribe for the length of a connection:
87
+
88
+ ```python
89
+ @app.websocket("/live/{code}")
90
+ async def live(websocket: WebSocket, code: str):
91
+ await websocket.accept()
92
+ async with hub.subscribe(f"link:{code}") as messages:
93
+ async for message in messages:
94
+ await websocket.send_text(message)
95
+ ```
96
+
97
+ `subscribe` is a context manager rather than a pair of calls because the failure it
98
+ prevents — a connection dropping and leaving its subscription behind forever — is silent,
99
+ cumulative, and shows up only as memory that never comes back.
100
+
101
+ ## When a subscriber falls behind
102
+
103
+ ```python
104
+ hub = Hub(redis, max_queue=100, overflow=Overflow.DROP_OLDEST)
105
+ ```
106
+
107
+ | | keeps | right for |
108
+ |---|---|---|
109
+ | `DROP_OLDEST` *(default)* | the newest | a live feed — the current number is the true one, and a stale one is worse than nothing |
110
+ | `DROP_NEWEST` | the first | an alert stream — the original cause matters more than the hundredth symptom |
111
+ | `CLOSE` | nothing | a consumer that would rather reconnect and resynchronise than carry on with a hole in it |
112
+
113
+ Set per Hub, or per subscription:
114
+
115
+ ```python
116
+ async with hub.subscribe(topic, max_queue=1000, overflow=Overflow.CLOSE) as messages:
117
+ ...
118
+ ```
119
+
120
+ `CLOSE` raises `SubscriberTooSlow` **in that subscriber's own iteration** — never in the
121
+ publisher. One slow client is the slow client's problem, and a broadcast must not fail
122
+ because somebody went into a tunnel.
123
+
124
+ Every subscription counts what it discarded:
125
+
126
+ ```python
127
+ messages.dropped # worth putting on a dashboard
128
+ ```
129
+
130
+ A number that climbs means a consumer that needs to be faster or a queue that needs to be
131
+ deeper. Not printing it is how that goes unnoticed for a month.
132
+
133
+ ## Without a Redis
134
+
135
+ ```python
136
+ hub = Hub() # in-process, same API
137
+ ```
138
+
139
+ Useful for a single-process deployment and for tests, and it is the same code path — so
140
+ nothing here is only exercised in production. `redis` is an optional extra, and CI checks
141
+ the package still imports and works with it uninstalled.
142
+
143
+ ## What it does not do
144
+
145
+ **Delivery guarantees.** Redis pub/sub is fire-and-forget: a subscriber that is not
146
+ connected when a message is published does not get it later. Nothing here adds replay,
147
+ acknowledgement or ordering across a reconnect. If losing a message is not survivable, you
148
+ want a log — Redis Streams, Kafka — not this.
149
+
150
+ **WebSockets.** A Hub moves strings between processes; it never touches a socket. That
151
+ keeps it usable from anything, and means heartbeats, reconnects and framing stay with the
152
+ framework that already owns them.
153
+
154
+ **Presence.** Knowing *who* is connected across instances is a different problem with its
155
+ own failure mode — an instance that dies leaves its users looking online forever.
156
+
157
+ ## One thing worth knowing
158
+
159
+ Every Hub keeps one extra channel open, `<prefix>:__hub__`, and never publishes on it.
160
+ redis-py's `listen()` loops `while self.subscribed`, so a pubsub with no channels ends the
161
+ generator immediately — the listener would exit before the first topic arrived and the Hub
162
+ would be silently deaf. It is visible in `PUBSUB CHANNELS`, so it is named for what it is
163
+ rather than hidden.
164
+
165
+ ## Tests
166
+
167
+ ```bash
168
+ docker run -d -p 6379:6379 redis:7-alpine
169
+ pytest
170
+ ```
171
+
172
+ The Redis tests use **two Hubs on one Redis**, which is the same arrangement as two
173
+ application processes behind a load balancer — one holds the connection that publishes,
174
+ the other the connection that is subscribed. A single Hub talking to itself proves nothing
175
+ about the part people install this for.
176
+
177
+ ## Requirements
178
+
179
+ Python 3.10 or newer. `redis>=5.0` only if you want the cross-process half.
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,152 @@
1
+ # hubcast
2
+
3
+ Fan-out to WebSocket subscribers across processes, on one Redis connection.
4
+
5
+ [![CI](https://github.com/Nappuccino-tlg/hubcast/actions/workflows/ci.yml/badge.svg)](https://github.com/Nappuccino-tlg/hubcast/actions/workflows/ci.yml)
6
+ ![Python](https://img.shields.io/badge/python-3.10%20--%203.13-blue)
7
+ ![License](https://img.shields.io/badge/license-MIT-green)
8
+
9
+ ```bash
10
+ pip install "hubcast[redis]"
11
+ ```
12
+
13
+ ## Two problems, and the second one is the hard one
14
+
15
+ You have a live feed — clicks arriving, prices moving, a build log — and clients watching
16
+ it over WebSockets. Two things go wrong, and only the first is obvious.
17
+
18
+ **Your app runs on more than one process.** The event arrives at instance A. The client
19
+ watching for it is connected to instance B. A knows nothing about B.
20
+
21
+ The usual fix is Redis pub/sub, and the usual implementation gives every connection its
22
+ own subscription. It works, and it stops working at exactly the scale where broadcasting
23
+ was worth doing: ten thousand clients become ten thousand Redis connections, and the
24
+ server runs out of file descriptors long before it runs out of anything interesting.
25
+
26
+ A `Hub` holds **one** Redis connection however many subscribers it has. It subscribes to a
27
+ channel when a topic gains its first local subscriber, drops it when it loses its last, and
28
+ fans out in-process. Redis sees how many *topics* a process cares about, not how many
29
+ clients it is serving.
30
+
31
+ **Someone's phone goes into a tunnel.** Their connection is open, their socket buffer is
32
+ full, and they are not reading. Now what?
33
+
34
+ That is not an edge case, and there is no answer that is right everywhere — which is why
35
+ it is an argument rather than a decision made for you.
36
+
37
+ ## Using it
38
+
39
+ ```python
40
+ from redis.asyncio import Redis
41
+ from hubcast import Hub
42
+
43
+ hub = Hub(Redis.from_url("redis://localhost"))
44
+
45
+ # once, at startup
46
+ await hub.start()
47
+ ```
48
+
49
+ Publish from anywhere, on any instance:
50
+
51
+ ```python
52
+ await hub.publish(f"link:{code}", json.dumps({"clicks": total}))
53
+ ```
54
+
55
+ Subscribe for the length of a connection:
56
+
57
+ ```python
58
+ @app.websocket("/live/{code}")
59
+ async def live(websocket: WebSocket, code: str):
60
+ await websocket.accept()
61
+ async with hub.subscribe(f"link:{code}") as messages:
62
+ async for message in messages:
63
+ await websocket.send_text(message)
64
+ ```
65
+
66
+ `subscribe` is a context manager rather than a pair of calls because the failure it
67
+ prevents — a connection dropping and leaving its subscription behind forever — is silent,
68
+ cumulative, and shows up only as memory that never comes back.
69
+
70
+ ## When a subscriber falls behind
71
+
72
+ ```python
73
+ hub = Hub(redis, max_queue=100, overflow=Overflow.DROP_OLDEST)
74
+ ```
75
+
76
+ | | keeps | right for |
77
+ |---|---|---|
78
+ | `DROP_OLDEST` *(default)* | the newest | a live feed — the current number is the true one, and a stale one is worse than nothing |
79
+ | `DROP_NEWEST` | the first | an alert stream — the original cause matters more than the hundredth symptom |
80
+ | `CLOSE` | nothing | a consumer that would rather reconnect and resynchronise than carry on with a hole in it |
81
+
82
+ Set per Hub, or per subscription:
83
+
84
+ ```python
85
+ async with hub.subscribe(topic, max_queue=1000, overflow=Overflow.CLOSE) as messages:
86
+ ...
87
+ ```
88
+
89
+ `CLOSE` raises `SubscriberTooSlow` **in that subscriber's own iteration** — never in the
90
+ publisher. One slow client is the slow client's problem, and a broadcast must not fail
91
+ because somebody went into a tunnel.
92
+
93
+ Every subscription counts what it discarded:
94
+
95
+ ```python
96
+ messages.dropped # worth putting on a dashboard
97
+ ```
98
+
99
+ A number that climbs means a consumer that needs to be faster or a queue that needs to be
100
+ deeper. Not printing it is how that goes unnoticed for a month.
101
+
102
+ ## Without a Redis
103
+
104
+ ```python
105
+ hub = Hub() # in-process, same API
106
+ ```
107
+
108
+ Useful for a single-process deployment and for tests, and it is the same code path — so
109
+ nothing here is only exercised in production. `redis` is an optional extra, and CI checks
110
+ the package still imports and works with it uninstalled.
111
+
112
+ ## What it does not do
113
+
114
+ **Delivery guarantees.** Redis pub/sub is fire-and-forget: a subscriber that is not
115
+ connected when a message is published does not get it later. Nothing here adds replay,
116
+ acknowledgement or ordering across a reconnect. If losing a message is not survivable, you
117
+ want a log — Redis Streams, Kafka — not this.
118
+
119
+ **WebSockets.** A Hub moves strings between processes; it never touches a socket. That
120
+ keeps it usable from anything, and means heartbeats, reconnects and framing stay with the
121
+ framework that already owns them.
122
+
123
+ **Presence.** Knowing *who* is connected across instances is a different problem with its
124
+ own failure mode — an instance that dies leaves its users looking online forever.
125
+
126
+ ## One thing worth knowing
127
+
128
+ Every Hub keeps one extra channel open, `<prefix>:__hub__`, and never publishes on it.
129
+ redis-py's `listen()` loops `while self.subscribed`, so a pubsub with no channels ends the
130
+ generator immediately — the listener would exit before the first topic arrived and the Hub
131
+ would be silently deaf. It is visible in `PUBSUB CHANNELS`, so it is named for what it is
132
+ rather than hidden.
133
+
134
+ ## Tests
135
+
136
+ ```bash
137
+ docker run -d -p 6379:6379 redis:7-alpine
138
+ pytest
139
+ ```
140
+
141
+ The Redis tests use **two Hubs on one Redis**, which is the same arrangement as two
142
+ application processes behind a load balancer — one holds the connection that publishes,
143
+ the other the connection that is subscribed. A single Hub talking to itself proves nothing
144
+ about the part people install this for.
145
+
146
+ ## Requirements
147
+
148
+ Python 3.10 or newer. `redis>=5.0` only if you want the cross-process half.
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "hubcast"
3
+ version = "0.1.0"
4
+ description = "Fan-out to WebSocket subscribers across processes, on one Redis connection."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Parham Roozmand" }]
9
+ keywords = ["websocket", "pubsub", "redis", "broadcast", "fanout", "asyncio", "realtime"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Framework :: AsyncIO",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ ]
20
+ # Redis is optional: without one this is an in-process hub, which is a real use and the
21
+ # one the tests exercise first.
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ redis = ["redis>=5.0"]
26
+ dev = ["pytest>=8.3", "pytest-asyncio>=0.26", "pytest-cov>=6.0", "ruff>=0.8", "redis>=5.0"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Nappuccino-tlg/hubcast"
30
+ Source = "https://github.com/Nappuccino-tlg/hubcast"
31
+ Issues = "https://github.com/Nappuccino-tlg/hubcast/issues"
32
+
33
+ [build-system]
34
+ requires = ["setuptools>=68"]
35
+ build-backend = "setuptools.build_meta"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+ asyncio_default_fixture_loop_scope = "function"
43
+ testpaths = ["tests"]
44
+ addopts = "-q --cov=hubcast --cov-report=term-missing"
45
+
46
+ [tool.coverage.report]
47
+ exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py310"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "N", "UP", "B", "C4", "SIM", "RUF"]
55
+ # N818 wants SubscriberTooSlowError. The short name is in the public API and
56
+ # `except SubscriberTooSlow:` is the sentence a caller writes.
57
+ ignore = ["N818"]
58
+
59
+ [tool.ruff.lint.per-file-ignores]
60
+ # SIM117: a Hub and the subscription taken inside it are two ideas, and nesting them says
61
+ # which one owns the other. Flattening reads as though they were peers.
62
+ "tests/*" = ["SIM117"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ """Fan-out to WebSocket subscribers across processes, on one Redis connection.
2
+
3
+ The obvious implementation gives every connection its own Redis subscription, and stops
4
+ working at exactly the scale where broadcasting was worth doing. This keeps one connection
5
+ per process however many clients it is serving, and makes the slow-subscriber question --
6
+ which is not an edge case, it is a phone on a train -- something you answer on purpose.
7
+ """
8
+
9
+ from hubcast._hub import (
10
+ DEFAULT_MAX_QUEUE,
11
+ DEFAULT_PREFIX,
12
+ Hub,
13
+ Overflow,
14
+ SubscriberTooSlow,
15
+ Subscription,
16
+ )
17
+
18
+ __all__ = [
19
+ "DEFAULT_MAX_QUEUE",
20
+ "DEFAULT_PREFIX",
21
+ "Hub",
22
+ "Overflow",
23
+ "SubscriberTooSlow",
24
+ "Subscription",
25
+ ]
26
+
27
+ __version__ = "0.1.0"