Skip to content

Commit c278dd9

Browse files
ttypicclaude
andcommitted
pubsub: add server and device packages
PDR-091 splits the Pub/Sub SDKs so that the package an application installs names the side it runs on. Ship that for Python as two thin additive distributions over the existing package, which takes on the role of the shared core and is otherwise unchanged: - ably-pubsub-server provides ably.pubsub.server, with create_http_client(), create_realtime_client(), and a sync submodule - ably-pubsub-device provides ably.pubsub.device, with create_client() Both re-export the core's public surface and return its clients unchanged, so behaviour is identical by construction. AblyRest and AblyRealtime emit a DeprecationWarning naming their replacement; they keep working and are not scheduled for removal. ably.pubsub is a PEP 420 namespace directory so that two distributions can each contribute a subpackage to it. The source stays in the shared ably/ tree because ably is a regular package, so Python resolves ably.pubsub only under the directory ably was imported from; each sdist reaches up to collect its subtree, and must therefore be built before its wheel. All three distributions publish in a single upload, since the wrappers pin the core exactly and a partial release is unusable. That requires all three PyPI projects to register the same trusted publisher. The side-declaring agent value PDR-091 also calls for is not included here: the packages name the side, but nothing distinguishes them on the wire yet. Tests cover factory pass-through and warning suppression, the deprecation in both async and generated sync flavours, re-export parity with the core, and the packaging invariants (namespace directory, version lockstep, exact core pin). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c1fe111 commit c278dd9

21 files changed

Lines changed: 941 additions & 29 deletions

File tree

.github/workflows/check.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,10 @@ jobs:
5151
run: uv run unasync
5252
- name: Test with pytest
5353
run: uv run pytest --verbose --tb=short --capture=no
54+
# Packaging metadata for the wrapper distributions is otherwise only
55+
# exercised at release time, where a mistake is expensive.
56+
- name: Check that every distribution builds
57+
run: |
58+
uv build --out-dir dist
59+
uv build packages/ably-pubsub-server --out-dir dist
60+
uv build packages/ably-pubsub-device --out-dir dist

