sillo-wire 0.1.0.dev1__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,76 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install
24
+ run: |
25
+ python -m pip install --upgrade pip
26
+ pip install sillo-framework
27
+ pip install pytest pytest-asyncio pytest-cov ruff mypy
28
+
29
+ - name: Lint
30
+ run: ruff check sillo_wire tests _sillo_wire_bootstrap.py
31
+
32
+ - name: Types
33
+ run: mypy sillo_wire
34
+
35
+ - name: Install the package
36
+ run: pip install -e .
37
+
38
+ # The `.pth` only fires for a real install, so the alias is checked
39
+ # against a built wheel rather than the editable checkout.
40
+ - name: Check the sillo.wire alias against a real install
41
+ run: |
42
+ pip install build && python -m build --wheel -o /tmp/w .
43
+ python -m venv /tmp/v && /tmp/v/bin/pip install -q sillo-framework /tmp/w/*.whl mypy
44
+ /tmp/v/bin/python -c "
45
+ import sillo, sillo.wire, sillo_wire, os
46
+ assert sillo.wire is sillo_wire
47
+ assert 'wire' not in os.listdir(os.path.dirname(sillo.__file__)), 'wrote into sillo/'
48
+ print('alias ok, sillo/ untouched')
49
+ "
50
+ printf 'from sillo.wire import Hub\nh: Hub = Hub()\n' > /tmp/tc.py
51
+ /tmp/v/bin/python -m mypy /tmp/tc.py
52
+
53
+ - name: Test
54
+ run: pytest --cov --cov-report=term-missing
55
+
56
+ build:
57
+ runs-on: ubuntu-latest
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+ - uses: actions/setup-python@v5
61
+ with:
62
+ python-version: "3.12"
63
+ - run: pip install build
64
+ - run: python -m build
65
+ # The wheel must not write anything into the framework's own package
66
+ # directory — that is the collision this layout exists to avoid.
67
+ - name: Check the wheel stays out of sillo/
68
+ run: |
69
+ python - <<'PY'
70
+ import zipfile, glob
71
+ names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
72
+ modules = [n for n in names if not n.startswith("sillo_wire-")]
73
+ assert not any(n.startswith("sillo/") for n in names), "must not touch sillo/"
74
+ assert not any(n.startswith("sillo/") for n in names), "must not touch sillo/"
75
+ print("\n".join(modules))
76
+ PY
@@ -0,0 +1,103 @@
1
+ name: Release on Tag
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'sillo-wire-v*' # sillo-wire releases: sillo-wire-v0.1.0
7
+
8
+ jobs:
9
+ release:
10
+ name: Build and Publish to PyPI
11
+ runs-on: ubuntu-latest
12
+
13
+ permissions:
14
+ contents: read # actions/checkout needs repo read access
15
+ id-token: write # for OIDC (trusted publishing)
16
+
17
+ steps:
18
+ - name: Checkout repo
19
+ uses: actions/checkout@v4
20
+ with:
21
+ fetch-depth: 0
22
+
23
+ - name: Extract version from tag
24
+ id: version
25
+ run: |
26
+ TAG="${{ github.ref_name }}"
27
+ VERSION="${TAG#sillo-wire-v}"
28
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
29
+ echo "📦 Releasing sillo-wire v$VERSION"
30
+
31
+ - name: Set up Python
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: '3.11'
35
+
36
+ - name: Install uv
37
+ run: |
38
+ curl -LsSf https://astral.sh/uv/install.sh | sh
39
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
40
+ echo "$HOME/.cargo/bin" >> $GITHUB_PATH
41
+
42
+ # PyPI takes the version from the package, not the tag. Without this a
43
+ # tag of sillo-wire-v0.1.1 against a pyproject still saying 0.1.0
44
+ # publishes 0.1.0 and reports success, and the version it claims to have
45
+ # released is one that does not exist.
46
+ #
47
+ # `sillo_wire.__version__` is checked too because it is what a user reads
48
+ # at runtime.
49
+ - name: The tag, the package and __version__ must agree
50
+ run: |
51
+ TAG_VERSION="${{ steps.version.outputs.version }}"
52
+ PKG_VERSION=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
53
+ MOD_VERSION=$(python -c "import re,pathlib; print(re.search(r'__version__.*?\"(.*?)\"', pathlib.Path('sillo_wire/__init__.py').read_text()).group(1))")
54
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
55
+ echo "::error::Tag says $TAG_VERSION, pyproject.toml says $PKG_VERSION."
56
+ exit 1
57
+ fi
58
+ if [ "$TAG_VERSION" != "$MOD_VERSION" ]; then
59
+ echo "::error::Tag says $TAG_VERSION, sillo_wire/__init__.py says $MOD_VERSION."
60
+ exit 1
61
+ fi
62
+ echo "all three say $PKG_VERSION"
63
+
64
+ - name: Clean Old Builds
65
+ run: |
66
+ rm -rf dist build *.egg-info
67
+
68
+ - name: Setup Virtual Environment
69
+ run: uv venv
70
+
71
+ - name: Install dependencies
72
+ run: uv pip install -e ".[dev]"
73
+
74
+ - name: Run tests
75
+ run: uv run pytest -q --tb=short
76
+
77
+ - name: Build the package
78
+ run: uv build
79
+
80
+ # The whole point of this package's layout: the wheel adds a top-level
81
+ # `sillo_wire` plus the `.pth`/bootstrap at the site-packages root, and
82
+ # it must never write into the framework's own `sillo/` directory.
83
+ - name: The wheel must stay out of sillo/
84
+ run: |
85
+ python - <<'PY'
86
+ import glob, zipfile
87
+ names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
88
+ assert not any(n.startswith("sillo/") for n in names), "wheel writes into sillo/"
89
+ print("wheel does not touch sillo/")
90
+ PY
91
+
92
+ # Prefers OIDC trusted publishing (the id-token permission above); falls
93
+ # back to an API token when the PYPI_TOKEN secret is set. Passing an empty
94
+ # --token is a hard failure, so the secret must not be forwarded blindly.
95
+ - name: Publish to PyPI
96
+ env:
97
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
98
+ run: |
99
+ if [ -n "$PYPI_TOKEN" ]; then
100
+ uv publish --token "$PYPI_TOKEN"
101
+ else
102
+ uv publish
103
+ fi
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ .coverage.*
9
+ htmlcov/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ .DS_Store
14
+
15
+ .venv*/
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0.dev1
4
+
5
+ Development pre-release for testing. Install with
6
+ `pip install --pre sillo-wire==0.1.0.dev1`.
7
+
8
+ First cut. Extracted from `sillo.websockets.channels` and rebuilt around
9
+ three things the original could not do.
10
+
11
+ ### Added
12
+
13
+ - `Hub` — rooms, membership and fan-out as an object rather than class methods
14
+ over a process-global dict. Two hubs are independent, which is what makes
15
+ tests and multi-tenancy straightforward.
16
+ - `Peer` — a connection with a bounded outbound queue and its own writer task.
17
+ - `Overflow` — `DROP_OLDEST`, `DROP_NEWEST` or `CLOSE` when a queue fills.
18
+ - `DeliveryReport` — what a fan-out actually did, per peer.
19
+ - `Envelope` — an immutable message carrying a monotonic sequence.
20
+ - `Backlog` protocol, with `MemoryBacklog` and `NullBacklog`.
21
+ - `Hub.replay` — send a reconnecting client only what it missed.
22
+ - `Hub.send_to` — reach every connection an identity has open.
23
+ - Presence: `on_join` / `on_leave` listeners, and `identities()` as a roster.
24
+ - `RoomConsumer` — class-based endpoint with guaranteed cleanup.
25
+ - `Hub.close` empties its rooms before awaiting anything and closes every peer
26
+ concurrently, so shutdown is not paced by the slowest socket.
27
+ - `sillo.wire.testing` — `FakeSocket` and `drain` for testing realtime code.
28
+ - Importable as both `sillo.wire` and `sillo_wire`. The alias is a
29
+ meta-path finder registered by a `.pth`, with PEP 561 partial stubs for
30
+ type checkers; nothing is written into the framework's own package.
31
+
32
+ ### Fixed, relative to the code this replaces
33
+
34
+ - **A broadcast no longer blocks on the slowest client.** The previous fan-out
35
+ awaited each socket in turn, so one client that had stopped reading stalled
36
+ every other member of the group.
37
+ - **The history cap now measures messages.** It previously sized the *list
38
+ object* with `sys.getsizeof`, so a 1 MB cap retained around 128 MB, and on
39
+ tripping discarded the entire history rather than the oldest part of it.
40
+ - **Expiry distinguishes idle from lifetime.** Sending reset the creation time,
41
+ so a documented TTL behaved as an idle timeout without saying so.
@@ -0,0 +1,27 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024-present, sillo-Labs OSS.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without modification,
7
+ are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+ 2. 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
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.5
2
+ Name: sillo-wire
3
+ Version: 0.1.0.dev1
4
+ Summary: Rooms, presence and fan-out for Sillo WebSockets — bounded queues, replayable backlog, no global state.
5
+ Project-URL: Homepage, https://sillo.build
6
+ Project-URL: Documentation, https://docs.sillo.build/packages/wire/
7
+ Project-URL: Source, https://github.com/sillohq/wire
8
+ Author-email: Chidebele Dunamis <techwithdunamix@gmail.com>
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Keywords: asgi,broadcast,presence,pubsub,realtime,sillo,websocket
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: BSD License
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: sillo-framework>=0.3
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.11; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # sillo-wire
34
+
35
+ Rooms, presence and fan-out for [Sillo](https://sillo.build) WebSockets.
36
+
37
+ ```bash
38
+ pip install sillo-wire
39
+ ```
40
+
41
+ Installs as `sillo-wire`, imports as `sillo.wire`.
42
+
43
+ ```python
44
+ from sillo import SilloApp
45
+ from sillo.wire import Hub, Peer
46
+
47
+ app = SilloApp()
48
+ hub = Hub()
49
+
50
+ @app.ws_route("/ws/room/{name}")
51
+ async def room(socket, name: str):
52
+ await socket.accept()
53
+ peer = Peer(socket, identity=socket.query_params.get("user"))
54
+ await hub.join(peer, name)
55
+ try:
56
+ async for message in socket.iter_json():
57
+ await hub.broadcast(name, message)
58
+ finally:
59
+ await hub.disconnect(peer)
60
+ ```
61
+
62
+ ## Why this exists
63
+
64
+ Three things differ from the obvious implementation, and they are the whole
65
+ point of the package.
66
+
67
+ **A broadcast never blocks.** Writing straight to each socket in turn means the
68
+ slowest member of a room sets the pace for everyone else — a client that has
69
+ stopped reading fills its kernel buffer, the write blocks, and the rest of the
70
+ room waits behind it. Here every peer has a bounded queue and a writer task, so
71
+ a broadcast only ever enqueues:
72
+
73
+ ```python
74
+ report = await hub.broadcast("lobby", {"msg": "hello"})
75
+ report.delivered # 41
76
+ report.dropped # 2 queues were full
77
+ report.failed # 1 socket was already gone
78
+ ```
79
+
80
+ You get a `DeliveryReport` rather than nothing, because a fan-out you cannot
81
+ measure is a fan-out you cannot operate.
82
+
83
+ **Nothing is global.** A `Hub` is an ordinary object. Two of them are two
84
+ independent worlds, so tests get a fresh one per case instead of remembering to
85
+ flush shared state, and a multi-tenant application keeps traffic apart without
86
+ a naming convention.
87
+
88
+ **History is replayable.** Every envelope carries a monotonic sequence, so a
89
+ client that reconnects asks for what it missed rather than for everything or
90
+ for nothing:
91
+
92
+ ```python
93
+ await hub.replay(peer, "lobby", since=last_seq_the_client_saw)
94
+ ```
95
+
96
+ ## Slow consumers
97
+
98
+ When a peer's queue fills, what happens is a choice, not a default:
99
+
100
+ ```python
101
+ from sillo.wire import Overflow, Peer
102
+
103
+ Peer(socket, overflow=Overflow.DROP_OLDEST) # keep current — prices, cursors
104
+ Peer(socket, overflow=Overflow.DROP_NEWEST) # keep order — reconcile later
105
+ Peer(socket, overflow=Overflow.CLOSE) # disconnect and let it reconnect
106
+ ```
107
+
108
+ ## Presence
109
+
110
+ ```python
111
+ @hub.on_join
112
+ async def joined(room, peer):
113
+ await hub.broadcast(room, {"event": "joined", "who": peer.identity})
114
+
115
+ hub.identities("lobby") # ["ada", "bob"] — people, not sockets
116
+ hub.count("lobby") # 5 — subscriptions
117
+ ```
118
+
119
+ Two peers can share an identity — the same person with a phone and two tabs —
120
+ and `send_to` reaches all of them:
121
+
122
+ ```python
123
+ await hub.send_to("ada", {"notice": "your export is ready"})
124
+ ```
125
+
126
+ ## Consumers
127
+
128
+ `RoomConsumer` is the class-based form. It accepts the socket, builds the peer,
129
+ joins the rooms, pumps messages, and guarantees the peer is removed from every
130
+ room when the connection ends — including when a hook raises.
131
+
132
+ ```python
133
+ from sillo.wire import Hub, RoomConsumer
134
+
135
+ hub = Hub()
136
+
137
+ class Chat(RoomConsumer):
138
+ hub = hub
139
+
140
+ async def identify(self, ctx):
141
+ return ctx.query_params.get("user")
142
+
143
+ async def rooms(self, ctx):
144
+ return [ctx.path_params["room"]]
145
+
146
+ async def on_message(self, data):
147
+ await self.broadcast({"from": self.peer.identity, "text": data})
148
+
149
+ app.add_ws_route(path="/ws/{room}", handler=Chat.as_handler())
150
+ ```
151
+
152
+ ## Backlog
153
+
154
+ Retention is per room and capped by payload bytes, evicting oldest first:
155
+
156
+ ```python
157
+ from sillo.wire import Hub, MemoryBacklog, NullBacklog
158
+
159
+ Hub(backlog=MemoryBacklog(capacity_bytes=4 * 1024 * 1024))
160
+ Hub(backlog=NullBacklog()) # keep nothing — typing indicators, telemetry
161
+ ```
162
+
163
+ `Backlog` is a `Protocol`, so a Redis or Postgres store satisfies it without
164
+ importing anything from here.
165
+
166
+ ## Testing
167
+
168
+ `sillo.wire.testing` ships the piece unit tests are missing — a socket:
169
+
170
+ ```python
171
+ from sillo.wire import Hub, Peer
172
+ from sillo_wire.testing import FakeSocket, drain
173
+
174
+ async def test_a_broadcast_reaches_the_room():
175
+ hub, socket = Hub(), FakeSocket()
176
+ peer = Peer(socket)
177
+ await hub.join(peer, "lobby")
178
+
179
+ await hub.broadcast("lobby", {"hello": True})
180
+ await drain(peer) # broadcasts enqueue; this waits for the write
181
+
182
+ assert socket.sent == [{"hello": True}]
183
+ ```
184
+
185
+ `FakeSocket(delay=…)` simulates a client that is slow to read, and
186
+ `FakeSocket(fail=True)` one that has gone away — the two cases that are hardest
187
+ to reproduce against a real server and the two most worth testing.
188
+
189
+ ## Reference
190
+
191
+ | | |
192
+ |---|---|
193
+ | `Hub` | `join` `leave` `leave_all` `disconnect` `broadcast` `send_to` `replay` `history` `clear_history` `on_join` `on_leave` `rooms` `members` `identities` `count` `prune` `close` |
194
+ | `Peer` | `offer` `send` `start` `close` `is_idle` `closed` `pending` `identity` |
195
+ | `Envelope` | `payload` `room` `seq` `sent_at` `size()` |
196
+ | `DeliveryReport` | `delivered` `dropped` `failed` `attempted` |
197
+ | `Backlog` | `MemoryBacklog` `NullBacklog`, or your own |
198
+ | `Overflow` | `DROP_OLDEST` `DROP_NEWEST` `CLOSE` |
199
+
200
+ ## The two import paths
201
+
202
+ `sillo.wire` and `sillo_wire` name the same objects. The code lives in the
203
+ top-level `sillo_wire` package; `sillo.wire` is an alias, so it reads as part
204
+ of the framework:
205
+
206
+ ```python
207
+ from sillo.wire import Hub # both of these
208
+ from sillo_wire import Hub # bind the same class
209
+ ```
210
+
211
+ The alias is a meta-path finder registered by a `.pth` at interpreter startup —
212
+ the only hook that runs before an `import sillo.wire` could fail. Type checkers
213
+ never run import hooks, so they are served separately by the partial stubs in
214
+ `sillo-stubs/` (PEP 561), which are additive: mypy resolves `sillo.wire` and
215
+ still uses the framework's own inline types for the rest of `sillo`.
216
+
217
+ Nothing is written into the framework's package directory. Shipping
218
+ `sillo/wire/` in there would be simpler, and it is what this did first — but
219
+ two distributions sharing one directory goes wrong in both directions.
220
+ Installing the framework from a checkout moves where `sillo` resolves and
221
+ orphans the copy in site-packages; removing or replacing the framework leaves
222
+ that directory standing with no `__init__.py`, which is an override rather than
223
+ an addition. Uninstalling either package here leaves the other exactly as it
224
+ was.
225
+
226
+ ## Working on it
227
+
228
+ The alias works under an editable install too — the `.pth` is shipped by the
229
+ editable build target as well as the wheel.
230
+
231
+ ```bash
232
+ pip install -e ".[dev]"
233
+ pytest --cov # 100% required, bootstrap included
234
+ ruff check sillo_wire tests _sillo_wire_bootstrap.py
235
+ mypy sillo_wire
236
+ ```
237
+
238
+ ## Requirements
239
+
240
+ Python 3.10+, `sillo-framework` 0.3 or newer. No other dependencies.
241
+
242
+ ## Licence
243
+
244
+ BSD-3-Clause.