From ce119634fcc3ddaf76c487e8eb003cd4aaf48361 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 14 Aug 2026 08:45:03 +0100 Subject: [PATCH] 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) --- .claude/skills/release/SKILL.md | 67 +++++++++++---- .github/workflows/check.yml | 7 ++ .github/workflows/release.yml | 70 ++++++++++----- CONTRIBUTING.md | 33 +++++++- README.md | 29 ++++++- ably/pubsub/device/__init__.py | 85 +++++++++++++++++++ ably/pubsub/server/__init__.py | 99 ++++++++++++++++++++++ ably/pubsub/server/sync.py | 73 ++++++++++++++++ ably/realtime/realtime.py | 12 +++ ably/rest/rest.py | 18 +++- ably/scripts/unasync.py | 10 ++- ably/util/deprecation.py | 44 ++++++++++ packages/ably-pubsub-device/README.md | 38 +++++++++ packages/ably-pubsub-device/pyproject.toml | 52 ++++++++++++ packages/ably-pubsub-server/README.md | 57 +++++++++++++ packages/ably-pubsub-server/pyproject.toml | 61 +++++++++++++ pyproject.toml | 11 ++- test/ably/rest/restdeprecation_test.py | 53 ++++++++++++ test/unit/pubsub_device_test.py | 44 ++++++++++ test/unit/pubsub_packaging_test.py | 69 +++++++++++++++ test/unit/pubsub_reexport_test.py | 40 +++++++++ test/unit/pubsub_server_test.py | 65 ++++++++++++++ 22 files changed, 992 insertions(+), 45 deletions(-) create mode 100644 ably/pubsub/device/__init__.py create mode 100644 ably/pubsub/server/__init__.py create mode 100644 ably/pubsub/server/sync.py create mode 100644 ably/util/deprecation.py create mode 100644 packages/ably-pubsub-device/README.md create mode 100644 packages/ably-pubsub-device/pyproject.toml create mode 100644 packages/ably-pubsub-server/README.md create mode 100644 packages/ably-pubsub-server/pyproject.toml create mode 100644 test/ably/rest/restdeprecation_test.py create mode 100644 test/unit/pubsub_device_test.py create mode 100644 test/unit/pubsub_packaging_test.py create mode 100644 test/unit/pubsub_reexport_test.py create mode 100644 test/unit/pubsub_server_test.py diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 635f9bf8..8bd799a4 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -15,12 +15,40 @@ Compute the new version by incrementing the appropriate component of the current Then perform these steps in order: 1. Run `git checkout -b release/NEW_VERSION` -2. Replace `OLD_VERSION` with `NEW_VERSION` in all of the following files: - - `pyproject.toml` — the `version` property - - `ably/__init__.py` — lib_version value - 3.Run `uv sync` to update `uv.lock` file -4. Commit all files together with message: `chore: bump version to NEW_VERSION` -3. Fetch merged PRs since the last release tag using: + +2. Replace `OLD_VERSION` with `NEW_VERSION` everywhere it appears in these files. This + repository publishes three distributions — `ably`, `ably-pubsub-server` and + `ably-pubsub-device` — which release in lockstep on the same version, so every one of + these must move together: + + | File | What to change | + |----------------------------------------------|---------------------------------------------------------------------| + | `pyproject.toml` | the `version` property | + | `ably/__init__.py` | `lib_version` | + | `packages/ably-pubsub-server/pyproject.toml` | the `version` property **and** every `ably==` / `ably[extra]==` pin | + | `packages/ably-pubsub-device/pyproject.toml` | the `version` property **and** every `ably==` / `ably[extra]==` pin | + | `ably/pubsub/server/__init__.py` | `__version__` | + | `ably/pubsub/device/__init__.py` | `__version__` | + + The pins in `packages/*/pyproject.toml` are easy to miss: each of those files carries the + version four times over (its own `version`, the `ably==` dependency, and the `oldcrypto`, + `crypto` and `vcdiff` extras). Confirm with `grep -rn OLD_VERSION` that nothing is left + behind before moving on — the old version must appear nowhere except `CHANGELOG.md` and + `uv.lock`. + +3. Run `uv sync` to update the `uv.lock` file. + +4. Verify the bump is complete and consistent by running: + ``` + uv run pytest test/unit/pubsub_packaging_test.py -q + ``` + These tests assert that all three distributions carry the same version and that each + wrapper pins the core exactly, so they fail if any location was missed. Do not continue + until they pass. + +5. Commit all changed files together with message: `chore: bump version to NEW_VERSION` + +6. Fetch merged PRs since the last release tag using: ``` gh pr list --state merged --base main --json number,title,mergedAt --limit 200 ``` @@ -30,23 +58,30 @@ Then perform these steps in order: ``` Filter the PRs to only those merged after that tag date. Format each as: ``` - - Short, one sentence summary from PR title and description [#NUMBER](https://github.com/ably/ably-java/pull/NUMBER) + - Short, one sentence summary from PR title and description [#NUMBER](https://github.com/ably/ably-python/pull/NUMBER) ``` If the tag doesn't exist or there are no merged PRs, use a single `-` placeholder bullet instead. -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: +7. In `CHANGELOG.md`, insert the following block immediately after the `# Change Log` heading + (and its trailing blank line), before the first existing `## [` version entry: + + ``` + ## [NEW_VERSION](https://github.com/ably/ably-python/tree/vNEW_VERSION) -``` -## [NEW_VERSION](https://github.com/ably/ably-java/tree/vNEW_VERSION) + [Full Changelog](https://github.com/ably/ably-python/compare/vOLD_VERSION...vNEW_VERSION) -[Full Changelog](https://github.com/ably/ably-java/compare/vOLD_VERSION...vNEW_VERSION) + ### What's Changed -### What's Changed + BULLETS_FROM_STEP_6 -BULLETS_FROM_STEP_3 + ``` -``` +8. Commit `CHANGELOG.md` with message: `docs: update CHANGELOG for NEW_VERSION release` -5. Commit `CHANGELOG.md` with message: `docs: update CHANGELOG for NEW_VERSION release` +After completing all steps, show the user a summary of what was done, including the list of +files whose version was bumped. 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. -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. +Also remind them that a new distribution added to `packages/` in future must be added to the +table in step 2, or its version will silently drift out of lockstep with the others. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 42f6972d..6d5af8d8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -51,3 +51,10 @@ jobs: run: uv run unasync - name: Test with pytest run: uv run pytest --verbose --tb=short --capture=no + # Packaging metadata for the wrapper distributions is otherwise only + # exercised at release time, where a mistake is expensive. + - name: Check that every distribution builds + run: | + uv build --out-dir dist + uv build packages/ably-pubsub-server --out-dir dist + uv build packages/ably-pubsub-device --out-dir dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f47e6b0..6b25ba5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,28 +35,35 @@ jobs: run: uv sync --extra crypto --extra dev - name: Generate rest sync code and tests run: uv run unasync + # All three distributions build into one directory and are uploaded in a + # single request, so that a release is all of them or none of them. The + # wrappers pin the core exactly, so a partial release is an unusable one. - name: Build a binary wheel and a source tarball - run: uv build + run: | + uv build --out-dir dist + uv build packages/ably-pubsub-server --out-dir dist + uv build packages/ably-pubsub-device --out-dir dist - name: Store the distribution packages uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: python-package-distributions path: dist/ + # The wrappers normalise to ably_pubsub_*, so ably-* selects the core alone - name: Check that wheel and tarball contains ably/sync/ run: | # Check wheel - WHEEL=$(ls dist/*.whl | head -n 1) + WHEEL=$(ls dist/ably-*.whl | head -n 1) echo "Checking wheel: $WHEEL" if unzip -l "$WHEEL" | grep -q "ably/sync/"; then echo "✅ Found ably/sync/ in wheel" else - unzip -l "$WHEEL" + unzip -l "$WHEEL" echo "❌ ably/sync/ not found in wheel" exit 1 fi - + # Check tarball - TARBALL=$(ls dist/*.tar.gz | head -n 1) + TARBALL=$(ls dist/ably-*.tar.gz | head -n 1) echo "Checking tarball: $TARBALL" if tar -tzf "$TARBALL" | grep -q "ably/sync/"; then echo "✅ Found ably/sync/ in tarball" @@ -66,8 +73,29 @@ jobs: exit 1 fi + - name: Check that all three distributions were built + run: | + # Publishing is a single upload, so a distribution missing here would + # silently ship a release that the other two cannot be installed with. + for NAME in ably ably_pubsub_server ably_pubsub_device; do + for EXT in tar.gz whl; do + COUNT=$(ls -1 dist/"$NAME"-*."$EXT" 2>/dev/null | wc -l) + if [ "$COUNT" -ne 1 ]; then + ls dist/ + echo "❌ expected exactly one $NAME .$EXT, found $COUNT" + exit 1 + fi + done + echo "✅ $NAME" + done + + # ably, ably-pubsub-server and ably-pubsub-device go up in one upload. The + # short-lived token PyPI mints from an OIDC request carries every project that + # trusts the requesting configuration, so one job publishes all three — which + # requires each of the three projects to register this repository, workflow + # and environment as a trusted publisher. publish-to-pypi: - name: Publish Python distribution to PyPI + name: Publish Python distributions to PyPI if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on tag pushes needs: - build @@ -91,27 +119,27 @@ jobs: TAG=${GITHUB_REF#refs/tags/v} echo "tag=$TAG" >> $GITHUB_OUTPUT - - name: Read VERSION_NAME from dist/ - id: version + - name: Compare every distribution's version with the tag run: | - VERSION_NAME=$(basename dist/ably-*.tar.gz | sed -E 's/^ably-([^-]+)\.tar\.gz$/\1/') - echo "version=$VERSION_NAME" >> $GITHUB_OUTPUT - - - name: Compare version with tag - run: | - if [ "$VERSION" != "$TAG" ]; then - echo "VERSION ($VERSION) does not match tag ($TAG)." - exit 1 - fi + # sdist names are -.tar.gz, and a normalised + # version never contains a hyphen, so the last one starts the version. + # Checking all three also catches a version that drifted out of lockstep. + for TARBALL in dist/*.tar.gz; do + VERSION=$(basename "$TARBALL" | sed -E 's/^.*-([^-]+)\.tar\.gz$/\1/') + if [ "$VERSION" != "$TAG" ]; then + echo "❌ $(basename "$TARBALL"): version ($VERSION) does not match tag ($TAG)." + exit 1 + fi + echo "✅ $(basename "$TARBALL") matches tag $TAG" + done env: - VERSION: ${{ steps.version.outputs.version }} TAG: ${{ steps.tag.outputs.tag }} - - name: Publish distribution 📦 to PyPI + - name: Publish distributions 📦 to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 publish-to-testpypi: - name: Publish Python distribution to TestPyPI + name: Publish Python distributions to TestPyPI needs: - build runs-on: ubuntu-latest @@ -129,7 +157,7 @@ jobs: with: name: python-package-distributions path: dist/ - - name: Publish distribution 📦 to TestPyPI + - name: Publish distributions 📦 to TestPyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 with: repository-url: https://test.pypi.org/legacy/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4a97cc..0b565215 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,12 +15,39 @@ git submodule update uv sync --extra crypto ``` +### Repository layout + +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: + +| Distribution | Source | Imported as | Role | +|--------------|--------|-------------|------| +| `ably` | [`ably/`](./ably), except `ably/pubsub` | `ably`, `ably.sync` | The shared core, containing all of the implementation | +| `ably-pubsub-server` | [`ably/pubsub/server/`](./ably/pubsub/server) | `ably.pubsub.server` | The server-side factories | +| `ably-pubsub-device` | [`ably/pubsub/device/`](./ably/pubsub/device) | `ably.pubsub.device` | The device-side factory | + +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. + +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): + +- **`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. +- **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. + ### Running the test suite ```shell uv run pytest ``` +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. + +To build all three distributions — build the sdist first, which `uv build` does by default: + +```shell +uv build --out-dir dist +uv build packages/ably-pubsub-server --out-dir dist +uv build packages/ably-pubsub-device --out-dir dist +``` + ## Release Process (Claude Code) 1. Ensure that all work intended for this release has landed to `main` @@ -36,11 +63,13 @@ uv run pytest 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). +`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. + The release process must include the following steps: 1. Ensure that all work intended for this release has landed to `main` 2. Create a release branch named like `release/2.0.1` -3. Add a commit to bump the version number, updating [`pyproject.toml`](./pyproject.toml) and [`ably/__init__.py`](./ably/__init__.py) +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 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: - 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). - 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: 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` 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` 9. Create the release on GitHub including populating the release notes -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 +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 11. Update the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)) with these changes We tend to use [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator) to collate the information required for a change log update. diff --git a/README.md b/README.md index 4ee29fd5..8570dedb 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,18 @@ The following platforms are supported: ## Installation -To get started with your project, install the package: +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: ```sh -pip install ably +# Trusted server environments — publishing, token issuing, backend subscribers +pip install ably-pubsub-server # provides ably.pubsub.server + +# End-user devices — desktop apps, CLIs, IoT and embedded clients +pip install ably-pubsub-device # provides ably.pubsub.device ``` +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. + > [!NOTE] Install [Python](https://www.python.org/downloads/) version 3.8 or greater. @@ -59,8 +65,10 @@ Install [Python](https://www.python.org/downloads/) version 3.8 or greater. 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. ```python +from ably.pubsub.device import create_client + # Initialize Ably Realtime client -async with AblyRealtime('your-ably-api-key', client_id='me') as realtime_client: +async with create_client('your-ably-api-key', client_id='me') as realtime_client: # Wait for connection to be established await realtime_client.connection.once_async('connected') print('Connected to Ably') @@ -78,6 +86,21 @@ async with AblyRealtime('your-ably-api-key', client_id='me') as realtime_client: await channel.publish('test-event', 'hello world') ``` +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`. + +### Migrating from the AblyRest and AblyRealtime constructors + +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: + +| Before | After | +|--------|-------| +| `ably.AblyRealtime(...)` on a device | `ably.pubsub.device.create_client(...)` | +| `ably.AblyRealtime(...)` on a server | `ably.pubsub.server.create_realtime_client(...)` | +| `ably.AblyRest(...)` | `ably.pubsub.server.create_http_client(...)` | +| `ably.sync.AblyRestSync(...)` | `ably.pubsub.server.sync.create_http_client(...)` | + +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. + ## Releases 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). diff --git a/ably/pubsub/device/__init__.py b/ably/pubsub/device/__init__.py new file mode 100644 index 00000000..8008d22f --- /dev/null +++ b/ably/pubsub/device/__init__.py @@ -0,0 +1,85 @@ +"""The Ably Pub/Sub client for devices. + +Devices are applications running in end-user environments — desktop apps, CLIs, +IoT and embedded clients — whose connections are identified by a `client_id` and +counted on accounts with monthly-active-user billing. This package names that +side, so the client an application should reach for is the one whose package +matches where it runs. + +Use `create_client()` to open a realtime connection with channels, presence and +history. It returns the same client `ably` does, with identical behaviour. + +Ships in the `ably-pubsub-device` distribution, which adds this subpackage to +the `ably` package installed by the `ably` distribution. +""" + +import asyncio +from typing import Optional + +from ably import ( + AblyAuthException, + AblyException, + AblyRealtime, + AblyRest, + AblyVCDiffDecoder, + Annotation, + AnnotationAction, + Auth, + Capability, + ChannelMode, + ChannelOptions, + CipherParams, + DeviceDetails, + IncompatibleClientIdException, + MessageAction, + MessageOperation, + MessageVersion, + Options, + PublishResult, + Push, + PushChannelSubscription, + UpdateDeleteResult, + VCDiffDecoder, +) +from ably.util.deprecation import suppress_constructor_deprecation + +__version__ = '3.1.2' + + +def create_client(key: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None, + **kwargs) -> AblyRealtime: + """Create a device Pub/Sub client: a realtime connection to Ably with + channels, presence and history. + + Takes the same arguments as `ably.AblyRealtime`, and behaves identically to it. + """ + with suppress_constructor_deprecation(): + return AblyRealtime(key=key, loop=loop, **kwargs) + + +__all__ = [ + 'AblyAuthException', + 'AblyException', + 'AblyRealtime', + 'AblyRest', + 'AblyVCDiffDecoder', + 'Annotation', + 'AnnotationAction', + 'Auth', + 'Capability', + 'ChannelMode', + 'ChannelOptions', + 'CipherParams', + 'DeviceDetails', + 'IncompatibleClientIdException', + 'MessageAction', + 'MessageOperation', + 'MessageVersion', + 'Options', + 'PublishResult', + 'Push', + 'PushChannelSubscription', + 'UpdateDeleteResult', + 'VCDiffDecoder', + 'create_client', +] diff --git a/ably/pubsub/server/__init__.py b/ably/pubsub/server/__init__.py new file mode 100644 index 00000000..a47ac6e1 --- /dev/null +++ b/ably/pubsub/server/__init__.py @@ -0,0 +1,99 @@ +"""The Ably Pub/Sub client for servers. + +Servers are trusted environments which typically authenticate with an API key, +and whose connections are exempt from monthly-active-user counting. This package +names that side, so the client an application should reach for is the one whose +package matches where it runs. + +Use `create_http_client()` for publish, history, presence reads, stats and token +issuing over HTTP, and `create_realtime_client()` when the server also needs to +subscribe to channels or enter presence over a persistent connection. Both +return the same clients `ably` does, with identical behaviour. + +Ships in the `ably-pubsub-server` distribution, which adds this subpackage to +the `ably` package installed by the `ably` distribution. +""" + +import asyncio +from typing import Optional + +from ably import ( + AblyAuthException, + AblyException, + AblyRealtime, + AblyRest, + AblyVCDiffDecoder, + Annotation, + AnnotationAction, + Auth, + Capability, + ChannelMode, + ChannelOptions, + CipherParams, + DeviceDetails, + IncompatibleClientIdException, + MessageAction, + MessageOperation, + MessageVersion, + Options, + PublishResult, + Push, + PushChannelSubscription, + UpdateDeleteResult, + VCDiffDecoder, +) +from ably.types.tokendetails import TokenDetails +from ably.util.deprecation import suppress_constructor_deprecation + +__version__ = '3.1.2' + + +def create_http_client(key: Optional[str] = None, token: Optional[str] = None, + token_details: Optional[TokenDetails] = None, **kwargs) -> AblyRest: + """Create a server Pub/Sub client that operates entirely over HTTP. + + Takes the same arguments as `ably.AblyRest`, and behaves identically to it. + """ + with suppress_constructor_deprecation(): + return AblyRest(key=key, token=token, token_details=token_details, **kwargs) + + +def create_realtime_client(key: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None, + **kwargs) -> AblyRealtime: + """Create a server Pub/Sub client with a persistent realtime connection. + + Everything the HTTP client does, plus subscribing to channels and entering + presence. Takes the same arguments as `ably.AblyRealtime`, and behaves + identically to it. + """ + with suppress_constructor_deprecation(): + return AblyRealtime(key=key, loop=loop, **kwargs) + + +__all__ = [ + 'AblyAuthException', + 'AblyException', + 'AblyRealtime', + 'AblyRest', + 'AblyVCDiffDecoder', + 'Annotation', + 'AnnotationAction', + 'Auth', + 'Capability', + 'ChannelMode', + 'ChannelOptions', + 'CipherParams', + 'DeviceDetails', + 'IncompatibleClientIdException', + 'MessageAction', + 'MessageOperation', + 'MessageVersion', + 'Options', + 'PublishResult', + 'Push', + 'PushChannelSubscription', + 'UpdateDeleteResult', + 'VCDiffDecoder', + 'create_http_client', + 'create_realtime_client', +] diff --git a/ably/pubsub/server/sync.py b/ably/pubsub/server/sync.py new file mode 100644 index 00000000..7aaf476a --- /dev/null +++ b/ably/pubsub/server/sync.py @@ -0,0 +1,73 @@ +"""The synchronous flavour of the Ably Pub/Sub client for servers. + +Mirrors `ably.sync`, which offers the HTTP client without an event loop. There +is no synchronous realtime client, so a server that needs to subscribe should +use `ably.pubsub.server.create_realtime_client()` instead. +""" + +from typing import Optional + +from ably.sync import ( + AblyAuthException, + AblyException, + AblyRestSync, + AblyVCDiffDecoder, + Annotation, + AnnotationAction, + AuthSync, + Capability, + ChannelMode, + ChannelOptions, + CipherParams, + DeviceDetails, + IncompatibleClientIdException, + MessageAction, + MessageOperation, + MessageVersion, + Options, + PublishResult, + PushChannelSubscription, + PushSync, + UpdateDeleteResult, + VCDiffDecoder, +) +from ably.sync.types.tokendetails import TokenDetails +from ably.sync.util.deprecation import suppress_constructor_deprecation + + +def create_http_client(key: Optional[str] = None, token: Optional[str] = None, + token_details: Optional[TokenDetails] = None, **kwargs) -> AblyRestSync: + """Create a synchronous server Pub/Sub client that operates entirely over HTTP. + + Takes the same arguments as `ably.sync.AblyRestSync`, and behaves identically + to it. + """ + with suppress_constructor_deprecation(): + return AblyRestSync(key=key, token=token, token_details=token_details, **kwargs) + + +__all__ = [ + 'AblyAuthException', + 'AblyException', + 'AblyRestSync', + 'AblyVCDiffDecoder', + 'Annotation', + 'AnnotationAction', + 'AuthSync', + 'Capability', + 'ChannelMode', + 'ChannelOptions', + 'CipherParams', + 'DeviceDetails', + 'IncompatibleClientIdException', + 'MessageAction', + 'MessageOperation', + 'MessageVersion', + 'Options', + 'PublishResult', + 'PushChannelSubscription', + 'PushSync', + 'UpdateDeleteResult', + 'VCDiffDecoder', + 'create_http_client', +] diff --git a/ably/realtime/realtime.py b/ably/realtime/realtime.py index ab435304..53aee36a 100644 --- a/ably/realtime/realtime.py +++ b/ably/realtime/realtime.py @@ -5,6 +5,7 @@ from ably.realtime.channel import Channels from ably.realtime.connection import Connection, ConnectionState from ably.rest.rest import AblyRest +from ably.util.deprecation import warn_constructor_deprecated log = logging.getLogger(__name__) @@ -13,6 +14,10 @@ class AblyRealtime(AblyRest): """ Ably Realtime Client + .. deprecated:: + Use `ably.pubsub.server.create_realtime_client()` from the + `ably-pubsub-server` package. + Attributes ---------- loop: AbstractEventLoop @@ -37,6 +42,10 @@ class AblyRealtime(AblyRest): def __init__(self, key: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None, **kwargs): """Constructs a RealtimeClient object using an Ably API key. + .. deprecated:: + Use `ably.pubsub.server.create_realtime_client()` from the + `ably-pubsub-server` package, instead. + Parameters ---------- key: str @@ -89,6 +98,9 @@ def __init__(self, key: Optional[str] = None, loop: Optional[asyncio.AbstractEve If no authentication key is not provided """ + warn_constructor_deprecated(AblyRealtime, 'ably.pubsub.server.create_realtime_client()', + 'ably-pubsub-server') + if loop is None: try: loop = asyncio.get_running_loop() diff --git a/ably/rest/rest.py b/ably/rest/rest.py index bc84e638..8192ed49 100644 --- a/ably/rest/rest.py +++ b/ably/rest/rest.py @@ -10,18 +10,28 @@ from ably.types.options import Options from ably.types.stats import stats_response_processor from ably.types.tokendetails import TokenDetails +from ably.util.deprecation import warn_constructor_deprecated from ably.util.exceptions import AblyException, catch_all log = logging.getLogger(__name__) class AblyRest: - """Ably Rest Client""" + """Ably Rest Client + + .. deprecated:: + Use `ably.pubsub.server.create_http_client()` from the `ably-pubsub-server` + package instead. + """ def __init__(self, key: Optional[str] = None, token: Optional[str] = None, token_details: Optional[TokenDetails] = None, **kwargs): """Create an AblyRest instance. + .. deprecated:: + Use `ably.pubsub.server.create_http_client()` from the + `ably-pubsub-server` package instead. + :Parameters: **Credentials** - `key`: a valid key string @@ -49,6 +59,12 @@ def __init__(self, key: Optional[str] = None, token: Optional[str] = None, - `auth_url`: Undocumented - `keep_alive`: use persistent connections. Defaults to True """ + # A realtime client sets _is_realtime before delegating here, and warns + # about its own constructor, so only warn for direct AblyRest use. + if not getattr(self, '_is_realtime', False): + warn_constructor_deprecated(AblyRest, 'ably.pubsub.server.create_http_client()', + 'ably-pubsub-server') + if key is not None and ('key_name' in kwargs or 'key_secret' in kwargs): raise ValueError("key and key_name or key_secret are mutually exclusive. " "Provider either a key or key_name & key_secret") diff --git a/ably/scripts/unasync.py b/ably/scripts/unasync.py index d13e20f2..cbbf6f8a 100644 --- a/ably/scripts/unasync.py +++ b/ably/scripts/unasync.py @@ -248,12 +248,20 @@ def run(): _CLASS_RENAME[class_name] = f"{class_name}Sync" _STRING_REPLACE["Auth"] = "AuthSync" + # The deprecation notice on AblyRestSync must name the sync factory, which + # lives in a submodule of the same package as the async one. + _STRING_REPLACE["ably.pubsub.server.create_http_client()"] = "ably.pubsub.server.sync.create_http_client()" src_dir_path = os.path.join(os.getcwd(), "ably") dest_dir_path = os.path.join(os.getcwd(), "ably", "sync") + # ably/pubsub is not part of the core: it is the source of the separate + # ably-pubsub-server and ably-pubsub-device distributions, and already + # provides its own hand-written sync entry points. + pubsub_dir_path = os.path.join(os.getcwd(), "ably", "pubsub") relevant_src_files = (set(find_files(src_dir_path, "*.py")) - - set(find_files(dest_dir_path, "*.py"))) + set(find_files(dest_dir_path, "*.py")) - + set(find_files(pubsub_dir_path, "*.py"))) unasync_files(list(relevant_src_files), [Rule(fromdir=src_dir_path, todir=dest_dir_path)]) diff --git a/ably/util/deprecation.py b/ably/util/deprecation.py new file mode 100644 index 00000000..a82315bc --- /dev/null +++ b/ably/util/deprecation.py @@ -0,0 +1,44 @@ +"""Deprecation of the client constructors in favour of the pubsub package factories. + +The `ably-pubsub-server` and `ably-pubsub-device` packages call the same +constructors internally, so they suppress the warning for the duration of the +call: the caller used the recommended entry point and has nothing to migrate. +""" + +import warnings +from contextlib import contextmanager +from contextvars import ContextVar + +# Set for the duration of a factory call in an Ably-authored pubsub package. +_suppressed: ContextVar = ContextVar('ably_constructor_deprecation_suppressed', default=False) + + +@contextmanager +def suppress_constructor_deprecation(): + """Silence the constructor deprecation warning within this block. + + This interface is only to be used by Ably-authored SDKs. + """ + token = _suppressed.set(True) + try: + yield + finally: + _suppressed.reset(token) + + +def warn_constructor_deprecated(cls, factory: str, package: str) -> None: + """Warn that constructing `cls` directly is deprecated. + + `factory` names the replacement entry point and `package` the distribution + it lives in, so that the warning tells the reader exactly what to migrate to. + """ + if _suppressed.get(): + return + warnings.warn( + f'{cls.__name__} is deprecated. Use {factory} from the {package} package instead, which ' + f'names the side your application runs on. {cls.__name__} keeps working and is not ' + f'scheduled for removal.', + DeprecationWarning, + # 1: this function, 2: the constructor, 3: the caller we want to point at. + stacklevel=3, + ) diff --git a/packages/ably-pubsub-device/README.md b/packages/ably-pubsub-device/README.md new file mode 100644 index 00000000..5a2c063b --- /dev/null +++ b/packages/ably-pubsub-device/README.md @@ -0,0 +1,38 @@ +# Ably Pub/Sub Python SDK for devices + +The Ably Pub/Sub client for devices: applications running in end-user environments (desktop apps, IoT and embedded clients) whose connections are identified by a `client_id` and counted on accounts with monthly-active-user billing. + +This package adds `ably.pubsub.device` to [`ably`](https://pypi.org/project/ably/), whose public surface it re-exports in full. If your application runs in a trusted server environment instead, use [`ably-pubsub-server`](https://pypi.org/project/ably-pubsub-server/). + +## Installation + +```sh +pip install ably-pubsub-device +``` + +## Usage + +```python +from ably.pubsub.device import create_client + +async with create_client('your-ably-api-key', client_id='me') as client: + await client.connection.once_async('connected') + + channel = client.channels.get('test-channel') + + def on_message(message): + print(f'Received message: {message.data}') + + await channel.subscribe(on_message) + await channel.publish('test-event', 'hello world') +``` + +`create_client()` takes the same arguments as `ably.AblyRealtime`, and behaves identically to it. + +## Migrating + +Constructing `AblyRealtime` directly still works and is not scheduled for removal, but the factories name the side your application runs on. Replace `ably.AblyRealtime(...)` with `ably.pubsub.device.create_client(...)`. + +## Support, feedback, and troubleshooting + +For help or technical support, visit Ably's [support page](https://ably.com/support) or [GitHub Issues](https://github.com/ably/ably-python/issues). diff --git a/packages/ably-pubsub-device/pyproject.toml b/packages/ably-pubsub-device/pyproject.toml new file mode 100644 index 00000000..a131560d --- /dev/null +++ b/packages/ably-pubsub-device/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "ably-pubsub-device" +# Released in lockstep with ably, which this pins exactly. +version = "3.1.2" +description = "Ably Pub/Sub client for devices" +readme = "README.md" +requires-python = ">=3.7" +license = { text = "Apache-2.0" } +authors = [ + { name = "Ably", email = "support@ably.com" } +] +classifiers = [ + "Development Status :: 6 - Mature", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "ably==3.1.2", +] + +[project.optional-dependencies] +oldcrypto = ["ably[oldcrypto]==3.1.2"] +crypto = ["ably[crypto]==3.1.2"] +vcdiff = ["ably[vcdiff]==3.1.2"] + +[project.urls] +Homepage = "https://ably.com" +Repository = "https://github.com/ably/ably-python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# See the equivalent comment in packages/ably-pubsub-server/pyproject.toml for +# why this ships only its own subtree, and why the source lives in the shared +# ably/ tree at the repository root rather than beside this file. +[tool.hatch.build.targets.sdist] +ignore-vcs = true +only-include = ["README.md", "pyproject.toml"] +exclude = ["**/*.pyc", "**/__pycache__"] + +[tool.hatch.build.targets.sdist.force-include] +"../../ably/pubsub/device" = "ably/pubsub/device" + +[tool.hatch.build.targets.wheel] +ignore-vcs = true +only-include = ["ably/pubsub/device"] +exclude = ["**/*.pyc", "**/__pycache__"] diff --git a/packages/ably-pubsub-server/README.md b/packages/ably-pubsub-server/README.md new file mode 100644 index 00000000..82dca40d --- /dev/null +++ b/packages/ably-pubsub-server/README.md @@ -0,0 +1,57 @@ +# Ably Pub/Sub Python SDK for servers + +The Ably Pub/Sub client for servers: trusted environments which typically authenticate with an API key, and whose connections are exempt from monthly-active-user counting. + +This package adds `ably.pubsub.server` to [`ably`](https://pypi.org/project/ably/), whose public surface it re-exports in full. If your application runs on an end-user device instead, use [`ably-pubsub-device`](https://pypi.org/project/ably-pubsub-device/). + +## Installation + +```sh +pip install ably-pubsub-server +``` + +## Usage + +Use `create_realtime_client()` when the server needs a persistent connection — subscribing to channels, or entering presence: + +```python +from ably.pubsub.server import create_realtime_client + +async with create_realtime_client('your-ably-api-key') as client: + channel = client.channels.get('test-channel') + await channel.publish('test-event', 'hello world') +``` + +Use `create_http_client()` when publish, history, presence reads, stats and token issuing over HTTP are enough: + +```python +from ably.pubsub.server import create_http_client + +client = create_http_client('your-ably-api-key') +await client.channels.get('test-channel').publish('test-event', 'hello world') +``` + +A synchronous HTTP client, for servers that do not run an event loop, is available from the `sync` submodule: + +```python +from ably.pubsub.server.sync import create_http_client + +client = create_http_client('your-ably-api-key') +client.channels.get('test-channel').publish('test-event', 'hello world') +``` + +These factories take the same arguments as `ably.AblyRealtime` and `ably.AblyRest`, and behave identically to them. + +## Migrating + +Constructing `AblyRest` or `AblyRealtime` directly still works, but the factories name the side your application runs on. Replace: + +| Before | After | +|--------|-------| +| `ably.AblyRest(...)` | `ably.pubsub.server.create_http_client(...)` | +| `ably.AblyRealtime(...)` | `ably.pubsub.server.create_realtime_client(...)` | +| `ably.sync.AblyRestSync(...)` | `ably.pubsub.server.sync.create_http_client(...)` | + +## Support, feedback, and troubleshooting + +For help or technical support, visit Ably's [support page](https://ably.com/support) or [GitHub Issues](https://github.com/ably/ably-python/issues). diff --git a/packages/ably-pubsub-server/pyproject.toml b/packages/ably-pubsub-server/pyproject.toml new file mode 100644 index 00000000..f6fb887d --- /dev/null +++ b/packages/ably-pubsub-server/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "ably-pubsub-server" +# Released in lockstep with ably, which this pins exactly. +version = "3.1.2" +description = "Ably Pub/Sub client for servers" +readme = "README.md" +requires-python = ">=3.7" +license = { text = "Apache-2.0" } +authors = [ + { name = "Ably", email = "support@ably.com" } +] +classifiers = [ + "Development Status :: 6 - Mature", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "ably==3.1.2", +] + +[project.optional-dependencies] +oldcrypto = ["ably[oldcrypto]==3.1.2"] +crypto = ["ably[crypto]==3.1.2"] +vcdiff = ["ably[vcdiff]==3.1.2"] + +[project.urls] +Homepage = "https://ably.com" +Repository = "https://github.com/ably/ably-python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# This distribution adds ably.pubsub.server to the ably package that ably +# installs, so it ships that subtree and nothing else — no ably/__init__.py and +# no ably/pubsub/__init__.py, which would collide with the core and the device +# package respectively. ably/pubsub is a namespace directory (PEP 420) for +# exactly that reason. +# +# The source lives in the shared ably/ tree at the repository root rather than +# beside this file, because ably is a regular package: Python resolves +# ably.pubsub only under the directory ably itself was imported from, so an +# editable checkout can only find it there. The sdist reaches up to collect it, +# and the wheel is then built from the sdist, where it already sits at its final +# path — so build the sdist first, as `uv build` does by default. +[tool.hatch.build.targets.sdist] +ignore-vcs = true +only-include = ["README.md", "pyproject.toml"] +exclude = ["**/*.pyc", "**/__pycache__"] + +[tool.hatch.build.targets.sdist.force-include] +"../../ably/pubsub/server" = "ably/pubsub/server" + +[tool.hatch.build.targets.wheel] +ignore-vcs = true +only-include = ["ably/pubsub/server"] +exclude = ["**/*.pyc", "**/__pycache__"] diff --git a/pyproject.toml b/pyproject.toml index e4dbab6e..23c26ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,12 +83,21 @@ include = [ ] exclude = [ "**/*.pyc", - "**/__pycache__" + "**/__pycache__", + # ably/pubsub is a namespace directory shipped by the ably-pubsub-server and + # ably-pubsub-device distributions, which live in packages/. Shipping it here + # too would put the same files in two distributions. + "/ably/pubsub" ] [tool.hatch.build.targets.wheel] ignore-vcs = true packages = ["ably"] +exclude = [ + "**/*.pyc", + "**/__pycache__", + "/ably/pubsub" +] [tool.pytest.ini_options] timeout = 30 diff --git a/test/ably/rest/restdeprecation_test.py b/test/ably/rest/restdeprecation_test.py new file mode 100644 index 00000000..a9913cad --- /dev/null +++ b/test/ably/rest/restdeprecation_test.py @@ -0,0 +1,53 @@ +import warnings + +from ably import AblyRealtime, AblyRest +from ably.util.deprecation import suppress_constructor_deprecation +from test.ably.utils import BaseAsyncTestCase + + +def constructor_warnings(construct): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + construct() + return [w for w in caught if issubclass(w.category, DeprecationWarning)] + + +class TestConstructorDeprecation(BaseAsyncTestCase): + + def test_rest_constructor_points_at_the_server_package(self): + warned = constructor_warnings(lambda: AblyRest(token='foo')) + assert len(warned) == 1 + message = str(warned[0].message) + assert AblyRest.__name__ in message + assert 'create_http_client' in message + assert 'ably-pubsub-server' in message + + # AblyRealtime delegates to AblyRest, which must not warn a second time + def test_realtime_constructor_warns_once(self): + warned = constructor_warnings(lambda: AblyRealtime(token='foo', auto_connect=False)) + assert len(warned) == 1 + message = str(warned[0].message) + assert 'AblyRealtime' in message + assert 'create_realtime_client' in message + assert 'ably-pubsub-server' in message + + def test_the_warning_is_attributed_to_the_calling_code(self): + warned = constructor_warnings(lambda: AblyRest(token='foo')) + assert warned[0].filename == __file__ + + def test_suppression_silences_the_warning(self): + with suppress_constructor_deprecation(): + assert constructor_warnings(lambda: AblyRest(token='foo')) == [] + + def test_suppression_is_restored_after_the_block(self): + with suppress_constructor_deprecation(): + AblyRest(token='foo') + assert len(constructor_warnings(lambda: AblyRest(token='foo'))) == 1 + + def test_suppression_is_restored_after_a_failure(self): + try: + with suppress_constructor_deprecation(): + raise RuntimeError('boom') + except RuntimeError: + pass + assert len(constructor_warnings(lambda: AblyRest(token='foo'))) == 1 diff --git a/test/unit/pubsub_device_test.py b/test/unit/pubsub_device_test.py new file mode 100644 index 00000000..55b33a4b --- /dev/null +++ b/test/unit/pubsub_device_test.py @@ -0,0 +1,44 @@ +import warnings + +import pytest + +import ably +from ably import AblyRealtime +from ably.pubsub import device +from ably.pubsub.device import create_client + + +class TestPubSubDevice: + + def test_version_matches_the_core_it_pins(self): + assert device.__version__ == ably.lib_version + + def test_client_is_the_core_realtime_client(self): + assert isinstance(create_client(token='foo', auto_connect=False), AblyRealtime) + + def test_options_are_passed_through(self): + client = create_client(key='name:secret', client_id='me', auto_connect=False) + assert client.options.key_name == 'name' + assert client.options.client_id == 'me' + assert client.options.auto_connect is False + + def test_the_key_can_be_positional_as_on_the_constructor(self): + assert create_client('name:secret', auto_connect=False).options.key_name == 'name' + + def test_token_auth_is_passed_through(self): + assert create_client(token='foo', auto_connect=False).options.auth_token == 'foo' + + def test_authentication_is_still_required(self): + with pytest.raises(ValueError): + create_client(auto_connect=False) + + # The factory is the recommended entry point, so it has nothing to warn about + def test_the_factory_does_not_warn(self): + with warnings.catch_warnings(): + warnings.simplefilter('error', DeprecationWarning) + create_client(token='foo', auto_connect=False) + + def test_the_constructor_still_warns_after_a_factory_call(self): + create_client(token='foo', auto_connect=False) + with pytest.warns(DeprecationWarning): + AblyRealtime(token='foo', auto_connect=False) diff --git a/test/unit/pubsub_packaging_test.py b/test/unit/pubsub_packaging_test.py new file mode 100644 index 00000000..6cdb7477 --- /dev/null +++ b/test/unit/pubsub_packaging_test.py @@ -0,0 +1,69 @@ +"""ably.pubsub is assembled at install time from three distributions. + +`ably` ships the core, `ably-pubsub-server` ships `ably/pubsub/server` and +`ably-pubsub-device` ships `ably/pubsub/device`. That only holds together if +`ably.pubsub` stays a namespace directory and the three stay on one version, so +this covers both — neither fails anywhere closer to the mistake than a release. +""" + +import re +from pathlib import Path + +import pytest + +import ably +from ably.pubsub import device, server + +REPO_ROOT = Path(__file__).resolve().parents[2] + +WRAPPERS = [ + ('ably-pubsub-server', server), + ('ably-pubsub-device', device), +] + + +def pyproject_field(path, field): + # Read rather than parse: tomllib only arrived in Python 3.11, and these are + # double-quoted scalars at the top of the [project] table. + match = re.search(rf'^{field} = "([^"]+)"', path.read_text(), re.MULTILINE) + assert match, f'no {field} in {path}' + return match.group(1) + + +# PEP 420: an __init__.py here would belong to whichever distribution shipped it, +# so the other one's subpackage would vanish when that distribution was removed +def test_pubsub_is_a_namespace_directory(): + assert not (REPO_ROOT / 'ably' / 'pubsub' / '__init__.py').exists() + + +@pytest.mark.parametrize('name,module', WRAPPERS) +def test_each_side_is_a_package_of_its_own(name, module): + assert Path(module.__file__).name == '__init__.py' + + +def test_the_core_version_matches_its_pyproject(): + assert pyproject_field(REPO_ROOT / 'pyproject.toml', 'version') == ably.lib_version + + +@pytest.mark.parametrize('name,module', WRAPPERS) +def test_wrapper_version_matches_its_pyproject(name, module): + assert pyproject_field(REPO_ROOT / 'packages' / name / 'pyproject.toml', 'version') == module.__version__ + + +@pytest.mark.parametrize('name,module', WRAPPERS) +def test_wrapper_is_released_in_lockstep_with_the_core(name, module): + assert module.__version__ == ably.lib_version + + +@pytest.mark.parametrize('name,module', WRAPPERS) +def test_wrapper_pins_the_core_exactly(name, module): + pyproject = (REPO_ROOT / 'packages' / name / 'pyproject.toml').read_text() + pins = set(re.findall(r'"ably(?:\[\w+\])?==([^"]+)"', pyproject)) + assert pins == {ably.lib_version} + + +@pytest.mark.parametrize('name,module', WRAPPERS) +def test_wrapper_ships_only_its_own_subtree(name, module): + pyproject = (REPO_ROOT / 'packages' / name / 'pyproject.toml').read_text() + side = name.rsplit('-', 1)[1] + assert f'only-include = ["ably/pubsub/{side}"]' in pyproject diff --git a/test/unit/pubsub_reexport_test.py b/test/unit/pubsub_reexport_test.py new file mode 100644 index 00000000..f351544a --- /dev/null +++ b/test/unit/pubsub_reexport_test.py @@ -0,0 +1,40 @@ +"""The pubsub packages re-export the core's public surface. + +Phase 1 of the package split keeps both sides identical: only the factories +differ. Dropping the parts of the surface that do not belong to a side (push +receive on server, push admin on device) is deferred, so until then a name that +reaches the core and not a side is an omission, and this catches it. +""" + +import pytest + +import ably +import ably.sync +from ably.pubsub import device, server +from ably.pubsub.server import sync as server_sync + +# ably.sync is generated from ably by unasync, so it carries a realtime client +# with the awaits stripped out of code that still calls asyncio. That is an +# artefact of the generation rather than a usable client — nothing tests it, and +# the sync entry point deliberately offers the HTTP client only. +SYNC_ONLY_BY_GENERATION = {'AblyRealtime'} + + +def exported_types(module): + """The classes a package offers, which is all the core's __init__ exports.""" + return {name for name in dir(module) if not name.startswith('_') and name[0].isupper()} + + +@pytest.mark.parametrize('side', [server, device]) +def test_side_re_exports_the_core_surface(side): + assert exported_types(ably) <= set(side.__all__) + + +def test_the_sync_entry_point_re_exports_the_sync_core_surface(): + assert exported_types(ably.sync) - SYNC_ONLY_BY_GENERATION <= set(server_sync.__all__) + + +@pytest.mark.parametrize('module', [server, device, server_sync]) +def test_everything_declared_public_is_importable(module): + missing = [name for name in module.__all__ if not hasattr(module, name)] + assert missing == [] diff --git a/test/unit/pubsub_server_test.py b/test/unit/pubsub_server_test.py new file mode 100644 index 00000000..b1554c9a --- /dev/null +++ b/test/unit/pubsub_server_test.py @@ -0,0 +1,65 @@ +import warnings + +import pytest + +import ably +from ably import AblyRealtime, AblyRest +from ably.pubsub import server +from ably.pubsub.server import create_http_client, create_realtime_client +from ably.pubsub.server import sync as server_sync +from ably.sync import AblyRestSync + + +class TestPubSubServer: + + def test_version_matches_the_core_it_pins(self): + assert server.__version__ == ably.lib_version + + def test_clients_are_the_core_clients(self): + assert isinstance(create_http_client(token='foo'), AblyRest) + assert isinstance(create_realtime_client(token='foo', auto_connect=False), AblyRealtime) + assert isinstance(server_sync.create_http_client(token='foo'), AblyRestSync) + + def test_options_are_passed_through(self): + client = create_http_client(key='name:secret', client_id='me', tls=False) + assert client.options.key_name == 'name' + assert client.options.client_id == 'me' + assert client.options.tls is False + + def test_realtime_options_are_passed_through(self): + client = create_realtime_client(key='name:secret', client_id='me', auto_connect=False) + assert client.options.key_name == 'name' + assert client.options.client_id == 'me' + + def test_sync_options_are_passed_through(self): + client = server_sync.create_http_client(key='name:secret', client_id='me') + assert client.options.key_name == 'name' + assert client.options.client_id == 'me' + + def test_the_key_can_be_positional_as_on_the_constructor(self): + assert create_http_client('name:secret').options.key_name == 'name' + assert create_realtime_client('name:secret', auto_connect=False).options.key_name == 'name' + assert server_sync.create_http_client('name:secret').options.key_name == 'name' + + def test_token_auth_is_passed_through(self): + assert create_http_client(token='foo').options.auth_token == 'foo' + + def test_authentication_is_still_required(self): + with pytest.raises(ValueError): + create_http_client() + + # The factory is the recommended entry point, so it has nothing to warn about + @pytest.mark.parametrize('factory,kwargs', [ + (create_http_client, {}), + (create_realtime_client, {'auto_connect': False}), + (server_sync.create_http_client, {}), + ]) + def test_factories_do_not_warn(self, factory, kwargs): + with warnings.catch_warnings(): + warnings.simplefilter('error', DeprecationWarning) + factory(token='foo', **kwargs) + + def test_the_constructors_still_warn_after_a_factory_call(self): + create_http_client(token='foo') + with pytest.warns(DeprecationWarning): + AblyRest(token='foo')