Skip to content

Split Pub/Sub SDK into ably-pubsub-core and ably-pubsub-server - #683

Draft
umair-ably wants to merge 7 commits into
integration/v4from
pubsub-split/restructure
Draft

Split Pub/Sub SDK into ably-pubsub-core and ably-pubsub-server#683
umair-ably wants to merge 7 commits into
integration/v4from
pubsub-split/restructure

Conversation

@umair-ably

@umair-ably umair-ably commented Sep 3, 2026

Copy link
Copy Markdown

Implements PDR-091b (PubSub package split) for the Python SDK — plan steps 11, 12, 13, 14, 14b, 14c of plan.md, which is row 1 of the PR stack in step 10b.

What and why

Today an application installs ably and gets one package regardless of where it runs. PDR-091 makes the package name the declaration of the side: the client you reach for is the one whose package matches where your code runs, and — because a server connection is exempt from monthly-active-user counting — that declaration has to reach the wire, not just the README.

PDR-091b settles how: new majors on a new core, not thin wrappers over the old package. The ably distribution is not touched, is not re-exported, and reaches EOL a year after GA; fixes for it ship from a maintenance branch.

So this repo stops publishing ably and starts publishing two distributions:

Distribution Ships For
ably-pubsub-core ably_pubsub/core/** internal — no direct dependency, no API stability promise
ably-pubsub-server ably_pubsub/server/** what applications install

Layout

pyproject.toml                              # workspace root only, nothing publishable
core/pyproject.toml                         # ably-pubsub-core   4.0.0
core/src/ably_pubsub/core/**                # today's ably/**, moved with `git mv`
core/src/ably_pubsub/core/sync/             # generated by unasync, gitignored
server/pyproject.toml                       # ably-pubsub-server 4.0.0
server/src/ably_pubsub/server/__init__.py   # the doors + enumerated re-exports
server/src/ably_pubsub/server/sync.py       # hand-written sync door
test/                                       # stays at the root, runs against both members

Three decisions worth calling out, all recorded in the plan:

  • The import namespace is ably_pubsub, not ably. This is the Python-specific problem Ruby and JS did not have. The legacy distribution owns the top-level ably/ import package; if the core shipped ably/ too, pip would install both file sets into one directory and uninstalling either would delete the other's files. An environment mid-migration — one venv serving two services, or a transitive dependency still on ably — has to keep both importable. ably_pubsub is a PEP 420 namespace with no __init__.py in either source tree, so the two wheels each contribute a subpackage and never claim the same file.
  • 4.0.0 for both, lockstep forever. ably reached 3.1.2; starting over at 1.0.0 would read as a downgrade. The server pins ably-pubsub-core==4.0.0 exactly — the analogue of ably-js's exact peer dependency — so there can never be two cores in one environment. Locally [tool.uv.sources] resolves that pin to the checkout, and one uv sync gives a venv with both members editable.
  • Class names do not change here. AblyRest / AblyRealtime stay, so this diff is a restructure and nothing else. The 091d public-API rename is a later, mechanical PR (step 18) and is not blocking on this.

requires-python becomes >=3.8, aligning metadata with what CI already tests; only the 3.7 dependency branches and two 3.7 mock shims go. crypto, vcdiff and oldcrypto all survive and forward from server to core at the same exact pin.

The doors

from ably_pubsub.server import create_http_client, create_realtime_client
from ably_pubsub.server.sync import create_http_client  # no event loop; no sync realtime client

Each takes exactly the keyword arguments the constructor it wraps takes today — the same key/token/token_details disambiguation, reused rather than reimplemented — so the migration is a call rewrite, not a re-read of the options docs. An options-object-first signature like ably-js and ably-ruby use was considered and rejected as un-Pythonic.

The types consumers need are enumerated re-exports (not a star import), mirroring ably-js's core-exports, so ably_pubsub.core never has to be imported directly.

The wire

Ably-Agent: ably-pubsub-python/4.0.0 python/3.12.1 ably-pubsub-server
  • The family identifier is renamed ably-pythonably-pubsub-python, still versioned with lib_version. It lands here, before any prerelease, so even prerelease traffic partitions cleanly from legacy ably-python/* traffic. The maintenance branch keeps ably-python.
  • Options gains an additive agents: dict[str, str | None] client option. HttpUtils.default_headers() renders each entry as name/version, or as a bare flag when the version is None — matching how the agents registry records entries that carry no version of their own, like browser. Both HTTP requests and the websocket handshake already funnel through that one function, so both paths are stamped identically.
  • The server doors stamp ably-pubsub-server with no version, applied last so it wins a collision on its own identifier — the side is the package's to declare, not the caller's to redefine. Caller-supplied agents are preserved, so an SDK layered on top keeps its attribution.
  • The -server suffix is load-bearing: realtime grants the MAU server exemption by matching an agent entry ending in it. There is a comment saying exactly that where the constant is defined, copied in spirit from ably-ruby's server.rb and ably-js's packages/shared/side.ts.

Both identifiers are registered in ably-common#361.

Commits

Deliberately split so each is diffable on its own:

Move the ably package to core/src/ably_pubsub/core pure git mv + mechanical import rewrite, no behaviour
Build two distributions from a uv workspace pyprojects, READMEs, unasync paths, gitignore, ruff
Rename the agent family identifier and make agents extensible the core-side agent work
Add the ably-pubsub-server factory doors the new package
Test the factory doors, the agent header and the packaging invariants
Point CI at the workspace and check the built distributions

Verification

Run locally on macOS / CPython 3.14.6 against nonprod:sandbox.

  • uv run ruff check — clean.
  • uv run unasync — regenerates core/src/ably_pubsub/core/sync/ and test/ably/sync/ against the new paths; the import rewrite keys on the full ably_pubsub.core prefix so it cannot reach into the hand-written ably_pubsub.server.
  • uv run pytest test/unit — 104 passed. That includes the three new modules:
    • pubsub_server_test.py — 16 tests. The doors return the core's clients and pass options through; the Ably-Agent value from create_http_client, from the sync door, and from the headers create_realtime_client's transport hands to websockets.connect all match ^ably-pubsub-python/\d+\.\d+\.\d+(\S*)? python/\S+ ably-pubsub-server$ and contain no ably-pubsub-server/ token (the name/None regression ably-js#2297 guards against); a bare core AblyRest declares no side; agents={'my-sdk': '1.0'} survives; agents={'ably-pubsub-server': 'x'} cannot override the side entry.
    • pubsub_packaging_test.py — 11 tests, including one that actually builds both wheels and asserts their file lists do not overlap, that the core wheel carries ably_pubsub/core/sync/, and that neither carries ably_pubsub/__init__.py.
    • pubsub_reexport_test.py — the server's __all__ stays level with the core's public surface.
  • uv build for both packages — ably_pubsub_core-4.0.0 (wheel + sdist, 57 files under ably_pubsub/core/sync/) and ably_pubsub_server-4.0.0 (wheel + sdist, exactly ably_pubsub/server/__init__.py and sync.py). No overlap, no ably_pubsub/__init__.py.
  • Full suite including the sandbox tests (uv run unasync && uv run pytest, against nonprod:sandbox) — 1284 passed, 2 skipped, 0 failed, in about 14 minutes. That covers the async and generated-sync flavours of every REST test, the realtime suite, and both protocols via VaryByProtocolTestsMetaclass.
    • The first run reported 4 failures and 12 errors, all in restcrypto_test.py and restchannelpublish_test.py's interoperability tests. They were the submodules/ (ably-common) checkout being absent from my worktree, not a regression: once the submodule was initialised, all 128 of those tests pass. CI already checks submodules out recursively.

test/ably/rest/resthttp_test.py's RSC7d assertion was rewritten for the new family identifier.

Out of scope / follow-ups

  • PR 2 — release tooling (plan step 15): release.yml still builds and publishes the single ably distribution and does not work against the workspace. It carries a TODO saying so. The artifact checks it used to run at tag time have moved into check.yml, where they now run on every PR.
  • PR 5 — docs: the root README.md, UPDATING.md, CHANGELOG.md and CONTRIBUTING.md are untouched. Each distribution has its own README as its readme; LONG_DESCRIPTION.rst is deleted.
  • PR 6 — 091d rename: AblyRest/AblyRealtimeHttpClient/RealtimeClient, ably_pubsub.core.rest.http, and the deprecated-surface deletions, gated on that DR being decided.
  • Repo rename, PyPI trusted publishers and the prerelease are steps 16–21 and unchanged by this PR.
  • .ably/capabilities.yaml already declares Agent Identifier: Agents, so it needed no change.

🤖 Generated with Claude Code

umair-ably and others added 6 commits September 3, 2026 13:21
Pure relocation plus the mechanical import rewrite that follows from it. The
PubSub package split (PDR-091b) builds two distributions out of this repo, so
today's single `ably` import package becomes `ably_pubsub.core` under a
`core/` workspace member laid out src-style.

`git mv` throughout so history follows the files. Every `from ably.x import y`
becomes `from ably_pubsub.core.x import y`, and the dotted module paths that
appear as strings — mock.patch targets, logger names, and the unasync
generator's own replacement tables — move with them. No behaviour changes here;
the packaging, the agent header and the new server package land separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PDR-091b splits the Pub/Sub SDK so the package an application installs names
the side it runs on, and builds the new packages on a new core rather than on
the existing `ably` distribution. This repo therefore stops publishing `ably`
and starts publishing two distributions:

- `ably-pubsub-core` (core/) ships `ably_pubsub/core/**`. Its description says
  plainly that it is an internal implementation package.
- `ably-pubsub-server` (server/) ships `ably_pubsub/server/**` and pins
  `ably-pubsub-core==4.0.0` exactly, the analogue of ably-js's exact peer
  dependency: lockstep versions, and never two cores in one environment.

`ably_pubsub` is a PEP 420 namespace with no `__init__.py` in either source
tree, so the two wheels can each contribute a subpackage to it and pip never
has two distributions writing the same files. That is also why the import
namespace is not `ably`: an environment mid-migration may hold both `ably` 3.x
and this SDK, and they must not overwrite each other.

Both distributions start at 4.0.0 — 3.1.2 is what `ably` reached, and starting
over at 1.0.0 would read as a downgrade. `requires-python` becomes ">=3.8",
which is what CI already tests; only the 3.7 dependency branches and the two
3.7 mock shims go. The extras (crypto, vcdiff, oldcrypto) forward from server
to core at the same exact pin.

The root pyproject is now a workspace root only: no publishable [project],
just the members, the dev dependency group and the shared pytest/ruff
configuration. Locally `[tool.uv.sources]` resolves the pin to the checkout, so
`uv sync` gives one venv with both members editable. ruff gains an explicit
target-version, which it can no longer infer without a [project] table.

unasync now generates `core/src/ably_pubsub/core/sync` from
`core/src/ably_pubsub/core`. Its import rewrite keys on the full
`ably_pubsub.core` prefix rather than the namespace root, so it cannot reach
into `ably_pubsub.server`, whose sync door is hand-written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Ably-Agent header is how the platform tells SDKs apart, and after the
package split it is also how a client declares which side it runs on. Two
changes, both in the core.

The family identifier becomes `ably-pubsub-python`, still versioned with
lib_version. It lands on the integration branch before any prerelease ships so
that even prerelease traffic partitions cleanly from legacy `ably-python/*`
traffic; the maintenance branch keeps `ably-python`. Registered in
ably-common#361.

`Options` gains an additive `agents: dict[str, str | None]` client option, and
`HttpUtils.default_headers()` renders each entry as `name/version`, or as a
bare flag when the version is None — matching how the agents registry records
entries that carry no version of their own, such as `browser`. Wire shape:

    ably-pubsub-python/4.0.0 python/3.12.1 ably-pubsub-server

Both the HTTP request path and the websocket handshake already funnel through
default_headers(), so both now pass the options' agents through and the header
is stamped identically on either transport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry points of the server distribution. `create_http_client()` and
`create_realtime_client()` in `ably_pubsub.server`, and `create_http_client()`
in `ably_pubsub.server.sync`, each take exactly what the constructor they wrap
takes today — the same keyword arguments, the same key/token/token_details
disambiguation — so nothing is lost in translation and the migration is a call
rewrite, not a re-read of the options documentation.

What the factories add is the agent entry `ably-pubsub-server`, stamped
without a version because the versioned `ably-pubsub-python` entry sits beside
it. The `-server` suffix is load-bearing: realtime grants the MAU server
exemption by matching an agent entry ending in it. There is a comment saying so
where the constant is defined, as ably-js and ably-ruby have.

A caller's own `agents` entries are preserved so that an SDK layered on top of
this package keeps its attribution, but the side entry is merged last and wins
a collision on its own identifier: which side the package declares is the
package's to state, not the caller's to redefine.

The types consumers need are enumerated re-exports, not a star import, so that
`ably_pubsub.core` — an internal package with no API stability promise — never
has to be imported directly. The sync door is hand-written against the
generated `ably_pubsub.core.sync`; there is no synchronous realtime client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three new unit test modules, none of which need the network.

`pubsub_server_test.py` covers the doors — the clients they return, the
arguments they pass through — and then the header they exist to stamp. The
agent assertions are written to fail loudly, because this is what billing
reads: the whole `Ably-Agent` value is matched against an anchored pattern
rather than searched for a substring, `ably-pubsub-server/` in its versioned
form is asserted absent (the `name/None` regression ably-js#2297 guards
against), and the websocket case asserts on the headers the transport hands to
`websockets.connect` rather than on the seam that produced them. A caller's own
agents survive; a caller cannot claim the side entry; a bare core client
declares no side at all.

`pubsub_packaging_test.py` asserts what a release would otherwise be the first
thing to check: no `ably_pubsub/__init__.py` in either source tree, the version
sites and the server's core pin all agreeing, every extra forwarding at that
same pin, and — by building both wheels — that their file lists do not overlap
and that the core carries the generated `ably_pubsub/core/sync/`.

`pubsub_reexport_test.py` keeps the server's enumerated re-exports level with
the core's public surface, so a type added to the core cannot end up reachable
only through the internal package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`uv sync` on the workspace root installs the dev dependency group, which pulls
both members in editable with the core's crypto and vcdiff extras — so one
command replaces the old `--extra` pair in check.yml and lint.yml. Both
workflows also run on pushes to integration/v4, the branch this work lands on.

check.yml gains a step that builds both distributions and asserts the two
invariants a namespace mistake would otherwise hide until after publish: the
core artifacts contain the generated `ably_pubsub/core/sync/`, and neither
wheel contains `ably_pubsub/__init__.py`. This is the check release.yml used to
carry, moved to where it runs on every pull request rather than once at a tag.

release.yml is otherwise left alone, with a note saying so: reworking it into a
lockstep release of the two distributions is the next PR on this branch.

The release skill's version-site list becomes the four sites this repo now has,
including the server's exact pin on the core, and its changelog links stop
pointing at ably-java.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The docs pass (#686) found the gap: 3.x users deep-imported `Message`,
`PresenceMessage` and `TokenRequest` from `ably.types.*`, and after the split
the only place those live is `ably_pubsub.core` — which the docs tell people
never to import. A type that is not re-exported here is not nameable at all by
supported means, which makes type hints and direct construction impossible.

Adds the value types a consumer legitimately names — the message, presence,
channel-detail, state-change and stats types, the two paginated result
containers, and `TokenRequest` — plus the four client-reachable object types
(`Channel`, `RealtimeChannel`, `Connection`, `RealtimePresence`) that turn up
in annotations. The sync door gets the same, minus the realtime types that have
no synchronous counterpart, and with unasync's renames applied
(`PaginatedResultSync`, `HttpPaginatedResponseSync`, `ChannelSync`).

Deliberately not included: transports, the connection manager, HTTP utilities,
encoding buffers and the encode/decode mixins. Those are implementation, and
091d may delete or reshape them.

`pubsub_reexport_test.py` now guards the list by name in both flavours, so a
future trim of the public surface has to drop a name on purpose rather than by
omission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@umair-ably

Copy link
Copy Markdown
Author

Follow-up from the docs pass (#686), pushed as c415b95.

#686 found that Message, PresenceMessage and TokenRequest were not reachable from ably_pubsub.server. 3.x users deep-imported those from ably.types.* (UPDATING.md literally shows from ably.types.message import Message), and after the split the only place they live is ably_pubsub.core — which the README tells people never to import. A type that is not re-exported is not nameable by supported means at all, so type hints and direct construction were impossible.

I grepped the test suite and UPDATING.md/CHANGELOG.md for what users are actually shown, and added everything a consumer would reasonably annotate or construct.

Added to ably_pubsub.server (22 names, __all__ now 49):

messages Message, MessageAnnotations
presence Presence, PresenceMessage, PresenceAction
tokens TokenRequest (TokenDetails was already there)
channel metadata ChannelDetails, ChannelStatus, ChannelOccupancy, ChannelMetrics — the full shape channel.status() returns
state ChannelState, ChannelStateChange, ConnectionState, ConnectionEvent, ConnectionStateChange
results PaginatedResult, HttpPaginatedResponse, Stats
client-reachable objects Channel, RealtimeChannel, Connection, RealtimePresence

Added to ably_pubsub.server.sync (14 names, __all__ now 39): the same, minus the realtime object and state types which have no synchronous counterpart, and with unasync's renames applied — ChannelSync, PaginatedResultSync, HttpPaginatedResponseSync.

Two notes on the audit:

  • There is no TokenParams class. Token params are passed as plain dicts throughout (auth.request_token(token_params={...})), so there is nothing to export. Worth saying explicitly in the docs rather than leaving readers hunting for it.
  • ErrorInfo has no separate type in PythonAblyException (already exported, along with AblyAuthException and IncompatibleClientIdException) carries code/status_code and is the equivalent.

Deliberately not added: transports, the connection manager, HTTP utilities, TypedBuffer/DataType/CipherData, EncodeDataMixin/DecodingContext/DeltaExtras, ConnectionDetails, and Flag. Those are implementation rather than surface, and 091d may delete or reshape them. Say the word if the docs need any of them and I will add them.

pubsub_reexport_test.py now pins the consumer-reachable list by name for both flavours, so a future trim of the public surface has to drop something on purpose rather than by omission.

Verification: uv run ruff check clean; uv run pytest test/unit 106 passed; test/unit plus the sandbox tests that touch these types (resthttp, restchannelstatus, reststats, restpresence) 186 passed.

umair-ably added a commit that referenced this pull request Sep 3, 2026
#683 added the missing re-exports, so the deep-import caveat is wrong:
Message, MessageAnnotations, Presence, PresenceMessage, PresenceAction,
TokenRequest, ChannelDetails/Status/Occupancy/Metrics, ChannelState,
ChannelStateChange, ConnectionState, ConnectionEvent,
ConnectionStateChange, PaginatedResult, HttpPaginatedResponse, Stats,
Channel, RealtimeChannel, Connection and RealtimePresence are all on
ably_pubsub.server now. Replaced the caveat with a deep-import mapping
table, one row per 3.x submodule, and listed the full supported surface
verbatim from __all__.

Also documents what the sync module omits (realtime and the state
types) and the three Sync-suffixed names, plus two names a reader will
go looking for and not find in either version: TokenParams is not a
class here (token params are plain dicts), and there is no separate
ErrorInfo -- AblyException carries code and status_code.

Every row was executed as an import against the workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant