Split Pub/Sub SDK into ably-pubsub-core and ably-pubsub-server - #683
Split Pub/Sub SDK into ably-pubsub-core and ably-pubsub-server#683umair-ably wants to merge 7 commits into
Conversation
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>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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>
|
Follow-up from the docs pass (#686), pushed as #686 found that I grepped the test suite and Added to
Added to Two notes on the audit:
Deliberately not added: transports, the connection manager, HTTP utilities,
Verification: |
#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>
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
ablyand 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
ablydistribution 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
ablyand starts publishing two distributions:ably-pubsub-coreably_pubsub/core/**ably-pubsub-serverably_pubsub/server/**Layout
Three decisions worth calling out, all recorded in the plan:
ably_pubsub, notably. This is the Python-specific problem Ruby and JS did not have. The legacy distribution owns the top-levelably/import package; if the core shippedably/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 onably— has to keep both importable.ably_pubsubis a PEP 420 namespace with no__init__.pyin either source tree, so the two wheels each contribute a subpackage and never claim the same file.4.0.0for both, lockstep forever.ablyreached 3.1.2; starting over at 1.0.0 would read as a downgrade. The server pinsably-pubsub-core==4.0.0exactly — 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 oneuv syncgives a venv with both members editable.AblyRest/AblyRealtimestay, 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-pythonbecomes>=3.8, aligning metadata with what CI already tests; only the 3.7 dependency branches and two 3.7mockshims go.crypto,vcdiffandoldcryptoall survive and forward from server to core at the same exact pin.The doors
Each takes exactly the keyword arguments the constructor it wraps takes today — the same
key/token/token_detailsdisambiguation, 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, soably_pubsub.corenever has to be imported directly.The wire
ably-python→ably-pubsub-python, still versioned withlib_version. It lands here, before any prerelease, so even prerelease traffic partitions cleanly from legacyably-python/*traffic. The maintenance branch keepsably-python.Optionsgains an additiveagents: dict[str, str | None]client option.HttpUtils.default_headers()renders each entry asname/version, or as a bare flag when the version isNone— matching how the agents registry records entries that carry no version of their own, likebrowser. Both HTTP requests and the websocket handshake already funnel through that one function, so both paths are stamped identically.ably-pubsub-serverwith 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-suppliedagentsare preserved, so an SDK layered on top keeps its attribution.-serversuffix 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'sserver.rband ably-js'spackages/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/coregit mv+ mechanical import rewrite, no behaviourBuild two distributions from a uv workspaceRename the agent family identifier and make agents extensibleAdd the ably-pubsub-server factory doorsTest the factory doors, the agent header and the packaging invariantsPoint CI at the workspace and check the built distributionsVerification
Run locally on macOS / CPython 3.14.6 against
nonprod:sandbox.uv run ruff check— clean.uv run unasync— regeneratescore/src/ably_pubsub/core/sync/andtest/ably/sync/against the new paths; the import rewrite keys on the fullably_pubsub.coreprefix so it cannot reach into the hand-writtenably_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; theAbly-Agentvalue fromcreate_http_client, from the sync door, and from the headerscreate_realtime_client's transport hands towebsockets.connectall match^ably-pubsub-python/\d+\.\d+\.\d+(\S*)? python/\S+ ably-pubsub-server$and contain noably-pubsub-server/token (thename/Noneregression ably-js#2297 guards against); a bare coreAblyRestdeclares 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 carriesably_pubsub/core/sync/, and that neither carriesably_pubsub/__init__.py.pubsub_reexport_test.py— the server's__all__stays level with the core's public surface.uv buildfor both packages —ably_pubsub_core-4.0.0(wheel + sdist, 57 files underably_pubsub/core/sync/) andably_pubsub_server-4.0.0(wheel + sdist, exactlyably_pubsub/server/__init__.pyandsync.py). No overlap, noably_pubsub/__init__.py.uv run unasync && uv run pytest, againstnonprod: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 viaVaryByProtocolTestsMetaclass.restcrypto_test.pyandrestchannelpublish_test.py's interoperability tests. They were thesubmodules/(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
release.ymlstill builds and publishes the singleablydistribution 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 intocheck.yml, where they now run on every PR.README.md,UPDATING.md,CHANGELOG.mdandCONTRIBUTING.mdare untouched. Each distribution has its own README as itsreadme;LONG_DESCRIPTION.rstis deleted.AblyRest/AblyRealtime→HttpClient/RealtimeClient,ably_pubsub.core.rest→.http, and the deprecated-surface deletions, gated on that DR being decided..ably/capabilities.yamlalready declaresAgent Identifier: Agents, so it needed no change.🤖 Generated with Claude Code