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.
- sillo_wire-0.1.0.dev1/.github/workflows/ci.yml +76 -0
- sillo_wire-0.1.0.dev1/.github/workflows/release.yml +103 -0
- sillo_wire-0.1.0.dev1/.gitignore +15 -0
- sillo_wire-0.1.0.dev1/CHANGELOG.md +41 -0
- sillo_wire-0.1.0.dev1/LICENSE +27 -0
- sillo_wire-0.1.0.dev1/PKG-INFO +244 -0
- sillo_wire-0.1.0.dev1/README.md +212 -0
- sillo_wire-0.1.0.dev1/_sillo_wire_bootstrap.py +104 -0
- sillo_wire-0.1.0.dev1/pyproject.toml +87 -0
- sillo_wire-0.1.0.dev1/sillo-stubs/py.typed +1 -0
- sillo_wire-0.1.0.dev1/sillo-stubs/wire.pyi +48 -0
- sillo_wire-0.1.0.dev1/sillo_wire/__init__.py +58 -0
- sillo_wire-0.1.0.dev1/sillo_wire/backlog.py +140 -0
- sillo_wire-0.1.0.dev1/sillo_wire/consumer.py +177 -0
- sillo_wire-0.1.0.dev1/sillo_wire/envelope.py +92 -0
- sillo_wire-0.1.0.dev1/sillo_wire/errors.py +30 -0
- sillo_wire-0.1.0.dev1/sillo_wire/hub.py +315 -0
- sillo_wire-0.1.0.dev1/sillo_wire/peer.py +231 -0
- sillo_wire-0.1.0.dev1/sillo_wire/policy.py +42 -0
- sillo_wire-0.1.0.dev1/sillo_wire/py.typed +0 -0
- sillo_wire-0.1.0.dev1/sillo_wire/testing.py +86 -0
- sillo_wire-0.1.0.dev1/sillo_wire.pth +1 -0
- sillo_wire-0.1.0.dev1/tests/test_alias.py +115 -0
- sillo_wire-0.1.0.dev1/tests/test_backlog.py +109 -0
- sillo_wire-0.1.0.dev1/tests/test_consumer.py +261 -0
- sillo_wire-0.1.0.dev1/tests/test_envelope.py +73 -0
- sillo_wire-0.1.0.dev1/tests/test_hub.py +397 -0
- sillo_wire-0.1.0.dev1/tests/test_peer.py +213 -0
- sillo_wire-0.1.0.dev1/tests/test_testing_helpers.py +73 -0
- sillo_wire-0.1.0.dev1/uv.lock +879 -0
|
@@ -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,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.
|