.github/workflows/release.yml

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,28 +35,35 @@ jobs:
3535
run: uv sync --extra crypto --extra dev
3636
- name: Generate rest sync code and tests
3737
run: uv run unasync
38+
# All three distributions build into one directory and are uploaded in a
39+
# single request, so that a release is all of them or none of them. The
40+
# wrappers pin the core exactly, so a partial release is an unusable one.
3841
- name: Build a binary wheel and a source tarball
39-
run: uv build
42+
run: |
43+
uv build --out-dir dist
44+
uv build packages/ably-pubsub-server --out-dir dist
45+
uv build packages/ably-pubsub-device --out-dir dist
4046
- name: Store the distribution packages
4147
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
4248
with:
4349
name: python-package-distributions
4450
path: dist/
51+
# The wrappers normalise to ably_pubsub_*, so ably-* selects the core alone
4552
- name: Check that wheel and tarball contains ably/sync/
4653
run: |
4754
# Check wheel
48-
WHEEL=$(ls dist/*.whl | head -n 1)
55+
WHEEL=$(ls dist/ably-*.whl | head -n 1)
4956
echo "Checking wheel: $WHEEL"
5057
if unzip -l "$WHEEL" | grep -q "ably/sync/"; then
5158
echo "✅ Found ably/sync/ in wheel"
5259
else
53-
unzip -l "$WHEEL"
60+
unzip -l "$WHEEL"
5461
echo "❌ ably/sync/ not found in wheel"
5562
exit 1
5663
fi
57-
64+
5865
# Check tarball
59-
TARBALL=$(ls dist/*.tar.gz | head -n 1)
66+
TARBALL=$(ls dist/ably-*.tar.gz | head -n 1)
6067
echo "Checking tarball: $TARBALL"
6168
if tar -tzf "$TARBALL" | grep -q "ably/sync/"; then
6269
echo "✅ Found ably/sync/ in tarball"
@@ -66,8 +73,29 @@ jobs:
6673
exit 1
6774
fi
6875
76+
- name: Check that all three distributions were built
77+
run: |
78+
# Publishing is a single upload, so a distribution missing here would
79+
# silently ship a release that the other two cannot be installed with.
80+
for NAME in ably ably_pubsub_server ably_pubsub_device; do
81+
for EXT in tar.gz whl; do
82+
COUNT=$(ls -1 dist/"$NAME"-*."$EXT" 2>/dev/null | wc -l)
83+
if [ "$COUNT" -ne 1 ]; then
84+
ls dist/
85+
echo "❌ expected exactly one $NAME .$EXT, found $COUNT"
86+
exit 1
87+
fi
88+
done
89+
echo "✅ $NAME"
90+
done
91+
92+
# ably, ably-pubsub-server and ably-pubsub-device go up in one upload. The
93+
# short-lived token PyPI mints from an OIDC request carries every project that
94+
# trusts the requesting configuration, so one job publishes all three — which
95+
# requires each of the three projects to register this repository, workflow
96+
# and environment as a trusted publisher.
6997
publish-to-pypi:
70-
name: Publish Python distribution to PyPI
98+
name: Publish Python distributions to PyPI
7199
if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on tag pushes
72100
needs:
73101
- build
@@ -91,27 +119,27 @@ jobs:
91119
TAG=${GITHUB_REF#refs/tags/v}
92120
echo "tag=$TAG" >> $GITHUB_OUTPUT
93121
94-
- name: Read VERSION_NAME from dist/
95-
id: version
122+
- name: Compare every distribution's version with the tag
96123
run: |
97-
VERSION_NAME=$(basename dist/ably-*.tar.gz | sed -E 's/^ably-([^-]+)\.tar\.gz$/\1/')
98-
echo "version=$VERSION_NAME" >> $GITHUB_OUTPUT
99-
100-
- name: Compare version with tag
101-
run: |
102-
if [ "$VERSION" != "$TAG" ]; then
103-
echo "VERSION ($VERSION) does not match tag ($TAG)."
104-
exit 1
105-
fi
124+
# sdist names are <normalised name>-<version>.tar.gz, and a normalised
125+
# version never contains a hyphen, so the last one starts the version.
126+
# Checking all three also catches a version that drifted out of lockstep.
127+
for TARBALL in dist/*.tar.gz; do
128+
VERSION=$(basename "$TARBALL" | sed -E 's/^.*-([^-]+)\.tar\.gz$/\1/')
129+
if [ "$VERSION" != "$TAG" ]; then
130+
echo "❌ $(basename "$TARBALL"): version ($VERSION) does not match tag ($TAG)."
131+
exit 1
132+
fi
133+
echo "✅ $(basename "$TARBALL") matches tag $TAG"
134+
done
106135
env:
107-
VERSION: ${{ steps.version.outputs.version }}
108136
TAG: ${{ steps.tag.outputs.tag }}
109137

110-
- name: Publish distribution 📦 to PyPI
138+
- name: Publish distributions 📦 to PyPI
111139
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
112140

113141
publish-to-testpypi:
114-
name: Publish Python distribution to TestPyPI
142+
name: Publish Python distributions to TestPyPI
115143
needs:
116144
- build
117145
runs-on: ubuntu-latest
@@ -129,7 +157,7 @@ jobs:
129157
with:
130158
name: python-package-distributions
131159
path: dist/
132-
- name: Publish distribution 📦 to TestPyPI
160+
- name: Publish distributions 📦 to TestPyPI
133161
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
134162
with:
135163
repository-url: https://test.pypi.org/legacy/

CONTRIBUTING.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,39 @@ git submodule update
1515
uv sync --extra crypto
1616
```
1717

18+
### Repository layout
19+
20+
This repository builds three distributions, released together on the same version. They all install into the one `ably` package, so what you import never tells you which distribution shipped it:
21+
22+
| Distribution | Source | Imported as | Role |
23+
|--------------|--------|-------------|------|
24+
| `ably` | [`ably/`](./ably), except `ably/pubsub` | `ably`, `ably.sync` | The shared core, containing all of the implementation |
25+
| `ably-pubsub-server` | [`ably/pubsub/server/`](./ably/pubsub/server) | `ably.pubsub.server` | The server-side factories |
26+
| `ably-pubsub-device` | [`ably/pubsub/device/`](./ably/pubsub/device) | `ably.pubsub.device` | The device-side factory |
27+
28+
Each side re-exports the core's public surface and adds factories that return the core's clients unchanged, so that the package a caller installs names the side their application runs on. They pin the core exactly, so any change to the core's public surface needs the corresponding re-export added to both.
29+
30+
The packaging metadata for the two pubsub distributions lives in [`packages/`](./packages), away from the code it ships. Two rules keep that arrangement working, and both are covered by [`test/unit/pubsub_packaging_test.py`](./test/unit/pubsub_packaging_test.py):
31+
32+
- **`ably/pubsub/` must not gain an `__init__.py`.** It is a namespace directory (PEP 420) so that two distributions can each contribute a subpackage to it. An `__init__.py` would belong to whichever one shipped it, and removing that distribution would take the other side's subpackage with it.
33+
- **The source stays in the shared `ably/` tree**, not beside the `pyproject.toml` that ships it. `ably` is a regular package, so Python looks for `ably.pubsub` only under the directory `ably` itself was imported from — in a checkout, that is `ably/`. Each sdist reaches up to collect its subtree, and its wheel is then built from that sdist.
34+
1835
### Running the test suite
1936

2037
```shell
2138
uv run pytest
2239
```
2340

41+
Because the pubsub code lives in the `ably/` tree, `ably.pubsub.server` and `ably.pubsub.device` import from a checkout with nothing installed beyond the core. Their tests are in [`test/unit/`](./test/unit) and need no network.
42+
43+
To build all three distributions — build the sdist first, which `uv build` does by default:
44+
45+
```shell
46+
uv build --out-dir dist
47+
uv build packages/ably-pubsub-server --out-dir dist
48+
uv build packages/ably-pubsub-device --out-dir dist
49+
```
50+
2451
## Release Process (Claude Code)
2552

2653
1. Ensure that all work intended for this release has landed to `main`
@@ -36,11 +63,13 @@ uv run pytest
3663

3764
Releases should always be made through a release pull request (PR), which needs to bump the version number and add to the [change log](CHANGELOG.md).
3865

66+
`ably`, `ably-pubsub-server` and `ably-pubsub-device` are published in a single upload, so that a release is all three or none of them — the wrappers pin the core exactly, so a partial release is an unusable one. This works because the short-lived token PyPI mints from an OIDC request carries every project that trusts the requesting configuration, which means **all three PyPI projects must register the same trusted publisher**: this repository, `release.yml`, and the `pypi` environment (and likewise `testpypi`). Adding a fourth distribution means registering it the same way before its first release, or the whole upload fails.
67+
3968
The release process must include the following steps:
4069

4170
1. Ensure that all work intended for this release has landed to `main`
4271
2. Create a release branch named like `release/2.0.1`
43-
3. Add a commit to bump the version number, updating [`pyproject.toml`](./pyproject.toml) and [`ably/__init__.py`](./ably/__init__.py)
72+
3. Add a commit to bump the version number. All three distributions release in lockstep, so this means [`pyproject.toml`](./pyproject.toml), [`ably/__init__.py`](./ably/__init__.py), and, for each pubsub distribution, its `pyproject.toml` under [`packages/`](./packages) (both its own version and its `ably==` pins) and the `__version__` in its module under [`ably/pubsub/`](./ably/pubsub). The tests in [`test/unit/pubsub_packaging_test.py`](./test/unit/pubsub_packaging_test.py) fail if any of these drift apart
4473
4. Run [`github_changelog_generator`](https://github.com/github-changelog-generator/github-changelog-generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). This may require some manual intervention, both in terms of how the command is run and how the change log file is modified. Your mileage may vary:
4574
- The command you will need to run will look something like this: `github_changelog_generator -u ably -p ably-python --since-tag v2.0.0 --output delta.md --token $GITHUB_TOKEN_WITH_REPO_ACCESS`. Generate token [here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token).
4675
- Using the command above, `--output delta.md` writes changes made after `--since-tag` to a new file
@@ -51,7 +80,7 @@ The release process must include the following steps:
5180
7. Create a release PR (ensure you include an SDK Team Engineering Lead and the SDK Team Product Manager as reviewers) and gain approvals for it, then merge that to `main`
5281
8. Create a tag named like `v2.0.1` and push it to GitHub - e.g. `git tag v2.0.1 && git push origin v2.0.1`
5382
9. Create the release on GitHub including populating the release notes
54-
10. Go to the [Release Workflow](https://github.com/ably/ably-python/actions/workflows/release.yml) and ask [ably/team-sdk](https://github.com/orgs/ably/teams/team-sdk) member to approve publishing to the PyPI registry
83+
10. Go to the [Release Workflow](https://github.com/ably/ably-python/actions/workflows/release.yml) and ask [ably/team-sdk](https://github.com/orgs/ably/teams/team-sdk) member to approve publishing to the PyPI registry. All three distributions go up in a single upload, so there is one approval for the release as a whole
5584
11. Update the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)) with these changes
5685

5786
We tend to use [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator) to collate the information required for a change log update.

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,18 @@ The following platforms are supported:
4545

4646
## Installation
4747

48-
To get started with your project, install the package:
48+
Install the package for the side your application runs on. Each pulls in `ably` and adds an entry point under `ably.pubsub` naming that side:
4949

5050
```sh
51-
pip install ably
51+
# Trusted server environments — publishing, token issuing, backend subscribers
52+
pip install ably-pubsub-server # provides ably.pubsub.server
53+
54+
# End-user devices — desktop apps, CLIs, IoT and embedded clients
55+
pip install ably-pubsub-device # provides ably.pubsub.device
5256
```
5357

58+
Installing `ably` on its own also still works, and remains fully supported. It is the shared core both build on, and the clients they return are its clients unchanged.
59+
5460
> [!NOTE]
5561
Install [Python](https://www.python.org/downloads/) version 3.8 or greater.
5662

@@ -59,8 +65,10 @@ Install [Python](https://www.python.org/downloads/) version 3.8 or greater.
5965
The following code connects to Ably's realtime messaging service, subscribes to a channel to receive messages, and publishes a test message to that same channel.
6066

6167
```python
68+
from ably.pubsub.device import create_client
69+
6270
# Initialize Ably Realtime client
63-
async with AblyRealtime('your-ably-api-key', client_id='me') as realtime_client:
71+
async with create_client('your-ably-api-key', client_id='me') as realtime_client:
6472
# Wait for connection to be established
6573
await realtime_client.connection.once_async('connected')
6674
print('Connected to Ably')
@@ -78,6 +86,21 @@ async with AblyRealtime('your-ably-api-key', client_id='me') as realtime_client:
7886
await channel.publish('test-event', 'hello world')
7987
```
8088

89+
On a server, use `ably.pubsub.server.create_realtime_client()` for the same client over a persistent connection, or `ably.pubsub.server.create_http_client()` when publish, history, presence reads, stats and token issuing over HTTP are enough. A synchronous HTTP client is available from `ably.pubsub.server.sync`.
90+
91+
### Migrating from the AblyRest and AblyRealtime constructors
92+
93+
Constructing `ably.AblyRest` or `ably.AblyRealtime` directly still works and is not scheduled for removal, but it emits a `DeprecationWarning` pointing at the factory for your side:
94+
95+
| Before | After |
96+
|--------|-------|
97+
| `ably.AblyRealtime(...)` on a device | `ably.pubsub.device.create_client(...)` |
98+
| `ably.AblyRealtime(...)` on a server | `ably.pubsub.server.create_realtime_client(...)` |
99+
| `ably.AblyRest(...)` | `ably.pubsub.server.create_http_client(...)` |
100+
| `ably.sync.AblyRestSync(...)` | `ably.pubsub.server.sync.create_http_client(...)` |
101+
102+
The factories take the same arguments as the constructors they replace and behave identically to them, so migrating is a change of entry point only.
103+
81104
## Releases
82105

83106
The [CHANGELOG.md](https://github.com/ably/ably-python/blob/main/CHANGELOG.md) contains details of the latest releases for this SDK. You can also view all Ably releases on [changelog.ably.com](https://changelog.ably.com).

ably/pubsub/device/__init__.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""The Ably Pub/Sub client for devices.
2+
3+
Devices are applications running in end-user environments — desktop apps, CLIs,
4+
IoT and embedded clients — whose connections are identified by a `client_id` and
5+
counted on accounts with monthly-active-user billing. This package names that
6+
side, so the client an application should reach for is the one whose package
7+
matches where it runs.
8+
9+
Use `create_client()` to open a realtime connection with channels, presence and
10+
history. It returns the same client `ably` does, with identical behaviour.
11+
12+
Ships in the `ably-pubsub-device` distribution, which adds this subpackage to
13+
the `ably` package installed by the `ably` distribution.
14+
"""
15+
16+
import asyncio
17+
from typing import Optional
18+
19+
from ably import (
20+
AblyAuthException,
21+
AblyException,
22+
AblyRealtime,
23+
AblyRest,
24+
AblyVCDiffDecoder,
25+
Annotation,
26+
AnnotationAction,
27+
Auth,
28+
Capability,
29+
ChannelMode,
30+
ChannelOptions,
31+
CipherParams,
32+
DeviceDetails,
33+
IncompatibleClientIdException,
34+
MessageAction,
35+
MessageOperation,
36+
MessageVersion,
37+
Options,
38+
PublishResult,
39+
Push,
40+
PushChannelSubscription,
41+
UpdateDeleteResult,
42+
VCDiffDecoder,
43+
)
44+
from ably.util.deprecation import suppress_constructor_deprecation
45+
46+
__version__ = '3.1.2'
47+
48+
49+
def create_client(key: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None,
50+
**kwargs) -> AblyRealtime:
51+
"""Create a device Pub/Sub client: a realtime connection to Ably with
52+
channels, presence and history.
53+
54+
Takes the same arguments as `ably.AblyRealtime`, and behaves identically to it.
55+
"""
56+
with suppress_constructor_deprecation():
57+
return AblyRealtime(key=key, loop=loop, **kwargs)
58+
59+
60+
__all__ = [
61+
'AblyAuthException',
62+
'AblyException',
63+
'AblyRealtime',
64+
'AblyRest',
65+
'AblyVCDiffDecoder',
66+
'Annotation',
67+
'AnnotationAction',
68+
'Auth',
69+
'Capability',
70+
'ChannelMode',
71+
'ChannelOptions',
72+
'CipherParams',
73+
'DeviceDetails',
74+
'IncompatibleClientIdException',
75+
'MessageAction',
76+
'MessageOperation',
77+
'MessageVersion',
78+
'Options',
79+
'PublishResult',
80+
'Push',
81+
'PushChannelSubscription',
82+
'UpdateDeleteResult',
83+
'VCDiffDecoder',
84+
'create_client',
85+
]

0 commit comments

Comments
 (0)