Skip to content

Commit ce11963

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 ce11963

22 files changed

Lines changed: 992 additions & 45 deletions

File tree

.claude/skills/release/SKILL.md

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,40 @@ Compute the new version by incrementing the appropriate component of the current
1515
Then perform these steps in order:
1616

1717
1. Run `git checkout -b release/NEW_VERSION`
18-
2. Replace `OLD_VERSION` with `NEW_VERSION` in all of the following files:
19-
- `pyproject.toml` — the `version` property
20-
- `ably/__init__.py` — lib_version value
21-
3.Run `uv sync` to update `uv.lock` file
22-
4. Commit all files together with message: `chore: bump version to NEW_VERSION`
23-
3. Fetch merged PRs since the last release tag using:
18+
19+
2. Replace `OLD_VERSION` with `NEW_VERSION` everywhere it appears in these files. This
20+
repository publishes three distributions — `ably`, `ably-pubsub-server` and
21+
`ably-pubsub-device` — which release in lockstep on the same version, so every one of
22+
these must move together:
23+
24+
| File | What to change |
25+
|----------------------------------------------|---------------------------------------------------------------------|
26+
| `pyproject.toml` | the `version` property |
27+
| `ably/__init__.py` | `lib_version` |
28+
| `packages/ably-pubsub-server/pyproject.toml` | the `version` property **and** every `ably==` / `ably[extra]==` pin |
29+
| `packages/ably-pubsub-device/pyproject.toml` | the `version` property **and** every `ably==` / `ably[extra]==` pin |
30+
| `ably/pubsub/server/__init__.py` | `__version__` |
31+
| `ably/pubsub/device/__init__.py` | `__version__` |
32+
33+
The pins in `packages/*/pyproject.toml` are easy to miss: each of those files carries the
34+
version four times over (its own `version`, the `ably==` dependency, and the `oldcrypto`,
35+
`crypto` and `vcdiff` extras). Confirm with `grep -rn OLD_VERSION` that nothing is left
36+
behind before moving on — the old version must appear nowhere except `CHANGELOG.md` and
37+
`uv.lock`.
38+
39+
3. Run `uv sync` to update the `uv.lock` file.
40+
41+
4. Verify the bump is complete and consistent by running:
42+
```
43+
uv run pytest test/unit/pubsub_packaging_test.py -q
44+
```
45+
These tests assert that all three distributions carry the same version and that each
46+
wrapper pins the core exactly, so they fail if any location was missed. Do not continue
47+
until they pass.
48+
49+
5. Commit all changed files together with message: `chore: bump version to NEW_VERSION`
50+
51+
6. Fetch merged PRs since the last release tag using:
2452
```
2553
gh pr list --state merged --base main --json number,title,mergedAt --limit 200
2654
```
@@ -30,23 +58,30 @@ Then perform these steps in order:
3058
```
3159
Filter the PRs to only those merged after that tag date. Format each as:
3260
```
33-
- Short, one sentence summary from PR title and description [#NUMBER](https://github.com/ably/ably-java/pull/NUMBER)
61+
- Short, one sentence summary from PR title and description [#NUMBER](https://github.com/ably/ably-python/pull/NUMBER)
3462
```
3563
If the tag doesn't exist or there are no merged PRs, use a single `-` placeholder bullet instead.
3664

37-
4. In `CHANGELOG.md`, insert the following block immediately after the `# Change Log` heading (and its trailing blank line), before the first existing `## [` version entry:
65+
7. In `CHANGELOG.md`, insert the following block immediately after the `# Change Log` heading
66+
(and its trailing blank line), before the first existing `## [` version entry:
67+
68+
```
69+
## [NEW_VERSION](https://github.com/ably/ably-python/tree/vNEW_VERSION)
3870
39-
```
40-
## [NEW_VERSION](https://github.com/ably/ably-java/tree/vNEW_VERSION)
71+
[Full Changelog](https://github.com/ably/ably-python/compare/vOLD_VERSION...vNEW_VERSION)
4172
42-
[Full Changelog](https://github.com/ably/ably-java/compare/vOLD_VERSION...vNEW_VERSION)
73+
### What's Changed
4374
44-
### What's Changed
75+
BULLETS_FROM_STEP_6
4576
46-
BULLETS_FROM_STEP_3
77+
```
4778
48-
```
79+
8. Commit `CHANGELOG.md` with message: `docs: update CHANGELOG for NEW_VERSION release`
4980
50-
5. Commit `CHANGELOG.md` with message: `docs: update CHANGELOG for NEW_VERSION release`
81+
After completing all steps, show the user a summary of what was done, including the list of
82+
files whose version was bumped. If PRs were found, list them. If the placeholder `-` was used
83+
instead, remind them to fill in the `### What's Changed` bullet points in `CHANGELOG.md`
84+
before merging.
5185
52-
After completing all steps, show the user a summary of what was done. If PRs were found, list them. If the placeholder `-` was used instead, remind them to fill in the `### What's Changed` bullet points in `CHANGELOG.md` before merging.
86+
Also remind them that a new distribution added to `packages/` in future must be added to the
87+
table in step 2, or its version will silently drift out of lockstep with the others.

.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).

0 commit comments

Comments
 (0)