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