diff --git a/.ably/capabilities.yaml b/.ably/capabilities.yaml new file mode 100644 index 00000000..807e2f55 --- /dev/null +++ b/.ably/capabilities.yaml @@ -0,0 +1,91 @@ +%YAML 1.2 +--- +common-version: 1.2.0 +compliance: + Agent Identifier: + Agents: + Authentication: + API Key: + Token: + Callback: + Literal: + URL: + Query Time: + Debugging: + Error Information: + Logs: + Protocol: + JSON: + MessagePack: + Realtime: + Authentication: + Get Confirmed Client Identifier: + Channel: + Attach: + Retry Timeout: + State Events: + Subscribe: + Connection: + Disconnected Retry Timeout: + Lifecycle Control: + Ping: + State Events: + Suspended Retry Timeout: + REST: + Authentication: + Authorize: + Create Token Request: + Get Client Identifier: + Request Token: + Channel: + Encryption: + Existence Check: + Get: + History: + Iterate: + Name: + Presence: + History: + Member List: + Publish: + Idempotence: + Push Notifications: + List Subscriptions: + Subscribe: + Release: + Status: + Channel Details: # https://github.com/ably/ably-python/pull/276 + Opaque Request: + Push Notifications Administration: + Channel Subscription: + List: + List Channels: + Remove: + Save: + Device Registration: + Get: + List: + Remove: + Save: + Publish: + Request Identifiers: + Request Timeout: + Service: + Get Time: + Statistics: + Query: + Service: + Environment: + Fallbacks: + Hosts: + Retry Count: + Retry Duration: + Retry Timeout: + Host: + Testing: + Disable TLS: + TCP Insecure Port: + TCP Secure Port: + Transport: + Connection Open Timeout: + HTTP/2: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c19eb8b9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" # weekdays (Monday to Friday) + labels: [ ] # prevent the default `dependencies` label from being added to pull requests diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..42f6972d --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,53 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions. +# Based upon: +# https://github.com/actions/starter-workflows/blob/main/ci/python-package.yml +# As directed from: +# https://docs.github.com/en/actions/guides/building-and-testing-python#starting-with-the-python-workflow-template + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +permissions: {} + +jobs: + check: + permissions: + contents: read + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: 'recursive' + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + id: setup-python + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + name: Define a cache for the virtual environment based on the dependencies lock file + id: cache + with: + path: ./.venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }} + + - name: Install dependencies + run: uv sync --extra crypto --extra dev + - name: Generate rest sync code and tests + run: uv run unasync + - name: Test with pytest + run: uv run pytest --verbose --tb=short --capture=no diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml new file mode 100644 index 00000000..7ef37a9a --- /dev/null +++ b/.github/workflows/features.yml @@ -0,0 +1,18 @@ +name: Features + +on: + pull_request: + push: + branches: + - main + +permissions: {} + +jobs: + build: + permissions: + contents: read + uses: ably/features/.github/workflows/sdk-features.yml@main + with: + repository-name: ably-python + secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..d1027713 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +name: Linting check + +on: + pull_request: + push: + branches: + - main + +permissions: {} + +jobs: + lint: + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: 'recursive' + persist-credentials: false + - name: Set up Python 3.9 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + id: setup-python + with: + python-version: '3.9' + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + name: Define a cache for the virtual environment based on the dependencies lock file + id: cache + with: + path: ./.venv + key: venv-${{ runner.os }}-3.9-${{ hashFiles('uv.lock') }} + + - name: Install dependencies + run: uv sync --extra dev + - name: Lint with ruff + run: uv run ruff check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8f47e6b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Publish Python distribution to PyPI + +on: + workflow_dispatch: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' + +permissions: {} + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: 'recursive' + persist-credentials: false + - name: Set up Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + id: setup-python + with: + python-version: 3.12 + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: false + + - name: Install dependencies + run: uv sync --extra crypto --extra dev + - name: Generate rest sync code and tests + run: uv run unasync + - name: Build a binary wheel and a source tarball + run: uv build + - name: Store the distribution packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-package-distributions + path: dist/ + - name: Check that wheel and tarball contains ably/sync/ + run: | + # Check wheel + WHEEL=$(ls dist/*.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" + echo "❌ ably/sync/ not found in wheel" + exit 1 + fi + + # Check tarball + TARBALL=$(ls dist/*.tar.gz | head -n 1) + echo "Checking tarball: $TARBALL" + if tar -tzf "$TARBALL" | grep -q "ably/sync/"; then + echo "✅ Found ably/sync/ in tarball" + else + tar -tzf "$TARBALL" + echo "❌ ably/sync/ not found in tarball" + exit 1 + fi + + publish-to-pypi: + name: Publish Python distribution to PyPI + if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/ably + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-package-distributions + path: dist/ + + - name: Extract tag + id: tag + run: | + TAG=${GITHUB_REF#refs/tags/v} + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Read VERSION_NAME from dist/ + id: version + 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 + env: + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.tag.outputs.tag }} + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + + publish-to-testpypi: + name: Publish Python distribution to TestPyPI + needs: + - build + runs-on: ubuntu-latest + + environment: + name: testpypi + url: https://test.pypi.org/p/ably + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index faaddef4..75ec0f34 100644 --- a/.gitignore +++ b/.gitignore @@ -18,14 +18,17 @@ develop-eggs lib lib64 __pycache__ +/.eggs/ # Installer logs pip-log.txt # Unit test / coverage reports +.cache .coverage +/.pytest_cache/ .tox -nosetests.xml +/htmlcov/ # Translations *.mo @@ -45,9 +48,12 @@ venv* .notes test.sh test_vars_out -.notes pytest app_spec app_spec.pkl ably/types/options.py.orig test/ably/restsetup.py.orig + +.idea/**/* +ably/sync/** +test/ably/sync/** diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..6efea5de --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "submodules"] + path = submodules + url = https://github.com/ably/ably-common.git diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..6826aa85 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.9.2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..793f50c0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,690 @@ +# Change Log + +## [3.1.2](https://github.com/ably/ably-python/tree/v3.1.2) + +[Full Changelog](https://github.com/ably/ably-python/compare/v3.1.1...v3.1.2) + +### What's Changed + +- Fixed preserving extras in message updates methods to prevent data loss [#670](https://github.com/ably/ably-python/pull/670) + +## [v3.1.1](https://github.com/ably/ably-python/tree/v3.1.1) + +[Full Changelog](https://github.com/ably/ably-python/compare/v3.1.0...v3.1.1) + +### What's Changed + +- Fixed handling of normal WebSocket close frames and improved reconnection logic [#672](https://github.com/ably/ably-python/pull/672) + +## [v3.1.0](https://github.com/ably/ably-python/tree/v3.1.0) + +[Full Changelog](https://github.com/ably/ably-python/compare/v3.0.0...v3.1.0) + +### What's Changed + +- Added realtime and rest support for Annotations API [#667](https://github.com/ably/ably-python/pull/667) + +## [v3.0.0](https://github.com/ably/ably-python/tree/v3.0.0) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.1.3...v3.0.0) + +### What's Changed + +- Added realtime publish support for publishing messages to channels over the realtime connection [#648](https://github.com/ably/ably-python/pull/648) +- Added realtime presence support, allowing clients to enter, leave, update presence data, and track presence on channels [#651](https://github.com/ably/ably-python/pull/651) +- Added mutable messages API with support for editing, deleting, and appending to messages [#660](https://github.com/ably/ably-python/pull/660), [#659](https://github.com/ably/ably-python/pull/659) +- Added publish results containing serial of published messages [#660](https://github.com/ably/ably-python/pull/660), [#659](https://github.com/ably/ably-python/pull/659) +- Deprecated `environment`, `rest_host`, and `realtime_host` client options in favor of `endpoint` option [#590](https://github.com/ably/ably-python/pull/590) + +### Breaking change + +The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: + +- The realtime channel publish method now uses WebSocket connection instead of REST +- `ably.realtime.realtime_channel` module renamed to `ably.realtime.channel` +- `ChannelOptions` moved to `ably.types.channeloptions` +- REST publish returns publish result with message serials instead of Response object +- Deprecated `environment`, `rest_host`, and `realtime_host` client options in favor of `endpoint` option + +For detailed migration instructions, please refer to the [Upgrading Guide](UPDATING.md). + +## [v2.1.3](https://github.com/ably/ably-python/tree/v2.1.3) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.1.2...v2.1.3) + +## What's Changed + +- Got rid of `methoddispatch` dependency in [\#639](https://github.com/ably/ably-python/pull/639) +- Upgraded internal build tools + +## [v2.1.2](https://github.com/ably/ably-python/tree/v2.1.2) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.1.1...v2.1.2) + +## What's Changed + +- Support `methoddispatch` version 5 [\#634](https://github.com/ably/ably-python/pull/634) +- Support `pyee` version 13 [\#635](https://github.com/ably/ably-python/pull/635) + +## [v2.1.1](https://github.com/ably/ably-python/tree/v2.1.1) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.1.0...v2.1.1) + +## What's Changed + +* Added missed `sync` folder to the wheel package + +## [v2.1.0](https://github.com/ably/ably-python/tree/v2.1.0) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.13...v2.1.0) + +## What's Changed + +* Added support for VCDiff delta-compressed messages. If VCDiff compression is enabled in the client options, and +deltas are provided by the Ably service, the SDK reconstructs full message payloads from the base content +and the received delta, reducing bandwidth usage without requiring changes to your application code. +[\#620](https://github.com/ably/ably-python/pull/620) + +## [v2.0.13](https://github.com/ably/ably-python/tree/v2.0.13) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.12...v2.0.13) + +## What's Changed +* Removed await from sync `connect()` function call by @kavindail in https://github.com/ably/ably-python/pull/605 +* Upgraded websockets dependency to support 15+ by @ttypic in https://github.com/ably/ably-python/pull/612 + +## [v2.0.12](https://github.com/ably/ably-python/tree/v2.0.12) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.11...v2.0.12) + +**Closed issues:** +- The REST client’s retry mechanism doesn’t follow the spec and doesn’t retry when it should [\#597](https://github.com/ably/ably-python/issues/597) + +## [v2.0.11](https://github.com/ably/ably-python/tree/v2.0.11) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.10...v2.0.11) + +**Closed issues:** +- Support `websockets` version 13 [\#591](https://github.com/ably/ably-python/issues/591) + +## [v2.0.10](https://github.com/ably/ably-python/tree/v2.0.10) + +Fixed sync version of the library + +## [v2.0.9](https://github.com/ably/ably-python/tree/v2.0.9) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.8...v2.0.9) + +**Fixed bugs:** + +- Fix the inability to pass a JSON string value for a `capability` parameter when creating a token [\#579](https://github.com/ably/ably-python/issues/579) + +**Closed issues:** +- Support `pyee` 12 [\#580](https://github.com/ably/ably-python/issues/580) + +## [v2.0.8](https://github.com/ably/ably-python/tree/v2.0.8) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.7...v2.0.8) + +**Fixed bugs:** + +- Fix `TypeError: '>' not supported between instances of 'float' and 'NoneType'` in http [\#573](https://github.com/ably/ably-python/pull/573) + +## [v2.0.7](https://github.com/ably/ably-python/tree/v2.0.7) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.6...v2.0.7) + +**Fixed bugs:** + +- Decoding issue for 40010 Error \(Invalid Channel Name\) [\#569](https://github.com/ably/ably-python/issues/569) + +## [v2.0.6](https://github.com/ably/ably-python/tree/v2.0.6) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.5...v2.0.6) + +**Closed issues:** + +- Support httpx 0.26, 0.27 and so on [\#560](https://github.com/ably/ably-python/issues/560) + +**Merged pull requests:** + +- Fix dependencies [\#559](https://github.com/ably/ably-python/pull/559) ([sacOO7](https://github.com/sacOO7)) + +## [v2.0.5](https://github.com/ably/ably-python/tree/v2.0.5) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.4...v2.0.5) + +**Closed issues:** + +- Question: Bump websockets version [\#556](https://github.com/ably/ably-python/issues/556) +- "RuntimeError: no running event loop" exception when connecting to Realtime [\#555](https://github.com/ably/ably-python/issues/555) + +**Merged pull requests:** + +- Bumped up websocket lib [\#557](https://github.com/ably/ably-python/pull/557) ([sacOO7](https://github.com/sacOO7)) + +## [v2.0.4](https://github.com/ably/ably-python/tree/v2.0.4) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.3...v2.0.4) + +**Merged pull requests:** + +- Upgrade httpx version [\#552](https://github.com/ably/ably-python/pull/552) ([sacOO7](https://github.com/sacOO7)) + +## [v2.0.3](https://github.com/ably/ably-python/tree/v2.0.3) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.2...v2.0.3) + +**Closed issues:** + +- Support for python 3.12 [\#546](https://github.com/ably/ably-python/issues/546) + +**Merged pull requests:** + +- Support latest python versions [\#547](https://github.com/ably/ably-python/pull/547) ([sacOO7](https://github.com/sacOO7)) +- Update README.md to add in 'publish message to channel including metadata' [\#545](https://github.com/ably/ably-python/pull/545) ([cameron-michie](https://github.com/cameron-michie)) + +## [v2.0.2](https://github.com/ably/ably-python/tree/v2.0.2) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.1...v2.0.2) + +**Implemented enhancements:** + +- Add synchronous AblyRest client (for more info see the [docs]()) [\#537](https://github.com/ably/ably-python/issues/537) + +**Closed issues:** + +- Update httpx dependency to version 0.24.1 or higher [\#523](https://github.com/ably/ably-python/issues/523) + +**Merged pull requests:** + +- Updated poetry httpx dependency and lock file [\#524](https://github.com/ably/ably-python/pull/524) ([sacOO7](https://github.com/sacOO7)) +- Remove unused dependency: h2 [\#526](https://github.com/ably/ably-python/pull/526) ([gdrosos](https://github.com/gdrosos)) +- Add sync support using unasync [\#537](https://github.com/ably/ably-python/pull/526) ([sacOO7](https://github.com/sacOO7)) + +## [v2.0.1](https://github.com/ably/ably-python/tree/v2.0.1) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.0...v2.0.1) + +**Closed issues:** + +- Implement / Add tests for TM1,TM2,TM3 Message spec [\#516](https://github.com/ably/ably-python/issues/516) + +**Merged pull requests:** + +- \[SDK-3807\] Implement and test empty inner message fields [\#517](https://github.com/ably/ably-python/pull/517) ([sacOO7](https://github.com/sacOO7)) + +## [v2.0.0](https://github.com/ably/ably-python/tree/v2.0.0) + +**New ably-python realtime client**: This new release features our first ever python realtime client! Currently the realtime client only supports realtime message subscription. Check out the README for usage examples. There have been some minor breaking changes from the 1.2 version, please consult the [migration guide](https://github.com/ably/ably-python/blob/main/UPDATING.md) for instructions on how to upgrade to 2.0. + +[Full Changelog](https://github.com/ably/ably-python/compare/v1.2.2...v2.0.0) + +- refactor!: add mandatory version param to `Rest.request` [\#500](https://github.com/ably/ably-python/issues/500) +- bump api_version to 2.0, add DeviceDetails.deviceSecret [\#507](https://github.com/ably/ably-python/issues/507) +- Include cause in AblyException.__str__ result [\#508](https://github.com/ably/ably-python/issues/508) +- feat!: use api v3 and untyped stats [\#505](https://github.com/ably/ably-python/issues/505) +- Implement `add_request_ids` client option [\#399](https://github.com/ably/ably-python/issues/399) +- Improve logger output upon disconnection [\#492](https://github.com/ably/ably-python/issues/492) +- Fix an issue where in some cases the client was unable to recover after loss of connectivity [\#493](https://github.com/ably/ably-python/issues/493) +- Remove soft-deprecated APIs [\#482](https://github.com/ably/ably-python/issues/482) +- Improve realtime client typings [\#476](https://github.com/ably/ably-python/issues/476) +- Improve REST client typings [\#477](https://github.com/ably/ably-python/issues/477) +- Stop raising `KeyError` when releasing a channel which doesn't exist [\#474](https://github.com/ably/ably-python/issues/474) +- Allow token auth methods for realtime constructor [\#425](https://github.com/ably/ably-python/issues/425) +- Send `AUTH` protocol message when `Auth.authorize` called on realtime client [\#427](https://github.com/ably/ably-python/issues/427) +- Reauth upon inbound `AUTH` protocol message [\#428](https://github.com/ably/ably-python/issues/428) +- Handle connection request failure due to token error [\#445](https://github.com/ably/ably-python/issues/445) +- Handle token `ERROR` response to a resume request [\#444](https://github.com/ably/ably-python/issues/444) +- Handle `DISCONNECTED` messages containing token errors [\#443](https://github.com/ably/ably-python/issues/443) +- Pass `clientId` as query string param when opening a new connection [\#449](https://github.com/ably/ably-python/issues/449) +- Validate `clientId` in `ClientOptions` [\#448](https://github.com/ably/ably-python/issues/448) +- Apply `Auth#clientId` only after a realtime connection has been established [\#409](https://github.com/ably/ably-python/issues/409) +- Channels should transition to `INITIALIZED` if `Connection.connect` called from terminal state [\#411](https://github.com/ably/ably-python/issues/411) +- Calling connect while `CLOSING` should start connect on a new transport [\#410](https://github.com/ably/ably-python/issues/410) +- Handle realtime channel errors [\#455](https://github.com/ably/ably-python/issues/455) +- Resend protocol messages for pending channels upon resume [\#347](https://github.com/ably/ably-python/issues/347) +- Attempt to resume connection when disconnected unexpectedly [\#346](https://github.com/ably/ably-python/issues/346) +- Handle `CONNECTED` messages once connected [\#345](https://github.com/ably/ably-python/issues/345) +- Implement `maxIdleInterval` [\#344](https://github.com/ably/ably-python/issues/344) +- Implement realtime connectivity check [\#343](https://github.com/ably/ably-python/issues/343) +- Use fallback realtime hosts when encountering an appropriate error [\#342](https://github.com/ably/ably-python/issues/342) +- Add `fallbackHosts` client option for realtime clients [\#341](https://github.com/ably/ably-python/issues/341) +- Implement `connectionStateTtl` [\#340](https://github.com/ably/ably-python/issues/340) +- Implement `disconnectedRetryTimeout` [\#339](https://github.com/ably/ably-python/issues/339) +- Handle recoverable connection opening errors [\#338](https://github.com/ably/ably-python/issues/338) +- Implement `channelRetryTimeout` [\#442](https://github.com/ably/ably-python/issues/436) +- Queue protocol messages when connection state is `CONNECTING` or `DISCONNECTED` [\#418](https://github.com/ably/ably-python/issues/418) +- Propagate connection interruptions to realtime channels [\#417](https://github.com/ably/ably-python/issues/417) +- Spec compliance: `Realtime.connect` should be sync [\#413](https://github.com/ably/ably-python/issues/413) +- Emit `update` event on additional `ATTACHED` message [\#386](https://github.com/ably/ably-python/issues/386) +- Set the `ATTACH_RESUME` flag on unclean attach [\#385](https://github.com/ably/ably-python/issues/385) +- Handle fatal resume error [\#384](https://github.com/ably/ably-python/issues/384) +- Handle invalid resume response [\#383](https://github.com/ably/ably-python/issues/383) +- Handle clean resume response [\#382](https://github.com/ably/ably-python/issues/382) +- Send resume query param when reconnecting within `connectionStateTtl` [\#381](https://github.com/ably/ably-python/issues/381) +- Immediately reattempt connection when unexpectedly disconnected [\#380](https://github.com/ably/ably-python/issues/380) +- Clear connection state when `connectionStateTtl` elapsed [\#379](https://github.com/ably/ably-python/issues/379) +- Refactor websocket async tasks into WebSocketTransport class [\#373](https://github.com/ably/ably-python/issues/373) +- Send version transport param [\#368](https://github.com/ably/ably-python/issues/368) +- Clear `Connection.error_reason` when `Connection.connect` is called [\#367](https://github.com/ably/ably-python/issues/367) +- Fix a bug with realtime_host configuration [\#358](https://github.com/ably/ably-python/pull/358) +- Create Basic Api Key connection [\#311](https://github.com/ably/ably-python/pull/311) +- Send Ably-Agent header in realtime connection [\#314](https://github.com/ably/ably-python/pull/314) +- Close client service [\#315](https://github.com/ably/ably-python/pull/315) +- Implement EventEmitter interface on Connection [\#316](https://github.com/ably/ably-python/pull/316) +- Finish tasks gracefully on failed connection [\#317](https://github.com/ably/ably-python/pull/317) +- Implement realtime ping [\#318](https://github.com/ably/ably-python/pull/318) +- Realtime channel attach/detach [\#319](https://github.com/ably/ably-python/pull/319) +- Add `auto_connect` implementation and client option [\#325](https://github.com/ably/ably-python/pull/325) +- RealtimeChannel subscribe/unsubscribe [\#326](https://github.com/ably/ably-python/pull/326) +- ConnectionStateChange [\#327](https://github.com/ably/ably-python/pull/327) +- Improve realtime logging [\#330](https://github.com/ably/ably-python/pull/330) +- Update readme with realtime documentation [\#334](334](https://github.com/ably/ably-python/pull/334) +- Use string-based enums [\#351](https://github.com/ably/ably-python/pull/351) +- Add environment client option for realtime [\#335](https://github.com/ably/ably-python/pull/335) +- EventEmitter: allow signatures with no event arg [\#350](https://github.com/ably/ably-python/pull/350) + +## [v2.0.0-beta.6](https://github.com/ably/ably-python/tree/v2.0.0-beta.6) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.0-beta.5...v2.0.0-beta.6) + +- Improve logger output upon disconnection [\#492](https://github.com/ably/ably-python/issues/492) +- Fix an issue where in some cases the client was unable to recover after loss of connectivity [\#493](https://github.com/ably/ably-python/issues/493) + +## [v2.0.0-beta.5](https://github.com/ably/ably-python/tree/v2.0.0-beta.5) + +The latest beta release of ably-python 2.0 makes some minor breaking changes, removing already soft-deprecated features from the 1.x branch. Most users will not be affected by these changes since the library was already warning that these features were deprecated. For information on how to migrate, please consult the [migration guide](https://github.com/ably/ably-python/blob/main/UPDATING.md). + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.0-beta.4...v2.0.0-beta.5) + +- Remove soft-deprecated APIs [\#482](https://github.com/ably/ably-python/issues/482) +- Improve realtime client typings [\#476](https://github.com/ably/ably-python/issues/476) +- Improve REST client typings [\#477](https://github.com/ably/ably-python/issues/477) +- Stop raising `KeyError` when releasing a channel which doesn't exist [\#474](https://github.com/ably/ably-python/issues/474) + +## [v2.0.0-beta.4](https://github.com/ably/ably-python/tree/v2.0.0-beta.4) + +This new beta release of the ably-python realtime client implements token authentication for realtime connections, allowing you to use all currently supported token options to authenticate a realtime client (auth_url, auth_callback, jwt, etc). The client will reauthenticate when the token expires or otherwise becomes invalid. + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.0-beta.3...v2.0.0-beta.4) + +- Allow token auth methods for realtime constructor [\#425](https://github.com/ably/ably-python/issues/425) +- Send `AUTH` protocol message when `Auth.authorize` called on realtime client [\#427](https://github.com/ably/ably-python/issues/427) +- Reauth upon inbound `AUTH` protocol message [\#428](https://github.com/ably/ably-python/issues/428) +- Handle connection request failure due to token error [\#445](https://github.com/ably/ably-python/issues/445) +- Handle token `ERROR` response to a resume request [\#444](https://github.com/ably/ably-python/issues/444) +- Handle `DISCONNECTED` messages containing token errors [\#443](https://github.com/ably/ably-python/issues/443) +- Pass `clientId` as query string param when opening a new connection [\#449](https://github.com/ably/ably-python/issues/449) +- Validate `clientId` in `ClientOptions` [\#448](https://github.com/ably/ably-python/issues/448) +- Apply `Auth#clientId` only after a realtime connection has been established [\#409](https://github.com/ably/ably-python/issues/409) +- Channels should transition to `INITIALIZED` if `Connection.connect` called from terminal state [\#411](https://github.com/ably/ably-python/issues/411) +- Calling connect while `CLOSING` should start connect on a new transport [\#410](https://github.com/ably/ably-python/issues/410) +- Handle realtime channel errors [\#455](https://github.com/ably/ably-python/issues/455) + +## [v2.0.0-beta.3](https://github.com/ably/ably-python/tree/v2.0.0-beta.3) + +This new beta release of the ably-python realtime client implements a number of new features to improve the stability of realtime connections, allowing the client to reconnect during a temporary disconnection, use fallback hosts when necessary, and catch up on messages missed while the client was disconnected. + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.0-beta.2...v2.0.0-beta.3) + +- Resend protocol messages for pending channels upon resume [\#347](https://github.com/ably/ably-python/issues/347) +- Attempt to resume connection when disconnected unexpectedly [\#346](https://github.com/ably/ably-python/issues/346) +- Handle `CONNECTED` messages once connected [\#345](https://github.com/ably/ably-python/issues/345) +- Implement `maxIdleInterval` [\#344](https://github.com/ably/ably-python/issues/344) +- Implement realtime connectivity check [\#343](https://github.com/ably/ably-python/issues/343) +- Use fallback realtime hosts when encountering an appropriate error [\#342](https://github.com/ably/ably-python/issues/342) +- Add `fallbackHosts` client option for realtime clients [\#341](https://github.com/ably/ably-python/issues/341) +- Implement `connectionStateTtl` [\#340](https://github.com/ably/ably-python/issues/340) +- Implement `disconnectedRetryTimeout` [\#339](https://github.com/ably/ably-python/issues/339) +- Handle recoverable connection opening errors [\#338](https://github.com/ably/ably-python/issues/338) +- Implement `channelRetryTimeout` [\#442](https://github.com/ably/ably-python/issues/436) +- Queue protocol messages when connection state is `CONNECTING` or `DISCONNECTED` [\#418](https://github.com/ably/ably-python/issues/418) +- Propagate connection interruptions to realtime channels [\#417](https://github.com/ably/ably-python/issues/417) +- Spec compliance: `Realtime.connect` should be sync [\#413](https://github.com/ably/ably-python/issues/413) +- Emit `update` event on additional `ATTACHED` message [\#386](https://github.com/ably/ably-python/issues/386) +- Set the `ATTACH_RESUME` flag on unclean attach [\#385](https://github.com/ably/ably-python/issues/385) +- Handle fatal resume error [\#384](https://github.com/ably/ably-python/issues/384) +- Handle invalid resume response [\#383](https://github.com/ably/ably-python/issues/383) +- Handle clean resume response [\#382](https://github.com/ably/ably-python/issues/382) +- Send resume query param when reconnecting within `connectionStateTtl` [\#381](https://github.com/ably/ably-python/issues/381) +- Immediately reattempt connection when unexpectedly disconnected [\#380](https://github.com/ably/ably-python/issues/380) +- Clear connection state when `connectionStateTtl` elapsed [\#379](https://github.com/ably/ably-python/issues/379) +- Refactor websocket async tasks into WebSocketTransport class [\#373](https://github.com/ably/ably-python/issues/373) +- Send version transport param [\#368](https://github.com/ably/ably-python/issues/368) +- Clear `Connection.error_reason` when `Connection.connect` is called [\#367](https://github.com/ably/ably-python/issues/367) + +## [v2.0.0-beta.2](https://github.com/ably/ably-python/tree/v2.0.0-beta.2) + +[Full Changelog](https://github.com/ably/ably-python/compare/v2.0.0-beta.1...v2.0.0-beta.2) +- Fix a bug with realtime_host configuration [\#358](https://github.com/ably/ably-python/pull/358) + +## [v2.0.0-beta.1](https://github.com/ably/ably-python/tree/v2.0.0-beta.1) + +**New ably-python realtime client**: This beta release features our first ever python realtime client! Currently the realtime client only supports basic authentication and realtime message subscription. Check out the README for usage examples. + +[Full Changelog](https://github.com/ably/ably-python/compare/v1.2.1...2.0.0-beta.1) + +- Create Basic Api Key connection [\#311](https://github.com/ably/ably-python/pull/311) +- Send Ably-Agent header in realtime connection [\#314](https://github.com/ably/ably-python/pull/314) +- Close client service [\#315](https://github.com/ably/ably-python/pull/315) +- Implement EventEmitter interface on Connection [\#316](https://github.com/ably/ably-python/pull/316) +- Finish tasks gracefully on failed connection [\#317](https://github.com/ably/ably-python/pull/317) +- Implement realtime ping [\#318](https://github.com/ably/ably-python/pull/318) +- Realtime channel attach/detach [\#319](https://github.com/ably/ably-python/pull/319) +- Add `auto_connect` implementation and client option [\#325](https://github.com/ably/ably-python/pull/325) +- RealtimeChannel subscribe/unsubscribe [\#326](https://github.com/ably/ably-python/pull/326) +- ConnectionStateChange [\#327](https://github.com/ably/ably-python/pull/327) +- Improve realtime logging [\#330](https://github.com/ably/ably-python/pull/330) +- Update readme with realtime documentation [\#334](334](https://github.com/ably/ably-python/pull/334) +- Use string-based enums [\#351](https://github.com/ably/ably-python/pull/351) +- Add environment client option for realtime [\#335](https://github.com/ably/ably-python/pull/335) +- EventEmitter: allow signatures with no event arg [\#350](https://github.com/ably/ably-python/pull/350) + +## [v1.2.1](https://github.com/ably/ably-python/tree/v1.2.1) + +[Full Changelog](https://github.com/ably/ably-python/compare/v1.2.0...v1.2.1) + +**Implemented enhancements:** + +- Add support to get channel lifecycle status [\#271](https://github.com/ably/ably-python/issues/271) +- Migrate project to poetry [\#305](https://github.com/ably/ably-python/issues/305) + +## [v1.2.0](https://github.com/ably/ably-python/tree/v1.2.0) + +**Breaking API Changes**: Please see our [Upgrade / Migration Guide](UPDATING.md) for notes on changes you need to make to your code to update it to use the new API introduced by version 1.2.0. + +[Full Changelog](https://github.com/ably/ably-python/compare/v1.1.1...v1.2.0) + +**Implemented enhancements:** + +- Respect content-type with charset [\#256](https://github.com/ably/ably-python/issues/256) +- Release a new version for python 3.10 support [\#249](https://github.com/ably/ably-python/issues/249) +- Support HTTP/2 [\#197](https://github.com/ably/ably-python/issues/197) +- Support Async HTTP [\#171](https://github.com/ably/ably-python/issues/171) +- Implement RSC7d \(Ably-Agent header\) [\#168](https://github.com/ably/ably-python/issues/168) +- Defaults: Generate environment fallbacks [\#155](https://github.com/ably/ably-python/issues/155) +- Clarify string encoding when sending push notifications [\#119](https://github.com/ably/ably-python/issues/119) +- Support for environments fallbacks [\#198](https://github.com/ably/ably-python/pull/198) ([d8x](https://github.com/d8x)) + +**Fixed bugs:** + +- Channel.publish sometimes returns None after exhausting retries [\#160](https://github.com/ably/ably-python/issues/160) +- Token issue potential bug [\#54](https://github.com/ably/ably-python/issues/54) + +**Closed issues:** + +- Conform ReadMe and create Contributing Document [\#199](https://github.com/ably/ably-python/issues/199) +- Add support for DataTypes TokenParams AO2g [\#187](https://github.com/ably/ably-python/issues/187) +- Add support for TO3m [\#172](https://github.com/ably/ably-python/issues/172 +- Using a clientId should no longer be forcing token auth in the 1.1 spec [\#149](https://github.com/ably/ably-python/issues/149) + +**Merged pull requests:** + +- Add support for Python 3.10, age out 3.6 [\#253](https://github.com/ably/ably-python/pull/253) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- Compat with 'httpx' public API changes. [\#252](https://github.com/ably/ably-python/pull/252) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- Respect content-type with charset [\#248](https://github.com/ably/ably-python/pull/248) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- 'TypedBuffer' fix attempt to call a non-callable object [\#226](https://github.com/ably/ably-python/pull/226) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- 'auth' module, fix possible unbound local variables warning [\#225](https://github.com/ably/ably-python/pull/225) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- rest setup - fix redeclared name without usage [\#217](https://github.com/ably/ably-python/pull/217) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- Fixes mutable-value used as argument default value [\#215](https://github.com/ably/ably-python/pull/215) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- Fixes most of the PEP 8 coding style violations [\#214](https://github.com/ably/ably-python/pull/214) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- 'Channel' remove unused 'history' parameter 'timeout'. [\#209](https://github.com/ably/ably-python/pull/209) ([tomkirbygreen](https://github.com/tomkirbygreen)) +- \[\#149\] Specifying clientId does not force token auth [\#204](https://github.com/ably/ably-python/pull/204) ([d8x](https://github.com/d8x)) +- Support for async [\#202](https://github.com/ably/ably-python/pull/202) ([d8x](https://github.com/d8x)) +- Support for HTTP/2 Protocol [\#200](https://github.com/ably/ably-python/pull/200) ([d8x](https://github.com/d8x)) +- Add missing `modified` property in DeviceDetails [\#196](https://github.com/ably/ably-python/pull/196) ([d8x](https://github.com/d8x)) +- RSC7d - Support for Ably-Agent header [\#195](https://github.com/ably/ably-python/pull/195) ([d8x](https://github.com/d8x)) +- fix error message for invalid push data type [\#169](https://github.com/ably/ably-python/pull/169) ([netspencer](https://github.com/netspencer)) +- Raise error if all servers reply with a 5xx response [\#161](https://github.com/ably/ably-python/pull/161) ([jdavid](https://github.com/jdavid)) + +## [v1.1.1](https://github.com/ably/ably-python/tree/v1.1.1) + +[Full Changelog](https://github.com/ably/ably-python/compare/v1.1.0...v1.1.1) + +**Implemented enhancements:** + +- Improve handling of clock skew [\#145](https://github.com/ably/ably-python/issues/145) +- Test variable length 256 bit AES CBC fixtures [\#150](https://github.com/ably/ably-python/pull/150) ([QuintinWillison](https://github.com/QuintinWillison)) + +**Closed issues:** + +- Remove develop branch [\#151](https://github.com/ably/ably-python/issues/151) + +**Merged pull requests:** + +- bump msgpack version to 1.0.0 and update tests [\#152](https://github.com/ably/ably-python/pull/152) ([abordeau](https://github.com/abordeau)) +- Fix flake8 [\#148](https://github.com/ably/ably-python/pull/148) ([jdavid](https://github.com/jdavid)) +- RSA4b1 Detect expired token to avoid extra request [\#147](https://github.com/ably/ably-python/pull/147) ([jdavid](https://github.com/jdavid)) +- push.admin.publish returns None [\#146](https://github.com/ably/ably-python/pull/146) ([jdavid](https://github.com/jdavid)) +- 'Known limitations' section in the README [\#143](https://github.com/ably/ably-python/pull/143) ([Srushtika](https://github.com/Srushtika)) + +## [v1.1.0](https://github.com/ably/ably-python/tree/v1.1.0) +[Full Changelog](https://github.com/ably/ably-python/compare/v1.0.3...v1.1.0) + +**Closed issues:** + +- Idempotent publishing is not enabled in the upcoming 1.1 release [\#132](https://github.com/ably/ably-python/issues/132) +- forward slash in channel name [\#130](https://github.com/ably/ably-python/issues/130) +- Refactor tests setup [\#109](https://github.com/ably/ably-python/issues/109) + +**Implemented enhancements:** + +- Add support for remembered REST fallback host [\#131](https://github.com/ably/ably-python/issues/131) +- Ensure request method accepts UPDATE, PATCH & DELETE verbs [\#128](https://github.com/ably/ably-python/issues/128) +- Add idempotent REST publishing support [\#121](https://github.com/ably/ably-python/issues/121) +- Allow to configure logger [\#107](https://github.com/ably/ably-python/issues/107) + +**Merged pull requests:** + +- Fix flake8 [\#142](https://github.com/ably/ably-python/pull/142) ([jdavid](https://github.com/jdavid)) +- Rsc15f Support for remembered REST fallback host [\#141](https://github.com/ably/ably-python/pull/141) ([jdavid](https://github.com/jdavid)) +- Add patch [\#135](https://github.com/ably/ably-python/pull/135) ([jdavid](https://github.com/jdavid)) +- Idempotent publishing [\#129](https://github.com/ably/ably-python/pull/129) ([jdavid](https://github.com/jdavid)) +- Push [\#127](https://github.com/ably/ably-python/pull/127) ([jdavid](https://github.com/jdavid)) +- RSH1c5 New push.admin.channel\_subscriptions.remove\_where [\#126](https://github.com/ably/ably-python/pull/126) ([jdavid](https://github.com/jdavid)) +- RSH1c4 New push.admin.channel\_subscriptions.remove [\#125](https://github.com/ably/ably-python/pull/125) ([jdavid](https://github.com/jdavid)) +- RSH1c2 New push.admin.channel\_subscriptions.list\_channels [\#124](https://github.com/ably/ably-python/pull/124) ([jdavid](https://github.com/jdavid)) +- RSH1c1 New push.admin.channel\_subscriptions.list [\#120](https://github.com/ably/ably-python/pull/120) ([jdavid](https://github.com/jdavid)) +- RSH1c3 New push.admin.channel\_subscriptions.save [\#118](https://github.com/ably/ably-python/pull/118) ([jdavid](https://github.com/jdavid)) +- RHS1b5 New push.admin.device\_registrations.remove\_where [\#117](https://github.com/ably/ably-python/pull/117) ([jdavid](https://github.com/jdavid)) +- RHS1b4 New push.admin.device\_registrations.remove [\#116](https://github.com/ably/ably-python/pull/116) ([jdavid](https://github.com/jdavid)) +- RSH1b2 New push.admin.device\_registrations.list [\#114](https://github.com/ably/ably-python/pull/114) ([jdavid](https://github.com/jdavid)) +- Rsh1b1 New push.admin.device\_registrations.get [\#113](https://github.com/ably/ably-python/pull/113) ([jdavid](https://github.com/jdavid)) +- RSH1b3 New push.admin.device\_registrations.save [\#112](https://github.com/ably/ably-python/pull/112) ([jdavid](https://github.com/jdavid)) +- Document how to configure logging [\#110](https://github.com/ably/ably-python/pull/110) ([jdavid](https://github.com/jdavid)) +- Rsh1a New push.admin.publish [\#106](https://github.com/ably/ably-python/pull/106) ([jdavid](https://github.com/jdavid)) + +## [v1.0.3](https://github.com/ably/ably-python/tree/v1.0.3) (2019-01-18) +[Full Changelog](https://github.com/ably/ably-python/compare/v1.0.2...v1.0.3) + +**Closed issues:** + +- Travis failures with Python 2 in the 1.0 branch [\#138](https://github.com/ably/ably-python/issues/138) + +**Fixed bugs:** + +- Authentication with auth\_url doesn't accept camel case [\#136](https://github.com/ably/ably-python/issues/136) + +**Merged pull requests:** + +- clientId must be a \(text\) string [\#139](https://github.com/ably/ably-python/pull/139) ([jdavid](https://github.com/jdavid)) +- Fix authentication with auth\_url [\#137](https://github.com/ably/ably-python/pull/137) ([jdavid](https://github.com/jdavid)) + +## [v1.0.2](https://github.com/ably/ably-python/tree/v1.0.2) (2018-12-10) +[Full Changelog](https://github.com/ably/ably-python/compare/v1.0.1...v1.0.2) + +**Fixed bugs:** + +- HTTP connection pooling [\#133](https://github.com/ably/ably-python/issues/133) +- Timeouts when publishing messages [\#111](https://github.com/ably/ably-python/issues/111) +- AWS lambda packaging [\#97](https://github.com/ably/ably-python/issues/97) +- Rate limit requests to sandbox app [\#68](https://github.com/ably/ably-python/issues/68) + +**Closed issues:** + +- TokenRequest ttl unit discrepancy [\#104](https://github.com/ably/ably-python/issues/104) +- Python subscribe? [\#100](https://github.com/ably/ably-python/issues/100) + +**Merged pull requests:** + +- Fix README so it doesn't mislead ttl to be in s [\#105](https://github.com/ably/ably-python/pull/105) ([jdavid](https://github.com/jdavid)) +- Fix tests [\#103](https://github.com/ably/ably-python/pull/103) ([jdavid](https://github.com/jdavid)) +- Update README with supported platforms [\#102](https://github.com/ably/ably-python/pull/102) ([funkyboy](https://github.com/funkyboy)) + +## [v1.0.1](https://github.com/ably/ably-python/tree/v1.0.1) (2017-12-20) +[Full Changelog](https://github.com/ably/ably-python/compare/v1.0.0...v1.0.1) + +**Implemented enhancements:** + +- Fix HttpRequest & HttpRetry timeouts [\#86](https://github.com/ably/ably-python/issues/86) +- Cast TTL to integer [\#71](https://github.com/ably/ably-python/issues/71) +- Make PyCrypto optional [\#65](https://github.com/ably/ably-python/issues/65) + +**Fixed bugs:** + +- Travis random failures [\#88](https://github.com/ably/ably-python/issues/88) + +**Closed issues:** + +- pycrypto --\> pycryptodome [\#96](https://github.com/ably/ably-python/issues/96) +- `ably` module seems to be broken / empty in some circumstances [\#95](https://github.com/ably/ably-python/issues/95) +- installing via pip installs a more restrictive version of requests [\#91](https://github.com/ably/ably-python/issues/91) +- Add test coverage to prevent possible MsgPack regression [\#89](https://github.com/ably/ably-python/issues/89) +- 1.0 spec review [\#84](https://github.com/ably/ably-python/issues/84) +- When using python2 with msgpack, dicts are not encoded correctly [\#72](https://github.com/ably/ably-python/issues/72) + +**Merged pull requests:** + +- Fix unit tests [\#99](https://github.com/ably/ably-python/pull/99) ([jdavid](https://github.com/jdavid)) +- Switch to cryptodome [\#98](https://github.com/ably/ably-python/pull/98) ([jdavid](https://github.com/jdavid)) +- ttl: use isinstance instead of type [\#94](https://github.com/ably/ably-python/pull/94) ([jdavid](https://github.com/jdavid)) +- Fix Flake8 warnings regarding spacing [\#93](https://github.com/ably/ably-python/pull/93) ([sginn](https://github.com/sginn)) +- Bumped upper limit on requests library, and removed websocket [\#92](https://github.com/ably/ably-python/pull/92) ([sginn](https://github.com/sginn)) +- Fix \#65, \#71, \#72, \#86 and \#89 [\#90](https://github.com/ably/ably-python/pull/90) ([jdavid](https://github.com/jdavid)) + +## [v1.0.0](https://github.com/ably/ably-python/tree/v1.0.0) (2017-03-07) +[Full Changelog](https://github.com/ably/ably-python/compare/v0.8.2...v1.0.0) + +### v1.0 release and upgrade notes from v0.8 + +- See https://github.com/ably/docs/issues/235 + +**Implemented enhancements:** + +- RSC19\*, HP\* - New REST \#request method + HttpPaginatedResponse type [\#78](https://github.com/ably/ably-python/issues/78) +- Update REST library for realtime platform to v1.0 specification [\#77](https://github.com/ably/ably-python/issues/77) + +**Closed issues:** + +- requests version pin too strict? [\#66](https://github.com/ably/ably-python/issues/66) + +**Merged pull requests:** + +- Issue\#84 TP4, RSC15a \(test\), RSC19e \(test\), .. [\#87](https://github.com/ably/ably-python/pull/87) ([jdavid](https://github.com/jdavid)) +- Fix issue 72 [\#85](https://github.com/ably/ably-python/pull/85) ([jdavid](https://github.com/jdavid)) +- Fix README, now using pytest instead of nose [\#83](https://github.com/ably/ably-python/pull/83) ([jdavid](https://github.com/jdavid)) +- RSA5, RSA6, RSA10, RSL\*, TM\*, TE6, TD7 [\#82](https://github.com/ably/ably-python/pull/82) ([jdavid](https://github.com/jdavid)) + +## [v0.8.2](https://github.com/ably/ably-python/tree/v0.8.2) (2017-02-17) +[Full Changelog](https://github.com/ably/ably-python/compare/v0.8.1...v0.8.2) + +**Implemented enhancements:** + +- PaginatedResult attributes [\#70](https://github.com/ably/ably-python/issues/70) +- 0.8.x finalisation [\#48](https://github.com/ably/ably-python/issues/48) + +**Fixed bugs:** + +- Do not persist authorise attributes force & timestamp [\#52](https://github.com/ably/ably-python/issues/52) + +**Closed issues:** + +- Publish on PyPI [\#50](https://github.com/ably/ably-python/issues/50) + +**Merged pull requests:** + +- RSC7, RSC11, RSC15, RSC19 [\#81](https://github.com/ably/ably-python/pull/81) ([jdavid](https://github.com/jdavid)) +- Several python code repo improvements [\#73](https://github.com/ably/ably-python/pull/73) ([txomon](https://github.com/txomon)) +- updated reqests version in requirements [\#67](https://github.com/ably/ably-python/pull/67) ([essweine](https://github.com/essweine)) + +## [v0.8.1](https://github.com/ably/ably-python/tree/v0.8.1) (2016-03-22) +[Full Changelog](https://github.com/ably/ably-python/compare/v0.8.0...v0.8.1) + +**Implemented enhancements:** + +- Don't require get\_default\_params for encryption [\#56](https://github.com/ably/ably-python/issues/56) +- Consistent README [\#8](https://github.com/ably/ably-python/issues/8) + +**Closed issues:** + +- when msgpack enabled, python 2 string literals are encoded as binaries [\#60](https://github.com/ably/ably-python/issues/60) + +**Merged pull requests:** + +- Python 2: assume str is intended as a string [\#64](https://github.com/ably/ably-python/pull/64) ([SimonWoolf](https://github.com/SimonWoolf)) +- Implement latest encryption spec [\#63](https://github.com/ably/ably-python/pull/63) ([SimonWoolf](https://github.com/SimonWoolf)) +- RSA7b4, RSA8f3, RSA8f4 [\#62](https://github.com/ably/ably-python/pull/62) ([fjsj](https://github.com/fjsj)) +- RSA7a4 [\#61](https://github.com/ably/ably-python/pull/61) ([fjsj](https://github.com/fjsj)) +- RSA7a2 [\#59](https://github.com/ably/ably-python/pull/59) ([fjsj](https://github.com/fjsj)) +- RSA12 [\#58](https://github.com/ably/ably-python/pull/58) ([fjsj](https://github.com/fjsj)) + +## [v0.8.0](https://github.com/ably/ably-python/tree/v0.8.0) (2016-03-10) +**Implemented enhancements:** + +- Switch arity of auth methods [\#42](https://github.com/ably/ably-python/issues/42) +- API changes Apr 2015 [\#7](https://github.com/ably/ably-python/issues/7) +- Change of repository name imminent [\#4](https://github.com/ably/ably-python/issues/4) + +**Fixed bugs:** + +- Switch arity of auth methods [\#42](https://github.com/ably/ably-python/issues/42) +- Use sandbox not staging [\#38](https://github.com/ably/ably-python/issues/38) +- API changes Apr 2015 [\#7](https://github.com/ably/ably-python/issues/7) + +**Closed issues:** + +- AblyException does not have \_\_str\_\_ [\#32](https://github.com/ably/ably-python/issues/32) +- Add a requirements-test.txt [\#29](https://github.com/ably/ably-python/issues/29) +- Fix message on test [\#23](https://github.com/ably/ably-python/issues/23) +- Rename test\_channels\_remove to test\_channels\_release [\#20](https://github.com/ably/ably-python/issues/20) +- Add comments in Python 2/3 code at ably/rest/channel.py [\#19](https://github.com/ably/ably-python/issues/19) +- Support for 2.6 [\#10](https://github.com/ably/ably-python/issues/10) +- Spec validation [\#9](https://github.com/ably/ably-python/issues/9) + +**Merged pull requests:** + +- Fixes for PyPI publishing \(already published\) [\#57](https://github.com/ably/ably-python/pull/57) ([fjsj](https://github.com/fjsj)) +- RSL1g [\#55](https://github.com/ably/ably-python/pull/55) ([fjsj](https://github.com/fjsj)) +- Ensure that force and timestamp are not stored in authorise [\#53](https://github.com/ably/ably-python/pull/53) ([meiralins](https://github.com/meiralins)) +- Improve readme, fix setup.py and add support for Python 3.5. [\#51](https://github.com/ably/ably-python/pull/51) ([meiralins](https://github.com/meiralins)) +- Minor adjustments to fit specs. [\#49](https://github.com/ably/ably-python/pull/49) ([meiralins](https://github.com/meiralins)) +- More changes to auth to fit specs. [\#47](https://github.com/ably/ably-python/pull/47) ([meiralins](https://github.com/meiralins)) +- Changes to auth to fit specs. [\#46](https://github.com/ably/ably-python/pull/46) ([aericson](https://github.com/aericson)) +- Changes to client options [\#44](https://github.com/ably/ably-python/pull/44) ([aericson](https://github.com/aericson)) +- RSA10: Auth\#authorise [\#43](https://github.com/ably/ably-python/pull/43) ([aericson](https://github.com/aericson)) +- Done with stats, as well as varying every test to each protocol \(G1\) [\#41](https://github.com/ably/ably-python/pull/41) ([aericson](https://github.com/aericson)) +- Requirements test [\#40](https://github.com/ably/ably-python/pull/40) ([aericson](https://github.com/aericson)) +- Now when sending binary data messages one should use bytearray [\#39](https://github.com/ably/ably-python/pull/39) ([aericson](https://github.com/aericson)) +- Fix travis [\#37](https://github.com/ably/ably-python/pull/37) ([aericson](https://github.com/aericson)) +- Rsc7 and rsc18 [\#36](https://github.com/ably/ably-python/pull/36) ([aericson](https://github.com/aericson)) +- Message pack [\#35](https://github.com/ably/ably-python/pull/35) ([aericson](https://github.com/aericson)) +- Add Query time parameter TO3j10 and RSA9d [\#34](https://github.com/ably/ably-python/pull/34) ([aericson](https://github.com/aericson)) +- Missing channel tests [\#33](https://github.com/ably/ably-python/pull/33) ([aericson](https://github.com/aericson)) +- RSL2a and RSL2b3 - Channel\#history [\#31](https://github.com/ably/ably-python/pull/31) ([aericson](https://github.com/aericson)) +- Message encoding [\#30](https://github.com/ably/ably-python/pull/30) ([aericson](https://github.com/aericson)) +- RSC13 and RSC15 - Hosts fallback and timeouts [\#28](https://github.com/ably/ably-python/pull/28) ([fjsj](https://github.com/fjsj)) +- RSP Presence, TG PaginatedResult and Presence Message TP [\#26](https://github.com/ably/ably-python/pull/26) ([aericson](https://github.com/aericson)) +- \(RSL1d\) Indicates an error if the message was not successfully published to Ably [\#25](https://github.com/ably/ably-python/pull/25) ([fjsj](https://github.com/fjsj)) +- Fix wrongly named tests [\#24](https://github.com/ably/ably-python/pull/24) ([fjsj](https://github.com/fjsj)) +- RSL1a, RSL1b, RSL1e and RSL1c \(incomplete\) [\#21](https://github.com/ably/ably-python/pull/21) ([fjsj](https://github.com/fjsj)) +- Channels - RSN1 to RSN4a [\#18](https://github.com/ably/ably-python/pull/18) ([fjsj](https://github.com/fjsj)) +- Rsc1 api constructor [\#16](https://github.com/ably/ably-python/pull/16) ([aericson](https://github.com/aericson)) +- Fix travis [\#15](https://github.com/ably/ably-python/pull/15) ([fjsj](https://github.com/fjsj)) +- Fix tests except for crypto, messagepack and stats [\#14](https://github.com/ably/ably-python/pull/14) ([aericson](https://github.com/aericson)) +- Fix the readme with the examples and the links [\#5](https://github.com/ably/ably-python/pull/5) ([matrixise](https://github.com/matrixise)) +- Ably Python Rest Library Testing Fixes [\#3](https://github.com/ably/ably-python/pull/3) ([jcrubino](https://github.com/jcrubino)) + + + +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8e89c1cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +- after making any code changes, run `uv ruff check` to make sure linting passes +- use `uv` to run any other necessary tasks such as `pytest` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..14ebf54b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to ably-python + +## Contributing + +### Initialising + +ably-python uses [uv](https://docs.astral.sh/uv/) for packaging and dependency management. Please refer to the [uv documentation](https://docs.astral.sh/uv/getting-started/installation/) for up to date instructions on how to install uv. + +Perform the following operations after cloning the repository contents: + +```shell +git submodule init +git submodule update +# Install the crypto extra if you wish to be able to run all of the tests +uv sync --extra crypto +``` + +### Running the test suite + +```shell +uv run pytest +``` + +## Release Process + +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). + +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) +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 + - The contents of that new file (`delta.md`) then need to be manually inserted at the top of the `CHANGELOG.md`, changing the "Unreleased" heading and linking with the current version numbers + - Also ensure that the "Full Changelog" link points to the new version tag instead of the `HEAD` +5. Commit this change: `git add CHANGELOG.md && git commit -m "Update change log."` +6. Push the release branch to GitHub +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 +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. +Your mileage may vary, but it seems the most reliable method to invoke the generator is something like: +`github_changelog_generator -u ably -p ably-python --since-tag v1.0.0 --output delta.md` +and then manually merge the delta contents in to the main change log (where `v1.0.0` in this case is the tag for the previous release). diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 00000000..6717bc41 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2015-2022 Ably Real-time Ltd (ably.com) diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LONG_DESCRIPTION.rst b/LONG_DESCRIPTION.rst new file mode 100644 index 00000000..3e4a6aed --- /dev/null +++ b/LONG_DESCRIPTION.rst @@ -0,0 +1,20 @@ +Official Ably Bindings for Python +================================== + +A Python client library for Ably Realtime messaging. + + +Setup +----- + +You can install this package by using the pip tool and installing: + + pip install ably + + +Using Ably for Python +--------------------- + +- Sign up for Ably at https://ably.com/sign-up +- Get usage examples at https://github.com/ably/ably-python +- Visit https://ably.com/docs for a complete API reference and more examples diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 00000000..6edbb959 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1 @@ +This repository is owned by the Ably SDK team. diff --git a/README.md b/README.md index 71e34dea..4ee29fd5 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,100 @@ -ably-python ------------ +![Ably Pub/Sub Python Header](images/pythonSDK-github.png) +[![PyPI version](https://badge.fury.io/py/ably.svg)](https://pypi.org/project/ably/) +[![License](https://img.shields.io/github/license/ably/ably-python)](https://github.com/ably/ably-python/blob/main/LICENSE) -Ably.io python client library - REST interface -## Dependencies +# Ably Pub/Sub Python SDK -The ably-python client has one dependency, -[requests>=1.0.0](https://github.com/kennethreitz/requests) +Build any realtime experience using Ably’s Pub/Sub Python SDK. -## Features +Ably Pub/Sub provides flexible APIs that deliver features such as pub-sub messaging, message history, presence, and push notifications. Utilizing Ably’s realtime messaging platform, applications benefit from its highly performant, reliable, and scalable infrastructure. -- Connection Pooling -- HTTP Keep-Alive -- Python 2.6-3.3 -- Compatible with gevent +Find out more: -## Installation +* [Ably Pub/Sub docs.](https://ably.com/docs/basics) +* [Ably Pub/Sub examples.](https://ably.com/examples?product=pubsub) -### From PyPi +--- - pip install ably-python +## Getting started -### From a git url +Everything you need to get started with Ably: - pip install -e git+https://github.com/ably/ably-python#egg=AblyPython +* [Getting started with Pub/Sub using Python.](https://ably.com/docs/getting-started/python) +* [SDK Setup for Python.](https://ably.com/docs/getting-started/setup?lang=python) -### Locally +--- - git clone https://github.com/ably/ably-python.git - cd ably-python - python setup.py install +## Supported platforms -#### To run the tests +Ably aims to support a wide range of platforms. If you experience any compatibility issues, open an issue in the repository or contact [Ably support](https://ably.com/support). - python setup.py test +The following platforms are supported: -## Basic Usage +| Platform | Support | +|----------|--------------------------| +| Python | Python 3.7+ through 3.14 | -```python -from ably.rest import AblyRest +> [!NOTE] +> This SDK works across all major operating platforms (Linux, macOS, Windows) as long as Python 3.7+ is available. -ably = AblyRest("key_str") -ably.time() # returns the server time in ms since the unix epoch -ably.stats() # returns an array of stats +> [!IMPORTANT] +> SDK versions < 2.0.0 are [deprecated](https://ably.com/docs/platform/deprecate/protocol-v1). -# Channels: -# Publish a message to channel 'foo' -ably.channels.foo.publish('msg_name', 'msg_data') +--- -# Get the history for channel 'foo' -ably.channels.foo.history() +## Installation -# Get presence for channel 'foo' -ably.channels.foo.presence() +To get started with your project, install the package: + +```sh +pip install ably ``` -## Options -### Credentials +> [!NOTE] +Install [Python](https://www.python.org/downloads/) version 3.8 or greater. -You can provide either a `key` string or `app_id` + `key_id` + `key_value` -combination. +## Usage -```python -ably = AblyRest("key-string") -``` -or +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 -ably = AblyRest(app_id="app-id", key_id="key-id", key_value="key-value") +# Initialize Ably Realtime client +async with AblyRealtime('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') + + # Get a reference to the 'test-channel' channel + channel = realtime_client.channels.get('test-channel') + + # Subscribe to all messages published to this channel + def on_message(message): + print(f'Received message: {message.data}') + + await channel.subscribe(on_message) + + # Publish a test message to the channel + await channel.publish('test-event', 'hello world') ``` + +## 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). + +--- + +## Contribute + +Read the [CONTRIBUTING.md](./CONTRIBUTING.md) guidelines to contribute to Ably. + +--- + +## 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) for community-reported bugs and discussions. + +### Full Realtime support unavailable + +This SDK currently supports only [Ably REST](https://ably.com/docs/rest) and basic realtime message subscriptions. To access full [Ably Realtime](https://ably.com/docs/realtime) features in Python, consider using the [MQTT adapter](https://ably.com/docs/mqtt). + diff --git a/UPDATING.md b/UPDATING.md new file mode 100644 index 00000000..4b4dd719 --- /dev/null +++ b/UPDATING.md @@ -0,0 +1,362 @@ +# Upgrade / Migration Guide + +## Version 2.x to 3.0.0 + +The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: + + - The realtime channel publish method now uses WebSocket connection instead of REST + - `ably.realtime.realtime_channel` module renamed to `ably.realtime.channel` + - `ChannelOptions` moved to `ably.types.channeloptions` + - REST publish returns publish result with message serials instead of Response object + +### The realtime channel publish method now uses WebSocket + +In previous versions, publishing messages on a realtime channel would use the REST API. In version 3.0.0, realtime channels now publish messages over the WebSocket connection, which is more efficient and provides better consistency. + +This change is mostly transparent to users, but you should be aware that: +- Messages are now published through the realtime connection +- You will receive publish results containing message serials +- The behavior is now consistent with other Ably SDKs + +### Module rename: `ably.realtime.realtime_channel` to `ably.realtime.channel` + +If you were importing from `ably.realtime.realtime_channel`, you will need to update your imports: + +Example 2.x code: +```python +from ably.realtime.realtime_channel import RealtimeChannel +``` + +Example 3.0.0 code: +```python +from ably.realtime.channel import RealtimeChannel +``` + +### `ChannelOptions` moved to `ably.types.channeloptions` + +The `ChannelOptions` class has been moved to a new location for better organization. + +Example 2.x code: +```python +from ably.realtime.realtime_channel import ChannelOptions +``` + +Example 3.0.0 code: +```python +from ably.types.channeloptions import ChannelOptions +``` + +### REST publish returns publish result with serials + +The REST `publish` method now returns a publish result object containing the message serial(s) instead of a raw Response object with `status_code`. + +Example 2.x code: +```python +response = await channel.publish('event', 'message') +print(response.status_code) # 201 +``` + +Example 3.0.0 code: +```python +result = await channel.publish('event', 'message') +print(result.serials) # message serials +``` + +### Client options: `endpoint` replaces `environment`, `rest_host`, and `realtime_host` + +The `environment`, `rest_host`, and `realtime_host` client options have been deprecated in favor of a single `endpoint` option for better consistency and simplicity. + +Example 2.x code: +```python +# Using environment +rest_client = AblyRest(key='api:key', environment='custom') + +# Or using rest_host +rest_client = AblyRest(key='api:key', rest_host='custom.ably.net') + +# For realtime +realtime_client = AblyRealtime(key='api:key', realtime_host='custom.ably.net') +``` + +Example 3.0.0 code: +```python +# Using environment +rest_client = AblyRest(key='api:key', endpoint='custom') + +# Using endpoint for REST +rest_client = AblyRest(key='api:key', endpoint='custom.ably.net') + +# Using endpoint for Realtime +realtime_client = AblyRealtime(key='api:key', endpoint='custom.ably.net') +``` + +## Version 1.2.x to 2.x + +The 2.0 version of ably-python introduces our first Python realtime client. For guidance on how to use the realtime client, refer to the usage examples in the [README](./README.md). + +In addition to this, we have also made some minor breaking changes, these include: + + - Added mandatory version param to `AblyRest.request` + - Changed return type of `AblyRest.stats` + - Removed `Auth.authorise` (in favour of `Auth.authorize`) + - Removed `Options.fallback_hosts_use_default` + - Removed `Crypto.get_default_params(key)` signature. + - Removed the `client_id` and `extras` kwargs from `Channel.publish` + - Calling `channels.release()` no longer raises a `KeyError` if the channel does not yet exist + +### Added mandatory version param to `AblyRest.request` + +If you were using the generic `request` method to query the Ably REST API, you will now need to pass a version string as the third parameter. The version string represents the version of the Ably REST API to use, allowing you to upgrade to newer versions of REST endpoints as soon as they are released. + +```python +await rest.request("GET", "/time", "1.2") +``` + +### Changed return type of `AblyRest.stats` + +The return type of the `stats` method has changed so that all statistics are now contained in a single `dict[string, int]` and the json schema for the entries is included in the response: + +```python +stats_pages = rest.stats(params) +stat = stats_pages.items[0] +print(stat.schema) # contains the canonical url for the statistics json schema +print(stat.entries["messages.inbound.realtime.all.count"]) # all statistics are now included as fields in the Stats.entries dict +``` + +### Deprecation of `Auth.authorise` + +If you were using `Auth.authorise` before, all you need to do to migrate is switch over to `Auth.authorize` (with a 'z') + +### Deprecation of `Options.fallback_hosts_use_default` + +This option is no longer required since the correct fallback hosts are inferred from the `environment` option. If you are still using it then you can safely remove it. + +### Deprecation of `Crypto.get_default_params(key)` signature + +This method now requires a params argument and will raise an error if it is called with just a key. If you were using this signature, you can still call the method using `{'key': key}` as the params argument. + +### Deprecation of `client_id` and `extras` kwargs for `Channel.publish` + +In order to use these options when publishing a message, you will now need to create an instance of the `Message` class. + +Example 1.2.x code: + +```python +await channel.publish(name='name', data='data', client_id='client_id', extras={'some': 'extras'}) +``` + +Example 2.x code: +```python +from ably.types.message import Message +message = Message(name='name', data='data', client_id='client_id', extras={'some': 'extras'}) +await channel.publish(message) +``` + +## Version 1.1.1 to 1.2.0 + +We have made **breaking changes** in the version 1.2 release of this SDK. + +In this guide we aim to highlight the main differences you will encounter when migrating your code from the interfaces we were offering prior to the version 1.2.0 release. + +These include: + + - Deprecation of support for Python versions 3.4, 3.5 and 3.6 + - New, asynchronous API + - Deprecated synchronous API + +### Deprecation of Python 3.4, 3.5 and 3.6 + +The minimum version of Python has increased to 3.7. +You may need to upgrade your environment in order to use this newer version of this SDK. +To see which versions of Python we test the SDK against, please look at our +[GitHub workflows](.github/workflows). + +### Asynchronous API + +The 1.2.0 version introduces a breaking change, which changes the way of interacting with the SDK from synchronous to asynchronous, using [the `asyncio` foundational library](https://docs.python.org/3.7/library/asyncio.html) to provide support for `async`/`await` syntax. +Because of this breaking change, every call that interacts with the Ably REST API must be refactored to this asynchronous way. + +For backwards compatibility, in ably-python 2.0.2 we have added a backwards compatible REST client so that you can still use the synchronous version of the REST interface if you are migrating forwards from version 1.1. +In order to use the synchronous variant, you can import the `AblyRestSync` constructor from `ably.sync`: + +```python +from ably.sync import AblyRestSync + +def main(): + ably = AblyRestSync('api:key') + channel = ably.channels.get("channel_name") + channel.publish('event', 'message') + +if __name__ == "__main__": + main() +``` + +#### Publishing Messages + +This old style, synchronous example: + +```python +from ably import AblyRest + +def main(): + ably = AblyRest('api:key') + channel = ably.channels.get("channel_name") + channel.publish('event', 'message') + +if __name__ == "__main__": + main() +``` + +Must now be replaced with this new style, asynchronous form: + +```python +import asyncio +from ably import AblyRest + +async def main(): + async with AblyRest('api:key') as ably: + channel = ably.channels.get("channel_name") + await channel.publish('event', 'message') + +if __name__ == "__main__": + asyncio.run(main()) +``` + +#### Querying History + +This old style, synchronous example: + +```python +message_page = channel.history() # Returns a PaginatedResult +message_page.items # List with messages from this page +message_page.has_next() # => True, indicates there is another page +message_page.next().items # List with messages from the second page +``` + +Must now be replaced with this new style, asynchronous form: + +```python +message_page = await channel.history() # Returns a PaginatedResult +message_page.items # List with messages from this page +message_page.has_next() # => True, indicates there is another page +next_page = await message_page.next() # Returns a next page +next_page.items # List with messages from the second page +``` + +#### Querying Presence Members on a Channel + +This old style, synchronous example: + +```python +members_page = channel.presence.get() # Returns a PaginatedResult +members_page.items +members_page.items[0].client_id # client_id of first member present +``` + +Must now be replaced with this new style, asynchronous form: + +```python +members_page = await channel.presence.get() # Returns a PaginatedResult +members_page.items +members_page.items[0].client_id # client_id of first member present +``` + +#### Querying Channel Presence History + +This old style, synchronous example: + +```python +presence_page = channel.presence.history() # Returns a PaginatedResult +presence_page.items +presence_page.items[0].client_id # client_id of first member +``` + +Must now be replaced with this new style, asynchronous form: + +```python +presence_page = await channel.presence.history() # Returns a PaginatedResult +presence_page.items +presence_page.items[0].client_id # client_id of first member +``` + +#### Generating a Token + +This old style, synchronous example: + +```python +token_details = client.auth.request_token() +token_details.token # => "xVLyHw.CLchevH3hF....MDh9ZC_Q" +new_client = AblyRest(token=token_details) +``` + +Must now be replaced with this new style, asynchronous form: + +```python +token_details = await client.auth.request_token() +token_details.token # => "xVLyHw.CLchevH3hF....MDh9ZC_Q" +new_client = AblyRest(token=token_details) +await new_client.close() +``` + +#### Generating a TokenRequest + +This old style, synchronous example: + +```python +token_request = client.auth.create_token_request( + { + 'client_id': 'jim', + 'capability': {'channel1': '"*"'}, + 'ttl': 3600 * 1000, # ms + } +) + +new_client = AblyRest(token=token_request) +``` + +Must now be replaced with this new style, asynchronous form: + +```python +token_request = await client.auth.create_token_request( + { + 'client_id': 'jim', + 'capability': {'channel1': '"*"'}, + 'ttl': 3600 * 1000, # ms + } +) + +new_client = AblyRest(token=token_request) +await new_client.close() +``` + +#### Fetching Application Statistics + +This old style, synchronous example: + +```python +stats = client.stats() # Returns a PaginatedResult +stats.items +``` + +Must now be replaced with this new style, asynchronous form: + +```python +stats = await client.stats() # Returns a PaginatedResult +stats.items +await client.close() +``` + +#### Fetching the Ably Service Time + +This old style, synchronous example: + +```python +client.time() +``` + +Must now be replaced with this new style, asynchronous form: + +```python +await client.time() +await client.close() +``` diff --git a/ably/__init__.py b/ably/__init__.py index 6e8d0ee9..e050b7c5 100644 --- a/ably/__init__.py +++ b/ably/__init__.py @@ -1,28 +1,24 @@ import logging -try: - from logging import NullHandler -except ImportError: - class NullHandler(logging.Handler): - def emit(self, record): - pass - - def handle(self, record): - pass - - def createLock(self): - return None - -logger = logging.getLogger(__name__) -logger.addHandler(NullHandler()) - -requests_log = logging.getLogger('requests') -requests_log.setLevel(logging.WARNING) - -from ably.rest.rest import AblyRest +from ably.realtime.realtime import AblyRealtime from ably.rest.auth import Auth +from ably.rest.push import Push +from ably.rest.rest import AblyRest +from ably.types.annotation import Annotation, AnnotationAction from ably.types.capability import Capability +from ably.types.channelmode import ChannelMode from ably.types.channeloptions import ChannelOptions -from ably.types.options import Options +from ably.types.channelsubscription import PushChannelSubscription +from ably.types.device import DeviceDetails +from ably.types.message import MessageAction, MessageVersion +from ably.types.operations import MessageOperation, PublishResult, UpdateDeleteResult +from ably.types.options import Options, VCDiffDecoder from ably.util.crypto import CipherParams -from ably.util.exceptions import AblyException +from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.vcdiff.defaultvcdiffdecoder import AblyVCDiffDecoder + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +api_version = '5' +lib_version = '3.1.2' diff --git a/ably/http/http.py b/ably/http/http.py index 69a8efc6..d21a9386 100644 --- a/ably/http/http.py +++ b/ably/http/http.py @@ -1,74 +1,63 @@ -from __future__ import absolute_import - import functools +import json import logging +import time +from urllib.parse import urljoin -from six.moves import range -from six.moves.urllib.parse import urljoin - -import requests +import httpx +import msgpack from ably.http.httputils import HttpUtils +from ably.rest.auth import Auth from ably.transport.defaults import Defaults from ably.util.exceptions import AblyException +from ably.util.helper import extract_url_params, is_token_error log = logging.getLogger(__name__) -# Decorator to attempt fallback hosts in case of a host-error -def fallback(func): +def reauth_if_expired(func): @functools.wraps(func) - def wrapper(http, *args, **kwargs): - try: - return func(http, *args, **kwargs) - except requests.exceptions.ConnectionError as e: - # if we cannot attempt a fallback, re-raise - # TODO: See if we can determine why this failed - fallback_hosts = Defaults.get_fallback_hosts(http.options) - if kwargs.get("host") or not fallback_hosts: - raise - - last_exception = None - for host in fallback_hosts: - try: - kwargs["host"] = host - return func(rest, *args, **kwargs) - except requests.exceptions.ConnectionError as e: - # TODO: as above - last_exception = e + async def wrapper(rest, *args, **kwargs): + if kwargs.get("skip_auth"): + return await func(rest, *args, **kwargs) - raise last_exception - return wrapper + # RSA4b1 Detect expired token to avoid round-trip request + auth = rest.auth + token_details = auth.token_details + if token_details and auth.time_offset is not None and auth.token_details_has_expired(): + await auth.authorize() + retried = True + else: + retried = False + try: + return await func(rest, *args, **kwargs) + except AblyException as e: + if is_token_error(e) and not retried: + await auth.authorize() + return await func(rest, *args, **kwargs) -def reauth_if_expired(func): - @functools.wraps(func) - def wrapper(rest, *args, **kwargs): - if kwargs.get("skip_auth"): - return func(rest, *args, **kwargs) + raise e - num_tries = 5 - for i in range(num_tries): - try: - return func(rest, *args, **kwargs) - except AblyException as e: - if e.code == 40140 and i < (num_tries - 1): - rest.reauth() - continue - raise return wrapper -class Request(object): - def __init__(self, method='GET', url='/', headers=None, body=None, skip_auth=False): +class Request: + def __init__(self, method='GET', url='/', version=None, headers=None, body=None, + skip_auth=False, raise_on_error=True): self.__method = method self.__headers = headers or {} self.__body = body self.__skip_auth = skip_auth self.__url = url + self.__version = version + self.raise_on_error = raise_on_error def with_relative_url(self, relative_url): - return Request(self.method, urljoin(self.url, relative_url), self.headers, self.body, self.skip_auth) + url = urljoin(self.url, relative_url) + return Request(self.method, url, self.version, self.headers, self.body, + self.skip_auth, self.raise_on_error) @property def method(self): @@ -90,91 +79,189 @@ def body(self): def skip_auth(self): return self.__skip_auth + @property + def version(self): + return self.__version + + +class Response: + """ + Composition for httpx.Response with delegation + """ -class Response(object): def __init__(self, response): self.__response = response - def json(self): - return self.response.json() + def to_native(self): + content = self.__response.content + if not content: + return None - @property - def response(self): - return self.__response + content_type = self.__response.headers.get('content-type') + if isinstance(content_type, str): + if content_type.startswith('application/x-msgpack'): + return msgpack.unpackb(content) + elif content_type.startswith('application/json'): + return self.__response.json() - @property - def text(self): - return self.response.text - - @property - def status_code(self): - return self.response.status_code + raise ValueError("Unsupported content type") @property - def headers(self): - return self.headers + def response(self): + return self.__response - @property - def content_type(self): - return self.response.headers['Content-Type'] + def __getattr__(self, attr): + return getattr(self.__response, attr) - @property - def links(self): - return self.response.links +class Http: + CONNECTION_RETRY_DEFAULTS = { + 'http_open_timeout': 4, + 'http_request_timeout': 10, + 'http_max_retry_duration': 15, + } -class Http(object): def __init__(self, ably, options): options = options or {} self.__ably = ably self.__options = options - - self.__session = requests.Session() self.__auth = None + # Cached fallback host (RSC15f) + self.__host = None + self.__host_expires = None + self.__client = httpx.AsyncClient(http2=True) + + async def close(self): + await self.__client.aclose() + + def dump_body(self, body): + if self.options.use_binary_protocol: + return msgpack.packb(body, use_bin_type=False) + else: + return json.dumps(body, separators=(',', ':')) + + def get_hosts(self): + hosts = self.options.get_hosts() + host = self.__host or self.options.fallback_host + if host is None: + return hosts + + # unstore saved fallback host after fallbackRetryTimeout (RSC15f) + if self.__host_expires is not None and time.time() > self.__host_expires: + self.__host = None + self.__host_expires = None + return hosts + + hosts = list(hosts) + hosts.remove(host) + hosts.insert(0, host) + return hosts - @fallback @reauth_if_expired - def make_request(self, method, url, headers=None, body=None, skip_auth=False, timeout=None, scheme=None, host=None, port=0): - scheme = scheme or self.preferred_scheme - host = host or self.preferred_host - port = port or self.preferred_port - base_url = "%s://%s:%d" % (scheme, host, port) - url = urljoin(base_url, url) - - hdrs = headers or {} - headers = HttpUtils.default_get_headers(not self.options.use_text_protocol) - headers.update(hdrs) - - if not skip_auth: - headers.update(self.auth._get_auth_headers()) - - request = requests.Request(method, url, data=body, headers=headers) - prepped = self.__session.prepare_request(request) - - # log.debug("Method: %s" % method) - # log.debug("Url: %s" % url) - # log.debug("Headers: %s" % headers) - # log.debug("Body: %s" % body) - # log.debug("Prepped: %s" % prepped) - - # TODO add timeouts from options here - response = self.__session.send(prepped) - - AblyException.raise_for_response(response) + async def make_request(self, method, path, version=None, headers=None, body=None, + skip_auth=False, timeout=None, raise_on_error=True): - return Response(response) + if body is not None and type(body) not in (bytes, str): + body = self.dump_body(body) - def request(self, request): - return self.make_request(request.method, request.url, headers=request.headers, body=request.body) + if body: + all_headers = HttpUtils.default_post_headers(self.options.use_binary_protocol, version=version) + else: + all_headers = HttpUtils.default_get_headers(self.options.use_binary_protocol, version=version) - def get(self, url, headers=None, skip_auth=False, timeout=None): - return self.make_request('GET', url, headers=headers, skip_auth=skip_auth, timeout=timeout) + params = HttpUtils.get_query_params(self.options) - def post(self, url, headers=None, body=None, skip_auth=False, timeout=None): - return self.make_request('POST', url, headers=headers, body=body, skip_auth=skip_auth, timeout=timeout) - - def delete(self, url, headers=None, skip_auth=False, timeout=None): - return self.make_request('DELETE', url, headers=headers, skip_auth=skip_auth, timeout=timeout) + if not skip_auth: + if self.auth.auth_mechanism == Auth.Method.BASIC and self.preferred_scheme.lower() == 'http': + raise AblyException( + "Cannot use Basic Auth over non-TLS connections", + 401, + 40103) + auth_headers = await self.auth._get_auth_headers() + all_headers.update(auth_headers) + if headers: + all_headers.update(headers) + + timeout = (self.http_open_timeout, self.http_request_timeout) + http_max_retry_duration = self.http_max_retry_duration + requested_at = time.time() + + hosts = self.get_hosts() + for retry_count, host in enumerate(hosts): + def should_stop_retrying(retry_count=retry_count): + time_passed = time.time() - requested_at + # if it's the last try or cumulative timeout is done, we stop retrying + return retry_count == len(hosts) - 1 or time_passed > http_max_retry_duration + + base_url = f"{self.preferred_scheme}://{host}:{self.preferred_port}" + url = urljoin(base_url, path) + + (clean_url, url_params) = extract_url_params(url) + + request = self.__client.build_request( + method=method, + url=clean_url, + content=body, + params=dict(url_params, **params), + headers=all_headers, + timeout=timeout, + ) + try: + response = await self.__client.send(request) + except Exception as e: + if should_stop_retrying(): + raise e + else: + # RSC15l4 + cloud_front_error = (response.headers.get('Server', '').lower() == 'cloudfront' + and response.status_code >= 400) + # RSC15l3 + retryable_server_error = response.status_code >= 500 and response.status_code <= 504 + # Resending requests that have failed for other failure conditions will not fix the problem + # and will simply increase the load on other datacenters unnecessarily + should_fallback = cloud_front_error or retryable_server_error + + try: + if raise_on_error: + AblyException.raise_for_response(response) + + if should_fallback and not should_stop_retrying(): + continue + + # Keep fallback host for later (RSC15f) + if retry_count > 0 and host != self.options.get_host(): + self.__host = host + self.__host_expires = time.time() + (self.options.fallback_retry_timeout / 1000.0) + + return Response(response) + except AblyException as e: + if should_stop_retrying() or not should_fallback: + raise e + + async def delete(self, url, headers=None, skip_auth=False, timeout=None): + result = await self.make_request('DELETE', url, headers=headers, + skip_auth=skip_auth, timeout=timeout) + return result + + async def get(self, url, headers=None, skip_auth=False, timeout=None): + result = await self.make_request('GET', url, headers=headers, + skip_auth=skip_auth, timeout=timeout) + return result + + async def patch(self, url, headers=None, body=None, skip_auth=False, timeout=None): + result = await self.make_request('PATCH', url, headers=headers, body=body, + skip_auth=skip_auth, timeout=timeout) + return result + + async def post(self, url, headers=None, body=None, skip_auth=False, timeout=None): + result = await self.make_request('POST', url, headers=headers, body=body, + skip_auth=skip_auth, timeout=timeout) + return result + + async def put(self, url, headers=None, body=None, skip_auth=False, timeout=None): + result = await self.make_request('PUT', url, headers=headers, body=body, + skip_auth=skip_auth, timeout=timeout) + return result @property def auth(self): @@ -190,7 +277,7 @@ def options(self): @property def preferred_host(self): - return Defaults.get_host(self.options) + return self.options.get_host() @property def preferred_port(self): @@ -199,3 +286,27 @@ def preferred_port(self): @property def preferred_scheme(self): return Defaults.get_scheme(self.options) + + @property + def http_open_timeout(self): + if self.options.http_open_timeout is not None: + return self.options.http_open_timeout + return self.CONNECTION_RETRY_DEFAULTS['http_open_timeout'] + + @property + def http_request_timeout(self): + if self.options.http_request_timeout is not None: + return self.options.http_request_timeout + return self.CONNECTION_RETRY_DEFAULTS['http_request_timeout'] + + @property + def http_max_retry_count(self): + if self.options.http_max_retry_count is not None: + return self.options.http_max_retry_count + return self.CONNECTION_RETRY_DEFAULTS['http_max_retry_count'] + + @property + def http_max_retry_duration(self): + if self.options.http_max_retry_duration is not None: + return self.options.http_max_retry_duration + return self.CONNECTION_RETRY_DEFAULTS['http_max_retry_duration'] diff --git a/ably/http/httputils.py b/ably/http/httputils.py index 1eb0f9bc..aca46b0f 100644 --- a/ably/http/httputils.py +++ b/ably/http/httputils.py @@ -1,37 +1,55 @@ -from __future__ import absolute_import +import base64 +import os +import platform -from ably.util.exceptions import AblyException +import ably -class HttpUtils(object): +class HttpUtils: default_format = "json" mime_types = { "json": "application/json", "xml": "application/xml", "html": "text/html", - # "binary": "application/x-thrift", + "binary": "application/x-msgpack", } @staticmethod - def default_get_headers(binary=False): + def default_get_headers(binary=False, version=None): + headers = HttpUtils.default_headers(version=version) if binary: - raise AblyException(reason="Binary protocol is not implemented", - status_code=400, - code=40000) + headers["Accept"] = HttpUtils.mime_types['binary'] else: - return { - "Accept": "application/json", - } + headers["Accept"] = HttpUtils.mime_types['json'] + return headers @staticmethod - def default_post_headers(binary=False): - if binary: - raise AblyException(reason="Binary protocol is not implemented", - status_code=400, - code=40000) - else: - return { - "Accept": "application/json", - "Content-Type": "application/json", - } + def default_post_headers(binary=False, version=None): + headers = HttpUtils.default_get_headers(binary=binary, version=version) + headers["Content-Type"] = headers["Accept"] + return headers + + @staticmethod + def get_host_header(host): + return { + 'Host': host, + } + + @staticmethod + def default_headers(version=None): + if version is None: + version = ably.api_version + return { + "X-Ably-Version": version, + "Ably-Agent": f'ably-python/{ably.lib_version} python/{platform.python_version()}' + } + + @staticmethod + def get_query_params(options): + params = {} + + if options.add_request_ids: + params['request_id'] = base64.urlsafe_b64encode(os.urandom(12)).decode('ascii') + + return params diff --git a/ably/http/paginatedresult.py b/ably/http/paginatedresult.py index 421fe08e..a034d9d1 100644 --- a/ably/http/paginatedresult.py +++ b/ably/http/paginatedresult.py @@ -1,85 +1,134 @@ -from __future__ import absolute_import - +import calendar import logging +from urllib.parse import urlencode from ably.http.http import Request +from ably.util import case log = logging.getLogger(__name__) -class PaginatedResult(object): - def __init__(self, http, current, content_type, rel_first, rel_current, - rel_next, response_processor): +def format_time_param(t): + try: + return f'{calendar.timegm(t.utctimetuple()) * 1000}' + except Exception: + return str(t) + + +def format_params(params=None, direction=None, start=None, end=None, limit=None, **kw): + if params is None: + params = {} + + for key, value in kw.items(): + if value is not None: + key = case.snake_to_camel(key) + params[key] = value + + if direction: + params['direction'] = str(direction) + if start: + params['start'] = format_time_param(start) + if end: + params['end'] = format_time_param(end) + if limit: + if limit > 1000: + raise ValueError("The maximum allowed limit is 1000") + params['limit'] = f'{limit}' + + if 'start' in params and 'end' in params and params['start'] > params['end']: + raise ValueError("'end' parameter has to be greater than or equal to 'start'") + + return '?' + urlencode(params) if params else '' + + +class PaginatedResult: + def __init__(self, http, items, content_type, rel_first, rel_next, + response_processor, response): self.__http = http - self.__current = current + self.__items = items self.__content_type = content_type self.__rel_first = rel_first - self.__rel_current = rel_current self.__rel_next = rel_next self.__response_processor = response_processor + self.response = response @property + def items(self): + return self.__items + def has_first(self): return self.__rel_first is not None - @property - def current(self): - return self.__current - - @property - def has_current(self): - return self.__rel_current is not None - - @property def has_next(self): return self.__rel_next is not None - def get_first(self): - return self.__get_rel(self.__rel_first) + def is_last(self): + return not self.has_next() - def get_current(self): - return self.__get_rel(self.__rel_current) + async def first(self): + return await self.__get_rel(self.__rel_first) if self.__rel_first else None - def get_next(self): - return self.__get_rel(self.__rel_next) + async def next(self): + return await self.__get_rel(self.__rel_next) if self.__rel_next else None - def __get_rel(self, rel_req): + async def __get_rel(self, rel_req): if rel_req is None: return None - return PaginatedResult.paginated_query_with_request(self.__http, rel_req, self.__response_processor) - - @staticmethod - def paginated_query(http, url, headers, response_processor): - req = Request(method='GET', url=url, headers=headers, body=None, skip_auth=True) - return PaginatedResult.paginated_query_with_request(http, req, response_processor) - - @staticmethod - def paginated_query_with_request(http, request, response_processor): - response = http.request(request) - - current_val = response_processor(response) - - content_type = response.content_type + return await self.paginated_query_with_request(self.__http, rel_req, self.__response_processor) + + @classmethod + async def paginated_query(cls, http, method='GET', url='/', version=None, body=None, + headers=None, response_processor=None, + raise_on_error=True): + headers = headers or {} + req = Request(method, url, version=version, body=body, headers=headers, skip_auth=False, + raise_on_error=raise_on_error) + return await cls.paginated_query_with_request(http, req, response_processor) + + @classmethod + async def paginated_query_with_request(cls, http, request, response_processor, + raise_on_error=True): + response = await http.make_request( + request.method, request.url, version=request.version, + headers=request.headers, body=request.body, + skip_auth=request.skip_auth, raise_on_error=request.raise_on_error) + + items = response_processor(response) + + content_type = response.headers['Content-Type'] links = response.links - log.debug("Links: %s" % links) - log.debug("Response: %s" % response) if 'first' in links: first_rel_request = request.with_relative_url(links['first']['url']) else: first_rel_request = None - if 'current' in links: - current_rel_request = request.with_relative_url(links['current']['url']) - else: - current_rel_request = None - if 'next' in links: - log.debug("Next: %s" % links['next']['url']) next_rel_request = request.with_relative_url(links['next']['url']) - log.debug("Next rel request: %s" % next_rel_request) else: next_rel_request = None - return PaginatedResult(http, current_val, content_type, - first_rel_request, current_rel_request, - next_rel_request, response_processor) + return cls(http, items, content_type, first_rel_request, + next_rel_request, response_processor, response) + + +class HttpPaginatedResponse(PaginatedResult): + @property + def status_code(self): + return self.response.status_code + + @property + def success(self): + status_code = self.status_code + return 200 <= status_code < 300 + + @property + def error_code(self): + return self.response.headers.get('X-Ably-Errorcode') + + @property + def error_message(self): + return self.response.headers.get('X-Ably-Errormessage') + + @property + def headers(self): + return list(self.response.headers.items()) diff --git a/ably/realtime/__init__.py b/ably/realtime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ably/realtime/annotations.py b/ably/realtime/annotations.py new file mode 100644 index 00000000..fbbbb755 --- /dev/null +++ b/ably/realtime/annotations.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ably.rest.annotations import RestAnnotations, construct_validate_annotation +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.annotation import Annotation, AnnotationAction +from ably.types.channelmode import ChannelMode +from ably.types.channelstate import ChannelState +from ably.util.eventemitter import EventEmitter +from ably.util.helper import is_callable_or_coroutine + +if TYPE_CHECKING: + from ably.realtime.channel import RealtimeChannel + from ably.realtime.connectionmanager import ConnectionManager + +log = logging.getLogger(__name__) + + +class RealtimeAnnotations: + """ + Provides realtime methods for managing annotations on messages, + including publishing annotations and subscribing to annotation events. + """ + + __connection_manager: ConnectionManager + __channel: RealtimeChannel + + def __init__(self, channel: RealtimeChannel, connection_manager: ConnectionManager): + """ + Initialize RealtimeAnnotations. + + Args: + channel: The Realtime Channel this annotations instance belongs to + """ + self.__channel = channel + self.__connection_manager = connection_manager + self.__subscriptions = EventEmitter() + self.__rest_annotations = RestAnnotations(channel) + + async def __send_annotation(self, annotation: Annotation, params: dict | None = None): + """ + Internal method to send an annotation via the realtime connection. + + Args: + annotation: Validated Annotation object with action and message_serial set + params: Optional dict of query parameters + """ + # Check if channel and connection are in publishable state + self.__channel._throw_if_unpublishable_state() + + log.info( + f'RealtimeAnnotations: sending annotation, channelName = {self.__channel.name}, ' + f'messageSerial = {annotation.message_serial}, ' + f'type = {annotation.type}, action = {annotation.action}' + ) + + # Convert to wire format (array of annotations) + wire_annotation = annotation.as_dict(binary=self.__channel.ably.options.use_binary_protocol) + + # Build protocol message + protocol_message = { + "action": ProtocolMessageAction.ANNOTATION, + "channel": self.__channel.name, + "annotations": [wire_annotation], + } + + if params: + # Stringify boolean params + stringified_params = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} + protocol_message["params"] = stringified_params + + # Send via WebSocket + await self.__connection_manager.send_protocol_message(protocol_message) + + async def publish(self, msg_or_serial, annotation: Annotation, params: dict | None = None): + """ + Publish an annotation on a message via the realtime connection. + + Args: + msg_or_serial: Either a message serial (string) or a Message object + annotation: Annotation object + params: Optional dict of query parameters + + Returns: + None + + Raises: + AblyException: If the request fails, inputs are invalid, or channel is in unpublishable state + """ + annotation = construct_validate_annotation(msg_or_serial, annotation) + + # RSAN1c1/RTAN1a: Explicitly set action to ANNOTATION_CREATE + annotation = annotation._copy_with(action=AnnotationAction.ANNOTATION_CREATE) + + await self.__send_annotation(annotation, params) + + async def delete( + self, + msg_or_serial, + annotation: Annotation, + params: dict | None = None, + ): + """ + Delete an annotation on a message. + + Args: + msg_or_serial: Either a message serial (string) or a Message object + annotation: Annotation containing annotation properties + params: Optional dict of query parameters + + Returns: + None + + Raises: + AblyException: If the request fails or inputs are invalid + """ + annotation = construct_validate_annotation(msg_or_serial, annotation) + + # RSAN2a/RTAN2a: Explicitly set action to ANNOTATION_DELETE + annotation = annotation._copy_with(action=AnnotationAction.ANNOTATION_DELETE) + + await self.__send_annotation(annotation, params) + + async def subscribe(self, *args): + """ + Subscribe to annotation events on this channel. + + Parameters + ---------- + *args: type_or_types, listener + Subscribe type(s) and listener + + arg1(type_or_types): str or list[str], optional + Subscribe to annotations of the given type or types (RTAN4c) + + arg2(listener): callable + Subscribe to all annotations on the channel + + When no type is provided, arg1 is used as the listener. + + Raises + ------ + ValueError + If no valid subscribe arguments are passed + """ + # Parse arguments similar to channel.subscribe + if len(args) == 0: + raise ValueError("annotations.subscribe called without arguments") + + annotation_types = None + + # RTAN4c: Support string or list of strings as first argument + if len(args) >= 2 and isinstance(args[0], (str, list)): + if isinstance(args[0], list): + annotation_types = args[0] + else: + annotation_types = [args[0]] + if not args[1]: + raise ValueError("annotations.subscribe called without listener") + if not is_callable_or_coroutine(args[1]): + raise ValueError("subscribe listener must be function or coroutine function") + listener = args[1] + elif is_callable_or_coroutine(args[0]): + listener = args[0] + else: + raise ValueError('invalid subscribe arguments') + + # RTAN4d: Implicitly attach channel on subscribe + await self.__channel.attach() + + # RTAN4e: Check if ANNOTATION_SUBSCRIBE mode is enabled (log warning per spec), + # only when server explicitly sent modes (non-empty list) + if self.__channel.state == ChannelState.ATTACHED and self.__channel.modes: + if ChannelMode.ANNOTATION_SUBSCRIBE not in self.__channel.modes: + log.warning( + "You are trying to add an annotation listener, but the " + "ANNOTATION_SUBSCRIBE channel mode was not included in the ATTACHED flags. " + "This subscription may not receive annotations. Ensure you request the " + "annotation_subscribe channel mode in ChannelOptions." + ) + + # Register subscription after successful attach + if annotation_types is not None: + for t in annotation_types: + self.__subscriptions.on(t, listener) + else: + self.__subscriptions.on(listener) + + def unsubscribe(self, *args): + """ + Unsubscribe from annotation events on this channel. + + Parameters + ---------- + *args: type_or_types, listener + Unsubscribe type(s) and listener + + arg1(type_or_types): str or list[str], optional + Unsubscribe from annotations of the given type or types + + arg2(listener): callable + Unsubscribe from all annotations on the channel + + When no type is provided, arg1 is used as the listener. + When no arguments are provided, unsubscribes all annotation listeners (RTAN5). + + Raises + ------ + ValueError + If invalid unsubscribe arguments are passed + """ + # RTAN5: Support no arguments to unsubscribe all annotation listeners + if len(args) == 0: + self.__subscriptions.off() + elif len(args) >= 2 and isinstance(args[0], (str, list)): + # RTAN5a: Support string or list of strings for type(s) + if isinstance(args[0], list): + annotation_types = args[0] + else: + annotation_types = [args[0]] + listener = args[1] + for t in annotation_types: + self.__subscriptions.off(t, listener) + elif is_callable_or_coroutine(args[0]): + listener = args[0] + self.__subscriptions.off(listener) + else: + raise ValueError('invalid unsubscribe arguments') + + def _process_incoming(self, incoming_annotations): + """ + Process incoming annotations from the server. + + This is called internally when ANNOTATION protocol messages are received. + + Args: + incoming_annotations: List of Annotation objects received from the server + """ + for annotation in incoming_annotations: + # Emit to type-specific listeners and catch-all listeners + annotation_type = annotation.type or '' + self.__subscriptions._emit(annotation_type, annotation) + + async def get(self, msg_or_serial, params: dict | None = None): + """ + Retrieve annotations for a message with pagination support. + + This delegates to the REST implementation. + + Args: + msg_or_serial: Either a message serial (string) or a Message object + params: Optional dict of query parameters (limit, start, end, direction) + + Returns: + PaginatedResult: A paginated result containing Annotation objects + + Raises: + AblyException: If the request fails or serial is invalid + """ + # Delegate to REST implementation + return await self.__rest_annotations.get(msg_or_serial, params) diff --git a/ably/realtime/channel.py b/ably/realtime/channel.py new file mode 100644 index 00000000..33e338d6 --- /dev/null +++ b/ably/realtime/channel.py @@ -0,0 +1,1078 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from ably.realtime.annotations import RealtimeAnnotations +from ably.realtime.connection import ConnectionState +from ably.realtime.presence import RealtimePresence +from ably.rest.channel import Channel +from ably.rest.channel import Channels as RestChannels +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.annotation import Annotation +from ably.types.channelmode import ChannelMode, decode_channel_mode, encode_channel_mode +from ably.types.channeloptions import ChannelOptions +from ably.types.channelstate import ChannelState, ChannelStateChange +from ably.types.flags import Flag, has_flag +from ably.types.message import Message, MessageAction, MessageVersion +from ably.types.mixins import DecodingContext +from ably.types.operations import MessageOperation, PublishResult, UpdateDeleteResult +from ably.types.presence import PresenceMessage +from ably.util.eventemitter import EventEmitter +from ably.util.exceptions import AblyException, IncompatibleClientIdException +from ably.util.helper import Timer, is_callable_or_coroutine, validate_message_size + +if TYPE_CHECKING: + from ably.realtime.realtime import AblyRealtime + +log = logging.getLogger(__name__) + + +class RealtimeChannel(EventEmitter, Channel): + """ + Ably Realtime Channel + + Attributes + ---------- + name: str + Channel name + state: str + Channel state + error_reason: AblyException + An AblyException instance describing the last error which occurred on the channel, if any. + + Methods + ------- + attach() + Attach to channel + detach() + Detach from channel + subscribe(*args) + Subscribe to messages on a channel + unsubscribe(*args) + Unsubscribe to messages from a channel + """ + + def __init__(self, realtime: AblyRealtime, name: str, channel_options: ChannelOptions | None = None): + EventEmitter.__init__(self) + self.__name = name + self.__realtime = realtime + self.__state = ChannelState.INITIALIZED + self.__message_emitter = EventEmitter() + self.__state_timer: Timer | None = None + self.__attach_resume = False + self.__attach_serial: str | None = None + self.__channel_serial: str | None = None + self.__retry_timer: Timer | None = None + self.__error_reason: AblyException | None = None + self.__channel_options = channel_options or ChannelOptions() + self.__params: dict[str, str] | None = None + self.__modes: list[ChannelMode] = [] # Channel mode flags from ATTACHED message + + # Delta-specific fields for RTL19/RTL20 compliance + vcdiff_decoder = self.__realtime.options.vcdiff_decoder if self.__realtime.options.vcdiff_decoder else None + self.__decoding_context = DecodingContext(vcdiff_decoder=vcdiff_decoder) + self.__decode_failure_recovery_in_progress = False + + # Used to listen to state changes internally, if we use the public event emitter interface then internals + # will be disrupted if the user called .off() to remove all listeners + self.__internal_state_emitter = EventEmitter() + + # Pass channel options as dictionary to parent Channel class + Channel.__init__(self, realtime, name, self.__channel_options.to_dict()) + + # Initialize presence for this channel + + self.__presence = RealtimePresence(self) + + # Initialize realtime annotations for this channel (override REST annotations) + self._Channel__annotations = RealtimeAnnotations(self, realtime.connection.connection_manager) + + async def set_options(self, channel_options: ChannelOptions) -> None: + """Set channel options""" + should_reattach = self.should_reattach_to_set_options(channel_options) + self.set_options_without_reattach(channel_options) + + if should_reattach: + self._attach_impl() + state_change = await self.__internal_state_emitter.once_async() + if state_change.current in (ChannelState.SUSPENDED, ChannelState.FAILED): + raise state_change.reason + + def set_options_without_reattach(self, channel_options: ChannelOptions) -> None: + """Internal method""" + self.__channel_options = channel_options + # Update parent class options + self.options = channel_options.to_dict() + + # RTL4 + async def attach(self) -> None: + """Attach to channel + + Attach to this channel ensuring the channel is created in the Ably system and all messages published + on the channel are received by any channel listeners registered using subscribe + + Raises + ------ + AblyException + If unable to attach channel + """ + + log.info(f'RealtimeChannel.attach() called, channel = {self.name}') + + # RTL4a - if channel is attached do nothing + if self.state == ChannelState.ATTACHED: + return + + self.__error_reason = None + + # RTL4b + if self.__realtime.connection.state not in [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED + ]: + raise AblyException( + message=f"Unable to attach; channel state = {self.state}", + code=90001, + status_code=400 + ) + + if self.state != ChannelState.ATTACHING: + self._request_state(ChannelState.ATTACHING) + + state_change = await self.__internal_state_emitter.once_async() + + if state_change.current in (ChannelState.SUSPENDED, ChannelState.FAILED): + raise state_change.reason + + def _attach_impl(self): + log.debug("RealtimeChannel.attach_impl(): sending ATTACH protocol message") + + # RTL4c + attach_msg = { + "action": ProtocolMessageAction.ATTACH, + "params": self.__channel_options.params, + "channel": self.name, + } + + flags = self._encode_flags() + + if flags: + attach_msg["flags"] = flags + if self.__channel_serial: + attach_msg["channelSerial"] = self.__channel_serial + + self._send_message(attach_msg) + + # RTL5 + async def detach(self) -> None: + """Detach from channel + + Any resulting channel state change is emitted to any listeners registered + Once all clients globally have detached from the channel, the channel will be released + in the Ably service within two minutes. + + Raises + ------ + AblyException + If unable to detach channel + """ + + log.info(f'RealtimeChannel.detach() called, channel = {self.name}') + + # RTL5g, RTL5b - raise exception if state invalid + if self.__realtime.connection.state in [ConnectionState.CLOSING, ConnectionState.FAILED]: + raise AblyException( + message=f"Unable to detach; channel state = {self.state}", + code=90001, + status_code=400 + ) + + # RTL5a - if channel already detached do nothing + if self.state in [ChannelState.INITIALIZED, ChannelState.DETACHED]: + return + + if self.state == ChannelState.SUSPENDED: + self._notify_state(ChannelState.DETACHED) + return + elif self.state == ChannelState.FAILED: + raise AblyException("Unable to detach; channel state = failed", 90001, 400) + else: + self._request_state(ChannelState.DETACHING) + + # RTL5h - wait for pending connection + if self.__realtime.connection.state == ConnectionState.CONNECTING: + self.__realtime.connect() + + state_change = await self.__internal_state_emitter.once_async() + new_state = state_change.current + + if new_state == ChannelState.DETACHED: + return + elif new_state == ChannelState.ATTACHING: + raise AblyException("Detach request superseded by a subsequent attach request", 90000, 409) + else: + raise state_change.reason + + def _detach_impl(self) -> None: + log.debug("RealtimeChannel.detach_impl(): sending DETACH protocol message") + + # RTL5d + detach_msg = { + "action": ProtocolMessageAction.DETACH, + "channel": self.__name, + } + + self._send_message(detach_msg) + + # RTL7 + async def subscribe(self, *args) -> None: + """Subscribe to a channel + + Registers a listener for messages on the channel. + The caller supplies a listener function, which is called + each time one or more messages arrives on the channel. + + The function resolves once the channel is attached. + + Parameters + ---------- + *args: event, listener + Subscribe event and listener + + arg1(event): str, optional + Subscribe to messages with the given event name + + arg2(listener): callable + Subscribe to all messages on the channel + + When no event is provided, arg1 is used as the listener. + + Raises + ------ + AblyException + If unable to subscribe to a channel due to invalid connection state + ValueError + If no valid subscribe arguments are passed + """ + if isinstance(args[0], str): + event = args[0] + if not args[1]: + raise ValueError("channel.subscribe called without listener") + if not is_callable_or_coroutine(args[1]): + raise ValueError("subscribe listener must be function or coroutine function") + listener = args[1] + elif is_callable_or_coroutine(args[0]): + listener = args[0] + event = None + else: + raise ValueError('invalid subscribe arguments') + + log.info(f'RealtimeChannel.subscribe called, channel = {self.name}, event = {event}') + + if event is not None: + # RTL7b + self.__message_emitter.on(event, listener) + else: + # RTL7a + self.__message_emitter.on(listener) + + # RTL7c + await self.attach() + + # RTL8 + def unsubscribe(self, *args) -> None: + """Unsubscribe from a channel + + Deregister the given listener for (for any/all event names). + This removes an earlier event-specific subscription. + + Parameters + ---------- + *args: event, listener + Unsubscribe event and listener + + arg1(event): str, optional + Unsubscribe to messages with the given event name + + arg2(listener): callable + Unsubscribe to all messages on the channel + + When no event is provided, arg1 is used as the listener. + + Raises + ------ + ValueError + If no valid unsubscribe arguments are passed, no listener or listener is not a function + or coroutine + """ + if len(args) == 0: + event = None + listener = None + elif isinstance(args[0], str): + event = args[0] + if not args[1]: + raise ValueError("channel.unsubscribe called without listener") + if not is_callable_or_coroutine(args[1]): + raise ValueError("unsubscribe listener must be a function or coroutine function") + listener = args[1] + elif is_callable_or_coroutine(args[0]): + listener = args[0] + event = None + else: + raise ValueError('invalid unsubscribe arguments') + + log.info(f'RealtimeChannel.unsubscribe called, channel = {self.name}, event = {event}') + + if listener is None: + # RTL8c + self.__message_emitter.off() + elif event is not None: + # RTL8b + self.__message_emitter.off(event, listener) + else: + # RTL8a + self.__message_emitter.off(listener) + + # RTL6 + async def publish(self, *args, **kwargs) -> PublishResult: + """Publish a message or messages on this channel + + Publishes a single message or an array of messages to the channel. + + Parameters + ---------- + *args: name and data, or message object(s) + Either: + - name (str) and data (any): publish a single message + - message (Message or dict): publish a single message object + - messages (list): publish multiple message objects + + Raises + ------ + AblyException + If the channel or connection state prevents publishing, + if clientId validation fails, or if message size exceeds limits + ValueError + If invalid arguments are provided + """ + messages = [] + + # RTL6i: Parse arguments - expect Message object, array of Messages, or name and data + if len(args) == 1: + if isinstance(args[0], Message): + # Single Message object + messages = [args[0]] + elif isinstance(args[0], dict): + # Message as dict + messages = [Message(**args[0])] + elif isinstance(args[0], list): + # RTL6i2: Array of Message objects + messages = [] + for msg in args[0]: + if isinstance(msg, Message): + messages.append(msg) + elif isinstance(msg, dict): + messages.append(Message(**msg)) + else: + raise ValueError("Array must contain Message objects or dicts") + else: + raise ValueError( + "The single-argument form of publish() expects a message object or an array of message objects" + ) + elif len(args) == 2: + # RTL6i1: name and data form + # RTL6i3: Allow name and/or data to be None + name = args[0] + data = args[1] + messages = [Message(name=name, data=data)] + else: + raise ValueError("publish() expects either (name, data) or a message object or array of messages") + + # RTL6g: Validate clientId for identified clients + if self.ably.auth.client_id: + for m in messages: + # RTL6g3: Reject messages with different clientId + if m.client_id == '*': + raise IncompatibleClientIdException( + 'Wildcard client_id is reserved and cannot be used when publishing messages', + 400, 40012) + elif m.client_id is not None and not self.ably.auth.can_assume_client_id(m.client_id): + raise IncompatibleClientIdException( + f'Cannot publish with client_id \'{m.client_id}\' as it is incompatible with the ' + f'current configured client_id \'{self.ably.auth.client_id}\'', + 400, 40012) + + + # Encode messages (RTL6a: same encoding as RestChannel#publish) + encoded_messages = [] + for m in messages: + # Encode the message with encryption if needed + if self.cipher: + m.encrypt(self.cipher) + + # Convert to dict representation + msg_dict = m.as_dict(binary=self.ably.options.use_binary_protocol) + encoded_messages.append(msg_dict) + + # RSL1i: Check message size limit + max_message_size = getattr(self.ably.options, 'max_message_size', 65536) # 64KB default + validate_message_size(encoded_messages, self.ably.options.use_binary_protocol, max_message_size) + + # RTL6c: Check connection and channel state + self._throw_if_unpublishable_state() + + log.info( + f'RealtimeChannel.publish(): sending message; ' + f'channel = {self.name}, state = {self.state}, message count = {len(encoded_messages)}' + ) + + # Send protocol message + protocol_message = { + "action": ProtocolMessageAction.MESSAGE, + "channel": self.name, + "messages": encoded_messages, + } + + # RTL6b: Await acknowledgment from server + return await self.__realtime.connection.connection_manager.send_protocol_message(protocol_message) + + def _throw_if_unpublishable_state(self) -> None: + """Check if the channel and connection are in a state that allows publishing + + Raises + ------ + AblyException + If the channel or connection state prevents publishing + """ + # RTL6c4: Check connection state + connection_state = self.__realtime.connection.state + if connection_state not in [ + ConnectionState.INITIALIZED, + ConnectionState.CONNECTED, + ConnectionState.CONNECTING, + ConnectionState.DISCONNECTED, + ]: + raise AblyException( + f"Cannot publish message; connection state is {connection_state}", + 400, + 40001, + ) + + # RTL6c4: Check channel state + if self.state in [ChannelState.SUSPENDED, ChannelState.FAILED]: + raise AblyException( + f"Cannot publish message; channel state is {self.state}", + 400, + 90001, + ) + + async def _send_update( + self, + message: Message, + action: MessageAction, + operation: MessageOperation | None = None, + params: dict | None = None, + ) -> UpdateDeleteResult: + """Internal method to send update/delete/append operations via websocket. + + Parameters + ---------- + message : Message + Message object with serial field required + action : MessageAction + The action type (MESSAGE_UPDATE, MESSAGE_DELETE, MESSAGE_APPEND) + operation : MessageOperation, optional + Operation metadata (description, metadata) + + Returns + ------- + UpdateDeleteResult + Result containing version serial of the operation + + Raises + ------ + AblyException + If message serial is missing or connection/channel state prevents operation + """ + # Check message has serial + if not message.serial: + raise AblyException( + "Message serial is required for update/delete/append operations", + status_code=400, + code=40003, + ) + + # Check connection and channel state + self._throw_if_unpublishable_state() + + # Create version from operation if provided + if not operation: + version = None + else: + version = MessageVersion( + client_id=operation.client_id, + description=operation.description, + metadata=operation.metadata + ) + + # Create a new message with the operation fields + update_message = Message( + name=message.name, + data=message.data, + client_id=message.client_id, + serial=message.serial, + action=action, + version=version, + extras=message.extras, + annotations=message.annotations, + ) + + # Encrypt if needed + if self.cipher: + update_message.encrypt(self.cipher) + + # Convert to dict representation + msg_dict = update_message.as_dict(binary=self.ably.options.use_binary_protocol) + + log.info( + f'RealtimeChannel._send_update(): sending {action.name} message; ' + f'channel = {self.name}, state = {self.state}, serial = {message.serial}' + ) + + stringified_params = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} \ + if params else None + + # Send protocol message + protocol_message = { + "action": ProtocolMessageAction.MESSAGE, + "channel": self.name, + "messages": [msg_dict], + "params": stringified_params, + } + + # Send and await acknowledgment + result = await self.__realtime.connection.connection_manager.send_protocol_message(protocol_message) + + # Return UpdateDeleteResult - we don't have version_serial from the result yet + # The server will send ACK with the result + if result and hasattr(result, 'serials') and result.serials: + return UpdateDeleteResult(version_serial=result.serials[0]) + return UpdateDeleteResult() + + async def update_message( + self, + message: Message, + operation: MessageOperation | None = None, + params: dict | None = None, + ) -> UpdateDeleteResult: + """Updates an existing message on this channel. + + Parameters + ---------- + message : Message + Message object to update. Must have a serial field. + operation : MessageOperation, optional + Optional MessageOperation containing description and metadata for the update. + + Returns + ------- + UpdateDeleteResult + Result containing the version serial of the updated message. + + Raises + ------ + AblyException + If message serial is missing or connection/channel state prevents the update + """ + return await self._send_update(message, MessageAction.MESSAGE_UPDATE, operation, params) + + async def delete_message( + self, + message: Message, + operation: MessageOperation | None = None, + params: dict | None = None + ) -> UpdateDeleteResult: + """Deletes a message on this channel. + + Parameters + ---------- + message : Message + Message object to delete. Must have a serial field. + operation : MessageOperation, optional + Optional MessageOperation containing description and metadata for the delete. + + Returns + ------- + UpdateDeleteResult + Result containing the version serial of the deleted message. + + Raises + ------ + AblyException + If message serial is missing or connection/channel state prevents the delete + """ + return await self._send_update(message, MessageAction.MESSAGE_DELETE, operation, params) + + async def append_message( + self, + message: Message, + operation: MessageOperation | None = None, + params: dict | None = None, + ) -> UpdateDeleteResult: + """Appends data to an existing message on this channel. + + Parameters + ---------- + message : Message + Message object with data to append. Must have a serial field. + operation : MessageOperation, optional + Optional MessageOperation containing description and metadata for the append. + + Returns + ------- + UpdateDeleteResult + Result containing the version serial of the appended message. + + Raises + ------ + AblyException + If message serial is missing or connection/channel state prevents the append + """ + return await self._send_update(message, MessageAction.MESSAGE_APPEND, operation, params) + + async def get_message(self, serial_or_message, timeout=None): + """Retrieves a single message by its serial using the REST API. + + Parameters + ---------- + serial_or_message : str or Message + Either a string serial or a Message object with a serial field. + timeout : float, optional + Timeout for the request. + + Returns + ------- + Message + Message object for the requested serial. + + Raises + ------ + AblyException + If the serial is missing or the message cannot be retrieved. + """ + # Delegate to parent Channel (REST) implementation + return await Channel.get_message(self, serial_or_message, timeout=timeout) + + async def get_message_versions(self, serial_or_message, params=None): + """Retrieves version history for a message using the REST API. + + Parameters + ---------- + serial_or_message : str or Message + Either a string serial or a Message object with a serial field. + params : dict, optional + Optional dict of query parameters for pagination. + + Returns + ------- + PaginatedResult + PaginatedResult containing Message objects representing each version. + + Raises + ------ + AblyException + If the serial is missing or versions cannot be retrieved. + """ + # Delegate to parent Channel (REST) implementation + return await Channel.get_message_versions(self, serial_or_message, params=params) + + def _on_message(self, proto_msg: dict) -> None: + action = proto_msg.get('action') + # RTL4c1 + channel_serial = proto_msg.get('channelSerial') + # TM2a, TM2c, TM2f + Message.update_inner_message_fields(proto_msg) + + if action == ProtocolMessageAction.ATTACHED: + flags = proto_msg.get('flags') + error = proto_msg.get("error") + exception = None + resumed = False + has_presence = False + + self.__attach_serial = channel_serial + self.__channel_serial = channel_serial + self.__params = proto_msg.get('params') + + if error: + exception = AblyException.from_dict(error) + + if flags: + resumed = has_flag(flags, Flag.RESUMED) + # RTP1: Check for HAS_PRESENCE flag + has_presence = has_flag(flags, Flag.HAS_PRESENCE) + # Store channel attach flags + self.__modes = decode_channel_mode(flags) + + # RTL12 + if self.state == ChannelState.ATTACHED: + if not resumed: + state_change = ChannelStateChange(self.state, ChannelState.ATTACHED, resumed, exception) + self._emit("update", state_change) + elif self.state == ChannelState.ATTACHING: + self._notify_state(ChannelState.ATTACHED, resumed=resumed, has_presence=has_presence) + else: + log.warn("RealtimeChannel._on_message(): ATTACHED received while not attaching") + elif action == ProtocolMessageAction.DETACHED: + if self.state == ChannelState.DETACHING: + self._notify_state(ChannelState.DETACHED) + elif self.state == ChannelState.ATTACHING: + self._notify_state(ChannelState.SUSPENDED) + else: + self._request_state(ChannelState.ATTACHING) + elif action == ProtocolMessageAction.MESSAGE: + messages = [] + try: + messages = Message.from_encoded_array(proto_msg.get('messages'), + cipher=self.cipher, context=self.__decoding_context) + self.__decoding_context.last_message_id = messages[-1].id + self.__channel_serial = channel_serial + except AblyException as e: + if e.code == 40018: # Delta decode failure - start recovery + self._start_decode_failure_recovery(e) + else: + log.error(f"Message processing error {e}. Skip messages {proto_msg.get('messages')}") + for message in messages: + self.__message_emitter._emit(message.name, message) + elif action == ProtocolMessageAction.PRESENCE: + # Handle PRESENCE messages + presence_messages = proto_msg.get('presence', []) + decoded_presence = PresenceMessage.from_encoded_array(presence_messages, cipher=self.cipher) + self.__presence.set_presence(decoded_presence, is_sync=False) + elif action == ProtocolMessageAction.SYNC: + # Handle SYNC messages (RTP18) + presence_messages = proto_msg.get('presence', []) + decoded_presence = PresenceMessage.from_encoded_array(presence_messages, cipher=self.cipher) + sync_channel_serial = proto_msg.get('channelSerial') + self.__presence.set_presence(decoded_presence, is_sync=True, sync_channel_serial=sync_channel_serial) + elif action == ProtocolMessageAction.ANNOTATION: + # Handle ANNOTATION messages + # RTAN4b: Populate annotation fields from protocol message + Annotation.update_inner_annotation_fields(proto_msg) + annotation_data = proto_msg.get('annotations', []) + try: + annotations = Annotation.from_encoded_array(annotation_data, cipher=self.cipher) + # Process annotations through the annotations handler + self.annotations._process_incoming(annotations) + # RTL15b: Update channel serial for ANNOTATION messages + self.__channel_serial = channel_serial + except Exception as e: + log.error(f"Annotation processing error {e}. Skip annotations {annotation_data}") + elif action == ProtocolMessageAction.ERROR: + error = AblyException.from_dict(proto_msg.get('error')) + self._notify_state(ChannelState.FAILED, reason=error) + + def _request_state(self, state: ChannelState) -> None: + log.debug(f'RealtimeChannel._request_state(): state = {state}') + self._notify_state(state) + self._check_pending_state() + + def _notify_state(self, state: ChannelState, reason: AblyException | None = None, + resumed: bool = False, has_presence: bool = False) -> None: + log.debug(f'RealtimeChannel._notify_state(): state = {state}') + + self.__clear_state_timer() + + if state == self.state: + return + + if reason is not None: + self.__error_reason = reason + + if state == ChannelState.INITIALIZED: + self.__error_reason = None + + if state == ChannelState.SUSPENDED and self.ably.connection.state == ConnectionState.CONNECTED: + self.__start_retry_timer() + else: + self.__cancel_retry_timer() + + # RTL4j1 + if state == ChannelState.ATTACHED: + self.__attach_resume = True + if state in (ChannelState.DETACHING, ChannelState.FAILED): + self.__attach_resume = False + + # RTP5a1 + if state in (ChannelState.DETACHED, ChannelState.SUSPENDED, ChannelState.FAILED): + self.__channel_serial = None + + if state != ChannelState.ATTACHING: + self.__decode_failure_recovery_in_progress = False + + state_change = ChannelStateChange(self.__state, state, resumed, reason=reason) + + self.__state = state + self._emit(state, state_change) + self.__internal_state_emitter._emit(state, state_change) + + # RTP5: Notify presence of channel state change + self.__presence.act_on_channel_state(state, has_presence=has_presence, error=reason) + + def _send_message(self, msg: dict) -> None: + asyncio.create_task(self.__realtime.connection.connection_manager.send_protocol_message(msg)) + + def _check_pending_state(self): + connection_state = self.__realtime.connection.connection_manager.state + + if connection_state is not ConnectionState.CONNECTED: + log.debug(f"RealtimeChannel._check_pending_state(): connection state = {connection_state}") + return + + if self.state == ChannelState.ATTACHING: + self.__start_state_timer() + self._attach_impl() + elif self.state == ChannelState.DETACHING: + self.__start_state_timer() + self._detach_impl() + + def __start_state_timer(self) -> None: + if not self.__state_timer: + def on_timeout() -> None: + log.debug('RealtimeChannel.start_state_timer(): timer expired') + self.__state_timer = None + self.__timeout_pending_state() + + self.__state_timer = Timer(self.__realtime.options.realtime_request_timeout, on_timeout) + + def __clear_state_timer(self) -> None: + if self.__state_timer: + self.__state_timer.cancel() + self.__state_timer = None + + def __timeout_pending_state(self) -> None: + if self.state == ChannelState.ATTACHING: + self._notify_state( + ChannelState.SUSPENDED, reason=AblyException("Channel attach timed out", 408, 90007)) + elif self.state == ChannelState.DETACHING: + self._notify_state(ChannelState.ATTACHED, reason=AblyException("Channel detach timed out", 408, 90007)) + else: + self._check_pending_state() + + def __start_retry_timer(self) -> None: + if self.__retry_timer: + return + + self.__retry_timer = Timer(self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire) + + def __cancel_retry_timer(self) -> None: + if self.__retry_timer: + self.__retry_timer.cancel() + self.__retry_timer = None + + def __on_retry_timer_expire(self) -> None: + if self.state == ChannelState.SUSPENDED and self.ably.connection.state == ConnectionState.CONNECTED: + self.__retry_timer = None + log.info("RealtimeChannel retry timer expired, attempting a new attach") + self._request_state(ChannelState.ATTACHING) + + def should_reattach_to_set_options(self, new_options: ChannelOptions) -> bool: + """Internal method""" + if self.state != ChannelState.ATTACHING and self.state != ChannelState.ATTACHED: + return False + return self.__channel_options != new_options + + # RTL23 + @property + def name(self) -> str: + """Returns channel name""" + return self.__name + + # RTL2b + @property + def state(self) -> ChannelState: + """Returns channel state""" + return self.__state + + @state.setter + def state(self, state: ChannelState) -> None: + self.__state = state + + # RTL24 + @property + def error_reason(self) -> AblyException | None: + """An AblyException instance describing the last error which occurred on the channel, if any.""" + return self.__error_reason + + @property + def params(self) -> dict[str, str]: + """Get channel parameters""" + return self.__params + + @property + def presence(self): + """Get the RealtimePresence object for this channel""" + return self.__presence + + @property + def annotations(self) -> RealtimeAnnotations: + return self._Channel__annotations + + @property + def modes(self): + """Get the list of channel modes""" + return self.__modes + + def _start_decode_failure_recovery(self, error: AblyException) -> None: + """Start RTL18 decode failure recovery procedure""" + + if self.__decode_failure_recovery_in_progress: + log.info('VCDiff recovery process already started, skipping') + return + + self.__decode_failure_recovery_in_progress = True + + # RTL18a: Log error with code 40018 + log.error(f'VCDiff decode failure: {error}') + + # RTL18b: Message is already discarded by not processing it + + # RTL18c: Send ATTACH with previous channel serial and transition to ATTACHING + self._notify_state(ChannelState.ATTACHING, reason=error) + self._check_pending_state() + + def _encode_flags(self) -> int | None: + if not self.__channel_options.modes and not self.__attach_resume: + return None + + flags = 0 + + if self.__attach_resume: + flags |= Flag.ATTACH_RESUME + + if self.__channel_options.modes: + flags |= encode_channel_mode(self.__channel_options.modes) + + return flags + + +class Channels(RestChannels): + """Creates and destroys RealtimeChannel objects. + + Methods + ------- + get(name) + Gets a channel + release(name) + Releases a channel + """ + + # RTS3 + def get(self, name: str, options: ChannelOptions | None = None, **kwargs) -> RealtimeChannel: + """Creates a new RealtimeChannel object, or returns the existing channel object. + + Parameters + ---------- + + name: str + Channel name + options: ChannelOptions or dict, optional + Channel options for the channel + **kwargs: + Additional keyword arguments to create ChannelOptions (e.g., cipher, params) + """ + # Convert kwargs to ChannelOptions if provided + if kwargs and not options: + options = ChannelOptions(**kwargs) + elif options and isinstance(options, dict): + options = ChannelOptions.from_dict(options) + + if name not in self.__all: + channel = self.__all[name] = RealtimeChannel(self.__ably, name, options) + else: + channel = self.__all[name] + # Update options if channel is not attached or currently attaching + if options and channel.should_reattach_to_set_options(options): + raise AblyException( + 'Channels.get() cannot be used to set channel options that would cause the channel to ' + 'reattach. Please, use RealtimeChannel.setOptions() instead.', + 400, + 40000 + ) + elif options: + channel.set_options_without_reattach(options) + return channel + + # RTS4 + def release(self, name: str) -> None: + """Releases a RealtimeChannel object, deleting it, and enabling it to be garbage collected + + It also removes any listeners associated with the channel. + To release a channel, the channel state must be INITIALIZED, DETACHED, or FAILED. + + + Parameters + ---------- + name: str + Channel name + """ + if name not in self.__all: + return + del self.__all[name] + + def _on_channel_message(self, msg: dict) -> None: + channel_name = msg.get('channel') + if not channel_name: + log.error( + 'Channels.on_channel_message()', + f'received event without channel, action = {msg.get("action")}' + ) + return + + channel = self.__all[channel_name] + if not channel: + log.warning( + 'Channels.on_channel_message()', + f'receieved event for non-existent channel: {channel_name}' + ) + return + + channel._on_message(msg) + + def _propagate_connection_interruption(self, state: ConnectionState, reason: AblyException | None) -> None: + from_channel_states = ( + ChannelState.ATTACHING, + ChannelState.ATTACHED, + ChannelState.DETACHING, + ChannelState.SUSPENDED, + ) + + connection_to_channel_state = { + ConnectionState.CLOSING: ChannelState.DETACHED, + ConnectionState.CLOSED: ChannelState.DETACHED, + ConnectionState.FAILED: ChannelState.FAILED, + ConnectionState.SUSPENDED: ChannelState.SUSPENDED, + } + + for channel_name in self.__all: + channel = self.__all[channel_name] + if channel.state in from_channel_states: + channel._notify_state(connection_to_channel_state[state], reason) + + def _on_connected(self) -> None: + for channel_name in self.__all: + channel = self.__all[channel_name] + if channel.state == ChannelState.ATTACHING or channel.state == ChannelState.DETACHING: + channel._check_pending_state() + elif channel.state == ChannelState.SUSPENDED: + asyncio.create_task(channel.attach()) + elif channel.state == ChannelState.ATTACHED: + channel._request_state(ChannelState.ATTACHING) + + def _initialize_channels(self) -> None: + for channel_name in self.__all: + channel = self.__all[channel_name] + channel._request_state(ChannelState.INITIALIZED) diff --git a/ably/realtime/connection.py b/ably/realtime/connection.py new file mode 100644 index 00000000..907f56a5 --- /dev/null +++ b/ably/realtime/connection.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import asyncio +import functools +import logging +from typing import TYPE_CHECKING + +from ably.realtime.connectionmanager import ConnectionManager +from ably.types.connectiondetails import ConnectionDetails +from ably.types.connectionstate import ConnectionEvent, ConnectionState, ConnectionStateChange +from ably.util.eventemitter import EventEmitter +from ably.util.exceptions import AblyException + +if TYPE_CHECKING: + from ably.realtime.realtime import AblyRealtime + +log = logging.getLogger(__name__) + + +class Connection(EventEmitter): # RTN4 + """Ably Realtime Connection + + Enables the management of a connection to Ably + + Attributes + ---------- + state: str + Connection state + error_reason: ErrorInfo + An ErrorInfo object describing the last error which occurred on the channel, if any. + + + Methods + ------- + connect() + Establishes a realtime connection + close() + Closes a realtime connection + ping() + Pings a realtime connection + """ + + def __init__(self, realtime: AblyRealtime): + self.__realtime = realtime + self.__error_reason: AblyException | None = None + self.__state = ConnectionState.CONNECTING if realtime.options.auto_connect else ConnectionState.INITIALIZED + self.__connection_manager = ConnectionManager(self.__realtime, self.state) + self.__connection_manager.on('connectionstate', self._on_state_update) # RTN4a + self.__connection_manager.on('update', self._on_connection_update) # RTN4h + super().__init__() + + # RTN11 + def connect(self) -> None: + """Establishes a realtime connection. + + Causes the connection to open, entering the connecting state + """ + self.__error_reason = None + self.connection_manager.request_state(ConnectionState.CONNECTING) + + async def close(self) -> None: + """Causes the connection to close, entering the closing state. + + Once closed, the library will not attempt to re-establish the + connection without an explicit call to connect() + """ + self.connection_manager.request_state(ConnectionState.CLOSING) + await self._when_state(ConnectionState.CLOSED) + + # RTN13 + async def ping(self) -> float: + """Send a ping to the realtime connection + + When connected, sends a heartbeat ping to the Ably server and executes + the callback with any error and the response time in milliseconds when + a heartbeat ping request is echoed from the server. + + Raises + ------ + AblyException + If ping request cannot be sent due to invalid state + + Returns + ------- + float + The response time in milliseconds + """ + return await self.__connection_manager.ping() + + def _when_state(self, state: ConnectionState): + if self.state == state: + fut = asyncio.get_event_loop().create_future() + fut.set_result(None) + return fut + return self.once_async(state) + + def _on_state_update(self, state_change: ConnectionStateChange) -> None: + log.info(f'Connection state changing from {self.state} to {state_change.current}') + self.__state = state_change.current + if state_change.reason is not None: + self.__error_reason = state_change.reason + self.__realtime.options.loop.call_soon(functools.partial(self._emit, state_change.current, state_change)) + + def _on_connection_update(self, state_change: ConnectionStateChange) -> None: + self.__realtime.options.loop.call_soon(functools.partial(self._emit, ConnectionEvent.UPDATE, state_change)) + + # RTN4d + @property + def state(self) -> ConnectionState: + """The current connection state of the connection""" + return self.__state + + # RTN25 + @property + def error_reason(self) -> AblyException | None: + """An object describing the last error which occurred on the channel, if any.""" + return self.__error_reason + + @state.setter + def state(self, value: ConnectionState) -> None: + self.__state = value + + @property + def connection_manager(self) -> ConnectionManager: + return self.__connection_manager + + @property + def connection_details(self) -> ConnectionDetails | None: + return self.__connection_manager.connection_details diff --git a/ably/realtime/connectionmanager.py b/ably/realtime/connectionmanager.py new file mode 100644 index 00000000..8b51fb0f --- /dev/null +++ b/ably/realtime/connectionmanager.py @@ -0,0 +1,789 @@ +from __future__ import annotations + +import asyncio +import logging +from collections import deque +from datetime import datetime +from itertools import zip_longest +from typing import TYPE_CHECKING + +import httpx + +from ably.transport.defaults import Defaults +from ably.transport.websockettransport import ProtocolMessageAction, WebSocketTransport +from ably.types.connectiondetails import ConnectionDetails +from ably.types.connectionerrors import ConnectionErrors +from ably.types.connectionstate import ConnectionEvent, ConnectionState, ConnectionStateChange +from ably.types.operations import PublishResult +from ably.types.tokendetails import TokenDetails +from ably.util.eventemitter import EventEmitter +from ably.util.exceptions import AblyException, IncompatibleClientIdException +from ably.util.helper import Timer, get_random_id, is_token_error + +if TYPE_CHECKING: + from ably.realtime.realtime import AblyRealtime + +log = logging.getLogger(__name__) + + +class PendingMessage: + """Represents a message awaiting acknowledgment from the server""" + + def __init__(self, message: dict): + self.message = message + self.future: asyncio.Future[PublishResult] | None = None + action = message.get('action') + + # Messages that require acknowledgment: MESSAGE, PRESENCE, ANNOTATION, OBJECT + self.ack_required = action in ( + ProtocolMessageAction.MESSAGE, + ProtocolMessageAction.PRESENCE, + ProtocolMessageAction.ANNOTATION, + ProtocolMessageAction.OBJECT, + ) + + if self.ack_required: + self.future = asyncio.Future() + + +class PendingMessageQueue: + """Queue for tracking messages awaiting acknowledgment""" + + def __init__(self): + self.messages: list[PendingMessage] = [] + + def push(self, pending_message: PendingMessage) -> None: + """Add a message to the queue""" + self.messages.append(pending_message) + + def count(self) -> int: + """Return the number of pending messages""" + return len(self.messages) + + def complete_messages( + self, + serial: int, + count: int, + res: list[PublishResult] | None, + err: AblyException | None = None + ) -> None: + """Complete messages based on serial and count from ACK/NACK + + Args: + serial: The msgSerial of the first message being acknowledged + count: The number of messages being acknowledged + res: List of PublishResult objects for each message acknowledged, or None if not available + err: Error from NACK, or None for successful ACK + """ + log.debug(f'MessageQueue.complete_messages(): serial={serial}, count={count}, res={res}, err={err}') + + if not self.messages: + log.warning('MessageQueue.complete_messages(): called on empty queue') + return + + first = self.messages[0] + if first: + start_serial = first.message.get('msgSerial') + if start_serial is None: + log.warning('MessageQueue.complete_messages(): first message has no msgSerial') + return + + end_serial = serial + count + + if end_serial > start_serial: + # Remove and complete the acknowledged messages + num_to_complete = min(end_serial - start_serial, len(self.messages)) + completed_messages = self.messages[:num_to_complete] + self.messages = self.messages[num_to_complete:] + + # Default res to empty list if None + res_list = res if res is not None else [] + for (msg, publish_result) in zip_longest(completed_messages, res_list): + if msg.future and not msg.future.done(): + if err: + msg.future.set_exception(err) + else: + # If publish_result is None, return empty PublishResult + if publish_result is None: + publish_result = PublishResult() + msg.future.set_result(publish_result) + + def complete_all_messages(self, err: AblyException) -> None: + """Complete all pending messages with an error""" + while self.messages: + msg = self.messages.pop(0) + if msg.future and not msg.future.done(): + msg.future.set_exception(err) + + def clear(self) -> None: + """Clear all messages from the queue""" + self.messages.clear() + + +class ConnectionManager(EventEmitter): + def __init__(self, realtime: AblyRealtime, initial_state): + self.options = realtime.options + self.__ably = realtime + self.__state: ConnectionState = initial_state + self.__ping_future: asyncio.Future | None = None + self.__timeout_in_secs: float = self.options.realtime_request_timeout / 1000 + self.transport: WebSocketTransport | None = None + self.__connection_details: ConnectionDetails | None = None + self.connection_id: str | None = None + self.__fail_state = ConnectionState.DISCONNECTED + self.transition_timer: Timer | None = None + self.suspend_timer: Timer | None = None + self.retry_timer: Timer | None = None + self.connect_base_task: asyncio.Task | None = None + self.disconnect_transport_task: asyncio.Task | None = None + self.__fallback_hosts: list[str] = self.options.get_fallback_hosts() + self.queued_messages: deque[PendingMessage] = deque() + self.__error_reason: AblyException | None = None + self.msg_serial: int = 0 + self.pending_message_queue: PendingMessageQueue = PendingMessageQueue() + super().__init__() + + def enact_state_change(self, state: ConnectionState, reason: AblyException | None = None) -> None: + current_state = self.__state + log.debug(f'ConnectionManager.enact_state_change(): {current_state} -> {state}; reason = {reason}') + self.__state = state + if reason: + self.__error_reason = reason + + # RTN16d: Clear connection state when entering SUSPENDED or terminal states + if state == ConnectionState.SUSPENDED or state in ( + ConnectionState.CLOSED, + ConnectionState.FAILED + ): + self.__connection_details = None + self.connection_id = None + self.__connection_key = None + self.msg_serial = 0 + + self._emit('connectionstate', ConnectionStateChange(current_state, state, state, reason)) + + def check_connection(self) -> bool: + try: + response = httpx.get(self.options.connectivity_check_url) + return 200 <= response.status_code < 300 and \ + (self.options.connectivity_check_url != Defaults.connectivity_check_url or "yes" in response.text) + except httpx.HTTPError: + return False + + def get_state_error(self) -> AblyException: + return ConnectionErrors[self.state] + + async def __get_transport_params(self) -> dict: + protocol_version = Defaults.protocol_version + params = await self.ably.auth.get_auth_transport_param() + params["v"] = protocol_version + if self.connection_details: + params["resume"] = self.connection_details.connection_key + # RTN2a: Set format to msgpack if use_binary_protocol is enabled + if self.options.use_binary_protocol: + params["format"] = "msgpack" + + # Add any custom transport params from options + params.update(self.options.transport_params) + + return params + + async def close_impl(self) -> None: + log.debug('ConnectionManager.close_impl()') + + self.cancel_suspend_timer() + self.start_transition_timer(ConnectionState.CLOSING, fail_state=ConnectionState.CLOSED) + if self.transport: + # Try to send protocol CLOSE message in the background + asyncio.create_task(self.transport.close()) + # Yield to event loop to give the close message a chance to send + await asyncio.sleep(0) + await self.transport.dispose() # Dispose transport resources + if self.connect_base_task: + self.connect_base_task.cancel() + if self.disconnect_transport_task: + await self.disconnect_transport_task + self.cancel_retry_timer() + + # Clear connection details to prevent resume on next connect + # When explicitly closed, we want a fresh connection, not a resume + self.__connection_details = None + self.connection_id = None + self.msg_serial = 0 + + self.notify_state(ConnectionState.CLOSED) + + async def send_protocol_message(self, protocol_message: dict) -> PublishResult | None: + """Send a protocol message and optionally track it for acknowledgment + + Args: + protocol_message: protocol message dict (new message) + Returns: + None + """ + state_should_queue = (self.state in + (ConnectionState.INITIALIZED, ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) + + if self.state != ConnectionState.CONNECTED and not state_should_queue: + raise AblyException(f"Cannot send message while connection is {self.state}", 400, 90000) + + # RTL6c2: If queueMessages is false, fail immediately when not CONNECTED + if state_should_queue and not self.options.queue_messages: + raise AblyException( + f"Cannot send message while connection is {self.state}, and queue_messages is false", + 400, + 90000, + ) + + pending_message = PendingMessage(protocol_message) + + # Assign msgSerial to messages that need acknowledgment + if pending_message.ack_required: + # New message - assign fresh serial + protocol_message['msgSerial'] = self.msg_serial + self.pending_message_queue.push(pending_message) + self.msg_serial += 1 + + if state_should_queue: + self.queued_messages.appendleft(pending_message) + if pending_message.ack_required: + return await pending_message.future + return None + + return await self._send_protocol_message_on_connected_state(pending_message) + + async def _send_protocol_message_on_connected_state( + self, pending_message: PendingMessage + ) -> PublishResult | None: + if self.state == ConnectionState.CONNECTED and self.transport: + # Add to pending queue before sending (for messages being resent from queue) + if pending_message.ack_required and pending_message not in self.pending_message_queue.messages: + self.pending_message_queue.push(pending_message) + await self.transport.send(pending_message.message) + else: + log.exception( + "ConnectionManager.send_protocol_message(): can not send message with no active transport" + ) + if pending_message.future: + pending_message.future.set_exception( + AblyException("No active transport", 500, 50000) + ) + if pending_message.ack_required: + return await pending_message.future + return None + + def send_queued_messages(self) -> None: + log.info(f'ConnectionManager.send_queued_messages(): sending {len(self.queued_messages)} message(s)') + while len(self.queued_messages) > 0: + pending_message = self.queued_messages.pop() + asyncio.create_task(self._send_protocol_message_on_connected_state(pending_message)) + + def requeue_pending_messages(self) -> None: + """RTN19a: Requeue messages awaiting ACK/NACK when transport disconnects + + These messages will be resent when connection becomes CONNECTED again. + RTN19a2: msgSerial is preserved for resume, reset for new connection. + """ + pending_count = self.pending_message_queue.count() + if pending_count == 0: + return + + log.info( + f'ConnectionManager.requeue_pending_messages(): ' + f'requeuing {pending_count} pending message(s) for resend' + ) + + # Get all pending messages and add them back to the queue + # They'll be sent again when we reconnect + pending_messages = list(self.pending_message_queue.messages) + + # Add back to front of queue (FIFO but priority over new messages) + # Store the entire PendingMessage object to preserve Future + for pending_msg in reversed(pending_messages): + # PendingMessage object retains its Future, msgSerial + self.queued_messages.append(pending_msg) + + # Clear the message queue since we're requeueing them all + # When they're resent, the existing Future will be resolved + self.pending_message_queue.clear() + + def fail_queued_messages(self, err) -> None: + log.info( + f"ConnectionManager.fail_queued_messages(): discarding {len(self.queued_messages)} messages;" + + f" reason = {err}" + ) + error = err or AblyException("Connection failed", 80000, 500) + while len(self.queued_messages) > 0: + pending_msg = self.queued_messages.pop() + log.exception( + f"ConnectionManager.fail_queued_messages(): Failed to send protocol message: " + f"{pending_msg.message}" + ) + # Fail the Future if it exists + if pending_msg.future and not pending_msg.future.done(): + pending_msg.future.set_exception(error) + + # Also fail all pending messages awaiting acknowledgment + if self.pending_message_queue.count() > 0: + count = self.pending_message_queue.count() + log.info( + f"ConnectionManager.fail_queued_messages(): failing {count} pending messages" + ) + self.pending_message_queue.complete_all_messages(error) + + async def ping(self) -> float: + if self.__ping_future: + try: + response = await self.__ping_future + except asyncio.CancelledError: + raise AblyException("Ping request cancelled due to request timeout", 504, 50003) from None + return response + + self.__ping_future = asyncio.Future() + if self.__state in [ConnectionState.CONNECTED, ConnectionState.CONNECTING]: + self.__ping_id = get_random_id() + ping_start_time = datetime.now().timestamp() + await self.send_protocol_message({"action": ProtocolMessageAction.HEARTBEAT, + "id": self.__ping_id}) + else: + raise AblyException("Cannot send ping request. Calling ping in invalid state", 40000, 400) + try: + await asyncio.wait_for(self.__ping_future, self.__timeout_in_secs) + except asyncio.TimeoutError: + raise AblyException("Timeout waiting for ping response", 504, 50003) from None + + ping_end_time = datetime.now().timestamp() + response_time_ms = (ping_end_time - ping_start_time) * 1000 + return round(response_time_ms, 2) + + def on_connected(self, connection_details: ConnectionDetails, connection_id: str, + reason: AblyException | None = None) -> None: + self.__fail_state = ConnectionState.DISCONNECTED + + # RTN19a2: Reset msgSerial if connectionId changed (new connection) + prev_connection_id = self.connection_id + connection_id_changed = prev_connection_id is not None and prev_connection_id != connection_id + + if connection_id_changed: + log.info('ConnectionManager.on_connected(): New connectionId; resetting msgSerial') + self.msg_serial = 0 + # Note: In JS they call resetSendAttempted() here, but we don't need it + # because we fail all pending messages on disconnect per RTN7e + + self.__connection_details = connection_details + self.connection_id = connection_id + + if connection_details.client_id: + try: + self.ably.auth._configure_client_id(connection_details.client_id) + except IncompatibleClientIdException as e: + self.notify_state(ConnectionState.FAILED, reason=e) + return + + if self.__state == ConnectionState.CONNECTED: + state_change = ConnectionStateChange(ConnectionState.CONNECTED, ConnectionState.CONNECTED, + ConnectionEvent.UPDATE) + self._emit(ConnectionEvent.UPDATE, state_change) + else: + self.notify_state(ConnectionState.CONNECTED, reason=reason) + + self.ably.channels._on_connected() + + async def on_disconnected(self, exception: AblyException) -> None: + # RTN15h + if self.transport: + await self.transport.dispose() + if exception: + status_code = exception.status_code + if status_code >= 500 and status_code <= 504: # RTN17f1 + if len(self.__fallback_hosts) > 0: + try: + await self.connect_with_fallback_hosts(self.__fallback_hosts) + except Exception as e: + self.notify_state(self.__fail_state, reason=e) + return + else: + log.info("No fallback host to try for disconnected protocol message") + elif is_token_error(exception): + await self.on_token_error(exception) + else: + self.notify_state(ConnectionState.DISCONNECTED, exception) + else: + log.warn("DISCONNECTED message received without error") + + async def on_token_error(self, exception: AblyException) -> None: + if self.__error_reason is None or not is_token_error(self.__error_reason): + self.__error_reason = exception + try: + await self.ably.auth._ensure_valid_auth_credentials(force=True) + except Exception as e: + self.on_error_from_authorize(e) + return + self.notify_state(self.__fail_state, exception, retry_immediately=True) + return + self.notify_state(self.__fail_state, exception) + + async def on_error(self, msg: dict, exception: AblyException) -> None: + if msg.get("channel") is not None: # RTN15i + self.on_channel_message(msg) + return + if self.transport: + await self.transport.dispose() + if is_token_error(exception): # RTN14b + await self.on_token_error(exception) + else: + self.enact_state_change(ConnectionState.FAILED, exception) + + def on_error_from_authorize(self, exception: AblyException) -> None: + log.info("ConnectionManager.on_error_from_authorize(): err = %s", exception) + # RSA4a + if exception.code == 40171: + self.notify_state(ConnectionState.FAILED, exception) + elif exception.status_code == 403: + msg = 'Client configured authentication provider returned 403; failing the connection' + log.error(f'ConnectionManager.on_error_from_authorize(): {msg}') + self.notify_state(ConnectionState.FAILED, AblyException(msg, 403, 80019)) + else: + msg = 'Client configured authentication provider request failed' + log.warning(f'ConnectionManager.on_error_from_authorize: {msg}') + self.notify_state(self.__fail_state, AblyException(msg, 401, 80019)) + + async def on_closed(self) -> None: + if self.transport: + await self.transport.dispose() + if self.connect_base_task: + self.connect_base_task.cancel() + + def on_channel_message(self, msg: dict) -> None: + self.__ably.channels._on_channel_message(msg) + + def on_heartbeat(self, id: str | None) -> None: + if self.__ping_future: + # Resolve on heartbeat from ping request. + if self.__ping_id == id: + if not self.__ping_future.cancelled(): + self.__ping_future.set_result(None) + self.__ping_future = None + + def on_ack( + self, serial: int, count: int, res: list[PublishResult] | None + ) -> None: + """Handle ACK protocol message from server + + Args: + serial: The msgSerial of the first message being acknowledged + count: The number of messages being acknowledged + res: List of PublishResult objects for each message acknowledged, or None if not available + """ + log.debug(f'ConnectionManager.on_ack(): serial={serial}, count={count}, res={res}') + self.pending_message_queue.complete_messages(serial, count, res) + + def on_nack(self, serial: int, count: int, err: AblyException | None) -> None: + """Handle NACK protocol message from server + + Args: + serial: The msgSerial of the first message being rejected + count: The number of messages being rejected + err: Error information from the server + """ + if not err: + err = AblyException('Unable to send message; channel not responding', 50001, 500) + + log.error(f'ConnectionManager.on_nack(): serial={serial}, count={count}, err={err}') + self.pending_message_queue.complete_messages(serial, count, None, err) + + def deactivate_transport(self, reason: AblyException | None = None): + # RTN19a: Before disconnecting, requeue any pending messages + # so they'll be resent on reconnection + if self.transport: + log.info('ConnectionManager.deactivate_transport(): requeuing pending messages') + self.requeue_pending_messages() + self.transport = None + self.notify_state(ConnectionState.DISCONNECTED, reason) + + def request_state(self, state: ConnectionState, force=False) -> None: + log.debug(f'ConnectionManager.request_state(): state = {state}') + + if not force and state == self.state: + return + + if state == ConnectionState.CONNECTING and self.__state == ConnectionState.CONNECTED: + return + + if state == ConnectionState.CLOSING and self.__state == ConnectionState.CLOSED: + return + + if state == ConnectionState.CONNECTING and self.__state in (ConnectionState.CLOSED, + ConnectionState.FAILED): + self.ably.channels._initialize_channels() + + if not force: + self.enact_state_change(state) + + if state == ConnectionState.CONNECTING: + self.start_connect() + + if state == ConnectionState.CLOSING: + asyncio.create_task(self.close_impl()) + + def start_connect(self) -> None: + self.start_suspend_timer() + self.start_transition_timer(ConnectionState.CONNECTING) + self.connect_base_task = asyncio.create_task(self.connect_base()) + + async def connect_with_fallback_hosts(self, fallback_hosts: list) -> Exception | None: + for host in fallback_hosts: + try: + if self.check_connection(): + await self.try_host(host) + return + else: + message = "Unable to connect, network unreachable" + log.exception(message) + exception = AblyException(message, status_code=404, code=80003) + self.notify_state(self.__fail_state, exception) + return + except Exception as exc: + exception = exc + log.exception(f'Connection to {host} failed, reason={exception}') + log.exception("No more fallback hosts to try") + return exception + + async def connect_base(self) -> None: + fallback_hosts = self.__fallback_hosts + primary_host = self.options.get_host() + try: + await self.try_host(primary_host) + return + except Exception as exception: + log.exception(f'Connection to {primary_host} failed, reason={exception}') + if len(fallback_hosts) > 0: + log.info("Attempting connection to fallback host(s)") + resp = await self.connect_with_fallback_hosts(fallback_hosts) + if not resp: + return + exception = resp + self.notify_state(self.__fail_state, reason=exception) + + async def try_host(self, host) -> None: + try: + params = await self.__get_transport_params() + except AblyException as e: + self.on_error_from_authorize(e) + return + self.transport = WebSocketTransport(self, host, params) + self._emit('transport.pending', self.transport) + self.transport.connect() + + future = asyncio.Future() + + def on_transport_connected(): + log.debug('ConnectionManager.try_a_host(): transport connected') + if self.transport: + self.transport.off('failed', on_transport_failed) + if not future.done(): + future.set_result(None) + + async def on_transport_failed(exception): + log.info('ConnectionManager.try_a_host(): transport failed') + if self.transport: + self.transport.off('connected', on_transport_connected) + await self.transport.dispose() + future.set_exception(exception) + + self.transport.once('connected', on_transport_connected) + self.transport.once('failed', on_transport_failed) + # Fix asyncio CancelledError in python 3.7 + try: + await future + except asyncio.CancelledError: + return + + def notify_state(self, state: ConnectionState, reason: AblyException | None = None, + retry_immediately: bool | None = None) -> None: + # RTN15a + retry_immediately = (retry_immediately is not False) and ( + state == ConnectionState.DISCONNECTED and self.__state == ConnectionState.CONNECTED) + + log.debug( + f'ConnectionManager.notify_state(): new state: {state}' + + ('; will retry immediately' if retry_immediately else '') + ) + + if state == self.__state: + return + + self.cancel_transition_timer() + self.check_suspend_timer(state) + + if retry_immediately: + self.options.loop.call_soon(self.request_state, ConnectionState.CONNECTING) + elif state == ConnectionState.DISCONNECTED: + self.start_retry_timer(self.options.disconnected_retry_timeout) + elif state == ConnectionState.SUSPENDED: + self.start_retry_timer(self.options.suspended_retry_timeout) + + if (state == ConnectionState.DISCONNECTED and not retry_immediately) or state == ConnectionState.SUSPENDED: + self.disconnect_transport() + + self.enact_state_change(state, reason) + + if state == ConnectionState.CONNECTED: + self.send_queued_messages() + elif state in ( + ConnectionState.CLOSING, + ConnectionState.CLOSED, + ConnectionState.SUSPENDED, + ConnectionState.FAILED, + ): + # RTN7e: Fail pending messages on SUSPENDED, CLOSED, FAILED + self.fail_queued_messages(reason) + self.ably.channels._propagate_connection_interruption(state, reason) + elif state == ConnectionState.DISCONNECTED and not self.options.queue_messages: + # RTN7d: If queueMessages is false, fail pending messages on DISCONNECTED + log.info( + 'ConnectionManager.notify_state(): queueMessages is false; ' + 'failing pending messages on DISCONNECTED' + ) + self.fail_queued_messages(reason) + + def start_transition_timer(self, state: ConnectionState, fail_state: ConnectionState | None = None) -> None: + log.debug(f'ConnectionManager.start_transition_timer(): transition state = {state}') + + if self.transition_timer: + log.debug('ConnectionManager.start_transition_timer(): clearing already-running timer') + self.transition_timer.cancel() + + if fail_state is None: + fail_state = self.__fail_state if state != ConnectionState.CLOSING else ConnectionState.CLOSED + + timeout = self.options.realtime_request_timeout + + def on_transition_timer_expire(): + if self.transition_timer: + self.transition_timer = None + log.info(f'ConnectionManager {state} timer expired, notifying new state: {fail_state}') + self.notify_state( + fail_state, + AblyException("Connection cancelled due to request timeout", 504, 50003) + ) + + log.debug(f'ConnectionManager.start_transition_timer(): setting timer for {timeout}ms') + + self.transition_timer = Timer(timeout, on_transition_timer_expire) + + def cancel_transition_timer(self): + log.debug('ConnectionManager.cancel_transition_timer()') + if self.transition_timer: + self.transition_timer.cancel() + self.transition_timer = None + + def start_suspend_timer(self) -> None: + log.debug('ConnectionManager.start_suspend_timer()') + if self.suspend_timer: + return + + def on_suspend_timer_expire() -> None: + if self.suspend_timer: + self.suspend_timer = None + log.info('ConnectionManager suspend timer expired, requesting new state: suspended') + self.notify_state( + ConnectionState.SUSPENDED, + AblyException("Connection to server unavailable", 400, 80002) + ) + self.__fail_state = ConnectionState.SUSPENDED + + self.suspend_timer = Timer(Defaults.connection_state_ttl, on_suspend_timer_expire) + + def check_suspend_timer(self, state: ConnectionState) -> None: + if state not in ( + ConnectionState.CONNECTING, + ConnectionState.DISCONNECTED, + ConnectionState.SUSPENDED, + ): + self.cancel_suspend_timer() + + def cancel_suspend_timer(self) -> None: + log.debug('ConnectionManager.cancel_suspend_timer()') + self.__fail_state = ConnectionState.DISCONNECTED + if self.suspend_timer: + self.suspend_timer.cancel() + self.suspend_timer = None + + def start_retry_timer(self, interval: int) -> None: + def on_retry_timeout(): + log.info('ConnectionManager retry timer expired, retrying') + self.retry_timer = None + self.request_state(ConnectionState.CONNECTING) + + self.retry_timer = Timer(interval, on_retry_timeout) + + def cancel_retry_timer(self) -> None: + if self.retry_timer: + self.retry_timer.cancel() + self.retry_timer = None + + def disconnect_transport(self) -> None: + log.info('ConnectionManager.disconnect_transport()') + if self.transport: + # RTN19a: Requeue pending messages before disposing transport + self.requeue_pending_messages() + self.disconnect_transport_task = asyncio.create_task(self.transport.dispose()) + + async def on_auth_updated(self, token_details: TokenDetails): + log.info(f"ConnectionManager.on_auth_updated(): state = {self.state}") + if self.state == ConnectionState.CONNECTED: + auth_message = { + "action": ProtocolMessageAction.AUTH, + "auth": { + "accessToken": token_details.token + } + } + await self.send_protocol_message(auth_message) + + state_change = await self.once_async() + + if state_change.current == ConnectionState.CONNECTED: + return + elif state_change.current == ConnectionState.FAILED: + raise state_change.reason + elif self.state == ConnectionState.CONNECTING: + if self.connect_base_task and not self.connect_base_task.done(): + self.connect_base_task.cancel() + if self.transport: + await self.transport.dispose() + if self.state != ConnectionState.CONNECTED: + future = asyncio.Future() + + def on_state_change(state_change: ConnectionStateChange) -> None: + if state_change.current == ConnectionState.CONNECTED: + self.off('connectionstate', on_state_change) + future.set_result(token_details) + if state_change.current in ( + ConnectionState.CLOSED, + ConnectionState.FAILED, + ConnectionState.SUSPENDED + ): + self.off('connectionstate', on_state_change) + future.set_exception(state_change.reason or self.get_state_error()) + + self.on('connectionstate', on_state_change) + + if self.state == ConnectionState.CONNECTING: + self.start_connect() + else: + self.request_state(ConnectionState.CONNECTING) + + return await future + + @property + def ably(self): + return self.__ably + + @property + def state(self) -> ConnectionState: + return self.__state + + @property + def connection_details(self) -> ConnectionDetails | None: + return self.__connection_details diff --git a/ably/realtime/presence.py b/ably/realtime/presence.py new file mode 100644 index 00000000..79d73070 --- /dev/null +++ b/ably/realtime/presence.py @@ -0,0 +1,790 @@ +""" +RealtimePresence - Manages presence operations on a realtime channel. + +This module implements presence functionality for realtime channels, +including enter/leave operations, presence state management, and SYNC handling. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from ably.realtime.connection import ConnectionState +from ably.realtime.presencemap import PresenceMap +from ably.types.channelstate import ChannelState, ChannelStateChange +from ably.types.presence import PresenceAction, PresenceMessage +from ably.util.eventemitter import EventEmitter +from ably.util.exceptions import AblyException + +if TYPE_CHECKING: + from ably.realtime.channel import RealtimeChannel + +log = logging.getLogger(__name__) + + +def _get_client_id(presence: RealtimePresence) -> str | None: + """Get the clientId for the current connection.""" + # Use auth.client_id if available (set after CONNECTED), + # otherwise fall back to auth_options.client_id + return presence.channel.ably.auth.client_id or presence.channel.ably.auth.auth_options.client_id + + +def _is_anonymous_or_wildcard(presence: RealtimePresence) -> bool: + """Check if the client is anonymous or has wildcard clientId (RTP8j).""" + realtime = presence.channel.ably + client_id = _get_client_id(presence) + + # If not currently connected, we can't assume we're anonymous + if realtime.connection.state != ConnectionState.CONNECTED: + return False + + return not client_id or client_id == '*' + + +class RealtimePresence(EventEmitter): + """ + Manages presence operations on a realtime channel. + + Enables clients to subscribe to presence events and to enter, update, + and leave presence on a channel. + + Attributes + ---------- + channel : RealtimeChannel + The channel this presence object belongs to + sync_complete : bool + True if the initial SYNC operation has completed (RTP13) + """ + + def __init__(self, channel: RealtimeChannel): + """ + Initialize a new RealtimePresence instance. + + Args: + channel: The RealtimeChannel this presence belongs to + """ + super().__init__() + self.channel = channel + self.sync_complete = False + + # RTP2: Main presence map keyed by memberKey (connectionId:clientId) + self.members = PresenceMap( + member_key_fn=lambda msg: msg.member_key + ) + + # RTP17: Internal presence map for own members, keyed by clientId only + self._my_members = PresenceMap( + member_key_fn=lambda msg: msg.client_id + ) + + # EventEmitter for presence subscriptions + self._subscriptions = EventEmitter() + + # RTP16: Queue for pending presence messages + self._pending_presence: list[dict] = [] + + async def enter(self, data: Any = None) -> None: + """ + Enter this client into the channel's presence (RTP8). + + Args: + data: Optional data to associate with this presence member + + Raises: + AblyException: If clientId is not specified or channel state prevents entering + """ + # RTP8j: Check for anonymous or wildcard client + if _is_anonymous_or_wildcard(self): + raise AblyException( + 'clientId must be specified to enter a presence channel', + 400, 40012 + ) + + return await self._enter_or_update_client(None, None, data, PresenceAction.ENTER) + + async def update(self, data: Any = None) -> None: + """ + Update this client's presence data (RTP9). + + If the client is not already entered, this will enter the client. + + Args: + data: Optional data to associate with this presence member + + Raises: + AblyException: If clientId is not specified or channel state prevents updating + """ + # RTP9e: In all other ways, identical to enter + if _is_anonymous_or_wildcard(self): + raise AblyException( + 'clientId must be specified to update presence data', + 400, 40012 + ) + + return await self._enter_or_update_client(None, None, data, PresenceAction.UPDATE) + + async def leave(self, data: Any = None) -> None: + """ + Leave this client from the channel's presence (RTP10). + + Args: + data: Optional data to send with the leave message + + Raises: + AblyException: If clientId is not specified or channel state prevents leaving + """ + if _is_anonymous_or_wildcard(self): + raise AblyException( + 'clientId must have been specified to enter or leave a presence channel', + 400, 40012 + ) + + return await self._leave_client(None, data) + + async def enter_client(self, client_id: str, data: Any = None) -> None: + """ + Enter into presence on behalf of another clientId (RTP14). + + This allows a single client with suitable permissions to register + presence on behalf of multiple clients. + + Args: + client_id: The clientId to enter + data: Optional data to associate with this presence member + + Raises: + AblyException: If channel state prevents entering or clientId mismatch + """ + return await self._enter_or_update_client(None, client_id, data, PresenceAction.ENTER) + + async def update_client(self, client_id: str, data: Any = None) -> None: + """ + Update presence on behalf of another clientId (RTP15). + + Args: + client_id: The clientId to update + data: Optional data to associate with this presence member + + Raises: + AblyException: If channel state prevents updating or clientId mismatch + """ + return await self._enter_or_update_client(None, client_id, data, PresenceAction.UPDATE) + + async def leave_client(self, client_id: str, data: Any = None) -> None: + """ + Leave presence on behalf of another clientId (RTP15). + + Args: + client_id: The clientId to leave + data: Optional data to send with the leave message + + Raises: + AblyException: If channel state prevents leaving or clientId mismatch + """ + return await self._leave_client(client_id, data) + + async def _enter_or_update_client( + self, + id: str | None, + client_id: str | None, + data: Any, + action: int + ) -> None: + """ + Internal method to handle enter/update operations. + + Args: + id: Optional presence message id + client_id: Optional clientId (if None, uses connection's clientId) + data: Optional data payload + action: The presence action (ENTER or UPDATE) + + Raises: + AblyException: If connection/channel state prevents operation or clientId mismatch + """ + channel = self.channel + + # Check connection state + if channel.ably.connection.state not in [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED + ]: + raise AblyException( + f'Unable to {PresenceAction._action_name(action).lower()} presence channel; ' + f'connection state = {channel.ably.connection.state}', + 400, 90001 + ) + + action_name = PresenceAction._action_name(action).lower() + + log.info( + f'RealtimePresence.{action_name}(): ' + f'channel = {channel.name}, ' + f'clientId = {client_id or "(implicit) " + str(_get_client_id(self))}' + ) + + # RTP15f: Check clientId mismatch (wildcard '*' is allowed to enter on behalf of any client) + if client_id is not None and not self.channel.ably.auth.can_assume_client_id(client_id): + raise AblyException( + f'Unable to {action_name} presence channel with clientId {client_id} ' + f'as it does not match the current clientId {self.channel.ably.auth.client_id}', + 400, 40012 + ) + + # RTP8c: Use connection's clientId if not explicitly provided + effective_client_id = client_id if client_id is not None else _get_client_id(self) + + # Create presence message + presence_msg = PresenceMessage( + id=id, + action=action, + client_id=effective_client_id, + data=data + ) + + # Encrypt if cipher is configured + if channel.cipher: + presence_msg.encrypt(channel.cipher) + + # Convert to wire format + wire_msg = presence_msg.to_encoded(binary=channel.ably.options.use_binary_protocol) + + # RTP8d/RTP8g: Handle based on channel state + if channel.state == ChannelState.ATTACHED: + # Send immediately + return await self._send_presence([wire_msg]) + elif channel.state in [ChannelState.INITIALIZED, ChannelState.DETACHED]: + # RTP8d: Implicitly attach + asyncio.create_task(channel.attach()) + # Queue the message + return await self._queue_presence(wire_msg) + elif channel.state == ChannelState.ATTACHING: + # Queue the message + return await self._queue_presence(wire_msg) + else: + # RTP8g: DETACHED, FAILED, etc. + raise AblyException( + f'Unable to {action_name} presence channel while in {channel.state} state', + 400, 90001 + ) + + async def _leave_client(self, client_id: str | None, data: Any = None) -> None: + """ + Internal method to handle leave operations. + + Args: + client_id: Optional clientId (if None, uses connection's clientId) + data: Optional data payload + + Raises: + AblyException: If connection/channel state prevents operation or clientId mismatch + """ + channel = self.channel + + # Check connection state + if channel.ably.connection.state not in [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED + ]: + raise AblyException( + f'Unable to leave presence channel; ' + f'connection state = {channel.ably.connection.state}', + 400, 90001 + ) + + log.info( + f'RealtimePresence.leave(): ' + f'channel = {channel.name}, ' + f'clientId = {client_id or _get_client_id(self)}' + ) + + # RTP15f: Check clientId mismatch (wildcard '*' is allowed to leave on behalf of any client) + if client_id is not None and not self.channel.ably.auth.can_assume_client_id(client_id): + raise AblyException( + f'Unable to leave presence channel with clientId {client_id} ' + f'as it does not match the current clientId {self.channel.ably.auth.client_id}', + 400, 40012 + ) + + # RTP10c: Use connection's clientId if not explicitly provided + effective_client_id = client_id if client_id is not None else _get_client_id(self) + + # Create presence message + presence_msg = PresenceMessage( + action=PresenceAction.LEAVE, + client_id=effective_client_id, + data=data + ) + + # Encrypt if cipher is configured + if channel.cipher: + presence_msg.encrypt(channel.cipher) + + # Convert to wire format + wire_msg = presence_msg.to_encoded(binary=channel.ably.options.use_binary_protocol) + + # RTP10e: Handle based on channel state + if channel.state == ChannelState.ATTACHED: + # Send immediately + return await self._send_presence([wire_msg]) + elif channel.state == ChannelState.ATTACHING: + # Queue the message + return await self._queue_presence(wire_msg) + elif channel.state in [ChannelState.INITIALIZED, ChannelState.FAILED]: + # RTP10e: Don't attach just to leave + raise AblyException( + 'Unable to leave presence channel (incompatible state)', + 400, 90001 + ) + else: + raise AblyException( + f'Unable to leave presence channel while in {channel.state} state', + 400, 90001 + ) + + async def _send_presence(self, presence_messages: list[dict]) -> None: + """ + Send presence messages to the server. + + Args: + presence_messages: List of encoded presence messages to send + """ + from ably.transport.websockettransport import ProtocolMessageAction + + protocol_msg = { + 'action': ProtocolMessageAction.PRESENCE, + 'channel': self.channel.name, + 'presence': presence_messages + } + + await self.channel.ably.connection.connection_manager.send_protocol_message(protocol_msg) + + async def _queue_presence(self, wire_msg: dict) -> None: + """ + Queue a presence message to be sent when channel attaches. + + Args: + wire_msg: Encoded presence message to queue + """ + future = asyncio.Future() + + self._pending_presence.append({ + 'presence': wire_msg, + 'future': future + }) + + return await future + + async def get( + self, + wait_for_sync: bool = True, + client_id: str | None = None, + connection_id: str | None = None + ) -> list[PresenceMessage]: + """ + Get the current presence members on this channel (RTP11). + + Args: + wait_for_sync: If True, waits for SYNC to complete before returning (default: True) + client_id: Optional filter by clientId + connection_id: Optional filter by connectionId + + Returns: + List of current presence members + + Raises: + AblyException: If channel state prevents getting presence + """ + # RTP11d: Handle SUSPENDED state + if self.channel.state == ChannelState.SUSPENDED: + if wait_for_sync: + raise AblyException( + 'Presence state is out of sync due to channel being in the SUSPENDED state', + 400, 91005 + ) + else: + # Return current members without waiting + return self.members.list(client_id=client_id, connection_id=connection_id) + + # RTP11b: Implicitly attach if needed + if self.channel.state in [ChannelState.INITIALIZED, ChannelState.DETACHED]: + await self.channel.attach() + elif self.channel.state in [ChannelState.DETACHING, ChannelState.FAILED]: + raise AblyException( + f'Unable to get presence; channel state = {self.channel.state}', + 400, 90001 + ) + + # If channel is still attaching, wait for it to become ATTACHED + if self.channel.state == ChannelState.ATTACHING: + # Wait for channel to reach ATTACHED state + state_change = await self.channel._RealtimeChannel__internal_state_emitter.once_async() + if state_change.current != ChannelState.ATTACHED: + raise AblyException( + f'Unable to get presence; channel state = {state_change.current}', + 400, 90001 + ) + + # Wait for sync if requested and a sync is actually in progress + # If sync_complete is already True OR no sync is in progress, don't wait + if wait_for_sync and not self.sync_complete and self.members.sync_in_progress: + await self._wait_for_sync() + + return self.members.list(client_id=client_id, connection_id=connection_id) + + async def _wait_for_sync(self) -> None: + """Wait for presence SYNC to complete.""" + if self.sync_complete: + return + + # Use the PresenceMap's wait_sync mechanism + future = asyncio.Future() + + def on_sync_complete(): + if not future.done(): + future.set_result(None) + + self.members.wait_sync(on_sync_complete) + + # Wait for the sync to complete + await future + + async def subscribe(self, *args) -> None: + """ + Subscribe to presence events on this channel (RTP6). + + Args: + *args: Either (listener) or (event, listener) or (events, listener) + - listener: Callback for all presence events + - event: Specific event name ('enter', 'leave', 'update', 'present') + - events: List of event names + - listener: Callback for specified events + + Raises: + AblyException: If channel state prevents subscription + """ + # Parse arguments: similar to channel subscribe + if len(args) == 1: + # subscribe(listener) + listener = args[0] + self._subscriptions.on(listener) + elif len(args) == 2: + # subscribe(event, listener) + event = args[0] + listener = args[1] + self._subscriptions.on(event, listener) + else: + raise ValueError('Invalid subscribe arguments') + + # RTP6d: Implicitly attach + if self.channel.state in [ChannelState.INITIALIZED, ChannelState.DETACHED, ChannelState.DETACHING]: + await self.channel.attach() + + def unsubscribe(self, *args) -> None: + """ + Unsubscribe from presence events on this channel (RTP7). + + Args: + *args: Either (), (listener), or (event, listener) + - (): Unsubscribe all listeners + - listener: Unsubscribe this specific listener + - event, listener: Unsubscribe listener for specific event + """ + if len(args) == 0: + # unsubscribe() - remove all + self._subscriptions.off() + elif len(args) == 1: + # unsubscribe(listener) + listener = args[0] + self._subscriptions.off(listener) + elif len(args) == 2: + # unsubscribe(event, listener) + event = args[0] + listener = args[1] + self._subscriptions.off(event, listener) + else: + raise ValueError('Invalid unsubscribe arguments') + + def set_presence( + self, + presence_set: list[PresenceMessage], + is_sync: bool, + sync_channel_serial: str | None = None + ) -> None: + """ + Process incoming presence messages from the server (Phase 3 - RTP2, RTP18). + + Args: + presence_set: List of presence messages received + is_sync: True if this is part of a SYNC operation + sync_channel_serial: Optional sync cursor for tracking sync progress + """ + log.info( + f'RealtimePresence.set_presence(): ' + f'received presence for {len(presence_set)} members; ' + f'syncChannelSerial = {sync_channel_serial}' + ) + + conn_id = self.channel.ably.connection.connection_manager.connection_id + broadcast_messages = [] + + # RTP18: Handle SYNC + if is_sync: + self.members.start_sync() + # Parse sync cursor if present + if sync_channel_serial: + # Format: : + parts = sync_channel_serial.split(':', 1) + sync_cursor = parts[1] if len(parts) > 1 else None + else: + sync_cursor = None + else: + sync_cursor = None + + # Process each presence message + for presence in presence_set: + if presence.action == PresenceAction.LEAVE: + # RTP2h: Handle LEAVE + if self.members.remove(presence): + broadcast_messages.append(presence) + + # RTP17b: Update internal presence map (not synthesized) + if presence.connection_id == conn_id and not presence.is_synthesized(): + self._my_members.remove(presence) + + elif presence.action in ( + PresenceAction.ENTER, + PresenceAction.PRESENT, + PresenceAction.UPDATE + ): + # RTP2d: Handle ENTER/PRESENT/UPDATE + if self.members.put(presence): + broadcast_messages.append(presence) + + # RTP17b: Update internal presence map + if presence.connection_id == conn_id: + self._my_members.put(presence) + + # RTP18b/RTP18c: End sync if cursor is empty or no channelSerial + if is_sync and (not sync_channel_serial or not sync_cursor): + residual, absent = self.members.end_sync() + self.sync_complete = True + + # RTP19: Emit synthesized leave events for residual members + for member in residual + absent: + synthesized_leave = PresenceMessage( + action=PresenceAction.LEAVE, + client_id=member.client_id, + connection_id=member.connection_id, + data=member.data, + encoding=member.encoding, + timestamp=datetime.now(timezone.utc) + ) + broadcast_messages.append(synthesized_leave) + + # Broadcast messages to subscribers + for presence in broadcast_messages: + action_name = PresenceAction._action_name(presence.action).lower() + self._subscriptions._emit(action_name, presence) + + def on_attached(self, has_presence: bool = False) -> None: + """ + Handle channel ATTACHED event (RTP5b). + + Args: + has_presence: True if server will send SYNC + """ + log.info( + f'RealtimePresence.on_attached(): ' + f'channel = {self.channel.name}, hasPresence = {has_presence}' + ) + + # RTP1: Handle presence sync flag + if has_presence: + self.members.start_sync() + self.sync_complete = False + else: + # RTP19a: No presence on channel, synthesize leaves for existing members + self._synthesize_leaves(self.members.values()) + self.members.clear() + self.sync_complete = True + # Also end sync in case one was started + if self.members.sync_in_progress: + self.members.end_sync() + + # RTP17i: Re-enter own members + self._ensure_my_members_present() + + # RTP5b: Send pending presence messages + asyncio.create_task(self._send_pending_presence()) + + def _ensure_my_members_present(self) -> None: + """ + Re-enter own presence members after attach (RTP17g). + """ + conn_id = self.channel.ably.connection.connection_manager.connection_id + + for _client_id, entry in list(self._my_members._map.items()): + log.info( + f'RealtimePresence._ensure_my_members_present(): ' + f'auto-reentering clientId "{entry.client_id}"' + ) + + # RTP17g1: Suppress id if connectionId has changed + msg_id = entry.id if entry.connection_id == conn_id else None + + # Create task to re-enter - use default args to bind loop variables + asyncio.create_task( + self._reenter_member(msg_id, entry.client_id, entry.data) + ) + + async def _reenter_member(self, msg_id: str | None, client_id: str, data: Any) -> None: + """ + Helper method to re-enter a member (RTP17g). + + Args: + msg_id: Optional message ID + client_id: The client ID to re-enter + data: The presence data + """ + try: + await self._enter_or_update_client( + msg_id, + client_id, + data, + PresenceAction.ENTER + ) + except AblyException as e: + log.error( + f'RealtimePresence._reenter_member(): ' + f'auto-reenter failed: {e}' + ) + # RTP17e: Emit update event with error + state_change = ChannelStateChange( + previous=self.channel.state, + current=self.channel.state, + resumed=False, + reason=e + ) + self.channel._emit("update", state_change) + + async def _send_pending_presence(self) -> None: + """ + Send pending presence messages after channel attaches (RTP5b). + """ + if not self._pending_presence: + return + + log.info( + f'RealtimePresence._send_pending_presence(): ' + f'sending {len(self._pending_presence)} queued messages' + ) + + pending = self._pending_presence + self._pending_presence = [] + + # Send all pending messages + presence_array = [item['presence'] for item in pending] + + try: + await self._send_presence(presence_array) + # Resolve all futures AFTER send completes + for item in pending: + if not item['future'].done(): + item['future'].set_result(None) + except Exception as e: + # Reject all futures + for item in pending: + if not item['future'].done(): + item['future'].set_exception(e) + + def _synthesize_leaves(self, members: list[PresenceMessage]) -> None: + """ + Emit synthesized leave events for members (RTP19, RTP19a). + + Args: + members: List of members to synthesize leaves for + """ + for member in members: + synthesized_leave = PresenceMessage( + action=PresenceAction.LEAVE, + client_id=member.client_id, + connection_id=member.connection_id, + data=member.data, + encoding=member.encoding, + timestamp=datetime.now(timezone.utc) + ) + self._subscriptions._emit('leave', synthesized_leave) + + def act_on_channel_state( + self, + state: ChannelState, + has_presence: bool = False, + error: AblyException | None = None + ) -> None: + """ + React to channel state changes (RTP5). + + Args: + state: The new channel state + has_presence: Whether the channel has presence (for ATTACHED) + error: Optional error associated with state change + """ + if state == ChannelState.ATTACHED: + self.on_attached(has_presence) + elif state in (ChannelState.DETACHED, ChannelState.FAILED): + # RTP5a: Clear maps and fail pending + self._my_members.clear() + self.members.clear() + self.sync_complete = False + self._fail_pending_presence(error) + elif state == ChannelState.SUSPENDED: + # RTP5f: Fail pending but keep members, reset sync state + self.sync_complete = False # Sync state is no longer valid + self._fail_pending_presence(error) + + def _fail_pending_presence(self, error: AblyException | None = None) -> None: + """ + Fail all pending presence messages. + + Args: + error: The error to reject with + """ + if not self._pending_presence: + return + + log.info( + f'RealtimePresence._fail_pending_presence(): ' + f'failing {len(self._pending_presence)} queued messages' + ) + + pending = self._pending_presence + self._pending_presence = [] + + exception = error or AblyException('Presence operation failed', 400, 90001) + + for item in pending: + if not item['future'].done(): + item['future'].set_exception(exception) + + +# Helper for PresenceAction to convert action to string +def _action_name_impl(action: int) -> str: + """Convert presence action to string name.""" + names = { + PresenceAction.ABSENT: 'absent', + PresenceAction.PRESENT: 'present', + PresenceAction.ENTER: 'enter', + PresenceAction.LEAVE: 'leave', + PresenceAction.UPDATE: 'update', + } + return names.get(action, f'unknown({action})') + + +# Monkey-patch the helper onto PresenceAction +PresenceAction._action_name = staticmethod(_action_name_impl) diff --git a/ably/realtime/presencemap.py b/ably/realtime/presencemap.py new file mode 100644 index 00000000..9c5adace --- /dev/null +++ b/ably/realtime/presencemap.py @@ -0,0 +1,351 @@ +""" +PresenceMap - Manages the state of presence members on a channel. + +This module implements RTP2 presence map requirements from the Ably specification. +""" + +import logging +from typing import Callable, Dict, List, Optional, Tuple + +from ably.types.presence import PresenceAction, PresenceMessage + +logger = logging.getLogger(__name__) + + +def _is_newer(item: PresenceMessage, existing: PresenceMessage) -> bool: + """ + Compare two presence messages for newness (RTP2b). + + RTP2b1: If either presence message has a connectionId which is not an initial + substring of its id, compare them by timestamp numerically. This will be the + case when one of them is a 'synthesized leave' event. + + RTP2b1a: If the timestamps compare equal, the newly-incoming message is + considered newer than the existing one. + + RTP2b2: Else split the id of both presence messages (format: connid:msgSerial:index) + and compare them first by msgSerial numerically, then by index numerically, + larger being newer in both cases. + + Args: + item: The incoming presence message + existing: The existing presence message in the map + + Returns: + True if item is newer than existing, False otherwise + + Raises: + ValueError: If message ids cannot be parsed for comparison + """ + # RTP2b1: if either is synthesized, compare by timestamp + if item.is_synthesized() or existing.is_synthesized(): + # RTP2b1a: if equal, prefer the newly-arrived one (item) + if item.timestamp is None and existing.timestamp is None: + return True + if item.timestamp is None: + return False + if existing.timestamp is None: + return True + return item.timestamp >= existing.timestamp + + # RTP2b2: compare by msgSerial and index + # parse_id will raise ValueError if id format is invalid + item_parts = item.parse_id() + existing_parts = existing.parse_id() + + if item_parts['msgSerial'] == existing_parts['msgSerial']: + return item_parts['index'] > existing_parts['index'] + else: + return item_parts['msgSerial'] > existing_parts['msgSerial'] + + +class PresenceMap: + """ + Manages the state of presence members on a channel. + + Maintains a map of members keyed by memberKey (connectionId:clientId). + Handles newness comparison, SYNC operations, and member filtering. + + Implements RTP2 specification requirements. + """ + + def __init__( + self, + member_key_fn: Callable[[PresenceMessage], str], + is_newer_fn: Optional[Callable[[PresenceMessage, PresenceMessage], bool]] = None, + logger_instance: Optional[logging.Logger] = None + ): + """ + Initialize a new PresenceMap. + + Args: + member_key_fn: Function to extract member key from a PresenceMessage + is_newer_fn: Optional custom function for newness comparison (default: _is_newer) + logger_instance: Optional logger instance (default: module logger) + """ + self._map: Dict[str, PresenceMessage] = {} + self._residual_members: Optional[Dict[str, PresenceMessage]] = None + self._sync_in_progress = False + self._member_key_fn = member_key_fn + self._is_newer_fn = is_newer_fn or _is_newer + self._logger = logger_instance or logger + self._sync_complete_callbacks: List[Callable[[], None]] = [] + + @property + def sync_in_progress(self) -> bool: + """Returns True if a SYNC operation is currently in progress.""" + return self._sync_in_progress + + def get(self, key: str) -> Optional[PresenceMessage]: + """ + Get a presence member by key. + + Args: + key: The member key (connectionId:clientId) + + Returns: + The PresenceMessage if found, None otherwise + """ + return self._map.get(key) + + def put(self, item: PresenceMessage) -> bool: + """ + Add or update a presence member (RTP2d). + + For ENTER, UPDATE, or PRESENT actions, the message is stored in the map + with action set to PRESENT (if it passes the newness check). + + Args: + item: The presence message to add/update + + Returns: + True if the item was added/updated, False if rejected due to newness check + """ + # RTP2d: ENTER, UPDATE, PRESENT all get stored as PRESENT + if item.action in (PresenceAction.ENTER, PresenceAction.UPDATE, PresenceAction.PRESENT): + # Create a copy with action set to PRESENT + item_to_store = PresenceMessage( + id=item.id, + action=PresenceAction.PRESENT, + client_id=item.client_id, + connection_id=item.connection_id, + data=item.data, + encoding=item.encoding, + timestamp=item.timestamp, + extras=item.extras + ) + else: + item_to_store = item + + key = self._member_key_fn(item_to_store) + if not key: + self._logger.warning("PresenceMap.put: item has no member key, ignoring") + return False + + # If we're in a sync, mark this member as seen (remove from residual) + if self._residual_members is not None and key in self._residual_members: + del self._residual_members[key] + + # Check newness against existing member + existing = self._map.get(key) + if existing and not self._is_newer_fn(item_to_store, existing): + self._logger.debug(f"PresenceMap.put: incoming message for {key} is not newer, ignoring") + return False + + self._map[key] = item_to_store + self._logger.debug(f"PresenceMap.put: added/updated member {key}") + return True + + def remove(self, item: PresenceMessage) -> bool: + """ + Remove a presence member (RTP2h). + + During a SYNC, the member is marked as ABSENT rather than removed. + Outside of SYNC, the member is removed from the map. + + Args: + item: The presence message with LEAVE action + + Returns: + True if a member was removed/marked absent, False if no action taken + """ + key = self._member_key_fn(item) + if not key: + return False + + existing = self._map.get(key) + if not existing: + return False + + # Check newness (RTP2h requires newness check) + if not self._is_newer_fn(item, existing): + self._logger.debug(f"PresenceMap.remove: incoming message for {key} is not newer, ignoring") + return False + + # RTP2h2: During SYNC, mark as ABSENT instead of removing + if self._sync_in_progress: + absent_item = PresenceMessage( + id=item.id, + action=PresenceAction.ABSENT, + client_id=item.client_id, + connection_id=item.connection_id, + data=item.data, + encoding=item.encoding, + timestamp=item.timestamp, + extras=item.extras + ) + self._map[key] = absent_item + self._logger.debug(f"PresenceMap.remove: marked member {key} as ABSENT (sync in progress)") + else: + # RTP2h1: Outside of SYNC, remove the member + del self._map[key] + self._logger.debug(f"PresenceMap.remove: removed member {key}") + + return True + + def values(self) -> List[PresenceMessage]: + """ + Get all presence members (excluding ABSENT members). + + Returns: + List of all PRESENT members + """ + return [ + msg for msg in self._map.values() + if msg.action != PresenceAction.ABSENT + ] + + def list( + self, + client_id: Optional[str] = None, + connection_id: Optional[str] = None + ) -> List[PresenceMessage]: + """ + Get presence members with optional filtering (RTP11). + + Args: + client_id: Optional filter by client ID + connection_id: Optional filter by connection ID + + Returns: + List of matching PRESENT members + """ + result = [] + for msg in self._map.values(): + # Skip ABSENT members + if msg.action == PresenceAction.ABSENT: + continue + + # Apply filters + if client_id and msg.client_id != client_id: + continue + if connection_id and msg.connection_id != connection_id: + continue + + result.append(msg) + + return result + + def start_sync(self) -> None: + """ + Start a SYNC operation (RTP18). + + Captures current members as residual members to track which ones + are not seen during the sync. + """ + self._logger.info(f"PresenceMap.start_sync: starting sync (in_progress={self._sync_in_progress})") + + # May be called multiple times while a sync is in progress + if not self._sync_in_progress: + # Copy current map as residual members + self._residual_members = dict(self._map) + self._sync_in_progress = True + self._logger.debug(f"PresenceMap.start_sync: captured {len(self._residual_members)} residual members") + + def end_sync(self) -> Tuple[List[PresenceMessage], List[PresenceMessage]]: + """ + End a SYNC operation (RTP18, RTP19). + + Removes ABSENT members and returns lists of members that should have + synthesized leave events emitted. + + Returns: + Tuple of (residual_members, absent_members) that need LEAVE events + """ + self._logger.info(f"PresenceMap.end_sync: ending sync (in_progress={self._sync_in_progress})") + + residual_list: List[PresenceMessage] = [] + absent_list: List[PresenceMessage] = [] + + if self._sync_in_progress: + # Collect ABSENT members and remove them from map (RTP2h2b) + keys_to_remove = [] + for key, msg in self._map.items(): + if msg.action == PresenceAction.ABSENT: + absent_list.append(msg) + keys_to_remove.append(key) + + for key in keys_to_remove: + del self._map[key] + + # Collect residual members (members present at start but not seen during sync) + # These need synthesized LEAVE events (RTP19) + if self._residual_members: + residual_list = list(self._residual_members.values()) + # Remove residual members from map + for key in self._residual_members.keys(): + if key in self._map: + del self._map[key] + + self._residual_members = None + self._sync_in_progress = False + self._logger.debug( + f"PresenceMap.end_sync: removed {len(absent_list)} absent members, " + f"{len(residual_list)} residual members" + ) + + # Notify callbacks that sync is complete + for callback in self._sync_complete_callbacks: + try: + callback() + except Exception as e: + self._logger.error(f"Error in sync complete callback: {e}") + self._sync_complete_callbacks.clear() + + return residual_list, absent_list + + def wait_sync(self, callback: Callable[[], None]) -> None: + """ + Wait for SYNC to complete, calling callback when done. + + If sync is not in progress, callback is called immediately. + + Args: + callback: Function to call when sync completes + """ + if not self._sync_in_progress: + callback() + else: + self._sync_complete_callbacks.append(callback) + + def clear(self) -> None: + """ + Clear all members and reset sync state. + + Used when channel enters DETACHED or FAILED state (RTP5a). + Invokes any pending sync callbacks before clearing to ensure + waiting Futures are resolved and callers are not left blocked. + """ + # Notify any callbacks waiting for sync to complete + # This ensures Futures created by _wait_for_sync() are resolved + for callback in self._sync_complete_callbacks: + try: + callback() + except Exception as e: + self._logger.error(f"Error in sync complete callback during clear: {e}") + + self._map.clear() + self._residual_members = None + self._sync_in_progress = False + self._sync_complete_callbacks.clear() + self._logger.debug("PresenceMap.clear: cleared all members") diff --git a/ably/realtime/realtime.py b/ably/realtime/realtime.py new file mode 100644 index 00000000..ab435304 --- /dev/null +++ b/ably/realtime/realtime.py @@ -0,0 +1,144 @@ +import asyncio +import logging +from typing import Optional + +from ably.realtime.channel import Channels +from ably.realtime.connection import Connection, ConnectionState +from ably.rest.rest import AblyRest + +log = logging.getLogger(__name__) + + +class AblyRealtime(AblyRest): + """ + Ably Realtime Client + + Attributes + ---------- + loop: AbstractEventLoop + asyncio running event loop + auth: Auth + authentication object + options: Options + auth options object + connection: Connection + realtime connection object + channels: Channels + realtime channel object + + Methods + ------- + connect() + Establishes the realtime connection + close() + Closes the realtime connection + """ + + def __init__(self, key: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None, **kwargs): + """Constructs a RealtimeClient object using an Ably API key. + + Parameters + ---------- + key: str + A valid ably API key string + loop: AbstractEventLoop, optional + asyncio running event loop + auto_connect: bool + When true, the client connects to Ably as soon as it is instantiated. + You can set this to false and explicitly connect to Ably using the + connect() method. The default is true. + **kwargs: client options + endpoint: str + Endpoint specifies either a routing policy name or fully qualified domain name to connect to Ably. + realtime_host: str + Deprecated: this property is deprecated and will be removed in a future version. + Enables a non-default Ably host to be specified for realtime connections. + For development environments only. The default value is realtime.ably.io. + environment: str + Deprecated: this property is deprecated and will be removed in a future version. + Enables a custom environment to be used with the Ably service. Defaults to `production` + realtime_request_timeout: float + Timeout (in milliseconds) for the wait of acknowledgement for operations performed via a realtime + connection. Operations include establishing a connection with Ably, or sending a HEARTBEAT, + CONNECT, ATTACH, DETACH or CLOSE request. The default is 10 seconds(10000 milliseconds). + disconnected_retry_timeout: float + If the connection is still in the DISCONNECTED state after this delay, the client library will + attempt to reconnect automatically. The default is 15 seconds. + channel_retry_timeout: float + When a channel becomes SUSPENDED following a server initiated DETACHED, after this delay, if the + channel is still SUSPENDED and the connection is in CONNECTED, the client library will attempt to + re-attach the channel automatically. The default is 15 seconds. + fallback_hosts: list[str] + An array of fallback hosts to be used in the case of an error necessitating the use of an + alternative host. If you have been provided a set of custom fallback hosts by Ably, please specify + them here. + connection_state_ttl: float + The duration that Ably will persist the connection state for when a Realtime client is abruptly + disconnected. + suspended_retry_timeout: float + When the connection enters the SUSPENDED state, after this delay, if the state is still SUSPENDED, + the client library attempts to reconnect automatically. The default is 30 seconds. + connectivity_check_url: string + Override the URL used by the realtime client to check if the internet is available. + In the event of a failure to connect to the primary endpoint, the client will send a + GET request to this URL to check if the internet is available. If this request returns + a success response the client will attempt to connect to a fallback host. + Raises + ------ + ValueError + If no authentication key is not provided + """ + + if loop is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + log.warning('Realtime client created outside event loop') + + self._is_realtime: bool = True + + # RTC1 + super().__init__(key, loop=loop, **kwargs) + + self.key = key + self.__connection = Connection(self) + self.__channels = Channels(self) + + # RTN3 + if self.options.auto_connect: + self.connection.connection_manager.request_state(ConnectionState.CONNECTING, force=True) + + # RTC15 + def connect(self) -> None: + """Establishes a realtime connection. + + Explicitly calling connect() is unnecessary unless the autoConnect attribute of the ClientOptions object + is false. Unless already connected or connecting, this method causes the connection to open, entering the + CONNECTING state. + """ + log.info('Realtime.connect() called') + # RTC15a + self.connection.connect() + + # RTC16 + async def close(self) -> None: + """Causes the connection to close, entering the closing state. + Once closed, the library will not attempt to re-establish the + connection without an explicit call to connect() + """ + log.info('Realtime.close() called') + # RTC16a + await self.connection.close() + await super().close() + + # RTC2 + @property + def connection(self) -> Connection: + """Returns the realtime connection object""" + return self.__connection + + # RTC3, RTS1 + @property + def channels(self) -> Channels: + """Returns the realtime channel object""" + return self.__channels diff --git a/ably/rest/annotations.py b/ably/rest/annotations.py new file mode 100644 index 00000000..fc2b29d5 --- /dev/null +++ b/ably/rest/annotations.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import base64 +import json +import logging +import os +from urllib import parse + +import msgpack + +from ably.http.paginatedresult import PaginatedResult, format_params +from ably.types.annotation import ( + Annotation, + AnnotationAction, + make_annotation_response_handler, +) +from ably.types.message import Message +from ably.types.options import Options +from ably.util.exceptions import AblyException + +log = logging.getLogger(__name__) + + +def serial_from_msg_or_serial(msg_or_serial): + """ + Extract the message serial from either a string serial or a Message object. + + Args: + msg_or_serial: Either a string serial or a Message object with a serial property + + Returns: + str: The message serial + + Raises: + AblyException: If the input is invalid or serial is missing + """ + if isinstance(msg_or_serial, str): + message_serial = msg_or_serial + elif isinstance(msg_or_serial, Message): + message_serial = msg_or_serial.serial + else: + message_serial = None + + if not message_serial or not isinstance(message_serial, str): + raise AblyException( + message='First argument of annotations.publish() must be either a Message ' + 'or a message serial (string)', + status_code=400, + code=40003, + ) + + return message_serial + + +def construct_validate_annotation(msg_or_serial, annotation: Annotation) -> Annotation: + """ + Construct and validate an Annotation from input values. + + Args: + msg_or_serial: Either a string serial or a Message object + annotation: Annotation object + + Returns: + Annotation: The constructed annotation + + Raises: + AblyException: If the inputs are invalid + """ + message_serial = serial_from_msg_or_serial(msg_or_serial) + + if not annotation or not isinstance(annotation, Annotation): + raise AblyException( + message='Second argument of annotations.publish() must be an Annotation ' + '(the intended annotation to publish)', + status_code=400, + code=40003, + ) + + # RSAN1a3: Validate that annotation type is specified + if not annotation.type: + raise AblyException( + message='Annotation type must be specified', + status_code=400, + code=40000, + ) + + return annotation._copy_with( + message_serial=message_serial, + ) + + +class RestAnnotations: + """ + Provides REST API methods for managing annotations on messages. + """ + + __client_options: Options + + def __init__(self, channel): + """ + Initialize RestAnnotations. + + Args: + channel: The REST Channel this annotations instance belongs to + """ + self.__channel = channel + self.__client_options = channel.ably.options + + def __base_path_for_serial(self, serial): + """ + Build the base API path for a message serial's annotations. + + Args: + serial: The message serial + + Returns: + str: The API path + """ + channel_path = '/channels/{}/'.format(parse.quote_plus(self.__channel.name, safe=':')) + return channel_path + 'messages/' + parse.quote_plus(serial, safe=':') + '/annotations' + + async def __send_annotation(self, annotation: Annotation, params: dict | None = None): + """ + Internal method to send an annotation to the API. + + Args: + annotation: Validated Annotation object with action and message_serial set + params: Optional dict of query parameters + """ + # RSAN1c4: Generate random ID if not provided (for idempotent publishing) + # Spec: base64-encode at least 9 random bytes, append ':0' + if not annotation.id and self.__client_options.idempotent_rest_publishing: + random_id = base64.b64encode(os.urandom(9)).decode('ascii') + ':0' + annotation = annotation._copy_with(id=random_id) + + # Convert to wire format + request_body = annotation.as_dict(binary=self.__channel.ably.options.use_binary_protocol) + + # Wrap in array as API expects array of annotations + request_body = [request_body] + + # Encode based on protocol + if not self.__channel.ably.options.use_binary_protocol: + request_body = json.dumps(request_body, separators=(',', ':')) + else: + request_body = msgpack.packb(request_body, use_bin_type=True) + + # Build path + path = self.__base_path_for_serial(annotation.message_serial) + if params: + params = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} + path += '?' + parse.urlencode(params) + + # Send request + await self.__channel.ably.http.post(path, body=request_body) + + async def publish( + self, + msg_or_serial, + annotation: Annotation, + params: dict | None = None, + ): + """ + Publish an annotation on a message. + + Args: + msg_or_serial: Either a message serial (string) or a Message object + annotation: Annotation object + params: Optional dict of query parameters + + Returns: + None + + Raises: + AblyException: If the request fails or inputs are invalid + """ + annotation = construct_validate_annotation(msg_or_serial, annotation) + + # RSAN1c1: Explicitly set action to ANNOTATION_CREATE + annotation = annotation._copy_with(action=AnnotationAction.ANNOTATION_CREATE) + + await self.__send_annotation(annotation, params) + + async def delete( + self, + msg_or_serial, + annotation: Annotation, + params: dict | None = None, + ): + """ + Delete an annotation on a message. + + This is a convenience method that sets the action to 'annotation.delete' + and calls publish(). + + Args: + msg_or_serial: Either a message serial (string) or a Message object + annotation: Annotation object + params: Optional dict of query parameters + + Returns: + None + + Raises: + AblyException: If the request fails or inputs are invalid + """ + annotation = construct_validate_annotation(msg_or_serial, annotation) + + # RSAN2a: Explicitly set action to ANNOTATION_DELETE + annotation = annotation._copy_with(action=AnnotationAction.ANNOTATION_DELETE) + + return await self.__send_annotation(annotation, params) + + async def get(self, msg_or_serial, params: dict | None = None): + """ + Retrieve annotations for a message with pagination support. + + Args: + msg_or_serial: Either a message serial (string) or a Message object + params: Optional dict of query parameters (limit, start, end, direction) + + Returns: + PaginatedResult: A paginated result containing Annotation objects + + Raises: + AblyException: If the request fails or serial is invalid + """ + message_serial = serial_from_msg_or_serial(msg_or_serial) + + # Build path + params_str = format_params({}, **params) if params else '' + path = self.__base_path_for_serial(message_serial) + params_str + + # Create annotation response handler + annotation_handler = make_annotation_response_handler(cipher=None) + + # Return paginated result + return await PaginatedResult.paginated_query( + self.__channel.ably.http, + url=path, + response_processor=annotation_handler + ) diff --git a/ably/rest/auth.py b/ably/rest/auth.py index ca62da8d..d2057533 100644 --- a/ably/rest/auth.py +++ b/ably/rest/auth.py @@ -1,58 +1,75 @@ -from __future__ import absolute_import +from __future__ import annotations import base64 -import hashlib -import hmac -import json import logging -import random import time +import uuid +from datetime import timedelta +from typing import TYPE_CHECKING -import six +import httpx -from ably.types.capability import Capability -from ably.types.tokendetails import TokenDetails +from ably.types.options import Options -# initialise and seed our own instance of random -rnd = random.Random() -rnd.seed() +if TYPE_CHECKING: + from ably.realtime.realtime import AblyRealtime + from ably.rest.rest import AblyRest -from ably.util.exceptions import AblyException +from ably.types.capability import Capability +from ably.types.tokendetails import TokenDetails +from ably.types.tokenrequest import TokenRequest +from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.util.helper import extract_url_params __all__ = ["Auth"] log = logging.getLogger(__name__) -class Auth(object): +class Auth: class Method: BASIC = "BASIC" TOKEN = "TOKEN" - def __init__(self, ably, options): + def __init__(self, ably: AblyRest | AblyRealtime, options: Options): self.__ably = ably self.__auth_options = options - self.__basic_credentials = None - self.__token_credentials = None - self.__auth_params = None - self.__token_details = None - - if options.key_value is not None and options.client_id is None: + if not self.ably._is_realtime: + self.__client_id = options.client_id + if not self.__client_id and options.token_details: + self.__client_id = options.token_details.client_id + else: + self.__client_id = None + self.__client_id_validated: bool = False + + self.__basic_credentials: str | None = None + self.__auth_params: dict | None = None + self.__token_details: TokenDetails | None = None + self.__time_offset: int | None = None + + must_use_token_auth = options.use_token_auth is True + must_not_use_token_auth = options.use_token_auth is False + can_use_basic_auth = options.key_secret is not None + if not must_use_token_auth and can_use_basic_auth: # We have the key, no need to authenticate the client # default to using basic auth log.debug("anonymous, using basic auth") - self.__auth_method = Auth.Method.BASIC - basic_key = "%s:%s" % (options.key_id, options.key_value) + self.__auth_mechanism = Auth.Method.BASIC + basic_key = f"{options.key_name}:{options.key_secret}" basic_key = base64.b64encode(basic_key.encode('utf-8')) self.__basic_credentials = basic_key.decode('ascii') return + elif must_not_use_token_auth and not can_use_basic_auth: + raise ValueError('If use_token_auth is False you must provide a key') # Using token auth - self.__auth_method = Auth.Method.TOKEN + self.__auth_mechanism = Auth.Method.TOKEN - if options.auth_token: - self.__token_details = TokenDetails(id=options.auth_token) + if options.token_details: + self.__token_details = options.token_details + elif options.auth_token: + self.__token_details = TokenDetails(token=options.auth_token) else: self.__token_details = None @@ -60,183 +77,231 @@ def __init__(self, ably, options): log.debug("using token auth with auth_callback") elif options.auth_url: log.debug("using token auth with auth_url") - elif options.key_value: + elif options.key_secret: log.debug("using token auth with client-side signing") elif options.auth_token: log.debug("using token auth with supplied token only") + elif options.token_details: + log.debug("using token auth with supplied token_details") + else: + raise ValueError("Can't authenticate via token, must provide " + "auth_callback, auth_url, key, token or a TokenDetail") + + async def get_auth_transport_param(self): + auth_credentials = {} + if self.auth_options.client_id and self.auth_options.client_id != '*': + auth_credentials["clientId"] = self.auth_options.client_id + if self.__auth_mechanism == Auth.Method.BASIC: + key_name = self.__auth_options.key_name + key_secret = self.__auth_options.key_secret + auth_credentials["key"] = f"{key_name}:{key_secret}" + elif self.__auth_mechanism == Auth.Method.TOKEN: + token_details = await self._ensure_valid_auth_credentials() + auth_credentials["accessToken"] = token_details.token + return auth_credentials + + async def __authorize_when_necessary(self, token_params=None, auth_options=None, force=False): + token_details = await self._ensure_valid_auth_credentials(token_params, auth_options, force) + + if self.ably._is_realtime: + await self.ably.connection.connection_manager.on_auth_updated(token_details) + + return token_details + + async def _ensure_valid_auth_credentials(self, token_params=None, auth_options=None, force=False): + self.__auth_mechanism = Auth.Method.TOKEN + if token_params is None: + token_params = dict(self.auth_options.default_token_params) else: - # Not a hard error, but any operation requiring authentication - # will fail - log.debug("no authentication parameters supplied") + self.auth_options.default_token_params = dict(token_params) + self.auth_options.default_token_params.pop('timestamp', None) - def authorise(self, force=False, **kwargs): - if self.__token_details: - if self.__token_details.expires > self._timestamp(): - if not force: - log.debug( - "using cached token; expires = %d", - self.__token_details.expires - ) - return self.__token_details - else: - # token has expired - self.__token_details = None + if auth_options is not None: + self.auth_options.replace(auth_options) + auth_options = dict(self.auth_options.auth_options) + if self.client_id is not None: + token_params['client_id'] = self.client_id + + token_details = self.__token_details + if not force and not self.token_details_has_expired(): + log.debug("using cached token; expires = %d", + token_details.expires) + return token_details + + self.__token_details = await self.request_token(token_params, **auth_options) + self._configure_client_id(self.__token_details.client_id) - self.__token_details = self.request_token(**kwargs) return self.__token_details - def request_token(self, key_id=None, key_value=None, query_time=None, - auth_token=None, auth_callback=None, auth_url=None, - auth_headers=None, auth_params=None, token_params=None): - key_id = key_id or self.auth_options.key_id - key_value = key_value or self.auth_options.key_value + def token_details_has_expired(self): + token_details = self.__token_details + if token_details is None: + return True - log.debug("Auth callback: %s" % auth_callback) - log.debug("Auth options: %s" % six.text_type(self.auth_options)) + if not self.__time_offset: + return False + + expires = token_details.expires + if expires is None: + return False + + timestamp = self._timestamp() + if self.__time_offset: + timestamp += self.__time_offset + + return expires < timestamp + token_details.TOKEN_EXPIRY_BUFFER + + async def authorize(self, token_params: dict | None = None, auth_options=None): + return await self.__authorize_when_necessary(token_params, auth_options, force=True) + + async def request_token(self, token_params: dict | None = None, + # auth_options + key_name: str | None = None, key_secret: str | None = None, auth_callback=None, + auth_url: str | None = None, auth_method: str | None = None, + auth_headers: dict | None = None, auth_params: dict | None = None, + query_time=None): + token_params = token_params or {} + token_params = dict(self.auth_options.default_token_params, + **token_params) + key_name = key_name or self.auth_options.key_name + key_secret = key_secret or self.auth_options.key_secret + + log.debug(f"Auth callback: {auth_callback}") + log.debug(f"Auth options: {self.auth_options}") + if query_time is None: + query_time = self.auth_options.query_time query_time = bool(query_time) - auth_token = auth_token or self.auth_options.auth_token auth_callback = auth_callback or self.auth_options.auth_callback auth_url = auth_url or self.auth_options.auth_url - auth_headers = auth_headers or { - "Content-Encoding": "utf-8", - "Content-Type": "application/json", - } - auth_params = auth_params or self.auth_params - token_params = token_params or {} + auth_params = auth_params or self.auth_options.auth_params or {} - token_params.setdefault("client_id", self.ably.client_id) + auth_method = (auth_method or self.auth_options.auth_method).upper() - signed_token_request = "" + auth_headers = auth_headers or self.auth_options.auth_headers or {} - log.debug("Token Params: %s" % token_params) + log.debug(f"Token Params: {token_params}") if auth_callback: log.debug("using token auth with authCallback") - signed_token_request = auth_callback(**token_params) + try: + token_request = await auth_callback(token_params) + except Exception as e: + raise AblyException("auth_callback raised an exception", 401, 40170, cause=e) from e elif auth_url: log.debug("using token auth with authUrl") - response = self.ably.http.post( - auth_url, - headers=auth_headers, - body=json.dumps(token_params), - skip_auth=True - ) - - AblyException.raise_for_response(response) - signed_token_request = response.text - elif key_value: - log.debug("using token auth with client-side signing") - signed_token_request = self.create_token_request( - key_id=key_id, - key_value=key_value, - query_time=query_time, - token_params=token_params) + token_request = await self.token_request_from_auth_url( + auth_method, auth_url, token_params, auth_headers, auth_params) + elif key_name is not None and key_secret is not None: + token_request = await self.create_token_request( + token_params, key_name=key_name, key_secret=key_secret, + query_time=query_time) else: - log.debug('No auth_callback, auth_url or key_value specified') - raise AblyException( - "Auth.request_token() must include valid auth parameters", - 400, - 40000) - - token_path = "/keys/%s/requestToken" % key_id - response = self.ably.http.post( + msg = "Need a new token but auth_options does not include a way to request one" + log.exception(msg) + raise AblyAuthException(msg, 403, 40171) + if isinstance(token_request, TokenDetails): + return token_request + elif isinstance(token_request, dict) and 'issued' in token_request: + return TokenDetails.from_dict(token_request) + elif isinstance(token_request, dict): + try: + token_request = TokenRequest.from_json(token_request) + except TypeError as e: + msg = "Expected token request callback to call back with a token string, token request object, or \ + token details object" + raise AblyAuthException(msg, 401, 40170, cause=e) from e + elif isinstance(token_request, str): + if len(token_request) == 0: + raise AblyAuthException("Token string is empty", 401, 4017) + return TokenDetails(token=token_request) + elif token_request is None: + raise AblyAuthException("Token string was None", 401, 40170) + + token_path = f"/keys/{token_request.key_name}/requestToken" + + response = await self.ably.http.post( token_path, headers=auth_headers, - body=signed_token_request, + body=token_request.to_dict(), skip_auth=True ) AblyException.raise_for_response(response) + response_dict = response.to_native() + log.debug("Token: {}".format(str(response_dict.get("token")))) + return TokenDetails.from_dict(response_dict) - access_token = response.json()["access_token"] - log.debug("Token: %s" % str(access_token)) - return TokenDetails.from_dict(access_token) - - def create_token_request(self, key_id=None, key_value=None, - query_time=False, token_params=None): + async def create_token_request(self, token_params: dict | str | None = None, key_name: str | None = None, + key_secret: str | None = None, query_time=None): token_params = token_params or {} + token_request = {} - if token_params.setdefault("id", key_id) != key_id: - raise AblyException("Incompatible key specified", 401, 40102) + key_name = key_name or self.auth_options.key_name + key_secret = key_secret or self.auth_options.key_secret + if not key_name or not key_secret: + log.debug('key_name or key_secret blank') + raise AblyException("No key specified: no means to generate a token", 401, 40101) - if not key_id or not key_value: - log.debug('key_id or key_value blank') - raise AblyException("No key specified", 401, 40101) + token_request['key_name'] = key_name + if token_params.get('timestamp'): + token_request['timestamp'] = token_params['timestamp'] + else: + if query_time is None: + query_time = self.auth_options.query_time - if not token_params.get("timestamp"): if query_time: - token_params["timestamp"] = self.ably.time() / 1000.0 + if self.__time_offset is None: + server_time = await self.ably.time() + local_time = self._timestamp() + self.__time_offset = server_time - local_time + token_request['timestamp'] = server_time + else: + local_time = self._timestamp() + token_request['timestamp'] = local_time + self.__time_offset else: - token_params["timestamp"] = self._timestamp() + token_request['timestamp'] = self._timestamp() - token_params["timestamp"] = int(token_params["timestamp"]) + token_request['timestamp'] = int(token_request['timestamp']) - if token_params.get("capability") is None: - token_params["capability"] = "" - else: - token_params['capability'] = six.text_type( - Capability(token_params["capability"]) - ) + ttl = token_params.get('ttl') + if ttl is not None: + if isinstance(ttl, timedelta): + ttl = ttl.total_seconds() * 1000 + token_request['ttl'] = int(ttl) - if token_params.get("client_id") is None: - token_params["client_id"] = "" + capability = token_params.get('capability') + if capability is not None: + token_request['capability'] = str(Capability(capability)) - if not token_params.get("nonce"): - # Note: There is no expectation that the client - # specifies the nonce; this is done by the library - # However, this can be overridden by the client - # simply for testing purposes - token_params["nonce"] = self._random() + token_request["client_id"] = token_params.get('client_id') or self.client_id - req = { - "id": key_id, - "capability": token_params["capability"], - "client_id": token_params["client_id"], - "timestamp": token_params["timestamp"], - "nonce": token_params["nonce"] - } + # Note: There is no expectation that the client + # specifies the nonce; this is done by the library + # However, this can be overridden by the client + # simply for testing purposes + token_request["nonce"] = token_params.get('nonce') or self._random_nonce() - if token_params.get("ttl"): - req["ttl"] = token_params["ttl"] + token_req = TokenRequest(**token_request) - if not token_params.get("mac"): + if token_params.get('mac') is None: # Note: There is no expectation that the client # specifies the mac; this is done by the library # However, this can be overridden by the client # simply for testing purposes. - sign_text = six.u("\n").join([six.text_type(x) for x in [ - token_params["id"], - token_params.get("ttl", ""), - token_params["capability"], - token_params["client_id"], - "%d" % token_params["timestamp"], - token_params.get("nonce", ""), - "", # to get the trailing new line - ]]) - key_value = key_value.encode('utf8') - sign_text = sign_text.encode('utf8') - log.debug("Key value: %s" % key_value) - log.debug("Sign text: %s" % sign_text) - - mac = hmac.new(key_value, sign_text, hashlib.sha256).digest() - mac = base64.b64encode(mac).decode('utf8') - token_params["mac"] = mac - - req["mac"] = token_params.get("mac") - - signed_request = json.dumps(req) - log.debug("generated signed request: %s", signed_request) - - return signed_request + token_req.sign_request(key_secret.encode('utf8')) + else: + token_req.mac = token_params['mac'] + + return token_req @property def ably(self): return self.__ably @property - def auth_method(self): - return self.__auth_method + def auth_mechanism(self): + return self.__auth_mechanism @property def auth_options(self): @@ -252,21 +317,121 @@ def basic_credentials(self): @property def token_credentials(self): - return self.__token_credentials + if self.__token_details: + token = self.__token_details.token + token_key = base64.b64encode(token.encode('utf-8')) + return token_key.decode('ascii') + + @property + def token_details(self): + return self.__token_details + + @property + def client_id(self): + return self.__client_id - def _get_auth_headers(self): - if self.__auth_method == Auth.Method.BASIC: + @property + def time_offset(self): + return self.__time_offset + + def _configure_client_id(self, new_client_id): + log.debug("Auth._configure_client_id(): new client_id = %s", new_client_id) + original_client_id = self.client_id or self.auth_options.client_id + + # If new client ID from Ably is a wildcard, but preconfigured clientId is set, + # then keep the existing clientId + if original_client_id != '*' and new_client_id == '*': + self.__client_id_validated = True + self.__client_id = original_client_id + return + + # If client_id is defined and not a wildcard, prevent it changing, this is not supported + if original_client_id is not None and original_client_id != '*' and new_client_id != original_client_id: + raise IncompatibleClientIdException( + "Client ID is immutable once configured for a client. " + f"Client ID cannot be changed to '{new_client_id}'", 400, 40102) + + self.__client_id_validated = True + self.__client_id = new_client_id + + def can_assume_client_id(self, assumed_client_id): + original_client_id = self.client_id or self.auth_options.client_id + + if self.__client_id_validated: + return self.client_id == '*' or self.client_id == assumed_client_id + elif original_client_id is None or original_client_id == '*': + return True # client ID is unknown + else: + return original_client_id == assumed_client_id + + async def _get_auth_headers(self): + if self.__auth_mechanism == Auth.Method.BASIC: + # RSA7e2 + if self.client_id: + return { + 'Authorization': f'Basic {self.basic_credentials}', + 'X-Ably-ClientId': base64.b64encode(self.client_id.encode('utf-8')) + } return { - 'Authorization': 'Basic %s' % self.__basic_credentials, + 'Authorization': f'Basic {self.basic_credentials}', } else: + await self.__authorize_when_necessary() return { - 'Authorization': 'Bearer %s' % self.authorise().id, + 'Authorization': f'Bearer {self.token_credentials}', } def _timestamp(self): - """Returns the local time in seconds since the unix epoch""" - return int(time.time()) + """Returns the local time in milliseconds since the unix epoch""" + return int(time.time() * 1000) + + def _random_nonce(self): + return uuid.uuid4().hex[:16] + + async def token_request_from_auth_url(self, method: str, url: str, token_params, + headers, auth_params): + # Extract URL parameters using utility function + clean_url, url_params = extract_url_params(url) + + body = None + params = None + if method == 'GET': + body = {} + # Merge URL params, auth_params, and token_params (later params override earlier ones) + # we do this because httpx version has inconsistency and some versions override query params + # that are specified in url string + params = {**url_params, **auth_params, **token_params} + elif method == 'POST': + if isinstance(auth_params, TokenDetails): + auth_params = auth_params.to_dict() + # For POST, URL params go in query string, auth_params and token_params go in body + params = url_params + body = dict(auth_params, **token_params) + + # Use clean URL for the request + url = clean_url + + from ably.http.http import Response + async with httpx.AsyncClient(http2=True) as client: + resp = await client.request(method=method, url=url, headers=headers, params=params, data=body) + response = Response(resp) - def _random(self): - return "%016d" % rnd.randint(0, 9999999999999999) + AblyException.raise_for_response(response) + + content_type = response.response.headers.get('content-type') + + if not content_type: + raise AblyAuthException("auth_url response missing a content-type header", 401, 40170) + + is_json = "application/json" in content_type + is_text = "application/jwt" in content_type or "text/plain" in content_type + + if is_json: + token_request = response.to_native() + elif is_text: + token_request = response.text + else: + msg = 'auth_url responded with unacceptable content-type ' + content_type + \ + ', should be either text/plain, application/jwt or application/json', + raise AblyAuthException(msg, 401, 40170) + return token_request diff --git a/ably/rest/channel.py b/ably/rest/channel.py index fcfe5763..32cc7e7e 100644 --- a/ably/rest/channel.py +++ b/ably/rest/channel.py @@ -1,137 +1,358 @@ -from __future__ import absolute_import - -import calendar +import base64 +import json import logging - -import six -from six.moves.urllib.parse import urlencode, quote - -from ably.http.httputils import HttpUtils -from ably.http.paginatedresult import PaginatedResult -from ably.types.message import Message, message_response_handler, make_encrypted_message_response_handler -from ably.types.presence import presence_response_handler +import os +from collections import OrderedDict +from typing import Iterator, Optional +from urllib import parse + +import msgpack + +from ably.http.paginatedresult import PaginatedResult, format_params +from ably.rest.annotations import RestAnnotations +from ably.types.channeldetails import ChannelDetails +from ably.types.message import ( + Message, + MessageAction, + MessageVersion, + make_message_response_handler, + make_single_message_response_handler, +) +from ably.types.operations import MessageOperation, PublishResult, UpdateDeleteResult +from ably.types.presence import Presence from ably.util.crypto import get_cipher -from ably.util.exceptions import catch_all - +from ably.util.exceptions import ( + AblyException, + IncompatibleClientIdException, + catch_all, +) log = logging.getLogger(__name__) -class Presence(object): - def __init__(self, channel): - self.__base_path = channel.base_path - self.__binary = not channel.ably.options.use_text_protocol - self.__http = channel.ably.http - - def get(self): - path = '%s/presence' % self.__base_path - headers = HttpUtils.default_get_headers(self.__binary) - response = self.__http.get(path, headers=headers) - return presence_response_handler(response) - - def history(self): - url = '/presence/history' - - headers = HttpUtils.default_get_headers(self.__binary) - response = self.__http.get(url, headers=headers) - # FIXME: Why response is not used here? - return PaginatedResult.paginated_query( - self.__http, - url, - headers, - presence_response_handler - ) - +class Channel: + __annotations: RestAnnotations -class Channel(object): def __init__(self, ably, name, options): self.__ably = ably self.__name = name - self.__options = options - self.__base_path = '/channels/%s/' % quote(name) + self.__base_path = '/channels/{}/'.format(parse.quote_plus(name, safe=':')) + self.__cipher = None + self.options = options self.__presence = Presence(self) - - if options and options.encrypted: - self.__cipher = get_cipher(options.cipher_params) - else: - self.__cipher = None - - def _format_time_param(self, t): - try: - return '%d' % (calendar.timegm(t.utctimetuple()) * 1000) - except: - return '%s' % t - - @catch_all - def presence(self, params=None, timeout=None): - """Returns the presence for this channel""" - params = params or {} - path = '/channels/%s/presence' % self.__name - return self.__ably._get(path, params=params, timeout=timeout).json() + self.__annotations = RestAnnotations(self) @catch_all - def history(self, direction=None, limit=None, start=None, end=None, timeout=None): + async def history(self, direction=None, limit: int = None, start=None, end=None): """Returns the history for this channel""" - params = {} + params = format_params({}, direction=direction, start=start, end=end, limit=limit) + path = self.__base_path + 'messages' + params - if direction: - params['direction'] = '%s' % direction - if limit: - params['limit'] = '%d' % limit - if start: - params['start'] = self._format_time_param(start) - if end: - params['end'] = self._format_time_param(end) + message_handler = make_message_response_handler(self.__cipher) + return await PaginatedResult.paginated_query( + self.ably.http, url=path, response_processor=message_handler) - path = '/channels/%s/history' % self.__name + def __publish_request_body(self, messages): + """ + Helper private method, separated from publish() to test RSL1j + """ + # Idempotent publishing + if self.ably.options.idempotent_rest_publishing: + # RSL1k1 + if all(message.id is None for message in messages): + base_id = base64.b64encode(os.urandom(12)).decode() + for serial, message in enumerate(messages): + message.id = f'{base_id}:{serial}' + + request_body_list = [] + for m in messages: + if m.client_id == '*': + raise IncompatibleClientIdException( + 'Wildcard client_id is reserved and cannot be used when publishing messages', + 400, 40012) + elif m.client_id is not None and not self.ably.auth.can_assume_client_id(m.client_id): + raise IncompatibleClientIdException( + f'Cannot publish with client_id \'{m.client_id}\' as it is incompatible with the ' + f'current configured client_id \'{self.ably.auth.client_id}\'', + 400, 40012) + + if self.cipher: + m.encrypt(self.__cipher) + + request_body_list.append(m) + + request_body = [ + message.as_dict(binary=self.ably.options.use_binary_protocol) + for message in request_body_list] + + if len(request_body) == 1: + request_body = request_body[0] + + return request_body + + async def _publish(self, arg, *args, **kwargs): + if isinstance(arg, Message): + return await self.publish_message(arg, *args, **kwargs) + elif isinstance(arg, list): + return await self.publish_messages(arg, *args, **kwargs) + elif isinstance(arg, str): + return await self.publish_name_data(arg, *args, **kwargs) + else: + raise TypeError(f'Unexpected type {type(arg)}') - if params: - path = path + '?' + urlencode(params) + async def publish_message(self, message, params=None, timeout=None): + return await self.publish_messages([message], params, timeout=timeout) - if self.__cipher: - message_handler = make_encrypted_message_response_handler(self.__cipher) + async def publish_messages(self, messages, params=None, timeout=None): + request_body = self.__publish_request_body(messages) + if not self.ably.options.use_binary_protocol: + request_body = json.dumps(request_body, separators=(',', ':')) else: - message_handler = message_response_handler + request_body = msgpack.packb(request_body, use_bin_type=True) - return PaginatedResult.paginated_query( - self.ably.http, - path, - None, - message_handler - ) + path = self.__base_path + 'messages' + if params: + params = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} + path += '?' + parse.urlencode(params) + response = await self.ably.http.post(path, body=request_body, timeout=timeout) - @catch_all - def publish(self, name, data, timeout=None): + # Parse response to extract serials + result_data = response.to_native() + if result_data and isinstance(result_data, dict): + return PublishResult.from_dict(result_data) + return PublishResult() + + async def publish_name_data(self, name, data, timeout=None): + messages = [Message(name, data)] + return await self.publish_messages(messages, timeout=timeout) + + async def publish(self, *args, **kwargs): """Publishes a message on this channel. :Parameters: - - `name`: the name for this message - - `data`: the data for this message + - `name`: the name for this message. + - `data`: the data for this message. + - `messages`: list of `Message` objects to be published. + - `message`: a single `Message` objet to be published + + :attention: You can publish using `name` and `data` OR `messages` OR + `message`, never all three. """ + # For backwards compatibility + if len(args) == 0: + if len(kwargs) == 0: + return await self.publish_name_data(None, None) + + if 'name' in kwargs or 'data' in kwargs: + name = kwargs.pop('name', None) + data = kwargs.pop('data', None) + return await self.publish_name_data(name, data, **kwargs) + + if 'messages' in kwargs: + messages = kwargs.pop('messages') + return await self.publish_messages(messages, **kwargs) + + return await self._publish(*args, **kwargs) + + async def status(self): + """Retrieves current channel active status with no. of publishers, subscribers, presence_members etc""" + + path = f'/channels/{self.name}' + response = await self.ably.http.get(path) + obj = response.to_native() + return ChannelDetails.from_dict(obj) + + async def _send_update( + self, + message: Message, + action: MessageAction, + operation: Optional[MessageOperation] = None, + params: Optional[dict] = None, + ): + """Internal method to send update/delete/append operations.""" + if not message.serial: + raise AblyException( + "Message serial is required for update/delete/append operations", + status_code=400, + code=40003, + ) + + if not operation: + version = None + else: + version = MessageVersion( + client_id=operation.client_id, + description=operation.description, + metadata=operation.metadata + ) + + # Create a new message with the operation fields + update_message = Message( + name=message.name, + data=message.data, + client_id=message.client_id, + serial=message.serial, + action=action, + version=version, + extras=message.extras, + annotations=message.annotations, + ) - message = Message(name, data) + # Encrypt if needed + if self.cipher: + update_message.encrypt(self.__cipher) - if self.encrypted: - message.encrypt(self.__cipher) + # Serialize the message + request_body = update_message.as_dict(binary=self.ably.options.use_binary_protocol) - if self.ably.options.use_text_protocol: - request_body = message.as_json() + if not self.ably.options.use_binary_protocol: + request_body = json.dumps(request_body, separators=(',', ':')) else: - request_body = message.as_thrift() + request_body = msgpack.packb(request_body, use_bin_type=True) + + # Build path with params + path = self.__base_path + 'messages/{}'.format(parse.quote_plus(message.serial, safe=':')) + if params: + params = {k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()} + path += '?' + parse.urlencode(params) + + # Send request + response = await self.ably.http.patch(path, body=request_body) + + # Parse response + result_data = response.to_native() + if result_data and isinstance(result_data, dict): + return UpdateDeleteResult.from_dict(result_data) + return UpdateDeleteResult() + + async def update_message(self, message: Message, operation: MessageOperation = None, params: dict = None): + """Updates an existing message on this channel. + + Parameters: + - message: Message object to update. Must have a serial field. + - operation: Optional MessageOperation containing description and metadata for the update. + - params: Optional dict of query parameters. + + Returns: + - UpdateDeleteResult containing the version serial of the updated message. + """ + return await self._send_update(message, MessageAction.MESSAGE_UPDATE, operation, params) + + async def delete_message(self, message: Message, operation: MessageOperation = None, params: dict = None): + """Deletes a message on this channel. + + Parameters: + - message: Message object to delete. Must have a serial field. + - operation: Optional MessageOperation containing description and metadata for the delete. + - params: Optional dict of query parameters. + + Returns: + - UpdateDeleteResult containing the version serial of the deleted message. + """ + return await self._send_update(message, MessageAction.MESSAGE_DELETE, operation, params) + + async def append_message(self, message: Message, operation: MessageOperation = None, params: dict = None): + """Appends data to an existing message on this channel. + + Parameters: + - message: Message object with data to append. Must have a serial field. + - operation: Optional MessageOperation containing description and metadata for the append. + - params: Optional dict of query parameters. + + Returns: + - UpdateDeleteResult containing the version serial of the appended message. + """ + return await self._send_update(message, MessageAction.MESSAGE_APPEND, operation, params) + + async def get_message(self, serial_or_message, timeout=None): + """Retrieves a single message by its serial. + + Parameters: + - serial_or_message: Either a string serial or a Message object with a serial field. - path = '/channels/%s/publish' % self.__name - headers = HttpUtils.default_post_headers(not self.ably.options.use_text_protocol) - return self.ably.http.post( - path, - headers=headers, - body=request_body, - timeout=timeout - ).json() + Returns: + - Message object for the requested serial. + + Raises: + - AblyException: If the serial is missing or the message cannot be retrieved. + """ + # Extract serial from string or Message object + if isinstance(serial_or_message, str): + serial = serial_or_message + elif isinstance(serial_or_message, Message): + serial = serial_or_message.serial + else: + serial = None + + if not serial: + raise AblyException( + 'This message lacks a serial. Make sure you have enabled "Message annotations, ' + 'updates, and deletes" in channel settings on your dashboard.', + status_code=400, + code=40003, + ) + + # Build the path + path = self.__base_path + 'messages/' + parse.quote_plus(serial, safe=':') + + # Make the request + response = await self.ably.http.get(path, timeout=timeout) + + # Create Message from the response + message_handler = make_single_message_response_handler(self.__cipher) + return message_handler(response) + + async def get_message_versions(self, serial_or_message, params=None): + """Retrieves version history for a message. + + Parameters: + - serial_or_message: Either a string serial or a Message object with a serial field. + - params: Optional dict of query parameters for pagination (e.g., limit, start, end, direction). + + Returns: + - PaginatedResult containing Message objects representing each version. + + Raises: + - AblyException: If the serial is missing or versions cannot be retrieved. + """ + # Extract serial from string or Message object + if isinstance(serial_or_message, str): + serial = serial_or_message + elif isinstance(serial_or_message, Message): + serial = serial_or_message.serial + else: + serial = None + + if not serial: + raise AblyException( + 'This message lacks a serial. Make sure you have enabled "Message annotations, ' + 'updates, and deletes" in channel settings on your dashboard.', + status_code=400, + code=40003, + ) + + # Build the path + params_str = format_params({}, **params) if params else '' + path = self.__base_path + 'messages/' + parse.quote_plus(serial, safe=':') + '/versions' + params_str + + # Create message handler for decoding + message_handler = make_message_response_handler(self.__cipher) + + # Return paginated result + return await PaginatedResult.paginated_query( + self.ably.http, + url=path, + response_processor=message_handler + ) @property def ably(self): return self.__ably + @property + def name(self): + return self.__name + @property def base_path(self): return self.__base_path @@ -140,32 +361,79 @@ def base_path(self): def cipher(self): return self.__cipher - @property - def encrypted(self): - return self.options and self.options.encrypted - @property def options(self): return self.__options + @property + def presence(self): + return self.__presence + + @property + def annotations(self) -> RestAnnotations: + return self.__annotations -class Channels(object): + @options.setter + def options(self, options): + self.__options = options + + if options and 'cipher' in options: + cipher = options.get('cipher') + if cipher is not None: + cipher = get_cipher(cipher) + self.__cipher = cipher + + +class Channels: def __init__(self, rest): self.__ably = rest - self.__attached = {} + self.__all: dict = OrderedDict() - def get(self, name, options=None): - if isinstance(name, six.binary_type): + def get(self, name, **kwargs): + if isinstance(name, bytes): name = name.decode('ascii') - if name not in self.__attached: - self.__attached[name] = Channel(self.__ably, name, options) - return self.__attached[name] + + if name not in self.__all: + result = self.__all[name] = Channel(self.__ably, name, kwargs) + else: + result = self.__all[name] + if len(kwargs) != 0: + result.options = kwargs + + return result def __getitem__(self, key): return self.get(key) def __getattr__(self, name): - try: - return getattr(super(Channels, self), name) - except AttributeError: - return self.get(name) + return self.get(name) + + def __contains__(self, item): + if isinstance(item, Channel): + name = item.name + elif isinstance(item, bytes): + name = item.decode('ascii') + else: + name = item + + return name in self.__all + + def __iter__(self) -> Iterator[str]: + return iter(self.__all.values()) + + # RSN4 + def release(self, name: str): + """Releases a Channel object, deleting it, and enabling it to be garbage collected. + If the channel does not exist, nothing happens. + + It also removes any listeners associated with the channel. + + Parameters + ---------- + name: str + Channel name + """ + + if name not in self.__all: + return + del self.__all[name] diff --git a/ably/rest/push.py b/ably/rest/push.py new file mode 100644 index 00000000..f99b2b1d --- /dev/null +++ b/ably/rest/push.py @@ -0,0 +1,193 @@ +from typing import Optional + +from ably.http.paginatedresult import PaginatedResult, format_params +from ably.types.channelsubscription import ( + PushChannelSubscription, + channel_subscriptions_response_processor, + channels_response_processor, +) +from ably.types.device import DeviceDetails, device_details_response_processor + + +class Push: + + def __init__(self, ably): + self.__ably = ably + self.__admin = PushAdmin(ably) + + @property + def admin(self): + return self.__admin + + +class PushAdmin: + + def __init__(self, ably): + self.__ably = ably + self.__device_registrations = PushDeviceRegistrations(ably) + self.__channel_subscriptions = PushChannelSubscriptions(ably) + + @property + def ably(self): + return self.__ably + + @property + def device_registrations(self): + return self.__device_registrations + + @property + def channel_subscriptions(self): + return self.__channel_subscriptions + + async def publish(self, recipient: dict, data: dict, timeout: Optional[float] = None): + """Publish a push notification to a single device. + + :Parameters: + - `recipient`: the recipient of the notification + - `data`: the data of the notification + """ + if not isinstance(recipient, dict): + raise TypeError(f'Unexpected {type(recipient)} recipient, expected a dict') + + if not isinstance(data, dict): + raise TypeError(f'Unexpected {type(data)} data, expected a dict') + + if not recipient: + raise ValueError('recipient is empty') + + if not data: + raise ValueError('data is empty') + + body = data.copy() + body.update({'recipient': recipient}) + await self.ably.http.post('/push/publish', body=body, timeout=timeout) + + +class PushDeviceRegistrations: + + def __init__(self, ably): + self.__ably = ably + + @property + def ably(self): + return self.__ably + + async def get(self, device_id: str): + """Returns a DeviceDetails object if the device id is found or results + in a not found error if the device cannot be found. + + :Parameters: + - `device_id`: the id of the device + """ + path = f'/push/deviceRegistrations/{device_id}' + response = await self.ably.http.get(path) + obj = response.to_native() + return DeviceDetails.from_dict(obj) + + async def list(self, **params): + """Returns a PaginatedResult object with the list of DeviceDetails + objects, filtered by the given parameters. + + :Parameters: + - `**params`: the parameters used to filter the list + """ + path = '/push/deviceRegistrations' + format_params(params) + return await PaginatedResult.paginated_query( + self.ably.http, url=path, + response_processor=device_details_response_processor) + + async def save(self, device: dict): + """Creates or updates the device. Returns a DeviceDetails object. + + :Parameters: + - `device`: a dictionary with the device information + """ + device_details = DeviceDetails.factory(device) + path = f'/push/deviceRegistrations/{device_details.id}' + body = device_details.as_dict() + response = await self.ably.http.put(path, body=body) + obj = response.to_native() + return DeviceDetails.from_dict(obj) + + async def remove(self, device_id: str): + """Deletes the registered device identified by the given device id. + + :Parameters: + - `device_id`: the id of the device + """ + path = f'/push/deviceRegistrations/{device_id}' + return await self.ably.http.delete(path) + + async def remove_where(self, **params): + """Deletes the registered devices identified by the given parameters. + + :Parameters: + - `**params`: the parameters that identify the devices to remove + """ + path = '/push/deviceRegistrations' + format_params(params) + return await self.ably.http.delete(path) + + +class PushChannelSubscriptions: + + def __init__(self, ably): + self.__ably = ably + + @property + def ably(self): + return self.__ably + + async def list(self, **params): + """Returns a PaginatedResult object with the list of + PushChannelSubscription objects, filtered by the given parameters. + + :Parameters: + - `**params`: the parameters used to filter the list + """ + path = '/push/channelSubscriptions' + format_params(params) + return await PaginatedResult.paginated_query(self.ably.http, url=path, + response_processor=channel_subscriptions_response_processor) + + async def list_channels(self, **params): + """Returns a PaginatedResult object with the list of + PushChannelSubscription objects, filtered by the given parameters. + + :Parameters: + - `**params`: the parameters used to filter the list + """ + path = '/push/channels' + format_params(params) + return await PaginatedResult.paginated_query(self.ably.http, url=path, + response_processor=channels_response_processor) + + async def save(self, subscription: dict): + """Creates or updates the subscription. Returns a + PushChannelSubscription object. + + :Parameters: + - `subscription`: a dictionary with the subscription information + """ + subscription = PushChannelSubscription.factory(subscription) + path = '/push/channelSubscriptions' + body = subscription.as_dict() + response = await self.ably.http.post(path, body=body) + obj = response.to_native() + return PushChannelSubscription.from_dict(obj) + + async def remove(self, subscription: dict): + """Deletes the given subscription. + + :Parameters: + - `subscription`: the subscription object to remove + """ + subscription = PushChannelSubscription.factory(subscription) + params = subscription.as_dict() + return await self.remove_where(**params) + + async def remove_where(self, **params): + """Deletes the subscriptions identified by the given parameters. + + :Parameters: + - `**params`: the parameters that identify the subscriptions to remove + """ + path = '/push/channelSubscriptions' + format_params(**params) + return await self.ably.http.delete(path) diff --git a/ably/rest/rest.py b/ably/rest/rest.py index 33ee48a2..bc84e638 100644 --- a/ably/rest/rest.py +++ b/ably/rest/rest.py @@ -1,24 +1,25 @@ -from __future__ import absolute_import - -import calendar import logging - -from six.moves.urllib.parse import urlencode +from typing import Optional +from urllib.parse import urlencode from ably.http.http import Http -from ably.http.paginatedresult import PaginatedResult +from ably.http.paginatedresult import HttpPaginatedResponse, PaginatedResult, format_params from ably.rest.auth import Auth from ably.rest.channel import Channels -from ably.util.exceptions import AblyException, catch_all +from ably.rest.push import Push from ably.types.options import Options from ably.types.stats import stats_response_processor +from ably.types.tokendetails import TokenDetails +from ably.util.exceptions import AblyException, catch_all log = logging.getLogger(__name__) -class AblyRest(object): +class AblyRest: """Ably Rest Client""" - def __init__(self, options=None): + + def __init__(self, key: Optional[str] = None, token: Optional[str] = None, + token_details: Optional[TokenDetails] = None, **kwargs): """Create an AblyRest instance. :Parameters: @@ -26,12 +27,19 @@ def __init__(self, options=None): - `key`: a valid key string **Or** - - `key_id`: Your Ably key id - - `key_value`: Your Ably key value + - `token`: a valid token string + - `token_details`: an instance of TokenDetails class **Optional Parameters** - `client_id`: Undocumented - - `host`: The host to connect to. Defaults to rest.ably.io + - `endpoint`: Endpoint specifies either a routing policy name or + fully qualified domain name to connect to Ably. + - `rest_host`: Deprecated: this property is deprecated and will + be removed in a future version. The host to connect to. + Defaults to rest.ably.io + - `environment`: Deprecated: this property is deprecated and + will be removed in a future version. The environment to use. + Defaults to 'production' - `port`: The port to connect to. Defaults to 80 - `tls_port`: The tls_port to connect to. Defaults to 443 - `tls`: Specifies whether the client should use TLS. Defaults @@ -41,15 +49,26 @@ def __init__(self, options=None): - `auth_url`: Undocumented - `keep_alive`: use persistent connections. Defaults to True """ - - options = options or Options() - - self.__client_id = options.client_id - - # if self.__keep_alive: - # self.__session = requests.Session() - # else: - # self.__session = None + 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") + if key is not None: + options = Options(key=key, **kwargs) + elif token is not None: + options = Options(auth_token=token, **kwargs) + elif token_details is not None: + if not isinstance(token_details, TokenDetails): + raise ValueError("token_details must be an instance of TokenDetails") + options = Options(token_details=token_details, **kwargs) + elif not ('auth_callback' in kwargs or 'auth_url' in kwargs or + # and don't have both key_name and key_secret + ('key_name' in kwargs and 'key_secret' in kwargs)): + raise ValueError("key is missing. Either an API key, token, or token auth method must be provided") + else: + options = Options(**kwargs) + + if not hasattr(self, '_is_realtime'): + self._is_realtime = False self.__http = Http(self, options) self.__auth = Auth(self, options) @@ -57,51 +76,29 @@ def __init__(self, options=None): self.__channels = Channels(self) self.__options = options + self.__push = Push(self) - @classmethod - def with_key(cls, key): - return cls(Options.with_key(key)) - - def _format_time_param(self, t): - try: - return '%d' % (calendar.timegm(t.utctimetuple()) * 1000) - except: - return '%s' % t + async def __aenter__(self): + return self @catch_all - def stats(self, direction=None, start=None, end=None, params=None, - limit=None, paginated=None, by=None, timeout=None): + async def stats(self, direction: Optional[str] = None, start=None, end=None, params: Optional[dict] = None, + limit: Optional[int] = None, paginated=None, unit=None, timeout=None): """Returns the stats for this application""" - params = params or {} - - if direction: - params["direction"] = "%s" % direction - if start: - params["start"] = self._format_time_param(start) - if end: - params["end"] = self._format_time_param(end) - if limit: - params["limit"] = "%d" % limit - if by: - params["by"] = "%s" % by - - url = '/stats' - if params: - url += '?' + urlencode(params) - - return PaginatedResult.paginated_query(self.http, - url, None, - stats_response_processor) + formatted_params = format_params(params, direction=direction, start=start, end=end, limit=limit, unit=unit) + url = '/stats' + formatted_params + return await PaginatedResult.paginated_query( + self.http, url=url, response_processor=stats_response_processor) @catch_all - def time(self, timeout=None): + async def time(self, timeout: Optional[float] = None) -> float: """Returns the current server time in ms since the unix epoch""" - r = self.http.get('/time', skip_auth=True, timeout=timeout) + r = await self.http.get('/time', skip_auth=True, timeout=timeout) AblyException.raise_for_response(r) - return r.json()[0] + return r.to_native()[0] @property - def client_id(self): + def client_id(self) -> Optional[str]: return self.options.client_id @property @@ -120,3 +117,35 @@ def http(self): @property def options(self): return self.__options + + @property + def push(self): + return self.__push + + async def request(self, method: str, path: str, version: str, params: + Optional[dict] = None, body=None, headers=None): + if version is None: + raise AblyException("No version parameter", 400, 40000) + + url = path + if params: + url += '?' + urlencode(params) + + def response_processor(response): + items = response.to_native() + if not items: + return [] + if type(items) is not list: + items = [items] + return items + + return await HttpPaginatedResponse.paginated_query( + self.http, method, url, version=version, body=body, headers=headers, + response_processor=response_processor, + raise_on_error=False) + + async def __aexit__(self, *excinfo): + await self.close() + + async def close(self): + await self.http.close() diff --git a/ably/scripts/__init__.py b/ably/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ably/scripts/unasync.py b/ably/scripts/unasync.py new file mode 100644 index 00000000..d13e20f2 --- /dev/null +++ b/ably/scripts/unasync.py @@ -0,0 +1,294 @@ +import glob +import os +import tokenize as std_tokenize + +import tokenize_rt + +rename_classes = [ + "AblyRest", + "Push", + "PushAdmin", + "Channel", + "Channels", + "Auth", + "Http", + "PaginatedResult", + "HttpPaginatedResponse" +] + +_TOKEN_REPLACE = { + "__aenter__": "__enter__", + "__aexit__": "__exit__", + "__aiter__": "__iter__", + "__anext__": "__next__", + "asynccontextmanager": "contextmanager", + "AsyncIterable": "Iterable", + "AsyncIterator": "Iterator", + "AsyncGenerator": "Generator", + "StopAsyncIteration": "StopIteration", +} + +_IMPORTS_REPLACE = { +} + +_STRING_REPLACE = { +} + +_CLASS_RENAME = { +} + + +class Rule: + """A single set of rules for 'unasync'ing file(s)""" + + def __init__(self, fromdir, todir, output_file_prefix="", additional_replacements=None): + self.fromdir = fromdir.replace("/", os.sep) + self.todir = todir.replace("/", os.sep) + self.ouput_file_prefix = output_file_prefix + + # Add any additional user-defined token replacements to our list. + self.token_replacements = _TOKEN_REPLACE.copy() + for key, val in (additional_replacements or {}).items(): + self.token_replacements[key] = val + + def _match(self, filepath): + """Determines if a Rule matches a given filepath and if so + returns a higher comparable value if the match is more specific. + """ + file_segments = [x for x in filepath.split(os.sep) if x] + from_segments = [x for x in self.fromdir.split(os.sep) if x] + len_from_segments = len(from_segments) + + if len_from_segments > len(file_segments): + return False + + for i in range(len(file_segments) - len_from_segments + 1): + if file_segments[i: i + len_from_segments] == from_segments: + return len_from_segments, i + + return False + + def _unasync_file(self, filepath): + with open(filepath, "rb") as f: + encoding, _ = std_tokenize.detect_encoding(f.readline) + + with open(filepath, encoding=encoding) as f: + tokens = tokenize_rt.src_to_tokens(f.read()) + tokens = self._unasync_tokens(tokens) + result = tokenize_rt.tokens_to_src(tokens) + new_file_path = os.path.join(os.path.dirname(filepath), + self.ouput_file_prefix + os.path.basename(filepath)) + outfilepath = new_file_path.replace(self.fromdir, self.todir) + os.makedirs(os.path.dirname(outfilepath), exist_ok=True) + with open(outfilepath, "wb") as f: + f.write(result.encode(encoding)) + + def _unasync_tokens(self, tokens: list): + new_tokens = [] + token_counter = 0 + async_await_block_started = False + async_await_char_diff = -6 # (len("async") or len("await") is 6) + async_await_offset = 0 + + renamed_class_call_started = False + renamed_class_char_diff = 0 + renamed_class_offset = 0 + + while token_counter < len(tokens): + token = tokens[token_counter] + + if async_await_block_started or renamed_class_call_started: + # Fix indentation issues for async/await fn definition/call + if token.src == '\n': + new_tokens.append(token) + token_counter = token_counter + 1 + next_newline_token = tokens[token_counter] + new_tab_src = next_newline_token.src + + if (renamed_class_call_started and + tokens[token_counter + 1].utf8_byte_offset >= renamed_class_offset): + if renamed_class_char_diff < 0: + new_tab_src = new_tab_src[:renamed_class_char_diff] + else: + new_tab_src = new_tab_src + renamed_class_char_diff * " " + + if (async_await_block_started and len(next_newline_token.src) >= 6 and + tokens[token_counter + 1].utf8_byte_offset >= async_await_offset + 6): + new_tab_src = new_tab_src[:async_await_char_diff] # remove last 6 white spaces + + next_newline_token = next_newline_token._replace(src=new_tab_src) + new_tokens.append(next_newline_token) + token_counter = token_counter + 1 + continue + + if token.src == ')': + async_await_block_started = False + async_await_offset = 0 + renamed_class_call_started = False + renamed_class_char_diff = 0 + + if token.src in ["async", "await"]: + # When removing async or await, we want to skip the following whitespace + token_counter = token_counter + 2 + is_async_start = tokens[token_counter].src == 'def' + is_await_start = False + for i in range(token_counter, token_counter + 6): + if tokens[i].src == '(': + is_await_start = True + break + if is_async_start or is_await_start: + # Fix indentation issues for async/await fn definition/call + async_await_offset = token.utf8_byte_offset + async_await_block_started = True + continue + + elif token.name == "NAME": + if token.src == "from": + if tokens[token_counter + 1].src == " ": + token_counter = self._replace_import(tokens, token_counter, new_tokens) + continue + else: + token_new_src = self._unasync_name(token.src) + if token.src == token_new_src: + token_new_src = self._class_rename(token.src) + if token.src != token_new_src: + renamed_class_offset = token.utf8_byte_offset + renamed_class_char_diff = len(token_new_src) - len(token.src) + for i in range(token_counter, token_counter + 6): + if tokens[i].src == '(': + renamed_class_call_started = True + break + + token = token._replace(src=token_new_src) + elif token.name == "STRING": + src_token = token.src.replace("'", "") + if _STRING_REPLACE.get(src_token) is not None: + new_token = f"'{_STRING_REPLACE[src_token]}'" + token = token._replace(src=new_token) + else: + src_token = token.src.replace("\"", "") + if _STRING_REPLACE.get(src_token) is not None: + new_token = f"\"{_STRING_REPLACE[src_token]}\"" + token = token._replace(src=new_token) + + new_tokens.append(token) + token_counter = token_counter + 1 + + return new_tokens + + def _replace_import(self, tokens, token_counter, new_tokens: list): + new_tokens.append(tokens[token_counter]) + new_tokens.append(tokens[token_counter + 1]) + + full_lib_name = '' + lib_name_counter = token_counter + 2 + if len(_IMPORTS_REPLACE.keys()) == 0: + return lib_name_counter + + while True: + if tokens[lib_name_counter].src == " ": + break + full_lib_name = full_lib_name + tokens[lib_name_counter].src + lib_name_counter = lib_name_counter + 1 + + for key, value in _IMPORTS_REPLACE.items(): + if key in full_lib_name: + updated_lib_name = full_lib_name.replace(key, value) + for lib_name_part in updated_lib_name.split("."): + lib_name_part = self._class_rename(lib_name_part) + new_tokens.append(tokenize_rt.Token("NAME", lib_name_part)) + new_tokens.append(tokenize_rt.Token("OP", ".")) + new_tokens.pop() + return lib_name_counter + + lib_name_counter = token_counter + 2 + return lib_name_counter + + def _class_rename(self, name): + if name in _CLASS_RENAME: + return _CLASS_RENAME[name] + return name + + def _unasync_name(self, name): + if name in self.token_replacements: + return self.token_replacements[name] + return name + + +def unasync_files(fpath_list, rules): + for f in fpath_list: + found_rule = None + found_weight = None + + for rule in rules: + weight = rule._match(f) + if weight and (found_weight is None or weight > found_weight): + found_rule = rule + found_weight = weight + + if found_rule: + found_rule._unasync_file(f) + + +def find_files(dir_path, file_name_regex): + return glob.glob(os.path.join(dir_path, "**", file_name_regex), recursive=True) + + +def run(): + # Source files ========================================== + + _TOKEN_REPLACE["AsyncClient"] = "Client" + _TOKEN_REPLACE["aclose"] = "close" + _TOKEN_REPLACE["assert_waiter"] = "assert_waiter_sync" + + _IMPORTS_REPLACE["ably"] = "ably.sync" + + # here... + for class_name in rename_classes: + _CLASS_RENAME[class_name] = f"{class_name}Sync" + + _STRING_REPLACE["Auth"] = "AuthSync" + + src_dir_path = os.path.join(os.getcwd(), "ably") + dest_dir_path = os.path.join(os.getcwd(), "ably", "sync") + + relevant_src_files = (set(find_files(src_dir_path, "*.py")) - + set(find_files(dest_dir_path, "*.py"))) + + unasync_files(list(relevant_src_files), [Rule(fromdir=src_dir_path, todir=dest_dir_path)]) + + # Test files ============================================== + + _TOKEN_REPLACE["asyncSetUp"] = "setUp" + _TOKEN_REPLACE["asyncTearDown"] = "tearDown" + _TOKEN_REPLACE["AsyncMock"] = "Mock" + + _TOKEN_REPLACE["_Channel__publish_request_body"] = "_ChannelSync__publish_request_body" + _TOKEN_REPLACE["_Http__client"] = "_HttpSync__client" + + _IMPORTS_REPLACE["test.ably"] = "test.ably.sync" + + _STRING_REPLACE['/../assets/testAppSpec.json'] = '/../../assets/testAppSpec.json' + _STRING_REPLACE['ably.rest.auth.Auth.request_token'] = 'ably.sync.rest.auth.AuthSync.request_token' + _STRING_REPLACE['ably.rest.auth.TokenRequest'] = 'ably.sync.rest.auth.TokenRequest' + _STRING_REPLACE['ably.rest.rest.Http.post'] = 'ably.sync.rest.rest.HttpSync.post' + _STRING_REPLACE['httpx.AsyncClient.send'] = 'httpx.Client.send' + _STRING_REPLACE['ably.util.exceptions.AblyException.raise_for_response'] = \ + 'ably.sync.util.exceptions.AblyException.raise_for_response' + _STRING_REPLACE['ably.rest.rest.AblyRest.time'] = 'ably.sync.rest.rest.AblyRestSync.time' + _STRING_REPLACE['ably.rest.auth.Auth._timestamp'] = 'ably.sync.rest.auth.AuthSync._timestamp' + + # round 1 + src_dir_path = os.path.join(os.getcwd(), "test", "ably") + dest_dir_path = os.path.join(os.getcwd(), "test", "ably", "sync") + src_files = [os.path.join(os.getcwd(), "test", "ably", "testapp.py"), + os.path.join(os.getcwd(), "test", "ably", "utils.py")] + + unasync_files(src_files, [Rule(fromdir=src_dir_path, todir=dest_dir_path)]) + + # round 2 + src_dir_path = os.path.join(os.getcwd(), "test", "ably", "rest") + dest_dir_path = os.path.join(os.getcwd(), "test", "ably", "sync", "rest") + src_files = find_files(src_dir_path, "*.py") + + unasync_files(src_files, [Rule(fromdir=src_dir_path, todir=dest_dir_path, output_file_prefix="sync_")]) diff --git a/ably/transport/defaults.py b/ably/transport/defaults.py index 32c750e8..40d73e08 100644 --- a/ably/transport/defaults.py +++ b/ably/transport/defaults.py @@ -1,18 +1,8 @@ -from __future__ import absolute_import +class Defaults: + protocol_version = "5" - -class Defaults(object): - protocol_version = 1 - fallback_hosts = [ - "A.ably-realtime.com", - "B.ably-realtime.com", - "C.ably-realtime.com", - "D.ably-realtime.com", - "E.ably-realtime.com", - ] - - host = "rest.ably.io" - ws_host = "realtime.ably.io" + connectivity_check_url = "https://internet-up.ably-realtime.com/is-the-internet-up.txt" + endpoint = 'main' port = 80 tls_port = 443 @@ -21,15 +11,17 @@ class Defaults(object): suspended_timeout = 60000 comet_recv_timeout = 90000 comet_send_timeout = 10000 + realtime_request_timeout = 10000 + channel_retry_timeout = 15000 + disconnected_retry_timeout = 15000 + connection_state_ttl = 120000 + suspended_retry_timeout = 30000 transports = [] # ["web_socket", "comet"] - @staticmethod - def get_host(options): - if options.host: - return options.host - else: - return Defaults.host + http_max_retry_count = 3 + + fallback_retry_timeout = 600000 # 10min @staticmethod def get_port(options): @@ -44,16 +36,42 @@ def get_port(options): else: return Defaults.port - @staticmethod - def get_fallback_hosts(options): - if options.host: - return [] - else: - return Defaults.fallback_hosts - @staticmethod def get_scheme(options): if options.tls: return "https" else: return "http" + + @staticmethod + def get_hostname(endpoint): + if "." in endpoint or "::" in endpoint or "localhost" in endpoint: + return endpoint + + if endpoint.startswith("nonprod:"): + return endpoint[len("nonprod:"):] + ".realtime.ably-nonprod.net" + + return endpoint + ".realtime.ably.net" + + @staticmethod + def get_fallback_hosts(endpoint="main"): + if "." in endpoint or "::" in endpoint or "localhost" in endpoint: + return [] + + if endpoint.startswith("nonprod:"): + root = endpoint.replace("nonprod:", "") + return [ + root + ".a.fallback.ably-realtime-nonprod.com", + root + ".b.fallback.ably-realtime-nonprod.com", + root + ".c.fallback.ably-realtime-nonprod.com", + root + ".d.fallback.ably-realtime-nonprod.com", + root + ".e.fallback.ably-realtime-nonprod.com", + ] + + return [ + endpoint + ".a.fallback.ably-realtime.com", + endpoint + ".b.fallback.ably-realtime.com", + endpoint + ".c.fallback.ably-realtime.com", + endpoint + ".d.fallback.ably-realtime.com", + endpoint + ".e.fallback.ably-realtime.com", + ] diff --git a/ably/transport/websockettransport.py b/ably/transport/websockettransport.py new file mode 100644 index 00000000..ad4f2856 --- /dev/null +++ b/ably/transport/websockettransport.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import socket +import urllib.parse +from enum import IntEnum +from typing import TYPE_CHECKING + +import msgpack + +from ably.http.httputils import HttpUtils +from ably.types.connectiondetails import ConnectionDetails +from ably.types.operations import PublishResult +from ably.util.eventemitter import EventEmitter +from ably.util.exceptions import AblyException +from ably.util.helper import Timer, unix_time_ms + +try: + # websockets 15+ preferred imports + from websockets import ClientConnection as WebSocketClientProtocol + from websockets import connect as ws_connect +except ImportError: + # websockets 14 and earlier fallback + from websockets.client import WebSocketClientProtocol + from websockets.client import connect as ws_connect + +from websockets.exceptions import ConnectionClosedOK, WebSocketException + +if TYPE_CHECKING: + from ably.realtime.connection import ConnectionManager + +log = logging.getLogger(__name__) + + +class ProtocolMessageAction(IntEnum): + HEARTBEAT = 0 + ACK = 1 + NACK = 2 + CONNECT = 3 + CONNECTED = 4 + DISCONNECT = 5 + DISCONNECTED = 6 + CLOSE = 7 + CLOSED = 8 + ERROR = 9 + ATTACH = 10 + ATTACHED = 11 + DETACH = 12 + DETACHED = 13 + PRESENCE = 14 + MESSAGE = 15 + SYNC = 16 + AUTH = 17 + ACTIVATE = 18 + OBJECT = 19 + OBJECT_SYNC = 20 + ANNOTATION = 21 + + +class WebSocketTransport(EventEmitter): + def __init__(self, connection_manager: ConnectionManager, host: str, params: dict): + self.websocket: WebSocketClientProtocol | None = None + self.read_loop: asyncio.Task | None = None + self.connect_task: asyncio.Task | None = None + self.ws_connect_task: asyncio.Task | None = None + self.connection_manager = connection_manager + self.options = self.connection_manager.options + self.is_connected = False + self.idle_timer = None + self.last_activity = None + self.max_idle_interval = None + self.is_disposed = False + self.host = host + self.params = params + self.format = params.get('format', 'json') + super().__init__() + + def connect(self): + headers = HttpUtils.default_headers() + query_params = urllib.parse.urlencode(self.params) + scheme = 'wss' if self.options.tls else 'ws' + ws_url = f'{scheme}://{self.host}?{query_params}' + log.info(f'connect(): attempting to connect to {ws_url}') + self.ws_connect_task = asyncio.create_task(self.ws_connect(ws_url, headers)) + self.ws_connect_task.add_done_callback(self.on_ws_connect_done) + + def on_ws_connect_done(self, task: asyncio.Task): + try: + exception = task.exception() + except asyncio.CancelledError as e: + exception = e + if exception is None or isinstance(exception, ConnectionClosedOK): + return + log.info( + f'WebSocketTransport.on_ws_connect_done(): exception = {exception}' + ) + + async def ws_connect(self, ws_url, headers): + try: + # Use additional_headers for websockets 15+, fallback to extra_headers for older versions + try: + async with ws_connect(ws_url, additional_headers=headers) as websocket: + await self._handle_websocket_connection(ws_url, websocket) + except TypeError: + # Fallback for websockets 14 and earlier + async with ws_connect(ws_url, extra_headers=headers) as websocket: + await self._handle_websocket_connection(ws_url, websocket) + except (WebSocketException, socket.gaierror) as e: + exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000) + log.exception(f'WebSocketTransport.ws_connect(): Error opening websocket connection: {exception}') + self._emit('failed', exception) + raise exception from e + + async def _handle_websocket_connection(self, ws_url, websocket): + log.info(f'ws_connect(): connection established to {ws_url}') + self._emit('connected') + self.websocket = websocket + self.read_loop = self.connection_manager.options.loop.create_task(self.ws_read_loop()) + self.read_loop.add_done_callback(self.on_read_loop_done) + try: + await self.read_loop + except WebSocketException as err: + if not self.is_disposed: + await self.dispose() + self.connection_manager.deactivate_transport(err) + else: + # Read loop exited normally (e.g., server sent normal WS close frame) + if not self.is_disposed: + await self.dispose() + self.connection_manager.deactivate_transport() + + async def on_protocol_message(self, msg): + self.on_activity() + log.debug(f'WebSocketTransport.on_protocol_message(): received protocol message: {msg}') + action = msg.get('action') + if action == ProtocolMessageAction.CONNECTED: + connection_id = msg.get('connectionId') + connection_details = ConnectionDetails.from_dict(msg.get('connectionDetails')) + + error = msg.get('error') + exception = None + if error: + exception = AblyException.from_dict(error) + + max_idle_interval = connection_details.max_idle_interval + if max_idle_interval: + self.max_idle_interval = max_idle_interval + self.options.realtime_request_timeout + self.on_activity() + self.is_connected = True + if self.host != self.options.get_host(): # RTN17e + self.options.fallback_host = self.host + self.connection_manager.on_connected(connection_details, connection_id, reason=exception) + elif action == ProtocolMessageAction.DISCONNECTED: + error = msg.get('error') + exception = None + if error is not None: + exception = AblyException.from_dict(error) + await self.connection_manager.on_disconnected(exception) + elif action == ProtocolMessageAction.AUTH: + try: + await self.connection_manager.ably.auth.authorize() + except Exception as exc: + log.exception(f"WebSocketTransport.on_protocol_message(): An exception \ + occurred during reauth: {exc}") + elif action == ProtocolMessageAction.CLOSED: + if self.ws_connect_task: + self.ws_connect_task.cancel() + await self.connection_manager.on_closed() + elif action == ProtocolMessageAction.ERROR: + error = msg.get('error') + exception = AblyException.from_dict(error) + await self.connection_manager.on_error(msg, exception) + elif action == ProtocolMessageAction.HEARTBEAT: + id = msg.get('id') + self.connection_manager.on_heartbeat(id) + elif action == ProtocolMessageAction.ACK: + # Handle acknowledgment of sent messages + msg_serial = msg.get('msgSerial', 0) + count = msg.get('count', 1) + res = msg.get('res') + if res is not None: + res = [PublishResult.from_dict(result) for result in res] + self.connection_manager.on_ack(msg_serial, count, res) + elif action == ProtocolMessageAction.NACK: + # Handle negative acknowledgment (error sending messages) + msg_serial = msg.get('msgSerial', 0) + count = msg.get('count', 1) + error = msg.get('error') + exception = AblyException.from_dict(error) if error else None + self.connection_manager.on_nack(msg_serial, count, exception) + elif action in ( + ProtocolMessageAction.ATTACHED, + ProtocolMessageAction.DETACHED, + ProtocolMessageAction.MESSAGE, + ProtocolMessageAction.PRESENCE, + ProtocolMessageAction.ANNOTATION, + ProtocolMessageAction.SYNC + ): + self.connection_manager.on_channel_message(msg) + + async def ws_read_loop(self): + if not self.websocket: + raise AblyException('ws_read_loop started with no websocket', 500, 50000) + try: + async for raw in self.websocket: + # Decode based on format + try: + msg = self.decode_raw_websocket_frame(raw) + task = asyncio.create_task(self.on_protocol_message(msg)) + task.add_done_callback(self.on_protcol_message_handled) + except Exception as e: + log.exception( + f"WebSocketTransport.decode(): Unexpected exception handling channel message: {e}" + ) + except (ConnectionClosedOK, GeneratorExit): + # ConnectionClosedOK: normal websocket closure + # GeneratorExit: coroutine being closed (e.g., during event loop shutdown) + return + + def decode_raw_websocket_frame(self, raw: str | bytes) -> dict: + if self.format == 'msgpack': + return msgpack.unpackb(raw, raw=False) + return json.loads(raw) + + def on_protcol_message_handled(self, task): + try: + exception = task.exception() + except Exception as e: + exception = e + if exception is not None: + log.exception(f"WebSocketTransport.on_protocol_message_handled(): uncaught exception: {exception}") + + def on_read_loop_done(self, task: asyncio.Task): + try: + exception = task.exception() + except asyncio.CancelledError as e: + exception = e + if isinstance(exception, ConnectionClosedOK): + return + + async def dispose(self): + self.is_disposed = True + + # Cancel tasks but don't await them yet to avoid deadlock + tasks_to_await = [] + + if self.read_loop: + self.read_loop.cancel() + tasks_to_await.append(self.read_loop) + if self.ws_connect_task: + self.ws_connect_task.cancel() + tasks_to_await.append(self.ws_connect_task) + if self.idle_timer: + self.idle_timer.cancel() + + # Schedule cleanup of cancelled tasks in the background to avoid blocking dispose() + # This prevents deadlock when dispose() is called from within these tasks + if tasks_to_await: + asyncio.create_task(self._cleanup_tasks(tasks_to_await)) + + if self.websocket: + try: + await self.websocket.close() + except asyncio.CancelledError: + return + + async def _cleanup_tasks(self, tasks): + """Wait for cancelled tasks to complete their cleanup.""" + for task in tasks: + try: + await task + except Exception: + pass # Ignore all exceptions from cancelled/failed tasks + + async def close(self): + await self.send({'action': ProtocolMessageAction.CLOSE}) + + async def send(self, message: dict): + if self.websocket is None: + raise Exception() + # Encode based on format + if self.format == 'msgpack': + raw_msg = msgpack.packb(message, use_bin_type=True) + log.info(f'WebSocketTransport.send(): sending msgpack message (length: {len(raw_msg)} bytes)') + else: + raw_msg = json.dumps(message) + log.info(f'WebSocketTransport.send(): sending {raw_msg}') + await self.websocket.send(raw_msg) + + def set_idle_timer(self, timeout: float): + if self.idle_timer: + self.idle_timer.cancel() + self.idle_timer = Timer(timeout, self.on_idle_timer_expire) + + async def on_idle_timer_expire(self): + self.idle_timer = None + since_last = unix_time_ms() - self.last_activity + time_remaining = self.max_idle_interval - since_last + msg = f"No activity seen from realtime in {since_last} ms; assuming connection has dropped" + if time_remaining <= 0: + log.error(msg) + await self.disconnect(AblyException(msg, 408, 80003)) + else: + self.set_idle_timer(time_remaining + 100) + + def on_activity(self): + if not self.max_idle_interval: + return + self.last_activity = unix_time_ms() + self.set_idle_timer(self.max_idle_interval + 100) + + async def disconnect(self, reason=None): + await self.dispose() + self.connection_manager.deactivate_transport(reason) diff --git a/ably/types/annotation.py b/ably/types/annotation.py new file mode 100644 index 00000000..c0926f58 --- /dev/null +++ b/ably/types/annotation.py @@ -0,0 +1,336 @@ +import logging +from enum import IntEnum + +from ably.types.mixins import EncodeDataMixin +from ably.util.encoding import encode_data +from ably.util.helper import to_text + +log = logging.getLogger(__name__) + + +# Sentinel value to distinguish between "not provided" and "explicitly None" +_UNSET = object() + + +class AnnotationAction(IntEnum): + """Annotation action types""" + ANNOTATION_CREATE = 0 + ANNOTATION_DELETE = 1 + + +class Annotation(EncodeDataMixin): + """ + Represents an annotation on a message, such as a reaction or other metadata. + + Annotations are not encrypted as they need to be parsed by the server for summarization. + """ + + def __init__(self, + action=None, + serial=None, + message_serial=None, + type=None, + name=None, + count=None, + data=None, + encoding='', + id=None, + client_id=None, + connection_id=None, + timestamp=None, + extras=None): + """ + Args: + action: The action type - either 'annotation.create' or 'annotation.delete' + serial: A unique identifier for the annotation + message_serial: The serial of the message this annotation is for + type: The type of annotation (e.g., 'reaction', 'like', etc.) + name: The name/value of the annotation (e.g., specific emoji) + count: Count associated with the annotation + data: Optional data payload for the annotation + encoding: Encoding format for the data + id: (TAN2a) A unique identifier for this annotation + client_id: The client ID that created this annotation + connection_id: The connection ID that created this annotation + timestamp: Timestamp of the annotation + extras: Additional metadata + """ + super().__init__(encoding) + + self.__serial = to_text(serial) if serial is not None else None + self.__message_serial = to_text(message_serial) if message_serial is not None else None + self.__type = to_text(type) if type is not None else None + self.__name = to_text(name) if name is not None else None + self.__action = action if action is not None else AnnotationAction.ANNOTATION_CREATE + self.__count = count + self.__data = data + self.__id = to_text(id) if id is not None else None + self.__client_id = to_text(client_id) if client_id is not None else None + self.__connection_id = to_text(connection_id) if connection_id is not None else None + self.__timestamp = timestamp + self.__extras = extras + self.__encoding = encoding + + def __eq__(self, other): + if isinstance(other, Annotation): + # TAN2i: serial is the unique identifier for the annotation + # If both have serials, use serial for comparison + if self.serial is not None and other.serial is not None: + return self.serial == other.serial + # Otherwise fall back to comparing multiple fields + return (self.message_serial == other.message_serial + and self.type == other.type + and self.name == other.name + and self.action == other.action + and self.client_id == other.client_id) + return NotImplemented + + def __ne__(self, other): + if isinstance(other, Annotation): + result = self.__eq__(other) + if result != NotImplemented: + return not result + return NotImplemented + + @property + def action(self): + return self.__action + + @property + def serial(self): + return self.__serial + + @property + def message_serial(self): + return self.__message_serial + + @property + def type(self): + return self.__type + + @property + def name(self): + return self.__name + + @property + def count(self): + return self.__count + + @property + def data(self): + return self.__data + + @property + def client_id(self): + return self.__client_id + + @property + def timestamp(self): + return self.__timestamp + + @property + def extras(self): + return self.__extras + + @property + def id(self): + return self.__id + + @property + def connection_id(self): + return self.__connection_id + + def as_dict(self, binary=False): + """ + Convert annotation to dictionary format for API communication. + + Note: Annotations are not encrypted as they need to be parsed by the server. + """ + request_body = { + 'action': int(self.action) if self.action is not None else None, + 'serial': self.serial, + 'messageSerial': self.message_serial, + 'type': self.type, # Annotation type (not data type) + 'name': self.name, + 'count': self.count, + 'id': self.id or None, + 'clientId': self.client_id or None, + 'connectionId': self.connection_id or None, + 'timestamp': self.timestamp or None, + 'extras': self.extras, + **encode_data(self.data, self._encoding_array, binary) + } + + # None values aren't included + request_body = {k: v for k, v in request_body.items() if v is not None} + + return request_body + + @staticmethod + def from_encoded(obj, cipher=None, context=None): + """ + Create an Annotation from an encoded object received from the API. + + Note: cipher parameter is accepted for consistency but annotations are not encrypted. + """ + action = obj.get('action') + serial = obj.get('serial') + message_serial = obj.get('messageSerial') + type_val = obj.get('type') + name = obj.get('name') + count = obj.get('count') + data = obj.get('data') + encoding = obj.get('encoding', '') + id = obj.get('id') + client_id = obj.get('clientId') + connection_id = obj.get('connectionId') + timestamp = obj.get('timestamp') + extras = obj.get('extras', None) + + # Decode data if present, passing data=None explicitly when absent + decoded_data = Annotation.decode(data, encoding, cipher, context) if data is not None else {'data': None} + + # Convert action from int to enum + if action is not None: + try: + action = AnnotationAction(action) + except ValueError: + # If it's not a valid action value, store as None + action = None + else: + action = None + + return Annotation( + action=action, + serial=serial, + message_serial=message_serial, + type=type_val, + name=name, + count=count, + id=id, + client_id=client_id, + connection_id=connection_id, + timestamp=timestamp, + extras=extras, + **decoded_data + ) + + @staticmethod + def from_encoded_array(obj_array, cipher=None, context=None): + """Create an array of Annotations from encoded objects""" + return [Annotation.from_encoded(obj, cipher, context) for obj in obj_array] + + @staticmethod + def from_values(values): + """Create an Annotation from a dict of values""" + return Annotation(**values) + + @staticmethod + def __update_empty_fields(proto_msg: dict, annotation: dict, annotation_index: int): + """Update empty annotation fields with values from protocol message""" + if annotation.get("id") is None or annotation.get("id") == '': + annotation['id'] = f"{proto_msg.get('id')}:{annotation_index}" + if annotation.get("connectionId") is None or annotation.get("connectionId") == '': + annotation['connectionId'] = proto_msg.get('connectionId') + if annotation.get("timestamp") is None or annotation.get("timestamp") == 0: + annotation['timestamp'] = proto_msg.get('timestamp') + + @staticmethod + def update_inner_annotation_fields(proto_msg: dict): + """ + Update inner annotation fields with protocol message data (RTAN4b). + + Populates empty id, connectionId, and timestamp fields in annotations + from the protocol message values. + """ + annotations: list[dict] = proto_msg.get('annotations') + if annotations is not None: + for annotation_index, annotation in enumerate(annotations): + Annotation.__update_empty_fields(proto_msg, annotation, annotation_index) + + def __str__(self): + return ( + f"Annotation(action={self.action}, messageSerial={self.message_serial}, " + f"type={self.type}, name={self.name})" + ) + + def __repr__(self): + return self.__str__() + + def _copy_with(self, + action=_UNSET, + serial=_UNSET, + message_serial=_UNSET, + type=_UNSET, + name=_UNSET, + count=_UNSET, + data=_UNSET, + encoding=_UNSET, + id=_UNSET, + client_id=_UNSET, + connection_id=_UNSET, + timestamp=_UNSET, + extras=_UNSET): + """ + Create a copy of this Annotation with optionally modified fields. + + To explicitly set a field to None, pass None as the value. + Fields not provided will retain their original values. + + Args: + action: Override the action type (or None to clear it) + serial: Override the serial (or None to clear it) + message_serial: Override the message serial (or None to clear it) + type: Override the type (or None to clear it) + name: Override the name (or None to clear it) + count: Override the count (or None to clear it) + data: Override the data payload (or None to clear it) + encoding: Override the encoding format (or None to clear it) + id: Override the ID (or None to clear it) + client_id: Override the client ID (or None to clear it) + connection_id: Override the connection ID (or None to clear it) + timestamp: Override the timestamp (or None to clear it) + extras: Override the extras metadata (or None to clear it) + + Returns: + A new Annotation instance with the specified fields updated + + Example: + # Keep existing name, change type + new_ann = annotation.copy_with(type="like") + + # Explicitly set name to None + new_ann = annotation.copy_with(name=None) + """ + # Get encoding from the mixin's property + return Annotation( + action=self.__action if action is _UNSET else action, + serial=self.__serial if serial is _UNSET else serial, + message_serial=self.__message_serial if message_serial is _UNSET else message_serial, + type=self.__type if type is _UNSET else type, + name=self.__name if name is _UNSET else name, + count=self.__count if count is _UNSET else count, + data=self.__data if data is _UNSET else data, + encoding=self.__encoding if encoding is _UNSET else encoding, + id=self.__id if id is _UNSET else id, + client_id=self.__client_id if client_id is _UNSET else client_id, + connection_id=self.__connection_id if connection_id is _UNSET else connection_id, + timestamp=self.__timestamp if timestamp is _UNSET else timestamp, + extras=self.__extras if extras is _UNSET else extras, + ) + + +def make_annotation_response_handler(cipher=None): + """Create a response handler for annotation API responses""" + def annotation_response_handler(response): + annotations = response.to_native() + return Annotation.from_encoded_array(annotations, cipher=cipher) + return annotation_response_handler + + +def make_single_annotation_response_handler(cipher=None): + """Create a response handler for single annotation API responses""" + def single_annotation_response_handler(response): + annotation = response.to_native() + return Annotation.from_encoded(annotation, cipher=cipher) + return single_annotation_response_handler diff --git a/ably/types/authoptions.py b/ably/types/authoptions.py index d1d504d0..7ee06af7 100644 --- a/ably/types/authoptions.py +++ b/ably/types/authoptions.py @@ -1,92 +1,101 @@ -from __future__ import absolute_import - -import six - from ably.util.exceptions import AblyException -class AuthOptions(object): - def __init__(self, auth_callback=None, auth_url=None, auth_token=None, - auth_headers=None, auth_params=None, key_id=None, key_value=None, - query_time=False): - self.__auth_callback = auth_callback - self.__auth_url = auth_url - self.__auth_token = auth_token - self.__auth_headers = auth_headers - self.__auth_params = auth_params - self.__key_id = key_id - self.__key_value = key_value - self.__query_time = query_time - - @classmethod - def with_key(cls, key, **kwargs): - kwargs = kwargs or {} - - key_components = key.split(':') - - if len(key_components) != 2: - raise AblyException("invalid key parameter", 401, 40101) - - kwargs['key_id'] = key_components[0] - kwargs['key_value'] = key_components[1] +class AuthOptions: + def __init__(self, auth_callback=None, auth_url=None, auth_method='GET', + auth_token=None, auth_headers=None, auth_params=None, + key_name=None, key_secret=None, key=None, query_time=False, + token_details=None, use_token_auth=None, + default_token_params=None): + self.__auth_options = {} + self.auth_options['auth_callback'] = auth_callback + self.auth_options['auth_url'] = auth_url + self.auth_options['auth_method'] = auth_method + self.auth_options['auth_headers'] = auth_headers + self.auth_options['auth_params'] = auth_params + self.auth_options['query_time'] = query_time + self.auth_options['key_name'] = key_name + self.auth_options['key_secret'] = key_secret + self.set_key(key) - return cls(**kwargs) - - def merge(self, other): - if self.__auth_callback is None: - self.__auth_callback = other.auth_callback - - if self.__auth_url is None: - self.__auth_url = other.auth_url - - if self.__key_id is None: - self.__key_id = other.key_id - - if self.__key_value is None: - self.__key_value = other.key_value - - if self.__auth_token is None: - self.__auth_token = other.auth_token - - if self.__auth_headers is None: - self.__auth_headers = other.auth_headers + self.__auth_token = auth_token + self.__token_details = token_details + self.__use_token_auth = use_token_auth + default_token_params = default_token_params or {} + default_token_params.pop('timestamp', None) + self.default_token_params = default_token_params + + def set_key(self, key): + if key is None: + return + + try: + key_name, key_secret = key.split(':') + self.auth_options['key_name'] = key_name + self.auth_options['key_secret'] = key_secret + except ValueError: + raise AblyException("key of not len 2 parameters: {}" + .format(key.split(':')), + 401, 40101) from None + + def replace(self, auth_options): + if type(auth_options) is dict: + auth_options = dict(auth_options) + key = auth_options.pop('key', None) + self.auth_options = auth_options + self.set_key(key) + elif type(auth_options) is AuthOptions: + self.auth_options = dict(auth_options.auth_options) + else: + raise KeyError('Expected dict or AuthOptions') - if self.__auth_params is None: - self.__auth_params = other.auth_params + @property + def auth_options(self): + return self.__auth_options - self.__query_time == self.__query_time and other.query_time + @auth_options.setter + def auth_options(self, value): + self.__auth_options = value @property def auth_callback(self): - return self.__auth_callback + return self.auth_options['auth_callback'] @auth_callback.setter def auth_callback(self, value): - self.__auth_callback = value + self.auth_options['auth_callback'] = value @property def auth_url(self): - return self.__auth_url + return self.auth_options['auth_url'] @auth_url.setter def auth_url(self, value): - self.__auth_url = value + self.auth_options['auth_url'] = value + + @property + def auth_method(self): + return self.auth_options['auth_method'] + + @auth_method.setter + def auth_method(self, value): + self.auth_options['auth_method'] = value.upper() @property - def key_id(self): - return self.__key_id + def key_name(self): + return self.auth_options['key_name'] - @key_id.setter - def key_id(self, value): - self.__key_id = value + @key_name.setter + def key_name(self, value): + self.auth_options['key_name'] = value @property - def key_value(self): - return self.__key_value + def key_secret(self): + return self.auth_options['key_secret'] - @key_value.setter - def key_value(self, value): - self.__key_value = value + @key_secret.setter + def key_secret(self, value): + self.auth_options['key_secret'] = value @property def auth_token(self): @@ -98,27 +107,51 @@ def auth_token(self, value): @property def auth_headers(self): - return self.__auth_headers + return self.auth_options['auth_headers'] @auth_headers.setter def auth_headers(self, value): - self.__auth_headers = value + self.auth_options['auth_headers'] = value @property def auth_params(self): - return self.__auth_params + return self.auth_options['auth_params'] @auth_params.setter def auth_params(self, value): - self.__auth_params = value + self.auth_options['auth_params'] = value @property def query_time(self): - return self.__query_time + return self.auth_options['query_time'] @query_time.setter def query_time(self, value): - self.__query_time = value + self.auth_options['query_time'] = value + + @property + def token_details(self): + return self.__token_details + + @token_details.setter + def token_details(self, value): + self.__token_details = value + + @property + def use_token_auth(self): + return self.__use_token_auth + + @use_token_auth.setter + def use_token_auth(self, value): + self.__use_token_auth = value + + @property + def default_token_params(self): + return self.__default_token_params + + @default_token_params.setter + def default_token_params(self, value): + self.__default_token_params = value - def __unicode__(self): - return six.text_type(self.__dict__) + def __str__(self): + return str(self.__dict__) diff --git a/ably/types/capability.py b/ably/types/capability.py index a7e1cbcf..4f931466 100644 --- a/ably/types/capability.py +++ b/ably/types/capability.py @@ -1,20 +1,25 @@ -from __future__ import absolute_import - -from collections import MutableMapping import json import logging +from collections.abc import MutableMapping +from typing import Optional, Union -import six +log = logging.getLogger(__name__) -from ably.util.unicodemixin import UnicodeMixin -log = logging.getLogger(__name__) +class Capability(MutableMapping): + def __init__(self, capability: Optional[Union[dict, str]] = None): + # RSA9f: provided capability can be a JSON string + if capability and isinstance(capability, str): + try: + capability = json.loads(capability) + except json.JSONDecodeError: + capability = json.loads(capability.replace("'", '"')) + if capability is None: + capability = {} -class Capability(MutableMapping, UnicodeMixin): - def __init__(self, obj={}): - self.__dict = dict(obj) - for k, v in six.iteritems(obj): + self.__dict = dict(capability) + for k, v in capability.items(): self[k] = v def __eq__(self, other): @@ -41,15 +46,15 @@ def __contains__(self, key): def __setitem__(self, key, value): # validate that the value is a list of ops and that the key is a string - if not isinstance(key, six.string_types): + if not isinstance(key, str): raise ValueError('Capability keys must be strings') - if isinstance(value, six.string_types): + if isinstance(value, str): value = [value] operations = set() for val in iter(value): - if not isinstance(val, six.string_types): + if not isinstance(val, str): raise ValueError('Operations must be strings') operations.add(val) @@ -63,21 +68,23 @@ def setdefault(self, key, default): self[key] = default return self[key] - def add_resource(self, resource, operations=[]): - if isinstance(operations, six.string_types): + def add_resource(self, resource, operations=None): + if operations is None: + operations = [] + if isinstance(operations, str): operations = [operations] self[resource] = list(operations) def add_operation_to_resource(self, operation, resource): self.setdefault(resource, []).append(operation) - def __unicode__(self): + def __str__(self): return Capability.c14n(self) + def to_dict(self): + return {k: sorted(v) for k, v in self.items()} + @staticmethod def c14n(capability): - sorted_ops = { - k: sorted(v) - for k, v in six.iteritems(capability) - } - return six.text_type(json.dumps(sorted_ops, sort_keys=True)) + sorted_ops = capability.to_dict() + return json.dumps(sorted_ops, sort_keys=True) diff --git a/ably/types/channeldetails.py b/ably/types/channeldetails.py new file mode 100644 index 00000000..d959d487 --- /dev/null +++ b/ably/types/channeldetails.py @@ -0,0 +1,116 @@ +from __future__ import annotations + + +class ChannelDetails: + + def __init__(self, channel_id, status): + self.__channel_id = channel_id + self.__status = status + + @property + def channel_id(self) -> str: + return self.__channel_id + + @property + def status(self) -> ChannelStatus: + return self.__status + + @staticmethod + def from_dict(obj): + kwargs = { + 'channel_id': obj.get("channelId"), + 'status': ChannelStatus.from_dict(obj.get("status")) + } + + return ChannelDetails(**kwargs) + + +class ChannelStatus: + + def __init__(self, is_active, occupancy): + self.__is_active = is_active + self.__occupancy = occupancy + + @property + def is_active(self) -> bool: + return self.__is_active + + @property + def occupancy(self) -> ChannelOccupancy: + return self.__occupancy + + @staticmethod + def from_dict(obj): + kwargs = { + 'is_active': obj.get("isActive"), + 'occupancy': ChannelOccupancy.from_dict(obj.get("occupancy")) + } + + return ChannelStatus(**kwargs) + + +class ChannelOccupancy: + + def __init__(self, metrics): + self.__metrics = metrics + + @property + def metrics(self) -> ChannelMetrics: + return self.__metrics + + @staticmethod + def from_dict(obj): + kwargs = { + 'metrics': ChannelMetrics.from_dict(obj.get("metrics")) + } + + return ChannelOccupancy(**kwargs) + + +class ChannelMetrics: + + def __init__(self, connections, presence_connections, presence_members, + presence_subscribers, publishers, subscribers): + self.__connections = connections + self.__presence_connections = presence_connections + self.__presence_members = presence_members + self.__presence_subscribers = presence_subscribers + self.__publishers = publishers + self.__subscribers = subscribers + + @property + def connections(self) -> int: + return self.__connections + + @property + def presence_connections(self) -> int: + return self.__presence_connections + + @property + def presence_members(self) -> int: + return self.__presence_members + + @property + def presence_subscribers(self) -> int: + return self.__presence_subscribers + + @property + def publishers(self) -> int: + return self.__publishers + + @property + def subscribers(self) -> int: + return self.__subscribers + + @staticmethod + def from_dict(obj): + kwargs = { + 'connections': obj.get("connections"), + 'presence_connections': obj.get("presenceConnections"), + 'presence_members': obj.get("presenceMembers"), + 'presence_subscribers': obj.get("presenceSubscribers"), + 'publishers': obj.get("publishers"), + 'subscribers': obj.get("subscribers") + } + + return ChannelMetrics(**kwargs) diff --git a/ably/types/channelmode.py b/ably/types/channelmode.py new file mode 100644 index 00000000..23ed735c --- /dev/null +++ b/ably/types/channelmode.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from enum import Enum + +from ably.types.flags import Flag + + +class ChannelMode(int, Enum): + PRESENCE = Flag.PRESENCE + PUBLISH = Flag.PUBLISH + SUBSCRIBE = Flag.SUBSCRIBE + PRESENCE_SUBSCRIBE = Flag.PRESENCE_SUBSCRIBE + ANNOTATION_PUBLISH = Flag.ANNOTATION_PUBLISH + ANNOTATION_SUBSCRIBE = Flag.ANNOTATION_SUBSCRIBE + + +def encode_channel_mode(modes: list[ChannelMode]) -> int: + """ + Encode a list of ChannelMode values into a bitmask. + + Args: + modes: List of ChannelMode values to encode + + Returns: + Integer bitmask with the corresponding flags set + """ + flags = 0 + + for mode in modes: + flags |= mode.value + + return flags + + +def decode_channel_mode(flags: int) -> list[ChannelMode]: + """ + Decode channel mode flags from a bitmask into a list of ChannelMode values. + + Args: + flags: Integer bitmask containing channel mode flags + + Returns: + List of ChannelMode values that are set in the flags + """ + modes = [] + + # Check each channel mode flag + for mode in ChannelMode: + if flags & mode.value: + modes.append(mode) + + return modes diff --git a/ably/types/channeloptions.py b/ably/types/channeloptions.py index a8dc3326..3e5052c6 100644 --- a/ably/types/channeloptions.py +++ b/ably/types/channeloptions.py @@ -1,12 +1,86 @@ -class ChannelOptions(object): - def __init__(self, encrypted=False, cipher_params=None): - self.__encrypted = encrypted - self.__cipher_params = cipher_params +from __future__ import annotations + +from typing import Any + +from ably.types.channelmode import ChannelMode +from ably.util.crypto import CipherParams +from ably.util.exceptions import AblyException + + +class ChannelOptions: + """Channel options for Ably Realtime channels + + Attributes + ---------- + cipher : CipherParams, optional + Requests encryption for this channel when not null, and specifies encryption-related parameters. + params : Dict[str, str], optional + Channel parameters that configure the behavior of the channel. + """ + + def __init__( + self, + cipher: CipherParams | None = None, + params: dict | None = None, + modes: list[ChannelMode] | None = None + ): + self.__cipher = cipher + self.__params = params + self.__modes = modes + # Validate params + if self.__params and not isinstance(self.__params, dict): + raise AblyException("params must be a dictionary", 40000, 400) + + @property + def cipher(self) -> CipherParams | None: + """Get cipher configuration""" + return self.__cipher @property - def encrypted(self): - return self.__encrypted + def params(self) -> dict[str, str] | None: + """Get channel parameters""" + return self.__params @property - def cipher_params(self): - return self.__cipher_params + def modes(self) -> list[ChannelMode] | None: + """Get channel modes""" + return self.__modes + + def __eq__(self, other): + """Check equality with another ChannelOptions instance""" + if not isinstance(other, ChannelOptions): + return False + + return (self.__cipher == other.__cipher and + self.__params == other.__params and self.__modes == other.__modes) + + def __hash__(self): + """Make ChannelOptions hashable""" + return hash(( + self.__cipher, + tuple(sorted(self.__params.items())) if self.__params else None, + tuple(sorted(self.__modes)) if self.__modes else None + )) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary representation""" + result = {} + if self.__cipher is not None: + result['cipher'] = self.__cipher + if self.__params: + result['params'] = self.__params + if self.__modes: + result['modes'] = self.__modes + return result + + @classmethod + def from_dict(cls, options_dict: dict[str, Any]) -> ChannelOptions: + """Create ChannelOptions from dictionary""" + if not isinstance(options_dict, dict): + raise AblyException("options must be a dictionary", 40000, 400) + + return cls( + cipher=options_dict.get('cipher'), + params=options_dict.get('params'), + modes=options_dict.get('modes'), + ) diff --git a/ably/types/channelstate.py b/ably/types/channelstate.py new file mode 100644 index 00000000..dcb68d67 --- /dev/null +++ b/ably/types/channelstate.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from ably.util.exceptions import AblyException + + +class ChannelState(str, Enum): + INITIALIZED = 'initialized' + ATTACHING = 'attaching' + ATTACHED = 'attached' + DETACHING = 'detaching' + DETACHED = 'detached' + SUSPENDED = 'suspended' + FAILED = 'failed' + + +@dataclass +class ChannelStateChange: + previous: ChannelState + current: ChannelState + resumed: bool + reason: Optional[AblyException] = None diff --git a/ably/types/channelsubscription.py b/ably/types/channelsubscription.py new file mode 100644 index 00000000..b4c0dbf8 --- /dev/null +++ b/ably/types/channelsubscription.py @@ -0,0 +1,70 @@ +from ably.util import case + + +class PushChannelSubscription: + + def __init__(self, channel, device_id=None, client_id=None, app_id=None): + if not device_id and not client_id: + raise ValueError('missing expected device or client id') + + if device_id and client_id: + raise ValueError('both device and client id given, only one expected') + + self.__channel = channel + self.__device_id = device_id + self.__client_id = client_id + self.__app_id = app_id + + @property + def channel(self): + return self.__channel + + @property + def device_id(self): + return self.__device_id + + @property + def client_id(self): + return self.__client_id + + @property + def app_id(self): + return self.__app_id + + def as_dict(self): + keys = ['channel', 'device_id', 'client_id', 'app_id'] + + obj = {} + for key in keys: + value = getattr(self, key) + if value is not None: + key = case.snake_to_camel(key) + obj[key] = value + + return obj + + @classmethod + def from_dict(cls, obj): + obj = {case.camel_to_snake(key): value for key, value in obj.items()} + return cls(**obj) + + @classmethod + def from_array(cls, array): + return [cls.from_dict(d) for d in array] + + @classmethod + def factory(cls, subscription): + if isinstance(subscription, cls): + return subscription + + return cls.from_dict(subscription) + + +def channel_subscriptions_response_processor(response): + native = response.to_native() + return PushChannelSubscription.from_array(native) + + +def channels_response_processor(response): + native = response.to_native() + return native diff --git a/ably/types/connectiondetails.py b/ably/types/connectiondetails.py new file mode 100644 index 00000000..a281daed --- /dev/null +++ b/ably/types/connectiondetails.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass() +class ConnectionDetails: + connection_state_ttl: int + max_idle_interval: int + connection_key: str + + def __init__(self, connection_state_ttl: int, max_idle_interval: int, + connection_key: str, client_id: str): + self.connection_state_ttl = connection_state_ttl + self.max_idle_interval = max_idle_interval + self.connection_key = connection_key + self.client_id = client_id + + @staticmethod + def from_dict(json_dict: dict): + return ConnectionDetails(json_dict.get('connectionStateTtl'), json_dict.get('maxIdleInterval'), + json_dict.get('connectionKey'), json_dict.get('clientId')) diff --git a/ably/types/connectionerrors.py b/ably/types/connectionerrors.py new file mode 100644 index 00000000..bb2fa1f4 --- /dev/null +++ b/ably/types/connectionerrors.py @@ -0,0 +1,30 @@ +from ably.types.connectionstate import ConnectionState +from ably.util.exceptions import AblyException + +ConnectionErrors = { + ConnectionState.DISCONNECTED: AblyException( + 'Connection to server temporarily unavailable', + 400, + 80003, + ), + ConnectionState.SUSPENDED: AblyException( + 'Connection to server unavailable', + 400, + 80002, + ), + ConnectionState.FAILED: AblyException( + 'Connection failed or disconnected by server', + 400, + 80000, + ), + ConnectionState.CLOSING: AblyException( + 'Connection closing', + 400, + 80017, + ), + ConnectionState.CLOSED: AblyException( + 'Connection closed', + 400, + 80017, + ), +} diff --git a/ably/types/connectionstate.py b/ably/types/connectionstate.py new file mode 100644 index 00000000..ec958358 --- /dev/null +++ b/ably/types/connectionstate.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from ably.util.exceptions import AblyException + + +class ConnectionState(str, Enum): + INITIALIZED = 'initialized' + CONNECTING = 'connecting' + CONNECTED = 'connected' + DISCONNECTED = 'disconnected' + CLOSING = 'closing' + CLOSED = 'closed' + FAILED = 'failed' + SUSPENDED = 'suspended' + + +class ConnectionEvent(str, Enum): + INITIALIZED = 'initialized' + CONNECTING = 'connecting' + CONNECTED = 'connected' + DISCONNECTED = 'disconnected' + CLOSING = 'closing' + CLOSED = 'closed' + FAILED = 'failed' + SUSPENDED = 'suspended' + UPDATE = 'update' + + +@dataclass +class ConnectionStateChange: + previous: ConnectionState + current: ConnectionState + event: ConnectionEvent + reason: Optional[AblyException] = None # RTN4f diff --git a/ably/types/device.py b/ably/types/device.py new file mode 100644 index 00000000..c2b84ee5 --- /dev/null +++ b/ably/types/device.py @@ -0,0 +1,115 @@ +from ably.util import case + +DevicePushTransportType = {'fcm', 'gcm', 'apns', 'web'} +DevicePlatform = {'android', 'ios', 'browser'} +DeviceFormFactor = {'phone', 'tablet', 'desktop', 'tv', 'watch', 'car', 'embedded', 'other'} + + +class DeviceDetails: + + def __init__(self, id, client_id=None, form_factor=None, metadata=None, + platform=None, push=None, update_token=None, app_id=None, + device_identity_token=None, modified=None, device_secret=None): + + if push: + recipient = push.get('recipient') + if recipient: + transport_type = recipient.get('transportType') + if transport_type is not None and transport_type not in DevicePushTransportType: + raise ValueError(f'unexpected transport type {transport_type}') + + if platform is not None and platform not in DevicePlatform: + raise ValueError(f'unexpected platform {platform}') + + if form_factor is not None and form_factor not in DeviceFormFactor: + raise ValueError(f'unexpected form factor {form_factor}') + + self.__id = id + self.__client_id = client_id + self.__form_factor = form_factor + self.__metadata = metadata + self.__platform = platform + self.__push = push + self.__update_token = update_token + self.__app_id = app_id + self.__device_identity_token = device_identity_token + self.__modified = modified + self.__device_secret = device_secret + + @property + def id(self): + return self.__id + + @property + def client_id(self): + return self.__client_id + + @property + def form_factor(self): + return self.__form_factor + + @property + def metadata(self): + return self.__metadata + + @property + def platform(self): + return self.__platform + + @property + def push(self): + return self.__push + + @property + def update_token(self): + return self.__update_token + + @property + def app_id(self): + return self.__app_id + + @property + def device_identity_token(self): + return self.__device_identity_token + + @property + def modified(self): + return self.__modified + + @property + def device_secret(self): + return self.__device_secret + + def as_dict(self): + keys = ['id', 'client_id', 'form_factor', 'metadata', 'platform', + 'push', 'update_token', 'app_id', 'device_identity_token', 'modified', 'device_secret'] + + obj = {} + for key in keys: + value = getattr(self, key) + if value is not None: + key = case.snake_to_camel(key) + obj[key] = value + + return obj + + @classmethod + def from_dict(cls, obj): + obj = {case.camel_to_snake(key): value for key, value in obj.items()} + return cls(**obj) + + @classmethod + def from_array(cls, array): + return [cls.from_dict(d) for d in array] + + @classmethod + def factory(cls, device): + if isinstance(device, cls): + return device + + return cls.from_dict(device) + + +def device_details_response_processor(response): + native = response.to_native() + return DeviceDetails.from_array(native) diff --git a/ably/types/flags.py b/ably/types/flags.py new file mode 100644 index 00000000..86666019 --- /dev/null +++ b/ably/types/flags.py @@ -0,0 +1,21 @@ +from enum import Enum + + +class Flag(int, Enum): + # Channel attach state flags + HAS_PRESENCE = 1 << 0 + HAS_BACKLOG = 1 << 1 + RESUMED = 1 << 2 + TRANSIENT = 1 << 4 + ATTACH_RESUME = 1 << 5 + # Channel mode flags + PRESENCE = 1 << 16 + PUBLISH = 1 << 17 + SUBSCRIBE = 1 << 18 + PRESENCE_SUBSCRIBE = 1 << 19 + ANNOTATION_PUBLISH = 1 << 21 + ANNOTATION_SUBSCRIBE = 1 << 22 + + +def has_flag(message_flags: int, flag: Flag): + return message_flags & flag > 0 diff --git a/ably/types/message.py b/ably/types/message.py index d4541375..2442a587 100644 --- a/ably/types/message.py +++ b/ably/types/message.py @@ -1,34 +1,169 @@ -from __future__ import absolute_import - -import base64 -import json import logging -import time - -import six -import msgpack +from enum import IntEnum +from ably.types.mixins import DeltaExtras, EncodeDataMixin from ably.types.typedbuffer import TypedBuffer from ably.util.crypto import CipherData +from ably.util.encoding import encode_data +from ably.util.exceptions import AblyException +from ably.util.helper import to_text log = logging.getLogger(__name__) -class Message(object): - def __init__(self, name=None, data=None, client_id=None, timestamp=None): - if name is None: - self.__name = None - elif isinstance(name, six.string_types): - self.__name = name - elif isinstance(name, six.binary_type): - self.__name = name.decode('ascii') - else: - # log.debug(name) - # log.debug(name.__class__) - raise ValueError("name must be a string or bytes") - self.__client_id = client_id +class MessageAnnotations: + """ + Contains information about annotations associated with a particular message. + """ + + def __init__(self, summary=None): + """ + Args: + summary: A dict mapping annotation types to their aggregated values. + The keys are annotation types (e.g., "reaction:distinct.v1"). + The values depend on the aggregation method of the annotation type. + """ + # TM8a: Ensure summary exists + self.__summary = summary if summary is not None else {} + + @property + def summary(self): + """A dict of annotation type to aggregated annotation values.""" + return self.__summary + + def as_dict(self): + """Convert MessageAnnotations to dictionary format.""" + return { + 'summary': self.summary, + } + + @staticmethod + def from_dict(obj): + """Create MessageAnnotations from dictionary.""" + if obj is None: + return MessageAnnotations() + return MessageAnnotations( + summary=obj.get('summary'), + ) + + +class MessageVersion: + """ + Contains the details regarding the current version of the message - including when it was updated and by whom. + """ + + def __init__(self, + serial=None, + timestamp=None, + client_id=None, + description=None, + metadata=None): + """ + Args: + serial: A unique identifier for the version of the message, lexicographically-comparable with other + versions (that share the same Message.serial). Will differ from the Message.serial only if the + message has been updated or deleted. + timestamp: The timestamp of the message version. If the Message.action is message.create, + this will equal the Message.timestamp. + client_id: The client ID of the client that updated the message to this version. + description: The description provided by the client that updated the message to this version. + metadata: A dict of string key-value pairs that may contain metadata associated with the operation + to update the message to this version. + """ + self.__serial = to_text(serial) if serial is not None else None + self.__timestamp = timestamp + self.__client_id = to_text(client_id) if client_id is not None else None + self.__description = to_text(description) if description is not None else None + self.__metadata = metadata + + @property + def serial(self): + return self.__serial + + @property + def timestamp(self): + return self.__timestamp + + @property + def client_id(self): + return self.__client_id + + @property + def description(self): + return self.__description + + @property + def metadata(self): + return self.__metadata + + def as_dict(self): + """Convert MessageVersion to dictionary format.""" + result = { + 'serial': self.serial, + 'timestamp': self.timestamp, + 'clientId': self.client_id, + 'description': self.description, + 'metadata': self.metadata, + } + # Remove None values + return {k: v for k, v in result.items() if v is not None} + + @staticmethod + def from_dict(obj): + """Create MessageVersion from dictionary.""" + if obj is None: + return None + return MessageVersion( + serial=obj.get('serial'), + timestamp=obj.get('timestamp'), + client_id=obj.get('clientId'), + description=obj.get('description'), + metadata=obj.get('metadata'), + ) + + +class MessageAction(IntEnum): + """Message action types""" + MESSAGE_CREATE = 0 + MESSAGE_UPDATE = 1 + MESSAGE_DELETE = 2 + META = 3 + MESSAGE_SUMMARY = 4 + MESSAGE_APPEND = 5 + + +class Message(EncodeDataMixin): + + def __init__(self, + name=None, # TM2g + data=None, # TM2d + client_id=None, # TM2b + id=None, # TM2a + connection_id=None, # TM2c + connection_key=None, # TM2h + encoding='', # TM2e + timestamp=None, # TM2f + extras=None, # TM2i + serial=None, # TM2r + action=None, # TM2j + version=None, # TM2s + annotations=None, # TM2t + ): + + super().__init__(encoding) + + self.__name = to_text(name) self.__data = data + self.__client_id = to_text(client_id) + self.__id = to_text(id) + self.__connection_id = connection_id + self.__connection_key = connection_key self.__timestamp = timestamp + self.__extras = extras + self.__serial = serial + self.__action = action + self.__version = version + self.__annotations = annotations def __eq__(self, other): if isinstance(other, Message): @@ -49,149 +184,216 @@ def __ne__(self, other): def name(self): return self.__name - @property - def client_id(self): - return self.__client_id - @property def data(self): return self.__data @property - def timestamp(self): - return self.__timestamp - - def encrypt(self, channel_cipher): - if isinstance(self.data, CipherData): - return + def client_id(self): + return self.__client_id - typed_data = TypedBuffer.from_obj(self.data) - if typed_data.buffer is None: - return True + @property + def id(self): + return self.__id - encrypted_data = channel_cipher.encrypt(typed_data.buffer) + @id.setter + def id(self, value): + self.__id = value - self.__data = CipherData(encrypted_data, typed_data.type) + @property + def connection_id(self): + return self.__connection_id - def decrypt(self, channel_cipher): - if not isinstance(self.data, CipherData): - return + @property + def connection_key(self): + return self.__connection_key - decrypted_data = channel_cipher.decrypt(self.data.buffer) - decrypted_typed_buffer = TypedBuffer(decrypted_data, self.data.type) + @property + def timestamp(self): + return self.__timestamp - self.__data = decrypted_typed_buffer.decode() + @property + def extras(self): + return self.__extras - def as_json(self): - data = self.data - encoding = None - data_type = None + @property + def version(self): + return self.__version - # log.debug(data.__class__) + @property + def serial(self): + return self.__serial - if isinstance(data, CipherData): - data_type = data.type - data = base64.b64encode(data.buffer).decode('ascii') - encoding = 'cipher+base64' - if isinstance(data, six.binary_type): - data = base64.b64encode(data).decode('ascii') - encoding = 'base64' + @property + def action(self): + return self.__action - # log.debug(data) - # log.debug(data.__class__) + @property + def annotations(self): + return self.__annotations - request_body = { - 'name': self.name, - 'data': data, - 'timestamp': self.timestamp or int(time.time() * 1000.0), - } + def encrypt(self, channel_cipher): + if isinstance(self.data, CipherData): + return - if encoding: - request_body['encoding'] = encoding + elif isinstance(self.data, str): + self._encoding_array.append('utf-8') - if data_type: - request_body['type'] = data_type + if isinstance(self.data, dict) or isinstance(self.data, list): + self._encoding_array.append('json') + self._encoding_array.append('utf-8') - request_body = json.dumps(request_body) - return request_body + typed_data = TypedBuffer.from_obj(self.data) + if typed_data.buffer is None: + return True + encrypted_data = channel_cipher.encrypt(typed_data.buffer) + self.__data = CipherData(encrypted_data, typed_data.type, + cipher_type=channel_cipher.cipher_type) @staticmethod - def from_json(obj): - name = obj.get('name') - data = obj.get('data') - timestamp = obj.get('timestamp') - encoding = obj.get('encoding') - - # log.debug("MESSAGE: %s", str(obj)) - - if encoding and encoding == six.u('base64'): - data = base64.b64decode(data) - elif encoding and encoding == six.u('cipher+base64'): - ciphertext = base64.b64decode(data) - data = CipherData(ciphertext, obj.get('type')) - - return Message(name=name, data=data, timestamp=timestamp) - - def as_msgpack(self): - data = self.data - encoding = None - data_type = None - - # log.debug(data.__class__) + def decrypt_data(channel_cipher, data): + if not isinstance(data, CipherData): + return + decrypted_data = channel_cipher.decrypt(data.buffer) + decrypted_typed_buffer = TypedBuffer(decrypted_data, data.type) - if isinstance(data, CipherData): - data_type = data.type - data = base64.b64encode(data.buffer).decode('ascii') - encoding = 'cipher+base64' - if isinstance(data, six.binary_type): - data = base64.b64encode(data).decode('ascii') - encoding = 'base64' + return decrypted_typed_buffer.decode() - # log.debug(data) - # log.debug(data.__class__) + def decrypt(self, channel_cipher): + decrypted_data = self.decrypt_data(channel_cipher, self.__data) + if decrypted_data is not None: + self.__data = decrypted_data + def as_dict(self, binary=False): request_body = { 'name': self.name, - 'data': data, - 'timestamp': self.timestamp or int(time.time() * 1000.0), + 'timestamp': self.timestamp or None, + 'clientId': self.client_id or None, + 'id': self.id or None, + 'connectionId': self.connection_id or None, + 'connectionKey': self.connection_key or None, + 'extras': self.extras, + 'version': self.version.as_dict() if self.version else None, + 'serial': self.serial, + 'action': int(self.action) if self.action is not None else None, + 'annotations': self.annotations.as_dict() if self.annotations else None, + **encode_data(self.data, self._encoding_array, binary), } - if encoding: - request_body['encoding'] = encoding + # None values aren't included + request_body = {k: v for k, v in request_body.items() if v is not None} - if data_type: - request_body['type'] = data_type - - request_body = json.dumps(request_body) return request_body @staticmethod - def from_msgpack(obj): + def from_encoded(obj, cipher=None, context=None): + id = obj.get('id') name = obj.get('name') data = obj.get('data') + client_id = obj.get('clientId') + connection_id = obj.get('connectionId') timestamp = obj.get('timestamp') - encoding = obj.get('encoding') - - # log.debug("MESSAGE: %s", str(obj)) - - if encoding and encoding == six.u('base64'): - data = msgpack.loads(base64.b64decode(data)) - elif encoding and encoding == six.u('cipher+base64'): - ciphertext = base64.b64decode(data) - data = CipherData(ciphertext, obj.get('type')) - data = msgpack.loads(data) - - return Message(name=name, data=data, timestamp=timestamp) + encoding = obj.get('encoding', '') + extras = obj.get('extras', None) + serial = obj.get('serial') + action = obj.get('action') + version = obj.get('version', None) + + delta_extra = DeltaExtras(extras) + if delta_extra.from_id and delta_extra.from_id != context.last_message_id: + raise AblyException(f"Delta message decode failure - previous message not available. " + f"Message id = {id}", 400, 40018) + + decoded_data = Message.decode(data, encoding, cipher, context) + + if action is not None: + try: + action = MessageAction(action) + except ValueError: + # If it's not a valid action value, store as None + action = None + else: + action = None + if version is not None: + version = MessageVersion.from_dict(version) + else: + # TM2s + version = MessageVersion(serial=serial, timestamp=timestamp) + + # Parse annotations from the wire format + annotations_obj = obj.get('annotations') + if annotations_obj is None: + # TM2u: Always initialize annotations with empty summary + annotations = MessageAnnotations() + else: + annotations = MessageAnnotations.from_dict(annotations_obj) + + # Process annotation summary entries to ensure clipped fields are set + if annotations and annotations.summary: + for annotation_type, summary_entry in annotations.summary.items(): + # TM7c1c, TM7d1c: For distinct.v1, unique.v1, multiple.v1 + if (annotation_type.endswith(':distinct.v1') or + annotation_type.endswith(':unique.v1') or + annotation_type.endswith(':multiple.v1')): + # These types have entries that need clipped field + if isinstance(summary_entry, dict): + for _entry_key, entry_value in summary_entry.items(): + if isinstance(entry_value, dict) and 'clipped' not in entry_value: + entry_value['clipped'] = False + # TM7c1c: For flag.v1 + elif annotation_type.endswith(':flag.v1'): + if isinstance(summary_entry, dict) and 'clipped' not in summary_entry: + summary_entry['clipped'] = False + + return Message( + id=id, + name=name, + connection_id=connection_id, + client_id=client_id, + timestamp=timestamp, + extras=extras, + serial=serial, + action=action, + version=version, + annotations=annotations, + **decoded_data + ) -def message_response_handler(response): - return [Message.from_json(j) for j in response.json()] + @staticmethod + def __update_empty_fields(proto_msg: dict, msg: dict, msg_index: int): + if msg.get("id") is None or msg.get("id") == '': + msg['id'] = f"{proto_msg.get('id')}:{msg_index}" + if msg.get("connectionId") is None or msg.get("connectionId") == '': + msg['connectionId'] = proto_msg.get('connectionId') + if msg.get("timestamp") is None or msg.get("timestamp") == 0: + msg['timestamp'] = proto_msg.get('timestamp') + @staticmethod + def update_inner_message_fields(proto_msg: dict): + messages: list[dict] = proto_msg.get('messages') + presence_messages: list[dict] = proto_msg.get('presence') + if messages is not None: + msg_index = 0 + for msg in messages: + Message.__update_empty_fields(proto_msg, msg, msg_index) + msg_index = msg_index + 1 + + if presence_messages is not None: + msg_index = 0 + for presence_msg in presence_messages: + Message.__update_empty_fields(proto_msg, presence_msg, msg_index) + msg_index = msg_index + 1 + + +def make_message_response_handler(cipher): + def encrypted_message_response_handler(response): + messages = response.to_native() + return Message.from_encoded_array(messages, cipher=cipher) + return encrypted_message_response_handler -def make_encrypted_message_response_handler(cipher): +def make_single_message_response_handler(cipher): def encrypted_message_response_handler(response): - messages = [Message.from_json(j) for j in response.json()] - for message in messages: - message.decrypt(cipher) - return messages + message = response.to_native() + return Message.from_encoded(message, cipher=cipher) return encrypted_message_response_handler diff --git a/ably/types/mixins.py b/ably/types/mixins.py new file mode 100644 index 00000000..2d2b6041 --- /dev/null +++ b/ably/types/mixins.py @@ -0,0 +1,130 @@ +import base64 +import json +import logging + +from ably.util.crypto import CipherData +from ably.util.exceptions import AblyException + +log = logging.getLogger(__name__) + +ENC_VCDIFF = "vcdiff" + + +class DeltaExtras: + def __init__(self, extras): + self.from_id = None + if extras and 'delta' in extras: + delta_info = extras['delta'] + if isinstance(delta_info, dict): + self.from_id = delta_info.get('from') + + +class DecodingContext: + def __init__(self, base_payload=None, last_message_id=None, vcdiff_decoder=None): + self.base_payload = base_payload + self.last_message_id = last_message_id + self.vcdiff_decoder = vcdiff_decoder + + +class EncodeDataMixin: + + def __init__(self, encoding): + self.encoding = encoding + + @property + def encoding(self): + return '/'.join(self._encoding_array).strip('/') + + @encoding.setter + def encoding(self, encoding): + if not encoding: + self._encoding_array = [] + else: + self._encoding_array = encoding.strip('/').split('/') + + @staticmethod + def decode(data, encoding='', cipher=None, context=None): + encoding = encoding.strip('/') + encoding_list = encoding.split('/') + + last_payload = data + + while encoding_list: + encoding = encoding_list.pop() + if not encoding: + # With messagepack, binary data is sent as bytes, without need + # to specify the base64 encoding. Here we coerce to bytearray, + # since that's what is used with the Json transport; though it + # can be argued that it should be the other way, and use always + # bytes, never bytearray. + if type(data) is bytes: + data = bytearray(data) + continue + if encoding == 'json': + if isinstance(data, bytes): + data = data.decode() + if isinstance(data, list) or isinstance(data, dict): + continue + data = json.loads(data) + elif encoding == 'base64': + data = bytearray(base64.b64decode(data)) if isinstance(data, bytes) \ + else bytearray(base64.b64decode(data.encode('utf-8'))) + if not encoding_list: + last_payload = data + elif encoding == ENC_VCDIFF: + if not context or not context.vcdiff_decoder: + log.error('Message cannot be decoded as no VCDiff decoder available') + raise AblyException('VCDiff decoder not available', 40019, 40019) + + if not context.base_payload: + log.error('VCDiff decoding requires base payload') + raise AblyException('VCDiff decode failure', 40018, 40018) + + try: + # Convert base payload to bytes if it's a string + base_data = context.base_payload + if isinstance(base_data, str): + base_data = base_data.encode('utf-8') + else: + base_data = bytes(base_data) + + # Convert delta to bytes if needed + delta_data = data + if isinstance(delta_data, (bytes, bytearray)): + delta_data = bytes(delta_data) + else: + delta_data = str(delta_data).encode('utf-8') + + # Decode with VCDiff + data = bytearray(context.vcdiff_decoder.decode(delta_data, base_data)) + last_payload = data + + except Exception as e: + log.error(f'VCDiff decode failed: {e}') + raise AblyException('VCDiff decode failure', 40018, 40018) from e + + elif encoding.startswith(f'{CipherData.ENCODING_ID}+'): + if not cipher: + log.error('Message cannot be decrypted as the channel is ' + 'not set up for encryption & decryption') + encoding_list.append(encoding) + break + data = cipher.decrypt(data) + elif encoding == 'utf-8' and isinstance(data, (bytes, bytearray)): + data = data.decode('utf-8') + elif encoding == 'utf-8': + pass + else: + log.error('Message cannot be decoded. ' + f"Unsupported encoding type: '{encoding}'") + encoding_list.append(encoding) + break + + if context: + context.base_payload = last_payload + encoding = '/'.join(encoding_list) + return {'encoding': encoding, 'data': data} + + @classmethod + def from_encoded_array(cls, objs, cipher=None, context=None): + return [cls.from_encoded(obj, cipher=cipher, context=context) for obj in objs] diff --git a/ably/types/operations.py b/ably/types/operations.py new file mode 100644 index 00000000..4e69db64 --- /dev/null +++ b/ably/types/operations.py @@ -0,0 +1,89 @@ +class MessageOperation: + """Metadata for message update/delete/append operations.""" + + def __init__(self, client_id=None, description=None, metadata=None): + """ + Args: + description: Optional description of the operation. + metadata: Optional dict of metadata key-value pairs associated with the operation. + """ + self.__client_id = client_id + self.__description = description + self.__metadata = metadata + + @property + def client_id(self): + return self.__client_id + + @property + def description(self): + return self.__description + + @property + def metadata(self): + return self.__metadata + + def as_dict(self): + """Convert MessageOperation to dictionary format.""" + result = { + 'clientId': self.client_id, + 'description': self.description, + 'metadata': self.metadata, + } + # Remove None values + return {k: v for k, v in result.items() if v is not None} + + @staticmethod + def from_dict(obj): + """Create MessageOperation from dictionary.""" + if obj is None: + return None + return MessageOperation( + client_id=obj.get('clientId'), + description=obj.get('description'), + metadata=obj.get('metadata'), + ) + + +class PublishResult: + """Result of a publish operation containing message serials.""" + + def __init__(self, serials=None): + """ + Args: + serials: List of message serials (strings or None) in 1:1 correspondence with published messages. + """ + self.__serials = serials or [] + + @property + def serials(self): + return self.__serials + + @staticmethod + def from_dict(obj): + """Create PublishResult from dictionary.""" + if obj is None: + return PublishResult() + return PublishResult(serials=obj.get('serials', [])) + + +class UpdateDeleteResult: + """Result of an update or delete operation containing version serial.""" + + def __init__(self, version_serial=None): + """ + Args: + version_serial: The serial of the resulting message version after the operation. + """ + self.__version_serial = version_serial + + @property + def version_serial(self): + return self.__version_serial + + @staticmethod + def from_dict(obj): + """Create UpdateDeleteResult from dictionary.""" + if obj is None: + return UpdateDeleteResult() + return UpdateDeleteResult(version_serial=obj.get('versionSerial')) diff --git a/ably/types/options.py b/ably/types/options.py index 79760f04..1dad41fb 100644 --- a/ably/types/options.py +++ b/ably/types/options.py @@ -1,43 +1,135 @@ -from __future__ import absolute_import +import logging +import random +from abc import ABC, abstractmethod +from ably.transport.defaults import Defaults from ably.types.authoptions import AuthOptions from ably.util.exceptions import AblyException +log = logging.getLogger(__name__) + + +class VCDiffDecoder(ABC): + """ + The VCDiffDecoder class defines the interface for delta decoding operations. + + This class serves as an abstract base class for implementing delta decoding + algorithms, which are used to generate target bytes from compressed delta + bytes and base bytes. Subclasses of this class should implement the decode + method to handle the specifics of delta decoding. The decode method typically + takes a delta bytes and base bytes as input and returns the decoded output. + + """ + @abstractmethod + def decode(self, delta: bytes, base: bytes) -> bytes: + pass + class Options(AuthOptions): - def __init__(self, client_id=None, log_level=0, tls=True, host=None, - ws_host=None, port=0, tls_port=0, use_text_protocol=True, - queue_messages=False, recover=False, **kwargs): - super(Options, self).__init__(**kwargs) + def __init__(self, client_id=None, log_level=0, tls=True, rest_host=None, realtime_host=None, port=0, + tls_port=0, use_binary_protocol=True, queue_messages=True, recover=False, endpoint=None, + environment=None, http_open_timeout=None, http_request_timeout=None, + realtime_request_timeout=None, http_max_retry_count=None, http_max_retry_duration=None, + fallback_hosts=None, fallback_retry_timeout=None, disconnected_retry_timeout=None, + idempotent_rest_publishing=None, loop=None, auto_connect=True, + suspended_retry_timeout=None, connectivity_check_url=None, + channel_retry_timeout=Defaults.channel_retry_timeout, add_request_ids=False, + vcdiff_decoder: VCDiffDecoder = None, transport_params=None, **kwargs): + + super().__init__(**kwargs) + + # REC1b1: endpoint is incompatible with deprecated options + if endpoint is not None: + if environment is not None or rest_host is not None or realtime_host is not None: + raise AblyException( + message='endpoint is incompatible with any of environment, rest_host or realtime_host', + status_code=400, + code=40106, + ) # TODO check these defaults + if fallback_retry_timeout is None: + fallback_retry_timeout = Defaults.fallback_retry_timeout + + if realtime_request_timeout is None: + realtime_request_timeout = Defaults.realtime_request_timeout + + if disconnected_retry_timeout is None: + disconnected_retry_timeout = Defaults.disconnected_retry_timeout + + if connectivity_check_url is None: + connectivity_check_url = Defaults.connectivity_check_url + + connection_state_ttl = Defaults.connection_state_ttl + + if suspended_retry_timeout is None: + suspended_retry_timeout = Defaults.suspended_retry_timeout + + if environment is not None and rest_host is not None: + raise AblyException( + message='specify rest_host or environment, not both', + status_code=400, + code=40106, + ) + + if environment is not None and realtime_host is not None: + raise AblyException( + message='specify realtime_host or environment, not both', + status_code=400, + code=40106, + ) + + if idempotent_rest_publishing is None: + from ably import api_version + idempotent_rest_publishing = api_version >= '1.2' + + if environment is not None and endpoint is None: + log.warning("environment client option is deprecated, please use endpoint instead") + endpoint = environment + + # REC1d: restHost or realtimeHost option + # REC1d1: restHost takes precedence over realtimeHost + if rest_host is not None and endpoint is None: + log.warning("rest_host client option is deprecated, please use endpoint instead") + endpoint = rest_host + elif realtime_host is not None and endpoint is None: + # REC1d2: realtimeHost if restHost not specified + log.warning("realtime_host client option is deprecated, please use endpoint instead") + endpoint = realtime_host + + if endpoint is None: + endpoint = Defaults.endpoint self.__client_id = client_id self.__log_level = log_level self.__tls = tls - self.__host = host - self.__ws_host = ws_host self.__port = port self.__tls_port = tls_port - self.__use_text_protocol = use_text_protocol + self.__use_binary_protocol = use_binary_protocol self.__queue_messages = queue_messages self.__recover = recover - - @classmethod - def with_key(cls, key, **kwargs): - kwargs = kwargs or {} - - key_components = key.split(':') - - if len(key_components) != 2: - raise AblyException("key of not len 2 parameters: {0}" - .format(key.split(':')), - 401, 40101) - - kwargs['key_id'] = key_components[0] - kwargs['key_value'] = key_components[1] - - return cls(**kwargs) + self.__endpoint = endpoint + self.__http_open_timeout = http_open_timeout + self.__http_request_timeout = http_request_timeout + self.__realtime_request_timeout = realtime_request_timeout + self.__http_max_retry_count = http_max_retry_count + self.__http_max_retry_duration = http_max_retry_duration + # Field for internal use only + self.__fallback_host = None + self.__fallback_hosts = fallback_hosts + self.__fallback_retry_timeout = fallback_retry_timeout + self.__disconnected_retry_timeout = disconnected_retry_timeout + self.__channel_retry_timeout = channel_retry_timeout + self.__idempotent_rest_publishing = idempotent_rest_publishing + self.__loop = loop + self.__auto_connect = auto_connect + self.__connection_state_ttl = connection_state_ttl + self.__suspended_retry_timeout = suspended_retry_timeout + self.__connectivity_check_url = connectivity_check_url + self.__add_request_ids = add_request_ids + self.__vcdiff_decoder = vcdiff_decoder + self.__transport_params = transport_params or {} + self.__hosts = self.__get_hosts() @property def client_id(self): @@ -63,14 +155,6 @@ def tls(self): def tls(self, value): self.__tls = value - @property - def host(self): - return self.__host - - @host.setter - def host(self, value): - self.__host = value - @property def port(self): return self.__port @@ -88,12 +172,12 @@ def tls_port(self, value): self.__tls_port = value @property - def use_text_protocol(self): - return self.__use_text_protocol + def use_binary_protocol(self): + return self.__use_binary_protocol - @use_text_protocol.setter - def use_text_protocol(self, value): - self.__use_text_protocol = value + @use_binary_protocol.setter + def use_binary_protocol(self, value): + self.__use_binary_protocol = value @property def queue_messages(self): @@ -110,3 +194,155 @@ def recover(self): @recover.setter def recover(self, value): self.__recover = value + + @property + def endpoint(self): + return self.__endpoint + + @property + def http_open_timeout(self): + return self.__http_open_timeout + + @http_open_timeout.setter + def http_open_timeout(self, value): + self.__http_open_timeout = value + + @property + def http_request_timeout(self): + return self.__http_request_timeout + + @property + def realtime_request_timeout(self): + return self.__realtime_request_timeout + + @http_request_timeout.setter + def http_request_timeout(self, value): + self.__http_request_timeout = value + + @property + def http_max_retry_count(self): + return self.__http_max_retry_count + + @http_max_retry_count.setter + def http_max_retry_count(self, value): + self.__http_max_retry_count = value + + @property + def http_max_retry_duration(self): + return self.__http_max_retry_duration + + @http_max_retry_duration.setter + def http_max_retry_duration(self, value): + self.__http_max_retry_duration = value + + @property + def fallback_hosts(self): + return self.__fallback_hosts + + @property + def fallback_retry_timeout(self): + return self.__fallback_retry_timeout + + @property + def disconnected_retry_timeout(self): + return self.__disconnected_retry_timeout + + @property + def channel_retry_timeout(self): + return self.__channel_retry_timeout + + @property + def idempotent_rest_publishing(self): + return self.__idempotent_rest_publishing + + @property + def loop(self): + return self.__loop + + # RTC1b + @property + def auto_connect(self): + return self.__auto_connect + + @property + def connection_state_ttl(self): + return self.__connection_state_ttl + + @connection_state_ttl.setter + def connection_state_ttl(self, value): + self.__connection_state_ttl = value + + @property + def suspended_retry_timeout(self): + return self.__suspended_retry_timeout + + @property + def connectivity_check_url(self): + return self.__connectivity_check_url + + @property + def fallback_host(self): + """ + For internal use only, can be deleted in future + """ + return self.__fallback_host + + @fallback_host.setter + def fallback_host(self, value): + """ + For internal use only, can be deleted in future + """ + self.__fallback_host = value + + @property + def add_request_ids(self): + return self.__add_request_ids + + @property + def vcdiff_decoder(self): + return self.__vcdiff_decoder + + @property + def transport_params(self): + return self.__transport_params + + def __get_hosts(self): + """ + Return the list of hosts as they should be tried. First comes the main + host. Then the fallback hosts in random order. + The returned list will have a length of up to http_max_retry_count. + """ + host = Defaults.get_hostname(self.endpoint) + # REC2: Determine fallback hosts + fallback_hosts = self.get_fallback_hosts() + + http_max_retry_count = self.http_max_retry_count + if http_max_retry_count is None: + http_max_retry_count = Defaults.http_max_retry_count + + # Shuffle + fallback_hosts = list(fallback_hosts) + random.shuffle(fallback_hosts) + self.__fallback_hosts = fallback_hosts + + # First main host + hosts = [host] + fallback_hosts + hosts = hosts[:http_max_retry_count] + return hosts + + def get_hosts(self): + return self.__hosts + + def get_host(self): + return self.__hosts[0] + + # REC2: Various client options collectively determine a set of fallback domains + def get_fallback_hosts(self): + # REC2a: If the fallbackHosts client option is specified + if self.__fallback_hosts is not None: + # REC2a2: the set of fallback domains is given by the value of the fallbackHosts option + return self.__fallback_hosts + + # REC2c: Otherwise, the set of fallback domains is defined implicitly by the options + # used to define the primary domain as specified in (REC1) + return Defaults.get_fallback_hosts(self.endpoint) diff --git a/ably/types/presence.py b/ably/types/presence.py index 68a3c562..7d1a3c05 100644 --- a/ably/types/presence.py +++ b/ably/types/presence.py @@ -1,78 +1,264 @@ -from __future__ import absolute_import +from datetime import datetime, timedelta +from urllib import parse -import base64 +from ably.http.paginatedresult import PaginatedResult +from ably.types.mixins import EncodeDataMixin +from ably.types.typedbuffer import TypedBuffer +from ably.util.crypto import CipherData +from ably.util.encoding import encode_data -class PresenceAction(object): - ENTER = 0 - LEAVE = 1 - UPDATE = 2 +def _ms_since_epoch(dt): + epoch = datetime.utcfromtimestamp(0) + delta = dt - epoch + return int(delta.total_seconds() * 1000) -class PresenceMessage(object): - def __init__(self, action=PresenceAction.ENTER, client_id=None, - member_id=None, client_data=None): +def _dt_from_ms_epoch(ms): + epoch = datetime.utcfromtimestamp(0) + return epoch + timedelta(milliseconds=ms) + + +class PresenceAction: + ABSENT = 0 + PRESENT = 1 + ENTER = 2 + LEAVE = 3 + UPDATE = 4 + + +class PresenceMessage(EncodeDataMixin): + + def __init__(self, + id=None, # TP3a + action=None, # TP3b + client_id=None, # TP3c + connection_id=None, # TP3d + data=None, # TP3e + encoding=None, # TP3f + timestamp=None, # TP3g + member_key=None, # TP3h (for RT only) + extras=None, # TP3i (functionality not specified) + ): + + super().__init__(encoding or '') + + self.__id = id self.__action = action self.__client_id = client_id - self.__member_id = member_id - self.__client_data = client_data + self.__connection_id = connection_id + self.__data = data + self.__timestamp = timestamp + self.__member_key = member_key + self.__extras = extras + + @property + def id(self): + return self.__id + + @property + def action(self): + return self.__action + + @property + def client_id(self): + return self.__client_id + + @property + def connection_id(self): + return self.__connection_id + + @property + def data(self): + return self.__data + + @property + def timestamp(self): + return self.__timestamp + + @property + def member_key(self): + if self.connection_id and self.client_id: + return f"{self.connection_id}:{self.client_id}" + + @property + def extras(self): + return self.__extras + + def is_synthesized(self): + """ + Check if message is synthesized (RTP2b1). + A message is synthesized if its connectionId is not an initial substring of its id. + This happens with synthesized leave events sent by realtime to indicate + a connection disconnected unexpectedly. + """ + if not self.id or not self.connection_id: + return False + return not self.id.startswith(self.connection_id + ':') + + def parse_id(self): + """ + Parse id into components (connId, msgSerial, index) for RTP2b2 comparison. + Expected format: connId:msgSerial:index (e.g., "aaaaaa:0:0") + + Returns: + dict with 'msgSerial' and 'index' as integers + + Raises: + ValueError: If id is missing or has invalid format + """ + if not self.id: + raise ValueError("Cannot parse id: id is None or empty") + + parts = self.id.split(':') + + try: + return { + 'msgSerial': int(parts[1]), + 'index': int(parts[2]) + } + except (ValueError, IndexError) as e: + raise ValueError(f"Cannot parse id: invalid msgSerial or index in '{self.id}'") from e + + def encrypt(self, channel_cipher): + """ + Encrypt the presence message data using the provided cipher. + Similar to Message.encrypt(). + """ + if isinstance(self.data, CipherData): + return + + elif isinstance(self.data, str): + self._encoding_array.append('utf-8') + + if isinstance(self.data, dict) or isinstance(self.data, list): + self._encoding_array.append('json') + self._encoding_array.append('utf-8') + + typed_data = TypedBuffer.from_obj(self.data) + if typed_data.buffer is None: + return + encrypted_data = channel_cipher.encrypt(typed_data.buffer) + self.__data = CipherData(encrypted_data, typed_data.type, + cipher_type=channel_cipher.cipher_type) + + def to_encoded(self, binary=False): + """ + Convert to wire protocol format for sending. + + Handles proper encoding of data including JSON serialization, + base64 encoding for binary data, and encryption support. + """ + + result = { + 'action': self.action, + **encode_data(self.data, self._encoding_array, binary), + } + + if self.id: + result['id'] = self.id + if self.client_id: + result['clientId'] = self.client_id + if self.connection_id: + result['connectionId'] = self.connection_id + if self.extras: + result['extras'] = self.extras + if self.timestamp: + result['timestamp'] = _ms_since_epoch(self.timestamp) + + return result @staticmethod - def from_dict(obj): + def from_encoded(obj, cipher=None, context=None): + id = obj.get('id') action = obj.get('action', PresenceAction.ENTER) client_id = obj.get('clientId') - member_id = obj.get('memberId') + connection_id = obj.get('connectionId') + data = obj.get('data') + encoding = obj.get('encoding', '') + timestamp = obj.get('timestamp') + # member_key = obj.get('memberKey', None) + extras = obj.get('extras', None) - encoding = obj.get('encoding') - client_data = obj.get('clientData') - if client_data and 'base64' == encoding: - client_data = base64.b64decode(client_data) + if timestamp is not None: + timestamp = _dt_from_ms_epoch(timestamp) + + decoded_data = PresenceMessage.decode(data, encoding, cipher) return PresenceMessage( + id=id, action=action, client_id=client_id, - member_id=member_id, - client_data=client_data + connection_id=connection_id, + timestamp=timestamp, + extras=extras, + **decoded_data ) @staticmethod - def messages_from_array(obj): - return [PresenceMessage.from_dict(d) for d in obj] + def from_encoded_array(encoded_array, cipher=None, context=None): + """ + Decode array of presence messages. + """ + return [PresenceMessage.from_encoded(item, cipher, context) for item in encoded_array] - def to_dict(self): - obj = { - 'action': self.action, - } - if self.client_id is not None: - obj['clientId'] = self.client_id +class Presence: + def __init__(self, channel): + self.__base_path = f'/channels/{parse.quote_plus(channel.name)}/' + self.__binary = channel.ably.options.use_binary_protocol + self.__http = channel.ably.http + self.__cipher = channel.cipher - if self.client_data is not None: - # TODO b64 encode data if necessary - obj['clientData'] = self.client_data + def _path_with_qs(self, rel_path, qs=None): + path = rel_path + if qs: + path += ('?' + parse.urlencode(qs)) + return path - if self.member_id is not None: - obj['memberId'] = self.member_id + async def get(self, limit=None): + qs = {} + if limit: + if limit > 1000: + raise ValueError("The maximum allowed limit is 1000") + qs['limit'] = limit + path = self._path_with_qs(self.__base_path + 'presence', qs) - return obj + presence_handler = make_presence_response_handler(self.__cipher) + return await PaginatedResult.paginated_query( + self.__http, url=path, response_processor=presence_handler) - @property - def action(self): - return self.__action + async def history(self, limit=None, direction=None, start=None, end=None): + qs = {} + if limit: + if limit > 1000: + raise ValueError("The maximum allowed limit is 1000") + qs['limit'] = limit + if direction: + qs['direction'] = direction + if start: + if isinstance(start, int): + qs['start'] = start + else: + qs['start'] = _ms_since_epoch(start) + if end: + if isinstance(end, int): + qs['end'] = end + else: + qs['end'] = _ms_since_epoch(end) - @property - def client_id(self): - return self.__client_id + if 'start' in qs and 'end' in qs and qs['start'] > qs['end']: + raise ValueError("'end' parameter has to be greater than or equal to 'start'") - @property - def client_data(self): - return self.__client_data + path = self._path_with_qs(self.__base_path + 'presence/history', qs) - @property - def member_id(self): - return self.__member_id + presence_handler = make_presence_response_handler(self.__cipher) + return await PaginatedResult.paginated_query( + self.__http, url=path, response_processor=presence_handler) -def presence_response_handler(response): - # TODO implement - pass +def make_presence_response_handler(cipher): + def encrypted_presence_response_handler(response): + messages = response.to_native() + return PresenceMessage.from_encoded_array(messages, cipher=cipher) + return encrypted_presence_response_handler diff --git a/ably/types/presencemessage.py b/ably/types/presencemessage.py deleted file mode 100644 index a8e41430..00000000 --- a/ably/types/presencemessage.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import absolute_import - -import base64 -import json - -import six - - -class PresenceAction(object): - ENTER = 0 - LEAVE = 1 - UPDATE = 2 - - -class PresenceMessage(object): - def __init__(self, action, client_id=None, - client_data=None, member_id=None): - self.__action = action - self.__client_id = client_id - self.__client_data = client_data - self.__member_id = None - - @property - def action(self): - return self.__action - - @property - def client_id(self): - return self.__client_id - - @property - def client_data(self): - return self.__client_data - - @property - def member_id(self): - return self.__member_id - - @staticmethod - def from_dict(obj): - pm = PresenceMessage() - pm.action = obj.get('action', 0) - pm.client_id = obj.get('clientId', '') - pm.member_id = obj.get('memberId', '') - encoding = obj.get('encoding', '') - client_data = obj.get('clientData', '') - - if 'base64' == encoding: - pm.client_data = base64.b64decode(client_data) - else: - pm.client_data = client_data - - return pm - - @staticmethod - def from_json(jsonstr): - obj = json.loads(jsonstr) - - if isinstance(obj, dict): - return PresenceMessage.from_obj(obj) - elif isinstance(obj, list): - return [PresenceMessage.from_obj(i) for i in obj] - else: - raise ValueError('Invalid presence message str') - - def to_dict(self): - d = { - "action": self.action, - } - if self.client_id is not None: - d["clientId"] = self.client_id - - if self.client_data is not None: - if isinstance(self.client_data, six.byte_type): - d['clientData'] = base64.b64encode(self.client_data) - d['encoding'] = 'base64' - else: - d['clientData'] = self.client_data - return d - - def to_json(self): - return json.dumps(self.to_dict()) diff --git a/ably/types/stats.py b/ably/types/stats.py index 38476ea6..ead5e548 100644 --- a/ably/types/stats.py +++ b/ably/types/stats.py @@ -1,155 +1,67 @@ -from __future__ import absolute_import - import logging +from datetime import datetime log = logging.getLogger(__name__) -class ResourceCount(object): - def __init__(self, opened=0.0, peak=0.0, mean=0.0, min=0.0, refused=0.0): - self.opened = opened - self.peak = peak - self.mean = mean - self.min = min - self.refused = refused - - @staticmethod - def from_dict(rc_dict): - rc_dict = rc_dict or {} - kwargs = { - "opened": rc_dict.get("opened"), - "peak": rc_dict.get("peak"), - "mean": rc_dict.get("mean"), - "min": rc_dict.get("min"), - "refused": rc_dict.get("refused"), - } - - return ResourceCount(**kwargs) - - -class ConnectionTypes(object): - def __init__(self, all=None, plain=None, tls=None): - self.all = ResourceCount() - self.plain = ResourceCount() - self.tls = ResourceCount() - - @staticmethod - def from_dict(ct_dict): - ct_dict = ct_dict or {} - kwargs = { - "all": ResourceCount.from_dict(ct_dict.get("all")), - "plain": ResourceCount.from_dict(ct_dict.get("plain")), - "tls": ResourceCount.from_dict(ct_dict.get("tls")), - } - return ConnectionTypes(**kwargs) - - -class MessageCount(object): - def __init__(self, count=0.0, data=0.0): - self.count = count - self.data = data - - @staticmethod - def from_dict(mc_dict): - mc_dict = mc_dict or {} - kwargs = { - "count": mc_dict.get("count"), - "data": mc_dict.get("data"), - } - return MessageCount(**kwargs) +class Stats: + def __init__(self, entries=None, unit=None, interval_id=None, in_progress=None, app_id=None, schema=None): + self.interval_id = interval_id or '' + self.entries = entries + self.unit = unit + self.interval_time = interval_from_interval_id(self.interval_id) + self.in_progress = in_progress + self.app_id = app_id + self.schema = schema -class MessageTypes(object): - def __init__(self, all=None, messages=None, presence=None): - self.all = all or MessageCount() - self.messages = messages or MessageCount() - self.presence = presence or MessageCount() + @classmethod + def from_dict(cls, stats_dict): + stats_dict = stats_dict or {} - @staticmethod - def from_dict(mt_dict): - mt_dict = mt_dict or {} kwargs = { - "all": MessageCount.from_dict(mt_dict.get("all")), - "messages": MessageCount.from_dict(mt_dict.get("messages")), - "presence": MessageCount.from_dict(mt_dict.get("presence")), + "entries": stats_dict.get("entries"), + "unit": stats_dict.get("unit"), + "interval_id": stats_dict.get("intervalId"), + "in_progress": stats_dict.get("inProgress"), + "app_id": stats_dict.get("appId"), + "schema": stats_dict.get("schema"), } - return MessageTypes(**kwargs) + return cls(**kwargs) -class MessageTraffic(object): - def __init__(self, all=None, realtime=None, rest=None, push=None, http_stream=None): - self.all = all or MessageTypes() - self.realtime = realtime or MessageTypes() - self.rest = rest or MessageTypes() - self.push = push or MessageTypes() - self.http_stream = http_stream or MessageTypes() + @classmethod + def from_array(cls, stats_array): + return [cls.from_dict(d) for d in stats_array] @staticmethod - def from_dict(mt_dict): - mt_dict = mt_dict or {} - kwargs = { - "all": MessageTypes.from_dict(mt_dict.get("all")), - "realtime": MessageTypes.from_dict(mt_dict.get("realtime")), - "rest": MessageTypes.from_dict(mt_dict.get("rest")), - "push": MessageTypes.from_dict(mt_dict.get("push")), - "http_stream": MessageTypes.from_dict(mt_dict.get("httpStream")), - } - return MessageTraffic(**kwargs) - + def to_interval_id(date_time, granularity): + return date_time.strftime(INTERVALS_FMT[granularity]) -class RequestCount(object): - def __init__(self, succeeded=0.0, failed=0.0, refused=0.0): - self.succeeded = succeeded - self.failed = failed - self.refused = refused - - @staticmethod - def from_dict(rc_dict): - rc_dict = rc_dict or {} - kwargs = { - "succeeded": rc_dict.get("succeeded"), - "failed": rc_dict.get("failed"), - "refused": rc_dict.get("refused"), - } - return RequestCount(**kwargs) - - -class Stats(object): - def __init__(self, all=None, inbound=None, outbound=None, persisted=None, - connections=None, channels=None, api_requests=None, - token_requests=None): - self.all = all or MessageTypes() - self.inbound = inbound or MessageTraffic() - self.outbound = outbound or MessageTraffic() - self.persisted = persisted or MessageTypes() - self.connections = connections or ConnectionTypes() - self.channels = channels or ResourceCount() - self.api_requests = api_requests or RequestCount() - self.token_requests = token_requests or RequestCount() - @staticmethod - def from_dict(stats_dict): - stats_dict = stats_dict or {} +def stats_response_processor(response): + stats_array = response.to_native() + return Stats.from_array(stats_array) - kwargs = { - "all": MessageTypes.from_dict(stats_dict.get("all")), - "inbound": MessageTraffic.from_dict(stats_dict.get("inbound")), - "outbound": MessageTraffic.from_dict(stats_dict.get("outbound")), - "persisted": MessageTypes.from_dict(stats_dict.get("persisted")), - "connections": ConnectionTypes.from_dict(stats_dict.get("connections")), - "channels": ResourceCount.from_dict(stats_dict["channels"]), - "api_requests": RequestCount.from_dict(stats_dict["apiRequests"]), - "token_requests": RequestCount.from_dict(stats_dict["tokenRequests"]), - } - return Stats(**kwargs) +INTERVALS_FMT = { + 'minute': '%Y-%m-%d:%H:%M', + 'hour': '%Y-%m-%d:%H', + 'day': '%Y-%m-%d', + 'month': '%Y-%m', +} - @staticmethod - def from_array(stats_array): - return [Stats.from_dict(d) for d in stats_array] +def granularity_from_interval_id(interval_id): + for key, value in INTERVALS_FMT.items(): + try: + datetime.strptime(interval_id, value) + return key + except ValueError: + pass + raise ValueError("Unsupported intervalId") -def stats_response_processor(response): - stats_array = response.json() - return Stats.from_array(stats_array) +def interval_from_interval_id(interval_id): + granularity = granularity_from_interval_id(interval_id) + return datetime.strptime(interval_id, INTERVALS_FMT[granularity]) diff --git a/ably/types/tokendetails.py b/ably/types/tokendetails.py index 48741bb3..771b29ec 100644 --- a/ably/types/tokendetails.py +++ b/ably/types/tokendetails.py @@ -1,28 +1,39 @@ -from __future__ import absolute_import +import json +import time from ably.types.capability import Capability -class TokenDetails(object): - def __init__(self, id=None, expires=0, issued_at=0, +class TokenDetails: + + DEFAULTS = {'ttl': 60 * 60 * 1000} + # Buffer in milliseconds before a token is considered unusable + # For example, if buffer is 10000ms, the token can no longer be used for + # new requests 9000ms before it expires + TOKEN_EXPIRY_BUFFER = 15 * 1000 + + def __init__(self, token=None, expires=None, issued=0, capability=None, client_id=None): - self.__id = id - self.__expires = expires - self.__issued_at = issued_at + if expires is None: + self.__expires = time.time() * 1000 + TokenDetails.DEFAULTS['ttl'] + else: + self.__expires = expires + self.__token = token + self.__issued = issued self.__capability = Capability(capability or {}) self.__client_id = client_id @property - def id(self): - return self.__id + def token(self): + return self.__token @property def expires(self): return self.__expires @property - def issued_at(self): - return self.__issued_at + def issued(self): + return self.__issued @property def capability(self): @@ -32,12 +43,49 @@ def capability(self): def client_id(self): return self.__client_id + def to_dict(self): + return { + 'expires': self.expires, + 'token': self.token, + 'issued': self.issued, + 'capability': self.capability.to_dict(), + 'clientId': self.client_id, + } + @staticmethod def from_dict(obj): - return TokenDetails( - id=obj.get("id"), - expires=int(obj.get("expires", 0)), - issued_at=int(obj.get("issued_at", 0)), - capability=obj.get("capability"), - client_id=obj.get("clientId") - ) + kwargs = { + 'token': obj.get("token"), + 'capability': obj.get("capability"), + 'client_id': obj.get("clientId") + } + expires = obj.get("expires") + kwargs['expires'] = expires if expires is None else int(expires) + issued = obj.get("issued") + kwargs['issued'] = issued if issued is None else int(issued) + + return TokenDetails(**kwargs) + + @staticmethod + def from_json(data): + if isinstance(data, str): + data = json.loads(data) + + mapping = { + 'clientId': 'client_id', + } + for name in data: + py_name = mapping.get(name) + if py_name: + data[py_name] = data.pop(name) + + return TokenDetails(**data) + + def __eq__(self, other): + if isinstance(other, TokenDetails): + return (self.expires == other.expires + and self.token == other.token + and self.issued == other.issued + and self.capability == other.capability + and self.client_id == other.client_id) + return NotImplemented diff --git a/ably/types/tokenrequest.py b/ably/types/tokenrequest.py new file mode 100644 index 00000000..3998175a --- /dev/null +++ b/ably/types/tokenrequest.py @@ -0,0 +1,107 @@ +import base64 +import hashlib +import hmac +import json + + +class TokenRequest: + + def __init__(self, key_name=None, client_id=None, nonce=None, mac=None, + capability=None, ttl=None, timestamp=None): + self.__key_name = key_name + self.__client_id = client_id + self.__nonce = nonce + self.__mac = mac + self.__capability = capability + self.__ttl = ttl + self.__timestamp = timestamp + + def sign_request(self, key_secret): + sign_text = "\n".join([str(x) for x in [ + self.key_name or "", + self.ttl or "", + self.capability or "", + self.client_id or "", + f"{self.timestamp or 0}", + self.nonce or "", + "", # to get the trailing new line + ]]) + try: + key_secret = key_secret.encode('utf8') + except AttributeError: + pass + try: + sign_text = sign_text.encode('utf8') + except AttributeError: + pass + mac = hmac.new(key_secret, sign_text, hashlib.sha256).digest() + self.mac = base64.b64encode(mac).decode('utf8') + + def to_dict(self): + return { + 'keyName': self.key_name, + 'clientId': self.client_id, + 'ttl': self.ttl, + 'nonce': self.nonce, + 'capability': self.capability, + 'timestamp': self.timestamp, + 'mac': self.mac + } + + @staticmethod + def from_json(data): + if isinstance(data, str): + data = json.loads(data) + + mapping = { + 'keyName': 'key_name', + 'clientId': 'client_id', + } + for name, py_name in mapping.items(): + if name in data: + data[py_name] = data.pop(name) + + return TokenRequest(**data) + + def __eq__(self, other): + if isinstance(other, TokenRequest): + return (self.key_name == other.key_name + and self.client_id == other.client_id + and self.nonce == other.nonce + and self.mac == other.mac + and self.capability == other.capability + and self.ttl == other.ttl + and self.timestamp == other.timestamp) + return NotImplemented + + @property + def key_name(self): + return self.__key_name + + @property + def client_id(self): + return self.__client_id + + @property + def nonce(self): + return self.__nonce + + @property + def mac(self): + return self.__mac + + @mac.setter + def mac(self, mac): + self.__mac = mac + + @property + def capability(self): + return self.__capability + + @property + def ttl(self): + return self.__ttl + + @property + def timestamp(self): + return self.__timestamp diff --git a/ably/types/typedbuffer.py b/ably/types/typedbuffer.py index ee39ba51..656f8947 100644 --- a/ably/types/typedbuffer.py +++ b/ably/types/typedbuffer.py @@ -1,15 +1,11 @@ # This functionality is depreceated and will be removed # Message Pack is the replacement for all binary data messages -from __future__ import absolute_import - import json import struct -import six - -class DataType(object): +class DataType: NONE = 0 TRUE = 1 FALSE = 2 @@ -22,26 +18,25 @@ class DataType(object): JSONOBJECT = 9 -class Limits(object): +class Limits: INT32_MAX = 2 ** 31 INT32_MIN = -(2 ** 31 + 1) INT64_MAX = 2 ** 63 INT64_MIN = - (2 ** 63 + 1) -_decoders = {} -_decoders[DataType.TRUE] = lambda b: True -_decoders[DataType.FALSE] = lambda b: False -_decoders[DataType.INT32] = lambda b: struct.unpack('>i', b)[0] -_decoders[DataType.INT64] = lambda b: struct.unpack('>q', b)[0] -_decoders[DataType.DOUBLE] = lambda b: struct.unpack('>d', b)[0] -_decoders[DataType.STRING] = lambda b: b.decode('utf-8') -_decoders[DataType.BUFFER] = lambda b: b -_decoders[DataType.JSONARRAY] = lambda b: json.loads(b.decode('utf-8')) -_decoders[DataType.JSONOBJECT] = lambda b: json.loads(b.decode('utf-8')) +_decoders = {DataType.TRUE: lambda b: True, + DataType.FALSE: lambda b: False, + DataType.INT32: lambda b: struct.unpack('>i', b)[0], + DataType.INT64: lambda b: struct.unpack('>q', b)[0], + DataType.DOUBLE: lambda b: struct.unpack('>d', b)[0], + DataType.STRING: lambda b: b.decode('utf-8'), + DataType.BUFFER: lambda b: b, + DataType.JSONARRAY: lambda b: json.loads(b.decode('utf-8')), + DataType.JSONOBJECT: lambda b: json.loads(b.decode('utf-8'))} -class TypedBuffer(object): +class TypedBuffer: def __init__(self, buffer, type): self.__buffer = buffer self.__type = type @@ -60,43 +55,39 @@ def __ne__(self, other): @staticmethod def from_obj(obj): - type = DataType.NONE - buffer = None - if isinstance(obj, TypedBuffer): return obj - elif isinstance(obj, six.binary_type): - type = DataType.BUFFER + elif isinstance(obj, (bytes, bytearray)): + data_type = DataType.BUFFER buffer = obj - elif isinstance(obj, six.string_types): - type = DataType.STRING + elif isinstance(obj, str): + data_type = DataType.STRING buffer = obj.encode('utf-8') elif isinstance(obj, bool): - type = DataType.TRUE if obj else DataType.FALSE + data_type = DataType.TRUE if obj else DataType.FALSE buffer = None - elif isinstance(obj, six.integer_types): - if obj >= Limits.INT32_MIN and obj <= Limits.INT32_MAX: - type = DataType.INT32 + elif isinstance(obj, int): + if Limits.INT32_MIN <= obj <= Limits.INT32_MAX: + data_type = DataType.INT32 buffer = struct.pack('>i', obj) - elif obj >= Limits.INT64_MIN and obj <= Limits.INT64_MAX: - type = DataType.INT64 + elif Limits.INT64_MIN <= obj <= Limits.INT64_MAX: + data_type = DataType.INT64 buffer = struct.pack('>q', obj) else: - # TODO throw more appropriate exception - raise 'number-too-large' + raise ValueError(f'Number too large {obj}') elif isinstance(obj, float): - type = DataType.DOUBLE + data_type = DataType.DOUBLE buffer = struct.pack('>d', obj) elif isinstance(obj, list): - type = DataType.JSONARRAY - buffer = json.dumps(obj).encode('utf-8') + data_type = DataType.JSONARRAY + buffer = json.dumps(obj, separators=(',', ':')).encode('utf-8') elif isinstance(obj, dict): - type = DataType.JSONOBJECT - buffer = json.dumps(obj).encode('utf-8') + data_type = DataType.JSONOBJECT + buffer = json.dumps(obj, separators=(',', ':')).encode('utf-8') else: - raise 'unsupported-type' + raise TypeError(f'Unexpected object type {type(obj)}') - return TypedBuffer(buffer, type) + return TypedBuffer(buffer, data_type) @property def buffer(self): @@ -107,7 +98,7 @@ def type(self): return self.__type def decode(self): - decoder = _decoders[self.type] - if decoder: + decoder = _decoders.get(self.type) + if decoder is not None: return decoder(self.buffer) - raise 'unsupported-type' + raise ValueError(f'Unsupported data type {self.type}') diff --git a/ably/util/case.py b/ably/util/case.py new file mode 100644 index 00000000..1cfff585 --- /dev/null +++ b/ably/util/case.py @@ -0,0 +1,17 @@ +import re + +first_cap_re = re.compile('(.)([A-Z][a-z]+)') +all_cap_re = re.compile('([a-z0-9])([A-Z])') + + +def camel_to_snake(name): + s1 = first_cap_re.sub(r'\1_\2', name) + return all_cap_re.sub(r'\1_\2', s1).lower() + + +def snake_to_camel(name): + name = name.split('_') + for i in range(1, len(name)): + name[i] = name[i].title() + + return ''.join(name) diff --git a/ably/util/crypto.py b/ably/util/crypto.py index 2ff3e573..8d8ddfd9 100644 --- a/ably/util/crypto.py +++ b/ably/util/crypto.py @@ -1,12 +1,11 @@ -from __future__ import absolute_import - +import base64 import logging -import six -from six.moves import range - -from Crypto.Cipher import AES -from Crypto import Random +try: + from Crypto import Random + from Crypto.Cipher import AES +except ImportError: + from .nocrypto import AES, Random from ably.types.typedbuffer import TypedBuffer from ably.util.exceptions import AblyException @@ -14,10 +13,12 @@ log = logging.getLogger(__name__) -class CipherParams(object): - def __init__(self, algorithm='AES', secret_key=None, iv=None): - self.__algorithm = algorithm +class CipherParams: + def __init__(self, algorithm='AES', mode='CBC', secret_key=None, iv=None): + self.__algorithm = algorithm.upper() self.__secret_key = secret_key + self.__key_length = len(secret_key) * 8 if secret_key is not None else 128 + self.__mode = mode.upper() self.__iv = iv @property @@ -32,25 +33,42 @@ def secret_key(self): def iv(self): return self.__iv + @property + def key_length(self): + return self.__key_length + + @property + def mode(self): + return self.__mode -class CbcChannelCipher(object): + +class CbcChannelCipher: def __init__(self, cipher_params): - self.__secret_key = cipher_params.secret_key or self.__random(32) + self.__secret_key = (cipher_params.secret_key or + self.__random(cipher_params.key_length / 8)) + if isinstance(self.__secret_key, str): + self.__secret_key = self.__secret_key.encode() self.__iv = cipher_params.iv or self.__random(16) self.__block_size = len(self.__iv) + if cipher_params.algorithm != 'AES': + raise NotImplementedError('Only AES algorithm is supported') self.__algorithm = cipher_params.algorithm + if cipher_params.mode != 'CBC': + raise NotImplementedError('Only CBC mode is supported') + self.__mode = cipher_params.mode + self.__key_length = cipher_params.key_length self.__encryptor = AES.new(self.__secret_key, AES.MODE_CBC, self.__iv) def __pad(self, data): padding_size = self.__block_size - (len(data) % self.__block_size) - padding_char = six.int2byte(padding_size) + padding_char = bytes((padding_size,)) padded = data + padding_char * padding_size return padded def __unpad(self, data): - padding_size = six.indexbytes(data, -1) + padding_size = data[-1] if padding_size > len(data): # Too short @@ -62,7 +80,7 @@ def __unpad(self, data): for i in range(padding_size): # Invalid padding bytes - if padding_size != six.indexbytes(data, -i - 1): + if padding_size != data[-i - 1]: raise AblyException('invalid-padding', 0, 0) return data[:-padding_size] @@ -72,17 +90,21 @@ def __random(self, length): return rndfile.read(length) def encrypt(self, plaintext): + if isinstance(plaintext, bytearray): + plaintext = bytes(plaintext) padded_plaintext = self.__pad(plaintext) encrypted = self.__iv + self.__encryptor.encrypt(padded_plaintext) self.__iv = encrypted[-self.__block_size:] return encrypted def decrypt(self, ciphertext): + if isinstance(ciphertext, bytearray): + ciphertext = bytes(ciphertext) iv = ciphertext[:self.__block_size] ciphertext = ciphertext[self.__block_size:] decryptor = AES.new(self.__secret_key, AES.MODE_CBC, iv) decrypted = decryptor.decrypt(ciphertext) - return self.__unpad(decrypted) + return bytearray(self.__unpad(decrypted)) @property def secret_key(self): @@ -92,27 +114,65 @@ def secret_key(self): def iv(self): return self.__iv + @property + def cipher_type(self): + return (f"{self.__algorithm}-{self.__key_length}-{self.__mode}").lower() + class CipherData(TypedBuffer): - pass + ENCODING_ID = 'cipher' + def __init__(self, buffer, type, cipher_type=None, **kwargs): + self.__cipher_type = cipher_type + super().__init__(buffer, type, **kwargs) -DEFAULT_KEYLENGTH = 16 + @property + def encoding_str(self): + return self.ENCODING_ID + '+' + self.__cipher_type + + +DEFAULT_KEYLENGTH = 256 DEFAULT_BLOCKLENGTH = 16 -def get_default_params(key=None): +def generate_random_key(length=DEFAULT_KEYLENGTH): rndfile = Random.new() - key = key or rndfile.read(DEFAULT_KEYLENGTH) - iv = rndfile.read(DEFAULT_BLOCKLENGTH) - return CipherParams(algorithm='AES', secret_key=key, iv=iv) + return rndfile.read(length // 8) + + +def get_default_params(params=None): + if type(params) in [str, bytes]: + raise ValueError("Calling get_default_params with a key directly is deprecated, it expects a params dict") + + key = params.get('key') + algorithm = params.get('algorithm') or 'AES' + iv = params.get('iv') or generate_random_key(DEFAULT_BLOCKLENGTH * 8) + mode = params.get('mode') or 'CBC' + + if not key: + raise ValueError("Crypto.get_default_params: a key is required") + + if isinstance(key, str): + key = base64.b64decode(key) + + cipher_params = CipherParams(algorithm=algorithm, secret_key=key, iv=iv, mode=mode) + validate_cipher_params(cipher_params) + return cipher_params -def get_cipher(cipher_params): - if cipher_params is None: - params = get_default_params() - elif isinstance(cipher_params, CipherParams): - params = cipher_params +def get_cipher(params): + if isinstance(params, CipherParams): + cipher_params = params else: - raise AblyException("ChannelOptions not supported", 400, 40000) - return CbcChannelCipher(params) + cipher_params = get_default_params(params) + return CbcChannelCipher(cipher_params) + + +def validate_cipher_params(cipher_params): + if cipher_params.algorithm == 'AES' and cipher_params.mode == 'CBC': + key_length = cipher_params.key_length + if key_length == 128 or key_length == 256: + return + raise ValueError( + f'Unsupported key length {key_length} for aes-cbc encryption. Encryption key must be 128 or 256 bits' + ' (16 or 32 ASCII characters)') diff --git a/ably/util/encoding.py b/ably/util/encoding.py new file mode 100644 index 00000000..5187aec2 --- /dev/null +++ b/ably/util/encoding.py @@ -0,0 +1,38 @@ +import base64 +import json +from typing import Any + +from ably.util.crypto import CipherData +from ably.util.exceptions import AblyException + + +def encode_data(data: Any, encoding_array: list, binary: bool = False): + encoding = encoding_array[:] + + if isinstance(data, (dict, list)): + encoding.append('json') + data = json.dumps(data) # json.dumps already returns str + elif isinstance(data, str) and not binary: + pass + elif not binary and isinstance(data, (bytearray, bytes)): + data = base64.b64encode(data).decode('ascii') + encoding.append('base64') + elif isinstance(data, CipherData): + encoding.append(data.encoding_str) + if not binary: + data = base64.b64encode(data.buffer).decode('ascii') + encoding.append('base64') + else: + data = data.buffer + elif binary and isinstance(data, bytearray): + data = bytes(data) + + result = { 'data': data } + + if not (isinstance(data, (bytes, str, list, dict, bytearray)) or data is None): + raise AblyException("Invalid data payload", 400, 40011) + + if encoding: + result['encoding'] = '/'.join(encoding).strip('/') + + return result diff --git a/ably/util/eventemitter.py b/ably/util/eventemitter.py new file mode 100644 index 00000000..74f0beb6 --- /dev/null +++ b/ably/util/eventemitter.py @@ -0,0 +1,186 @@ +import asyncio +import logging + +from pyee.asyncio import AsyncIOEventEmitter + +from ably.util.helper import is_callable_or_coroutine + +# pyee's event emitter doesn't support attaching a listener to all events +# so to patch it, we create a wrapper which uses two event emitters, one +# is used to listen to all events and this arbitrary string is the event name +# used to emit all events on that listener +_all_event = 'all' + +log = logging.getLogger(__name__) + + +def _is_named_event_args(*args): + return len(args) == 2 and is_callable_or_coroutine(args[1]) + + +def _is_all_event_args(*args): + return len(args) == 1 and is_callable_or_coroutine(args[0]) + + +class EventEmitter: + """ + A generic interface for event registration and delivery used in a number of the types in the Realtime client + library. For example, the Connection object emits events for connection state using the EventEmitter pattern. + + Methods + ------- + on(*args) + Attach to channel + once(*args) + Detach from channel + off() + Subscribe to messages on a channel + """ + + def __init__(self): + self.__named_event_emitter = AsyncIOEventEmitter() + self.__all_event_emitter = AsyncIOEventEmitter() + self.__wrapped_listeners = {} + + def on(self, *args): + """ + Registers the provided listener for the specified event, if provided, and otherwise for all events. + If on() is called more than once with the same listener and event, the listener is added multiple times to + its listener registry. Therefore, as an example, assuming the same listener is registered twice using + on(), and an event is emitted once, the listener would be invoked twice. + + Parameters + ---------- + name : str + The named event to listen for. + listener : callable + The event listener. + """ + if _is_all_event_args(*args): + event = _all_event + listener = args[0] + emitter = self.__all_event_emitter + # self.__all_event_emitter.add_listener(_all_event, args[0]) + elif _is_named_event_args(*args): + event = args[0] + listener = args[1] + emitter = self.__named_event_emitter + # self.__named_event_emitter.add_listener(args[0], args[1]) + else: + raise ValueError("EventEmitter.on(): invalid args") + + if asyncio.iscoroutinefunction(listener): + async def wrapped_listener(*args, **kwargs): + try: + await listener(*args, **kwargs) + except Exception as err: + log.exception(f'EventEmitter.emit(): uncaught listener exception: {err}') + else: + def wrapped_listener(*args, **kwargs): + try: + listener(*args, **kwargs) + except Exception as err: + log.exception(f'EventEmitter.emit(): uncaught listener exception: {err}') + + self.__wrapped_listeners[listener] = wrapped_listener + + emitter.add_listener(event, wrapped_listener) + + def once(self, *args): + """ + Registers the provided listener for the first event that is emitted. If once() is called more than once + with the same listener, the listener is added multiple times to its listener registry. Therefore, as an + example, assuming the same listener is registered twice using once(), and an event is emitted once, the + listener would be invoked twice. However, all subsequent events emitted would not invoke the listener as + once() ensures that each registration is only invoked once. + + Parameters + ---------- + name : str + The named event to listen for. + listener : callable + The event listener. + """ + if _is_all_event_args(*args): + event = _all_event + listener = args[0] + emitter = self.__all_event_emitter + # self.__all_event_emitter.add_listener(_all_event, args[0]) + elif _is_named_event_args(*args): + event = args[0] + listener = args[1] + emitter = self.__named_event_emitter + # self.__named_event_emitter.add_listener(args[0], args[1]) + else: + raise ValueError("EventEmitter.on(): invalid args") + + if asyncio.iscoroutinefunction(listener): + async def wrapped_listener(*args, **kwargs): + try: + await listener(*args, **kwargs) + except Exception as err: + log.exception(f'EventEmitter.emit(): uncaught listener exception: {err}') + else: + def wrapped_listener(*args, **kwargs): + try: + listener(*args, **kwargs) + except Exception as err: + log.exception(f'EventEmitter.emit(): uncaught listener exception: {err}') + + self.__wrapped_listeners[listener] = wrapped_listener + + emitter.once(event, wrapped_listener) + + def off(self, *args): + """ + Removes all registrations that match both the specified listener and, if provided, the specified event. + If called with no arguments, deregisters all registrations, for all events and listeners. + + Parameters + ---------- + name : str + The named event to listen for. + listener : callable + The event listener. + """ + if len(args) == 0: + self.__all_event_emitter.remove_all_listeners() + self.__named_event_emitter.remove_all_listeners() + return + elif _is_all_event_args(*args): + event = _all_event + listener = args[0] + emitter = self.__all_event_emitter + elif _is_named_event_args(*args): + event = args[0] + listener = args[1] + emitter = self.__named_event_emitter + else: + raise ValueError("EventEmitter.once(): invalid args") + + wrapped_listener = self.__wrapped_listeners.get(listener) + + if wrapped_listener is None: + return + + emitter.remove_listener(event, wrapped_listener) + self.__wrapped_listeners[listener] = None + + async def once_async(self, state=None): + future = asyncio.Future() + + def on_state_change(*args): + future.set_result(*args) + + if state is not None: + self.once(state, on_state_change) + else: + self.once(on_state_change) + + state_change = await future + + return state_change + + def _emit(self, *args): + self.__named_event_emitter.emit(*args) + self.__all_event_emitter.emit(_all_event, *args[1:]) diff --git a/ably/util/exceptions.py b/ably/util/exceptions.py index 2f2640b8..a8bbae39 100644 --- a/ably/util/exceptions.py +++ b/ably/util/exceptions.py @@ -1,64 +1,110 @@ -from __future__ import absolute_import - import functools import logging -import six -from ably.util.unicodemixin import UnicodeMixin +import msgpack log = logging.getLogger(__name__) -class AblyException(BaseException, UnicodeMixin): - def __init__(self, reason, status_code, code): - super(AblyException, self).__init__() - self.reason = reason +class AblyException(Exception): + def __new__(cls, message, status_code, code, cause=None): + if cls == AblyException and status_code == 401: + return AblyAuthException(message, status_code, code, cause) + return super().__new__(cls, message, status_code, code, cause) + + def __init__(self, message, status_code, code, cause=None): + super().__init__() + self.message = message self.code = code self.status_code = status_code + self.cause = cause - def __unicode__(self): - return six.u('%s %s %s') % (self.code, self.status_code, self.reason) + def __str__(self): + str = f'{self.code} {self.status_code} {self.message}' + if self.cause is not None: + str += f' (cause: {self.cause})' + return str + + @property + def is_server_error(self): + return 500 <= self.status_code <= 599 @staticmethod def raise_for_response(response): - if response.status_code >= 200 and response.status_code < 300: + if 200 <= response.status_code < 300: # Valid response return try: - json_response = response.json() - if json_response: - try: - raise AblyException(json_response['reason'], - json_response['statusCode'], - json_response['code']) - except KeyError: - msg = "Unexpected exception decoding server response: %s" - msg = msg % response.text - raise AblyException(msg, 500, 50000) - except: - log.debug("Response: %d %s", response.status_code, response.text) - raise AblyException( - response.text, - response.status_code, - response.status_code * 100) - - raise AblyException("", response.status_code, response.status_code*100) + decoded_response = AblyException.decode_error_response(response) + except Exception: + log.debug("Response not json or msgpack: %d %s", + response.status_code, + response.text) + raise AblyException(message=response.text, + status_code=response.status_code, + code=response.status_code * 100) from None + + if decoded_response and 'error' in decoded_response: + error = decoded_response['error'] + try: + raise AblyException( + message=error['message'], + status_code=error['statusCode'], + code=int(error['code']), + ) + except KeyError: + msg = "Unexpected exception decoding server response: %s" + msg = msg % response.text + raise AblyException(message=msg, status_code=500, code=50000) from None + + raise AblyException(message="", + status_code=response.status_code, + code=response.status_code * 100) + + @staticmethod + def decode_error_response(response): + content_type = response.headers.get('content-type') + if isinstance(content_type, str): + if content_type.startswith('application/x-msgpack'): + return msgpack.unpackb(response.content) + elif content_type.startswith('application/json'): + return response.json() + + raise ValueError("Unsupported content type") @staticmethod def from_exception(e): if isinstance(e, AblyException): return e - return AblyException("Unexpected exception: %s" % e, 500, 50000) + exc_type = type(e).__name__ + exc_msg = str(e) + if exc_msg: + message = f"{exc_type}: {exc_msg}" + else: + message = exc_type + return AblyException(f"Unexpected exception: {message}", 500, 50000) + + @staticmethod + def from_dict(value: dict): + return AblyException(value.get('message'), value.get('statusCode'), value.get('code')) def catch_all(func): @functools.wraps(func) - def wrapper(*args, **kwargs): + async def wrapper(*args, **kwargs): try: - return func(*args, **kwargs) + return await func(*args, **kwargs) except Exception as e: log.exception(e) - raise AblyException.from_exception(e) + raise AblyException.from_exception(e) from e return wrapper + + +class AblyAuthException(AblyException): + pass + + +class IncompatibleClientIdException(AblyException): + pass diff --git a/ably/util/helper.py b/ably/util/helper.py new file mode 100644 index 00000000..a35ebe6e --- /dev/null +++ b/ably/util/helper.py @@ -0,0 +1,110 @@ +import asyncio +import inspect +import json +import random +import string +import time +from typing import Callable, Dict, Tuple +from urllib.parse import parse_qs, urlparse + +import msgpack + +from ably.util.exceptions import AblyException + + +def get_random_id(): + # get random string of letters and digits + source = string.ascii_letters + string.digits + random_id = ''.join(random.choice(source) for i in range(8)) + return random_id + + +def is_callable_or_coroutine(value): + return asyncio.iscoroutinefunction(value) or inspect.isfunction(value) or inspect.ismethod(value) + + +def unix_time_ms(): + return round(time.time_ns() / 1_000_000) + + +def is_token_error(exception): + return 40140 <= exception.code < 40150 + + +def extract_url_params(url: str) -> Tuple[str, Dict[str, str]]: + """ + Extract URL parameters from a URL and return a clean URL and parameters dict. + + Args: + url: The URL to parse + + Returns: + Tuple of (clean_url_without_params, url_params_dict) + """ + parsed_url = urlparse(url) + url_params = {} + + if parsed_url.query: + # Convert query parameters to a flat dictionary + query_params = parse_qs(parsed_url.query) + for key, values in query_params.items(): + # Take the last value if multiple values exist for the same key + url_params[key] = values[-1] + + # Reconstruct clean URL without query parameters + clean_url = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" + if parsed_url.fragment: + clean_url += f"#{parsed_url.fragment}" + + return clean_url, url_params + + +class Timer: + def __init__(self, timeout: float, callback: Callable): + self._timeout = timeout + self._callback = callback + self._task = asyncio.create_task(self._job()) + + async def _job(self): + await asyncio.sleep(self._timeout / 1000) + if asyncio.iscoroutinefunction(self._callback): + await self._callback() + else: + self._callback() + + def cancel(self): + self._task.cancel() + +def validate_message_size(encoded_messages: list, use_binary_protocol: bool, max_message_size: int) -> None: + """Validate that encoded messages don't exceed the maximum size limit. + + Args: + encoded_messages: List of encoded message dictionaries + use_binary_protocol: Whether to use binary (msgpack) or JSON encoding + max_message_size: Maximum allowed size in bytes + + Raises: + AblyException: If the encoded messages exceed the maximum size + """ + if use_binary_protocol: + size = len(msgpack.packb(encoded_messages, use_bin_type=True)) + else: + size = len(json.dumps(encoded_messages, separators=(',', ':')).encode('utf-8')) + + if size > max_message_size: + raise AblyException( + f"Maximum size of messages that can be published at once exceeded " + f"(was {size} bytes; limit is {max_message_size} bytes)", + 400, + 40009, + ) + +def to_text(value): + if value is None: + return value + elif isinstance(value, str): + return value + elif isinstance(value, bytes): + return value.decode() + else: + raise TypeError(f"expected string or bytes, not {type(value)}") diff --git a/ably/util/nocrypto.py b/ably/util/nocrypto.py new file mode 100644 index 00000000..a66669b3 --- /dev/null +++ b/ably/util/nocrypto.py @@ -0,0 +1,9 @@ + +class InstallPycrypto: + def __getattr__(self, name): + raise ImportError( + "This requires to install ably with crypto support: pip install 'ably[crypto]'" + ) + + +AES = Random = InstallPycrypto() diff --git a/ably/util/unicodemixin.py b/ably/util/unicodemixin.py deleted file mode 100644 index bfbe72f5..00000000 --- a/ably/util/unicodemixin.py +++ /dev/null @@ -1,10 +0,0 @@ -import six - - -class UnicodeMixin(object): - if six.PY3: - def __str__(self): - return self.__unicode__() - else: - def __str__(self): - return self.__unicode__().encode('utf8') diff --git a/ably/vcdiff/__init__.py b/ably/vcdiff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ably/vcdiff/defaultvcdiffdecoder.py b/ably/vcdiff/defaultvcdiffdecoder.py new file mode 100644 index 00000000..ae2d3263 --- /dev/null +++ b/ably/vcdiff/defaultvcdiffdecoder.py @@ -0,0 +1,82 @@ +""" +VCDiff Decoder for Ably Python SDK + +This module provides a production-ready VCDiff decoder using the vcdiff-decoder library. +It implements the VCDiffDecoder interface. + +Usage: + from ably.vcdiff import AblyVCDiffDecoder, AblyRealtime + + # Create VCDiff decoder + vcdiff_decoder = AblyVCDiffDecoder() + + # Create client with decoder + client = AblyRealtime(key="your-key", vcdiff_decoder=vcdiff_decoder) + + # Get channel with delta enabled + channel = client.channels.get("test", ChannelOptions(params={"delta": "vcdiff"})) +""" + +import logging + +from ably.types.options import VCDiffDecoder +from ably.util.exceptions import AblyException + +log = logging.getLogger(__name__) + + +class AblyVCDiffDecoder(VCDiffDecoder): + """ + Production VCDiff decoder using Ably's vcdiff-decoder library. + + Raises: + ImportError: If vcdiff is not installed + AblyException: If VCDiff decoding fails + """ + + def __init__(self): + """Initialize the VCDiff plugin. + + Raises: + ImportError: If vcdiff-decoder library is not available + """ + try: + import vcdiff_decoder as vcdiff + self._vcdiff = vcdiff + except ImportError as e: + log.error("vcdiff library not found. Install with: pip install ably[vcdiff]") + raise ImportError( + "VCDiff plugin requires vcdiff library. " + "Install with: pip install ably[vcdiff]" + ) from e + + def decode(self, delta: bytes, base: bytes) -> bytes: + """ + Decode a VCDiff delta against a base payload. + + Args: + delta: The VCDiff-encoded delta data + base: The base payload to apply the delta to + + Returns: + bytes: The decoded message payload + + Raises: + AblyException: If VCDiff decoding fails (error code 40018) + """ + if not isinstance(delta, bytes): + raise TypeError("Delta must be bytes") + if not isinstance(base, bytes): + raise TypeError("Base must be bytes") + + try: + # Use the vcdiff library to decode + result = self._vcdiff.decode(base, delta) + return result + except Exception as e: + log.error(f"VCDiff decode failed: {e}") + raise AblyException(f"VCDiff decode failure: {e}", 40018, 40018) from e + + +# Export for easy importing +__all__ = ['AblyVCDiffDecoder'] diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..62c6a7e9 --- /dev/null +++ b/conftest.py @@ -0,0 +1,5 @@ + + +# Configure pytest-asyncio +pytest_plugins = ('pytest_asyncio',) + diff --git a/images/pythonSDK-github.png b/images/pythonSDK-github.png new file mode 100644 index 00000000..1fd7f1be Binary files /dev/null and b/images/pythonSDK-github.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..e4dbab6e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,118 @@ +[project] +name = "ably" +version = "3.1.2" +description = "Python REST and Realtime client library SDK for Ably realtime messaging service" +readme = "LONG_DESCRIPTION.rst" +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", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "msgpack>=1.0.0,<2.0.0", + "httpx>=0.24.1,<1.0; python_version=='3.7'", + "httpx>=0.25.0,<1.0; python_version>='3.8'", + "h2>=4.1.0,<5.0.0", + "websockets>=10.0,<12.0; python_version=='3.7'", + "websockets>=12.0,<15.0; python_version=='3.8'", + "websockets>=15.0,<16.0; python_version>='3.9'", + "pyee>=9.0.4,<10.0.0; python_version=='3.7'", + "pyee>=11.1.0,<14.0.0; python_version>='3.8'", +] + +[project.optional-dependencies] +oldcrypto = ["pycrypto>=2.6.1,<3.0.0"] +crypto = ["pycryptodome"] +vcdiff = ["vcdiff-decoder>=0.1.0,<0.2.0"] +dev = [ + "pytest>=7.1,<8.0", + "pytest-asyncio>=0.21.0,<0.23.0; python_version=='3.7'", + "pytest-asyncio>=0.23.0,<1.0.0; python_version>='3.8'", + "mock>=4.0.3,<5.0.0", + "pytest-cov>=2.4,<3.0", + "ruff>=0.14.0,<1.0.0", + "pytest-xdist>=1.15,<2.0", + "respx>=0.20.0,<0.21.0; python_version=='3.7'", + "respx>=0.22.0,<0.23.0; python_version>='3.8'", + "importlib-metadata>=4.12,<5.0", + "pytest-timeout>=2.1.0,<3.0.0", + "async-case>=10.1.0,<11.0.0; python_version=='3.7'", + "tokenize_rt", + "vcdiff-decoder>=0.1.0a1", +] + +[project.scripts] +unasync = "ably.scripts.unasync:run" + +[project.urls] +Homepage = "https://ably.com" +Repository = "https://github.com/ably/ably-python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +ignore-vcs = true +include = [ + "/ably", + "/COPYRIGHT", + "/README.md", + "/LICENSE", + "/LONG_DESCRIPTION.rst", + "/images", + "/setup.cfg", + "/pyproject.toml" +] +exclude = [ + "**/*.pyc", + "**/__pycache__" +] + +[tool.hatch.build.targets.wheel] +ignore-vcs = true +packages = ["ably"] + +[tool.pytest.ini_options] +timeout = 30 +asyncio_mode = "auto" + +[[tool.uv.index]] +name = "experimental" +url = "https://test.pypi.org/simple/" +explicit = true + +[tool.ruff] +line-length = 115 +extend-exclude = [ + "ably/sync", + "test/ably/sync", +] + +[tool.ruff.lint] +# Enable Pyflakes (F), pycodestyle (E, W), pep8-naming (N), isort (I), pyupgrade (UP), bugbear (B) and comprehensions (C4) +select = ["E", "W", "F", "N", "I", "UP", "B", "C4"] +ignore = [ + "N818", # exception name should end in 'Error' + "UP026", # mock -> unittest.mock (need mock package for Python 3.7 AsyncMock support) +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # imported but unused diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6859a9d3..00000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -msgpack-python==0.4.6 -pycrypto==2.6.1 -requests==2.6.0 -six==1.9.0 -#wsgiref==0.1.2,<2 diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 00000000..d0d75494 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,205 @@ +# Ably Python Client Library SDK: Roadmap + +This document outlines our plans for the evolution of this SDK. + +## Milestone 1: Realtime Channel Subscription ✅ + +Once we've completed the scope and objectives detailed in this milestone, +we'll be in a good position to make a release in order to start getting feedback from customers. + +That release will allow applications built against it to: + +- Create a persistent Realtime connection to the Ably service +- Subscribe to Ably channels in order to receive messages over that connection + +That release will come with the following known limitations: + +- No resilience to single Ably endpoint failure. To be implemented under [Milestone 2: Realtime Connectivity Hardening](#milestone-2-realtime-connectivity-hardening). +- No support for [Token authentication](https://ably.com/docs/core-features/authentication#token-authentication), meaning that it only supports authentication by directly using a 'raw' Ably API key ([Basic authentication](https://ably.com/docs/core-features/authentication#basic-authentication)). To be implemented under [Milestone 3: Token Authentication](#milestone-3-token-authentication). +- No capability to publish over the Realtime connection. To be implemented under [Milestone 4: Realtime Channel Publish](#milestone-4-realtime-channel-publish). +- No capability to receive or publish member presence messages for a channel over the Realtime connection. To be implemented under [Milestone 5: Realtime Channel Presence](#milestone-5-realtime-channel-presence). + +### Milestone 1a: Solidify Existing Foundations ✅ + +Ensure the current source code is in a good enough state to build upon. +This means solving currently known pain points (development environment stabilisation) as well as reassessing our baselines. + +**Scope**: + +- Resolve issues with dependency pinning +- Ensure linter is pulling its weight - state of the art changes fast in this area, so we should assess what rules are enabled, which are not, what we could be leveraging, etc.. +- Check language and runtime requirements, in case any of them can be increased in order for us to be able to use more modern foundation features of Python + +**Objective**: Achieve confidence that we have foundations we can confidently build upon, knowing what's coming up in future milestones. + +### Milestone 1b: Establish Realtime Foundations and Connect ✅ + +**Scope**: + +- pick a WebSocket library +- pick an event model (async/await vs dedicated thread) +- establish connection with basic credentials (Ably API key passed in through Authorization header) + - triggering on explicit call to `client.connect()` rather than autoConnect + +**Objective**: Successfully connect to Ably Realtime. + +### Milestone 1c: Realtime Connection Lifecycle ✅ + +The basic foundations of Realtime connectivity, plus client identification (`Agent`). + +**Scope**: + +- send `Ably-Agent` header when establishing WebSocket connection ([`RSC7d2`](https://docs.ably.io/client-lib-development-guide/features/#RSC7d2)) +- loop to read protocol messages from the WebSocket +- handle basic connectivity messages: `CONNECTED`, `DISCONNECTED`, `CLOSED`, `ERROR` +- handle `HEARTBEAT` messages +- Connection state machine +- queryable connection state + - consider whether there is a Python-idiomatic alternative to blindly implementing `EventEmitter` + +**Objective**: Track connection state and offer API to query it. + +### Milestone 1d: Basic Realtime-Client-initiated Messages ✅ + +Give our users some control. + +**Scope**: + +- client to service `CLOSE` ([`RTC16`](https://docs.ably.io/client-lib-development-guide/features/#RTC16)) +- ping ([`RTN13`](https://docs.ably.io/client-lib-development-guide/features/#RTN13)) + - loop to read messages from user + - send a ping (`HEARTBEAT`) + - wait for a response (`HEARTBEAT`) + - callback to user with timing info + +**Objective**: Provide APIs for sending basic messages to the service, +resulting in proof-of-life / smoke-test proving interactions with the event model chosen in [1b](#milestone-1b-establish-realtime-foundations-and-connect). + +### Milestone 1e: Attach and Subscribe ✅ + +Start receiving messages from the Ably service. + +**Scope**: + +- channels, including: + - Channels.get ([`RTS3c`](https://docs.ably.io/client-lib-development-guide/features/#RTS3c)) + - Channels.release ([`RTS34`](https://docs.ably.io/client-lib-development-guide/features/RTS34)) + - RealtimeChannel state machine + - attach ([`RTL4`](https://docs.ably.io/client-lib-development-guide/features/#RTL4)) + - detach ([`RTL5`](https://docs.ably.io/client-lib-development-guide/features/#RTL5)) + - subscribe ([`RTL7`](https://docs.ably.io/client-lib-development-guide/features/#RTL7)) / unsubscribe ([`RTL8`](https://docs.ably.io/client-lib-development-guide/features/#RTL8)) + - consider whether there is a Python-idiomatic alternative to blindly implementing `EventEmitter` + +**Objective**: Receive application level messages from the network. + +## Milestone 2: Realtime Connectivity Hardening ✅ + +This milestone will add connection error handling to the realtime client, +allowing it to continue operating in the event of a recoverable connection error. +It will also improve the visibility of what went wrong in the event of a fatal connection error. + +### Milestone 2a: Handle connection opening errors ✅ + +Implement the correct behaviour for all potential errors that may occur when establishing a new realtime connection. + +**Scope**: + +- Implement configurable `realtimeRequestTimeout` and transition to `DISCONNECTED` if the initial `CONNECTED` message is not received in time ([`RTN14c`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN14c)) +- Populate the `Connection.errorReason` field when a connection error is encountered ([`RTN14a`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN14a)) +- Transition to `DISCONNECTED` upon recoverable errors as defined by [`RTN14d`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN14d) (network failure, disconnected response) + +**Objective**: Achieve confidence that the library has defined behaviour for all errors it may encounter upon establishing a realtime connection. + +### Milestone 2b: Retry failed connection attempts ✅ + +Attempt to re-establish connection upon a recoverable connection attempt failure and give users visibility of the connection state when the library is doing so. + +**Scope**: + +- Implement configurable `disconnectedRetryTimeout` and retry connection periodically while the connection state is `DISCONNECTED` ([`RTN14d`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN14d)) +- Implement configurable `connectionStateTtl` and transition connection to `SUSPENDED` when `connectionStateTtl` is exceeded ([`RTN14e`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN14e)) +- Fallback hosts are outside of the scope of this milestone: each retry should be against the primary realtime endpoint +- Incremental backoff and jitter is outside of the scope of this milestone + +**Objective**: Allow the library to re-establish connection in the event of a recoverable connection opening failure. + +### Milestone 2c: Use fallback hosts ✅ + +Use fallback hosts in the case of a connection error, allowing the library to still connect to Ably when connection to the primary host is unavailable. + +**Scope**: + +- Implement the `fallbackHosts` client option ([`RTN17b2`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN17b2)) +- Use a new fallback host when encountering an appropriate error ([`RTN17d`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN17d)) +- Implement connectivity check and check connectivity before using a new fallback host ([`RTN17c`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN17c)) + +**Objective**: Make the realtime client resilient when one or more realtime endpoints are unavailable. + +### Milestone 2d: Handle connection errors once connected ✅ + +Handle errors which the realtime client may encounter once already in the `CONNECTED` state, resuming the connection and reattaching to channels when appropriate. + +**Scope**: + +- Implement `maxIdleInterval` and handle `HEARTBEAT` messages and disconnect transport once `maxIdleInterval` is exceeded ([`RTN23`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN23)) +- Handle `CONNECTED` messages once connected ([`RTN24`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN24)) +- Resend protocol messages for pending channels upon resume ([`RTN19b`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN19b)) +- When `connectionStateTtl` elapsed, clear connection state ([`RTN15g`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15g)) +- Immediately reattempt connection when unexpectedly disconnected ([`RTN15a`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15a)) +- Connection resume: + - Send resume query param when reconnecting within `connectionStateTtl` ([`RTN15b`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15b)) + - Handle clean resume response ([`RTN15c6`](https://sdk.ably.com/builds/ably/specification/pull/108/features/#RTN15c6), [`RTL4c`](https://sdk.ably.com/builds/ably/specification/main/features/#RTL4c), [`RTN15e`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15e)) + - Handle invalid resume response ([`RTN15c7`](https://sdk.ably.com/builds/ably/specification/pull/108/features/#RTN15c7)) + - Handle fatal resume error ([`RTN15c4`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15c4)) +- Set the `ATTACH_RESUME` flag on unclean attach ([`RTL4j`](https://sdk.ably.com/builds/ably/specification/main/features/#RTL4j)) +- Emit `update` event on additional `ATTACHED` message ([`RTL12`](https://sdk.ably.com/builds/ably/specification/main/features/#RTL12)) + +**Objective**: Detect connection errors while connected and handle them appropriately. + +## Milestone 3: Token Authentication ✅ + +This milestone will add token-based authentication to the realtime client. + +### Milestone 3a: Enable token-based authentication and re-authentication ✅ + +Implement the expected behavior for successful token-based authentication and re-authentication. + +**Scope**: + +- Allow token auth methods for realtime constructor ([`RTC4`](https://sdk.ably.com/builds/ably/specification/main/features/#RTC4), [`RTC8`](https://sdk.ably.com/builds/ably/specification/main/features/#RTC8)) +- Send `AUTH` protocol message when `Auth.authorize` called on realtime client ([`RTC8`](https://sdk.ably.com/builds/ably/specification/main/features/#RTC8), [`RSA3c`](https://sdk.ably.com/builds/ably/specification/main/features/#RSA3c), [`RSA3d`](https://sdk.ably.com/builds/ably/specification/main/features/#RSA3d)) +- Reauth upon inbound `AUTH` protocol message ([`RTN22`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN22), [`RTC8a`](https://sdk.ably.com/builds/ably/specification/main/features/#RTC8a), [`RTC8a1`](https://sdk.ably.com/builds/ably/specification/main/features/#RTC8a1)) + +**Objective**: Create functionality that will allow the client to authenticate with Ably via tokens. + +### Milestone 3b: Error scenarios ✅ + +Implement the correct handling of edge cases when there are connectivity issues or authentication errors during token-based authentication. + +**Scope**: + +- Handle connection request failure due to token error ([`RTN14b`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN14b), [`RSA4a`](https://sdk.ably.com/builds/ably/specification/main/features/#RSA4a)) +- Handle `DISCONNECTED` messages containing token errors ([`RTN15h`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15h), [`RTN15h1`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15h1), [`RTN15h2`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15h2), [`RTN22a`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN22a)) +- Handle token `ERROR` response to a resume request ([`RTN15c5`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15c5), [`RTN15h`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN15h)) + +**Objective**: Display the correct errors and place client in expected state during error scenarios that may arise during authentication process. + +### Milestone 3c: Client ID ✅ + +Properly handle and set `clientId` attribute during token-based authentication. + +**Scope**: + +- Apply `Auth#clientId` only after a realtime connection has been established ([`RTC4a`](https://sdk.ably.com/builds/ably/specification/main/features/#RTC4a), [`RSA7b3`](https://sdk.ably.com/builds/ably/specification/main/features/#RSA7b3), [`RSA7b4`](https://sdk.ably.com/builds/ably/specification/main/features/#RSA7b4)) +- Validate `clientId` in `ClientOptions` ([`RSA15`](https://sdk.ably.com/builds/ably/specification/main/features/#RSA15)) +- Pass `clientId` as query string param when opening a new connection ([`RTN2d`](https://sdk.ably.com/builds/ably/specification/main/features/#RTN2d)) + +**Objective**: Ensure `clientId` is set after authentication so that it can be used for follow-on development of realtime functionality. + +## Milestone 4: Realtime Channel Publish + +_T.B.D._ + +## Milestone 5: Realtime Channel Presence + +_T.B.D._ diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..6171d1aa --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[coverage:run] +branch=True + +[tool:pytest] +#log_level = DEBUG diff --git a/setup.py b/setup.py deleted file mode 100644 index 52cc6543..00000000 --- a/setup.py +++ /dev/null @@ -1,16 +0,0 @@ -from setuptools import setup - -setup( - name='ably-python', - version='0.1dev', - classifiers=[ - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - ], - packages=['ably',], - install_requires=['requests>=1.0.0',], - long_description='', - test_suite='nose.collector', - tests_require=['nose>=1.0.0',] -) - diff --git a/submodules b/submodules new file mode 160000 index 00000000..dd700951 --- /dev/null +++ b/submodules @@ -0,0 +1 @@ +Subproject commit dd70095146dba8126e1f27e0407fa453304fb659 diff --git a/test.py b/test.py deleted file mode 100644 index 98c57739..00000000 --- a/test.py +++ /dev/null @@ -1,15 +0,0 @@ -import sys -import os -import subprocess as sub - -commands { 'run':None, - 'clean':None, - 'stdout':None, - "":None, -} - - -if __name__ == "__main__": - if len(sys.argv) > 1: - args = sys.argv[1:] - diff --git a/test/ably/__init__.py b/test/ably/__init__.py index ce458fe7..e69de29b 100644 --- a/test/ably/__init__.py +++ b/test/ably/__init__.py @@ -1,8 +0,0 @@ -from test.ably.restsetup import RestSetup - -def setup_package(): - RestSetup.get_test_vars() - -def teardown_package(): - RestSetup.clear_test_vars() - diff --git a/test/ably/conftest.py b/test/ably/conftest.py new file mode 100644 index 00000000..01483272 --- /dev/null +++ b/test/ably/conftest.py @@ -0,0 +1,10 @@ +import pytest_asyncio + +from test.ably.testapp import TestApp + + +@pytest_asyncio.fixture(scope='session', autouse=True) +async def test_app_setup(): + await TestApp.get_test_vars() + yield + await TestApp.clear_test_vars() diff --git a/test/ably/realtime/eventemitter_test.py b/test/ably/realtime/eventemitter_test.py new file mode 100644 index 00000000..71db2c74 --- /dev/null +++ b/test/ably/realtime/eventemitter_test.py @@ -0,0 +1,50 @@ +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +class TestEventEmitter(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + + async def test_event_listener_error(self): + realtime = await TestApp.get_ably_realtime() + call_count = 0 + + def listener(_): + nonlocal call_count + call_count += 1 + raise Exception() + + # If a listener throws an exception it should not propagate (#RTE6) + listener.side_effect = Exception() + realtime.connection.on(ConnectionState.CONNECTED, listener) + + realtime.connect() + await realtime.connection.once_async(ConnectionState.CONNECTED) + + assert call_count == 1 + await realtime.close() + + async def test_event_emitter_off(self): + realtime = await TestApp.get_ably_realtime() + call_count = 0 + + def listener(_): + nonlocal call_count + call_count += 1 + + realtime.connection.on(ConnectionState.CONNECTED, listener) + realtime.connection.off(ConnectionState.CONNECTED, listener) + + await realtime.connection.once_async(ConnectionState.CONNECTED) + + assert call_count == 0 + await asyncio.sleep(0) + assert call_count == 0 + await realtime.close() diff --git a/test/ably/realtime/presencemap_test.py b/test/ably/realtime/presencemap_test.py new file mode 100644 index 00000000..043baeb0 --- /dev/null +++ b/test/ably/realtime/presencemap_test.py @@ -0,0 +1,772 @@ +""" +Unit tests for PresenceMap implementation. + +Tests RTP2 specification requirements for presence map operations. +""" + +from datetime import datetime + +import pytest + +from ably.realtime.presencemap import PresenceMap, _is_newer +from ably.types.presence import PresenceAction, PresenceMessage +from test.ably.utils import BaseAsyncTestCase + + +class TestPresenceMessageHelpers(BaseAsyncTestCase): + """Test helper methods on PresenceMessage (RTP2b support).""" + + def test_is_synthesized_with_matching_connection_id(self): + """Test that normal messages are not synthesized.""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + assert not msg.is_synthesized() + + def test_is_synthesized_with_non_matching_connection_id(self): + """Test that synthesized leave events are detected (RTP2b1).""" + msg = PresenceMessage( + id='different:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.LEAVE + ) + assert msg.is_synthesized() + + def test_is_synthesized_without_id(self): + """Test that messages without id are not considered synthesized.""" + msg = PresenceMessage( + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + assert not msg.is_synthesized() + + def test_parse_id_valid(self): + """Test parsing valid presence message id (RTP2b2).""" + msg = PresenceMessage( + id='connection123:42:7', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + parsed = msg.parse_id() + assert parsed['msgSerial'] == 42 + assert parsed['index'] == 7 + + def test_parse_id_without_id(self): + """Test parsing message without id raises ValueError.""" + msg = PresenceMessage( + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + with pytest.raises(ValueError) as context: + msg.parse_id() + assert "id is None or empty" in str(context.value) + + def test_parse_id_invalid_format(self): + """Test parsing invalid id format raises ValueError.""" + msg = PresenceMessage( + id='invalid', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + with pytest.raises(ValueError) as context: + msg.parse_id() + assert "invalid msgSerial or index" in str(context.value) + + def test_parse_id_non_numeric_parts(self): + """Test parsing id with non-numeric msgSerial/index raises ValueError.""" + msg = PresenceMessage( + id='connection123:abc:def', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + with pytest.raises(ValueError) as context: + msg.parse_id() + assert "invalid msgSerial or index" in str(context.value) + + def test_member_key_property(self): + """Test member_key property (TP3h).""" + msg = PresenceMessage( + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + assert msg.member_key == 'connection123:client1' + + def test_member_key_without_connection_id(self): + """Test member_key when connection_id is missing.""" + msg = PresenceMessage( + client_id='client1', + action=PresenceAction.PRESENT + ) + assert msg.member_key is None + + def test_to_encoded(self): + """Test converting message to wire format.""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data='test data', + timestamp=datetime(2024, 1, 1, 12, 0, 0) + ) + encoded = msg.to_encoded() + assert encoded['action'] == PresenceAction.ENTER + assert encoded['id'] == 'connection123:0:0' + assert encoded['connectionId'] == 'connection123' + assert encoded['clientId'] == 'client1' + assert encoded['data'] == 'test data' + assert 'timestamp' in encoded + + def test_to_encoded_with_dict_data(self): + """Test converting message with dict data (should be JSON serialized).""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data={'key': 'value', 'number': 42} + ) + encoded = msg.to_encoded() + assert encoded['data'] == '{"key": "value", "number": 42}' + assert encoded['encoding'] == 'json' + + def test_to_encoded_with_list_data(self): + """Test converting message with list data (should be JSON serialized).""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data=['item1', 'item2', 3] + ) + encoded = msg.to_encoded() + assert encoded['data'] == '["item1", "item2", 3]' + assert encoded['encoding'] == 'json' + + def test_to_encoded_with_binary_data(self): + """Test converting message with binary data (should be base64 encoded).""" + import base64 + binary_data = b'binary data here' + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data=binary_data + ) + encoded = msg.to_encoded() + assert encoded['data'] == base64.b64encode(binary_data).decode('ascii') + assert encoded['encoding'] == 'base64' + + def test_to_encoded_with_bytearray_data(self): + """Test converting message with bytearray data (should be base64 encoded).""" + import base64 + binary_data = bytearray(b'bytearray data') + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data=binary_data + ) + encoded = msg.to_encoded() + assert encoded['data'] == base64.b64encode(binary_data).decode('ascii') + assert encoded['encoding'] == 'base64' + + def test_to_encoded_with_existing_encoding(self): + """Test that existing encoding is preserved and appended to.""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data=b'test', + encoding='utf-8' + ) + encoded = msg.to_encoded() + assert 'utf-8' in encoded['encoding'] + assert 'base64' in encoded['encoding'] + assert encoded['encoding'] == 'utf-8/base64' + + def test_to_encoded_binary_mode(self): + """Test converting message in binary mode (no base64 encoding).""" + binary_data = b'binary data' + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data=binary_data + ) + encoded = msg.to_encoded(binary=True) + assert encoded['data'] == binary_data + assert 'encoding' not in encoded # No base64 added in binary mode + + def test_from_encoded_array(self): + """Test decoding array of presence messages.""" + encoded_array = [ + { + 'id': 'conn1:0:0', + 'action': PresenceAction.ENTER, + 'clientId': 'client1', + 'connectionId': 'conn1', + 'data': 'data1' + }, + { + 'id': 'conn2:0:0', + 'action': PresenceAction.PRESENT, + 'clientId': 'client2', + 'connectionId': 'conn2', + 'data': 'data2' + } + ] + messages = PresenceMessage.from_encoded_array(encoded_array) + assert len(messages) == 2 + assert messages[0].client_id == 'client1' + assert messages[1].client_id == 'client2' + + +class TestNewnessComparison(BaseAsyncTestCase): + """Test newness comparison logic (RTP2b).""" + + def test_synthesized_message_newer_by_timestamp(self): + """Test RTP2b1: synthesized messages compared by timestamp.""" + older = PresenceMessage( + id='different:0:0', # Synthesized (doesn't match connection_id) + connection_id='connection123', + client_id='client1', + action=PresenceAction.LEAVE, + timestamp=datetime(2024, 1, 1, 12, 0, 0) + ) + newer = PresenceMessage( + id='connection123:5:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT, + timestamp=datetime(2024, 1, 1, 12, 0, 1) + ) + assert _is_newer(newer, older) + assert not _is_newer(older, newer) + + def test_synthesized_equal_timestamp_incoming_wins(self): + """Test RTP2b1a: equal timestamps, incoming is newer.""" + timestamp = datetime(2024, 1, 1, 12, 0, 0) + existing = PresenceMessage( + id='different:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.LEAVE, + timestamp=timestamp + ) + incoming = PresenceMessage( + id='other:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.LEAVE, + timestamp=timestamp + ) + # Incoming should be considered newer (>=) + assert _is_newer(incoming, existing) + + def test_normal_message_newer_by_msg_serial(self): + """Test RTP2b2: normal messages compared by msgSerial.""" + older = PresenceMessage( + id='connection123:5:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT, + timestamp=datetime(2024, 1, 1, 12, 0, 0) + ) + newer = PresenceMessage( + id='connection123:10:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT, + timestamp=datetime(2024, 1, 1, 11, 0, 0) # Earlier timestamp doesn't matter + ) + assert _is_newer(newer, older) + assert not _is_newer(older, newer) + + def test_normal_message_newer_by_index(self): + """Test RTP2b2: when msgSerial equal, compare by index.""" + older = PresenceMessage( + id='connection123:5:2', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + newer = PresenceMessage( + id='connection123:5:3', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + assert _is_newer(newer, older) + assert not _is_newer(older, newer) + + def test_normal_message_same_serial_and_index(self): + """Test equal msgSerial and index - incoming is not newer.""" + msg1 = PresenceMessage( + id='connection123:5:3', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + msg2 = PresenceMessage( + id='connection123:5:3', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + # Index not greater, so not newer + assert not _is_newer(msg2, msg1) + + +class TestPresenceMapBasicOperations(BaseAsyncTestCase): + """Test basic PresenceMap operations.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Set up test fixtures.""" + self.presence_map = PresenceMap( + member_key_fn=lambda msg: msg.member_key + ) + yield + + def test_put_enter_message(self): + """Test RTP2d: ENTER message stored as PRESENT.""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data='test' + ) + result = self.presence_map.put(msg) + assert result is True + + stored = self.presence_map.get('connection123:client1') + assert stored is not None + assert stored.action == PresenceAction.PRESENT + assert stored.client_id == 'client1' + assert stored.data == 'test' + + def test_put_update_message(self): + """Test RTP2d: UPDATE message stored as PRESENT.""" + msg = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.UPDATE, + data='updated' + ) + result = self.presence_map.put(msg) + assert result is True + + stored = self.presence_map.get('connection123:client1') + assert stored.action == PresenceAction.PRESENT + + def test_put_rejects_older_message(self): + """Test RTP2a: older messages are rejected.""" + newer = PresenceMessage( + id='connection123:10:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER + ) + older = PresenceMessage( + id='connection123:5:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.UPDATE + ) + + # Add newer first + self.presence_map.put(newer) + # Try to add older - should be rejected + result = self.presence_map.put(older) + assert result is False + + # Should still have the newer one + stored = self.presence_map.get('connection123:client1') + assert stored.parse_id()['msgSerial'] == 10 + + def test_put_accepts_newer_message(self): + """Test RTP2a: newer messages replace older ones.""" + older = PresenceMessage( + id='connection123:5:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER, + data='old' + ) + newer = PresenceMessage( + id='connection123:10:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.UPDATE, + data='new' + ) + + self.presence_map.put(older) + result = self.presence_map.put(newer) + assert result is True + + stored = self.presence_map.get('connection123:client1') + assert stored.data == 'new' + assert stored.parse_id()['msgSerial'] == 10 + + def test_remove_member(self): + """Test RTP2h1: LEAVE removes member outside of sync.""" + enter = PresenceMessage( + id='connection123:0:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.ENTER + ) + leave = PresenceMessage( + id='connection123:1:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.LEAVE + ) + + self.presence_map.put(enter) + result = self.presence_map.remove(leave) + assert result is True + + # Member should be removed + assert self.presence_map.get('connection123:client1') is None + + def test_remove_rejects_older_leave(self): + """Test RTP2h: LEAVE must pass newness check.""" + newer = PresenceMessage( + id='connection123:10:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.PRESENT + ) + older_leave = PresenceMessage( + id='connection123:5:0', + connection_id='connection123', + client_id='client1', + action=PresenceAction.LEAVE + ) + + self.presence_map.put(newer) + result = self.presence_map.remove(older_leave) + assert result is False + + # Member should still be present + assert self.presence_map.get('connection123:client1') is not None + + def test_values_excludes_absent(self): + """Test that values() excludes ABSENT members.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + msg2 = PresenceMessage( + id='conn2:0:0', + connection_id='conn2', + client_id='client2', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.put(msg2) + + # Manually add an ABSENT member (happens during sync) + absent = PresenceMessage( + id='conn3:0:0', + connection_id='conn3', + client_id='client3', + action=PresenceAction.ABSENT + ) + self.presence_map._map[absent.member_key] = absent + + values = self.presence_map.values() + assert len(values) == 2 + assert all(msg.action == PresenceAction.PRESENT for msg in values) + + def test_list_with_client_id_filter(self): + """Test RTP11c2: list with clientId filter.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + msg2 = PresenceMessage( + id='conn2:0:0', + connection_id='conn2', + client_id='client2', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.put(msg2) + + result = self.presence_map.list(client_id='client1') + assert len(result) == 1 + assert result[0].client_id == 'client1' + + def test_list_with_connection_id_filter(self): + """Test RTP11c3: list with connectionId filter.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + msg2 = PresenceMessage( + id='conn1:0:1', + connection_id='conn1', + client_id='client2', + action=PresenceAction.PRESENT + ) + msg3 = PresenceMessage( + id='conn2:0:0', + connection_id='conn2', + client_id='client3', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.put(msg2) + self.presence_map.put(msg3) + + result = self.presence_map.list(connection_id='conn1') + assert len(result) == 2 + assert all(msg.connection_id == 'conn1' for msg in result) + + def test_clear(self): + """Test RTP5a: clear removes all members.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + self.presence_map.put(msg1) + self.presence_map.clear() + + assert len(self.presence_map.values()) == 0 + assert not self.presence_map.sync_in_progress + + +class TestPresenceMapSyncOperations(BaseAsyncTestCase): + """Test SYNC operations (RTP18, RTP19).""" + + @pytest.fixture(autouse=True) + def setup(self): + """Set up test fixtures.""" + self.presence_map = PresenceMap( + member_key_fn=lambda msg: msg.member_key + ) + yield + + def test_start_sync(self): + """Test RTP18: start_sync captures residual members.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + msg2 = PresenceMessage( + id='conn2:0:0', + connection_id='conn2', + client_id='client2', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.put(msg2) + + self.presence_map.start_sync() + assert self.presence_map.sync_in_progress is True + assert self.presence_map._residual_members is not None + assert len(self.presence_map._residual_members) == 2 + + def test_put_during_sync_removes_from_residual(self): + """Test that members seen during sync are removed from residual.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.start_sync() + + # Update the same member during sync + msg1_update = PresenceMessage( + id='conn1:1:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT, + data='updated' + ) + self.presence_map.put(msg1_update) + + # Member should be removed from residual + assert 'conn1:client1' not in self.presence_map._residual_members + + def test_remove_during_sync_marks_absent(self): + """Test RTP2h2: LEAVE during sync marks member as ABSENT.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.start_sync() + + leave = PresenceMessage( + id='conn1:1:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.LEAVE + ) + result = self.presence_map.remove(leave) + assert result is True + + # Should be marked ABSENT, not removed + stored = self.presence_map.get('conn1:client1') + assert stored is not None + assert stored.action == PresenceAction.ABSENT + + def test_end_sync_removes_absent_members(self): + """Test RTP2h2b: end_sync removes ABSENT members.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.start_sync() + + leave = PresenceMessage( + id='conn1:1:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.LEAVE + ) + self.presence_map.remove(leave) + + residual, absent = self.presence_map.end_sync() + + # Member should be removed after sync + assert self.presence_map.get('conn1:client1') is None + assert not self.presence_map.sync_in_progress + + def test_end_sync_returns_residual_members(self): + """Test RTP19: end_sync returns residual members for leave synthesis.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + msg2 = PresenceMessage( + id='conn2:0:0', + connection_id='conn2', + client_id='client2', + action=PresenceAction.PRESENT + ) + + # Add two members + self.presence_map.put(msg1) + self.presence_map.put(msg2) + + self.presence_map.start_sync() + + # Only see msg1 during sync + msg1_update = PresenceMessage( + id='conn1:1:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + self.presence_map.put(msg1_update) + + # End sync - msg2 should be in residual + residual, absent = self.presence_map.end_sync() + + assert len(residual) == 1 + assert residual[0].client_id == 'client2' + + # msg2 should be removed from map + assert self.presence_map.get('conn2:client2') is None + # msg1 should still be present + assert self.presence_map.get('conn1:client1') is not None + + def test_start_sync_multiple_times(self): + """Test that start_sync can be called multiple times during sync.""" + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.start_sync() + + initial_residual = self.presence_map._residual_members + + # Call start_sync again - should not reset residual + self.presence_map.start_sync() + assert self.presence_map._residual_members is initial_residual + + def test_clear_invokes_sync_callbacks(self): + """ + Test that clear() invokes pending sync callbacks to prevent hanging. + + This ensures that if get() is waiting for sync and the channel + transitions to DETACHED/FAILED, the waiting Future is resolved + and the caller is not left blocked. + """ + msg1 = PresenceMessage( + id='conn1:0:0', + connection_id='conn1', + client_id='client1', + action=PresenceAction.PRESENT + ) + + self.presence_map.put(msg1) + self.presence_map.start_sync() + + # Register a callback as if _wait_for_sync() was called + callback_invoked = False + + def sync_callback(): + nonlocal callback_invoked + callback_invoked = True + + self.presence_map.wait_sync(sync_callback) + + # Clear should invoke the callback + self.presence_map.clear() + + assert callback_invoked, "clear() should invoke pending sync callbacks" + assert not self.presence_map.sync_in_progress + assert len(self.presence_map.values()) == 0 diff --git a/test/ably/realtime/realtimeannotations_test.py b/test/ably/realtime/realtimeannotations_test.py new file mode 100644 index 00000000..a82b6b2b --- /dev/null +++ b/test/ably/realtime/realtimeannotations_test.py @@ -0,0 +1,343 @@ +import asyncio +import logging +import random +import string + +import pytest + +from ably.types.annotation import Annotation, AnnotationAction +from ably.types.channelmode import ChannelMode +from ably.types.channeloptions import ChannelOptions +from ably.types.message import MessageAction +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, ReusableFuture, assert_waiter + +log = logging.getLogger(__name__) + + +@pytest.mark.parametrize("transport", ["json", "msgpack"], ids=["JSON", "MsgPack"]) +class TestRealtimeAnnotations(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self, transport): + self.test_vars = await TestApp.get_test_vars() + + client_id = ''.join(random.choices(string.ascii_letters + string.digits, k=10)) + self.realtime_client = await TestApp.get_ably_realtime( + use_binary_protocol=True if transport == 'msgpack' else False, + client_id=client_id, + ) + self.rest_client = await TestApp.get_ably_rest( + use_binary_protocol=True if transport == 'msgpack' else False, + client_id=client_id, + ) + + async def test_publish_and_subscribe_annotations(self): + """RTAN1/RTAN4: Publish and subscribe to annotations via realtime and REST""" + channel_options = ChannelOptions(modes=[ + ChannelMode.PUBLISH, + ChannelMode.SUBSCRIBE, + ChannelMode.ANNOTATION_PUBLISH, + ChannelMode.ANNOTATION_SUBSCRIBE + ]) + channel_name = self.get_channel_name('mutable:publish_and_subscribe_annotations') + channel = self.realtime_client.channels.get( + channel_name, + channel_options, + ) + rest_channel = self.rest_client.channels.get(channel_name) + await channel.attach() + + # Setup annotation listener + annotation_future = asyncio.Future() + + async def on_annotation(annotation): + if not annotation_future.done(): + annotation_future.set_result(annotation) + + await channel.annotations.subscribe(on_annotation) + + # Publish a message + publish_result = await channel.publish('message', 'foobar') + + # Reset for next message (summary) + message_summary = asyncio.Future() + + def on_message(msg): + if not message_summary.done(): + message_summary.set_result(msg) + + await channel.subscribe('message', on_message) + + # Publish annotation using realtime + await channel.annotations.publish(publish_result.serials[0], Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + # Wait for annotation + annotation = await annotation_future + assert annotation.action == AnnotationAction.ANNOTATION_CREATE + assert annotation.message_serial == publish_result.serials[0] + assert annotation.type == 'reaction:distinct.v1' + assert annotation.name == '👍' + assert annotation.serial > annotation.message_serial + + # Wait for summary message + summary = await message_summary + assert summary.action == MessageAction.MESSAGE_SUMMARY + assert summary.serial == publish_result.serials[0] + assert summary.annotations.summary['reaction:distinct.v1']['👍']['total'] == 1 + + # Try again but with REST publish + annotation_future2 = asyncio.Future() + + async def on_annotation2(annotation): + if not annotation_future2.done(): + annotation_future2.set_result(annotation) + + await channel.annotations.subscribe(on_annotation2) + + await rest_channel.annotations.publish(publish_result.serials[0], Annotation( + type='reaction:distinct.v1', + name='😕' + )) + + annotation = await annotation_future2 + assert annotation.action == AnnotationAction.ANNOTATION_CREATE + assert annotation.message_serial == publish_result.serials[0] + assert annotation.type == 'reaction:distinct.v1' + assert annotation.name == '😕' + assert annotation.serial > annotation.message_serial + + async def test_get_all_annotations_for_a_message(self): + """RTAN3: Retrieve all annotations for a message""" + channel_options = ChannelOptions(modes=[ + ChannelMode.PUBLISH, + ChannelMode.SUBSCRIBE, + ChannelMode.ANNOTATION_PUBLISH, + ChannelMode.ANNOTATION_SUBSCRIBE + ]) + channel = self.realtime_client.channels.get( + self.get_channel_name('mutable:get_all_annotations_for_a_message'), + channel_options + ) + await channel.attach() + + # Publish a message + publish_result = await channel.publish('message', 'foobar') + + # Publish multiple annotations + emojis = ['👍', '😕', '👎'] + for emoji in emojis: + await channel.annotations.publish(publish_result.serials[0], Annotation( + type='reaction:distinct.v1', + name=emoji + )) + + # Wait for all annotations to appear + annotations = [] + + async def check_annotations(): + nonlocal annotations + res = await channel.annotations.get(publish_result.serials[0], {}) + annotations = res.items + return len(annotations) == 3 + + await assert_waiter(check_annotations, timeout=10) + + # Verify annotations + assert annotations[0].action == AnnotationAction.ANNOTATION_CREATE + assert annotations[0].message_serial == publish_result.serials[0] + assert annotations[0].type == 'reaction:distinct.v1' + assert annotations[0].name == '👍' + assert annotations[1].name == '😕' + assert annotations[2].name == '👎' + assert annotations[1].serial > annotations[0].serial + assert annotations[2].serial > annotations[1].serial + + async def test_subscribe_by_annotation_type(self): + """RTAN4c: Subscribe to annotations filtered by type""" + channel_options = ChannelOptions(modes=[ + ChannelMode.PUBLISH, + ChannelMode.SUBSCRIBE, + ChannelMode.ANNOTATION_PUBLISH, + ChannelMode.ANNOTATION_SUBSCRIBE + ]) + channel = self.realtime_client.channels.get( + self.get_channel_name('mutable:subscribe_by_type'), + channel_options + ) + await channel.attach() + + # Setup message listener + message_future = asyncio.Future() + + def on_message(msg): + if not message_future.done(): + message_future.set_result(msg) + + await channel.subscribe('message', on_message) + + # Subscribe to specific annotation type + reaction_future = asyncio.Future() + + async def on_reaction(annotation): + if not reaction_future.done(): + reaction_future.set_result(annotation) + + await channel.annotations.subscribe('reaction:distinct.v1', on_reaction) + + # Publish message and annotation + publish_result = await channel.publish('message', 'test') + + await channel.annotations.publish(publish_result.serials[0], Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + # Should receive the annotation + annotation = await reaction_future + assert annotation.type == 'reaction:distinct.v1' + assert annotation.name == '👍' + + async def test_unsubscribe_annotations(self): + """RTAN5: Unsubscribe from annotation events""" + channel_options = ChannelOptions(modes=[ + ChannelMode.PUBLISH, + ChannelMode.SUBSCRIBE, + ChannelMode.ANNOTATION_PUBLISH, + ChannelMode.ANNOTATION_SUBSCRIBE + ]) + channel = self.realtime_client.channels.get( + self.get_channel_name('mutable:unsubscribe_annotations'), + channel_options + ) + await channel.attach() + + annotations_received = [] + annotation_future = ReusableFuture() + + async def on_annotation(annotation): + annotations_received.append(annotation) + annotation_future.set_result(annotation) + + await channel.annotations.subscribe(on_annotation) + + # Publish message and first annotation + publish_result = await channel.publish('message', 'test') + + await channel.annotations.publish(publish_result.serials[0], Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + # Wait for the first annotation to appear + await annotation_future.get() + assert len(annotations_received) == 1 + + # Unsubscribe + channel.annotations.unsubscribe(on_annotation) + + await channel.annotations.subscribe(lambda annotation: annotation_future.set_result(annotation)) + + # Publish another annotation + await channel.annotations.publish(publish_result.serials[0], Annotation( + type='reaction:distinct.v1', + name='😕' + )) + + # Wait for the second annotation to appear in another listener + await annotation_future.get() + + assert len(annotations_received) == 1 + + async def test_delete_annotation(self): + """RTAN2: Delete an annotation via realtime""" + channel_options = ChannelOptions(modes=[ + ChannelMode.PUBLISH, + ChannelMode.SUBSCRIBE, + ChannelMode.ANNOTATION_PUBLISH, + ChannelMode.ANNOTATION_SUBSCRIBE + ]) + channel = self.realtime_client.channels.get( + self.get_channel_name('mutable:delete_annotation'), + channel_options + ) + await channel.attach() + + # Setup message listener + message_future = asyncio.Future() + + def on_message(msg): + if not message_future.done(): + message_future.set_result(msg) + + await channel.subscribe('message', on_message) + + annotations_received = [] + annotation_future = ReusableFuture() + async def on_annotation(annotation): + annotations_received.append(annotation) + annotation_future.set_result(annotation) + + await channel.annotations.subscribe(on_annotation) + + # Publish message and annotation + await channel.publish('message', 'test') + message = await message_future + + await channel.annotations.publish(message.serial, Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + await annotation_future.get() + + # Wait for create annotation + assert len(annotations_received) == 1 + assert annotations_received[0].action == AnnotationAction.ANNOTATION_CREATE + + # Delete the annotation + await channel.annotations.delete(message.serial, Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + # Wait for delete annotation + await annotation_future.get() + + assert len(annotations_received) == 2 + assert annotations_received[1].action == AnnotationAction.ANNOTATION_DELETE + + async def test_subscribe_without_annotation_mode_warns(self, caplog): + """RTAN4e: Subscribing without ANNOTATION_SUBSCRIBE mode logs a warning. + + Per spec, the library should log a warning indicating that the user has tried + to add an annotation listener without having requested the ANNOTATION_SUBSCRIBE + channel mode. + """ + # Create channel without annotation_subscribe mode + channel_options = ChannelOptions(modes=[ + ChannelMode.PUBLISH, + ChannelMode.SUBSCRIBE + ]) + channel = self.realtime_client.channels.get( + self.get_channel_name('mutable:no_annotation_mode'), + channel_options + ) + await channel.attach() + + async def on_annotation(annotation): + pass + + # RTAN4e: Should log a warning (not raise), and still register the listener + with caplog.at_level(logging.WARNING, logger='ably.realtime.annotations'): + await channel.annotations.subscribe(on_annotation) + + # Verify warning was logged mentioning the missing mode + assert any('ANNOTATION_SUBSCRIBE' in record.message for record in caplog.records) + + # Listener should still be registered (subscribe didn't fail) + # Unsubscribe to clean up + channel.annotations.unsubscribe(on_annotation) diff --git a/test/ably/realtime/realtimeauth_test.py b/test/ably/realtime/realtimeauth_test.py new file mode 100644 index 00000000..6ec53356 --- /dev/null +++ b/test/ably/realtime/realtimeauth_test.py @@ -0,0 +1,673 @@ +import asyncio +import json +import urllib.parse + +import httpx +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.connectionstate import ConnectionEvent +from ably.types.tokendetails import TokenDetails +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, random_string + +echo_url = 'https://echo.ably.io' + + +async def auth_callback_failure(options, expect_failure=False): + realtime = await TestApp.get_ably_realtime(**options) + + state_change = await realtime.connection.once_async() + + if expect_failure: + assert state_change.current == ConnectionState.FAILED + assert state_change.reason.status_code == 403 + else: + assert state_change.current == ConnectionState.DISCONNECTED + assert state_change.reason.status_code == 401 + assert state_change.reason.code == 80019 + + await realtime.close() + + +class TestRealtimeAuth(BaseAsyncTestCase): + async def test_auth_valid_api_key(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + assert ably.connection.error_reason is None + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + await ably.close() + + async def test_auth_wrong_api_key(self): + api_key = "js9de7r:08sdnuvfasd" + ably = await TestApp.get_ably_realtime(key=api_key) + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert ably.connection.error_reason == state_change.reason + assert state_change.reason.code == 40101 + assert state_change.reason.status_code == 401 + await ably.close() + + async def test_auth_with_token_string(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + ably = await TestApp.get_ably_realtime(token=token_details.token) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_invalid_token_string(self): + invalid_token = "Sdnurv_some_invalid_token_nkds9r7" + ably = await TestApp.get_ably_realtime(token=invalid_token) + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40101 + assert state_change.reason.status_code == 401 + await ably.close() + + async def test_auth_with_token_details(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + ably = await TestApp.get_ably_realtime(token_details=token_details) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_invalid_token_details(self): + invalid_token_details = TokenDetails(token="invalid-token") + ably = await TestApp.get_ably_realtime(token_details=invalid_token_details) + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40101 + assert state_change.reason.status_code == 401 + await ably.close() + + async def test_auth_with_auth_callback_with_token_request(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.create_token_request(token_params=params) + return token_details + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_auth_callback_token_with_details(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_auth_callback_with_token_string(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_auth_callback_invalid_token(self): + async def callback(params): + return "invalid token" + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40101 + assert state_change.reason.status_code == 401 + await ably.close() + + async def test_auth_with_auth_url_json(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + token_details_json = json.dumps(token_details.to_dict()) + url_path = f"{echo_url}/?type=json&body={urllib.parse.quote_plus(token_details_json)}" + + ably = await TestApp.get_ably_realtime(auth_url=url_path) + await asyncio.wait_for( + ably.connection.once_async(ConnectionState.CONNECTED), + timeout=5, + ) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_auth_url_text_plain(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + url_path = f"{echo_url}/?type=text&body={token_details.token}" + + ably = await TestApp.get_ably_realtime(auth_url=url_path) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_auth_with_auth_url_post(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + url_path = f"{echo_url}/?type=json&" + + ably = await TestApp.get_ably_realtime(auth_url=url_path, auth_method='POST', + auth_params=token_details) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert ably.connection.error_reason is None + await ably.close() + + async def test_reauth_while_connected(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert ably.connection.connection_manager.transport + original_access_token = ably.connection.connection_manager.transport.params.get('accessToken') + assert original_access_token is not None + + original_send_protocol_message = ably.connection.connection_manager.send_protocol_message + fut1 = asyncio.Future() + + async def send_protocol_message(protocol_message): + if protocol_message.get('action') == ProtocolMessageAction.AUTH: + fut1.set_result(protocol_message) + await original_send_protocol_message(protocol_message) + ably.connection.connection_manager.send_protocol_message = send_protocol_message + + fut2 = asyncio.Future() + + def on_update(state_change): + fut2.set_result(state_change) + + ably.connection.on(ConnectionEvent.UPDATE, on_update) + + await ably.auth.authorize() + message = await fut1 + new_access_token = message.get('auth').get('accessToken') + assert new_access_token is not None + assert new_access_token is not original_access_token + + state_change = await fut2 + assert state_change.current == ConnectionState.CONNECTED + await ably.close() + + async def test_reauth_while_connecting(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + original_transport = await ably.connection.connection_manager.once_async('transport.pending') + await ably.auth.authorize() + assert ably.connection.state == ConnectionState.CONNECTED + assert ably.connection.connection_manager.transport is not original_transport + + await ably.close() + + async def test_reauth_immediately(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + await ably.auth.authorize() + assert ably.connection.state == ConnectionState.CONNECTED + + await ably.close() + + async def test_capability_change_without_loss_of_continuity(self): + rest = await TestApp.get_ably_rest() + channel_name = random_string(5) + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + + await ably.auth.authorize({"capability": {channel_name: "*"}}) + + channel = ably.channels.get(channel_name) + await channel.attach() + + await ably.auth.authorize({"capability": {channel_name: "*", random_string(5): "*"}}) + await channel.once_async(ChannelState.ATTACHED) + + await ably.close() + + async def test_capability_downgrade(self): + rest = await TestApp.get_ably_rest() + channel_name = random_string(5) + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + + await ably.auth.authorize({"capability": {channel_name: "*"}}) + + channel = ably.channels.get(channel_name) + await channel.attach() + + future = asyncio.Future() + + def on_channel_state_change(state_change): + future.set_result(state_change) + + channel.on(ChannelState.FAILED, on_channel_state_change) + + await ably.auth.authorize({"capability": {random_string(5): "*"}}) + + state_change = await future + + assert state_change.reason is not None + assert state_change.reason.code == 40160 + assert state_change.reason.status_code == 401 + + await ably.close() + + async def test_reauth_inbound_auth_protocol_msg(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + msg = { + "action": ProtocolMessageAction.AUTH, + } + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + auth_future = asyncio.Future() + + def on_update(state_change): + auth_future.set_result(state_change) + + ably.connection.on("update", on_update) + await ably.connection.connection_manager.transport.on_protocol_message(msg) + await auth_future + await ably.close() + + # RSC8a4 + async def test_jwt_reauth(self): + test_vars = await TestApp.get_test_vars() + key = test_vars["keys"][0] + key_name = key["key_name"] + key_secret = key["key_secret"] + + async def auth_callback(_): + response = httpx.get( + echo_url + '/createJWT', + params={"keyName": key_name, "keySecret": key_secret, "expiresIn": 35} + ) + return response.text + + ably = await TestApp.get_ably_realtime(auth_callback=auth_callback) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + original_token_details = ably.auth.token_details + await ably.connection.once_async(ConnectionEvent.UPDATE) + assert ably.auth.token_details is not original_token_details + + await ably.close() + + # RTN14b + async def test_renew_token_single_attempt(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + msg = { + "action": ProtocolMessageAction.ERROR, + "error": { + "code": 40142, + "statusCode": 401 + } + } + + transport = await ably.connection.connection_manager.once_async('transport.pending') + original_token_details = ably.auth.token_details + await transport.on_protocol_message(msg) + assert ably.auth.token_details is not original_token_details + await ably.close() + await rest.close() + + # RTN14b + async def test_renew_token_connection_attempt_fails(self): + rest = await TestApp.get_ably_rest() + call_count = 0 + + async def callback(params): + nonlocal call_count + call_count += 1 + params = {"ttl": 1} + token_details = await rest.auth.request_token(token_params=params) + return token_details + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + + await ably.connection.once_async(ConnectionState.DISCONNECTED) + assert call_count == 2 + assert ably.connection.error_reason.code == 40142 + assert ably.connection.error_reason.status_code == 401 + + await ably.close() + await rest.close() + + # RSA4a + async def test_renew_token_no_renew_means_provided(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token(token_params={'ttl': 1}) + + ably = await TestApp.get_ably_realtime(token_details=token_details) + + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40171 + assert state_change.reason.status_code == 403 + await ably.close() + await rest.close() + + async def test_auth_callback_error(self): + async def auth_callback(_): + raise Exception("An error from client code that the authCallback might return") + + await auth_callback_failure({ + 'auth_callback': auth_callback + }) + + @pytest.mark.skip(reason="blocked by https://github.com/ably/ably-python/issues/461") + async def test_auth_callback_timeout(self): + async def auth_callback(_): + await asyncio.sleep(10_000) + + await auth_callback_failure({ + 'auth_callback': auth_callback, + 'realtime_request_timeout': 100, + }) + + async def test_auth_callback_nothing(self): + async def auth_callback(_): + return + + await auth_callback_failure({ + 'auth_callback': auth_callback, + }) + + async def test_auth_callback_malformed(self): + async def auth_callback(_): + return {"horse": "ebooks"} + + await auth_callback_failure({ + 'auth_callback': auth_callback, + }) + + async def test_auth_callback_empty_string(self): + async def auth_callback(_): + return "" + + await auth_callback_failure({ + 'auth_callback': auth_callback, + }) + + @pytest.mark.skip(reason="blocked by https://github.com/ably/ably-python/issues/461") + async def test_auth_url_timeout(self): + await auth_callback_failure({ + "auth_url": "http://10.255.255.1/" + }) + + async def test_auth_url_404(self): + await auth_callback_failure({ + "auth_url": "http://example.com/404" + }) + + async def test_auth_url_wrong_content_type(self): + await auth_callback_failure({ + "auth_url": "http://example.com/" + }) + + async def test_auth_url_401(self): + await auth_callback_failure({ + "auth_url": echo_url + '/respondwith?status=401' + }) + + async def test_auth_url_403(self): + await auth_callback_failure({ + "auth_url": echo_url + '/respondwith?status=403' + }, expect_failure=True) + + async def test_auth_url_403_custom_error(self): + error = json.dumps({ + "error": { + "some_custom": "error", + } + }) + + await auth_callback_failure({ + "auth_url": echo_url + '/respondwith?status=403&body=' + urllib.parse.quote_plus(error) + }, expect_failure=True) + + # RTN15h2 + async def test_renew_token_single_attempt_upon_disconnection(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + msg = { + "action": ProtocolMessageAction.DISCONNECTED, + "error": { + "code": 40142, + "statusCode": 401 + } + } + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + original_token_details = ably.auth.token_details + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(msg) + assert ably.auth.token_details is not original_token_details + await ably.close() + await rest.close() + + # RTN15h1 + async def test_renew_token_no_renew_means_provided_upon_disconnection(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + + ably = await TestApp.get_ably_realtime(token_details=token_details) + + state_change = await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + msg = { + "action": ProtocolMessageAction.DISCONNECTED, + "error": { + "code": 40142, + "statusCode": 401 + } + } + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(msg) + + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40171 + assert state_change.reason.status_code == 403 + await ably.close() + await rest.close() + + async def test_renew_token_single_attempt_on_resume(self): + rest = await TestApp.get_ably_rest() + + async def callback(params): + token_details = await rest.auth.request_token(token_params=params) + return token_details.token + + ably = await TestApp.get_ably_realtime(auth_callback=callback) + msg = { + "action": ProtocolMessageAction.ERROR, + "error": { + "code": 40142, + "statusCode": 401 + } + } + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + connection_key = ably.connection.connection_details.connection_key + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + transport = await ably.connection.connection_manager.once_async('transport.pending') + assert ably.connection.connection_manager.transport.params["resume"] == connection_key + + original_token_details = ably.auth.token_details + await transport.on_protocol_message(msg) + assert ably.auth.token_details is not original_token_details + await ably.close() + await rest.close() + + async def test_renew_token_no_renew_means_provided_on_resume(self): + rest = await TestApp.get_ably_rest() + token_details = await rest.auth.request_token() + + ably = await TestApp.get_ably_realtime(token_details=token_details) + + msg = { + "action": ProtocolMessageAction.DISCONNECTED, + "error": { + "code": 40142, + "statusCode": 401 + } + } + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + connection_key = ably.connection.connection_details.connection_key + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + state_change = await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + assert ably.connection.connection_manager.transport.params["resume"] == connection_key + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(msg) + + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40171 + assert state_change.reason.status_code == 403 + await ably.close() + await rest.close() + + # Request a token using client_id, then initialize a connection without one, + # and check that the connection inherits the client_id from the token_details + async def test_auth_client_id_inheritance_auth_callback(self): + rest = await TestApp.get_ably_rest() + client_id = 'test_client_id' + + async def auth_callback(_): + return await rest.auth.request_token({"client_id": client_id}) + + realtime = await TestApp.get_ably_realtime(auth_callback=auth_callback) + + # RTC4a + assert realtime.auth.client_id is None + + await realtime.connection.once_async(ConnectionState.CONNECTED) + + assert realtime.auth.client_id == client_id + + await realtime.close() + await rest.close() + + # Rest token generation with client_id, then connecting with a + # different client_id, should fail with a library-generated message + # (RSA15a, RSA15c) + async def test_auth_client_id_mismatch(self): + rest = await TestApp.get_ably_rest() + client_id = 'test_client_id' + + token_details = await rest.auth.request_token({"client_id": client_id}) + + realtime = await TestApp.get_ably_realtime(token_details=token_details, client_id="WRONG") + + assert realtime.auth.client_id is None + + state_change = await realtime.connection.once_async(ConnectionState.FAILED) + + assert state_change.reason.code == 40102 + + await realtime.close() + await rest.close() + + # Rest token generation with clientId '*', then connecting with just the + # token string and a different clientId, should succeed (RSA15b) + async def test_auth_client_id_wildcard_token(self): + rest = await TestApp.get_ably_rest() + client_id = 'test_client_id' + + token_details = await rest.auth.request_token({"client_id": "*"}) + + realtime = await TestApp.get_ably_realtime(token_details=token_details, client_id=client_id) + + assert realtime.auth.client_id is None + + await realtime.connection.once_async(ConnectionState.CONNECTED) + + assert realtime.auth.client_id == client_id + + await realtime.close() + await rest.close() + + # Request a token using clientId, then initialize a connection using just the token string, + # and check that the connection inherits the clientId from the connectionDetails + async def test_auth_client_id_inheritance_token(self): + rest = await TestApp.get_ably_rest() + client_id = 'test_client_id' + + token_details = await rest.auth.request_token({"client_id": client_id}) + + realtime = await TestApp.get_ably_realtime(token_details=token_details) + + assert realtime.auth.client_id is None + + await realtime.connection.once_async(ConnectionState.CONNECTED) + + assert realtime.auth.client_id == client_id + + await realtime.close() + await rest.close() diff --git a/test/ably/realtime/realtimechannel_publish_test.py b/test/ably/realtime/realtimechannel_publish_test.py new file mode 100644 index 00000000..9ecf10f9 --- /dev/null +++ b/test/ably/realtime/realtimechannel_publish_test.py @@ -0,0 +1,1043 @@ +import asyncio + +import pytest + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channeloptions import ChannelOptions +from ably.types.message import Message +from ably.util.crypto import CipherParams +from ably.util.exceptions import AblyException, IncompatibleClientIdException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, WaitableEvent, assert_waiter + + +@pytest.mark.parametrize("transport", ["json", "msgpack"], ids=["JSON", "MsgPack"]) +class TestRealtimeChannelPublish(BaseAsyncTestCase): + """Tests for RTN7 spec - Message acknowledgment""" + + @pytest.fixture(autouse=True) + async def setup(self, transport): + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = True if transport == 'msgpack' else False + + # RTN7a - Basic ACK/NACK functionality + async def test_publish_returns_ack_on_success(self): + """RTN7a: Verify that publish awaits ACK from server""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_ack_channel') + await channel.attach() + + # Publish should complete successfully when ACK is received + await channel.publish('test_event', 'test_data') + + await ably.close() + + async def test_publish_raises_on_nack(self): + """RTN7a: Verify that publish raises exception when NACK is received""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_nack_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Intercept transport send to simulate NACK + original_send = connection_manager.transport.send + + async def send_and_nack(message): + await original_send(message) + # Simulate NACK from server + if message.get('action') == ProtocolMessageAction.MESSAGE: + msg_serial = message.get('msgSerial', 0) + nack_message = { + 'action': ProtocolMessageAction.NACK, + 'msgSerial': msg_serial, + 'count': 1, + 'error': { + 'message': 'Test NACK error', + 'statusCode': 400, + 'code': 40000 + } + } + await connection_manager.transport.on_protocol_message(nack_message) + + connection_manager.transport.send = send_and_nack + + # Publish should raise exception when NACK is received + with pytest.raises(AblyException) as exc_info: + await channel.publish('test_event', 'test_data') + + assert 'Test NACK error' in str(exc_info.value) + assert exc_info.value.code == 40000 + + await ably.close() + + # RTN7b - msgSerial incrementing + async def test_msgserial_increments_sequentially(self): + """RTN7b: Verify that msgSerial increments for each message""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_msgserial_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + sent_serials = [] + + # Intercept messages to capture msgSerial values + original_send = connection_manager.transport.send + + async def capture_serial(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + sent_serials.append(message.get('msgSerial')) + await original_send(message) + + connection_manager.transport.send = capture_serial + + # Publish multiple messages + await channel.publish('event1', 'data1') + await channel.publish('event2', 'data2') + await channel.publish('event3', 'data3') + + # Verify msgSerial increments: 0, 1, 2 + assert sent_serials == [0, 1, 2], f"Expected [0, 1, 2], got {sent_serials}" + + await ably.close() + + # RTN7e - Fail pending messages on SUSPENDED, CLOSED, FAILED + async def test_pending_messages_fail_on_suspended(self): + """RTN7e: Verify pending messages fail when connection enters SUSPENDED state""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_suspended_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Block ACKs to keep message pending + original_send = connection_manager.transport.send + blocked_messages = [] + + async def block_acks(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + blocked_messages.append(message) + # Don't actually send - keep it pending + return + await original_send(message) + + connection_manager.transport.send = block_acks + + # Start publish but don't await (it will hang waiting for ACK) + publish_task = asyncio.create_task(channel.publish('test_event', 'test_data')) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() > 0 + await assert_waiter(check_pending, timeout=2) + + # Force connection to SUSPENDED state + connection_manager.notify_state( + ConnectionState.SUSPENDED, + AblyException('Test suspension', 400, 80002) + ) + + # The publish should now complete with an exception + with pytest.raises(AblyException) as exc_info: + await publish_task + + assert 'Test suspension' in str(exc_info.value) or exc_info.value.code == 80002 + + await ably.close() + + async def test_pending_messages_fail_on_failed(self): + """RTN7e: Verify pending messages fail when connection enters FAILED state""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_failed_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Block ACKs + original_send = connection_manager.transport.send + + async def block_acks(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + return # Don't send + await original_send(message) + + connection_manager.transport.send = block_acks + + # Start publish + publish_task = asyncio.create_task(channel.publish('test_event', 'test_data')) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() > 0 + await assert_waiter(check_pending, timeout=2) + + # Force FAILED state + connection_manager.notify_state( + ConnectionState.FAILED, + AblyException('Test failure', 80000, 500) + ) + + # Should raise exception + with pytest.raises(AblyException): + await publish_task + + await ably.close() + + # RTN7d - Fail on DISCONNECTED when queueMessages=false + async def test_fail_on_disconnected_when_queue_messages_false(self): + """RTN7d: Verify pending messages fail on DISCONNECTED if queueMessages is false""" + # Create client with queueMessages=False + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol, queue_messages=False) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_disconnected_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Block ACKs + original_send = connection_manager.transport.send + + async def block_acks(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + return + await original_send(message) + + connection_manager.transport.send = block_acks + + # Start publish + publish_task = asyncio.create_task(channel.publish('test_event', 'test_data')) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() > 0 + await assert_waiter(check_pending, timeout=2) + + # Force DISCONNECTED state + connection_manager.notify_state( + ConnectionState.DISCONNECTED, + AblyException('Test disconnect', 400, 80003) + ) + + # Should raise exception because queueMessages is false + with pytest.raises(AblyException): + await publish_task + + await ably.close() + + async def test_queue_on_disconnected_when_queue_messages_true(self): + """RTN7d: Verify messages are queued (not failed) on DISCONNECTED when queueMessages is true""" + # Create client with queueMessages=True (default) + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol, queue_messages=True) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_queue_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Block ACKs + original_send = connection_manager.transport.send + + async def block_acks(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + return + await original_send(message) + + connection_manager.transport.send = block_acks + + # Start publish (will be pending) + publish_task = asyncio.create_task(channel.publish('test_event', 'test_data')) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() > 0 + await assert_waiter(check_pending, timeout=2) + + # Force DISCONNECTED state + connection_manager.notify_state(ConnectionState.DISCONNECTED, None) + + # Give time for state transition + async def check_disconnected(): + return connection_manager.state != ConnectionState.CONNECTED + await assert_waiter(check_disconnected, timeout=2) + + # Task should still be pending (not failed) because queueMessages=True + assert not publish_task.done(), "Publish should still be pending when queueMessages=True" + + # Message should still be in pending queue OR moved to queued_messages + assert connection_manager.pending_message_queue.count() + len(connection_manager.queued_messages) > 0 + + # Now restore connection would normally complete the publish + # For this test, we'll just cancel it + publish_task.cancel() + + await ably.close() + + async def test_publish_fails_on_initialized_when_queue_messages_false(self): + """RTN7d: Verify publish fails immediately when connection is CONNECTING and queueMessages=false""" + # Create client with queueMessages=False + ably = await TestApp.get_ably_realtime( + use_binary_protocol=self.use_binary_protocol, + queue_messages=False, + auto_connect=False + ) + + channel = ably.channels.get('test_initialized_channel') + + # Try to publish while in the INITIALIZED state with queueMessages=false + with pytest.raises(AblyException) as exc_info: + await channel.publish('test_event', 'test_data') + + # Verify it failed with appropriate error + assert exc_info.value.code == 90000 + assert exc_info.value.status_code == 400 + + await ably.close() + + # RTN19a2 - Reset msgSerial on new connectionId + async def test_msgserial_resets_on_new_connection_id(self): + """RTN19a2: Verify msgSerial resets to 0 when connectionId changes""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_reset_serial_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Publish a message to increment msgSerial + await channel.publish('event1', 'data1') + + # msgSerial should now be 1 + assert connection_manager.msg_serial == 1, f"Expected msgSerial=1, got {connection_manager.msg_serial}" + + # Simulate new connection with different connectionId + new_connection_id = 'new_connection_id_12345' + + # Simulate server sending CONNECTED with new connectionId + from ably.types.connectiondetails import ConnectionDetails + new_connection_details = ConnectionDetails( + connection_state_ttl=120000, + max_idle_interval=15000, + connection_key='new_key', + client_id=None + ) + + connection_manager.on_connected(new_connection_details, new_connection_id) + + # msgSerial should be reset to 0 + assert connection_manager.msg_serial == 0, ( + f"Expected msgSerial=0 after new connection, got {connection_manager.msg_serial}" + ) + + await ably.close() + + async def test_msgserial_not_reset_on_same_connection_id(self): + """RTN19a2: Verify msgSerial is NOT reset when connectionId stays the same""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_same_connection_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Publish messages to increment msgSerial + await channel.publish('event1', 'data1') + await channel.publish('event2', 'data2') + + # msgSerial should be 2 + assert connection_manager.msg_serial == 2 + + # Simulate reconnection with SAME connectionId (transport change, not new connection) + same_connection_id = connection_manager.connection_id + + from ably.types.connectiondetails import ConnectionDetails + connection_details = ConnectionDetails( + connection_state_ttl=120000, + max_idle_interval=15000, + connection_key='different_key', # Key can change + client_id=None + ) + + connection_manager.on_connected(connection_details, same_connection_id) + + # msgSerial should NOT be reset (stays at 2) + assert connection_manager.msg_serial == 2, ( + f"Expected msgSerial=2 (unchanged), got {connection_manager.msg_serial}" + ) + + await ably.close() + + # Test that multiple messages get correct msgSerial values + async def test_multiple_messages_concurrent(self): + """RTN7b: Test that multiple concurrent publishes get sequential msgSerials""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_concurrent_channel') + await channel.attach() + + # Publish multiple messages concurrently + tasks = [ + channel.publish('event', f'data{i}') + for i in range(5) + ] + + # All should complete successfully + await asyncio.gather(*tasks) + + # msgSerial should have incremented to 5 + assert ably.connection.connection_manager.msg_serial == 5 + + await ably.close() + + # RTN19a - Resend messages awaiting ACK on reconnect + async def test_pending_messages_resent_on_reconnect(self): + """RTN19a: Verify messages awaiting ACK are resent when transport reconnects""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_resend_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Block ACKs from being processed + original_on_ack = connection_manager.on_ack + connection_manager.on_ack = lambda *args: None + + # Publish a message + publish_future = asyncio.create_task(connection_manager.send_protocol_message({ + "action": ProtocolMessageAction.MESSAGE, + "channel": channel.name, + "messages": [{"name": "test", "data": "data"}] + })) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() == 1 + await assert_waiter(check_pending, timeout=2) + + # Verify msgSerial was assigned + pending_msg = list(connection_manager.pending_message_queue.messages)[0] + assert pending_msg.message.get('msgSerial') == 0 + + # Simulate requeueing (what happens on disconnect) + connection_manager.requeue_pending_messages() + + # Pending queue should now be empty (messages moved to queued_messages) + assert connection_manager.pending_message_queue.count() == 0 + assert len(connection_manager.queued_messages) == 1 + + # Verify the PendingMessage object is in the queue (preserves Future) + queued_msg = connection_manager.queued_messages.pop() + assert queued_msg.message.get('msgSerial') == 0, "msgSerial should be preserved" + + # Add back to pending queue to simulate resend + connection_manager.pending_message_queue.push(queued_msg) + + # Restore on_ack and simulate ACK from server + connection_manager.on_ack = original_on_ack + connection_manager.on_ack(0, 1, None) + + # Future should be resolved + result = await asyncio.wait_for(publish_future, timeout=1) + assert result is not None, "Publish should have succeeded" + + await ably.close() + + async def test_msgserial_preserved_on_resume(self): + """RTN19a2: Verify msgSerial counter is preserved when resuming (same connectionId)""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_preserve_serial_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + original_connection_id = connection_manager.connection_id + + # Block ACKs to keep messages pending + original_on_ack = connection_manager.on_ack + connection_manager.on_ack = lambda *args: None + + # Publish a message (msgSerial will be 0) + asyncio.create_task(connection_manager.send_protocol_message({ + "action": ProtocolMessageAction.MESSAGE, + "channel": channel.name, + "messages": [{"name": "test1", "data": "data1"}] + })) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() == 1 + await assert_waiter(check_pending, timeout=2) + + # msgSerial counter should be at 1 now + assert connection_manager.msg_serial == 1 + + # Simulate resume with SAME connectionId + from ably.types.connectiondetails import ConnectionDetails + connection_details = ConnectionDetails( + connection_state_ttl=120000, + max_idle_interval=15000, + connection_key='same_key', + client_id=None + ) + connection_manager.on_connected(connection_details, original_connection_id) + + # msgSerial counter should STILL be 1 (preserved on resume) + assert connection_manager.msg_serial == 1, ( + f"Expected msgSerial=1 preserved, got {connection_manager.msg_serial}" + ) + + # Restore on_ack and clean up + connection_manager.on_ack = original_on_ack + connection_manager.pending_message_queue.complete_all_messages(AblyException("cleanup", 0, 0)) + + await ably.close() + + async def test_msgserial_reset_on_failed_resume(self): + """RTN19a2: Verify msgSerial counter is reset when resume fails (new connectionId)""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_reset_serial_resume_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Block ACKs to keep messages pending + original_on_ack = connection_manager.on_ack + connection_manager.on_ack = lambda *args: None + + # Publish a message (msgSerial will be 0) + asyncio.create_task(connection_manager.send_protocol_message({ + "action": ProtocolMessageAction.MESSAGE, + "channel": channel.name, + "messages": [{"name": "test1", "data": "data1"}] + })) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() == 1 + await assert_waiter(check_pending, timeout=2) + + # msgSerial counter should be at 1 now + assert connection_manager.msg_serial == 1 + + # Simulate NEW connection (different connectionId = failed resume) + from ably.types.connectiondetails import ConnectionDetails + new_connection_details = ConnectionDetails( + connection_state_ttl=120000, + max_idle_interval=15000, + connection_key='new_key', + client_id=None + ) + new_connection_id = 'new_connection_id_67890' + connection_manager.on_connected(new_connection_details, new_connection_id) + + # msgSerial counter should be reset to 0 (new connection) + assert connection_manager.msg_serial == 0, ( + f"Expected msgSerial reset to 0, got {connection_manager.msg_serial}" + ) + + # Restore on_ack and clean up + connection_manager.on_ack = original_on_ack + connection_manager.pending_message_queue.complete_all_messages(AblyException("cleanup", 0, 0)) + + await ably.close() + + # Test ACK with count > 1 + async def test_ack_with_multiple_count(self): + """RTN7a/RTN7b: Test that ACK with count > 1 completes multiple messages""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_multi_ack_channel') + await channel.attach() + + connection_manager = ably.connection.connection_manager + + # Intercept transport to delay ACKs + original_send = connection_manager.transport.send + pending_messages = [] + + async def delay_ack(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + pending_messages.append(message) + # Don't send yet + return + await original_send(message) + + connection_manager.transport.send = delay_ack + + # Start 3 publishes + task1 = asyncio.create_task(channel.publish('event1', 'data1')) + task2 = asyncio.create_task(channel.publish('event2', 'data2')) + task3 = asyncio.create_task(channel.publish('event3', 'data3')) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() == 3 + await assert_waiter(check_pending, timeout=2) + + # Send ACK for all 3 messages at once (count=3) + ack_message = { + 'action': ProtocolMessageAction.ACK, + 'msgSerial': 0, # First message serial + 'count': 3 # Acknowledging 3 messages + } + await connection_manager.transport.on_protocol_message(ack_message) + + # All tasks should now complete + await task1 + await task2 + await task3 + + await ably.close() + + async def test_queued_messages_sent_before_channel_reattach(self): + """RTL3d + RTL6c2: Verify queued messages are sent immediately on reconnection, + without waiting for channel reattachment to complete""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol, queue_messages=True) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_rtl3d_rtl6c2_channel') + await channel.attach() + + # Verify channel is ATTACHED + assert channel.state == ChannelState.ATTACHED + + connection_manager = ably.connection.connection_manager + + # Track channel reattachment + channel_attaching_seen = False + + def track_attaching(state_change): + nonlocal channel_attaching_seen + if state_change.current == ChannelState.ATTACHING: + channel_attaching_seen = True + + channel.on('attaching', track_attaching) + + # Force an invalid resume to ensure a new connection + # (like test_attached_channel_reattaches_on_invalid_resume) + assert connection_manager.connection_details + connection_manager.connection_details.connection_key = 'ably-python-fake-key' + + # Queue a message before disconnecting (to ensure it gets queued) + # Block message sending first + original_send = connection_manager.transport.send + + async def block_messages(message): + if message.get('action') == ProtocolMessageAction.MESSAGE: + # Don't send MESSAGE, just queue it + return + await original_send(message) + + connection_manager.transport.send = block_messages + + # Publish a message (will be blocked and moved to pending) + publish_task = asyncio.create_task(channel.publish('test_event', 'test_data')) + + # Wait for message to be pending + async def check_pending(): + return connection_manager.pending_message_queue.count() > 0 + await assert_waiter(check_pending, timeout=2) + + # Now disconnect to move pending messages to queued + assert connection_manager.transport + await connection_manager.transport.dispose() + connection_manager.notify_state(ConnectionState.DISCONNECTED, retry_immediately=False) + + # Give time for state transition and message requeueing + async def check_requeue_happened(): + return len(connection_manager.queued_messages) > 0 + await assert_waiter(check_requeue_happened, timeout=2) + + # Verify message was moved to queued_messages + queued_count_before = len(connection_manager.queued_messages) + assert queued_count_before > 0, "Message should be queued after DISCONNECTED" + assert not publish_task.done(), "Publish task should still be pending" + + # Reconnect (will fail resume due to fake key, creating new connection) + ably.connect() + + # Wait for CONNECTED state (RTL3d + RTL6c2 happens here) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=10) + + # Give time for send_queued_messages() and channel reattachment to process + async def check_sent_queued_messages(): + return len(connection_manager.queued_messages) == 0 + await assert_waiter(check_sent_queued_messages, timeout=2) + + # Verify queued messages were sent (RTL6c2) + queued_count_after = len(connection_manager.queued_messages) + assert queued_count_after < queued_count_before, \ + "Queued messages should be sent immediately when entering CONNECTED (RTL6c2)" + + # Verify channel transitioned to ATTACHING (RTL3d) + assert channel_attaching_seen, "Channel should have transitioned to ATTACHING (RTL3d)" + + # Wait for channel to reach ATTACHED state + if channel.state != ChannelState.ATTACHED: + await asyncio.wait_for(channel.once_async(ChannelState.ATTACHED), timeout=5) + + # Verify publish completes successfully + await asyncio.wait_for(publish_task, timeout=5) + + await ably.close() + + # RSL1i - Message size limit tests + async def test_publish_message_exceeding_size_limit(self): + """RSL1i: Verify that publishing a message exceeding the size limit raises an exception""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_size_limit_channel') + await channel.attach() + + # Create a message that exceeds the default 65536 byte limit + # 70KB of data should definitely exceed the limit + large_data = 'x' * (70 * 1024) + + # Attempt to publish should raise AblyException with code 40009 + with pytest.raises(AblyException) as exc_info: + await channel.publish('test_event', large_data) + + assert exc_info.value.code == 40009 + assert 'Maximum size of messages' in str(exc_info.value) + + await ably.close() + + async def test_publish_message_within_size_limit(self): + """RSL1i: Verify that publishing a message within the size limit succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_size_ok_channel') + await channel.attach() + + # Create a message that is well within the 65536 byte limit + # 10KB of data should be safe + medium_data = 'x' * (10 * 1024) + + # Publish should complete successfully + await channel.publish('test_event', medium_data) + + await ably.close() + + # RTL6g - Client ID validation tests + async def test_publish_with_matching_client_id(self): + """RTL6g2: Verify that publishing with explicit matching clientId succeeds""" + ably = await TestApp.get_ably_realtime( + use_binary_protocol=self.use_binary_protocol, client_id='test_client_123' + ) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_client_id_channel') + await channel.attach() + + # Create message with matching clientId + message = Message(name='test_event', data='test_data', client_id='test_client_123') + + # Publish should succeed with matching clientId + await channel.publish(message) + + await ably.close() + + async def test_publish_with_null_client_id_when_identified(self): + """RTL6g1: Verify that publishing with null clientId gets populated by server when client is identified""" + ably = await TestApp.get_ably_realtime( + use_binary_protocol=self.use_binary_protocol, client_id='test_client_456' + ) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_null_client_id_channel') + await channel.attach() + + # Publish without explicit clientId (will be populated by server) + await channel.publish('test_event', 'test_data') + + await ably.close() + + async def test_publish_with_mismatched_client_id_fails(self): + """RTL6g3: Verify that publishing with mismatched clientId is rejected""" + ably = await TestApp.get_ably_realtime( + use_binary_protocol=self.use_binary_protocol, client_id='test_client_789' + ) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_mismatch_client_id_channel') + await channel.attach() + + # Create message with different clientId + message = Message(name='test_event', data='test_data', client_id='different_client') + + # Publish should raise IncompatibleClientIdException + with pytest.raises(IncompatibleClientIdException) as exc_info: + await channel.publish(message) + + assert exc_info.value.code == 40012 + assert 'incompatible' in str(exc_info.value).lower() + + await ably.close() + + async def test_publish_with_wildcard_client_id_fails(self): + """RTL6g3: Verify that publishing with wildcard clientId is rejected""" + ably = await TestApp.get_ably_realtime( + use_binary_protocol=self.use_binary_protocol, client_id='test_client_wildcard' + ) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_wildcard_client_id_channel') + await channel.attach() + + # Create message with wildcard clientId + message = Message(name='test_event', data='test_data', client_id='*') + + # Publish should raise IncompatibleClientIdException + with pytest.raises(IncompatibleClientIdException) as exc_info: + await channel.publish(message) + + assert exc_info.value.code == 40012 + assert 'wildcard' in str(exc_info.value).lower() + + await ably.close() + + # RTL6i - Data type variation tests + async def test_publish_with_string_data(self): + """RTL6i: Verify that publishing with string data succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_string_data_channel') + await channel.attach() + + # Publish message with string data + await channel.publish('test_event', 'simple string data') + + await ably.close() + + async def test_publish_with_json_object_data(self): + """RTL6i: Verify that publishing with JSON object data succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_json_object_channel') + await channel.attach() + + # Publish message with JSON object data + json_data = { + 'key1': 'value1', + 'key2': 42, + 'key3': True, + 'nested': {'inner': 'data'} + } + await channel.publish('test_event', json_data) + + await ably.close() + + async def test_publish_with_json_array_data(self): + """RTL6i: Verify that publishing with JSON array data succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_json_array_channel') + await channel.attach() + + # Publish message with JSON array data + array_data = ['item1', 'item2', 42, True, {'nested': 'object'}] + await channel.publish('test_event', array_data) + + await ably.close() + + async def test_publish_with_null_data(self): + """RTL6i3: Verify that publishing with null data succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_null_data_channel') + await channel.attach() + + # Publish message with null data (RTL6i3: null data is permitted) + await channel.publish('test_event', None) + + await ably.close() + + async def test_publish_with_null_name(self): + """RTL6i3: Verify that publishing with null name succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_null_name_channel') + await channel.attach() + + # Publish message with null name (RTL6i3: null name is permitted) + await channel.publish(None, 'test data') + + await ably.close() + + async def test_publish_message_array(self): + """RTL6i2: Verify that publishing an array of messages succeeds""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_message_array_channel') + await channel.attach() + + # Publish array of messages (RTL6i2) + messages = [ + Message(name='event1', data='data1'), + Message(name='event2', data='data2'), + Message(name='event3', data={'key': 'value'}), + ] + await channel.publish(messages) + + await ably.close() + + # RTL6c4 - Channel state validation tests + async def test_publish_fails_on_suspended_channel(self): + """RTL6c4: Verify that publishing on a SUSPENDED channel fails""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_suspended_channel') + await channel.attach() + + # Force channel to SUSPENDED state + channel._notify_state(ChannelState.SUSPENDED) + + # Verify channel is SUSPENDED + assert channel.state == ChannelState.SUSPENDED + + # Attempt to publish should raise AblyException with code 90001 + with pytest.raises(AblyException) as exc_info: + await channel.publish('test_event', 'test_data') + + assert exc_info.value.code == 90001 + assert 'suspended' in str(exc_info.value).lower() + + await ably.close() + + async def test_publish_fails_on_failed_channel(self): + """RTL6c4: Verify that publishing on a FAILED channel fails""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('test_failed_channel') + await channel.attach() + + # Force channel to FAILED state + channel._notify_state(ChannelState.FAILED) + + # Verify channel is FAILED + assert channel.state == ChannelState.FAILED + + # Attempt to publish should raise AblyException with code 90001 + with pytest.raises(AblyException) as exc_info: + await channel.publish('test_event', 'test_data') + + assert exc_info.value.code == 90001 + assert 'failed' in str(exc_info.value).lower() + + await ably.close() + + # RSL1k - Idempotent publishing test + async def test_idempotent_realtime_publishing(self): + """RSL1k2, RSL1k5: Verify that messages with explicit IDs can be published for idempotent behavior""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get(f'test_idempotent_channel_{self.use_binary_protocol}') + await channel.attach() + + idempotent_id = 'test-msg-id-12345' + different_id = 'test-msg-id-67890' + + data_received = [] + different_id_received = WaitableEvent() + def on_message(message): + try: + data_received.append(message.data) + + if message.id == different_id: + different_id_received.finish() + except Exception as e: + different_id_received.finish() + raise e + + await channel.subscribe(on_message) + + # RSL1k2: Publish messages with explicit IDs + # Messages with explicit IDs should include those IDs in the published message + message1 = Message(name='idempotent_event', data='first message', id=idempotent_id) + + # Publish should succeed with explicit ID + await channel.publish(message1) + + # Publish another message with the same ID (RSL1k5: idempotent publishing) + # With idempotent publishing enabled on the server, messages with the same ID + # should be deduplicated. Here we verify that publishing with the same ID succeeds. + message2 = Message(name='idempotent_event', data='second message', id=idempotent_id) + await channel.publish(message2) + + # Publish a message with a different ID + message3 = Message(name='unique_event', data='third message', id=different_id) + await channel.publish(message3) + + await different_id_received.wait() + + assert len(data_received) == 2, "Only two messages should have been received" + assert data_received[0] == 'first message' + assert data_received[1] == 'third message' + + await ably.close() + + async def test_publish_with_encryption(self): + """Verify that encrypted messages can be published and received correctly""" + # Create connection with binary protocol enabled + ably = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + # Get channel with encryption enabled + cipher_params = CipherParams(secret_key=b'0123456789abcdef0123456789abcdef') + channel_options = ChannelOptions(cipher=cipher_params) + channel = ably.channels.get('encrypted_channel', channel_options) + await channel.attach() + + received_data = None + data_received = WaitableEvent() + def on_message(message): + nonlocal received_data + try: + received_data = message.data + data_received.finish() + except Exception as e: + data_received.finish() + raise e + + await channel.subscribe(on_message) + + await channel.publish('encrypted_event', 'sensitive data') + + await data_received.wait() + + assert received_data == 'sensitive data' + + await ably.close() diff --git a/test/ably/realtime/realtimechannel_test.py b/test/ably/realtime/realtimechannel_test.py new file mode 100644 index 00000000..6d2865f2 --- /dev/null +++ b/test/ably/realtime/realtimechannel_test.py @@ -0,0 +1,533 @@ +import asyncio + +import pytest + +from ably.realtime.channel import ChannelState, RealtimeChannel +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channeloptions import ChannelOptions +from ably.types.message import Message +from ably.util.exceptions import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, random_string + + +class TestRealtimeChannel(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.valid_key_format = "api:key" + + async def test_channels_get(self): + ably = await TestApp.get_ably_realtime() + channel = ably.channels.get('my_channel') + assert channel == ably.channels.get('my_channel') + assert isinstance(channel, RealtimeChannel) + await ably.close() + + async def test_channels_release(self): + ably = await TestApp.get_ably_realtime() + ably.channels.get('my_channel') + ably.channels.release('my_channel') + + for _ in ably.channels: + raise AssertionError("Expected no channels to exist") + + await ably.close() + + async def test_channel_attach(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + assert channel.state == ChannelState.INITIALIZED + await channel.attach() + assert channel.state == ChannelState.ATTACHED + await ably.close() + + async def test_channel_detach(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + await channel.detach() + assert channel.state == ChannelState.DETACHED + await ably.close() + + # RTL7b + async def test_subscribe(self): + ably = await TestApp.get_ably_realtime() + + first_message_future = asyncio.Future() + second_message_future = asyncio.Future() + + def listener(message): + if not first_message_future.done(): + first_message_future.set_result(message) + else: + second_message_future.set_result(message) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + await channel.subscribe('event', listener) + + # publish a message using rest client + await channel.publish('event', 'data') + message = await first_message_future + + assert isinstance(message, Message) + assert message.name == 'event' + assert message.data == 'data' + + # test that the listener is called again for further publishes + await channel.publish('event', 'data') + await second_message_future + + await ably.close() + + # TM2a, TM2c, TM2f + async def test_check_inner_fields_updated(self): + ably = await TestApp.get_ably_realtime() + + message_future = asyncio.Future() + + def listener(msg: Message): + if not message_future.done(): + message_future.set_result(msg) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + await channel.subscribe('event', listener) + + # publish a message using rest client + await channel.publish('event', 'data') + message = await message_future + + assert isinstance(message, Message) + assert message.name == 'event' + assert message.data == 'data' + assert message.id is not None + assert message.timestamp is not None + + await ably.close() + + async def test_subscribe_coroutine(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + + message_future = asyncio.Future() + + # AsyncMock doesn't work in python 3.7 so use an actual coroutine + async def listener(msg): + message_future.set_result(msg) + + await channel.subscribe('event', listener) + + # publish a message using rest client + rest = await TestApp.get_ably_rest() + rest_channel = rest.channels.get('my_channel') + await rest_channel.publish('event', 'data') + + message = await message_future + assert isinstance(message, Message) + assert message.name == 'event' + assert message.data == 'data' + + await ably.close() + await rest.close() + + # RTL7a + async def test_subscribe_all_events(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + + message_future = asyncio.Future() + + def listener(msg): + message_future.set_result(msg) + + await channel.subscribe(listener) + + # publish a message using rest client + rest = await TestApp.get_ably_rest() + rest_channel = rest.channels.get('my_channel') + await rest_channel.publish('event', 'data') + message = await message_future + + assert isinstance(message, Message) + assert message.name == 'event' + assert message.data == 'data' + + await ably.close() + await rest.close() + + # RTL7c + async def test_subscribe_auto_attach(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + assert channel.state == ChannelState.INITIALIZED + + def listener(_): + pass + + await channel.subscribe('event', listener) + + assert channel.state == ChannelState.ATTACHED + + await ably.close() + + # RTL8b + async def test_unsubscribe(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + + message_future = asyncio.Future() + call_count = 0 + + def listener(msg): + nonlocal call_count + call_count += 1 + message_future.set_result(msg) + + await channel.subscribe('event', listener) + + # publish a message using rest client + rest = await TestApp.get_ably_rest() + rest_channel = rest.channels.get('my_channel') + await rest_channel.publish('event', 'data') + await message_future + assert call_count == 1 + + # unsubscribe the listener from the channel + channel.unsubscribe('event', listener) + + # test that the listener is not called again for further publishes + await rest_channel.publish('event', 'data') + await asyncio.sleep(1) + assert call_count == 1 + + await ably.close() + await rest.close() + + # RTL8c + async def test_unsubscribe_all(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('my_channel') + await channel.attach() + + message_future = asyncio.Future() + call_count = 0 + + def listener(msg): + nonlocal call_count + call_count += 1 + message_future.set_result(msg) + + await channel.subscribe('event', listener) + + # publish a message using rest client + rest = await TestApp.get_ably_rest() + rest_channel = rest.channels.get('my_channel') + await rest_channel.publish('event', 'data') + await message_future + assert call_count == 1 + + # unsubscribe all listeners from the channel + channel.unsubscribe() + + # test that the listener is not called again for further publishes + await rest_channel.publish('event', 'data') + await asyncio.sleep(1) + assert call_count == 1 + + await ably.close() + await rest.close() + + async def test_realtime_request_timeout_attach(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + original_send_protocol_message = ably.connection.connection_manager.send_protocol_message + + async def new_send_protocol_message(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + return + await original_send_protocol_message(msg) + ably.connection.connection_manager.send_protocol_message = new_send_protocol_message + + channel = ably.channels.get('channel_name') + with pytest.raises(AblyException) as exception: + await channel.attach() + assert exception.value.code == 90007 + assert exception.value.status_code == 408 + await ably.close() + + async def test_realtime_request_timeout_detach(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + original_send_protocol_message = ably.connection.connection_manager.send_protocol_message + + async def new_send_protocol_message(msg): + if msg.get('action') == ProtocolMessageAction.DETACH: + return + await original_send_protocol_message(msg) + ably.connection.connection_manager.send_protocol_message = new_send_protocol_message + + channel = ably.channels.get('channel_name') + await channel.attach() + with pytest.raises(AblyException) as exception: + await channel.detach() + assert exception.value.code == 90007 + assert exception.value.status_code == 408 + await ably.close() + + async def test_channel_detached_once_connection_closed(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get(random_string(5)) + await channel.attach() + + await ably.close() + assert channel.state == ChannelState.DETACHED + + async def test_channel_failed_once_connection_failed(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get(random_string(5)) + await channel.attach() + + ably.connection.connection_manager.notify_state(ConnectionState.SUSPENDED) + assert channel.state == ChannelState.SUSPENDED + + await ably.close() + + async def test_channel_suspended_once_connection_suspended(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get(random_string(5)) + await channel.attach() + + ably.connection.connection_manager.notify_state(ConnectionState.FAILED) + assert channel.state == ChannelState.FAILED + + await ably.close() + + async def test_attach_while_connecting(self): + ably = await TestApp.get_ably_realtime() + channel = ably.channels.get(random_string(5)) + await channel.attach() + assert channel.state == ChannelState.ATTACHED + await ably.close() + + # RTL13a + async def test_channel_attach_retry_immediately_on_unexpected_detached(self): + ably = await TestApp.get_ably_realtime(channel_retry_timeout=500) + channel_name = random_string(5) + channel = ably.channels.get(channel_name) + await channel.attach() + + # Simulate an unexpected DETACHED message from ably + message = { + "action": ProtocolMessageAction.DETACHED, + "channel": channel_name, + } + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(message) + + # The channel should retry attachment immediately + assert channel.state == ChannelState.ATTACHING + + # Make sure the channel sucessfully re-attaches + await channel.once_async(ChannelState.ATTACHED) + + await ably.close() + + # RTL13b + async def test_channel_attach_retry_after_unsuccessful_attach(self): + ably = await TestApp.get_ably_realtime(channel_retry_timeout=500, realtime_request_timeout=1000) + channel_name = random_string(5) + channel = ably.channels.get(channel_name) + call_count = 0 + + original_send_protocol_message = ably.connection.connection_manager.send_protocol_message + + # Discard the first ATTACHED message recieved + async def new_send_protocol_message(msg): + nonlocal call_count + if call_count == 0 and msg.get('action') == ProtocolMessageAction.ATTACH: + call_count += 1 + return + await original_send_protocol_message(msg) + ably.connection.connection_manager.send_protocol_message = new_send_protocol_message + + with pytest.raises(AblyException): + await channel.attach() + + # The channel should become SUSPENDED but will still retry again after channel_retry_timeout + assert channel.state == ChannelState.SUSPENDED + + # Make sure the channel sucessfully re-attaches + await channel.once_async(ChannelState.ATTACHED) + + await ably.close() + + async def test_channel_initialized_on_connection_from_terminal_state(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name) + await channel.attach() + await ably.close() + ably.connect() + assert channel.state == ChannelState.INITIALIZED + await ably.close() + + async def test_channel_error(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name) + await channel.attach() + code = 12345 + status_code = 123 + + msg = { + "action": ProtocolMessageAction.ERROR, + "channel": channel_name, + "error": { + "message": "test error", + "code": code, + "statusCode": status_code, + }, + } + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(msg) + + assert channel.state == ChannelState.FAILED + assert channel.error_reason + assert channel.error_reason.code == code + assert channel.error_reason.status_code == status_code + + await ably.close() + + async def test_channel_error_cleared_upon_attach(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name) + await channel.attach() + code = 12345 + status_code = 123 + + msg = { + "action": ProtocolMessageAction.ERROR, + "channel": channel_name, + "error": { + "message": "test error", + "code": code, + "statusCode": status_code, + }, + } + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(msg) + + assert channel.error_reason is not None + await channel.attach() + assert channel.error_reason is None + + await ably.close() + + async def test_channel_error_cleared_upon_connect_from_terminal_state(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name) + await channel.attach() + code = 12345 + status_code = 123 + + msg = { + "action": ProtocolMessageAction.ERROR, + "channel": channel_name, + "error": { + "message": "test error", + "code": code, + "statusCode": status_code, + }, + } + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.on_protocol_message(msg) + + await ably.close() + + assert channel.error_reason is not None + ably.connect() + assert channel.error_reason is None + + await ably.close() + + async def test_channel_params_received_by_relatime(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name, ChannelOptions(params={ + "rewind": "1" + })) + await channel.attach() + assert channel.params["rewind"] == "1" + + await ably.close() + + async def test_channel_params_unknown_params_skipped_by_relatime(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name, ChannelOptions(params={ + "rewind": "1", + "foo": "bar" + })) + await channel.attach() + assert channel.params["rewind"] == "1" + assert channel.params.get("foo") is None + + await ably.close() + + async def test_channel_params_as_dict(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name, ChannelOptions(params={"delta": "vcdiff"})) + await channel.attach() + assert channel.params["delta"] == "vcdiff" + + await ably.close() + + async def test_channel_get_channel_with_same_params(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name, ChannelOptions(params={"rewind": "1"})) + await channel.attach() + same_channel = ably.channels.get(channel_name, ChannelOptions(params={"rewind": "1"})) + assert channel == same_channel + + await ably.close() + + async def test_channel_get_channel_with_different_params(self): + ably = await TestApp.get_ably_realtime() + channel_name = random_string(5) + channel = ably.channels.get(channel_name, ChannelOptions(params={"rewind": "1"})) + await channel.attach() + + with pytest.raises(AblyException) as exception: + ably.channels.get(channel_name, ChannelOptions(params={"delta": "vcdiff"})) + + assert exception.value.code == 40000 + assert exception.value.status_code == 400 + + assert channel.params == {"rewind": "1"} + + await ably.close() diff --git a/test/ably/realtime/realtimechannel_vcdiff_test.py b/test/ably/realtime/realtimechannel_vcdiff_test.py new file mode 100644 index 00000000..48a484a9 --- /dev/null +++ b/test/ably/realtime/realtimechannel_vcdiff_test.py @@ -0,0 +1,228 @@ +import asyncio +import json + +import pytest + +from ably import AblyVCDiffDecoder +from ably.realtime.connection import ConnectionState +from ably.types.channeloptions import ChannelOptions +from ably.types.options import VCDiffDecoder +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, WaitableEvent + + +class MockVCDiffDecoder(VCDiffDecoder): + """Test VCDiff decoder that tracks number of calls""" + + def __init__(self): + self.number_of_calls = 0 + self.last_decoded_data = None + self.vcdiff_decoder = AblyVCDiffDecoder() + + def decode(self, delta: bytes, base: bytes) -> bytes: + self.number_of_calls += 1 + self.last_decoded_data = self.vcdiff_decoder.decode(delta, base) + return self.last_decoded_data + + +class FailingVCDiffDecoder(VCDiffDecoder): + """VCDiff decoder that always fails""" + + def decode(self, delta: bytes, base: bytes) -> bytes: + raise Exception("Failed to decode delta.") + + +class TestRealtimeChannelVCDiff(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.valid_key_format = "api:key" + + # Test data equivalent to JavaScript version + self.test_data = [ + {'foo': 'bar', 'count': 1, 'status': 'active'}, + {'foo': 'bar', 'count': 2, 'status': 'active'}, + {'foo': 'bar', 'count': 2, 'status': 'inactive'}, + {'foo': 'bar', 'count': 3, 'status': 'inactive'}, + {'foo': 'bar', 'count': 3, 'status': 'active'}, + ] + + def _equals(self, a, b): + """Helper method to compare objects like the JavaScript version""" + return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True) + + async def test_delta_plugin(self): + """Test VCDiff delta plugin functionality""" + test_vcdiff_decoder = MockVCDiffDecoder() + ably = await TestApp.get_ably_realtime(vcdiff_decoder=test_vcdiff_decoder) + + try: + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('delta_plugin', ChannelOptions(params={'delta': 'vcdiff'})) + await channel.attach() + + messages_received = [] + waitable_event = WaitableEvent() + + def on_message(message): + try: + index = int(message.name) + messages_received.append(message.data) + + if index == len(self.test_data) - 1: + # All messages received + waitable_event.finish() + except Exception as e: + waitable_event.finish() + raise e + + await channel.subscribe(on_message) + + # Publish all test messages + for i, data in enumerate(self.test_data): + await channel.publish(str(i), data) + + # Wait for all messages to be received + await waitable_event.wait(timeout=30) + for (expected_message, actual_message) in zip(self.test_data, messages_received): + assert expected_message == actual_message, f"Check message.data for message {expected_message}" + + assert test_vcdiff_decoder.number_of_calls == len(self.test_data) - 1, "Check number of delta messages" + + finally: + await ably.close() + + async def test_unused_plugin(self): + """Test that VCDiff plugin is not used when delta is not enabled""" + test_vcdiff_decoder = MockVCDiffDecoder() + ably = await TestApp.get_ably_realtime(vcdiff_decoder=test_vcdiff_decoder) + + try: + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + # Channel without delta parameter + channel = ably.channels.get('unused_plugin') + await channel.attach() + + messages_received = [] + waitable_event = WaitableEvent() + + def on_message(message): + try: + index = int(message.name) + messages_received.append(message.data) + + if index == len(self.test_data) - 1: + waitable_event.finish() + except Exception as e: + waitable_event.finish() + raise e + + await channel.subscribe(on_message) + + # Publish all test messages + for i, data in enumerate(self.test_data): + await channel.publish(str(i), data) + + # Wait for all messages to be received + await waitable_event.wait(timeout=30) + assert test_vcdiff_decoder.number_of_calls == 0, "Check number of delta messages" + for (expected_message, actual_message) in zip(self.test_data, messages_received): + assert expected_message == actual_message, f"Check message.data for message {expected_message}" + finally: + await ably.close() + + async def test_delta_decode_failure_recovery(self): + """Test channel recovery when VCDiff decode fails""" + failing_decoder = FailingVCDiffDecoder() + ably = await TestApp.get_ably_realtime(vcdiff_decoder=failing_decoder) + + try: + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get('decode_failure_recovery', ChannelOptions(params={'delta': 'vcdiff'})) + + # Monitor for attaching state changes + attaching_events = [] + + def on_attaching(state_change): + attaching_events.append(state_change) + # RTL18c - Check error code + if state_change.reason and state_change.reason.code: + assert state_change.reason.code == 40018, "Check error code passed through per RTL18c" + + channel.on('attaching', on_attaching) + await channel.attach() + + messages_received = [] + waitable_event = WaitableEvent() + + def on_message(message): + try: + index = int(message.name) + messages_received.append(message.data) + + if index == len(self.test_data) - 1: + waitable_event.finish() + except Exception as e: + waitable_event.finish() + raise e + + await channel.subscribe(on_message) + + # Publish all test messages + for i, data in enumerate(self.test_data): + await channel.publish(str(i), data) + + # Wait for messages - should recover and receive them + await waitable_event.wait(timeout=30) + + # Should have triggered at least one reattach due to decode failure + assert len(attaching_events) > 0, "Should have triggered channel reattaching" + + for (expected_message, actual_message) in zip(self.test_data, messages_received): + assert expected_message == actual_message, f"Check message.data for message {expected_message}" + finally: + await ably.close() + + async def test_delta_message_out_of_order(self): + test_vcdiff_decoder = MockVCDiffDecoder() + ably = await TestApp.get_ably_realtime(vcdiff_decoder=test_vcdiff_decoder) + + try: + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + channel = ably.channels.get('delta_plugin_out_of_order', ChannelOptions(params={'delta': 'vcdiff'})) + await channel.attach() + message_waiters = [WaitableEvent(), WaitableEvent()] + messages_received = [] + counter = 0 + + def on_message(message): + nonlocal counter + messages_received.append(message.data) + message_waiters[counter].finish() + counter += 1 + + await channel.subscribe(on_message) + await channel.publish("1", self.test_data[0]) + await message_waiters[0].wait(timeout=30) + + attaching_reason = None + + def on_attaching(state_change): + nonlocal attaching_reason + attaching_reason = state_change.reason + + channel.on('attaching', on_attaching) + + object.__getattribute__(channel, '_RealtimeChannel__decoding_context').last_message_id = 'fake_id' + await channel.publish("2", self.test_data[1]) + await message_waiters[1].wait(timeout=30) + assert test_vcdiff_decoder.number_of_calls == 0, "Check that no delta message was decoded" + assert self._equals(messages_received[0], self.test_data[0]), "Check message.data for message 1" + assert self._equals(messages_received[1], self.test_data[1]), "Check message.data for message 2" + assert attaching_reason.code == 40018, "Check error code passed through per RTL18c" + + finally: + await ably.close() diff --git a/test/ably/realtime/realtimechannelmutablemessages_test.py b/test/ably/realtime/realtimechannelmutablemessages_test.py new file mode 100644 index 00000000..afe8a60f --- /dev/null +++ b/test/ably/realtime/realtimechannelmutablemessages_test.py @@ -0,0 +1,333 @@ +import logging +from typing import List + +import pytest + +from ably import AblyException, CipherParams, MessageAction +from ably.types.message import Message +from ably.types.operations import MessageOperation +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, WaitableEvent, assert_waiter + +log = logging.getLogger(__name__) + + +@pytest.mark.parametrize("transport", ["json", "msgpack"], ids=["JSON", "MsgPack"]) +class TestRealtimeChannelMutableMessages(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self, transport): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_realtime( + use_binary_protocol=True if transport == 'msgpack' else False, + ) + + async def test_update_message_success(self): + """Test successfully updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_test')] + + # First publish a message + result = await channel.publish('test-event', 'original data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial for update + message = Message( + data='updated data', + serial=serial, + ) + + # Update the message + update_result = await channel.update_message(message) + assert update_result is not None + updated_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert updated_message.data == 'updated data' + assert updated_message.version.serial == update_result.version_serial + assert updated_message.serial == serial + + async def test_update_message_without_serial_fails(self): + """Test that updating without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:update_test_no_serial')] + + message = Message(name='test-event', data='data') + + with pytest.raises(AblyException) as exc_info: + await channel.update_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_delete_message_success(self): + """Test successfully deleting a message""" + channel = self.ably.channels[self.get_channel_name('mutable:delete_test')] + + # First publish a message + result = await channel.publish('test-event', 'data to delete') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial for deletion + message = Message(serial=serial) + + operation = MessageOperation( + description='Inappropriate content', + metadata={'reason': 'moderation'} + ) + + # Delete the message + delete_result = await channel.delete_message(message, operation) + assert delete_result is not None + + # Verify the deletion propagated + deleted_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_DELETE + ) + assert deleted_message.action == MessageAction.MESSAGE_DELETE + assert deleted_message.version.serial == delete_result.version_serial + assert deleted_message.version.description == 'Inappropriate content' + assert deleted_message.version.metadata == {'reason': 'moderation'} + assert deleted_message.serial == serial + + async def test_delete_message_without_serial_fails(self): + """Test that deleting without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:delete_test_no_serial')] + + message = Message(name='test-event', data='data') + + with pytest.raises(AblyException) as exc_info: + await channel.delete_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_append_message_success(self): + """Test successfully appending to a message""" + channel = self.ably.channels[self.get_channel_name('mutable:append_test')] + + # First publish a message + result = await channel.publish('test-event', 'original content') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial and data to append + message = Message( + data=' appended content', + serial=serial + ) + + operation = MessageOperation( + description='Added more info', + metadata={'type': 'amendment'} + ) + + # Append to the message + append_result = await channel.append_message(message, operation) + assert append_result is not None + + # Verify the append propagated - action will be MESSAGE_UPDATE, data should be concatenated + appended_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert appended_message.data == 'original content appended content' + assert appended_message.version.serial == append_result.version_serial + assert appended_message.version.description == 'Added more info' + assert appended_message.version.metadata == {'type': 'amendment'} + assert appended_message.serial == serial + + async def test_append_message_without_serial_fails(self): + """Test that appending without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:append_test_no_serial')] + + message = Message(name='test-event', data='data to append') + + with pytest.raises(AblyException) as exc_info: + await channel.append_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_update_message_with_encryption(self): + """Test updating an encrypted message""" + # Create channel with encryption + channel_name = self.get_channel_name('mutable:update_encrypted') + cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes') + channel = self.ably.channels.get(channel_name, cipher=cipher_params) + + # Publish encrypted message + result = await channel.publish('encrypted-event', 'secret data') + assert result.serials is not None + assert len(result.serials) > 0 + + # Update the encrypted message + message = Message( + name='encrypted-event', + data='updated secret data', + serial=result.serials[0] + ) + + operation = MessageOperation(description='Updated encrypted message') + update_result = await channel.update_message(message, operation) + assert update_result is not None + + async def test_publish_returns_serials(self): + """Test that publish returns PublishResult with serials""" + channel = self.ably.channels[self.get_channel_name('mutable:publish_serials')] + + # Publish multiple messages + messages = [ + Message('event1', 'data1'), + Message('event2', 'data2'), + Message('event3', 'data3') + ] + + result = await channel.publish(messages) + assert result is not None + assert hasattr(result, 'serials') + assert len(result.serials) == 3 + + async def test_complete_workflow_publish_update_delete(self): + """Test complete workflow: publish, update, delete""" + channel = self.ably.channels[self.get_channel_name('mutable:complete_workflow')] + + # 1. Publish a message + result = await channel.publish('workflow_event', 'Initial data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # 2. Update the message + update_message = Message( + name='workflow_event_updated', + data='Updated data', + serial=serial + ) + update_operation = MessageOperation(description='Updated message') + update_result = await channel.update_message(update_message, update_operation) + assert update_result is not None + + # 3. Delete the message + delete_message = Message(serial=serial, data='Deleted') + delete_operation = MessageOperation(description='Deleted message') + delete_result = await channel.delete_message(delete_message, delete_operation) + assert delete_result is not None + + versions = await self.wait_until_get_all_message_version(channel, serial, 3) + + assert versions[0].version.serial == serial + assert versions[1].version.serial == update_result.version_serial + assert versions[2].version.serial == delete_result.version_serial + + async def test_append_message_with_string_data(self): + """Test appending string data to a message""" + channel = self.ably.channels[self.get_channel_name('mutable:append_string')] + + # Publish initial message + result = await channel.publish('append_event', 'Initial data') + assert len(result.serials) > 0 + serial = result.serials[0] + + messages_received = [] + append_received = WaitableEvent() + + def on_message(message): + messages_received.append(message) + if len(messages_received) == 2: + append_received.finish() + + await channel.subscribe(on_message) + + # Append data + append_message = Message( + data=' appended data', + serial=serial + ) + append_operation = MessageOperation(description='Appended to message') + append_result = await channel.append_message(append_message, append_operation) + assert append_result is not None + + # Verify the append + appended_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + + second_append_result = await channel.append_message(append_message, append_operation) + + await append_received.wait() + + assert messages_received[0].data == 'Initial data appended data' + assert messages_received[0].action == MessageAction.MESSAGE_UPDATE + assert appended_message.data == 'Initial data appended data' + assert appended_message.version.serial == append_result.version_serial + assert appended_message.version.description == 'Appended to message' + assert appended_message.serial == serial + + assert messages_received[1].data == ' appended data' + assert messages_received[1].action == MessageAction.MESSAGE_APPEND + assert messages_received[1].version.serial == second_append_result.version_serial + + # RTL32b, TM2i + async def test_update_message_preserves_extras(self): + """Test that extras are preserved when updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_extras')] + + # Publish a message + result = await channel.publish('test-event', 'original data') + assert len(result.serials) > 0 + serial = result.serials[0] + + messages_received = [] + update_received = WaitableEvent() + + def on_message(message): + if message.action == MessageAction.MESSAGE_UPDATE: + messages_received.append(message) + update_received.finish() + + await channel.subscribe(on_message) + + # Update with extras + message = Message( + data='updated data', + serial=serial, + extras={'headers': {'status': 'complete'}}, + ) + + update_result = await channel.update_message(message) + assert update_result is not None + + await update_received.wait() + + assert len(messages_received) > 0 + received = messages_received[0] + assert received.extras is not None + assert received.extras['headers']['status'] == 'complete' + + async def wait_until_message_with_action_appears(self, channel, serial, action): + message: Message | None = None + async def check_message_action(): + nonlocal message + try: + message = await channel.get_message(serial) + return message.action == action + except Exception: + return False + + await assert_waiter(check_message_action) + + return message + + async def wait_until_get_all_message_version(self, channel, serial, count): + versions: List[Message] = [] + async def check_message_versions(): + nonlocal versions + versions = (await channel.get_message_versions(serial)).items + return len(versions) >= count + + await assert_waiter(check_message_versions) + + return versions diff --git a/test/ably/realtime/realtimeconnection_test.py b/test/ably/realtime/realtimeconnection_test.py new file mode 100644 index 00000000..2593eb3e --- /dev/null +++ b/test/ably/realtime/realtimeconnection_test.py @@ -0,0 +1,575 @@ +import asyncio + +import pytest +from websockets import connect as _ws_connect + +try: + # websockets 15+ preferred import + from websockets.asyncio.server import serve as ws_serve +except ImportError: + # websockets 14 and earlier fallback + from websockets.server import serve as ws_serve + +from ably.realtime.connection import ConnectionEvent, ConnectionState +from ably.transport.defaults import Defaults +from ably.transport.websockettransport import ProtocolMessageAction +from ably.util.exceptions import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +async def _relay(src, dst): + try: + async for msg in src: + await dst.send(msg) + except Exception: + pass + + +class WsProxy: + """Local WS proxy that forwards to real Ably and lets tests trigger a normal close.""" + + def __init__(self, target_host: str): + self.target_host = target_host + self.server = None + self.port: int | None = None + self._close_event: asyncio.Event | None = None + + async def _handler(self, client_ws): + # Create a fresh event for this connection; signal to drop the connection cleanly + self._close_event = asyncio.Event() + path = client_ws.request.path # e.g. "/?key=...&format=json" + target_url = f"wss://{self.target_host}{path}" + try: + async with _ws_connect(target_url, ping_interval=None) as server_ws: + c2s = asyncio.create_task(_relay(client_ws, server_ws)) + s2c = asyncio.create_task(_relay(server_ws, client_ws)) + close_task = asyncio.create_task(self._close_event.wait()) + try: + await asyncio.wait([c2s, s2c, close_task], return_when=asyncio.FIRST_COMPLETED) + finally: + c2s.cancel() + s2c.cancel() + close_task.cancel() + except Exception: + pass + # After _handler returns the websockets server sends a normal close frame (1000) + + async def close_active_connection(self): + """Trigger a normal WS close (code 1000) on the currently active client connection. + + Signals the handler to exit; the websockets server framework then sends the + close frame automatically when the handler coroutine returns. + """ + if self._close_event: + self._close_event.set() + + @property + def endpoint(self) -> str: + """Endpoint string to pass to AblyRealtime (combine with tls=False).""" + return f"127.0.0.1:{self.port}" + + async def __aenter__(self): + self.server = await ws_serve(self._handler, "127.0.0.1", 0, ping_interval=None) + self.port = self.server.sockets[0].getsockname()[1] + return self + + async def __aexit__(self, *args): + if self.server: + self.server.close() + await self.server.wait_closed() + + +class TestRealtimeConnection(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.valid_key_format = "api:key" + + async def test_connection_state(self): + ably = await TestApp.get_ably_realtime(auto_connect=False) + assert ably.connection.state == ConnectionState.INITIALIZED + ably.connect() + await ably.connection.once_async() + assert ably.connection.state == ConnectionState.CONNECTING + await ably.connection.once_async() + assert ably.connection.state == ConnectionState.CONNECTED + await ably.close() + assert ably.connection.state == ConnectionState.CLOSED + + async def test_connection_state_is_connecting_on_init(self): + ably = await TestApp.get_ably_realtime() + assert ably.connection.state == ConnectionState.CONNECTING + await ably.close() + + async def test_auth_invalid_key(self): + ably = await TestApp.get_ably_realtime(key=self.valid_key_format) + state_change = await ably.connection.once_async() + assert ably.connection.state == ConnectionState.FAILED + assert state_change.reason + assert state_change.reason.code == 40101 + assert state_change.reason.status_code == 401 + assert ably.connection.error_reason + assert ably.connection.error_reason.code == 40101 + assert ably.connection.error_reason.status_code == 401 + await ably.close() + + async def test_connection_ping_connected(self): + ably = await TestApp.get_ably_realtime() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + response_time_ms = await ably.connection.ping() + assert response_time_ms is not None + assert type(response_time_ms) is float + await ably.close() + + async def test_connection_ping_initialized(self): + ably = await TestApp.get_ably_realtime(auto_connect=False) + assert ably.connection.state == ConnectionState.INITIALIZED + with pytest.raises(AblyException) as exception: + await ably.connection.ping() + assert exception.value.code == 400 + assert exception.value.status_code == 40000 + + async def test_connection_ping_failed(self): + ably = await TestApp.get_ably_realtime(key=self.valid_key_format) + await ably.connection.once_async(ConnectionState.FAILED) + assert ably.connection.state == ConnectionState.FAILED + with pytest.raises(AblyException) as exception: + await ably.connection.ping() + assert exception.value.code == 400 + assert exception.value.status_code == 40000 + await ably.close() + + async def test_connection_ping_closed(self): + ably = await TestApp.get_ably_realtime() + ably.connect() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + await ably.close() + with pytest.raises(AblyException) as exception: + await ably.connection.ping() + assert exception.value.code == 400 + assert exception.value.status_code == 40000 + + async def test_auto_connect(self): + ably = await TestApp.get_ably_realtime() + connect_future = asyncio.Future() + ably.connection.on(ConnectionState.CONNECTED, lambda change: connect_future.set_result(change)) + await connect_future + assert ably.connection.state == ConnectionState.CONNECTED + await ably.close() + + async def test_connection_state_change(self): + ably = await TestApp.get_ably_realtime() + + connected_future = asyncio.Future() + + def on_state_change(change): + connected_future.set_result(change) + + ably.connection.on(ConnectionState.CONNECTED, on_state_change) + + state_change = await connected_future + assert state_change.previous == ConnectionState.CONNECTING + assert state_change.current == ConnectionState.CONNECTED + await ably.close() + + async def test_connection_state_change_reason(self): + ably = await TestApp.get_ably_realtime(key=self.valid_key_format) + + state_change = await ably.connection.once_async() + + assert state_change.previous == ConnectionState.CONNECTING + assert state_change.current == ConnectionState.FAILED + assert ably.connection.error_reason is not None + assert ably.connection.error_reason is state_change.reason + await ably.close() + + async def test_realtime_request_timeout_connect(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=0.000001) + state_change = await ably.connection.once_async() + assert state_change.reason is not None + assert state_change.reason.code == 50003 + assert state_change.reason.status_code == 504 + assert ably.connection.state == ConnectionState.DISCONNECTED + assert ably.connection.error_reason == state_change.reason + await ably.close() + + async def test_realtime_request_timeout_ping(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + original_send_protocol_message = ably.connection.connection_manager.send_protocol_message + + async def new_send_protocol_message(protocol_message): + if protocol_message.get('action') == ProtocolMessageAction.HEARTBEAT: + return + await original_send_protocol_message(protocol_message) + + ably.connection.connection_manager.send_protocol_message = new_send_protocol_message + + with pytest.raises(AblyException) as exception: + await ably.connection.ping() + + assert exception.value.code == 50003 + assert exception.value.status_code == 504 + await ably.close() + + async def test_disconnected_retry_timeout(self): + ably = await TestApp.get_ably_realtime(disconnected_retry_timeout=2000, auto_connect=False) + original_connect = ably.connection.connection_manager.connect_base + call_count = 0 + + # intercept the library connection mechanism to fail the first two connection attempts + async def new_connect(): + nonlocal call_count + if call_count < 2: + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + call_count += 1 + else: + await original_connect() + + ably.connection.connection_manager.connect_base = new_connect + + ably.connect() + + await ably.connection.once_async(ConnectionState.DISCONNECTED) + + # Test that the library eventually connects after two failed attempts + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + await ably.close() + + async def test_connectivity_check_default(self): + ably = await TestApp.get_ably_realtime(auto_connect=False) + # The default connectivity check should return True + assert ably.connection.connection_manager.check_connection() is True + + async def test_connectivity_check_non_default(self): + ably = await TestApp.get_ably_realtime( + connectivity_check_url="https://echo.ably.io/respondWith?status=200", auto_connect=False) + # A non-default URL should return True with a HTTP OK despite not returning "Yes" in the body + assert ably.connection.connection_manager.check_connection() is True + + async def test_connectivity_check_bad_status(self): + ably = await TestApp.get_ably_realtime( + connectivity_check_url="https://echo.ably.io/respondWith?status=400", auto_connect=False) + # Should return False when the URL returns a non-2xx response code + assert ably.connection.connection_manager.check_connection() is False + + async def test_unroutable_host(self): + ably = await TestApp.get_ably_realtime(endpoint="10.255.255.1", realtime_request_timeout=3000) + state_change = await ably.connection.once_async() + assert state_change.reason + assert state_change.reason.code == 50003 + assert state_change.reason.status_code == 504 + assert ably.connection.state == ConnectionState.DISCONNECTED + assert ably.connection.error_reason == state_change.reason + await ably.close() + + async def test_invalid_host(self): + ably = await TestApp.get_ably_realtime(endpoint="iamnotahost") + state_change = await ably.connection.once_async() + assert state_change.reason + assert state_change.reason.code == 40000 + assert state_change.reason.status_code == 400 + assert ably.connection.state == ConnectionState.DISCONNECTED + assert ably.connection.error_reason == state_change.reason + await ably.close() + + async def test_connection_state_ttl(self): + Defaults.connection_state_ttl = 10 + ably = await TestApp.get_ably_realtime() + + state_change = await ably.connection.once_async() + + assert state_change.previous == ConnectionState.CONNECTING + assert state_change.current == ConnectionState.SUSPENDED + assert state_change.reason + assert state_change.reason.code == 80002 + assert state_change.reason.status_code == 400 + assert ably.connection.connection_details is None + await ably.close() + + Defaults.connection_state_ttl = 120000 + + async def test_handle_connected(self): + ably = await TestApp.get_ably_realtime() + test_future = asyncio.Future() + + def on_update(connection_state): + if connection_state.event == ConnectionEvent.UPDATE: + test_future.set_result(connection_state) + + ably.connection.on(ConnectionEvent.UPDATE, on_update) + + async def on_transport_pending(transport): + await transport.on_protocol_message({'action': 4, "connectionDetails": {"connectionStateTtl": 200}}) + + ably.connection.connection_manager.on('transport.pending', on_transport_pending) + + state_change = await test_future + + assert state_change.previous == ConnectionState.CONNECTED + assert state_change.current == ConnectionState.CONNECTED + assert state_change.event == ConnectionEvent.UPDATE + await ably.close() + + async def test_max_idle_interval(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000) + + def on_transport_pending(transport): + original_on_protocol_message = transport.on_protocol_message + + async def on_protocol_message(msg): + if msg["action"] == ProtocolMessageAction.CONNECTED: + msg["connectionDetails"]["maxIdleInterval"] = 100 + + await original_on_protocol_message(msg) + + transport.on_protocol_message = on_protocol_message + + ably.connection.connection_manager.on('transport.pending', on_transport_pending) + + state_change = await ably.connection.once_async(ConnectionState.DISCONNECTED) + + assert state_change.previous == ConnectionState.CONNECTED + assert state_change.current == ConnectionState.DISCONNECTED + assert state_change.reason.code == 80003 + assert state_change.reason.status_code == 408 + + await ably.close() + + # RTN15a + async def test_retry_immediately_upon_unexpected_disconnection(self): + # Set timeouts to 500s so that if the client uses retry delay the test will fail with a timeout + ably = await TestApp.get_ably_realtime( + disconnected_retry_timeout=500_000, + suspended_retry_timeout=500_000 + ) + + # Wait for the client to connect + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + # Simulate random loss of connection + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + assert ably.connection.state == ConnectionState.DISCONNECTED + + # Wait for the client to connect again + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + await ably.close() + + async def test_fallback_host(self): + ably = await TestApp.get_ably_realtime() + + await ably.connection.connection_manager.once_async('transport.pending') + assert ably.connection.connection_manager.transport + ably.connection.connection_manager.transport._emit('failed', AblyException("test exception", 502, 50200)) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert ably.connection.connection_manager.transport.host != self.test_vars["host"] + assert ably.options.fallback_host != self.test_vars["host"] + await ably.close() + + async def test_fallback_host_no_connection(self): + ably = await TestApp.get_ably_realtime() + + await ably.connection.connection_manager.once_async('transport.pending') + assert ably.connection.connection_manager.transport + + def check_connection(): + return False + + ably.connection.connection_manager.check_connection = check_connection + + asyncio.create_task(ably.connection.connection_manager.transport.on_protocol_message({ + "action": ProtocolMessageAction.DISCONNECTED, + "error": { + "statusCode": 502, + "code": 50200, + "message": "test exception" + } + })) + + await ably.connection.once_async(ConnectionState.DISCONNECTED) + + assert ably.options.fallback_host is None + await ably.close() + + async def test_fallback_host_disconnected_protocol_msg(self): + ably = await TestApp.get_ably_realtime() + + await ably.connection.connection_manager.once_async('transport.pending') + assert ably.connection.connection_manager.transport + asyncio.create_task(ably.connection.connection_manager.transport.on_protocol_message({ + "action": ProtocolMessageAction.DISCONNECTED, + "error": { + "statusCode": 502, + "code": 50200, + "message": "test exception" + } + })) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert ably.connection.connection_manager.transport.host != self.test_vars["host"] + assert ably.options.fallback_host != self.test_vars["host"] + await ably.close() + + # RTN2d + async def test_connection_null_client_id_query_params(self): + rest = await TestApp.get_ably_rest() + + token_details = await rest.auth.request_token() + + realtime = await TestApp.get_ably_realtime(token_details=token_details) + + await realtime.connection.once_async(ConnectionState.CONNECTED) + assert realtime.connection.connection_manager.transport.params.get("client_id") is None + assert realtime.auth.client_id is None + + await realtime.close() + await rest.close() + + async def test_connection_client_id_query_params(self): + client_id = 'test_client_id' + + ably = await TestApp.get_ably_realtime(client_id=client_id) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + assert ably.connection.connection_manager.transport.params["clientId"] == client_id + assert ably.auth.client_id == client_id + + await ably.close() + + async def test_lost_connection_lifecycle(self): + ably = await TestApp.get_ably_realtime(realtime_request_timeout=2000, disconnected_retry_timeout=2000) + + # when client connectivity is lost, the transport will become aware of a connectivity issue + # when it stops seeing activity from realtime within maxIdleInterval, therefore setting the max idle + # interval arbitrarily low will simulate client behaviour when connectivity is lost. + def on_transport_pending(transport): + original_on_protocol_message = transport.on_protocol_message + + async def on_protocol_message(msg): + if msg["action"] == ProtocolMessageAction.CONNECTED: + msg["connectionDetails"]["maxIdleInterval"] = 1000 + + await original_on_protocol_message(msg) + + transport.on_protocol_message = on_protocol_message + + ably.connection.connection_manager.once('transport.pending', on_transport_pending) + + # should transition to disconnected due to lack of activity from realtime + await ably.connection.once_async(ConnectionState.DISCONNECTED) + + # should re-establish connection after disconnected_retry_timeout + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + await ably.close() + + # RTN2f - Test msgpack format parameter when use_binary_protocol is enabled + async def test_connection_format_msgpack_with_binary_protocol(self): + """Test that format=msgpack is sent when use_binary_protocol=True""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=True) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + received_raw_websocket_frames = [] + transport = ably.connection.connection_manager.transport + original_decode_raw_websocket_frame = transport.decode_raw_websocket_frame + + def intercepted_websocket_frame(data): + received_raw_websocket_frames.append(data) + return original_decode_raw_websocket_frame(data) + + transport.decode_raw_websocket_frame = intercepted_websocket_frame + + # Verify transport has format set to msgpack + assert ably.connection.connection_manager.transport is not None + assert ably.connection.connection_manager.transport.format == 'msgpack' + + # Verify params include format=msgpack + assert ably.connection.connection_manager.transport.params.get('format') == 'msgpack' + + await ably.channels.get('connection_test').publish('test', b'test') + + assert len(received_raw_websocket_frames) > 0 + assert all(isinstance(frame, bytes) for frame in received_raw_websocket_frames) + + await ably.close() + + async def test_connection_format_json_without_binary_protocol(self): + """Test that format defaults to json when use_binary_protocol=False""" + ably = await TestApp.get_ably_realtime(use_binary_protocol=False) + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + received_raw_websocket_frames = [] + transport = ably.connection.connection_manager.transport + original_decode_raw_websocket_frame = transport.decode_raw_websocket_frame + + def intercepted_websocket_frame(data): + received_raw_websocket_frames.append(data) + return original_decode_raw_websocket_frame(data) + + transport.decode_raw_websocket_frame = intercepted_websocket_frame + + # Verify transport has format set to json (default) + assert ably.connection.connection_manager.transport is not None + assert ably.connection.connection_manager.transport.format == 'json' + + await ably.channels.get('connection_test').publish('test', b'test') + + # Verify params don't include format parameter (or it's json) + transport_format = ably.connection.connection_manager.transport.params.get('format') + assert transport_format is None or transport_format == 'json' + + assert len(received_raw_websocket_frames) > 0 + assert all(isinstance(frame, str) for frame in received_raw_websocket_frames) + + await ably.close() + + # TO3g + async def test_queue_messages_defaults_to_true(self): + """TO3g: Verify that queueMessages client option defaults to true""" + ably = await TestApp.get_ably_realtime(auto_connect=False) + + # TO3g: queueMessages defaults to true + assert ably.options.queue_messages is True + assert ably.connection.connection_manager.options.queue_messages is True + + async def test_normal_ws_close_triggers_immediate_reconnection(self): + """Server normal WS close (code 1000) must trigger immediate reconnection. + + Regression test: ConnectionClosedOK was silently swallowed and deactivate_transport + was never called, leaving the client disconnected until the idle timer fired. + """ + async with WsProxy(self.test_vars["host"]) as proxy: + ably = await TestApp.get_ably_realtime( + disconnected_retry_timeout=500_000, + suspended_retry_timeout=500_000, + tls=False, + endpoint=proxy.endpoint, + ) + + try: + await asyncio.wait_for( + ably.connection.once_async(ConnectionState.CONNECTED), timeout=10 + ) + + # Simulate server sending a normal WS close frame + await proxy.close_active_connection() + + # Must go CONNECTING quickly — not after the 25 s idle timer + await asyncio.wait_for( + ably.connection.once_async(ConnectionState.CONNECTING), timeout=1 + ) + + # Must reconnect immediately — not after the 500 s retry timer + await asyncio.wait_for( + ably.connection.once_async(ConnectionState.CONNECTED), timeout=10 + ) + finally: + await ably.close() diff --git a/test/ably/realtime/realtimeinit_test.py b/test/ably/realtime/realtimeinit_test.py new file mode 100644 index 00000000..4009d046 --- /dev/null +++ b/test/ably/realtime/realtimeinit_test.py @@ -0,0 +1,42 @@ +import asyncio + +import pytest + +from ably import Auth +from ably.realtime.connection import ConnectionState +from ably.util.exceptions import AblyAuthException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +class TestRealtimeInit(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.valid_key_format = "api:key" + + async def test_init_with_valid_key(self): + ably = await TestApp.get_ably_realtime(key=self.test_vars["keys"][0]["key_str"], auto_connect=False) + assert Auth.Method.BASIC == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + assert ably.auth.auth_options.key_name == self.test_vars["keys"][0]['key_name'] + assert ably.auth.auth_options.key_secret == self.test_vars["keys"][0]['key_secret'] + + async def test_init_with_incorrect_key(self): + with pytest.raises(AblyAuthException): + await TestApp.get_ably_realtime(key="some invalid key", auto_connect=False) + + async def test_init_with_valid_key_format(self): + key = self.valid_key_format.split(":") + ably = await TestApp.get_ably_realtime(key=self.valid_key_format, auto_connect=False) + assert Auth.Method.BASIC == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + assert ably.auth.auth_options.key_name == key[0] + assert ably.auth.auth_options.key_secret == key[1] + + async def test_init_without_autoconnect(self): + ably = await TestApp.get_ably_realtime(auto_connect=False) + assert ably.connection.state == ConnectionState.INITIALIZED + ably.connect() + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + assert ably.connection.state == ConnectionState.CONNECTED + await ably.close() + assert ably.connection.state == ConnectionState.CLOSED diff --git a/test/ably/realtime/realtimepresence_test.py b/test/ably/realtime/realtimepresence_test.py new file mode 100644 index 00000000..86a073c7 --- /dev/null +++ b/test/ably/realtime/realtimepresence_test.py @@ -0,0 +1,887 @@ +""" +Integration tests for RealtimePresence. + +These tests verify presence functionality with real Ably connections, +testing enter/leave/update operations, presence subscriptions, and SYNC behavior. +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.types.channelstate import ChannelState +from ably.types.presence import PresenceAction +from ably.util.exceptions import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +async def force_suspended(client): + client.connection.connection_manager.request_state(ConnectionState.DISCONNECTED) + + await client.connection._when_state(ConnectionState.DISCONNECTED) + + client.connection.connection_manager.notify_state( + ConnectionState.SUSPENDED, + AblyException("Connection to server unavailable", 400, 80002) + ) + + await client.connection._when_state(ConnectionState.SUSPENDED) + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceBasics(BaseAsyncTestCase): + """Test basic presence operations: enter, leave, update.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + + self.client1 = await TestApp.get_ably_realtime( + client_id='client1', + use_binary_protocol=use_binary_protocol + ) + self.client2 = await TestApp.get_ably_realtime( + client_id='client2', + use_binary_protocol=use_binary_protocol + ) + + yield + + await self.client1.close() + await self.client2.close() + + async def test_presence_enter_without_attach(self): + """ + Test RTP8d: Enter presence without prior attach (implicit attach). + """ + channel_name = self.get_channel_name('enter_without_attach') + + # Client 1 listens for presence + channel1 = self.client1.channels.get(channel_name) + + presence_received = asyncio.Future() + + def on_presence(msg): + if msg.action == PresenceAction.ENTER and msg.client_id == 'client2': + presence_received.set_result(msg) + + await channel1.presence.subscribe(on_presence) + + # Client 2 enters without attaching first + channel2 = self.client2.channels.get(channel_name) + assert channel2.state == ChannelState.INITIALIZED + + await channel2.presence.enter('test data') + + # Should receive presence event + msg = await asyncio.wait_for(presence_received, timeout=5.0) + assert msg.client_id == 'client2' + assert msg.data == 'test data' + assert msg.action == PresenceAction.ENTER + + async def test_presence_enter_with_callback(self): + """ + Test RTP8b: Enter with callback - callback should be called on success. + """ + channel_name = self.get_channel_name('enter_with_callback') + + channel = self.client1.channels.get(channel_name) + await channel.attach() + + # Enter presence - should succeed + await channel.presence.enter('test data') + + # Verify member is present + members = await channel.presence.get() + assert len(members) == 1 + assert members[0].client_id == 'client1' + assert members[0].data == 'test data' + + async def test_presence_enter_and_leave(self): + """ + Test RTP10: Enter and leave presence, await leave event. + """ + channel_name = self.get_channel_name('enter_and_leave') + + channel1 = self.client1.channels.get(channel_name) + channel2 = self.client2.channels.get(channel_name) + + await channel1.attach() + + # Track events + events = [] + + def on_presence(msg): + events.append((msg.action, msg.client_id)) + + await channel1.presence.subscribe(on_presence) + + # Client 2 enters + await channel2.presence.enter('enter data') + + # Wait for enter event + await asyncio.sleep(0.5) + assert (PresenceAction.ENTER, 'client2') in events + + # Client 2 leaves + await channel2.presence.leave() + + # Wait for leave event + await asyncio.sleep(0.5) + assert (PresenceAction.LEAVE, 'client2') in events + + async def test_presence_enter_update(self): + """ + Test RTP9: Update presence data. + """ + channel_name = self.get_channel_name('enter_update') + + channel1 = self.client1.channels.get(channel_name) + channel2 = self.client2.channels.get(channel_name) + + await channel1.attach() + + # Track update events + updates = [] + + def on_update(msg): + if msg.action == PresenceAction.UPDATE: + updates.append(msg.data) + + await channel1.presence.subscribe('update', on_update) + + # Client 2 enters then updates + await channel2.presence.enter('original data') + await asyncio.sleep(0.3) + + await channel2.presence.update('updated data') + + # Wait for update event + await asyncio.sleep(0.5) + assert 'updated data' in updates + + async def test_presence_anonymous_client_error(self): + """ + Test RTP8j: Anonymous clients cannot enter presence. + """ + # Create client without clientId + client = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + await client.connection.once_async('connected') + + channel = client.channels.get(self.get_channel_name('anonymous')) + + try: + await channel.presence.enter('data') + pytest.fail('Should have raised exception for anonymous client') + except Exception as e: + assert 'clientId must be specified' in str(e) + finally: + await client.close() + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceGet(BaseAsyncTestCase): + """Test presence.get() functionality.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + + self.client1 = await TestApp.get_ably_realtime( + client_id='client1', + use_binary_protocol=use_binary_protocol + ) + self.client2 = await TestApp.get_ably_realtime( + client_id='client2', + use_binary_protocol=use_binary_protocol + ) + + yield + + await self.client1.close() + await self.client2.close() + + async def test_presence_enter_get(self): + """ + Test RTP11a: Enter presence and get members. + """ + channel_name = self.get_channel_name('enter_get') + + channel1 = self.client1.channels.get(channel_name) + channel2 = self.client2.channels.get(channel_name) + + # Client 1 enters + await channel1.presence.enter('test data') + + # Wait for presence to sync + await asyncio.sleep(0.5) + + # Client 2 gets presence + members = await channel2.presence.get() + + assert len(members) == 1 + assert members[0].client_id == 'client1' + assert members[0].data == 'test data' + assert members[0].action == PresenceAction.PRESENT + + async def test_presence_get_unattached(self): + """ + Test RTP11b: Get presence on unattached channel (should attach and wait for sync). + """ + channel_name = self.get_channel_name('get_unattached') + + # Client 1 enters + channel1 = self.client1.channels.get(channel_name) + await channel1.presence.enter('test data') + + # Wait for presence + await asyncio.sleep(0.5) + + # Client 2 gets without attaching first + channel2 = self.client2.channels.get(channel_name) + assert channel2.state == ChannelState.INITIALIZED + + members = await channel2.presence.get() + + # Channel should now be attached + assert channel2.state == ChannelState.ATTACHED + assert len(members) == 1 + assert members[0].client_id == 'client1' + + async def test_presence_enter_leave_get(self): + """ + Test RTP11a + RTP10c: Enter, leave, then get (should be empty). + """ + channel_name = self.get_channel_name('enter_leave_get') + + channel1 = self.client1.channels.get(channel_name) + channel2 = self.client2.channels.get(channel_name) + + # Client 1 enters then leaves + await channel1.presence.enter('test data') + await asyncio.sleep(0.3) + await channel1.presence.leave() + + # Wait for leave to process + await asyncio.sleep(0.5) + + # Client 2 gets presence + members = await channel2.presence.get() + + assert len(members) == 0 + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceSubscribe(BaseAsyncTestCase): + """Test presence.subscribe() functionality.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + + self.client1 = await TestApp.get_ably_realtime( + client_id='client1', + use_binary_protocol=use_binary_protocol + ) + self.client2 = await TestApp.get_ably_realtime( + client_id='client2', + use_binary_protocol=use_binary_protocol + ) + + yield + + await self.client1.close() + await self.client2.close() + + async def test_presence_subscribe_unattached(self): + """ + Test RTP6d: Subscribe on unattached channel should implicitly attach. + """ + channel_name = self.get_channel_name('subscribe_unattached') + + channel1 = self.client1.channels.get(channel_name) + + received = asyncio.Future() + + def on_presence(msg): + if msg.client_id == 'client2': + received.set_result(msg) + + # Subscribe without attaching first + assert channel1.state == ChannelState.INITIALIZED + await channel1.presence.subscribe(on_presence) + + # Should implicitly attach + await asyncio.sleep(0.5) + assert channel1.state == ChannelState.ATTACHED + + # Client 2 enters + channel2 = self.client2.channels.get(channel_name) + await channel2.presence.enter('data') + + # Should receive event + msg = await asyncio.wait_for(received, timeout=5.0) + assert msg.client_id == 'client2' + + async def test_presence_message_action(self): + """ + Test RTP8c: PresenceMessage should have correct action string. + """ + channel_name = self.get_channel_name('message_action') + + channel1 = self.client1.channels.get(channel_name) + + received = asyncio.Future() + + def on_presence(msg): + if msg.action == PresenceAction.ENTER: + received.set_result(msg) + + await channel1.presence.subscribe(on_presence) + await channel1.presence.enter() + + msg = await asyncio.wait_for(received, timeout=5.0) + assert msg.action == PresenceAction.ENTER + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceEnterClient(BaseAsyncTestCase): + """Test enterClient/updateClient/leaveClient functionality.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + + # Use wildcard auth for enterClient + self.client = await TestApp.get_ably_realtime( + client_id='*', + use_binary_protocol=use_binary_protocol + ) + + yield + + await self.client.close() + + async def test_enter_client_multiple(self): + """ + Test RTP14/RTP15: Enter multiple clients on one connection. + """ + channel_name = self.get_channel_name('enter_client_multiple') + channel = self.client.channels.get(channel_name) + + # Enter multiple clients + for i in range(5): + await channel.presence.enter_client(f'test_client_{i}', f'data_{i}') + + # Wait for presence to sync + await asyncio.sleep(0.5) + + # Get all members + members = await channel.presence.get() + + assert len(members) == 5 + client_ids = {m.client_id for m in members} + assert all(f'test_client_{i}' in client_ids for i in range(5)) + + async def test_update_client(self): + """ + Test RTP15: Update client presence data. + """ + channel_name = self.get_channel_name('update_client') + channel = self.client.channels.get(channel_name) + + # Enter client + await channel.presence.enter_client('test_client', 'original data') + await asyncio.sleep(0.3) + + # Update client + await channel.presence.update_client('test_client', 'updated data') + await asyncio.sleep(0.3) + + # Get member + members = await channel.presence.get(client_id='test_client') + + assert len(members) == 1 + assert members[0].data == 'updated data' + + async def test_leave_client(self): + """ + Test RTP15: Leave client presence. + """ + channel_name = self.get_channel_name('leave_client') + channel = self.client.channels.get(channel_name) + + # Enter multiple clients + await channel.presence.enter_client('client1', 'data1') + await channel.presence.enter_client('client2', 'data2') + await asyncio.sleep(0.3) + + # Leave one client + await channel.presence.leave_client('client1') + await asyncio.sleep(0.5) + + # Only client2 should remain + members = await channel.presence.get() + + assert len(members) == 1 + assert members[0].client_id == 'client2' + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceConnectionLifecycle(BaseAsyncTestCase): + """Test presence behavior during connection lifecycle events.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + yield + + async def test_presence_enter_without_connect(self): + """ + Test entering presence before connection is established. + Related to RTP8d. + """ + channel_name = self.get_channel_name('enter_without_connect') + + # Create listener client + listener_client = await TestApp.get_ably_realtime( + client_id='listener', + use_binary_protocol=self.use_binary_protocol + ) + listener_channel = listener_client.channels.get(channel_name) + + received = asyncio.Future() + + def on_presence(msg): + if msg.client_id == 'enterer' and msg.action == PresenceAction.ENTER: + received.set_result(msg) + + await listener_channel.presence.subscribe(on_presence) + + # Create client and enter before it's connected + enterer_client = await TestApp.get_ably_realtime( + client_id='enterer', + use_binary_protocol=self.use_binary_protocol + ) + enterer_channel = enterer_client.channels.get(channel_name) + + # Enter without waiting for connection + await enterer_channel.presence.enter('test data') + + # Should receive presence event + msg = await asyncio.wait_for(received, timeout=5.0) + assert msg.client_id == 'enterer' + assert msg.data == 'test data' + + await listener_client.close() + await enterer_client.close() + + async def test_presence_enter_after_close(self): + """ + Test re-entering presence after connection close and reconnect. + Related to RTP8d. + """ + channel_name = self.get_channel_name('enter_after_close') + + # Create listener + listener_client = await TestApp.get_ably_realtime( + client_id='listener', + use_binary_protocol=self.use_binary_protocol + ) + listener_channel = listener_client.channels.get(channel_name) + + second_enter_received = asyncio.Future() + + def on_presence(msg): + if msg.client_id == 'enterer' and msg.data == 'second' and msg.action == PresenceAction.ENTER: + second_enter_received.set_result(msg) + + await listener_channel.presence.subscribe(on_presence) + + # Create enterer client + enterer_client = await TestApp.get_ably_realtime( + client_id='enterer', + use_binary_protocol=self.use_binary_protocol + ) + enterer_channel = enterer_client.channels.get(channel_name) + + await enterer_client.connection.once_async('connected') + + # First enter + await enterer_channel.presence.enter('first') + await asyncio.sleep(0.3) + + # Close and wait + await enterer_client.close() + + # Reconnect + enterer_client.connection.connect() + await enterer_client.connection.once_async('connected') + + # Second enter - should automatically reattach + await enterer_channel.presence.enter('second') + + # Should receive second enter event + msg = await asyncio.wait_for(second_enter_received, timeout=5.0) + assert msg.data == 'second' + + await listener_client.close() + await enterer_client.close() + + async def test_presence_enter_closed_error(self): + """ + Test RTP15e: Entering presence on closed connection should error. + """ + channel_name = self.get_channel_name('enter_closed') + + client = await TestApp.get_ably_realtime(use_binary_protocol=self.use_binary_protocol) + channel = client.channels.get(channel_name) + + await client.connection.once_async('connected') + + # Close the connection + await client.close() + + # Try to enter - should fail + try: + await channel.presence.enter_client('client1', 'data') + pytest.fail('Should have raised exception for closed connection') + except Exception as e: + # Should get an error about closed/failed connection + assert 'closed' in str(e).lower() or 'failed' in str(e).lower() or '80017' in str(e) + + await client.close() + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceAutoReentry(BaseAsyncTestCase): + """Test automatic re-entry of presence after connection suspension.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + yield + + async def test_presence_auto_reenter_after_suspend(self): + """ + Test RTP5f, RTP17, RTP17g, RTP17i: Members automatically re-enter after suspension. + + This test verifies that when a connection is suspended and then reconnected, + presence members that were entered automatically re-enter. + """ + channel_name = self.get_channel_name('auto_reenter') + + client = await TestApp.get_ably_realtime( + client_id='test_client', + use_binary_protocol=self.use_binary_protocol + ) + channel = client.channels.get(channel_name) + + await channel.attach() + + # Enter presence + await channel.presence.enter('original_data') + await asyncio.sleep(0.5) + + # Verify member is present + members = await channel.presence.get() + assert len(members) == 1 + assert members[0].client_id == 'test_client' + assert members[0].data == 'original_data' + + # Suspend the connection + await force_suspended(client) + + # Reconnect - connection will be resumed with same connection ID + client.connection.connect() + await client.connection.once_async('connected') + + # Wait for channel to reattach after suspension + await channel.once_async('attached') + + # Give time for auto-reenter to complete + # Auto-reenter sends a presence message, server ACKs it, but doesn't + # broadcast a new ENTER event because on a resumed connection with + # unchanged data, no state change occurred from the server's perspective + await asyncio.sleep(0.5) + + # Verify member is still in presence set (auto-reenter worked) + # This is the actual requirement of RTP17i - members are automatically + # re-entered after suspension, ensuring they remain in the presence set + members = await channel.presence.get() + assert len(members) >= 1 + assert any(m.client_id == 'test_client' and m.data == 'original_data' for m in members) + + await client.close() + + async def test_presence_auto_reenter_different_connid(self): + """ + Test RTP17g, RTP17g1: Auto re-entry with different connectionId. + + When connection is suspended and reconnects with a different connectionId, + verify that: + 1. A LEAVE is sent for the old connectionId + 2. An ENTER is sent for the new connectionId + 3. The new ENTER does not have the same message ID as the original + """ + channel_name = self.get_channel_name('auto_reenter_different_connid') + + # Create observer client + observer_client = await TestApp.get_ably_realtime( + client_id='observer', + use_binary_protocol=self.use_binary_protocol + ) + observer_channel = observer_client.channels.get(channel_name) + await observer_channel.attach() + + # Track presence events + events = [] + + def on_presence(msg): + events.append({ + 'action': msg.action, + 'client_id': msg.client_id, + 'connection_id': msg.connection_id, + 'id': getattr(msg, 'id', None) + }) + + await observer_channel.presence.subscribe(on_presence) + + # Create main client with remainPresentFor to control LEAVE timing + # This tells the server to send LEAVE for presence members 5 seconds after disconnect + client = await TestApp.get_ably_realtime( + client_id='test_client', + transport_params={'remainPresentFor': 1000}, + use_binary_protocol=self.use_binary_protocol + ) + channel = client.channels.get(channel_name) + + await client.connection.once_async('connected') + first_conn_id = client.connection.connection_manager.connection_id + + # Enter presence + await channel.presence.enter('test_data') + await asyncio.sleep(0.5) + + # Get the original message ID + original_msg_id = None + for event in events: + if event['action'] == PresenceAction.ENTER and event['client_id'] == 'test_client': + original_msg_id = event['id'] + break + + # Force suspension and reconnection with different connection ID + await force_suspended(client) + + # Reconnect + client.connection.connect() + await client.connection.once_async('connected') + second_conn_id = client.connection.connection_manager.connection_id + + # Connection IDs should be different after suspend + assert first_conn_id != second_conn_id + + # Wait for presence events including LEAVE (which arrives after remainPresentFor timeout) + await asyncio.sleep(2) + + # Should see LEAVE for old connection and ENTER for new connection + leave_events = [e for e in events if e['action'] == PresenceAction.LEAVE + and e['client_id'] == 'test_client'] + enter_events = [e for e in events if e['action'] == PresenceAction.ENTER + and e['client_id'] == 'test_client'] + + assert len(leave_events) >= 1, "Should have LEAVE event for old connection" + assert len(enter_events) >= 2, "Should have ENTER event for new connection" + + # Find the leave for first connection + leave_for_first = [e for e in leave_events if e['connection_id'] == first_conn_id] + assert len(leave_for_first) >= 1, "Should have LEAVE for first connection ID" + + # Find the enter for second connection + enter_for_second = [e for e in enter_events if e['connection_id'] == second_conn_id] + assert len(enter_for_second) >= 1, "Should have ENTER for second connection ID" + + # The new ENTER should have a different message ID + new_msg_id = enter_for_second[0]['id'] + if original_msg_id and new_msg_id: + assert original_msg_id != new_msg_id, "New ENTER should have different message ID" + + await observer_client.close() + await client.close() + + +@pytest.mark.parametrize('use_binary_protocol', [True, False], ids=['msgpack', 'json']) +class TestRealtimePresenceSyncBehavior(BaseAsyncTestCase): + """Test presence SYNC behavior and state management.""" + + @pytest.fixture(autouse=True) + async def setup(self, use_binary_protocol): + """Set up test fixtures.""" + self.test_vars = await TestApp.get_test_vars() + self.use_binary_protocol = use_binary_protocol + yield + + async def test_presence_refresh_on_detach(self): + """ + Test RTP15b: Presence map refresh when channel detaches and reattaches. + + When a channel detaches and then reattaches, and the presence set has + changed during that time, verify that the presence map is correctly + refreshed with the new state. + """ + channel_name = self.get_channel_name('refresh_on_detach') + + # Client that manages presence + manager_client = await TestApp.get_ably_realtime( + client_id='*', + use_binary_protocol=self.use_binary_protocol + ) + manager_channel = manager_client.channels.get(channel_name) + + # Observer client that will detach/reattach + observer_client = await TestApp.get_ably_realtime( + client_id='observer', + use_binary_protocol=self.use_binary_protocol + ) + observer_channel = observer_client.channels.get(channel_name) + + # Enter two members + await manager_channel.presence.enter_client('client_one', 'data_one') + await manager_channel.presence.enter_client('client_two', 'data_two') + await asyncio.sleep(0.3) + + # Observer attaches and verifies + await observer_channel.attach() + members = await observer_channel.presence.get() + assert len(members) == 2 + client_ids = {m.client_id for m in members} + assert 'client_one' in client_ids + assert 'client_two' in client_ids + + # Observer detaches + await observer_channel.detach() + + # Change presence while observer is detached + await manager_channel.presence.leave_client('client_two') + await manager_channel.presence.enter_client('client_three', 'data_three') + await asyncio.sleep(0.3) + + # Track presence events on observer + presence_events = [] + + def on_presence(msg): + presence_events.append(msg.client_id) + + await observer_channel.presence.subscribe(on_presence) + + # Reattach and wait for sync + await observer_channel.attach() + await asyncio.sleep(1.0) + + # Should receive PRESENT events for current members + members = await observer_channel.presence.get() + assert len(members) == 2 + client_ids = {m.client_id for m in members} + assert 'client_one' in client_ids + assert 'client_three' in client_ids + assert 'client_two' not in client_ids + + await manager_client.close() + await observer_client.close() + + async def test_suspended_preserves_presence(self): + """ + Test RTP5f, RTP11d: Presence map is preserved during SUSPENDED state. + + Verify that: + 1. Presence map is preserved when connection goes to SUSPENDED + 2. get() with waitForSync=False works while suspended + 3. get() without waitForSync returns error while suspended + 4. Only changed members trigger events after reconnection + """ + channel_name = self.get_channel_name('suspended_preserves') + + # Create multiple clients + main_client = await TestApp.get_ably_realtime( + client_id='main', + use_binary_protocol=self.use_binary_protocol + ) + continuous_client = await TestApp.get_ably_realtime( + client_id='continuous', + use_binary_protocol=self.use_binary_protocol + ) + leaves_client = await TestApp.get_ably_realtime( + client_id='leaves', + use_binary_protocol=self.use_binary_protocol + ) + + main_channel = main_client.channels.get(channel_name) + continuous_channel = continuous_client.channels.get(channel_name) + leaves_channel = leaves_client.channels.get(channel_name) + + # All enter presence + await main_channel.presence.enter('main_data') + await continuous_channel.presence.enter('continuous_data') + await leaves_channel.presence.enter('leaves_data') + await asyncio.sleep(0.5) + + # Verify all present + members = await main_channel.presence.get() + assert len(members) == 3 + client_ids = {m.client_id for m in members} + assert client_ids == {'main', 'continuous', 'leaves'} + + # Simulate suspension on main client + await force_suspended(main_client) + + # leaves_client leaves while main is suspended + await leaves_client.close() + await asyncio.sleep(0.3) + + # Track presence events on main after reconnect + presence_events = [] + + def on_presence(msg): + presence_events.append({ + 'action': msg.action, + 'client_id': msg.client_id + }) + + await main_channel.presence.subscribe(on_presence) + + # Reconnect main client + main_client.connection.connect() + await main_client.connection.once_async('connected') + await main_channel.once_async('attached') + + # Wait for presence sync + await asyncio.sleep(1.0) + + # Should only see LEAVE for leaves_client + leave_events = [e for e in presence_events + if e['action'] == PresenceAction.LEAVE and e['client_id'] == 'leaves'] + assert len(leave_events) >= 1, "Should see LEAVE for leaves client" + + # Final state should have main and continuous + members = await main_channel.presence.get() + assert len(members) >= 2 + client_ids = {m.client_id for m in members} + assert 'main' in client_ids + assert 'continuous' in client_ids + + await main_client.close() + await continuous_client.close() diff --git a/test/ably/realtime/realtimeresume_test.py b/test/ably/realtime/realtimeresume_test.py new file mode 100644 index 00000000..bfe77efa --- /dev/null +++ b/test/ably/realtime/realtimeresume_test.py @@ -0,0 +1,207 @@ +import asyncio + +import pytest + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, random_string + + +async def send_and_await(rest_channel, realtime_channel): + event = random_string(5) + message = random_string(5) + future = asyncio.Future() + + def on_message(_): + future.set_result(None) + + await realtime_channel.subscribe(event, on_message) + await rest_channel.publish(event, message) + + await future + + +class TestRealtimeResume(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.valid_key_format = "api:key" + + # RTN15c6 - valid resume response + async def test_connection_resume(self): + ably = await TestApp.get_ably_realtime() + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + prev_connection_id = ably.connection.connection_manager.connection_id + connection_key = ably.connection.connection_details.connection_key + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + new_connection_id = ably.connection.connection_manager.connection_id + assert ably.connection.connection_manager.transport.params["resume"] == connection_key + assert prev_connection_id == new_connection_id + + await ably.close() + + # RTN15c4 - fatal resume error + async def test_fatal_resume_error(self): + ably = await TestApp.get_ably_realtime() + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + ably.auth.auth_options.key_name = "wrong-key" + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + state_change = await ably.connection.once_async(ConnectionState.FAILED) + assert state_change.reason.code == 40101 + assert state_change.reason.status_code == 401 + await ably.close() + + # RTN15c7 - invalid resume response + async def test_invalid_resume_response(self): + ably = await TestApp.get_ably_realtime() + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert ably.connection.connection_manager.connection_details + ably.connection.connection_manager.connection_details.connection_key = 'ably-python-fake-key' + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + state_change = await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert state_change.reason.code == 80018 + assert state_change.reason.status_code == 400 + assert ably.connection.error_reason == state_change.reason + + await ably.close() + + async def test_attached_channel_reattaches_on_invalid_resume(self): + ably = await TestApp.get_ably_realtime() + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get(random_string(5)) + + await channel.attach() + + assert ably.connection.connection_manager.connection_details + ably.connection.connection_manager.connection_details.connection_key = 'ably-python-fake-key' + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert channel.state == ChannelState.ATTACHING + + await channel.once_async(ChannelState.ATTACHED) + + await ably.close() + + async def test_suspended_channel_reattaches_on_invalid_resume(self): + ably = await TestApp.get_ably_realtime() + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + channel = ably.channels.get(random_string(5)) + channel.state = ChannelState.SUSPENDED + + assert ably.connection.connection_manager.connection_details + ably.connection.connection_manager.connection_details.connection_key = 'ably-python-fake-key' + + assert ably.connection.connection_manager.transport + await ably.connection.connection_manager.transport.dispose() + ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED) + + await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) + + assert channel.state == ChannelState.ATTACHING + + await channel.once_async(ChannelState.ATTACHED) + + await ably.close() + + async def test_resume_receives_channel_messages_while_disconnected(self): + realtime = await TestApp.get_ably_realtime() + rest = await TestApp.get_ably_rest() + + channel_name = random_string(5) + + realtime_channel = realtime.channels.get(channel_name) + rest_channel = rest.channels.get(channel_name) + + await realtime.connection.once_async(ConnectionState.CONNECTED) + + asyncio.create_task(realtime_channel.attach()) + state_change = await realtime_channel.once_async(ChannelState.ATTACHED) + assert state_change.resumed is False + + await send_and_await(rest_channel, realtime_channel) + + assert realtime.connection.connection_manager.transport + await realtime.connection.connection_manager.transport.dispose() + realtime.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED, retry_immediately=False) + + event_name = random_string(5) + message = random_string(5) + await rest_channel.publish(event_name, message) + + future = asyncio.Future() + + def on_message(message): + future.set_result(message) + + await realtime_channel.subscribe(event_name, on_message) + + realtime.connect() + await realtime.connection.once_async(ConnectionState.CONNECTED) + + state_change = await realtime_channel.once_async(ChannelState.ATTACHED) + + assert state_change.resumed is True + + received_message = await future + + assert received_message.data == message + + await realtime.close() + await rest.close() + + async def test_resume_update_channel_attached(self): + realtime = await TestApp.get_ably_realtime() + + name = random_string(5) + channel = realtime.channels.get(name) + await channel.attach() + error_code = 123 + error_status_code = 456 + error_message = "some error" + message = { + "action": ProtocolMessageAction.ATTACHED, + "channel": name, + "error": { + "code": error_code, + "statusCode": error_status_code, + "message": error_message + } + } + future = asyncio.Future() + + def on_update(state_change): + future.set_result(state_change) + + channel.once("update", on_update) + await realtime.connection.connection_manager.transport.on_protocol_message(message) + + state_change = await future + assert state_change.reason.code == error_code + assert state_change.reason.status_code == error_status_code + assert state_change.reason.message == error_message + await realtime.close() diff --git a/test/ably/rest/encoders_test.py b/test/ably/rest/encoders_test.py new file mode 100644 index 00000000..f8023c5d --- /dev/null +++ b/test/ably/rest/encoders_test.py @@ -0,0 +1,455 @@ +import base64 +import json +import logging +import sys +from unittest import mock + +import msgpack +import pytest + +from ably import CipherParams +from ably.types.message import Message +from ably.util.crypto import get_cipher +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + +if sys.version_info >= (3, 8): + from unittest.mock import AsyncMock +else: + from mock import AsyncMock + +log = logging.getLogger(__name__) + + +class TestTextEncodersNoEncryption(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest(use_binary_protocol=False) + yield + await self.ably.close() + + async def test_text_utf8(self): + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', 'foó') + _, kwargs = post_mock.call_args + assert json.loads(kwargs['body'])['data'] == 'foó' + assert not json.loads(kwargs['body']).get('encoding', '') + + async def test_str(self): + # This test only makes sense for py2 + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', 'foo') + _, kwargs = post_mock.call_args + assert json.loads(kwargs['body'])['data'] == 'foo' + assert not json.loads(kwargs['body']).get('encoding', '') + + async def test_with_binary_type(self): + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', bytearray(b'foo')) + _, kwargs = post_mock.call_args + raw_data = json.loads(kwargs['body'])['data'] + assert base64.b64decode(raw_data.encode('ascii')) == bytearray(b'foo') + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'base64' + + async def test_with_bytes_type(self): + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', b'foo') + _, kwargs = post_mock.call_args + raw_data = json.loads(kwargs['body'])['data'] + assert base64.b64decode(raw_data.encode('ascii')) == bytearray(b'foo') + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'base64' + + async def test_with_json_dict_data(self): + channel = self.ably.channels["persisted:publish"] + data = {'foó': 'bár'} + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + raw_data = json.loads(json.loads(kwargs['body'])['data']) + assert raw_data == data + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'json' + + async def test_with_json_list_data(self): + channel = self.ably.channels["persisted:publish"] + data = ['foó', 'bár'] + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + raw_data = json.loads(json.loads(kwargs['body'])['data']) + assert raw_data == data + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'json' + + async def test_text_utf8_decode(self): + channel = self.ably.channels["persisted:stringdecode"] + + await channel.publish('event', 'fóo') + history = await channel.history() + message = history.items[0] + assert message.data == 'fóo' + assert isinstance(message.data, str) + assert not message.encoding + + async def test_text_str_decode(self): + channel = self.ably.channels["persisted:stringnonutf8decode"] + + await channel.publish('event', 'foo') + history = await channel.history() + message = history.items[0] + assert message.data == 'foo' + assert isinstance(message.data, str) + assert not message.encoding + + async def test_with_binary_type_decode(self): + channel = self.ably.channels["persisted:binarydecode"] + + await channel.publish('event', bytearray(b'foob')) + history = await channel.history() + message = history.items[0] + assert message.data == bytearray(b'foob') + assert isinstance(message.data, bytearray) + assert not message.encoding + + async def test_with_json_dict_data_decode(self): + channel = self.ably.channels["persisted:jsondict"] + data = {'foó': 'bár'} + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + async def test_with_json_list_data_decode(self): + channel = self.ably.channels["persisted:jsonarray"] + data = ['foó', 'bár'] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + def test_decode_with_invalid_encoding(self): + data = 'foó' + encoded = base64.b64encode(data.encode('utf-8')) + decoded_data = Message.decode(encoded, 'foo/bar/utf-8/base64') + assert decoded_data['data'] == data + assert decoded_data['encoding'] == 'foo/bar' + + +class TestTextEncodersEncryption(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest(use_binary_protocol=False) + self.cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes') + yield + await self.ably.close() + + def decrypt(self, payload, options=None): + if options is None: + options = {} + ciphertext = base64.b64decode(payload.encode('ascii')) + cipher = get_cipher({'key': b'keyfordecrypt_16'}) + return cipher.decrypt(ciphertext) + + async def test_text_utf8(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', 'fóo') + _, kwargs = post_mock.call_args + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'utf-8/cipher+aes-128-cbc/base64' + data = self.decrypt(json.loads(kwargs['body'])['data']).decode('utf-8') + assert data == 'fóo' + + async def test_str(self): + # This test only makes sense for py2 + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', 'foo') + _, kwargs = post_mock.call_args + assert json.loads(kwargs['body'])['data'] == 'foo' + assert not json.loads(kwargs['body']).get('encoding', '') + + async def test_with_binary_type(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', bytearray(b'foo')) + _, kwargs = post_mock.call_args + + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'cipher+aes-128-cbc/base64' + data = self.decrypt(json.loads(kwargs['body'])['data']) + assert data == bytearray(b'foo') + assert isinstance(data, bytearray) + + async def test_with_json_dict_data(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + data = {'foó': 'bár'} + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'json/utf-8/cipher+aes-128-cbc/base64' + raw_data = self.decrypt(json.loads(kwargs['body'])['data']).decode('ascii') + assert json.loads(raw_data) == data + + async def test_with_json_list_data(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + data = ['foó', 'bár'] + with mock.patch('ably.rest.rest.Http.post', new_callable=AsyncMock) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + assert json.loads(kwargs['body'])['encoding'].strip('/') == 'json/utf-8/cipher+aes-128-cbc/base64' + raw_data = self.decrypt(json.loads(kwargs['body'])['data']).decode('ascii') + assert json.loads(raw_data) == data + + async def test_text_utf8_decode(self): + channel = self.ably.channels.get("persisted:enc_stringdecode", + cipher=self.cipher_params) + await channel.publish('event', 'foó') + history = await channel.history() + message = history.items[0] + assert message.data == 'foó' + assert isinstance(message.data, str) + assert not message.encoding + + async def test_with_binary_type_decode(self): + channel = self.ably.channels.get("persisted:enc_binarydecode", + cipher=self.cipher_params) + + await channel.publish('event', bytearray(b'foob')) + history = await channel.history() + message = history.items[0] + assert message.data == bytearray(b'foob') + assert isinstance(message.data, bytearray) + assert not message.encoding + + async def test_with_json_dict_data_decode(self): + channel = self.ably.channels.get("persisted:enc_jsondict", + cipher=self.cipher_params) + data = {'foó': 'bár'} + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + async def test_with_json_list_data_decode(self): + channel = self.ably.channels.get("persisted:enc_list", + cipher=self.cipher_params) + data = ['foó', 'bár'] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + +class TestBinaryEncodersNoEncryption(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + yield + await self.ably.close() + + def decode(self, data): + return msgpack.unpackb(data) + + async def test_text_utf8(self): + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', 'foó') + _, kwargs = post_mock.call_args + assert self.decode(kwargs['body'])['data'] == 'foó' + assert self.decode(kwargs['body']).get('encoding', '').strip('/') == '' + + async def test_with_binary_type(self): + channel = self.ably.channels["persisted:publish"] + + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', bytearray(b'foo')) + _, kwargs = post_mock.call_args + assert self.decode(kwargs['body'])['data'] == bytearray(b'foo') + assert self.decode(kwargs['body']).get('encoding', '').strip('/') == '' + + async def test_with_json_dict_data(self): + channel = self.ably.channels["persisted:publish"] + data = {'foó': 'bár'} + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + raw_data = json.loads(self.decode(kwargs['body'])['data']) + assert raw_data == data + assert self.decode(kwargs['body'])['encoding'].strip('/') == 'json' + + async def test_with_json_list_data(self): + channel = self.ably.channels["persisted:publish"] + data = ['foó', 'bár'] + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + raw_data = json.loads(self.decode(kwargs['body'])['data']) + assert raw_data == data + assert self.decode(kwargs['body'])['encoding'].strip('/') == 'json' + + async def test_text_utf8_decode(self): + channel = self.ably.channels["persisted:stringdecode-bin"] + + await channel.publish('event', 'fóo') + history = await channel.history() + message = history.items[0] + assert message.data == 'fóo' + assert isinstance(message.data, str) + assert not message.encoding + + async def test_with_binary_type_decode(self): + channel = self.ably.channels["persisted:binarydecode-bin"] + + await channel.publish('event', bytearray(b'foob')) + history = await channel.history() + message = history.items[0] + assert message.data == bytearray(b'foob') + assert not message.encoding + + async def test_with_json_dict_data_decode(self): + channel = self.ably.channels["persisted:jsondict-bin"] + data = {'foó': 'bár'} + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + async def test_with_json_list_data_decode(self): + channel = self.ably.channels["persisted:jsonarray-bin"] + data = ['foó', 'bár'] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + +class TestBinaryEncodersEncryption(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + self.cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes') + yield + await self.ably.close() + + def decrypt(self, payload, options=None): + if options is None: + options = {} + cipher = get_cipher({'key': b'keyfordecrypt_16'}) + return cipher.decrypt(payload) + + def decode(self, data): + return msgpack.unpackb(data) + + async def test_text_utf8(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', 'fóo') + _, kwargs = post_mock.call_args + assert self.decode(kwargs['body'])['encoding'].strip('/') == 'utf-8/cipher+aes-128-cbc' + data = self.decrypt(self.decode(kwargs['body'])['data']).decode('utf-8') + assert data == 'fóo' + + async def test_with_binary_type(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', bytearray(b'foo')) + _, kwargs = post_mock.call_args + + assert self.decode(kwargs['body'])['encoding'].strip('/') == 'cipher+aes-128-cbc' + data = self.decrypt(self.decode(kwargs['body'])['data']) + assert data == bytearray(b'foo') + assert isinstance(data, bytearray) + + async def test_with_json_dict_data(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + data = {'foó': 'bár'} + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + assert self.decode(kwargs['body'])['encoding'].strip('/') == 'json/utf-8/cipher+aes-128-cbc' + raw_data = self.decrypt(self.decode(kwargs['body'])['data']).decode('ascii') + assert json.loads(raw_data) == data + + async def test_with_json_list_data(self): + channel = self.ably.channels.get("persisted:publish_enc", + cipher=self.cipher_params) + data = ['foó', 'bár'] + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish('event', data) + _, kwargs = post_mock.call_args + assert self.decode(kwargs['body'])['encoding'].strip('/') == 'json/utf-8/cipher+aes-128-cbc' + raw_data = self.decrypt(self.decode(kwargs['body'])['data']).decode('ascii') + assert json.loads(raw_data) == data + + async def test_text_utf8_decode(self): + channel = self.ably.channels.get("persisted:enc_stringdecode-bin", + cipher=self.cipher_params) + await channel.publish('event', 'foó') + history = await channel.history() + message = history.items[0] + assert message.data == 'foó' + assert isinstance(message.data, str) + assert not message.encoding + + async def test_with_binary_type_decode(self): + channel = self.ably.channels.get("persisted:enc_binarydecode-bin", + cipher=self.cipher_params) + + await channel.publish('event', bytearray(b'foob')) + history = await channel.history() + message = history.items[0] + assert message.data == bytearray(b'foob') + assert isinstance(message.data, bytearray) + assert not message.encoding + + async def test_with_json_dict_data_decode(self): + channel = self.ably.channels.get("persisted:enc_jsondict-bin", + cipher=self.cipher_params) + data = {'foó': 'bár'} + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding + + async def test_with_json_list_data_decode(self): + channel = self.ably.channels.get("persisted:enc_list-bin", + cipher=self.cipher_params) + data = ['foó', 'bár'] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] + assert message.data == data + assert not message.encoding diff --git a/test/ably/rest/restannotations_test.py b/test/ably/rest/restannotations_test.py new file mode 100644 index 00000000..fcf2c696 --- /dev/null +++ b/test/ably/rest/restannotations_test.py @@ -0,0 +1,203 @@ +import logging +import random +import string + +import pytest + +from ably import AblyException +from ably.types.annotation import Annotation, AnnotationAction +from ably.types.message import Message +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, assert_waiter + +log = logging.getLogger(__name__) + + +@pytest.mark.parametrize("transport", ["json", "msgpack"], ids=["JSON", "MsgPack"]) +class TestRestAnnotations(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self, transport): + self.test_vars = await TestApp.get_test_vars() + client_id = ''.join(random.choices(string.ascii_letters + string.digits, k=10)) + self.ably = await TestApp.get_ably_rest( + use_binary_protocol=True if transport == 'msgpack' else False, + client_id=client_id, + ) + + async def test_publish_annotation_success(self): + """Test successfully publishing an annotation on a message""" + channel = self.ably.channels[self.get_channel_name('mutable:annotation_publish_test')] + + # First publish a message + result = await channel.publish('test-event', 'test data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Publish an annotation + await channel.annotations.publish(serial, Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + annotations_result = None + + # Wait for annotations to appear + async def check_annotations(): + nonlocal annotations_result + annotations_result = await channel.annotations.get(serial) + return len(annotations_result.items) == 1 + + await assert_waiter(check_annotations, timeout=10) + + # Get annotations to verify + annotations = annotations_result.items + assert len(annotations) >= 1 + assert annotations[0].message_serial == serial + assert annotations[0].type == 'reaction:distinct.v1' + assert annotations[0].name == '👍' + + async def test_publish_annotation_with_message_object(self): + """Test publishing an annotation using a Message object""" + channel = self.ably.channels[self.get_channel_name('mutable:annotation_publish_msg_obj')] + + # Publish a message + result = await channel.publish('test-event', 'test data') + serial = result.serials[0] + + # Create a message object + message = Message(serial=serial) + + # Publish annotation with message object + await channel.annotations.publish(message, Annotation( + type='reaction:distinct.v1', + name='😕' + )) + + annotations_result = None + + # Wait for annotations to appear + async def check_annotations(): + nonlocal annotations_result + annotations_result = await channel.annotations.get(serial) + return len(annotations_result.items) == 1 + + await assert_waiter(check_annotations, timeout=10) + + # Verify + annotations_result = await channel.annotations.get(serial) + annotations = annotations_result.items + assert len(annotations) >= 1 + assert annotations[0].name == '😕' + + async def test_publish_annotation_without_serial_fails(self): + """Test that publishing without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:annotation_no_serial')] + + with pytest.raises(AblyException) as exc_info: + await channel.annotations.publish(None, Annotation(type='reaction', name='👍')) + + assert exc_info.value.status_code == 400 + assert exc_info.value.code == 40003 + + async def test_delete_annotation_success(self): + """Test successfully deleting an annotation""" + channel = self.ably.channels[self.get_channel_name('mutable:annotation_delete_test')] + + # Publish a message + result = await channel.publish('test-event', 'test data') + serial = result.serials[0] + + # Publish an annotation + await channel.annotations.publish(serial, Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + annotations_result = None + + # Wait for annotation to appear + async def check_annotation(): + nonlocal annotations_result + annotations_result = await channel.annotations.get(serial) + return len(annotations_result.items) >= 1 + + await assert_waiter(check_annotation, timeout=10) + + # Delete the annotation + await channel.annotations.delete(serial, Annotation( + type='reaction:distinct.v1', + name='👍' + )) + + # Wait for annotation to appear + async def check_deleted_annotation(): + nonlocal annotations_result + annotations_result = await channel.annotations.get(serial) + return len(annotations_result.items) >= 2 + + await assert_waiter(check_deleted_annotation, timeout=10) + assert annotations_result.items[-1].type == 'reaction:distinct.v1' + assert annotations_result.items[-1].action == AnnotationAction.ANNOTATION_DELETE + + async def test_get_all_annotations(self): + """Test retrieving all annotations for a message""" + channel = self.ably.channels[self.get_channel_name('mutable:annotation_get_all_test')] + + # Publish a message + result = await channel.publish('test-event', 'test data') + serial = result.serials[0] + + # Publish annotations + await channel.annotations.publish(serial, Annotation(type='reaction:distinct.v1', name='👍')) + await channel.annotations.publish(serial, Annotation(type='reaction:distinct.v1', name='😕')) + await channel.annotations.publish(serial, Annotation(type='reaction:distinct.v1', name='👎')) + + # Wait and get all annotations + async def check_annotations(): + res = await channel.annotations.get(serial) + return len(res.items) >= 3 + + await assert_waiter(check_annotations, timeout=10) + + annotations_result = await channel.annotations.get(serial) + annotations = annotations_result.items + assert len(annotations) >= 3 + assert annotations[0].type == 'reaction:distinct.v1' + assert annotations[0].message_serial == serial + # Verify serials are in order + if len(annotations) > 1: + assert annotations[1].serial > annotations[0].serial + if len(annotations) > 2: + assert annotations[2].serial > annotations[1].serial + + async def test_annotation_properties(self): + """Test that annotation properties are correctly set""" + channel = self.ably.channels[self.get_channel_name('mutable:annotation_properties_test')] + + # Publish a message + result = await channel.publish('test-event', 'test data') + serial = result.serials[0] + + # Publish annotation with various properties + await channel.annotations.publish(serial, Annotation( + type='reaction:distinct.v1', + name='❤️', + data={'count': 5} + )) + + # Retrieve and verify + async def check_annotation(): + res = await channel.annotations.get(serial) + return len(res.items) > 0 + + await assert_waiter(check_annotation, timeout=10) + + annotations_result = await channel.annotations.get(serial) + annotation = annotations_result.items[0] + assert annotation.message_serial == serial + assert annotation.type == 'reaction:distinct.v1' + assert annotation.name == '❤️' + assert annotation.serial is not None + assert annotation.serial > serial diff --git a/test/ably/rest/restauth_test.py b/test/ably/rest/restauth_test.py new file mode 100644 index 00000000..185021e1 --- /dev/null +++ b/test/ably/rest/restauth_test.py @@ -0,0 +1,655 @@ +import base64 +import logging +import sys +import time +import uuid +from unittest import mock +from urllib.parse import parse_qs + +import pytest +import respx +from httpx import AsyncClient, Response + +import ably +from ably import AblyAuthException, AblyRest, Auth +from ably.types.tokendetails import TokenDetails +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + +if sys.version_info >= (3, 8): + from unittest.mock import AsyncMock +else: + from mock import AsyncMock + +log = logging.getLogger(__name__) + + +# does not make any request, no need to vary by protocol +class TestAuth(BaseAsyncTestCase): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + + def test_auth_init_key_only(self): + ably = AblyRest(key=self.test_vars["keys"][0]["key_str"]) + assert Auth.Method.BASIC == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + assert ably.auth.auth_options.key_name == self.test_vars["keys"][0]['key_name'] + assert ably.auth.auth_options.key_secret == self.test_vars["keys"][0]['key_secret'] + + def test_auth_init_token_only(self): + ably = AblyRest(token="this_is_not_really_a_token") + + assert Auth.Method.TOKEN == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + + def test_auth_token_details(self): + td = TokenDetails() + ably = AblyRest(token_details=td) + + assert Auth.Method.TOKEN == ably.auth.auth_mechanism + assert ably.auth.token_details is td + + async def test_auth_init_with_token_callback(self): + callback_called = [] + + def token_callback(token_params): + callback_called.append(True) + return "this_is_not_really_a_token_request" + + ably = await TestApp.get_ably_rest( + key=None, + key_name=self.test_vars["keys"][0]["key_name"], + auth_callback=token_callback) + + try: + await ably.stats(None) + except Exception: + pass + + assert callback_called, "Token callback not called" + assert Auth.Method.TOKEN == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + + def test_auth_init_with_key_and_client_id(self): + ably = AblyRest(key=self.test_vars["keys"][0]["key_str"], client_id='testClientId') + + assert Auth.Method.BASIC == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + assert ably.auth.client_id == 'testClientId' + + async def test_auth_init_with_token(self): + ably = await TestApp.get_ably_rest(key=None, token="this_is_not_really_a_token") + assert Auth.Method.TOKEN == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + + # RSA11 + async def test_request_basic_auth_header(self): + ably = AblyRest(key_secret='foo', key_name='bar') + + with mock.patch.object(AsyncClient, 'send') as get_mock: + get_mock.return_value = {"status": 200, "headers": {}} + try: + await ably.http.get('/time', skip_auth=False) + except Exception: + pass + request = get_mock.call_args_list[0][0][0] + authorization = request.headers['Authorization'] + assert authorization == 'Basic {}'.format(base64.b64encode('bar:foo'.encode('ascii')).decode('utf-8')) + + # RSA7e2 + async def test_request_basic_auth_header_with_client_id(self): + ably = AblyRest(key_secret='foo', key_name='bar', client_id='client_id') + + with mock.patch.object(AsyncClient, 'send') as get_mock: + get_mock.return_value = {"status": 200, "headers": {}} + try: + await ably.http.get('/time', skip_auth=False) + except Exception: + pass + request = get_mock.call_args_list[0][0][0] + client_id = request.headers['x-ably-clientid'] + assert client_id == base64.b64encode('client_id'.encode('ascii')).decode('utf-8') + + async def test_request_token_auth_header(self): + ably = AblyRest(token='not_a_real_token') + + with mock.patch.object(AsyncClient, 'send') as get_mock: + get_mock.return_value = {"status": 200, "headers": {}} + try: + await ably.http.get('/time', skip_auth=False) + except Exception: + pass + request = get_mock.call_args_list[0][0][0] + authorization = request.headers['Authorization'] + expected_token = base64.b64encode('not_a_real_token'.encode('ascii')).decode('utf-8') + assert authorization == f'Bearer {expected_token}' + + def test_if_cant_authenticate_via_token(self): + with pytest.raises(ValueError): + AblyRest(use_token_auth=True) + + def test_use_auth_token(self): + ably = AblyRest(use_token_auth=True, key=self.test_vars["keys"][0]["key_str"]) + assert ably.auth.auth_mechanism == Auth.Method.TOKEN + + def test_with_client_id(self): + ably = AblyRest(use_token_auth=True, client_id='client_id', key=self.test_vars["keys"][0]["key_str"]) + assert ably.auth.auth_mechanism == Auth.Method.TOKEN + + def test_with_auth_url(self): + ably = AblyRest(auth_url='auth_url') + assert ably.auth.auth_mechanism == Auth.Method.TOKEN + + def test_with_auth_callback(self): + ably = AblyRest(auth_callback=lambda x: x) + assert ably.auth.auth_mechanism == Auth.Method.TOKEN + + def test_with_token(self): + ably = AblyRest(token='a token') + assert ably.auth.auth_mechanism == Auth.Method.TOKEN + + def test_default_ttl_is_1hour(self): + one_hour_in_ms = 60 * 60 * 1000 + assert TokenDetails.DEFAULTS['ttl'] == one_hour_in_ms + + def test_with_auth_method(self): + ably = AblyRest(token='a token', auth_method='POST') + assert ably.auth.auth_options.auth_method == 'POST' + + def test_with_auth_headers(self): + ably = AblyRest(token='a token', auth_headers={'h1': 'v1'}) + assert ably.auth.auth_options.auth_headers == {'h1': 'v1'} + + def test_with_auth_params(self): + ably = AblyRest(token='a token', auth_params={'p': 'v'}) + assert ably.auth.auth_options.auth_params == {'p': 'v'} + + def test_with_default_token_params(self): + ably = AblyRest(key=self.test_vars["keys"][0]["key_str"], + default_token_params={'ttl': 12345}) + assert ably.auth.auth_options.default_token_params == {'ttl': 12345} + + +class TestAuthAuthorize(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + self.test_vars = await TestApp.get_test_vars() + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + async def test_if_authorize_changes_auth_mechanism_to_token(self): + assert Auth.Method.BASIC == self.ably.auth.auth_mechanism, "Unexpected Auth method mismatch" + + await self.ably.auth.authorize() + + assert Auth.Method.TOKEN == self.ably.auth.auth_mechanism, "Authorize should change the Auth method" + + # RSA10a + @dont_vary_protocol + async def test_authorize_always_creates_new_token(self): + await self.ably.auth.authorize({'capability': {'test': ['publish']}}) + await self.ably.channels.test.publish('event', 'data') + + await self.ably.auth.authorize({'capability': {'test': ['subscribe']}}) + with pytest.raises(AblyAuthException): + await self.ably.channels.test.publish('event', 'data') + + async def test_authorize_create_new_token_if_expired(self): + token = await self.ably.auth.authorize() + with mock.patch('ably.rest.auth.Auth.token_details_has_expired', + return_value=True): + new_token = await self.ably.auth.authorize() + + assert token is not new_token + + async def test_authorize_returns_a_token_details(self): + token = await self.ably.auth.authorize() + assert isinstance(token, TokenDetails) + + @dont_vary_protocol + async def test_authorize_adheres_to_request_token(self): + token_params = {'ttl': 10, 'client_id': 'client_id'} + auth_params = {'auth_url': 'somewhere.com', 'query_time': True} + with mock.patch('ably.rest.auth.Auth.request_token', new_callable=AsyncMock) as request_mock: + await self.ably.auth.authorize(token_params, auth_params) + + token_called, auth_called = request_mock.call_args + assert token_called[0] == token_params + + # Authorize may call request_token with some default auth_options. + for arg, value in auth_params.items(): + assert auth_called[arg] == value, f"{arg} called with wrong value: {value}" + + async def test_with_token_str_https(self): + token = await self.ably.auth.authorize() + token = token.token + ably = await TestApp.get_ably_rest(key=None, token=token, tls=True, + use_binary_protocol=self.use_binary_protocol) + await ably.channels.test_auth_with_token_str.publish('event', 'foo_bar') + await ably.close() + + async def test_with_token_str_http(self): + token = await self.ably.auth.authorize() + token = token.token + ably = await TestApp.get_ably_rest(key=None, token=token, tls=False, + use_binary_protocol=self.use_binary_protocol) + await ably.channels.test_auth_with_token_str.publish('event', 'foo_bar') + await ably.close() + + async def test_if_default_client_id_is_used(self): + ably = await TestApp.get_ably_rest(client_id='my_client_id', + use_binary_protocol=self.use_binary_protocol) + token = await ably.auth.authorize() + assert token.client_id == 'my_client_id' + await ably.close() + + # RSA10j + async def test_if_parameters_are_stored_and_used_as_defaults(self): + # Define some parameters + auth_options = dict(self.ably.auth.auth_options.auth_options) + auth_options['auth_headers'] = {'a_headers': 'a_value'} + await self.ably.auth.authorize({'ttl': 555}, auth_options) + with mock.patch('ably.rest.auth.Auth.request_token', + wraps=self.ably.auth.request_token) as request_mock: + await self.ably.auth.authorize() + + token_called, auth_called = request_mock.call_args + assert token_called[0] == {'ttl': 555} + assert auth_called['auth_headers'] == {'a_headers': 'a_value'} + + # Different parameters, should completely replace the first ones, not merge + auth_options = dict(self.ably.auth.auth_options.auth_options) + auth_options['auth_headers'] = None + await self.ably.auth.authorize({}, auth_options) + with mock.patch('ably.rest.auth.Auth.request_token', + wraps=self.ably.auth.request_token) as request_mock: + await self.ably.auth.authorize() + + token_called, auth_called = request_mock.call_args + assert token_called[0] == {} + assert auth_called['auth_headers'] is None + + # RSA10g + async def test_timestamp_is_not_stored(self): + # authorize once with arbitrary defaults + auth_options = dict(self.ably.auth.auth_options.auth_options) + auth_options['auth_headers'] = {'a_headers': 'a_value'} + token_1 = await self.ably.auth.authorize( + {'ttl': 60 * 1000, 'client_id': 'new_id'}, + auth_options) + assert isinstance(token_1, TokenDetails) + + # call authorize again with timestamp set + timestamp = await self.ably.time() + with mock.patch('ably.rest.auth.TokenRequest', + wraps=ably.types.tokenrequest.TokenRequest) as tr_mock: + auth_options = dict(self.ably.auth.auth_options.auth_options) + auth_options['auth_headers'] = {'a_headers': 'a_value'} + token_2 = await self.ably.auth.authorize( + {'ttl': 60 * 1000, 'client_id': 'new_id', 'timestamp': timestamp}, + auth_options) + assert isinstance(token_2, TokenDetails) + assert token_1 != token_2 + assert tr_mock.call_args[1]['timestamp'] == timestamp + + # call authorize again with no params + with mock.patch('ably.rest.auth.TokenRequest', + wraps=ably.types.tokenrequest.TokenRequest) as tr_mock: + token_4 = await self.ably.auth.authorize() + assert isinstance(token_4, TokenDetails) + assert token_2 != token_4 + assert tr_mock.call_args[1]['timestamp'] != timestamp + + async def test_client_id_precedence(self): + client_id = uuid.uuid4().hex + overridden_client_id = uuid.uuid4().hex + ably = await TestApp.get_ably_rest( + use_binary_protocol=self.use_binary_protocol, + client_id=client_id, + default_token_params={'client_id': overridden_client_id}) + token = await ably.auth.authorize() + assert token.client_id == client_id + assert ably.auth.client_id == client_id + + channel = ably.channels[ + self.get_channel_name('test_client_id_precedence')] + await channel.publish('test', 'data') + history = await channel.history() + assert history.items[0].client_id == client_id + await ably.close() + + +class TestRequestToken(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + + def per_protocol_setup(self, use_binary_protocol): + self.use_binary_protocol = use_binary_protocol + + async def test_with_key(self): + ably = await TestApp.get_ably_rest(use_binary_protocol=self.use_binary_protocol) + + token_details = await ably.auth.request_token() + assert isinstance(token_details, TokenDetails) + await ably.close() + + ably = await TestApp.get_ably_rest(key=None, token_details=token_details, + use_binary_protocol=self.use_binary_protocol) + channel = self.get_channel_name('test_request_token_with_key') + + await ably.channels[channel].publish('event', 'foo') + + history = await ably.channels[channel].history() + assert history.items[0].data == 'foo' + await ably.close() + + @dont_vary_protocol + @respx.mock + async def test_with_auth_url_headers_and_params_http_post(self): # noqa: N802 + url = 'http://www.example.com' + headers = {'foo': 'bar'} + ably = await TestApp.get_ably_rest(key=None, auth_url=url) + + auth_params = {'foo': 'auth', 'spam': 'eggs'} + token_params = {'foo': 'token'} + auth_route = respx.post(url) + + def call_back(request): + assert request.headers['content-type'] == 'application/x-www-form-urlencoded' + assert headers['foo'] == request.headers['foo'] + + # TokenParams has precedence + assert parse_qs(request.content.decode('utf-8')) == {'foo': ['token'], 'spam': ['eggs']} + return Response( + status_code=200, + content="token_string", + headers={ + "Content-Type": "text/plain", + } + ) + + auth_route.side_effect = call_back + token_details = await ably.auth.request_token( + token_params=token_params, auth_url=url, auth_headers=headers, + auth_method='POST', auth_params=auth_params) + + assert 1 == auth_route.called + assert isinstance(token_details, TokenDetails) + assert 'token_string' == token_details.token + await ably.close() + + @dont_vary_protocol + @respx.mock + async def test_with_auth_url_headers_and_params_http_get(self): # noqa: N802 + url = 'http://www.example.com' + headers = {'foo': 'bar'} + ably = await TestApp.get_ably_rest( + key=None, auth_url=url, + auth_headers={'this': 'will_not_be_used'}, + auth_params={'this': 'will_not_be_used'}) + + auth_params = {'foo': 'auth', 'spam': 'eggs'} + token_params = {'foo': 'token'} + auth_route = respx.get(url, params={'foo': ['token'], 'spam': ['eggs']}) + + def call_back(request): + assert request.headers['foo'] == 'bar' + assert 'this' not in request.headers + assert not request.content + + return Response( + status_code=200, + json={'issued': 1, 'token': 'another_token_string'} + ) + auth_route.side_effect = call_back + token_details = await ably.auth.request_token( + token_params=token_params, auth_url=url, auth_headers=headers, + auth_params=auth_params) + assert 'another_token_string' == token_details.token + await ably.close() + + @dont_vary_protocol + async def test_with_callback(self): + called_token_params = {'ttl': '3600000'} + + async def callback(token_params): + assert token_params == called_token_params + return 'token_string' + + ably = await TestApp.get_ably_rest(key=None, auth_callback=callback) + + token_details = await ably.auth.request_token( + token_params=called_token_params, auth_callback=callback) + assert isinstance(token_details, TokenDetails) + assert 'token_string' == token_details.token + + async def callback(token_params): + assert token_params == called_token_params + return TokenDetails(token='another_token_string') + + token_details = await ably.auth.request_token( + token_params=called_token_params, auth_callback=callback) + assert 'another_token_string' == token_details.token + await ably.close() + + @dont_vary_protocol + @respx.mock + async def test_when_auth_url_has_query_string(self): + url = 'http://www.example.com?with=query' + headers = {'foo': 'bar'} + ably = await TestApp.get_ably_rest(key=None, auth_url=url) + auth_route = respx.get('http://www.example.com', params={'with': 'query', 'spam': 'eggs'}).mock( + return_value=Response(status_code=200, content='token_string', headers={"Content-Type": "text/plain"})) + await ably.auth.request_token(auth_url=url, + auth_headers=headers, + auth_params={'spam': 'eggs'}) + assert auth_route.called + await ably.close() + + @dont_vary_protocol + async def test_client_id_null_for_anonymous_auth(self): + ably = await TestApp.get_ably_rest( + key=None, + key_name=self.test_vars["keys"][0]["key_name"], + key_secret=self.test_vars["keys"][0]["key_secret"]) + token = await ably.auth.authorize() + + assert isinstance(token, TokenDetails) + assert token.client_id is None + assert ably.auth.client_id is None + await ably.close() + + @dont_vary_protocol + async def test_client_id_null_until_auth(self): + client_id = uuid.uuid4().hex + token_ably = await TestApp.get_ably_rest( + default_token_params={'client_id': client_id}) + # before auth, client_id is None + assert token_ably.auth.client_id is None + + token = await token_ably.auth.authorize() + assert isinstance(token, TokenDetails) + + # after auth, client_id is defined + assert token.client_id == client_id + assert token_ably.auth.client_id == client_id + await token_ably.close() + + +class TestRenewToken(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.host = 'fake-host.ably.io' + self.ably = await TestApp.get_ably_rest(use_binary_protocol=False, endpoint=self.host) + # with headers + self.publish_attempts = 0 + self.channel = uuid.uuid4().hex + tokens = ['a_token', 'another_token'] + headers = {'Content-Type': 'application/json'} + self.mocked_api = respx.mock(base_url=f'https://{self.host}') + self.request_token_route = self.mocked_api.post( + "/keys/{}/requestToken".format(self.test_vars["keys"][0]['key_name']), + name="request_token_route") + self.request_token_route.return_value = Response( + status_code=200, + headers=headers, + json={ + 'token': tokens[self.request_token_route.call_count - 1], + 'expires': (time.time() + 60) * 1000 + }, + ) + + def call_back(request): + self.publish_attempts += 1 + if self.publish_attempts in [1, 3]: + return Response( + status_code=201, + headers=headers, + json=[], + ) + return Response( + status_code=401, + headers=headers, + json={ + 'error': {'message': 'Authentication failure', 'statusCode': 401, 'code': 40140} + }, + ) + + self.publish_attempt_route = self.mocked_api.post(f"/channels/{self.channel}/messages", + name="publish_attempt_route") + self.publish_attempt_route.side_effect = call_back + self.mocked_api.start() + yield + # We need to have quiet here in order to do not have check if all endpoints were called + self.mocked_api.stop(quiet=True) + self.mocked_api.reset() + await self.ably.close() + + # RSA4b + async def test_when_renewable(self): + await self.ably.auth.authorize() + await self.ably.channels[self.channel].publish('evt', 'msg') + assert self.mocked_api["request_token_route"].call_count == 1 + assert self.publish_attempts == 1 + + # Triggers an authentication 401 failure which should automatically request a new token + await self.ably.channels[self.channel].publish('evt', 'msg') + assert self.mocked_api["request_token_route"].call_count == 2 + assert self.publish_attempts == 3 + + # RSA4a + async def test_when_not_renewable(self): + await self.ably.close() + + self.ably = await TestApp.get_ably_rest( + key=None, + endpoint=self.host, + token='token ID cannot be used to create a new token', + use_binary_protocol=False) + await self.ably.channels[self.channel].publish('evt', 'msg') + assert self.publish_attempts == 1 + + publish = self.ably.channels[self.channel].publish + + match = "Need a new token but auth_options does not include a way to request one" + with pytest.raises(AblyAuthException, match=match): + await publish('evt', 'msg') + + assert not self.mocked_api["request_token_route"].called + + # RSA4a + async def test_when_not_renewable_with_token_details(self): + token_details = TokenDetails(token='a_dummy_token') + self.ably = await TestApp.get_ably_rest( + key=None, + endpoint=self.host, + token_details=token_details, + use_binary_protocol=False) + await self.ably.channels[self.channel].publish('evt', 'msg') + assert self.mocked_api["publish_attempt_route"].call_count == 1 + + publish = self.ably.channels[self.channel].publish + + match = "Need a new token but auth_options does not include a way to request one" + with pytest.raises(AblyAuthException, match=match): + await publish('evt', 'msg') + + assert not self.mocked_api["request_token_route"].called + + +class TestRenewExpiredToken(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.publish_attempts = 0 + self.channel = uuid.uuid4().hex + + self.host = 'fake-host.ably.io' + key = self.test_vars["keys"][0]['key_name'] + headers = {'Content-Type': 'application/json'} + + self.mocked_api = respx.mock(base_url=f'https://{self.host}') + self.request_token_route = self.mocked_api.post(f"/keys/{key}/requestToken", + name="request_token_route") + self.request_token_route.return_value = Response( + status_code=200, + headers=headers, + json={ + 'token': 'a_token', + 'expires': int(time.time() * 1000), # Always expires + } + ) + self.publish_message_route = self.mocked_api.post(f"/channels/{self.channel}/messages", + name="publish_message_route") + self.time_route = self.mocked_api.get("/time", name="time_route") + self.time_route.return_value = Response( + status_code=200, + headers=headers, + json=[int(time.time() * 1000)] + ) + + def cb_publish(request): + self.publish_attempts += 1 + if self.publish_fail: + self.publish_fail = False + return Response( + status_code=401, + json={ + 'error': {'message': 'Authentication failure', 'statusCode': 401, 'code': 40140} + } + ) + return Response( + status_code=201, + json='[]' + ) + + self.publish_message_route.side_effect = cb_publish + self.mocked_api.start() + yield + self.mocked_api.stop(quiet=True) + self.mocked_api.reset() + + # RSA4b1 + async def test_query_time_false(self): + ably = await TestApp.get_ably_rest(endpoint=self.host) + await ably.auth.authorize() + self.publish_fail = True + await ably.channels[self.channel].publish('evt', 'msg') + assert self.publish_attempts == 2 + await ably.close() + + # RSA4b1 + async def test_query_time_true(self): + ably = await TestApp.get_ably_rest(query_time=True, endpoint=self.host) + await ably.auth.authorize() + self.publish_fail = False + await ably.channels[self.channel].publish('evt', 'msg') + assert self.publish_attempts == 1 + await ably.close() diff --git a/test/ably/rest/restcapability_test.py b/test/ably/rest/restcapability_test.py new file mode 100644 index 00000000..c95c651d --- /dev/null +++ b/test/ably/rest/restcapability_test.py @@ -0,0 +1,255 @@ +import pytest + +from ably.types.capability import Capability +from ably.util.exceptions import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + + +class TestRestCapability(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest() + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + + async def test_blanket_intersection_with_key(self): + key = self.test_vars['keys'][1] + token_details = await self.ably.auth.request_token(key_name=key['key_name'], key_secret=key['key_secret']) + expected_capability = Capability(key["capability"]) + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability." + + async def test_equal_intersection_with_key(self): + key = self.test_vars['keys'][1] + + token_details = await self.ably.auth.request_token( + key_name=key['key_name'], + key_secret=key['key_secret'], + token_params={'capability': key['capability']}) + + expected_capability = Capability(key["capability"]) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + @dont_vary_protocol + async def test_empty_ops_intersection(self): + key = self.test_vars['keys'][1] + with pytest.raises(AblyException): + await self.ably.auth.request_token( + key_name=key['key_name'], + key_secret=key['key_secret'], + token_params={'capability': {'testchannel': ['subscribe']}}) + + @dont_vary_protocol + async def test_empty_paths_intersection(self): + key = self.test_vars['keys'][1] + with pytest.raises(AblyException): + await self.ably.auth.request_token( + key_name=key['key_name'], + key_secret=key['key_secret'], + token_params={'capability': {"testchannelx": ["publish"]}}) + + async def test_non_empty_ops_intersection(self): + key = self.test_vars['keys'][4] + + token_params = {"capability": { + "channel2": ["presence", "subscribe"] + }} + kwargs = { + "key_name": key["key_name"], + "key_secret": key["key_secret"], + } + + expected_capability = Capability({ + "channel2": ["subscribe"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + async def test_non_empty_paths_intersection(self): + key = self.test_vars['keys'][4] + token_params = { + "capability": { + "channel2": ["presence", "subscribe"], + "channelx": ["presence", "subscribe"], + } + } + kwargs = { + "key_name": key["key_name"], + + "key_secret": key["key_secret"] + } + + expected_capability = Capability({ + "channel2": ["subscribe"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + async def test_wildcard_ops_intersection(self): + key = self.test_vars['keys'][4] + + token_params = { + "capability": { + "channel2": ["*"], + }, + } + kwargs = { + "key_name": key["key_name"], + "key_secret": key["key_secret"], + } + + expected_capability = Capability({ + "channel2": ["subscribe", "publish"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + async def test_wildcard_ops_intersection_2(self): + key = self.test_vars['keys'][4] + + token_params = { + "capability": { + "channel6": ["publish", "subscribe"], + }, + } + kwargs = { + "key_name": key["key_name"], + "key_secret": key["key_secret"], + } + + expected_capability = Capability({ + "channel6": ["subscribe", "publish"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + async def test_wildcard_resources_intersection(self): + key = self.test_vars['keys'][2] + + token_params = { + "capability": { + "cansubscribe": ["subscribe"], + }, + } + kwargs = { + "key_name": key["key_name"], + "key_secret": key["key_secret"], + } + + expected_capability = Capability({ + "cansubscribe": ["subscribe"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + async def test_wildcard_resources_intersection_2(self): + key = self.test_vars['keys'][2] + + token_params = { + "capability": { + "cansubscribe:check": ["subscribe"], + }, + } + kwargs = { + "key_name": key["key_name"], + "key_secret": key["key_secret"], + } + + expected_capability = Capability({ + "cansubscribe:check": ["subscribe"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + async def test_wildcard_resources_intersection_3(self): + key = self.test_vars['keys'][2] + + token_params = { + "capability": { + "cansubscribe:*": ["subscribe"], + }, + } + kwargs = { + "key_name": key["key_name"], + "key_secret": key["key_secret"], + + } + + expected_capability = Capability({ + "cansubscribe:*": ["subscribe"] + }) + + token_details = await self.ably.auth.request_token(token_params, **kwargs) + + assert token_details.token is not None, "Expected token" + assert expected_capability == token_details.capability, "Unexpected capability" + + @dont_vary_protocol + async def test_invalid_capabilities(self): + with pytest.raises(AblyException) as excinfo: + await self.ably.auth.request_token( + token_params={'capability': {"channel0": ["publish_"]}}) + + the_exception = excinfo.value + assert 400 == the_exception.status_code + assert 40000 == the_exception.code + + @dont_vary_protocol + async def test_invalid_capabilities_2(self): + with pytest.raises(AblyException) as excinfo: + await self.ably.auth.request_token( + token_params={'capability': {"channel0": ["*", "publish"]}}) + + the_exception = excinfo.value + assert 400 == the_exception.status_code + assert 40000 == the_exception.code + + @dont_vary_protocol + async def test_invalid_capabilities_3(self): + with pytest.raises(AblyException) as excinfo: + await self.ably.auth.request_token( + token_params={'capability': {"channel0": []}}) + + the_exception = excinfo.value + assert 400 == the_exception.status_code + assert 40000 == the_exception.code + + @dont_vary_protocol + def test_capability_from_string(self): + capability_from_str = Capability('{"cansubscribe":["subscribe"]}') + capability_from_str_single_quote = Capability('{\'cansubscribe\':[\'subscribe\']}') + + capability_from_dict = Capability({ + "cansubscribe": ["subscribe"] + }) + + assert capability_from_str == capability_from_dict, "Unexpected Capability constructed from string" + assert ( + capability_from_str_single_quote == capability_from_dict + ), "Unexpected Capability constructed from string" diff --git a/test/ably/rest/restchannelhistory_test.py b/test/ably/rest/restchannelhistory_test.py new file mode 100644 index 00000000..a9a2245b --- /dev/null +++ b/test/ably/rest/restchannelhistory_test.py @@ -0,0 +1,332 @@ +import logging + +import pytest +import respx + +from ably import AblyException +from ably.http.paginatedresult import PaginatedResult +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + +log = logging.getLogger(__name__) + + +class TestRestChannelHistory(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest(fallback_hosts=[]) + self.test_vars = await TestApp.get_test_vars() + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + + async def test_channel_history_types(self): + history0 = self.get_channel('persisted:channelhistory_types') + + await history0.publish('history0', 'This is a string message payload') + await history0.publish('history1', b'This is a byte[] message payload') + await history0.publish('history2', {'test': 'This is a JSONObject message payload'}) + await history0.publish('history3', ['This is a JSONArray message payload']) + + history = await history0.history() + assert isinstance(history, PaginatedResult) + messages = history.items + assert messages is not None, "Expected non-None messages" + assert 4 == len(messages), "Expected 4 messages" + + message_contents = {m.name: m for m in messages} + assert "This is a string message payload" == message_contents["history0"].data, \ + "Expect history0 to be expected String)" + assert b"This is a byte[] message payload" == message_contents["history1"].data, \ + "Expect history1 to be expected byte[]" + assert {"test": "This is a JSONObject message payload"} == message_contents["history2"].data, \ + "Expect history2 to be expected JSONObject" + assert ["This is a JSONArray message payload"] == message_contents["history3"].data, \ + "Expect history3 to be expected JSONObject" + + expected_message_history = [ + message_contents['history3'], + message_contents['history2'], + message_contents['history1'], + message_contents['history0'], + ] + assert expected_message_history == messages, "Expect messages in reverse order" + + async def test_channel_history_multi_50_forwards(self): + history0 = self.get_channel('persisted:channelhistory_multi_50_f') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='forwards') + assert history is not None + messages = history.items + assert len(messages) == 50, "Expected 50 messages" + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(50)] + assert messages == expected_messages, 'Expect messages in forward order' + + async def test_channel_history_multi_50_backwards(self): + history0 = self.get_channel('persisted:channelhistory_multi_50_b') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='backwards') + assert history is not None + messages = history.items + assert 50 == len(messages), "Expected 50 messages" + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(49, -1, -1)] + assert expected_messages == messages, 'Expect messages in reverse order' + + def history_mock_url(self, channel_name): + kwargs = { + 'scheme': 'https' if self.test_vars['tls'] else 'http', + 'host': self.test_vars['host'], + 'channel_name': channel_name + } + port = self.test_vars['tls_port'] if self.test_vars.get('tls') else kwargs['port'] + if port == 80: + kwargs['port_sufix'] = '' + else: + kwargs['port_sufix'] = ':' + str(port) + url = '{scheme}://{host}{port_sufix}/channels/{channel_name}/messages' + return url.format(**kwargs) + + @respx.mock + @dont_vary_protocol + async def test_channel_history_default_limit(self): + self.per_protocol_setup(True) + channel = self.ably.channels['persisted:channelhistory_limit'] + url = self.history_mock_url('persisted:channelhistory_limit') + self.respx_add_empty_msg_pack(url) + await channel.history() + assert 'limit' not in respx.calls[0].request.url.params.keys() + + @respx.mock + @dont_vary_protocol + async def test_channel_history_with_limits(self): + self.per_protocol_setup(True) + channel = self.ably.channels['persisted:channelhistory_limit'] + url = self.history_mock_url('persisted:channelhistory_limit') + self.respx_add_empty_msg_pack(url) + + await channel.history(limit=500) + assert '500' in respx.calls[0].request.url.params.get('limit') + + await channel.history(limit=1000) + assert '1000' in respx.calls[1].request.url.params.get('limit') + + @dont_vary_protocol + async def test_channel_history_max_limit_is_1000(self): + channel = self.ably.channels['persisted:channelhistory_limit'] + with pytest.raises(AblyException): + await channel.history(limit=1001) + + async def test_channel_history_limit_forwards(self): + history0 = self.get_channel('persisted:channelhistory_limit_f') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='forwards', limit=25) + assert history is not None + messages = history.items + assert len(messages) == 25, "Expected 25 messages" + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(25)] + assert messages == expected_messages, 'Expect messages in forward order' + + async def test_channel_history_limit_backwards(self): + history0 = self.get_channel('persisted:channelhistory_limit_b') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='backwards', limit=25) + assert history is not None + messages = history.items + assert len(messages) == 25, "Expected 25 messages" + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(49, 24, -1)] + assert messages == expected_messages, 'Expect messages in forward order' + + async def test_channel_history_time_forwards(self): + history0 = self.get_channel('persisted:channelhistory_time_f') + + for i in range(20): + await history0.publish(f'history{i}', str(i)) + + interval_start = await self.ably.time() + + for i in range(20, 40): + await history0.publish(f'history{i}', str(i)) + + interval_end = await self.ably.time() + + for i in range(40, 60): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='forwards', start=interval_start, + end=interval_end) + + messages = history.items + assert 20 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(20, 40)] + assert expected_messages == messages, 'Expect messages in forward order' + + async def test_channel_history_time_backwards(self): + history0 = self.get_channel('persisted:channelhistory_time_b') + + for i in range(20): + await history0.publish(f'history{i}', str(i)) + + interval_start = await self.ably.time() + + for i in range(20, 40): + await history0.publish(f'history{i}', str(i)) + + interval_end = await self.ably.time() + + for i in range(40, 60): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='backwards', start=interval_start, + end=interval_end) + + messages = history.items + assert 20 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(39, 19, -1)] + assert expected_messages, messages == 'Expect messages in reverse order' + + async def test_channel_history_paginate_forwards(self): + history0 = self.get_channel('persisted:channelhistory_paginate_f') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='forwards', limit=10) + messages = history.items + + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(0, 10)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.next() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(10, 20)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.next() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(20, 30)] + assert expected_messages == messages, 'Expected 10 messages' + + async def test_channel_history_paginate_backwards(self): + history0 = self.get_channel('persisted:channelhistory_paginate_b') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='backwards', limit=10) + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(49, 39, -1)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.next() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(39, 29, -1)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.next() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(29, 19, -1)] + assert expected_messages == messages, 'Expected 10 messages' + + async def test_channel_history_paginate_forwards_first(self): + history0 = self.get_channel('persisted:channelhistory_paginate_first_f') + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='forwards', limit=10) + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(0, 10)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.next() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(10, 20)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.first() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(0, 10)] + assert expected_messages == messages, 'Expected 10 messages' + + async def test_channel_history_paginate_backwards_rel_first(self): + history0 = self.get_channel('persisted:channelhistory_paginate_first_b') + + for i in range(50): + await history0.publish(f'history{i}', str(i)) + + history = await history0.history(direction='backwards', limit=10) + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(49, 39, -1)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.next() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(39, 29, -1)] + assert expected_messages == messages, 'Expected 10 messages' + + history = await history.first() + messages = history.items + assert 10 == len(messages) + + message_contents = {m.name: m for m in messages} + expected_messages = [message_contents[f'history{i}'] for i in range(49, 39, -1)] + assert expected_messages == messages, 'Expected 10 messages' diff --git a/test/ably/rest/restchannelmutablemessages_test.py b/test/ably/rest/restchannelmutablemessages_test.py new file mode 100644 index 00000000..b4f32ef4 --- /dev/null +++ b/test/ably/rest/restchannelmutablemessages_test.py @@ -0,0 +1,323 @@ +import logging +from typing import List + +import pytest + +from ably import AblyException, CipherParams, MessageAction +from ably.types.message import Message +from ably.types.operations import MessageOperation +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, assert_waiter + +log = logging.getLogger(__name__) + + +@pytest.mark.parametrize("transport", ["json", "msgpack"], ids=["JSON", "MsgPack"]) +class TestRestChannelMutableMessages(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self, transport): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest( + use_binary_protocol=True if transport == 'msgpack' else False, + ) + + async def test_update_message_success(self): + """Test successfully updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_test')] + + # First publish a message + result = await channel.publish('test-event', 'original data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial for update + message = Message( + data='updated data', + serial=serial, + ) + + # Update the message + update_result = await channel.update_message(message) + assert update_result is not None + updated_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert updated_message.data == 'updated data' + assert updated_message.version.serial == update_result.version_serial + assert updated_message.serial == serial + + async def test_update_message_without_serial_fails(self): + """Test that updating without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:update_test_no_serial')] + + message = Message(name='test-event', data='data') + + with pytest.raises(AblyException) as exc_info: + await channel.update_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_delete_message_success(self): + """Test successfully deleting a message""" + channel = self.ably.channels[self.get_channel_name('mutable:delete_test')] + + # First publish a message + result = await channel.publish('test-event', 'data to delete') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial for deletion + message = Message(serial=serial) + + operation = MessageOperation( + description='Inappropriate content', + metadata={'reason': 'moderation'} + ) + + # Delete the message + delete_result = await channel.delete_message(message, operation) + assert delete_result is not None + + # Verify the deletion propagated + deleted_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_DELETE + ) + assert deleted_message.action == MessageAction.MESSAGE_DELETE + assert deleted_message.version.serial == delete_result.version_serial + assert deleted_message.version.description == 'Inappropriate content' + assert deleted_message.version.metadata == {'reason': 'moderation'} + assert deleted_message.serial == serial + + async def test_delete_message_without_serial_fails(self): + """Test that deleting without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:delete_test_no_serial')] + + message = Message(name='test-event', data='data') + + with pytest.raises(AblyException) as exc_info: + await channel.delete_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_append_message_success(self): + """Test successfully appending to a message""" + channel = self.ably.channels[self.get_channel_name('mutable:append_test')] + + # First publish a message + result = await channel.publish('test-event', 'original content') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial and data to append + message = Message( + data=' appended content', + serial=serial + ) + + operation = MessageOperation( + description='Added more info', + metadata={'type': 'amendment'} + ) + + # Append to the message + append_result = await channel.append_message(message, operation) + assert append_result is not None + + # Verify the append propagated - action will be MESSAGE_UPDATE, data should be concatenated + appended_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert appended_message.data == 'original content appended content' + assert appended_message.version.serial == append_result.version_serial + assert appended_message.version.description == 'Added more info' + assert appended_message.version.metadata == {'type': 'amendment'} + assert appended_message.serial == serial + + async def test_append_message_without_serial_fails(self): + """Test that appending without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:append_test_no_serial')] + + message = Message(name='test-event', data='data to append') + + with pytest.raises(AblyException) as exc_info: + await channel.append_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_update_message_with_encryption(self): + """Test updating an encrypted message""" + # Create channel with encryption + channel_name = self.get_channel_name('mutable:update_encrypted') + cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes') + channel = self.ably.channels.get(channel_name, cipher=cipher_params) + + # Publish encrypted message + result = await channel.publish('encrypted-event', 'secret data') + assert result.serials is not None + assert len(result.serials) > 0 + + # Update the encrypted message + message = Message( + name='encrypted-event', + data='updated secret data', + serial=result.serials[0] + ) + + operation = MessageOperation(description='Updated encrypted message') + update_result = await channel.update_message(message, operation) + assert update_result is not None + + async def test_update_message_with_params(self): + """Test updating a message with query parameters""" + channel = self.ably.channels[self.get_channel_name('mutable:update_params')] + + # Publish message + result = await channel.publish('test-event', 'original') + assert len(result.serials) > 0 + + # Update with params + message = Message( + name='test-event', + data='updated', + serial=result.serials[0] + ) + + operation = MessageOperation(description='Test with params') + params = {'testParam': 'value'} + + update_result = await channel.update_message(message, operation, params) + assert update_result is not None + + async def test_publish_returns_serials(self): + """Test that publish returns PublishResult with serials""" + channel = self.ably.channels[self.get_channel_name('mutable:publish_serials')] + + # Publish multiple messages + messages = [ + Message('event1', 'data1'), + Message('event2', 'data2'), + Message('event3', 'data3') + ] + + result = await channel.publish(messages=messages) + assert result is not None + assert hasattr(result, 'serials') + assert len(result.serials) == 3 + + async def test_complete_workflow_publish_update_delete(self): + """Test complete workflow: publish, update, delete""" + channel = self.ably.channels[self.get_channel_name('mutable:complete_workflow')] + + # 1. Publish a message + result = await channel.publish('workflow_event', 'Initial data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # 2. Update the message + update_message = Message( + name='workflow_event_updated', + data='Updated data', + serial=serial + ) + update_operation = MessageOperation(description='Updated message') + update_result = await channel.update_message(update_message, update_operation) + assert update_result is not None + + # 3. Delete the message + delete_message = Message(serial=serial, data='Deleted') + delete_operation = MessageOperation(description='Deleted message') + delete_result = await channel.delete_message(delete_message, delete_operation) + assert delete_result is not None + + versions = await self.wait_until_get_all_message_version(channel, serial, 3) + + assert versions[0].version.serial == serial + assert versions[1].version.serial == update_result.version_serial + assert versions[2].version.serial == delete_result.version_serial + + async def test_append_message_with_string_data(self): + """Test appending string data to a message""" + channel = self.ably.channels[self.get_channel_name('mutable:append_string')] + + # Publish initial message + result = await channel.publish('append_event', 'Initial data') + assert len(result.serials) > 0 + serial = result.serials[0] + + # Append data + append_message = Message( + data=' appended data', + serial=serial + ) + append_operation = MessageOperation(description='Appended to message') + append_result = await channel.append_message(append_message, append_operation) + assert append_result is not None + + # Verify the append + appended_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert appended_message.data == 'Initial data appended data' + assert appended_message.version.serial == append_result.version_serial + assert appended_message.version.description == 'Appended to message' + assert appended_message.serial == serial + + # RSL15b, TM2i + async def test_update_message_preserves_extras(self): + """Test that extras are preserved when updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_extras')] + + # Publish a message + result = await channel.publish('test-event', 'original data') + assert len(result.serials) > 0 + serial = result.serials[0] + + # Update with extras + message = Message( + data='updated data', + serial=serial, + extras={'headers': {'status': 'complete'}}, + ) + + update_result = await channel.update_message(message) + assert update_result is not None + + updated_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert updated_message.data == 'updated data' + assert updated_message.extras is not None + assert updated_message.extras['headers']['status'] == 'complete' + + async def wait_until_message_with_action_appears(self, channel, serial, action): + message: Message | None = None + async def check_message_action(): + nonlocal message + try: + message = await channel.get_message(serial) + return message.action == action + except Exception: + return False + + await assert_waiter(check_message_action) + + return message + + async def wait_until_get_all_message_version(self, channel, serial, count): + versions: List[Message] = [] + async def check_message_versions(): + nonlocal versions + versions = (await channel.get_message_versions(serial)).items + return len(versions) >= count + + await assert_waiter(check_message_versions) + + return versions diff --git a/test/ably/rest/restchannelpublish_test.py b/test/ably/rest/restchannelpublish_test.py new file mode 100644 index 00000000..41c2018b --- /dev/null +++ b/test/ably/rest/restchannelpublish_test.py @@ -0,0 +1,573 @@ +import base64 +import binascii +import json +import logging +import os +import uuid +from unittest import mock + +import httpx +import msgpack +import pytest + +from ably import AblyException, IncompatibleClientIdException, api_version +from ably.rest.auth import Auth +from ably.types.message import Message +from ably.types.tokendetails import TokenDetails +from ably.util import case +from test.ably import utils +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, assert_waiter, dont_vary_protocol + +log = logging.getLogger(__name__) + + +# Ignore library warning regarding client_id +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +class TestRestChannelPublish(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest() + self.client_id = uuid.uuid4().hex + self.ably_with_client_id = await TestApp.get_ably_rest(client_id=self.client_id, use_token_auth=True) + yield + await self.ably.close() + await self.ably_with_client_id.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.ably_with_client_id.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + async def test_publish_various_datatypes_text(self): + publish0 = self.ably.channels[ + self.get_channel_name('persisted:publish0')] + + await publish0.publish("publish0", "This is a string message payload") + await publish0.publish("publish1", b"This is a byte[] message payload") + await publish0.publish("publish2", {"test": "This is a JSONObject message payload"}) + await publish0.publish("publish3", ["This is a JSONArray message payload"]) + + # Get the history for this channel + history = await publish0.history() + messages = history.items + assert messages is not None, "Expected non-None messages" + assert len(messages) == 4, "Expected 4 messages" + + message_contents = {m.name: m.data for m in messages} + log.debug(f"message_contents: {str(message_contents)}") + + assert message_contents["publish0"] == "This is a string message payload", \ + "Expect publish0 to be expected String)" + + assert message_contents["publish1"] == b"This is a byte[] message payload", \ + "Expect publish1 to be expected byte[]. Actual: {}".format(str(message_contents['publish1'])) + + assert message_contents["publish2"] == {"test": "This is a JSONObject message payload"}, \ + "Expect publish2 to be expected JSONObject" + + assert message_contents["publish3"] == ["This is a JSONArray message payload"], \ + "Expect publish3 to be expected JSONObject" + + @dont_vary_protocol + async def test_unsupported_payload_must_raise_exception(self): + channel = self.ably.channels["persisted:publish0"] + for data in [1, 1.1, True]: + with pytest.raises(AblyException): + await channel.publish('event', data) + + async def test_publish_message_list(self): + channel = self.ably.channels[ + self.get_channel_name('persisted:message_list_channel')] + + expected_messages = [Message(f"name-{i}", str(i)) for i in range(3)] + + await channel.publish(messages=expected_messages) + + # Get the history for this channel + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == len(expected_messages), "Expected 3 messages" + + for m, expected_m in zip(messages, reversed(expected_messages)): + assert m.name == expected_m.name + assert m.data == expected_m.data + + async def test_message_list_generate_one_request(self): + channel = self.ably.channels[ + self.get_channel_name('persisted:message_list_channel_one_request')] + + expected_messages = [Message(f"name-{i}", str(i)) for i in range(3)] + + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish(messages=expected_messages) + assert post_mock.call_count == 1 + + if self.use_binary_protocol: + messages = msgpack.unpackb(post_mock.call_args[1]['body']) + else: + messages = json.loads(post_mock.call_args[1]['body']) + + for i, message in enumerate(messages): + assert message['name'] == 'name-' + str(i) + assert message['data'] == str(i) + + async def test_publish_error(self): + ably = await TestApp.get_ably_rest(use_binary_protocol=self.use_binary_protocol) + await ably.auth.authorize( + token_params={'capability': {"only_subscribe": ["subscribe"]}}) + + with pytest.raises(AblyException) as excinfo: + await ably.channels["only_subscribe"].publish() + + assert 401 == excinfo.value.status_code + assert 40160 == excinfo.value.code + await ably.close() + + async def test_publish_message_null_name(self): + channel = self.ably.channels[ + self.get_channel_name('persisted:message_null_name_channel')] + + data = "String message" + await channel.publish(name=None, data=data) + + # Get the history for this channel + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 1, "Expected 1 message" + assert messages[0].name is None + assert messages[0].data == data + + async def test_publish_message_null_data(self): + channel = self.ably.channels[ + self.get_channel_name('persisted:message_null_data_channel')] + + name = "Test name" + await channel.publish(name=name, data=None) + + # Get the history for this channel + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 1, "Expected 1 message" + + assert messages[0].name == name + assert messages[0].data is None + + async def test_publish_message_null_name_and_data(self): + channel = self.ably.channels[ + self.get_channel_name('persisted:null_name_and_data_channel')] + + await channel.publish(name=None, data=None) + await channel.publish() + + # Get the history for this channel + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 2, "Expected 2 messages" + + for m in messages: + assert m.name is None + assert m.data is None + + async def test_publish_message_null_name_and_data_keys_arent_sent(self): + channel = self.ably.channels[ + self.get_channel_name('persisted:null_name_and_data_keys_arent_sent_channel')] + + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish(name=None, data=None) + + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 1, "Expected 1 message" + + assert post_mock.call_count == 1 + + if self.use_binary_protocol: + posted_body = msgpack.unpackb(post_mock.call_args[1]['body']) + else: + posted_body = json.loads(post_mock.call_args[1]['body']) + + assert 'name' not in posted_body + assert 'data' not in posted_body + + async def test_message_attr(self): + publish0 = self.ably.channels[ + self.get_channel_name('persisted:publish_message_attr')] + + messages = [Message('publish', + {"test": "This is a JSONObject message payload"}, + client_id='client_id')] + await publish0.publish(messages=messages) + + # Get the history for this channel + history = await publish0.history() + message = history.items[0] + assert isinstance(message, Message) + assert message.id + assert message.name + assert message.data == {'test': 'This is a JSONObject message payload'} + assert message.encoding == '' + assert message.client_id == 'client_id' + assert isinstance(message.timestamp, int) + + async def test_token_is_bound_to_options_client_id_after_publish(self): + # null before publish + assert self.ably_with_client_id.auth.token_details is None + + # created after message publish and will have client_id + channel = self.ably_with_client_id.channels[ + self.get_channel_name('persisted:restricted_to_client_id')] + await channel.publish(name='publish', data='test') + + # defined after publish + assert isinstance(self.ably_with_client_id.auth.token_details, TokenDetails) + assert self.ably_with_client_id.auth.token_details.client_id == self.client_id + assert self.ably_with_client_id.auth.auth_mechanism == Auth.Method.TOKEN + history = await channel.history() + assert history.items[0].client_id == self.client_id + + async def test_publish_message_without_client_id_on_identified_client(self): + channel = self.ably_with_client_id.channels[ + self.get_channel_name('persisted:no_client_id_identified_client')] + + with mock.patch('ably.rest.rest.Http.post', + wraps=channel.ably.http.post) as post_mock: + await channel.publish(name='publish', data='test') + + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 1, "Expected 1 message" + + assert post_mock.call_count == 2 + + if self.use_binary_protocol: + posted_body = msgpack.unpackb( + post_mock.mock_calls[0][2]['body']) + else: + posted_body = json.loads( + post_mock.mock_calls[0][2]['body']) + + assert 'client_id' not in posted_body + + # Get the history for this channel + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 1, "Expected 1 message" + + assert messages[0].client_id == self.ably_with_client_id.client_id + + async def test_publish_message_with_client_id_on_identified_client(self): + # works if same + channel = self.ably_with_client_id.channels[ + self.get_channel_name('persisted:with_client_id_identified_client')] + message = Message(name='publish', data='test', client_id=self.ably_with_client_id.client_id) + await channel.publish(message) + + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 1, "Expected 1 message" + + assert messages[0].client_id == self.ably_with_client_id.client_id + + message = Message(name='publish', data='test', client_id='invalid') + # fails if different + with pytest.raises(IncompatibleClientIdException): + await channel.publish(message) + + async def test_publish_message_with_wrong_client_id_on_implicit_identified_client(self): + new_token = await self.ably.auth.authorize(token_params={'client_id': uuid.uuid4().hex}) + new_ably = await TestApp.get_ably_rest(key=None, + token=new_token.token, + use_binary_protocol=self.use_binary_protocol) + + channel = new_ably.channels[ + self.get_channel_name('persisted:wrong_client_id_implicit_client')] + + message = Message(name='publish', data='test', client_id='invalid') + with pytest.raises(AblyException) as excinfo: + await channel.publish(message) + + assert 400 == excinfo.value.status_code + assert 40012 == excinfo.value.code + await new_ably.close() + + # RSA15b + async def test_wildcard_client_id_can_publish_as_others(self): + wildcard_token_details = await self.ably.auth.request_token({'client_id': '*'}) + wildcard_ably = await TestApp.get_ably_rest( + key=None, + token_details=wildcard_token_details, + use_binary_protocol=self.use_binary_protocol) + + assert wildcard_ably.auth.client_id == '*' + channel = wildcard_ably.channels[ + self.get_channel_name('persisted:wildcard_client_id')] + await channel.publish(name='publish1', data='no client_id') + some_client_id = uuid.uuid4().hex + message = Message(name='publish2', data='some client_id', client_id=some_client_id) + await channel.publish(message) + + history = await channel.history() + messages = history.items + + assert messages is not None, "Expected non-None messages" + assert len(messages) == 2, "Expected 2 messages" + + assert messages[0].client_id == some_client_id + assert messages[1].client_id is None + + await wildcard_ably.close() + + # TM2h + @dont_vary_protocol + async def test_invalid_connection_key(self): + channel = self.ably.channels["persisted:invalid_connection_key"] + message = Message(data='payload', connection_key='should.be.wrong') + with pytest.raises(AblyException) as excinfo: + await channel.publish(messages=[message]) + + assert 400 == excinfo.value.status_code + assert 40006 == excinfo.value.code + + # TM2i, RSL6a2, RSL1h + async def test_publish_extras(self): + channel = self.ably.channels[ + self.get_channel_name('canpublish:extras_channel')] + extras = { + 'push': { + 'notification': {"title": "Testing"}, + } + } + message = Message(name='test-name', data='test-data', extras=extras) + await channel.publish(message) + + # Get the history for this channel + history = await channel.history() + message = history.items[0] + assert message.name == 'test-name' + assert message.data == 'test-data' + assert message.extras == extras + + # RSL6a1 + async def test_interoperability(self): + name = self.get_channel_name('persisted:interoperability_channel') + channel = self.ably.channels[name] + + url = 'https://{}/channels/{}/messages'.format(self.test_vars["host"], name) + key = self.test_vars['keys'][0] + auth = (key['key_name'], key['key_secret']) + + type_mapping = { + 'string': str, + 'jsonObject': dict, + 'jsonArray': list, + 'binary': bytearray, + } + + path = os.path.join(utils.get_submodule_dir(__file__), 'test-resources', 'messages-encoding.json') + with open(path) as f: + data = json.load(f) + for input_msg in data['messages']: + msg_data = input_msg['data'] + encoding = input_msg['encoding'] + expected_type = input_msg['expectedType'] + if expected_type == 'binary': + expected_value = input_msg.get('expectedHexValue') + expected_value = expected_value.encode('ascii') + expected_value = binascii.a2b_hex(expected_value) + else: + expected_value = input_msg.get('expectedValue') + + # 1) + await channel.publish(data=expected_value) + + async def check_data(encoding=encoding, msg_data=msg_data): + async with httpx.AsyncClient(http2=True) as client: + r = await client.get(url, auth=auth) + item = r.json()[0] + encoding_is_correct = item.get('encoding') == encoding + if encoding == 'json': + return encoding_is_correct and json.loads(item['data']) == json.loads(msg_data) + else: + return encoding_is_correct and item['data'] == msg_data + + await assert_waiter(check_data) + + # 2) + await channel.publish(messages=[Message(data=msg_data, encoding=encoding)]) + + async def check_history(expected_value=expected_value, expected_type=expected_type): + history = await channel.history() + message = history.items[0] + return message.data == expected_value and isinstance(message.data, type_mapping[expected_type]) + + await assert_waiter(check_history) + + # https://github.com/ably/ably-python/issues/130 + async def test_publish_slash(self): + channel = self.ably.channels.get(self.get_channel_name('persisted:widgets/')) + name, data = 'Name', 'Data' + await channel.publish(name, data) + history = await channel.history() + assert len(history.items) == 1 + assert history.items[0].name == name + assert history.items[0].data == data + + # RSL1l + @dont_vary_protocol + async def test_publish_params(self): + channel = self.ably.channels.get(self.get_channel_name()) + + message = Message('name', 'data') + with pytest.raises(AblyException) as excinfo: + await channel.publish(message, {'_forceNack': True}) + + assert 400 == excinfo.value.status_code + assert 40099 == excinfo.value.code + + +class TestRestChannelPublishIdempotent(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + self.ably_idempotent = await TestApp.get_ably_rest(idempotent_rest_publishing=True) + yield + await self.ably.close() + await self.ably_idempotent.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + # TO3n + @dont_vary_protocol + async def test_idempotent_rest_publishing(self): + # Test default value + if api_version < '1.2': + assert self.ably.options.idempotent_rest_publishing is False + else: + assert self.ably.options.idempotent_rest_publishing is True + + # Test setting value explicitly + ably = await TestApp.get_ably_rest(idempotent_rest_publishing=True) + assert ably.options.idempotent_rest_publishing is True + await ably.close() + + ably = await TestApp.get_ably_rest(idempotent_rest_publishing=False) + assert ably.options.idempotent_rest_publishing is False + await ably.close() + + # RSL1j + @dont_vary_protocol + async def test_message_serialization(self): + channel = self.get_channel() + + data = { + 'name': 'name', + 'data': 'data', + 'client_id': 'client_id', + 'extras': {}, + 'id': 'foobar', + } + message = Message(**data) + request_body = channel._Channel__publish_request_body(messages=[message]) + input_keys = {case.snake_to_camel(x) for x in data.keys()} + assert input_keys - set(request_body) == set() + + # RSL1k1 + @dont_vary_protocol + def test_idempotent_library_generated(self): + channel = self.ably_idempotent.channels[self.get_channel_name()] + + message = Message('name', 'data') + request_body = channel._Channel__publish_request_body(messages=[message]) + base_id, serial = request_body['id'].split(':') + assert len(base64.b64decode(base_id)) >= 9 + assert serial == '0' + + # RSL1k2 + @dont_vary_protocol + def test_idempotent_client_supplied(self): + channel = self.ably_idempotent.channels[self.get_channel_name()] + + message = Message('name', 'data', id='foobar') + request_body = channel._Channel__publish_request_body(messages=[message]) + assert request_body['id'] == 'foobar' + + # RSL1k3 + @dont_vary_protocol + def test_idempotent_mixed_ids(self): + channel = self.ably_idempotent.channels[self.get_channel_name()] + + messages = [ + Message('name', 'data', id='foobar'), + Message('name', 'data'), + ] + request_body = channel._Channel__publish_request_body(messages=messages) + assert request_body[0]['id'] == 'foobar' + assert 'id' not in request_body[1] + + def get_ably_rest(self, *args, **kwargs): + kwargs['use_binary_protocol'] = self.use_binary_protocol + return TestApp.get_ably_rest(*args, **kwargs) + + # RSL1k4 + async def test_idempotent_library_generated_retry(self): + test_vars = await TestApp.get_test_vars() + ably = await self.get_ably_rest(idempotent_rest_publishing=True, fallback_hosts=[test_vars["host"]] * 3) + channel = ably.channels[self.get_channel_name()] + + state = {'failures': 0} + client = httpx.AsyncClient(http2=True) + send = client.send + + async def side_effect(*args, **kwargs): + x = await send(args[1]) + if state['failures'] < 2: + state['failures'] += 1 + raise Exception('faked exception') + return x + + messages = [Message('name1', 'data1')] + with mock.patch('httpx.AsyncClient.send', side_effect=side_effect, autospec=True): + await channel.publish(messages=messages) + + assert state['failures'] == 2 + history = await channel.history() + assert len(history.items) == 1 + await client.aclose() + await ably.close() + + # RSL1k5 + async def test_idempotent_client_supplied_publish(self): + ably = await self.get_ably_rest(idempotent_rest_publishing=True) + channel = ably.channels[self.get_channel_name()] + + messages = [Message('name1', 'data1', id='foobar')] + await channel.publish(messages=messages) + await channel.publish(messages=messages) + await channel.publish(messages=messages) + history = await channel.history() + assert len(history.items) == 1 + await ably.close() diff --git a/test/ably/rest/restchannels_test.py b/test/ably/rest/restchannels_test.py new file mode 100644 index 00000000..b5e59957 --- /dev/null +++ b/test/ably/rest/restchannels_test.py @@ -0,0 +1,90 @@ +from collections.abc import Iterable + +import pytest + +from ably import AblyException +from ably.rest.channel import Channel, Channels, Presence +from ably.util.crypto import generate_random_key +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +# makes no request, no need to use different protocols +class TestChannels(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest() + yield + await self.ably.close() + + def test_rest_channels_attr(self): + assert hasattr(self.ably, 'channels') + assert isinstance(self.ably.channels, Channels) + + def test_channels_get_returns_new_or_existing(self): + channel = self.ably.channels.get('new_channel') + assert isinstance(channel, Channel) + channel_same = self.ably.channels.get('new_channel') + assert channel is channel_same + + def test_channels_get_returns_new_with_options(self): + key = generate_random_key() + channel = self.ably.channels.get('new_channel', cipher={'key': key}) + assert isinstance(channel, Channel) + assert channel.cipher.secret_key is key + + def test_channels_get_updates_existing_with_options(self): + key = generate_random_key() + channel = self.ably.channels.get('new_channel', cipher={'key': key}) + assert channel.cipher is not None + + channel_same = self.ably.channels.get('new_channel', cipher=None) + assert channel is channel_same + assert channel.cipher is None + + def test_channels_get_doesnt_updates_existing_with_none_options(self): + key = generate_random_key() + channel = self.ably.channels.get('new_channel', cipher={'key': key}) + assert channel.cipher is not None + + channel_same = self.ably.channels.get('new_channel') + assert channel is channel_same + assert channel.cipher is not None + + def test_channels_in(self): + assert 'new_channel' not in self.ably.channels + self.ably.channels.get('new_channel') + new_channel_2 = self.ably.channels.get('new_channel_2') + assert 'new_channel' in self.ably.channels + assert new_channel_2 in self.ably.channels + + def test_channels_iteration(self): + channel_names = [f'channel_{i}' for i in range(5)] + [self.ably.channels.get(name) for name in channel_names] + + assert isinstance(self.ably.channels, Iterable) + for name, channel in zip(channel_names, self.ably.channels): + assert isinstance(channel, Channel) + assert name == channel.name + + # RSN4a, RSN4b + def test_channels_release(self): + self.ably.channels.get('new_channel') + self.ably.channels.release('new_channel') + self.ably.channels.release('new_channel') + + def test_channel_has_presence(self): + channel = self.ably.channels.get('new_channnel') + assert channel.presence + assert isinstance(channel.presence, Presence) + + async def test_without_permissions(self): + key = self.test_vars["keys"][2] + ably = await TestApp.get_ably_rest(key=key["key_str"]) + with pytest.raises(AblyException) as excinfo: + await ably.channels['test_publish_without_permission'].publish('foo', 'woop') + + assert 40160 == excinfo.value.code + await ably.close() diff --git a/test/ably/rest/restchannelstatus_test.py b/test/ably/rest/restchannelstatus_test.py new file mode 100644 index 00000000..cb455362 --- /dev/null +++ b/test/ably/rest/restchannelstatus_test.py @@ -0,0 +1,49 @@ +import logging + +import pytest + +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass + +log = logging.getLogger(__name__) + + +class TestRestChannelStatus(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + async def test_channel_status(self): + channel_name = self.get_channel_name('test_channel_status') + channel = self.ably.channels[channel_name] + + channel_status = await channel.status() + + assert channel_status is not None, "Expected non-None channel_status" + assert channel_name == channel_status.channel_id, "Expected channel name to match" + assert channel_status.status.is_active is True, "Expected is_active to be True" + assert isinstance(channel_status.status.occupancy.metrics.publishers, int) and\ + channel_status.status.occupancy.metrics.publishers >= 0,\ + "Expected publishers to be a non-negative int" + assert isinstance(channel_status.status.occupancy.metrics.connections, int) and\ + channel_status.status.occupancy.metrics.connections >= 0,\ + "Expected connections to be a non-negative int" + assert isinstance(channel_status.status.occupancy.metrics.subscribers, int) and\ + channel_status.status.occupancy.metrics.subscribers >= 0,\ + "Expected subscribers to be a non-negative int" + assert isinstance(channel_status.status.occupancy.metrics.presence_members, int) and\ + channel_status.status.occupancy.metrics.presence_members >= 0,\ + "Expected presence_members to be a non-negative int" + assert isinstance(channel_status.status.occupancy.metrics.presence_connections, int) and\ + channel_status.status.occupancy.metrics.presence_connections >= 0,\ + "Expected presence_connections to be a non-negative int" + assert isinstance(channel_status.status.occupancy.metrics.presence_subscribers, int) and\ + channel_status.status.occupancy.metrics.presence_subscribers >= 0,\ + "Expected presence_subscribers to be a non-negative int" diff --git a/test/ably/rest/restcrypto_test.py b/test/ably/rest/restcrypto_test.py new file mode 100644 index 00000000..94812b29 --- /dev/null +++ b/test/ably/rest/restcrypto_test.py @@ -0,0 +1,263 @@ +import base64 +import json +import logging +import os + +import pytest +from Crypto import Random + +from ably import AblyException +from ably.types.message import Message +from ably.util.crypto import CipherParams, generate_random_key, get_cipher, get_default_params +from test.ably import utils +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, BaseTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + +log = logging.getLogger(__name__) + + +class TestRestCrypto(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest() + self.ably2 = await TestApp.get_ably_rest() + yield + await self.ably.close() + await self.ably2.close() + + def per_protocol_setup(self, use_binary_protocol): + # This will be called every test that vary by protocol for each protocol + self.ably.options.use_binary_protocol = use_binary_protocol + self.ably2.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + @dont_vary_protocol + def test_cbc_channel_cipher(self): + key = ( + b'\x93\xe3\x5c\xc9\x77\x53\xfd\x1a' + b'\x79\xb4\xd8\x84\xe7\xdc\xfd\xdf') + + iv = ( + b'\x28\x4c\xe4\x8d\x4b\xdc\x9d\x42' + b'\x8a\x77\x6b\x53\x2d\xc7\xb5\xc0') + + log.debug(f"KEY_LEN: {len(key)}") + log.debug(f"IV_LEN: {len(iv)}") + cipher = get_cipher({'key': key, 'iv': iv}) + + plaintext = b"The quick brown fox" + expected_ciphertext = ( + b'\x28\x4c\xe4\x8d\x4b\xdc\x9d\x42' + b'\x8a\x77\x6b\x53\x2d\xc7\xb5\xc0' + b'\x83\x5c\xcf\xce\x0c\xfd\xbe\x37' + b'\xb7\x92\x12\x04\x1d\x45\x68\xa4' + b'\xdf\x7f\x6e\x38\x17\x4a\xff\x50' + b'\x73\x23\xbb\xca\x16\xb0\xe2\x84') + + actual_ciphertext = cipher.encrypt(plaintext) + + assert expected_ciphertext == actual_ciphertext + + async def test_crypto_publish(self): + channel_name = self.get_channel_name('persisted:crypto_publish_text') + publish0 = self.ably.channels.get(channel_name, cipher={'key': generate_random_key()}) + + await publish0.publish("publish3", "This is a string message payload") + await publish0.publish("publish4", b"This is a byte[] message payload") + await publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) + await publish0.publish("publish6", ["This is a JSONArray message payload"]) + + history = await publish0.history() + messages = history.items + assert messages is not None, "Expected non-None messages" + assert 4 == len(messages), "Expected 4 messages" + + message_contents = {m.name: m.data for m in messages} + log.debug(f"message_contents: {str(message_contents)}") + + assert "This is a string message payload" == message_contents["publish3"],\ + "Expect publish3 to be expected String)" + + assert b"This is a byte[] message payload" == message_contents["publish4"],\ + "Expect publish4 to be expected byte[]. Actual: {}".format(str(message_contents['publish4'])) + + assert {"test": "This is a JSONObject message payload"} == message_contents["publish5"],\ + "Expect publish5 to be expected JSONObject" + + assert ["This is a JSONArray message payload"] == message_contents["publish6"],\ + "Expect publish6 to be expected JSONObject" + + async def test_crypto_publish_256(self): + rndfile = Random.new() + key = rndfile.read(32) + channel_name = 'persisted:crypto_publish_text_256' + channel_name += '_bin' if self.use_binary_protocol else '_text' + + publish0 = self.ably.channels.get(channel_name, cipher={'key': key}) + + await publish0.publish("publish3", "This is a string message payload") + await publish0.publish("publish4", b"This is a byte[] message payload") + await publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) + await publish0.publish("publish6", ["This is a JSONArray message payload"]) + + history = await publish0.history() + messages = history.items + assert messages is not None, "Expected non-None messages" + assert 4 == len(messages), "Expected 4 messages" + + message_contents = {m.name: m.data for m in messages} + log.debug(f"message_contents: {str(message_contents)}") + + assert "This is a string message payload" == message_contents["publish3"],\ + "Expect publish3 to be expected String)" + + assert b"This is a byte[] message payload" == message_contents["publish4"],\ + "Expect publish4 to be expected byte[]. Actual: {}".format(str(message_contents['publish4'])) + + assert {"test": "This is a JSONObject message payload"} == message_contents["publish5"],\ + "Expect publish5 to be expected JSONObject" + + assert ["This is a JSONArray message payload"] == message_contents["publish6"],\ + "Expect publish6 to be expected JSONObject" + + async def test_crypto_publish_key_mismatch(self): + channel_name = self.get_channel_name('persisted:crypto_publish_key_mismatch') + + publish0 = self.ably.channels.get(channel_name, cipher={'key': generate_random_key()}) + + await publish0.publish("publish3", "This is a string message payload") + await publish0.publish("publish4", b"This is a byte[] message payload") + await publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) + await publish0.publish("publish6", ["This is a JSONArray message payload"]) + + rx_channel = self.ably2.channels.get(channel_name, cipher={'key': generate_random_key()}) + + with pytest.raises(AblyException) as excinfo: + await rx_channel.history() + + message = excinfo.value.message + assert 'invalid-padding' == message or "codec can't decode" in message + + async def test_crypto_send_unencrypted(self): + channel_name = self.get_channel_name('persisted:crypto_send_unencrypted') + publish0 = self.ably.channels[channel_name] + + await publish0.publish("publish3", "This is a string message payload") + await publish0.publish("publish4", b"This is a byte[] message payload") + await publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) + await publish0.publish("publish6", ["This is a JSONArray message payload"]) + + rx_channel = self.ably2.channels.get(channel_name, cipher={'key': generate_random_key()}) + + history = await rx_channel.history() + messages = history.items + assert messages is not None, "Expected non-None messages" + assert 4 == len(messages), "Expected 4 messages" + + message_contents = {m.name: m.data for m in messages} + log.debug(f"message_contents: {str(message_contents)}") + + assert "This is a string message payload" == message_contents["publish3"],\ + "Expect publish3 to be expected String" + + assert b"This is a byte[] message payload" == message_contents["publish4"],\ + "Expect publish4 to be expected byte[]. Actual: {}".format(str(message_contents['publish4'])) + + assert {"test": "This is a JSONObject message payload"} == message_contents["publish5"],\ + "Expect publish5 to be expected JSONObject" + + assert ["This is a JSONArray message payload"] == message_contents["publish6"],\ + "Expect publish6 to be expected JSONObject" + + async def test_crypto_encrypted_unhandled(self): + channel_name = self.get_channel_name('persisted:crypto_send_encrypted_unhandled') + key = b'0123456789abcdef' + data = 'foobar' + publish0 = self.ably.channels.get(channel_name, cipher={'key': key}) + + await publish0.publish("publish0", data) + + rx_channel = self.ably2.channels[channel_name] + history = await rx_channel.history() + message = history.items[0] + cipher = get_cipher(get_default_params({'key': key})) + assert cipher.decrypt(message.data).decode() == data + assert message.encoding == 'utf-8/cipher+aes-128-cbc' + + @dont_vary_protocol + def test_cipher_params(self): + params = CipherParams(secret_key='0123456789abcdef') + assert params.algorithm == 'AES' + assert params.mode == 'CBC' + assert params.key_length == 128 + + params = CipherParams(secret_key='0123456789abcdef' * 2) + assert params.algorithm == 'AES' + assert params.mode == 'CBC' + assert params.key_length == 256 + + +class AbstractTestCryptoWithFixture: + + @pytest.fixture(autouse=True) + def setUpClass(self): + resources_path = os.path.join(utils.get_submodule_dir(__file__), 'test-resources', self.fixture_file) + with open(resources_path) as f: + self.fixture = json.loads(f.read()) + self.params = { + 'secret_key': base64.b64decode(self.fixture['key'].encode('ascii')), + 'mode': self.fixture['mode'], + 'algorithm': self.fixture['algorithm'], + 'iv': base64.b64decode(self.fixture['iv'].encode('ascii')), + } + self.cipher_params = CipherParams(**self.params) + self.cipher = get_cipher(self.cipher_params) + self.items = self.fixture['items'] + + def get_encoded(self, encoded_item): + if encoded_item.get('encoding') == 'base64': + return base64.b64decode(encoded_item['data'].encode('ascii')) + elif encoded_item.get('encoding') == 'json': + return json.loads(encoded_item['data']) + return encoded_item['data'] + + # TM3 + def test_decode(self): + for item in self.items: + assert item['encoded']['name'] == item['encrypted']['name'] + message = Message.from_encoded(item['encrypted'], self.cipher) + assert message.encoding == '' + expected_data = self.get_encoded(item['encoded']) + assert expected_data == message.data + + # TM3 + def test_decode_array(self): + items_encrypted = [item['encrypted'] for item in self.items] + messages = Message.from_encoded_array(items_encrypted, self.cipher) + for i, message in enumerate(messages): + assert message.encoding == '' + expected_data = self.get_encoded(self.items[i]['encoded']) + assert expected_data == message.data + + def test_encode(self): + for item in self.items: + # need to reset iv + self.cipher_params = CipherParams(**self.params) + self.cipher = get_cipher(self.cipher_params) + data = self.get_encoded(item['encoded']) + expected = item['encrypted'] + message = Message(item['encoded']['name'], data) + message.encrypt(self.cipher) + as_dict = message.as_dict() + assert as_dict['data'] == expected['data'] + assert as_dict['encoding'] == expected['encoding'] + + +class TestCryptoWithFixture128(AbstractTestCryptoWithFixture, BaseTestCase): + fixture_file = 'crypto-data-128.json' + + +class TestCryptoWithFixture256(AbstractTestCryptoWithFixture, BaseTestCase): + fixture_file = 'crypto-data-256.json' diff --git a/test/ably/rest/resthttp_test.py b/test/ably/rest/resthttp_test.py new file mode 100644 index 00000000..01bc6ba6 --- /dev/null +++ b/test/ably/rest/resthttp_test.py @@ -0,0 +1,221 @@ +import base64 +import re +import time +from unittest import mock +from urllib.parse import urljoin + +import httpx +import pytest +import respx +from httpx import Response + +from ably import AblyRest +from ably.transport.defaults import Defaults +from ably.types.options import Options +from ably.util.exceptions import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +class TestRestHttp(BaseAsyncTestCase): + async def test_max_retry_attempts_and_timeouts_defaults(self): + ably = AblyRest(token="foo") + assert 'http_open_timeout' in ably.http.CONNECTION_RETRY_DEFAULTS + assert 'http_request_timeout' in ably.http.CONNECTION_RETRY_DEFAULTS + + with mock.patch('httpx.AsyncClient.send', side_effect=httpx.RequestError('')) as send_mock: + with pytest.raises(httpx.RequestError): + await ably.http.make_request('GET', '/', version=Defaults.protocol_version, skip_auth=True) + + assert send_mock.call_count == Defaults.http_max_retry_count + assert send_mock.call_args == mock.call(mock.ANY) + await ably.close() + + async def test_cumulative_timeout(self): + ably = AblyRest(token="foo") + assert 'http_max_retry_duration' in ably.http.CONNECTION_RETRY_DEFAULTS + + ably.options.http_max_retry_duration = 0.5 + + def sleep_and_raise(*args, **kwargs): + time.sleep(0.51) + raise httpx.TimeoutException('timeout') + + with mock.patch('httpx.AsyncClient.send', side_effect=sleep_and_raise) as send_mock: + with pytest.raises(httpx.TimeoutException): + await ably.http.make_request('GET', '/', skip_auth=True) + + assert send_mock.call_count == 1 + await ably.close() + + async def test_host_fallback(self): + ably = AblyRest(token="foo") + + def make_url(host): + base_url = f"{ably.http.preferred_scheme}://{host}:{ably.http.preferred_port}" + return urljoin(base_url, '/') + + with mock.patch('httpx.Request', wraps=httpx.Request) as request_mock: + with mock.patch('httpx.AsyncClient.send', side_effect=httpx.RequestError('')) as send_mock: + with pytest.raises(httpx.RequestError): + await ably.http.make_request('GET', '/', skip_auth=True) + + assert send_mock.call_count == Defaults.http_max_retry_count + + expected_urls_set = { + make_url(host) + for host in Options(http_max_retry_count=10).get_hosts() + } + for ((_, url), _) in request_mock.call_args_list: + assert url in expected_urls_set + expected_urls_set.remove(url) + + expected_hosts_set = set(Options(http_max_retry_count=10).get_hosts()) + for (prep_request_tuple, _) in send_mock.call_args_list: + assert prep_request_tuple[0].headers.get('host') in expected_hosts_set + expected_hosts_set.remove(prep_request_tuple[0].headers.get('host')) + await ably.close() + + @respx.mock + async def test_no_host_fallback_nor_retries_if_custom_host(self): + custom_host = 'example.org' + ably = AblyRest(token="foo", endpoint=custom_host) + + mock_route = respx.get("https://example.org").mock(side_effect=httpx.RequestError('')) + + with pytest.raises(httpx.RequestError): + await ably.http.make_request('GET', '/', skip_auth=True) + + assert mock_route.call_count == 1 + assert respx.calls.call_count == 1 + + await ably.close() + + # RSC15f + async def test_cached_fallback(self): + timeout = 2000 + ably = await TestApp.get_ably_rest(fallback_retry_timeout=timeout) + host = ably.options.get_host() + + state = {'errors': 0} + client = httpx.AsyncClient(http2=True) + send = client.send + + async def side_effect(*args, **kwargs): + if args[1].url.host == host: + state['errors'] += 1 + raise RuntimeError + return await send(args[1]) + + with mock.patch('httpx.AsyncClient.send', side_effect=side_effect, autospec=True): + # The main host is called and there's an error + await ably.time() + assert state['errors'] == 1 + + # The cached host is used: no error + await ably.time() + await ably.time() + await ably.time() + assert state['errors'] == 1 + + # The cached host has expired, we've an error again + time.sleep(timeout / 1000.0) + await ably.time() + assert state['errors'] == 2 + + await client.aclose() + await ably.close() + + @respx.mock + async def test_no_retry_if_not_500_to_599_http_code(self): + default_host = Options().get_host() + ably = AblyRest(token="foo") + + default_url = f"{ably.http.preferred_scheme}://{default_host}:{ably.http.preferred_port}/" + + mock_response = httpx.Response(600, json={'message': "", 'status_code': 600, 'code': 50500}) + + mock_route = respx.get(default_url).mock(return_value=mock_response) + + with pytest.raises(AblyException): + await ably.http.make_request('GET', '/', skip_auth=True) + + assert mock_route.call_count == 1 + assert respx.calls.call_count == 1 + + await ably.close() + + @respx.mock + async def test_500_errors(self): + """ + Raise error if all the servers reply with a 5xx error. + https://github.com/ably/ably-python/issues/160 + """ + + ably = AblyRest(token="foo") + + mock_request = respx.route().mock(return_value=httpx.Response(500, text="Internal Server Error")) + + with pytest.raises(AblyException): + await ably.http.make_request('GET', '/', skip_auth=True) + + assert mock_request.call_count == 3 + + await ably.close() + + def test_custom_http_timeouts(self): + ably = AblyRest( + token="foo", http_request_timeout=30, http_open_timeout=8, + http_max_retry_count=6, http_max_retry_duration=20) + + assert ably.http.http_request_timeout == 30 + assert ably.http.http_open_timeout == 8 + assert ably.http.http_max_retry_count == 6 + assert ably.http.http_max_retry_duration == 20 + + # RSC7a, RSC7b + async def test_request_headers(self): + ably = await TestApp.get_ably_rest() + r = await ably.http.make_request('HEAD', '/time', skip_auth=True) + + # API + assert 'X-Ably-Version' in r.request.headers + assert r.request.headers['X-Ably-Version'] == '5' + + # Agent + assert 'Ably-Agent' in r.request.headers + expr = r"^ably-python\/\d+.\d+.\d+(-beta\.\d+)? python\/\d.\d+.\d+$" + assert re.search(expr, r.request.headers['Ably-Agent']) + await ably.close() + + # RSC7c + async def test_add_request_ids(self): + # With request id + ably = await TestApp.get_ably_rest(add_request_ids=True) + r = await ably.http.make_request('HEAD', '/time', skip_auth=True) + assert 'request_id' in r.request.url.params + request_id1 = r.request.url.params['request_id'] + assert len(base64.urlsafe_b64decode(request_id1)) == 12 + + # With request id and new request + r = await ably.http.make_request('HEAD', '/time', skip_auth=True) + assert 'request_id' in r.request.url.params + request_id2 = r.request.url.params['request_id'] + assert len(base64.urlsafe_b64decode(request_id2)) == 12 + assert request_id1 != request_id2 + await ably.close() + + # With request id and new request + ably = await TestApp.get_ably_rest() + r = await ably.http.make_request('HEAD', '/time', skip_auth=True) + assert 'request_id' not in r.request.url.params + await ably.close() + + async def test_request_over_http2(self): + url = 'https://www.google.com' + respx.get(url).mock(return_value=Response(status_code=200)) + + ably = await TestApp.get_ably_rest(endpoint=url) + r = await ably.http.make_request('GET', url, skip_auth=True) + assert r.http_version == 'HTTP/2' + await ably.close() diff --git a/test/ably/rest/restinit_test.py b/test/ably/rest/restinit_test.py new file mode 100644 index 00000000..25e7c5af --- /dev/null +++ b/test/ably/rest/restinit_test.py @@ -0,0 +1,227 @@ +from unittest.mock import patch + +import pytest +from httpx import AsyncClient + +from ably import AblyException, AblyRest +from ably.transport.defaults import Defaults +from ably.types.tokendetails import TokenDetails +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + + +class TestRestInit(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + + @dont_vary_protocol + def test_key_only(self): + ably = AblyRest(key=self.test_vars["keys"][0]["key_str"]) + assert ably.options.key_name == self.test_vars["keys"][0]["key_name"], "Key name does not match" + assert ably.options.key_secret == self.test_vars["keys"][0]["key_secret"], "Key secret does not match" + + def per_protocol_setup(self, use_binary_protocol): + self.use_binary_protocol = use_binary_protocol + + @dont_vary_protocol + def test_with_token(self): + ably = AblyRest(token="foo") + assert ably.options.auth_token == "foo", "Token not set at options" + + @dont_vary_protocol + def test_with_token_details(self): + td = TokenDetails() + ably = AblyRest(token_details=td) + assert ably.options.token_details is td + + @dont_vary_protocol + def test_with_options_token_callback(self): + def token_callback(**params): + return "this_is_not_really_a_token_request" + AblyRest(auth_callback=token_callback) + + @dont_vary_protocol + def test_ambiguous_key_raises_value_error(self): + with pytest.raises(ValueError, match="mutually exclusive"): + AblyRest(key=self.test_vars["keys"][0]["key_str"], key_name='x') + with pytest.raises(ValueError, match="mutually exclusive"): + AblyRest(key=self.test_vars["keys"][0]["key_str"], key_secret='x') + + @dont_vary_protocol + def test_with_key_name_or_secret_only(self): + with pytest.raises(ValueError, match="key is missing"): + AblyRest(key_name='x') + with pytest.raises(ValueError, match="key is missing"): + AblyRest(key_secret='x') + + @dont_vary_protocol + def test_with_key_name_and_secret(self): + ably = AblyRest(key_name="foo", key_secret="bar") + assert ably.options.key_name == "foo", "Key name does not match" + assert ably.options.key_secret == "bar", "Key secret does not match" + + @dont_vary_protocol + def test_with_options_auth_url(self): + AblyRest(auth_url='not_really_an_url') + + # RSC11 + @dont_vary_protocol + def test_rest_host_and_environment(self): + # endpoint host + ably = AblyRest(token='foo', endpoint="some.other.host") + assert "some.other.host" == ably.options.get_host(), "Unexpected host mismatch" + + # endpoint: main + ably = AblyRest(token='foo', endpoint="main") + host = ably.options.get_host() + assert "main.realtime.ably.net" == host, f"Unexpected host mismatch {host}" + + # endpoint: other + ably = AblyRest(token='foo', endpoint="nonprod:sandbox") + host = ably.options.get_host() + assert "sandbox.realtime.ably-nonprod.net" == host, f"Unexpected host mismatch {host}" + + # both, as per #TO3k2 + with pytest.raises(AblyException): + ably = AblyRest(token='foo', rest_host="some.other.host", + endpoint="some.other.environment") + + # RSC15 + @dont_vary_protocol + def test_fallback_hosts(self): + # Specify the fallback_hosts (RSC15a) + fallback_hosts = [ + ['fallback1.com', 'fallback2.com'], + [], + ] + + # Fallback hosts specified (RSC15g1) + for aux in fallback_hosts: + ably = AblyRest(token='foo', fallback_hosts=aux) + assert sorted(aux) == sorted(ably.options.get_fallback_hosts()) + + # Specify endpoint (RSC15g2) + ably = AblyRest(token='foo', endpoint='nonprod:sandbox', http_max_retry_count=10) + assert sorted(Defaults.get_fallback_hosts('nonprod:sandbox')) == sorted( + ably.options.get_fallback_hosts()) + + # Fallback hosts and endpoint not specified (RSC15g3) + ably = AblyRest(token='foo', http_max_retry_count=10) + assert sorted(Defaults.get_fallback_hosts()) == sorted(ably.options.get_fallback_hosts()) + + # RSC15f + ably = AblyRest(token='foo') + assert 600000 == ably.options.fallback_retry_timeout + ably = AblyRest(token='foo', fallback_retry_timeout=1000) + assert 1000 == ably.options.fallback_retry_timeout + + @dont_vary_protocol + def test_specified_host(self): + ably = AblyRest(token='foo', endpoint="some.other.host") + assert "some.other.host" == ably.options.get_host(), "Unexpected host mismatch" + + @dont_vary_protocol + def test_specified_port(self): + ably = AblyRest(token='foo', port=9998, tls_port=9999) + assert 9999 == Defaults.get_port(ably.options),\ + f"Unexpected port mismatch. Expected: 9999. Actual: {ably.options.tls_port}" + + @dont_vary_protocol + def test_specified_non_tls_port(self): + ably = AblyRest(token='foo', port=9998, tls=False) + assert 9998 == Defaults.get_port(ably.options),\ + f"Unexpected port mismatch. Expected: 9999. Actual: {ably.options.tls_port}" + + @dont_vary_protocol + def test_specified_tls_port(self): + ably = AblyRest(token='foo', tls_port=9999, tls=True) + assert 9999 == Defaults.get_port(ably.options),\ + f"Unexpected port mismatch. Expected: 9999. Actual: {ably.options.tls_port}" + + @dont_vary_protocol + def test_tls_defaults_to_true(self): + ably = AblyRest(token='foo') + assert ably.options.tls, "Expected encryption to default to true" + assert Defaults.tls_port == Defaults.get_port(ably.options), "Unexpected port mismatch" + + @dont_vary_protocol + def test_tls_can_be_disabled(self): + ably = AblyRest(token='foo', tls=False) + assert not ably.options.tls, "Expected encryption to be False" + assert Defaults.port == Defaults.get_port(ably.options), "Unexpected port mismatch" + + @dont_vary_protocol + def test_with_no_params(self): + with pytest.raises(ValueError): + AblyRest() + + @dont_vary_protocol + def test_with_no_auth_params(self): + with pytest.raises(ValueError): + AblyRest(port=111) + + # RSA10k + async def test_query_time_param(self): + ably = await TestApp.get_ably_rest(query_time=True, + use_binary_protocol=self.use_binary_protocol) + + timestamp = ably.auth._timestamp + with patch('ably.rest.rest.AblyRest.time', wraps=ably.time) as server_time,\ + patch('ably.rest.auth.Auth._timestamp', wraps=timestamp) as local_time: + await ably.auth.request_token() + assert local_time.call_count == 1 + assert server_time.call_count == 1 + await ably.auth.request_token() + assert local_time.call_count == 2 + assert server_time.call_count == 1 + + await ably.close() + + @dont_vary_protocol + def test_requests_over_https_production(self): + ably = AblyRest(token='token') + assert 'https://main.realtime.ably.net' == f'{ably.http.preferred_scheme}://{ably.http.preferred_host}' + assert ably.http.preferred_port == 443 + + @dont_vary_protocol + def test_requests_over_http_production(self): + ably = AblyRest(token='token', tls=False) + assert 'http://main.realtime.ably.net' == f'{ably.http.preferred_scheme}://{ ably.http.preferred_host}' + assert ably.http.preferred_port == 80 + + @dont_vary_protocol + async def test_request_basic_auth_over_http_fails(self): + ably = AblyRest(key_secret='foo', key_name='bar', tls=False) + + with pytest.raises(AblyException) as excinfo: + await ably.http.get('/time', skip_auth=False) + + assert 401 == excinfo.value.status_code + assert 40103 == excinfo.value.code + assert 'Cannot use Basic Auth over non-TLS connections' == excinfo.value.message + + @dont_vary_protocol + async def test_environment(self): + ably = AblyRest(token='token', endpoint='custom') + with patch.object(AsyncClient, 'send', wraps=ably.http._Http__client.send) as get_mock: + try: + await ably.time() + except AblyException: + pass + request = get_mock.call_args_list[0][0][0] + assert request.url == 'https://custom.realtime.ably.net:443/time' + + await ably.close() + + @dont_vary_protocol + def test_accepts_custom_http_timeouts(self): + ably = AblyRest( + token="foo", http_request_timeout=30, http_open_timeout=8, + http_max_retry_count=6, http_max_retry_duration=20) + + assert ably.options.http_request_timeout == 30 + assert ably.options.http_open_timeout == 8 + assert ably.options.http_max_retry_count == 6 + assert ably.options.http_max_retry_duration == 20 diff --git a/test/ably/rest/restpaginatedresult_test.py b/test/ably/rest/restpaginatedresult_test.py new file mode 100644 index 00000000..9aa85689 --- /dev/null +++ b/test/ably/rest/restpaginatedresult_test.py @@ -0,0 +1,91 @@ +import pytest +import respx +from httpx import Response + +from ably.http.paginatedresult import PaginatedResult +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase + + +class TestPaginatedResult(BaseAsyncTestCase): + + def get_response_callback(self, headers, body, status): + def callback(request): + res = request.url.params.get('page') + if res: + return Response( + status_code=status, + headers=headers, + content=f'[{{"page": {int(res)}}}]' + ) + + return Response( + status_code=status, + headers=headers, + content=body + ) + + return callback + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest(use_binary_protocol=False) + # Mocked responses + # without specific headers + self.mocked_api = respx.mock(base_url='http://main.realtime.ably.net') + self.ch1_route = self.mocked_api.get('/channels/channel_name/ch1') + self.ch1_route.return_value = Response( + headers={'content-type': 'application/json'}, + status_code=200, + content='[{"id": 0}, {"id": 1}]', + ) + # with headers + self.ch2_route = self.mocked_api.get('/channels/channel_name/ch2') + self.ch2_route.side_effect = self.get_response_callback( + headers={ + 'content-type': 'application/json', + 'link': + '; rel="first",' + ' ; rel="next"' + }, + body='[{"id": 0}, {"id": 1}]', + status=200 + ) + # start intercepting requests + self.mocked_api.start() + + self.paginated_result = await PaginatedResult.paginated_query( + self.ably.http, + url='http://main.realtime.ably.net/channels/channel_name/ch1', + response_processor=lambda response: response.to_native()) + self.paginated_result_with_headers = await PaginatedResult.paginated_query( + self.ably.http, + url='http://main.realtime.ably.net/channels/channel_name/ch2', + response_processor=lambda response: response.to_native()) + yield + self.mocked_api.stop() + self.mocked_api.reset() + await self.ably.close() + + def test_items(self): + assert len(self.paginated_result.items) == 2 + + async def test_with_no_headers(self): + assert await self.paginated_result.first() is None + assert await self.paginated_result.next() is None + assert self.paginated_result.is_last() + + def test_with_next(self): + pag = self.paginated_result_with_headers + assert pag.has_next() + assert not pag.is_last() + + async def test_first(self): + pag = self.paginated_result_with_headers + pag = await pag.first() + assert pag.items[0]['page'] == 1 + + async def test_next(self): + pag = self.paginated_result_with_headers + pag = await pag.next() + assert pag.items[0]['page'] == 2 diff --git a/test/ably/rest/restpresence_test.py b/test/ably/rest/restpresence_test.py new file mode 100644 index 00000000..8767b0c6 --- /dev/null +++ b/test/ably/rest/restpresence_test.py @@ -0,0 +1,212 @@ +from datetime import datetime, timedelta + +import pytest +import respx + +from ably.http.paginatedresult import PaginatedResult +from ably.types.presence import PresenceMessage +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + + +class TestPresence(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest() + self.channel = self.ably.channels.get('persisted:presence_fixtures') + self.ably.options.use_binary_protocol = True + yield + self.ably.channels.release('persisted:presence_fixtures') + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + + async def test_channel_presence_get(self): + presence_page = await self.channel.presence.get() + assert isinstance(presence_page, PaginatedResult) + assert len(presence_page.items) == 6 + member = presence_page.items[0] + assert isinstance(member, PresenceMessage) + assert member.action + assert member.id + assert member.client_id + assert member.data + assert member.connection_id + assert member.timestamp + + async def test_channel_presence_history(self): + presence_history = await self.channel.presence.history() + assert isinstance(presence_history, PaginatedResult) + assert len(presence_history.items) == 6 + member = presence_history.items[0] + assert isinstance(member, PresenceMessage) + assert member.action + assert member.id + assert member.client_id + assert member.data + assert member.connection_id + assert member.timestamp + assert member.encoding + + async def test_presence_get_encoded(self): + presence_history = await self.channel.presence.history() + assert presence_history.items[-1].data == "true" + assert presence_history.items[-2].data == "24" + assert presence_history.items[-3].data == "This is a string clientData payload" + # this one doesn't have encoding field + assert presence_history.items[-4].data == '{ "test": "This is a JSONObject clientData payload"}' + assert presence_history.items[-5].data == {"example": {"json": "Object"}} + + async def test_timestamp_is_datetime(self): + presence_page = await self.channel.presence.get() + member = presence_page.items[0] + assert isinstance(member.timestamp, datetime) + + async def test_presence_message_has_correct_member_key(self): + presence_page = await self.channel.presence.get() + member = presence_page.items[0] + + assert member.member_key == f"{member.connection_id}:{member.client_id}" + + def presence_mock_url(self): + kwargs = { + 'scheme': 'https' if self.test_vars['tls'] else 'http', + 'host': self.test_vars['host'] + } + port = self.test_vars['tls_port'] if self.test_vars.get('tls') else kwargs['port'] + if port == 80: + kwargs['port_sufix'] = '' + else: + kwargs['port_sufix'] = ':' + str(port) + url = '{scheme}://{host}{port_sufix}/channels/persisted%3Apresence_fixtures/presence' + return url.format(**kwargs) + + def history_mock_url(self): + kwargs = { + 'scheme': 'https' if self.test_vars['tls'] else 'http', + 'host': self.test_vars['host'] + } + port = self.test_vars['tls_port'] if self.test_vars.get('tls') else kwargs['port'] + if port == 80: + kwargs['port_sufix'] = '' + else: + kwargs['port_sufix'] = ':' + str(port) + url = '{scheme}://{host}{port_sufix}/channels/persisted%3Apresence_fixtures/presence/history' + return url.format(**kwargs) + + @dont_vary_protocol + @respx.mock + async def test_get_presence_default_limit(self): + url = self.presence_mock_url() + self.respx_add_empty_msg_pack(url) + await self.channel.presence.get() + assert 'limit' not in respx.calls[0].request.url.params.keys() + + @dont_vary_protocol + @respx.mock + async def test_get_presence_with_limit(self): + url = self.presence_mock_url() + self.respx_add_empty_msg_pack(url) + await self.channel.presence.get(300) + assert '300' == respx.calls[0].request.url.params.get('limit') + + @dont_vary_protocol + @respx.mock + async def test_get_presence_max_limit_is_1000(self): + url = self.presence_mock_url() + self.respx_add_empty_msg_pack(url) + with pytest.raises(ValueError): + await self.channel.presence.get(5000) + + @dont_vary_protocol + @respx.mock + async def test_history_default_limit(self): + url = self.history_mock_url() + self.respx_add_empty_msg_pack(url) + await self.channel.presence.history() + assert 'limit' not in respx.calls[0].request.url.params.keys() + + @dont_vary_protocol + @respx.mock + async def test_history_with_limit(self): + url = self.history_mock_url() + self.respx_add_empty_msg_pack(url) + await self.channel.presence.history(300) + assert '300' == respx.calls[0].request.url.params.get('limit') + + @dont_vary_protocol + @respx.mock + async def test_history_with_direction(self): + url = self.history_mock_url() + self.respx_add_empty_msg_pack(url) + await self.channel.presence.history(direction='backwards') + assert 'backwards' == respx.calls[0].request.url.params.get('direction') + + @dont_vary_protocol + @respx.mock + async def test_history_max_limit_is_1000(self): + url = self.history_mock_url() + self.respx_add_empty_msg_pack(url) + with pytest.raises(ValueError): + await self.channel.presence.history(5000) + + @dont_vary_protocol + @respx.mock + async def test_with_milisecond_start_end(self): + url = self.history_mock_url() + self.respx_add_empty_msg_pack(url) + await self.channel.presence.history(start=100000, end=100001) + assert '100000' == respx.calls[0].request.url.params.get('start') + assert '100001' == respx.calls[0].request.url.params.get('end') + + @dont_vary_protocol + @respx.mock + async def test_with_timedate_startend(self): + url = self.history_mock_url() + start = datetime(2015, 8, 15, 17, 11, 44, 706539) + start_ms = 1439658704706 + end = start + timedelta(hours=1) + end_ms = start_ms + (1000 * 60 * 60) + self.respx_add_empty_msg_pack(url) + await self.channel.presence.history(start=start, end=end) + assert str(start_ms) in respx.calls[0].request.url.params.get('start') + assert str(end_ms) in respx.calls[0].request.url.params.get('end') + + @dont_vary_protocol + @respx.mock + async def test_with_start_gt_end(self): + url = self.history_mock_url() + end = datetime(2015, 8, 15, 17, 11, 44, 706539) + start = end + timedelta(hours=1) + self.respx_add_empty_msg_pack(url) + with pytest.raises(ValueError, match="'end' parameter has to be greater than or equal to 'start'"): + await self.channel.presence.history(start=start, end=end) + + +class TestPresenceCrypt(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + key = b'0123456789abcdef' + self.channel = self.ably.channels.get('persisted:presence_fixtures', cipher={'key': key}) + yield + self.ably.channels.release('persisted:presence_fixtures') + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + + async def test_presence_history_encrypted(self): + presence_history = await self.channel.presence.history() + assert presence_history.items[0].data == {'foo': 'bar'} + + async def test_presence_get_encrypted(self): + messages = await self.channel.presence.get() + messages = (msg for msg in messages.items if msg.client_id == 'client_encoded') + message = next(messages) + + assert message.data == {'foo': 'bar'} diff --git a/test/ably/rest/restpush_test.py b/test/ably/rest/restpush_test.py new file mode 100644 index 00000000..867e8b90 --- /dev/null +++ b/test/ably/rest/restpush_test.py @@ -0,0 +1,400 @@ +import itertools +import random +import string +import time + +import pytest + +from ably import AblyAuthException, AblyException, DeviceDetails, PushChannelSubscription +from ably.http.paginatedresult import PaginatedResult +from test.ably.testapp import TestApp +from test.ably.utils import ( + BaseAsyncTestCase, + VaryByProtocolTestsMetaclass, + get_random_key, + new_dict, + random_string, +) + +DEVICE_TOKEN = '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad' + + +class TestPush(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + + # Register several devices for later use + self.devices = {} + for _ in range(10): + await self.save_device() + + # Register several subscriptions for later use + self.channels = {'canpublish:test1': [], 'canpublish:test2': [], 'canpublish:test3': []} + for key, channel in zip(self.devices, itertools.cycle(self.channels)): + device = self.devices[key] + await self.save_subscription(channel, device_id=device.id) + assert len(list(itertools.chain(*self.channels.values()))) == len(self.devices) + yield + for key, channel in zip(self.devices, itertools.cycle(self.channels)): + device = self.devices[key] + await self.remove_subscription(channel, device_id=device.id) + await self.ably.push.admin.device_registrations.remove(device_id=device.id) + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + + def get_client_id(self): + return random_string(12) + + def get_device_id(self): + return random_string(26, string.ascii_uppercase + string.digits) + + def gen_device_data(self, data=None, **kw): + if data is None: + data = { + 'id': self.get_device_id(), + 'clientId': self.get_client_id(), + 'platform': random.choice(['android', 'ios']), + 'formFactor': 'phone', + 'deviceSecret': 'test-secret', + 'push': { + 'recipient': { + 'transportType': 'apns', + 'deviceToken': DEVICE_TOKEN, + } + }, + } + else: + data = data.copy() + + data.update(kw) + return data + + async def save_device(self, data=None, **kw): + """ + Helper method to register a device, to not have this code repeated + everywhere. Returns the input dict that was sent to Ably, and the + device details returned by Ably. + """ + data = self.gen_device_data(data, **kw) + device = await self.ably.push.admin.device_registrations.save(data) + self.devices[device.id] = device + return device + + async def remove_device(self, device_id): + result = await self.ably.push.admin.device_registrations.remove(device_id) + self.devices.pop(device_id, None) + return result + + async def remove_device_where(self, **kw): + remove_where = self.ably.push.admin.device_registrations.remove_where + result = await remove_where(**kw) + + aux = {'deviceId': 'id', 'clientId': 'client_id'} + for device in list(self.devices.values()): + for key, value in kw.items(): + key = aux[key] + if getattr(device, key) == value: + del self.devices[device.id] + + return result + + def get_device(self): + key = get_random_key(self.devices) + return self.devices[key] + + def get_channel(self): + key = get_random_key(self.channels) + return key, self.channels[key] + + async def save_subscription(self, channel, **kw): + """ + Helper method to register a device, to not have this code repeated + everywhere. Returns the input dict that was sent to Ably, and the + device details returned by Ably. + """ + subscription = PushChannelSubscription(channel, **kw) + subscription = await self.ably.push.admin.channel_subscriptions.save(subscription) + self.channels.setdefault(channel, []).append(subscription) + return subscription + + async def remove_subscription(self, channel, **kw): + subscription = PushChannelSubscription(channel, **kw) + subscription = await self.ably.push.admin.channel_subscriptions.remove(subscription) + return subscription + + # RSH1a + async def test_admin_publish(self): + recipient = {'clientId': 'ablyChannel'} + data = { + 'data': {'foo': 'bar'}, + } + + publish = self.ably.push.admin.publish + with pytest.raises(TypeError): + await publish('ablyChannel', data) + with pytest.raises(TypeError): + await publish(recipient, 25) + with pytest.raises(ValueError): + await publish({}, data) + with pytest.raises(ValueError): + await publish(recipient, {}) + + with pytest.raises(AblyException): + await publish(recipient, {'xxx': 5}) + + assert await publish(recipient, data) is None + + # RSH1b1 + async def test_admin_device_registrations_get(self): + get = self.ably.push.admin.device_registrations.get + + # Not found + with pytest.raises(AblyException): + await get('not-found') + + # Found + device = self.get_device() + device_details = await get(device.id) + assert device_details.id == device.id + assert device_details.platform == device.platform + assert device_details.form_factor == device.form_factor + + # RSH1b2 + async def test_admin_device_registrations_list(self): + list_devices = self.ably.push.admin.device_registrations.list + + list_response = await list_devices() + assert type(list_response) is PaginatedResult + assert type(list_response.items) is list + assert type(list_response.items[0]) is DeviceDetails + + # limit + list_response = await list_devices(limit=5000) + assert len(list_response.items) == len(self.devices) + list_response = await list_devices(limit=2) + assert len(list_response.items) == 2 + + # Filter by device id + device = self.get_device() + list_response = await list_devices(deviceId=device.id) + assert len(list_response.items) == 1 + list_response = await list_devices(deviceId=self.get_device_id()) + assert len(list_response.items) == 0 + + # Filter by client id + list_response = await list_devices(clientId=device.client_id) + assert len(list_response.items) == 1 + list_response = await list_devices(clientId=self.get_client_id()) + assert len(list_response.items) == 0 + + # RSH1b3 + async def test_admin_device_registrations_save(self): + # Create + data = self.gen_device_data() + device = await self.save_device(data) + assert type(device) is DeviceDetails + + # Update + await self.save_device(data, formFactor='tablet') + + # Invalid values + with pytest.raises(ValueError): + push = {'recipient': new_dict(data['push']['recipient'], transportType='xyz')} + await self.save_device(data, push=push) + with pytest.raises(ValueError): + await self.save_device(data, platform='native') + with pytest.raises(ValueError): + await self.save_device(data, formFactor='fridge') + + # Fail + with pytest.raises(AblyException): + await self.save_device(data, push={'color': 'red'}) + + # RSH1b4 + async def test_admin_device_registrations_remove(self): + get = self.ably.push.admin.device_registrations.get + + device = self.get_device() + + # Remove + get_response = await get(device.id) + assert get_response.id == device.id # Exists + remove_device_response = await self.remove_device(device.id) + assert remove_device_response.status_code == 204 + with pytest.raises(AblyException): # Doesn't exist + await get(device.id) + + # Remove again, it doesn't fail + remove_device_response = await self.remove_device(device.id) + assert remove_device_response.status_code == 204 + + # RSH1b5 + async def test_admin_device_registrations_remove_where(self): + get = self.ably.push.admin.device_registrations.get + + # Remove by device id + device = self.get_device() + foo_device = await get(device.id) + assert foo_device.id == device.id # Exists + remove_foo_device_response = await self.remove_device_where(deviceId=device.id) + assert remove_foo_device_response.status_code == 204 + with pytest.raises(AblyException): # Doesn't exist + await get(device.id) + + # Remove by client id + device = self.get_device() + boo_device = await get(device.id) + assert boo_device.id == device.id # Exists + remove_boo_device_response = await self.remove_device_where(clientId=device.client_id) + assert remove_boo_device_response.status_code == 204 + # Doesn't exist (Deletion is async: wait up to a few seconds before giving up) + with pytest.raises(AblyException): + for _ in range(5): + time.sleep(1) + await get(device.id) + + # Remove with no matching params + remove_boo_device_response = await self.remove_device_where(clientId=device.client_id) + assert remove_boo_device_response.status_code == 204 + + # # RSH1c1 + async def test_admin_channel_subscriptions_list(self): + list_ = self.ably.push.admin.channel_subscriptions.list + + channel, subscriptions = self.get_channel() + + list_response = await list_(channel=channel) + + assert type(list_response) is PaginatedResult + assert type(list_response.items) is list + assert type(list_response.items[0]) is PushChannelSubscription + + # limit + list_response = await list_(channel=channel, limit=2) + assert len(list_response.items) == 2 + + list_response = await list_(channel=channel, limit=5000) + assert len(list_response.items) == len(subscriptions) + + # Filter by device id + device_id = subscriptions[0].device_id + list_response = await list_(channel=channel, deviceId=device_id) + assert len(list_response.items) == 1 + assert list_response.items[0].device_id == device_id + assert list_response.items[0].channel == channel + list_response = await list_(channel=channel, deviceId=self.get_device_id()) + assert len(list_response.items) == 0 + + # Filter by client id + device = self.get_device() + list_response = await list_(channel=channel, clientId=device.client_id) + assert len(list_response.items) == 0 + + # RSH1c2 + async def test_admin_channels_list(self): + list_ = self.ably.push.admin.channel_subscriptions.list_channels + + list_response = await list_() + assert type(list_response) is PaginatedResult + assert type(list_response.items) is list + assert type(list_response.items[0]) is str + + # limit + list_response = await list_(limit=5000) + assert len(list_response.items) == len(self.channels) + list_response = await list_(limit=1) + assert len(list_response.items) == 1 + + # RSH1c3 + async def test_admin_channel_subscriptions_save(self): + save = self.ably.push.admin.channel_subscriptions.save + + # Subscribe + device = self.get_device() + channel = 'canpublish:testsave' + subscription = await self.save_subscription(channel, device_id=device.id) + assert type(subscription) is PushChannelSubscription + assert subscription.channel == channel + assert subscription.device_id == device.id + assert subscription.client_id is None + + # Failures + client_id = self.get_client_id() + with pytest.raises(ValueError): + PushChannelSubscription(channel, device_id=device.id, client_id=client_id) + + subscription = PushChannelSubscription('notallowed', device_id=device.id) + with pytest.raises(AblyAuthException): + await save(subscription) + + subscription = PushChannelSubscription(channel, device_id='notregistered') + with pytest.raises(AblyException): + await save(subscription) + + # RSH1c4 + async def test_admin_channel_subscriptions_remove(self): + save = self.ably.push.admin.channel_subscriptions.save + remove = self.ably.push.admin.channel_subscriptions.remove + list_ = self.ably.push.admin.channel_subscriptions.list + + channel = 'canpublish:testremove' + + # Subscribe device + device = self.get_device() + subscription = await save(PushChannelSubscription(channel, device_id=device.id)) + list_response = await list_(channel=channel) + assert device.id in (x.device_id for x in list_response.items) + remove_response = await remove(subscription) + assert remove_response.status_code == 204 + list_response = await list_(channel=channel) + assert device.id not in (x.device_id for x in list_response.items) + + # Subscribe client + client_id = self.get_client_id() + subscription = await save(PushChannelSubscription(channel, client_id=client_id)) + list_response = await list_(channel=channel) + assert client_id in (x.client_id for x in list_response.items) + remove_response = await remove(subscription) + assert remove_response.status_code == 204 + list_response = await list_(channel=channel) + assert client_id not in (x.client_id for x in list_response.items) + + # Remove again, it doesn't fail + remove_response = await remove(subscription) + assert remove_response.status_code == 204 + + # RSH1c5 + async def test_admin_channel_subscriptions_remove_where(self): + save = self.ably.push.admin.channel_subscriptions.save + remove = self.ably.push.admin.channel_subscriptions.remove_where + list_ = self.ably.push.admin.channel_subscriptions.list + + channel = 'canpublish:testremovewhere' + + # Subscribe device + device = self.get_device() + await save(PushChannelSubscription(channel, device_id=device.id)) + list_response = await list_(channel=channel) + assert device.id in (x.device_id for x in list_response.items) + remove_response = await remove(channel=channel, device_id=device.id) + assert remove_response.status_code == 204 + list_response = await list_(channel=channel) + assert device.id not in (x.device_id for x in list_response.items) + + # Subscribe client + client_id = self.get_client_id() + await save(PushChannelSubscription(channel, client_id=client_id)) + list_response = await list_(channel=channel) + assert client_id in (x.client_id for x in list_response.items) + remove_response = await remove(channel=channel, client_id=client_id) + assert remove_response.status_code == 204 + list_response = await list_(channel=channel) + assert client_id not in (x.client_id for x in list_response.items) + + # Remove again, it doesn't fail + remove_response = await remove(channel=channel, client_id=client_id) + assert remove_response.status_code == 204 diff --git a/test/ably/rest/restrequest_test.py b/test/ably/rest/restrequest_test.py new file mode 100644 index 00000000..967da19e --- /dev/null +++ b/test/ably/rest/restrequest_test.py @@ -0,0 +1,225 @@ +import httpx +import pytest +import respx + +from ably import AblyRest +from ably.http.paginatedresult import HttpPaginatedResponse +from ably.transport.defaults import Defaults +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + + +# RSC19 +class TestRestRequest(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + self.test_vars = await TestApp.get_test_vars() + + # Populate the channel (using the new api) + self.channel = self.get_channel_name() + self.path = f'/channels/{self.channel}/messages' + for i in range(20): + body = {'name': f'event{i}', 'data': f'lorem ipsum {i}'} + await self.ably.request('POST', self.path, body=body, version=Defaults.protocol_version) + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + async def test_post(self): + body = {'name': 'test-post', 'data': 'lorem ipsum'} + result = await self.ably.request('POST', self.path, body=body, version=Defaults.protocol_version) + + assert isinstance(result, HttpPaginatedResponse) # RSC19d + # HP3 + assert type(result.items) is list + assert len(result.items) == 1 + assert result.items[0]['channel'] == self.channel + assert 'messageId' in result.items[0] + + async def test_get(self): + params = {'limit': 10, 'direction': 'forwards'} + result = await self.ably.request('GET', self.path, params=params, version=Defaults.protocol_version) + + assert isinstance(result, HttpPaginatedResponse) # RSC19d + + # HP2 + assert isinstance(await result.next(), HttpPaginatedResponse) + assert isinstance(await result.first(), HttpPaginatedResponse) + + # HP3 + assert isinstance(result.items, list) + item = result.items[0] + assert isinstance(item, dict) + assert 'timestamp' in item + assert 'id' in item + assert item['name'] == 'event0' + assert item['data'] == 'lorem ipsum 0' + + assert result.status_code == 200 # HP4 + assert result.success is True # HP5 + assert result.error_code is None # HP6 + assert result.error_message is None # HP7 + assert isinstance(result.headers, list) # HP7 + + @dont_vary_protocol + async def test_not_found(self): + result = await self.ably.request('GET', '/not-found', version=Defaults.protocol_version) + assert isinstance(result, HttpPaginatedResponse) # RSC19d + assert result.status_code == 404 # HP4 + assert result.success is False # HP5 + + @dont_vary_protocol + async def test_error(self): + params = {'limit': 'abc'} + result = await self.ably.request('GET', self.path, params=params, version=Defaults.protocol_version) + assert isinstance(result, HttpPaginatedResponse) # RSC19d + assert result.status_code == 400 # HP4 + assert not result.success + assert result.error_code + assert result.error_message + + async def test_headers(self): + key = 'X-Test' + value = 'lorem ipsum' + result = await self.ably.request('GET', '/time', headers={key: value}, version=Defaults.protocol_version) + assert result.response.request.headers[key] == value + + # RSC19e + @dont_vary_protocol + async def test_timeout(self): + # Timeout + timeout = 0.000001 + ably = AblyRest(token="foo", http_request_timeout=timeout) + assert ably.http.http_request_timeout == timeout + with pytest.raises(httpx.ReadTimeout): + await ably.request('GET', '/time', version=Defaults.protocol_version) + await ably.close() + + default_endpoint = 'https://sandbox.realtime.ably-nonprod.net/time' + fallback_host = 'sandbox.a.fallback.ably-realtime-nonprod.com' + fallback_endpoint = f'https://{fallback_host}/time' + ably = await TestApp.get_ably_rest(fallback_hosts=[fallback_host]) + with respx.mock: + default_route = respx.get(default_endpoint) + fallback_route = respx.get(fallback_endpoint) + headers = { + "Content-Type": "application/json" + } + default_route.side_effect = httpx.ConnectError('') + fallback_route.return_value = httpx.Response(200, headers=headers, text='[123]') + await ably.request('GET', '/time', version=Defaults.protocol_version) + await ably.close() + + # Bad host, no Fallback + ably = AblyRest(key=self.test_vars["keys"][0]["key_str"], + endpoint='some.other.host', + port=self.test_vars["port"], + tls_port=self.test_vars["tls_port"], + tls=self.test_vars["tls"]) + with pytest.raises(httpx.ConnectError): + await ably.request('GET', '/time', version=Defaults.protocol_version) + await ably.close() + + # RSC15l3 + @dont_vary_protocol + async def test_503_status_fallback(self): + default_endpoint = 'https://sandbox.realtime.ably-nonprod.net/time' + fallback_host = 'sandbox.a.fallback.ably-realtime-nonprod.com' + fallback_endpoint = f'https://{fallback_host}/time' + ably = await TestApp.get_ably_rest(fallback_hosts=[fallback_host]) + with respx.mock: + default_route = respx.get(default_endpoint) + fallback_route = respx.get(fallback_endpoint) + headers = { + "Content-Type": "application/json" + } + default_route.return_value = httpx.Response(503, headers=headers) + fallback_route.return_value = httpx.Response(200, headers=headers, text='[123]') + result = await ably.request('GET', '/time', version=Defaults.protocol_version) + assert default_route.called + assert result.status_code == 200 + assert result.items[0] == 123 + await ably.close() + + # RSC15l2 + @dont_vary_protocol + async def test_httpx_timeout_fallback(self): + default_endpoint = 'https://sandbox.realtime.ably-nonprod.net/time' + fallback_host = 'sandbox.a.fallback.ably-realtime-nonprod.com' + fallback_endpoint = f'https://{fallback_host}/time' + ably = await TestApp.get_ably_rest(fallback_hosts=[fallback_host]) + with respx.mock: + default_route = respx.get(default_endpoint) + fallback_route = respx.get(fallback_endpoint) + headers = { + "Content-Type": "application/json" + } + default_route.side_effect = httpx.ReadTimeout + fallback_route.return_value = httpx.Response(200, headers=headers, text='[123]') + result = await ably.request('GET', '/time', version=Defaults.protocol_version) + assert default_route.called + assert result.status_code == 200 + assert result.items[0] == 123 + await ably.close() + + # RSC15l3 + @dont_vary_protocol + async def test_503_status_fallback_on_publish(self): + default_endpoint = 'https://sandbox.realtime.ably-nonprod.net/channels/test/messages' + fallback_host = 'sandbox.a.fallback.ably-realtime-nonprod.com' + fallback_endpoint = f'https://{fallback_host}/channels/test/messages' + + fallback_response_text = ( + '{"id": "unique_id:0", "channel": "test", "name": "test", "data": "data", ' + '"clientId": null, "connectionId": "connection_id", "timestamp": 1696944145000, ' + '"encoding": null}' + ) + + ably = await TestApp.get_ably_rest(fallback_hosts=[fallback_host]) + with respx.mock: + default_route = respx.post(default_endpoint) + fallback_route = respx.post(fallback_endpoint) + headers = { + "Content-Type": "application/json" + } + default_route.return_value = httpx.Response(503, headers=headers) + fallback_route.return_value = httpx.Response( + 200, + headers=headers, + text=fallback_response_text, + ) + await ably.channels['test'].publish('test', 'data') + assert default_route.called + await ably.close() + + # RSC15l4 + @dont_vary_protocol + async def test_400_cloudfront_fallback(self): + default_endpoint = 'https://sandbox.realtime.ably-nonprod.net/time' + fallback_host = 'sandbox.a.fallback.ably-realtime-nonprod.com' + fallback_endpoint = f'https://{fallback_host}/time' + ably = await TestApp.get_ably_rest(fallback_hosts=[fallback_host]) + with respx.mock: + default_route = respx.get(default_endpoint) + fallback_route = respx.get(fallback_endpoint) + headers = { + "Server": "CloudFront", + "Content-Type": "application/json", + } + default_route.return_value = httpx.Response(400, headers=headers, text='[456]') + fallback_route.return_value = httpx.Response(200, headers=headers, text='[123]') + result = await ably.request('GET', '/time', version=Defaults.protocol_version) + assert default_route.called + assert result.status_code == 200 + assert result.items[0] == 123 + await ably.close() + + async def test_version(self): + version = "150" # chosen arbitrarily + result = await self.ably.request('GET', '/time', "150") + assert result.response.request.headers["X-Ably-Version"] == version diff --git a/test/ably/rest/reststats_test.py b/test/ably/rest/reststats_test.py new file mode 100644 index 00000000..cef28817 --- /dev/null +++ b/test/ably/rest/reststats_test.py @@ -0,0 +1,307 @@ +import logging +from datetime import datetime, timedelta + +import pytest + +from ably.http.paginatedresult import PaginatedResult +from ably.types.stats import Stats +from ably.util.exceptions import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + +log = logging.getLogger(__name__) + + +class TestRestAppStatsSetup: + __stats_added = False + + def get_params(self): + return { + 'start': self.last_interval, + 'end': self.last_interval, + 'unit': 'minute', + 'limit': 1 + } + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + self.ably_text = await TestApp.get_ably_rest(use_binary_protocol=False) + + self.last_year = datetime.now().year - 1 + self.previous_year = datetime.now().year - 2 + self.last_interval = datetime(self.last_year, 2, 3, 15, 5) + self.previous_interval = datetime(self.previous_year, 2, 3, 15, 5) + previous_year_stats = 120 + stats = [ + { + 'intervalId': Stats.to_interval_id(self.last_interval - timedelta(minutes=2), + 'minute'), + 'inbound': {'realtime': {'messages': {'count': 50, 'data': 5000}}}, + 'outbound': {'realtime': {'messages': {'count': 20, 'data': 2000}}} + }, + { + 'intervalId': Stats.to_interval_id(self.last_interval - timedelta(minutes=1), + 'minute'), + 'inbound': {'realtime': {'messages': {'count': 60, 'data': 6000}}}, + 'outbound': {'realtime': {'messages': {'count': 10, 'data': 1000}}} + }, + { + 'intervalId': Stats.to_interval_id(self.last_interval, 'minute'), + 'inbound': {'realtime': {'messages': {'count': 70, 'data': 7000}}}, + 'outbound': {'realtime': {'messages': {'count': 40, 'data': 4000}}}, + 'persisted': {'presence': {'count': 20, 'data': 2000}}, + 'connections': {'tls': {'peak': 20, 'opened': 10}}, + 'channels': {'peak': 50, 'opened': 30}, + 'apiRequests': {'succeeded': 50, 'failed': 10}, + 'tokenRequests': {'succeeded': 60, 'failed': 20}, + } + ] + + previous_stats = [] + for i in range(previous_year_stats): + previous_stats.append( + { + 'intervalId': Stats.to_interval_id(self.previous_interval - timedelta(minutes=i), + 'minute'), + 'inbound': {'realtime': {'messages': {'count': i}}} + } + ) + # asynctest does not support setUpClass method + if not TestRestAppStatsSetup.__stats_added: + await self.ably.http.post('/stats', body=stats + previous_stats) + TestRestAppStatsSetup.__stats_added = True + yield + await self.ably.close() + await self.ably_text.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + + +class TestDirectionForwards(TestRestAppStatsSetup, BaseAsyncTestCase, + metaclass=VaryByProtocolTestsMetaclass): + + def get_params(self): + return { + 'start': self.last_interval - timedelta(minutes=2), + 'end': self.last_interval, + 'unit': 'minute', + 'direction': 'forwards', + 'limit': 1 + } + + async def test_stats_are_forward(self): + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.inbound.realtime.all.count"] == 50 + + async def test_three_pages(self): + stats_pages = await self.ably.stats(**self.get_params()) + assert not stats_pages.is_last() + page2 = await stats_pages.next() + page3 = await page2.next() + assert page3.items[0].entries["messages.inbound.realtime.all.count"] == 70 + + +class TestDirectionBackwards(TestRestAppStatsSetup, BaseAsyncTestCase, + metaclass=VaryByProtocolTestsMetaclass): + + def get_params(self): + return { + 'end': self.last_interval, + 'unit': 'minute', + 'direction': 'backwards', + 'limit': 1 + } + + async def test_stats_are_forward(self): + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.inbound.realtime.all.count"] == 70 + + async def test_three_pages(self): + stats_pages = await self.ably.stats(**self.get_params()) + assert not stats_pages.is_last() + page2 = await stats_pages.next() + page3 = await page2.next() + assert not stats_pages.is_last() + assert page3.items[0].entries["messages.inbound.realtime.all.count"] == 50 + + +class TestOnlyLastYear(TestRestAppStatsSetup, BaseAsyncTestCase, + metaclass=VaryByProtocolTestsMetaclass): + + def get_params(self): + return { + 'end': self.last_interval, + 'unit': 'minute', + 'limit': 3 + } + + async def test_default_is_backwards(self): + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + assert stats[0].entries["messages.inbound.realtime.messages.count"] == 70 + assert stats[-1].entries["messages.inbound.realtime.messages.count"] == 50 + + +class TestPreviousYear(TestRestAppStatsSetup, BaseAsyncTestCase, + metaclass=VaryByProtocolTestsMetaclass): + + def get_params(self): + return { + 'end': self.previous_interval, + 'unit': 'minute', + } + + async def test_default_100_pagination(self): + self.stats_pages = await self.ably.stats(**self.get_params()) + stats = self.stats_pages.items + assert len(stats) == 100 + next_page = await self.stats_pages.next() + assert len(next_page.items) == 20 + + +class TestRestAppStats(TestRestAppStatsSetup, BaseAsyncTestCase, + metaclass=VaryByProtocolTestsMetaclass): + + @dont_vary_protocol + async def test_protocols(self): + stats_pages = await self.ably.stats(**self.get_params()) + stats_pages1 = await self.ably_text.stats(**self.get_params()) + assert len(stats_pages.items) == len(stats_pages1.items) + + async def test_paginated_response(self): + stats_pages = await self.ably.stats(**self.get_params()) + assert isinstance(stats_pages, PaginatedResult) + assert isinstance(stats_pages.items[0], Stats) + + async def test_units(self): + for unit in ['hour', 'day', 'month']: + params = { + 'start': self.last_interval, + 'end': self.last_interval, + 'unit': unit, + 'direction': 'forwards', + 'limit': 1 + } + stats_pages = await self.ably.stats(**params) + stat = stats_pages.items[0] + assert len(stats_pages.items) == 1 + assert stat.entries["messages.all.messages.count"] == 50 + 20 + 60 + 10 + 70 + 40 + assert stat.entries["messages.all.messages.data"] == 5000 + 2000 + 6000 + 1000 + 7000 + 4000 + + @dont_vary_protocol + async def test_when_argument_start_is_after_end(self): + params = { + 'start': self.last_interval, + 'end': self.last_interval - timedelta(minutes=2), + 'unit': 'minute', + } + with pytest.raises(AblyException, match="'end' parameter has to be greater than or equal to 'start'"): + await self.ably.stats(**params) + + @dont_vary_protocol + async def test_when_limit_gt_1000(self): + params = { + 'end': self.last_interval, + 'limit': 5000 + } + with pytest.raises(AblyException, match="The maximum allowed limit is 1000"): + await self.ably.stats(**params) + + async def test_no_arguments(self): + params = { + 'end': self.last_interval, + } + stats_pages = await self.ably.stats(**params) + self.stat = stats_pages.items[0] + assert self.stat.unit == 'minute' + + async def test_got_1_record(self): + stats_pages = await self.ably.stats(**self.get_params()) + assert 1 == len(stats_pages.items), "Expected 1 record" + + async def test_return_aggregated_message_data(self): + # returns aggregated message data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.all.messages.count"] == 70 + 40 + assert stat.entries["messages.all.messages.data"] == 7000 + 4000 + + async def test_inbound_realtime_all_data(self): + # returns inbound realtime all data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.inbound.realtime.all.count"] == 70 + assert stat.entries["messages.inbound.realtime.all.data"] == 7000 + + async def test_inboud_realtime_message_data(self): + # returns inbound realtime message data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.inbound.realtime.messages.count"] == 70 + assert stat.entries["messages.inbound.realtime.messages.data"] == 7000 + + async def test_outbound_realtime_all_data(self): + # returns outboud realtime all data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.outbound.realtime.all.count"] == 40 + assert stat.entries["messages.outbound.realtime.all.data"] == 4000 + + async def test_persisted_data(self): + # returns persisted presence all data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["messages.persisted.all.count"] == 20 + assert stat.entries["messages.persisted.all.data"] == 2000 + + async def test_connections_data(self): + # returns connections all data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["connections.all.peak"] == 20 + assert stat.entries["connections.all.opened"] == 10 + + async def test_channels_all_data(self): + # returns channels all data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["channels.peak"] == 50 + assert stat.entries["channels.opened"] == 30 + + async def test_api_requests_data(self): + # returns api_requests data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["apiRequests.other.succeeded"] == 50 + assert stat.entries["apiRequests.other.failed"] == 10 + + async def test_token_requests(self): + # returns token_requests data + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.entries["apiRequests.tokenRequests.succeeded"] == 60 + assert stat.entries["apiRequests.tokenRequests.failed"] == 20 + + async def test_interval(self): + # interval + stats_pages = await self.ably.stats(**self.get_params()) + stats = stats_pages.items + stat = stats[0] + assert stat.unit == 'minute' + assert stat.interval_id == self.last_interval.strftime('%Y-%m-%d:%H:%M') + assert stat.interval_time == self.last_interval diff --git a/test/ably/rest/resttime_test.py b/test/ably/rest/resttime_test.py new file mode 100644 index 00000000..4b78620a --- /dev/null +++ b/test/ably/rest/resttime_test.py @@ -0,0 +1,42 @@ +import time + +import pytest + +from ably import AblyException +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + + +class TestRestTime(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + yield + await self.ably.close() + + async def test_time_accuracy(self): + reported_time = await self.ably.time() + actual_time = time.time() * 1000.0 + + seconds = 10 + assert abs(actual_time - reported_time) < seconds * 1000, f"Time is not within {seconds} seconds" + + async def test_time_without_key_or_token(self): + reported_time = await self.ably.time() + actual_time = time.time() * 1000.0 + + seconds = 10 + assert abs(actual_time - reported_time) < seconds * 1000, f"Time is not within {seconds} seconds" + + @dont_vary_protocol + async def test_time_fails_without_valid_host(self): + ably = await TestApp.get_ably_rest(key=None, token='foo', endpoint="this.host.does.not.exist") + with pytest.raises(AblyException): + await ably.time() + + await ably.close() diff --git a/test/ably/rest/resttoken_test.py b/test/ably/rest/resttoken_test.py new file mode 100644 index 00000000..5052f1be --- /dev/null +++ b/test/ably/rest/resttoken_test.py @@ -0,0 +1,339 @@ +import datetime +import json +import logging +from unittest.mock import patch + +import pytest + +from ably import AblyException, AblyRest, Capability +from ably.types.tokendetails import TokenDetails +from ably.types.tokenrequest import TokenRequest +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol + +log = logging.getLogger(__name__) + + +class TestRestToken(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + async def server_time(self): + return await self.ably.time() + + @pytest.fixture(autouse=True) + async def setup(self): + capability = {"*": ["*"]} + self.permit_all = str(Capability(capability)) + self.ably = await TestApp.get_ably_rest() + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + async def test_request_token_null_params(self): + pre_time = await self.server_time() + token_details = await self.ably.auth.request_token() + post_time = await self.server_time() + assert token_details.token is not None, "Expected token" + assert token_details.issued + 300 >= pre_time, "Unexpected issued time" + assert token_details.issued <= post_time + 500, "Unexpected issued time" + assert self.permit_all == str(token_details.capability), "Unexpected capability" + + async def test_request_token_explicit_timestamp(self): + pre_time = await self.server_time() + token_details = await self.ably.auth.request_token(token_params={'timestamp': pre_time}) + post_time = await self.server_time() + assert token_details.token is not None, "Expected token" + assert token_details.issued + 300 >= pre_time, "Unexpected issued time" + assert token_details.issued <= post_time, "Unexpected issued time" + assert self.permit_all == str(Capability(token_details.capability)), "Unexpected Capability" + + async def test_request_token_explicit_invalid_timestamp(self): + request_time = await self.server_time() + explicit_timestamp = request_time - 30 * 60 * 1000 + + with pytest.raises(AblyException): + await self.ably.auth.request_token(token_params={'timestamp': explicit_timestamp}) + + async def test_request_token_with_system_timestamp(self): + pre_time = await self.server_time() + token_details = await self.ably.auth.request_token(query_time=True) + post_time = await self.server_time() + assert token_details.token is not None, "Expected token" + assert token_details.issued >= pre_time, "Unexpected issued time" + assert token_details.issued <= post_time, "Unexpected issued time" + assert self.permit_all == str(Capability(token_details.capability)), "Unexpected Capability" + + async def test_request_token_with_duplicate_nonce(self): + request_time = await self.server_time() + token_params = { + 'timestamp': request_time, + 'nonce': '1234567890123456' + } + token_details = await self.ably.auth.request_token(token_params) + assert token_details.token is not None, "Expected token" + + with pytest.raises(AblyException): + await self.ably.auth.request_token(token_params) + + async def test_request_token_with_capability_that_subsets_key_capability(self): + capability = Capability({ + "onlythischannel": ["subscribe"] + }) + + token_details = await self.ably.auth.request_token( + token_params={'capability': capability}) + + assert token_details is not None + assert token_details.token is not None + assert capability == token_details.capability, "Unexpected capability" + + async def test_request_token_with_specified_key(self): + test_vars = await TestApp.get_test_vars() + key = test_vars["keys"][1] + token_details = await self.ably.auth.request_token( + key_name=key["key_name"], key_secret=key["key_secret"]) + assert token_details.token is not None, "Expected token" + assert key.get("capability") == token_details.capability, "Unexpected capability" + + @dont_vary_protocol + async def test_request_token_with_invalid_mac(self): + with pytest.raises(AblyException): + await self.ably.auth.request_token(token_params={'mac': "thisisnotavalidmac"}) + + async def test_request_token_with_specified_ttl(self): + token_details = await self.ably.auth.request_token(token_params={'ttl': 100}) + assert token_details.token is not None, "Expected token" + assert token_details.issued + 100 == token_details.expires, "Unexpected expires" + + @dont_vary_protocol + async def test_token_with_excessive_ttl(self): + excessive_ttl = 365 * 24 * 60 * 60 * 1000 + with pytest.raises(AblyException): + await self.ably.auth.request_token(token_params={'ttl': excessive_ttl}) + + @dont_vary_protocol + async def test_token_generation_with_invalid_ttl(self): + with pytest.raises(AblyException): + await self.ably.auth.request_token(token_params={'ttl': -1}) + + async def test_token_generation_with_local_time(self): + timestamp = self.ably.auth._timestamp + with patch('ably.rest.rest.AblyRest.time', wraps=self.ably.time) as server_time,\ + patch('ably.rest.auth.Auth._timestamp', wraps=timestamp) as local_time: + await self.ably.auth.request_token() + assert local_time.called + assert not server_time.called + + # RSA10k + async def test_token_generation_with_server_time(self): + timestamp = self.ably.auth._timestamp + with patch('ably.rest.rest.AblyRest.time', wraps=self.ably.time) as server_time,\ + patch('ably.rest.auth.Auth._timestamp', wraps=timestamp) as local_time: + await self.ably.auth.request_token(query_time=True) + assert local_time.call_count == 1 + assert server_time.call_count == 1 + await self.ably.auth.request_token(query_time=True) + assert local_time.call_count == 2 + assert server_time.call_count == 1 + + # TD7 + async def test_toke_details_from_json(self): + token_details = await self.ably.auth.request_token() + token_details_dict = token_details.to_dict() + token_details_str = json.dumps(token_details_dict) + + assert token_details == TokenDetails.from_json(token_details_dict) + assert token_details == TokenDetails.from_json(token_details_str) + + # Issue #71 + @dont_vary_protocol + async def test_request_token_float_and_timedelta(self): + lifetime = datetime.timedelta(hours=4) + await self.ably.auth.request_token({'ttl': lifetime.total_seconds() * 1000}) + await self.ably.auth.request_token({'ttl': lifetime}) + + +class TestCreateTokenRequest(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + + @pytest.fixture(autouse=True) + async def setup(self): + self.ably = await TestApp.get_ably_rest() + self.key_name = self.ably.options.key_name + self.key_secret = self.ably.options.key_secret + yield + await self.ably.close() + + def per_protocol_setup(self, use_binary_protocol): + self.ably.options.use_binary_protocol = use_binary_protocol + self.use_binary_protocol = use_binary_protocol + + @dont_vary_protocol + async def test_key_name_and_secret_are_required(self): + ably = await TestApp.get_ably_rest(key=None, token='not a real token') + with pytest.raises(AblyException, match="40101 401 No key specified"): + await ably.auth.create_token_request() + with pytest.raises(AblyException, match="40101 401 No key specified"): + await ably.auth.create_token_request(key_name=self.key_name) + with pytest.raises(AblyException, match="40101 401 No key specified"): + await ably.auth.create_token_request(key_secret=self.key_secret) + + @dont_vary_protocol + async def test_with_local_time(self): + timestamp = self.ably.auth._timestamp + with patch('ably.rest.rest.AblyRest.time', wraps=self.ably.time) as server_time,\ + patch('ably.rest.auth.Auth._timestamp', wraps=timestamp) as local_time: + await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret, query_time=False) + assert local_time.called + assert not server_time.called + + # RSA10k + @dont_vary_protocol + async def test_with_server_time(self): + timestamp = self.ably.auth._timestamp + with patch('ably.rest.rest.AblyRest.time', wraps=self.ably.time) as server_time,\ + patch('ably.rest.auth.Auth._timestamp', wraps=timestamp) as local_time: + await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret, query_time=True) + assert local_time.call_count == 1 + assert server_time.call_count == 1 + await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret, query_time=True) + assert local_time.call_count == 2 + assert server_time.call_count == 1 + + async def test_token_request_can_be_used_to_get_a_token(self): + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert isinstance(token_request, TokenRequest) + + async def auth_callback(token_params): + return token_request + + ably = await TestApp.get_ably_rest(key=None, + auth_callback=auth_callback, + use_binary_protocol=self.use_binary_protocol) + + token = await ably.auth.authorize() + assert isinstance(token, TokenDetails) + await ably.close() + + async def test_token_request_dict_can_be_used_to_get_a_token(self): + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert isinstance(token_request, TokenRequest) + + async def auth_callback(token_params): + return token_request.to_dict() + + ably = await TestApp.get_ably_rest(key=None, + auth_callback=auth_callback, + use_binary_protocol=self.use_binary_protocol) + + token = await ably.auth.authorize() + assert isinstance(token, TokenDetails) + await ably.close() + + # TE6 + @dont_vary_protocol + async def test_token_request_from_json(self): + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert isinstance(token_request, TokenRequest) + + token_request_dict = token_request.to_dict() + assert token_request == TokenRequest.from_json(token_request_dict) + + token_request_str = json.dumps(token_request_dict) + assert token_request == TokenRequest.from_json(token_request_str) + + @dont_vary_protocol + async def test_nonce_is_random_and_longer_than_15_characters(self): + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert len(token_request.nonce) > 15 + + another_token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert len(another_token_request.nonce) > 15 + + assert token_request.nonce != another_token_request.nonce + + # RSA5 + @dont_vary_protocol + async def test_ttl_is_optional_and_specified_in_ms(self): + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert token_request.ttl is None + + # RSA6 + @dont_vary_protocol + async def test_capability_is_optional(self): + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret) + assert token_request.capability is None + + @dont_vary_protocol + async def test_accept_all_token_params(self): + token_params = { + 'ttl': 1000, + 'capability': Capability({'channel': ['publish']}), + 'client_id': 'a_id', + 'timestamp': 1000, + 'nonce': 'a_nonce', + } + token_request = await self.ably.auth.create_token_request( + token_params, + key_name=self.key_name, key_secret=self.key_secret, + ) + assert token_request.ttl == token_params['ttl'] + assert token_request.capability == str(token_params['capability']) + assert token_request.client_id == token_params['client_id'] + assert token_request.timestamp == token_params['timestamp'] + assert token_request.nonce == token_params['nonce'] + + async def test_capability(self): + capability = Capability({'channel': ['publish']}) + token_request = await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret, + token_params={'capability': capability}) + assert token_request.capability == str(capability) + + async def auth_callback(token_params): + return token_request + + ably = await TestApp.get_ably_rest(key=None, auth_callback=auth_callback, + use_binary_protocol=self.use_binary_protocol) + + token = await ably.auth.authorize() + + assert str(token.capability) == str(capability) + await ably.close() + + @dont_vary_protocol + async def test_hmac(self): + ably = AblyRest(key_name='a_key_name', key_secret='a_secret') + token_params = { + 'ttl': 1000, + 'nonce': 'abcde100', + 'client_id': 'a_id', + 'timestamp': 1000, + } + token_request = await ably.auth.create_token_request( + token_params, key_secret='a_secret', key_name='a_key_name') + assert token_request.mac == 'sYkCH0Un+WgzI7/Nhy0BoQIKq9HmjKynCRs4E3qAbGQ=' + await ably.close() + + # AO2g + @dont_vary_protocol + async def test_query_server_time(self): + with patch('ably.rest.rest.AblyRest.time', wraps=self.ably.time) as server_time: + await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret, query_time=True) + assert server_time.call_count == 1 + + await self.ably.auth.create_token_request( + key_name=self.key_name, key_secret=self.key_secret, query_time=False) + assert server_time.call_count == 1 diff --git a/test/ably/restappstats_test.py b/test/ably/restappstats_test.py deleted file mode 100644 index ecdbca8e..00000000 --- a/test/ably/restappstats_test.py +++ /dev/null @@ -1,340 +0,0 @@ -from __future__ import absolute_import - -import math -from datetime import datetime -from datetime import timedelta -import logging -import time -import unittest - - -from ably import AblyException -from ably import AblyRest -from ably import Options - -from test.ably.restsetup import RestSetup -log = logging.getLogger(__name__) -test_vars = RestSetup.get_test_vars() -log.debug("KEY init: "+test_vars["keys"][0]["key_str"]) - - -class TestRestAppStats(unittest.TestCase): - test_start = 0 - interval_start = 0 - interval_end = 0 - - @classmethod - def setUpClass(cls): - log.debug("KEY class: "+test_vars["keys"][0]["key_str"]) - log.debug("TLS: "+str(test_vars["tls"])) - cls.ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"])) - time_from_service = cls.ably.time() - cls.time_offset = time_from_service / 1000.0 - time.time() - - #cls._test_infos = {} - #cls._publish(50) - #cls._publish(60) - #cls._publish(70) - #cls.sleep_for(timedelta(seconds=8)) - - @classmethod - def server_now(cls): - return datetime.fromtimestamp(cls.time_offset + time.time()) - - @classmethod - def sleep_until_next_minute(cls): - server_now = cls.server_now() - one_minute = timedelta(minutes=1) - next_minute = server_now + one_minute - next_minute = next_minute.replace(second=0, microsecond=0) - - cls.sleep_for(next_minute - server_now) - - @classmethod - def sleep_for(cls, td): - cls.sleep_until(datetime.utcnow() + td) - - @staticmethod - def sleep_until(until): - now = datetime.utcnow() - while now < until: - dt = until - now - time.sleep(dt.total_seconds()) - now = datetime.utcnow() - - @classmethod - def _publish(cls, num_messages, channel_name): - cls.sleep_until_next_minute() - cls.interval_start = cls.server_now() - - if not cls.test_start: - cls.test_start = cls.interval_start - - channel = cls.ably.channels.get(channel_name) - for i in range(num_messages): - channel.publish('stats%d' % i, i) - - cls.interval_end = cls.server_now() - - cls.sleep_for(timedelta(seconds=8)) - - def test_app_stats_01_minute_level_forwards(self): - TestRestAppStats._publish(50, 'appstats_0') - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.interval_start, - 'end': TestRestAppStats.interval_end, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - def test_app_stats_02_hour_level_forwards(self): - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.interval_start, - 'end': TestRestAppStats.interval_end, - 'by': 'hour', - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - def test_app_stats_03_day_level_forwards(self): - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.interval_start, - 'end': TestRestAppStats.interval_end, - 'by': 'day', - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - def test_app_stats_04_month_level_forwards(self): - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.interval_start, - 'end': TestRestAppStats.interval_end, - 'by': 'month', - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - def test_app_stats_05_minute_level_backwards(self): - TestRestAppStats._publish(60, 'appstats_1') - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.interval_start, - 'end': TestRestAppStats.interval_end, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - def test_app_stats_06_hour_level_backwards(self): - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'by': 'hour', - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertTrue(1 == len(stats_page) or 2 == len(stats_page), "Expected 1 or 2 records") - if (1 == len(stats_page)): - self.assertEqual(110, stats_page[0].inbound.all.all.count, "Expected 110 messages") - else: - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - - def test_app_stats_07_day_level_backwards(self): - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'by': 'day', - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertTrue(1 == len(stats_page) or 2 == len(stats_page), "Expected 1 or 2 records") - if (1 == len(stats_page)): - self.assertEqual(110, stats_page[0].inbound.all.all.count, "Expected 110 messages") - else: - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - def test_app_stats_08_month_level_backwards(self): - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'by': 'month', - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertTrue(1 == len(stats_page) or 2 == len(stats_page), "Expected 1 or 2 records") - if (1 == len(stats_page)): - self.assertEqual(110, stats_page[0].inbound.all.all.count, "Expected 110 messages") - else: - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - def test_app_stats_09_limit_backwards(self): - TestRestAppStats._publish(70, 'appstats_2') - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'limit': 1, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(70, stats_page[0].inbound.all.all.count, "Expected 70 messages") - - def test_app_stats_10_limit_forwards(self): - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'limit': 1, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - def test_app_stats_11_pagination_backwards(self): - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'limit': 1, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(70, stats_page[0].inbound.all.all.count, "Expected 70 messages") - - self.assertTrue(stats_pages.has_next) - stats_pages = stats_pages.get_next() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - self.assertTrue(stats_pages.has_next) - stats_pages = stats_pages.get_next() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - self.assertFalse(stats_pages.has_next) - stats_pages = stats_pages.get_next() - self.assertIsNone(stats_pages, "Expected None") - - def test_app_stats_12_pagination_forwards(self): - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'limit': 1, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - self.assertTrue(stats_pages.has_next) - stats_pages = stats_pages.get_next() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - self.assertTrue(stats_pages.has_next) - stats_pages = stats_pages.get_next() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(70, stats_page[0].inbound.all.all.count, "Expected 70 messages") - - self.assertFalse(stats_pages.has_next) - stats_pages = stats_pages.get_next() - self.assertIsNone(stats_pages, "Expected None") - - def test_app_stats_13_pagination_backwards_get_first(self): - params = { - 'direction': 'backwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'limit': 1, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(70, stats_page[0].inbound.all.all.count, "Expected 70 messages") - - self.assertTrue(stats_pages.has_next) - stats_pages = stats_pages.get_next() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - self.assertTrue(stats_pages.has_first) - stats_pages = stats_pages.get_first() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(70, stats_page[0].inbound.all.all.count, "Expected 70 messages") - - def test_app_stats_14_pagination_forwards_get_first(self): - params = { - 'direction': 'forwards', - 'start': TestRestAppStats.test_start, - 'end': TestRestAppStats.interval_end, - 'limit': 1, - } - stats_pages = TestRestAppStats.ably.stats(**params) - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") - - self.assertTrue(stats_pages.has_next) - stats_pages = stats_pages.get_next() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(60, stats_page[0].inbound.all.all.count, "Expected 60 messages") - - self.assertTrue(stats_pages.has_first) - stats_pages = stats_pages.get_first() - stats_page = stats_pages.current - - self.assertEqual(1, len(stats_page), "Expected 1 record") - self.assertEqual(50, stats_page[0].inbound.all.all.count, "Expected 50 messages") diff --git a/test/ably/restauth_test.py b/test/ably/restauth_test.py deleted file mode 100644 index c611cd07..00000000 --- a/test/ably/restauth_test.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import absolute_import - -import logging -import unittest -import os - -from ably import AblyRest -from ably import Auth -from ably import Options - - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() - - -log = logging.getLogger(__name__) - -class TestAuth(unittest.TestCase): - def test_auth_init_key_only(self): - - ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"])) - print(test_vars["keys"][0]["key_str"]) - log.debug("Method: %s" % ably.auth.auth_method) - self.assertEqual(Auth.Method.BASIC, ably.auth.auth_method, - msg="Unexpected Auth method mismatch") - - def test_auth_init_token_only(self): - options = { - "auth_token": "this_is_not_really_a_token", - } - - ably = AblyRest(Options(auth_token="this_is_not_really_a_token")) - - self.assertEqual(Auth.Method.TOKEN, ably.auth.auth_method, - msg="Unexpected Auth method mismatch") - - def test_auth_init_with_token_callback(self): - callback_called = [] - - def token_callback(**params): - callback_called.append(True) - return "this_is_not_really_a_token_request" - - options = Options() - options.key_id = test_vars["keys"][0]["key_id"] - options.host = test_vars["host"] - options.port = test_vars["port"] - options.tls_port = test_vars["tls_port"] - options.tls = test_vars["tls"] - options.auth_callback = token_callback - - ably = AblyRest(options) - - try: - ably.stats(None) - except: - pass - - self.assertTrue(callback_called, msg="Token callback not called") - self.assertEqual(Auth.Method.TOKEN, ably.auth.auth_method, - msg="Unexpected Auth method mismatch") - - def test_auth_init_with_key_and_client_id(self): - options = Options.with_key(test_vars["keys"][0]["key_str"]) - options.client_id = "testClientId" - - ably = AblyRest(options) - - self.assertEqual(Auth.Method.TOKEN, ably.auth.auth_method, - msg="Unexpected Auth method mismatch") - - def test_auth_init_with_token(self): - options = Options(host=test_vars["host"], port=test_vars["port"], - tls_port=test_vars["tls_port"], tls=test_vars["tls"]) - - ably = AblyRest(options) - - self.assertEqual(Auth.Method.TOKEN, ably.auth.auth_method, - msg="Unexpected Auth method mismatch") diff --git a/test/ably/restcapability_test.py b/test/ably/restcapability_test.py deleted file mode 100644 index 437ca4d9..00000000 --- a/test/ably/restcapability_test.py +++ /dev/null @@ -1,313 +0,0 @@ -from __future__ import absolute_import - -import math -from datetime import datetime -from datetime import timedelta -import json -import unittest - -import six - -from ably import AblyRest -from ably import Options -from ably.types.capability import Capability -from ably.util.exceptions import AblyException - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() - -class TestRestCapability(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"])) - - @property - def ably(self): - return self.__class__.ably - - def test_blanket_intersection_with_key(self): - key = test_vars['keys'][1] - token_details = self.ably.auth.request_token(key_id=key['key_id'], - key_value=key['key_value']) - expected_capability = Capability(key["capability"]) - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability.") - - def test_equal_intersection_with_key(self): - key = test_vars['keys'][1] - - token_params = { - "capability": key["capability"], - } - - token_details = self.ably.auth.request_token(key_id=key['key_id'], - key_value=key['key_value'], - token_params=token_params) - - expected_capability = Capability(key["capability"]) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - - def test_empty_ops_intersection(self): - key = test_vars['keys'][1] - - token_params = { - "capability": { - "testchannel": ["subscribe"], - }, - } - - self.assertRaises(AblyException, self.ably.auth.request_token, - key_id=key['key_id'], - key_value=key['key_value'], - token_params=token_params) - - def test_empty_paths_intersection(self): - key = test_vars['keys'][1] - - token_params = { - "capability": { - "testchannelx": ["publish"], - }, - } - - self.assertRaises(AblyException, self.ably.auth.request_token, - key_id=key['key_id'], - key_value=key['key_value'], - token_params=token_params) - - def test_non_empty_ops_intersection(self): - key = test_vars['keys'][4] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "channel2": ["presence", "subscribe"], - }, - }, - } - - expected_capability = Capability({ - "channel2": ["subscribe"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - def test_non_empty_paths_intersection(self): - key = test_vars['keys'][4] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "channel2": ["presence", "subscribe"], - "channelx": ["presence", "subscribe"], - }, - }, - } - - expected_capability = Capability({ - "channel2": ["subscribe"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - def test_wildcard_ops_intersection(self): - key = test_vars['keys'][4] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "channel2": ["*"], - }, - }, - } - - expected_capability = Capability({ - "channel2": ["subscribe", "publish"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - - def test_wildcard_ops_intersection_2(self): - key = test_vars['keys'][4] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "channel6": ["publish", "subscribe"], - }, - }, - } - - expected_capability = Capability({ - "channel6": ["subscribe", "publish"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - def test_wildcard_resources_intersection(self): - key = test_vars['keys'][2] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "cansubscribe": ["subscribe"], - }, - }, - } - - expected_capability = Capability({ - "cansubscribe": ["subscribe"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - def test_wildcard_resources_intersection_2(self): - key = test_vars['keys'][2] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "cansubscribe:check": ["subscribe"], - }, - }, - } - - expected_capability = Capability({ - "cansubscribe:check": ["subscribe"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - def test_wildcard_resources_intersection_3(self): - key = test_vars['keys'][2] - - kwargs = { - "key_id": key["key_id"], - "key_value": key["key_value"], - "token_params": { - "capability": { - "cansubscribe:*": ["subscribe"], - }, - }, - } - - expected_capability = Capability({ - "cansubscribe:*": ["subscribe"] - }) - - token_details = self.ably.auth.request_token(**kwargs) - - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(expected_capability, - token_details.capability, - msg="Unexpected capability") - - def test_invalid_capabilities(self): - kwargs = { - "token_params": { - "capability": { - "channel0": ["publish_"], - }, - }, - } - - with self.assertRaises(AblyException) as cm: - token_details = self.ably.auth.request_token(**kwargs) - - the_exception = cm.exception - self.assertEqual(400, the_exception.status_code) - self.assertEqual(40000, the_exception.code) - - def test_invalid_capabilities_2(self): - kwargs = { - "token_params": { - "capability": { - "channel0": ["*", "publish"], - }, - }, - } - - with self.assertRaises(AblyException) as cm: - token_details = self.ably.auth.request_token(**kwargs) - - the_exception = cm.exception - self.assertEqual(400, the_exception.status_code) - self.assertEqual(40000, the_exception.code) - - - def test_invalid_capabilities_3(self): - capability = Capability({ - "channel0": [] - }) - - kwargs = { - "token_params": { - "capability": { - "channel0": [], - }, - }, - } - - with self.assertRaises(AblyException) as cm: - token_details = self.ably.auth.request_token(**kwargs) - - the_exception = cm.exception - self.assertEqual(400, the_exception.status_code) - self.assertEqual(40000, the_exception.code) - - diff --git a/test/ably/restchannelhistory_test.py b/test/ably/restchannelhistory_test.py deleted file mode 100644 index 98060557..00000000 --- a/test/ably/restchannelhistory_test.py +++ /dev/null @@ -1,396 +0,0 @@ -from __future__ import absolute_import - -import math -from datetime import datetime -from datetime import timedelta -import logging -import time -import unittest - -import six -from six.moves import range - -from ably import AblyException -from ably import AblyRest -from ably import Options - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() -log = logging.getLogger(__name__) - - -class TestRestChannelHistory(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"])) - cls.time_offset = cls.ably.time() - int(time.time()) - - @property - def ably(self): - return TestRestChannelHistory.ably - - def test_channel_history_types(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_types'] - history0.publish('history0', True) - history0.publish('history1', 24) - history0.publish('history2', 24.234) - history0.publish('history3', six.u('This is a string message payload')) - history0.publish('history4', b'This is a byte[] message payload') - history0.publish('history5', {'test': 'This is a JSONObject message payload'}) - history0.publish('history6', ['This is a JSONArray message payload']) - - # Wait for the history to be persisted - time.sleep(16) - - history = history0.history() - messages = history.current - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = {m.name:m for m in messages} - - self.assertEqual(True, message_contents["history0"].data, - msg="Expect history0 to be Boolean(true)") - self.assertEqual(24, int(message_contents["history1"].data), - msg="Expect history1 to be Int(24)") - self.assertEqual(24.234, float(message_contents["history2"].data), - msg="Expect history2 to be Double(24.234)") - self.assertEqual(six.u("This is a string message payload"), - message_contents["history3"].data, - msg="Expect history3 to be expected String)") - self.assertEqual(b"This is a byte[] message payload", - message_contents["history4"].data, - msg="Expect history4 to be expected byte[]") - self.assertEqual({"test": "This is a JSONObject message payload"}, - message_contents["history5"].data, - msg="Expect history5 to be expected JSONObject") - self.assertEqual(["This is a JSONArray message payload"], - message_contents["history6"].data, - msg="Expect history6 to be expected JSONObject") - - expected_message_history = [ - message_contents['history6'], - message_contents['history5'], - message_contents['history4'], - message_contents['history3'], - message_contents['history2'], - message_contents['history1'], - message_contents['history0'], - ] - - self.assertEqual(expected_message_history, messages, - msg="Expect messages in reverse order") - - def test_channel_history_multi_50_forwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_multi_50_f'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='forwards') - self.assertIsNotNone(history) - messages = history.current - self.assertEqual(50, len(messages), - msg="Expected 50 messages") - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(50)] - self.assertEqual(expected_messages, messages, - msg='Expect messages in forward order') - - def test_channel_history_multi_50_backwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_multi_50_b'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='backwards') - self.assertIsNotNone(history) - messages = history.current - self.assertEqual(50, len(messages), - msg="Expected 50 messages") - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(49, -1, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expect messages in reverse order') - - def test_channel_history_limit_forwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_limit_f'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='forwards', limit=25) - self.assertIsNotNone(history) - messages = history.current - self.assertEqual(25, len(messages), - msg="Expected 25 messages") - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(25)] - - self.assertEqual(expected_messages, messages, - msg='Expect messages in forward order') - - def test_channel_history_limit_backwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_limit_f'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='backwards', limit=25) - self.assertIsNotNone(history) - messages = history.current - self.assertEqual(25, len(messages), - msg="Expected 25 messages") - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(49, 24, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expect messages in forward order') - - def test_channel_history_time_forwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_time_f'] - - for i in range(20): - history0.publish('history%d' % i, i) - time.sleep(0.1) - - interval_start = TestRestChannelHistory.ably.time() - - for i in range(20, 40): - history0.publish('history%d' % i, i) - time.sleep(0.1) - - interval_end = TestRestChannelHistory.ably.time() - - for i in range(40, 60): - history0.publish('history%d' % i, i) - time.sleep(0.1) - - time.sleep(16) - - history = history0.history(direction='forwards', start=interval_start, - end=interval_end) - - messages = history.current - self.assertEqual(20, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(20, 40)] - - self.assertEqual(expected_messages, messages, - msg='Expect messages in forward order') - - def test_channel_history_time_backwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_time_b'] - - for i in range(20): - history0.publish('history%d' % i, i) - time.sleep(0.1) - - interval_start = TestRestChannelHistory.ably.time() - - for i in range(20, 40): - history0.publish('history%d' % i, i) - time.sleep(0.1) - - interval_end = TestRestChannelHistory.ably.time() - - for i in range(40, 60): - history0.publish('history%d' % i, i) - time.sleep(0.1) - - time.sleep(16) - - history = history0.history(direction='backwards', start=interval_start, - end=interval_end) - - messages = history.current - self.assertEqual(20, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(39, 19, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expect messages in reverse order') - - def test_channel_history_paginate_forwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_paginate_f'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='forwards', limit=10) - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(0, 10)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_next() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(10, 20)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_next() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(20, 30)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - def test_channel_history_paginate_backwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_paginate_b'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='backwards', limit=10) - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(49, 39, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_next() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(39, 29, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_next() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(29, 19, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - def test_channel_history_paginate_forwards(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_paginate_first_f'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='forwards', limit=10) - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(0, 10)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_next() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(10, 20)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_first() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(0, 10)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - def test_channel_history_paginate_backwards_rel_first(self): - history0 = TestRestChannelHistory.ably.channels['persisted:channelhistory_paginate_first_b'] - - for i in range(50): - history0.publish('history%d' % i, i) - - time.sleep(16) - - history = history0.history(direction='backwards', limit=10) - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(49, 39, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_next() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(39, 29, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') - - history = history.get_first() - messages = history.current - - self.assertEqual(10, len(messages)) - - message_contents = {m.name:m for m in messages} - expected_messages = [message_contents['history%d' % i] for i in range(49, 39, -1)] - - self.assertEqual(expected_messages, messages, - msg='Expected 10 messages') diff --git a/test/ably/restchannelpublish_test.py b/test/ably/restchannelpublish_test.py deleted file mode 100644 index 94dce5a5..00000000 --- a/test/ably/restchannelpublish_test.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import absolute_import - -import math -from datetime import datetime -from datetime import timedelta -import json -import logging -import time -import unittest - -import six - -from ably import AblyException -from ably import AblyRest -from ably import Options - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() -log = logging.getLogger(__name__) - -class TestRestChannelPublish(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"], - use_text_protocol=True)) - - cls.ably_binary = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"], - use_text_protocol=False)) - - def test_publish_various_datatypes_text(self): - publish0 = TestRestChannelPublish.ably.channels["persisted:publish0"] - - publish0.publish("publish0", True) - publish0.publish("publish1", 24) - publish0.publish("publish2", 24.234) - publish0.publish("publish3", six.u("This is a string message payload")) - publish0.publish("publish4", b"This is a byte[] message payload") - publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish6", ["This is a JSONArray message payload"]) - - # Wait for the history to be persisted - time.sleep(16) - - # Get the history for this channel - history = publish0.history() - messages = history.current - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) - - self.assertEqual(True, message_contents["publish0"], - msg="Expect publish0 to be Boolean(true)") - self.assertEqual(24, int(message_contents["publish1"]), - msg="Expect publish1 to be Int(24)") - self.assertEqual(24.234, float(message_contents["publish2"]), - msg="Expect publish2 to be Double(24.234)") - self.assertEqual(six.u("This is a string message payload"), - message_contents["publish3"], - msg="Expect publish3 to be expected String)") - self.assertEqual(b"This is a byte[] message payload", - message_contents["publish4"], - msg="Expect publish4 to be expected byte[]. Actual: %s" % str(message_contents['publish4'])) - self.assertEqual({"test": "This is a JSONObject message payload"}, - message_contents["publish5"], - msg="Expect publish5 to be expected JSONObject") - self.assertEqual(["This is a JSONArray message payload"], - message_contents["publish6"], - msg="Expect publish6 to be expected JSONObject") - - def test_publish_various_datatypes_binary(self): - publish1 = TestRestChannelPublish.ably_binary.channels.publish1 - - publish1.publish("publish0", True) - publish1.publish("publish1", 24) - publish1.publish("publish2", 24.234) - publish1.publish("publish3", "This is a string message payload") - publish1.publish("publish4", bytearray("This is a byte[] message payload", "utf_8")) - publish1.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish1.publish("publish6", ["This is a JSONArray message payload"]) - - # Wait for the history to be persisted - time.sleep(16) - - # Get the history for this channel - messages = publish1.history() - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = dict((m.name, m.data) for m in messages) - - self.assertEqual(True, message_contents["publish0"], - msg="Expect publish0 to be Boolean(true)") - self.assertEqual(24, int(message_contents["publish1"]), - msg="Expect publish1 to be Int(24)") - self.assertEqual(24.234, float(message_contents["publish2"]), - msg="Expect publish2 to be Double(24.234)") - self.assertEqual("This is a string message payload", - message_contents["publish3"], - msg="Expect publish3 to be expected String)") - self.assertEqual("This is a byte[] message payload", - message_contents["publish4"], - msg="Expect publish4 to be expected byte[]") - self.assertEqual({"test": "This is a JSONObject message payload"}, - json.loads(message_contents["publish5"]), - msg="Expect publish5 to be expected JSONObject") - self.assertEqual(["This is a JSONArray message payload"], - json.loads(message_contents["publish6"]), - msg="Expect publish6 to be expected JSONObject") - diff --git a/test/ably/restcrypto_test.py b/test/ably/restcrypto_test.py deleted file mode 100644 index 7370e135..00000000 --- a/test/ably/restcrypto_test.py +++ /dev/null @@ -1,245 +0,0 @@ -from __future__ import absolute_import - -import logging -import time -import unittest - -import six - -from ably import AblyException -from ably import AblyRest -from ably import ChannelOptions -from ably import Options -from ably.util.crypto import CipherParams, CipherData, get_cipher, get_default_params - -from Crypto import Random - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() -log = logging.getLogger(__name__) - -class TestRestCrypto(unittest.TestCase): - @classmethod - def setUpClass(cls): - options = Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"], - use_text_protocol=True) - cls.ably = AblyRest(options) - cls.ably2 = AblyRest(options) - - def test_cbc_channel_cipher(self): - key = six.b( - '\x93\xe3\x5c\xc9\x77\x53\xfd\x1a' - '\x79\xb4\xd8\x84\xe7\xdc\xfd\xdf' - ) - iv = six.b( - '\x28\x4c\xe4\x8d\x4b\xdc\x9d\x42' - '\x8a\x77\x6b\x53\x2d\xc7\xb5\xc0' - ) - log.debug("KEY_LEN: %d" % len(key)) - log.debug("IV_LEN: %d" % len(iv)) - cipher = get_cipher(CipherParams(secret_key=key, iv=iv)) - - plaintext = six.b("The quick brown fox") - expected_ciphertext = six.b( - '\x28\x4c\xe4\x8d\x4b\xdc\x9d\x42' - '\x8a\x77\x6b\x53\x2d\xc7\xb5\xc0' - '\x83\x5c\xcf\xce\x0c\xfd\xbe\x37' - '\xb7\x92\x12\x04\x1d\x45\x68\xa4' - '\xdf\x7f\x6e\x38\x17\x4a\xff\x50' - '\x73\x23\xbb\xca\x16\xb0\xe2\x84' - ) - - actual_ciphertext = cipher.encrypt(plaintext) - - self.assertEqual(expected_ciphertext, actual_ciphertext) - - def test_crypto_publish_text(self): - channel_options = ChannelOptions(encrypted=True) - publish0 = TestRestCrypto.ably.channels.get("persisted:crypto_publish_text", channel_options) - - publish0.publish("publish0", True) - publish0.publish("publish1", 24) - publish0.publish("publish2", 24.234) - publish0.publish("publish3", six.u("This is a string message payload")) - publish0.publish("publish4", six.b("This is a byte[] message payload")) - publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish6", ["This is a JSONArray message payload"]) - - time.sleep(16) - - history = publish0.history() - messages = history.current - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) - - self.assertEqual(True, message_contents["publish0"], - msg="Expect publish0 to be Boolean(true)") - self.assertEqual(24, int(message_contents["publish1"]), - msg="Expect publish1 to be Int(24)") - self.assertEqual(24.234, float(message_contents["publish2"]), - msg="Expect publish2 to be Double(24.234)") - self.assertEqual(six.u("This is a string message payload"), - message_contents["publish3"], - msg="Expect publish3 to be expected String)") - self.assertEqual(b"This is a byte[] message payload", - message_contents["publish4"], - msg="Expect publish4 to be expected byte[]. Actual: %s" % str(message_contents['publish4'])) - self.assertEqual({"test": "This is a JSONObject message payload"}, - message_contents["publish5"], - msg="Expect publish5 to be expected JSONObject") - self.assertEqual(["This is a JSONArray message payload"], - message_contents["publish6"], - msg="Expect publish6 to be expected JSONObject") - - def test_crypto_publish_text_256(self): - rndfile = Random.new() - key = rndfile.read(32) - cipher_params = get_default_params(key=key) - channel_options = ChannelOptions(encrypted=True, cipher_params=cipher_params) - - publish0 = TestRestCrypto.ably.channels.get("persisted:crypto_publish_text_256", channel_options) - - publish0.publish("publish0", True) - publish0.publish("publish1", 24) - publish0.publish("publish2", 24.234) - publish0.publish("publish3", six.u("This is a string message payload")) - publish0.publish("publish4", six.b("This is a byte[] message payload")) - publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish6", ["This is a JSONArray message payload"]) - - time.sleep(16) - - history = publish0.history() - messages = history.current - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) - - self.assertEqual(True, message_contents["publish0"], - msg="Expect publish0 to be Boolean(true)") - self.assertEqual(24, int(message_contents["publish1"]), - msg="Expect publish1 to be Int(24)") - self.assertEqual(24.234, float(message_contents["publish2"]), - msg="Expect publish2 to be Double(24.234)") - self.assertEqual(six.u("This is a string message payload"), - message_contents["publish3"], - msg="Expect publish3 to be expected String)") - self.assertEqual(b"This is a byte[] message payload", - message_contents["publish4"], - msg="Expect publish4 to be expected byte[]. Actual: %s" % str(message_contents['publish4'])) - self.assertEqual({"test": "This is a JSONObject message payload"}, - message_contents["publish5"], - msg="Expect publish5 to be expected JSONObject") - self.assertEqual(["This is a JSONArray message payload"], - message_contents["publish6"], - msg="Expect publish6 to be expected JSONObject") - - def test_crypto_publish_key_mismatch(self): - channel_options = ChannelOptions(encrypted=True) - publish0 = TestRestCrypto.ably.channels.get("persisted:crypto_publish_key_mismatch", channel_options) - - publish0.publish("publish0", True) - publish0.publish("publish1", 24) - publish0.publish("publish2", 24.234) - publish0.publish("publish3", six.u("This is a string message payload")) - publish0.publish("publish4", six.b("This is a byte[] message payload")) - publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish6", ["This is a JSONArray message payload"]) - - time.sleep(16) - rx_channel = TestRestCrypto.ably2.channels.get("persisted:crypto_publish_key_mismatch", channel_options) - - try: - with self.assertRaises(AblyException) as cm: - messages = rx_channel.history() - except Exception as e: - log.debug('test_crypto_publish_key_mismatch_fail: rx_channel.history not creating exception') - log.debug(messages.current[0].data) - log.debug(messages.current[0].decrypt()) - - raise(e) - - - the_exception = cm.exception - self.assertEqual('invalid-padding', the_exception.reason) - - def test_crypto_send_unencrypted(self): - publish0 = TestRestCrypto.ably.channels['persisted:crypto_send_unencrypted'] - publish0.publish("publish0", True) - publish0.publish("publish1", 24) - publish0.publish("publish2", 24.234) - publish0.publish("publish3", six.u("This is a string message payload")) - publish0.publish("publish4", six.b("This is a byte[] message payload")) - publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish6", ["This is a JSONArray message payload"]) - - time.sleep(16) - rx_options = ChannelOptions(encrypted=True) - rx_channel = TestRestCrypto.ably2.channels.get('persisted:crypto_send_unencrypted', rx_options) - - history = rx_channel.history() - messages = history.current - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) - - self.assertEqual(True, message_contents["publish0"], - msg="Expect publish0 to be Boolean(true)") - self.assertEqual(24, int(message_contents["publish1"]), - msg="Expect publish1 to be Int(24)") - self.assertEqual(24.234, float(message_contents["publish2"]), - msg="Expect publish2 to be Double(24.234)") - self.assertEqual(six.u("This is a string message payload"), - message_contents["publish3"], - msg="Expect publish3 to be expected String)") - self.assertEqual(b"This is a byte[] message payload", - message_contents["publish4"], - msg="Expect publish4 to be expected byte[]. Actual: %s" % str(message_contents['publish4'])) - self.assertEqual({"test": "This is a JSONObject message payload"}, - message_contents["publish5"], - msg="Expect publish5 to be expected JSONObject") - self.assertEqual(["This is a JSONArray message payload"], - message_contents["publish6"], - msg="Expect publish6 to be expected JSONObject") - - def test_crypto_send_encrypted_unhandled(self): - channel_options = ChannelOptions(encrypted=True) - publish0 = TestRestCrypto.ably.channels.get("persisted:crypto_send_encrypted_unhandled", channel_options) - - publish0.publish("publish0", True) - publish0.publish("publish1", 24) - publish0.publish("publish2", 24.234) - publish0.publish("publish3", six.u("This is a string message payload")) - publish0.publish("publish4", six.b("This is a byte[] message payload")) - publish0.publish("publish5", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish6", ["This is a JSONArray message payload"]) - - time.sleep(16) - - rx_channel = TestRestCrypto.ably2.channels['persisted:crypto_send_encrypted_unhandled'] - history = rx_channel.history() - messages = history.current - self.assertIsNotNone(messages, msg="Expected non-None messages") - self.assertEqual(7, len(messages), msg="Expected 7 messages") - - message_contents = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) - - for k, v in six.iteritems(message_contents): - if (k == "publish0"): - self.assertEqual(True, v, "Expect publish0 to be BOOL(True)") - continue - self.assertTrue(isinstance(v, CipherData)) - diff --git a/test/ably/restinit_test.py b/test/ably/restinit_test.py deleted file mode 100644 index 5047c7f3..00000000 --- a/test/ably/restinit_test.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import absolute_import - -import unittest - -from ably import AblyRest -from ably import AblyException -from ably import Options -from ably.transport.defaults import Defaults - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() - - -class TestRestInit(unittest.TestCase): - def test_key_only(self): - AblyRest(Options.with_key(test_vars["keys"][0]["key_str"])) - - def test_key_in_options(self): - AblyRest(Options.with_key(test_vars["keys"][0]["key_str"])) - - def test_specified_host(self): - ably = AblyRest(Options(host="some.other.host")) - self.assertEqual("some.other.host", ably.options.host, - msg="Unexpected host mismatch") - - def test_specified_port(self): - ably = AblyRest(Options(port=9998, tls_port=9999)) - self.assertEqual(9999, Defaults.get_port(ably.options), - msg="Unexpected port mismatch. Expected: 9999. Actual: %d" % ably.options.tls_port) - - def test_tls_defaults_to_true(self): - ably = AblyRest() - self.assertTrue(ably.options.tls, - msg="Expected encryption to default to true") - self.assertEqual(Defaults.tls_port, Defaults.get_port(ably.options), - msg="Unexpected port mismatch") - - def test_tls_can_be_disabled(self): - ably = AblyRest(Options(tls=False)) - self.assertFalse(ably.options.tls, - msg="Expected encryption to be False") - self.assertEqual(Defaults.port, Defaults.get_port(ably.options), - msg="Unexpected port mismatch") - - -if __name__ == "__main__": - unittest.main() - diff --git a/test/ably/restsetup.py b/test/ably/restsetup.py deleted file mode 100644 index 2dd64d9c..00000000 --- a/test/ably/restsetup.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import absolute_import, print_function - -import json -import os -import logging - -from ably.http.httputils import HttpUtils -from ably.rest.rest import AblyRest -from ably.types.capability import Capability -from ably.types.options import Options -from ably.util.exceptions import AblyException - -app_spec_text = "" -log = logging.getLogger(__name__) - -with open(os.path.dirname(__file__) + '/../assets/testAppSpec.json', 'r') as f: - app_spec_text = f.read() - -print(app_spec_text) - -tls = (os.environ.get('ABLY_TLS') or "true").lower() == "true" -host = os.environ.get('ABLY_HOST') - - -if host is None: - host = "staging-rest.ably.io" - -if host.endswith("rest.ably.io"): - host = "staging-rest.ably.io" - port = 80 - tls_port = 443 -else: - tls = tls and not host.equals("localhost") - port = 8080 - tls_port = 8081 - - -ably = AblyRest(Options(host=host, - port=port, - tls_port=tls_port, - tls=tls)) - - -class RestSetup: - __test_vars = None - - @staticmethod - def get_test_vars(sender=None): - if not RestSetup.__test_vars: - r = ably.http.post("/apps", headers=HttpUtils.default_post_headers(), - body=app_spec_text, skip_auth=True) - AblyException.raise_for_response(r) - - app_spec = r.json() - - app_id = app_spec.get("appId", "") - - test_vars = { - "app_id": app_id, - "host": host, - "port": port, - "tls_port": tls_port, - "tls": tls, - "keys": [{ - "key_id": "%s.%s" % (app_id, k.get("id", "")), - "key_value": k.get("value", ""), - "key_str": "%s.%s:%s" % (app_id, k.get("id", ""), k.get("value", "")), - "capability": Capability(json.loads(k.get("capability", "{}"))), - } for k in app_spec.get("keys", [])] - } - - RestSetup.__test_vars = test_vars - log.debug([(app_id, k.get("id", ""), k.get("value", "")) - for k in app_spec.get("keys", [])]) - return RestSetup.__test_vars - - @staticmethod - def clear_test_vars(): - test_vars = RestSetup.__test_vars - options = Options.with_key(test_vars["keys"][0]["key_str"]) - options.host = test_vars["host"] - options.port = test_vars["port"] - options.tls_port = test_vars["tls_port"] - options.tls = test_vars["tls"] - ably = AblyRest(options) - - headers = HttpUtils.default_get_headers() - ably.http.delete('/apps/' + test_vars['app_id'], headers) - - RestSetup.__test_vars = None diff --git a/test/ably/resttime_test.py b/test/ably/resttime_test.py deleted file mode 100644 index ee88f6ce..00000000 --- a/test/ably/resttime_test.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import absolute_import - -import time -import unittest - -from ably import AblyException -from ably import AblyRest -from ably import Options - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() - - -class TestRestTime(unittest.TestCase): - def test_time_accuracy(self): - ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"])) - - reported_time = ably.time() - actual_time = time.time() * 1000.0 - - self.assertLess(abs(actual_time - reported_time), 2000, - msg="Time is not within 2 seconds") - - def test_time_without_key_or_token(self): - ably = AblyRest(Options(host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"])) - - ably.time() - - def test_time_fails_without_valid_host(self): - ably = AblyRest(Options(host="this.host.does.not.exist", - port=test_vars["port"], - tls_port=test_vars["tls_port"])) - - self.assertRaises(AblyException, ably.time) - - diff --git a/test/ably/resttoken_test.py b/test/ably/resttoken_test.py deleted file mode 100644 index efbc4c9c..00000000 --- a/test/ably/resttoken_test.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import absolute_import - -import time -import json -import logging -import unittest - -import six - -from ably import AblyException -from ably import AblyRest -from ably import Capability -from ably import Options - -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() -log = logging.getLogger(__name__) - - -class TestRestToken(unittest.TestCase): - def server_time(self): - return int(self.ably.time() / 1000.0) - - def setUp(self): - capability = {"*":["*"]} - self.permit_all = six.text_type(Capability(capability)) - self.ably = AblyRest(Options.with_key(test_vars["keys"][0]["key_str"], - host=test_vars["host"], - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"])) - - def test_request_token_null_params(self): - pre_time = self.server_time() - token_details = self.ably.auth.request_token() - post_time = self.server_time() - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertGreaterEqual(token_details.issued_at, - pre_time, - msg="Unexpected issued at time") - self.assertLessEqual(token_details.issued_at, - post_time, - msg="Unexpected issued at time") - self.assertEqual(self.permit_all, - six.text_type(token_details.capability), - msg="Unexpected capability") - - def test_request_token_explicit_timestamp(self): - pre_time = self.server_time() - token_details = self.ably.auth.request_token(token_params={ - "timestamp":pre_time - }) - post_time = self.server_time() - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertGreaterEqual(token_details.issued_at, - pre_time, - msg="Unexpected issued at time") - self.assertLessEqual(token_details.issued_at, - post_time, - msg="Unexpected issued at time") - self.assertEqual(self.permit_all, - six.text_type(Capability(token_details.capability)), - msg="Unexpected Capability") - - def test_request_token_explicit_invalid_timestamp(self): - request_time = self.server_time() - explicit_timestamp = request_time - 30 * 60 - - self.assertRaises(AblyException, self.ably.auth.request_token, - token_params={"timestamp":explicit_timestamp}) - - def test_request_token_with_system_timestamp(self): - pre_time = self.server_time() - token_details = self.ably.auth.request_token(query_time=True) - post_time = self.server_time() - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertGreaterEqual(token_details.issued_at, - pre_time, - msg="Unexpected issued at time") - self.assertLessEqual(token_details.issued_at, - post_time, - msg="Unexpected issued at time") - self.assertEqual(self.permit_all, - six.text_type(Capability(token_details.capability)), - msg="Unexpected Capability") - - def test_request_token_with_duplicate_nonce(self): - request_time = self.server_time() - token_details = self.ably.auth.request_token(token_params={ - "timestamp":request_time, - "nonce":'1234567890123456' - }) - self.assertIsNotNone(token_details.id, msg="Expected token id") - - self.assertRaises(AblyException, self.ably.auth.request_token, - token_params={ - "timestamp":request_time, - "nonce":'1234567890123456' - }) - - def test_request_token_with_capability_that_subsets_key_capability(self): - capability = Capability({ - "onlythischannel": ["subscribe"] - }) - - token_params = { - "capability": capability, - } - - token_details = self.ably.auth.request_token(token_params=token_params) - - self.assertIsNotNone(token_details) - self.assertIsNotNone(token_details.id) - self.assertEqual(capability, token_details.capability, - msg="Unexpected capability") - - def test_request_token_with_specified_key(self): - key = RestSetup.get_test_vars()["keys"][1] - token_details = self.ably.auth.request_token(key_id=key["key_id"], - key_value=key["key_value"]) - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(key.get("capability"), - token_details.capability, - msg="Unexpected capability") - - def test_request_token_with_invalid_mac(self): - self.assertRaises(AblyException, self.ably.auth.request_token, - token_params={"mac":"thisisnotavalidmac"}) - - def test_request_token_with_specified_ttl(self): - token_details = self.ably.auth.request_token(token_params={ - "ttl":100 - }) - self.assertIsNotNone(token_details.id, msg="Expected token id") - self.assertEqual(token_details.issued_at + 100, - token_details.expires, msg="Unexpected expires") - - def test_token_with_excessive_ttl(self): - excessive_ttl = 365 * 24 * 60 * 60 - self.assertRaises(AblyException, self.ably.auth.request_token, - token_params={"ttl":excessive_ttl}) - - def test_token_generation_with_invalid_ttl(self): - self.assertRaises(AblyException, self.ably.auth.request_token, - token_params={"ttl":-1}) diff --git a/test/ably/testapp.py b/test/ably/testapp.py new file mode 100644 index 00000000..f657fdd4 --- /dev/null +++ b/test/ably/testapp.py @@ -0,0 +1,102 @@ +import json +import logging +import os + +from ably.realtime.realtime import AblyRealtime +from ably.rest.rest import AblyRest +from ably.transport.defaults import Defaults +from ably.types.capability import Capability +from ably.types.options import Options +from ably.util.exceptions import AblyException + +log = logging.getLogger(__name__) + +with open(os.path.dirname(__file__) + '/../assets/testAppSpec.json') as f: + app_spec_local = json.loads(f.read()) + +tls = (os.environ.get('ABLY_TLS') or "true").lower() == "true" +endpoint = os.environ.get('ABLY_ENDPOINT', 'nonprod:sandbox') + +port = 80 +tls_port = 443 + +ably = AblyRest(token='not_a_real_token', + port=port, tls_port=tls_port, tls=tls, + endpoint=endpoint, + use_binary_protocol=False) + + +class TestApp: + __test_vars = None + + @staticmethod + async def get_test_vars(): + if not TestApp.__test_vars: + r = await ably.http.post("/apps", body=app_spec_local, skip_auth=True) + AblyException.raise_for_response(r) + + app_spec = r.json() + + app_id = app_spec.get("appId", "") + + test_vars = { + "app_id": app_id, + "port": port, + "tls_port": tls_port, + "tls": tls, + "endpoint": endpoint, + "host": Defaults.get_hostname(endpoint), + "keys": [{ + "key_name": "{}.{}".format(app_id, k.get("id", "")), + "key_secret": k.get("value", ""), + "key_str": "{}.{}:{}".format(app_id, k.get("id", ""), k.get("value", "")), + "capability": Capability(json.loads(k.get("capability", "{}"))), + } for k in app_spec.get("keys", [])] + } + + TestApp.__test_vars = test_vars + log.debug([(app_id, k.get("id", ""), k.get("value", "")) + for k in app_spec.get("keys", [])]) + + return TestApp.__test_vars + + @staticmethod + async def get_ably_rest(**kw): + test_vars = await TestApp.get_test_vars() + options = TestApp.get_options(test_vars, **kw) + options.update(kw) + return AblyRest(**options) + + @staticmethod + async def get_ably_realtime(**kw): + test_vars = await TestApp.get_test_vars() + options = TestApp.get_options(test_vars, **kw) + return AblyRealtime(**options) + + @staticmethod + def get_options(test_vars, **kwargs): + options = { + 'port': test_vars["port"], + 'tls_port': test_vars["tls_port"], + 'tls': test_vars["tls"], + 'endpoint': test_vars["endpoint"], + } + auth_methods = ["auth_url", "auth_callback", "token", "token_details", "key"] + if not any(x in kwargs for x in auth_methods): + options["key"] = test_vars["keys"][0]["key_str"] + + options.update(kwargs) + + return options + + @staticmethod + async def clear_test_vars(): + test_vars = TestApp.__test_vars + options = Options(key=test_vars["keys"][0]["key_str"]) + options.port = test_vars["port"] + options.tls_port = test_vars["tls_port"] + options.tls = test_vars["tls"] + ably = await TestApp.get_ably_rest() + await ably.http.delete('/apps/' + test_vars['app_id']) + TestApp.__test_vars = None + await ably.close() diff --git a/test/ably/utils.py b/test/ably/utils.py new file mode 100644 index 00000000..ae19e0b5 --- /dev/null +++ b/test/ably/utils.py @@ -0,0 +1,267 @@ +import asyncio +import functools +import os +import random +import string +import time +from typing import Awaitable, Callable +from unittest import mock + +import msgpack +import respx +from httpx import Response + +from ably.http.http import Http + + +class BaseTestCase: + + def respx_add_empty_msg_pack(self, url, method='GET'): + respx.route(method=method, url=url).return_value = Response( + status_code=200, + headers={'content-type': 'application/x-msgpack'}, + content=msgpack.packb({}) + ) + + @classmethod + def get_channel_name(cls, prefix=''): + return prefix + random_string(10) + + @classmethod + def get_channel(cls, prefix=''): + name = cls.get_channel_name(prefix) + return cls.ably.channels.get(name) + + +class BaseAsyncTestCase: + + def respx_add_empty_msg_pack(self, url, method='GET'): + respx.route(method=method, url=url).return_value = Response( + status_code=200, + headers={'content-type': 'application/x-msgpack'}, + content=msgpack.packb({}) + ) + + @classmethod + def get_channel_name(cls, prefix=''): + return prefix + random_string(10) + + def get_channel(self, prefix=''): + name = self.get_channel_name(prefix) + return self.ably.channels.get(name) + + +def assert_responses_type(protocol): + """ + This is a decorator to check if we retrieved responses with the correct protocol. + usage: + + @assert_responses_type('json') + def test_something(self): + ... + + this will check if all responses received during the test will be in the format + json. + supports json and msgpack + """ + responses = [] + + def patch(): + original = Http.make_request + + async def fake_make_request(self, *args, **kwargs): + response = await original(self, *args, **kwargs) + responses.append(response) + return response + + patcher = mock.patch.object(Http, 'make_request', fake_make_request) + patcher.start() + return patcher + + def unpatch(patcher): + patcher.stop() + + def test_decorator(fn): + @functools.wraps(fn) + async def test_decorated(self, *args, **kwargs): + patcher = patch() + await fn(self, *args, **kwargs) + unpatch(patcher) + + assert len(responses) >= 1, \ + "If your test doesn't make any requests, use the @dont_vary_protocol decorator" + + for response in responses: + # In HTTP/2 some header fields are optional in case of 204 status code + if protocol == 'json': + if response.status_code != 204: + assert response.headers['content-type'] == 'application/json' + if response.content: + response.json() + else: + if response.status_code != 204: + assert response.headers['content-type'] == 'application/x-msgpack' + if response.content: + msgpack.unpackb(response.content) + + return test_decorated + + return test_decorator + + +class VaryByProtocolTestsMetaclass(type): + """ + Metaclass to run tests in more than one protocol. + Usage: + * set this as metaclass of the TestCase class + * create the following method: + def per_protocol_setup(self, use_binary_protocol): + # do something here that will run before each test. + * now every test will run twice and before test is run per_protocol_setup + is called + * exclude tests with the @dont_vary_protocol decorator + """ + + def __new__(cls, clsname, bases, dct): + for key, value in tuple(dct.items()): + if key.startswith('test') and not getattr(value, 'dont_vary_protocol', + False): + wrapper_bin = cls.wrap_as('bin', key, value) + wrapper_text = cls.wrap_as('text', key, value) + + dct[key + '_bin'] = wrapper_bin + dct[key + '_text'] = wrapper_text + del dct[key] + + return super().__new__(cls, clsname, bases, dct) + + @staticmethod + def wrap_as(ttype, old_name, old_func): + expected_content = {'bin': 'msgpack', 'text': 'json'} + + @assert_responses_type(expected_content[ttype]) + async def wrapper(self): + if hasattr(self, 'per_protocol_setup'): + self.per_protocol_setup(ttype == 'bin') + await old_func(self) + + wrapper.__name__ = old_name + '_' + ttype + return wrapper + + +def dont_vary_protocol(func): + func.dont_vary_protocol = True + return func + + +def random_string(length, alphabet=string.ascii_letters): + return ''.join([random.choice(alphabet) for x in range(length)]) + + +def new_dict(src, **kw): + new = src.copy() + new.update(kw) + return new + + +def get_random_key(d): + return random.choice(list(d)) + + +def get_submodule_dir(filepath): + root_dir = os.path.dirname(filepath) + while True: + if os.path.exists(os.path.join(root_dir, 'submodules')): + return os.path.join(root_dir, 'submodules') + root_dir = os.path.dirname(root_dir) + + +async def assert_waiter(block: Callable[[], Awaitable[bool]], timeout: float = 10) -> None: + """ + Polls a condition until it succeeds or times out. + Args: + block: A callable that returns a boolean indicating success + timeout: Maximum time to wait in seconds (default: 10) + Raises: + TimeoutError: If condition not met within timeout + """ + try: + await asyncio.wait_for(_poll_until_success(block), timeout=timeout) + except asyncio.TimeoutError: + raise asyncio.TimeoutError(f"Condition not met within {timeout}s") from None + + +async def _poll_until_success(block: Callable[[], Awaitable[bool]]) -> None: + while True: + try: + success = await block() + if success: + break + except Exception: + pass + + await asyncio.sleep(0.1) + + +def assert_waiter_sync(block: Callable[[], bool], timeout: float = 10) -> None: + """ + Blocking version of assert_waiter that polls a condition until it succeeds or times out. + Args: + block: A callable that returns a boolean indicating success + timeout: Maximum time to wait in seconds (default: 10) + Raises: + TimeoutError: If condition not met within timeout + """ + start_time = time.time() + + while True: + try: + success = block() + if success: + break + except Exception: + pass + + if time.time() - start_time >= timeout: + raise TimeoutError(f"Condition not met within {timeout}s") + + time.sleep(0.1) + + +class WaitableEvent: + """ + Replacement for asyncio.Future that will work with autogenerated sync tests. + """ + def __init__(self): + self._finished = False + + def checker(self): + async def inner_checker(): + return self._finished + + return inner_checker + + async def wait(self, timeout=10): + await assert_waiter(self.checker(), timeout) + + def finish(self): + self._finished = True + +class ReusableFuture: + """ + A reusable future that after each wait() resets itself and wait for the next value. + """ + def __init__(self): + self.__future = asyncio.Future() + + async def get(self, timeout=10): + await asyncio.wait_for(self.__future, timeout=timeout) + self.__future = asyncio.Future() + + def set_result(self, result): + if not self.__future.done(): + self.__future.set_result(result) + + def set_exception(self, exception): + if not self.__future.done(): + self.__future.set_exception(exception) diff --git a/test/assets/testAppSpec.json b/test/assets/testAppSpec.json index f1da7b41..90f1655e 100644 --- a/test/assets/testAppSpec.json +++ b/test/assets/testAppSpec.json @@ -22,6 +22,31 @@ { "id": "persisted", "persisted": true - } - ] -} \ No newline at end of file + }, + { + "id": "canpublish", + "pushEnabled": true + }, + { + "id": "mutable", + "mutableMessages": true + } + ], + "channels": [ + { + "name": "persisted:presence_fixtures", + "presence": [ + { "clientId": "client_bool", "data": "true" }, + { "clientId": "client_int", "data": "24" }, + { "clientId": "client_string", "data": "This is a string clientData payload" }, + { "clientId": "client_json", "data": "{ \"test\": \"This is a JSONObject clientData payload\"}" }, + { "clientId": "client_decoded", "data": "{\"example\":{\"json\":\"Object\"}}", "encoding": "json/utf-8" }, + { + "clientId": "client_encoded", + "data": "O5ExL1suyT7v+CzpfU+IUZM+o4S/xshIRp/uPrhf8wg=", + "encoding": "json/utf-8/cipher+aes-128-cbc/base64" + } + ] + } + ] +} diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 00000000..e5bc4004 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,4 @@ +# Configure pytest-asyncio +pytest_plugins = ( + 'pytest_asyncio', +) diff --git a/test/unit/annotation_test.py b/test/unit/annotation_test.py new file mode 100644 index 00000000..947ed04e --- /dev/null +++ b/test/unit/annotation_test.py @@ -0,0 +1,319 @@ +"""Unit tests for Annotation type and validation logic. + +Tests cover: +- RSAN1a3: type validation in construct_validate_annotation +- TAN2a: id and connectionId fields on Annotation +- RSAN1c4: idempotent publishing ID format +- RTAN4b: protocol message field population +- RSAN1c1/RSAN2a: explicit action setting in publish/delete +- TAN3: from_encoded / from_encoded_array decoding +- TAN2i: serial-based equality +""" + +import base64 + +import pytest + +from ably.rest.annotations import construct_validate_annotation, serial_from_msg_or_serial +from ably.types.annotation import Annotation, AnnotationAction +from ably.types.message import Message +from ably.util.exceptions import AblyException + +# --- RSAN1a3: type validation --- + +def test_construct_validate_annotation_requires_type(): + """RSAN1a3: Annotation type must be specified""" + annotation = Annotation(name='👍') # No type + with pytest.raises(AblyException) as exc_info: + construct_validate_annotation('serial123', annotation) + assert exc_info.value.status_code == 400 + assert exc_info.value.code == 40000 + assert 'type' in str(exc_info.value).lower() + + +def test_construct_validate_annotation_with_type_succeeds(): + """RSAN1a3: Annotation with type should pass validation""" + annotation = Annotation(type='reaction:distinct.v1', name='👍') + result = construct_validate_annotation('serial123', annotation) + assert result.type == 'reaction:distinct.v1' + assert result.message_serial == 'serial123' + + +def test_construct_validate_annotation_requires_annotation_object(): + """Second argument must be an Annotation instance""" + with pytest.raises(AblyException) as exc_info: + construct_validate_annotation('serial123', 'not_an_annotation') + assert exc_info.value.status_code == 400 + + +def test_serial_from_msg_or_serial_with_string(): + """RSAN1a: Accept string serial""" + assert serial_from_msg_or_serial('abc123') == 'abc123' + + +def test_serial_from_msg_or_serial_with_message(): + """RSAN1a1: Accept Message object with serial""" + msg = Message(serial='abc123') + assert serial_from_msg_or_serial(msg) == 'abc123' + + +def test_serial_from_msg_or_serial_rejects_invalid(): + """RSAN1a: Reject invalid input""" + with pytest.raises(AblyException): + serial_from_msg_or_serial(None) + with pytest.raises(AblyException): + serial_from_msg_or_serial(12345) + + +# --- TAN2a: id field on Annotation --- + +def test_annotation_has_id_field(): + """TAN2a: Annotation must have id field""" + annotation = Annotation(id='test-id-123', type='reaction', name='👍') + assert annotation.id == 'test-id-123' + + +def test_annotation_id_in_as_dict(): + """TAN2a: id should be included in as_dict() output""" + annotation = Annotation(id='test-id', type='reaction', name='👍') + d = annotation.as_dict() + assert d['id'] == 'test-id' + + +def test_annotation_id_from_encoded(): + """TAN2a: id should be read from encoded wire format""" + encoded = { + 'id': 'wire-id-123', + 'type': 'reaction', + 'name': '👍', + 'action': 0, + } + annotation = Annotation.from_encoded(encoded) + assert annotation.id == 'wire-id-123' + + +def test_annotation_id_in_copy_with(): + """TAN2a: id should be preserved/overridden in _copy_with()""" + annotation = Annotation(id='original-id', type='reaction', name='👍') + copy = annotation._copy_with(id='new-id') + assert copy.id == 'new-id' + assert annotation.id == 'original-id' # Original unchanged + + +# --- TAN2a/TAN2c: connectionId field --- + +def test_annotation_has_connection_id(): + """Annotation must have connection_id field""" + annotation = Annotation(connection_id='conn-123', type='reaction', name='👍') + assert annotation.connection_id == 'conn-123' + + +def test_annotation_connection_id_from_encoded(): + """connection_id should be read from encoded wire format""" + encoded = { + 'connectionId': 'conn-456', + 'type': 'reaction', + 'action': 0, + } + annotation = Annotation.from_encoded(encoded) + assert annotation.connection_id == 'conn-456' + + +# --- RSAN1c4: idempotent publishing ID format --- + +def test_idempotent_id_format(): + """RSAN1c4: ID should be base64(9 random bytes) + ':0'""" + # We can't test the actual REST publish without a server, but we can + # verify the format by checking the regex pattern + import os + random_id = base64.b64encode(os.urandom(9)).decode('ascii') + ':0' + # Should be base64 chars followed by ':0' + assert random_id.endswith(':0') + # Base64 of 9 bytes = 12 chars + base64_part = random_id[:-2] + assert len(base64_part) == 12 + # Verify it's valid base64 + decoded = base64.b64decode(base64_part) + assert len(decoded) == 9 + + +# --- RTAN4b: protocol message field population --- + +def test_update_inner_annotation_fields(): + """RTAN4b: Populate annotation fields from protocol message envelope""" + proto_msg = { + 'id': 'proto-msg-id', + 'connectionId': 'conn-abc', + 'timestamp': 1234567890, + 'annotations': [ + {'type': 'reaction', 'name': '👍'}, + {'type': 'reaction', 'name': '👎'}, + ] + } + Annotation.update_inner_annotation_fields(proto_msg) + annotations = proto_msg['annotations'] + + # First annotation + assert annotations[0]['id'] == 'proto-msg-id:0' + assert annotations[0]['connectionId'] == 'conn-abc' + assert annotations[0]['timestamp'] == 1234567890 + + # Second annotation + assert annotations[1]['id'] == 'proto-msg-id:1' + assert annotations[1]['connectionId'] == 'conn-abc' + assert annotations[1]['timestamp'] == 1234567890 + + +def test_update_inner_annotation_fields_preserves_existing(): + """RTAN4b: Don't overwrite existing annotation fields""" + proto_msg = { + 'id': 'proto-msg-id', + 'connectionId': 'conn-abc', + 'timestamp': 1234567890, + 'annotations': [ + { + 'type': 'reaction', + 'id': 'existing-id', + 'connectionId': 'existing-conn', + 'timestamp': 9999999999, + }, + ] + } + Annotation.update_inner_annotation_fields(proto_msg) + annotation = proto_msg['annotations'][0] + + # Existing values should be preserved + assert annotation['id'] == 'existing-id' + assert annotation['connectionId'] == 'existing-conn' + assert annotation['timestamp'] == 9999999999 + + +def test_update_inner_annotation_fields_no_annotations(): + """RTAN4b: Should handle missing annotations gracefully""" + proto_msg = {'id': 'proto-msg-id'} + # Should not raise + Annotation.update_inner_annotation_fields(proto_msg) + + +# --- RSAN1c1/RSAN2a: explicit action setting --- + +def test_annotation_default_action_is_create(): + """Default action should be ANNOTATION_CREATE""" + annotation = Annotation(type='reaction', name='👍') + assert annotation.action == AnnotationAction.ANNOTATION_CREATE + + +def test_annotation_copy_with_action(): + """_copy_with should allow changing action""" + annotation = Annotation(type='reaction', name='👍') + deleted = annotation._copy_with(action=AnnotationAction.ANNOTATION_DELETE) + assert deleted.action == AnnotationAction.ANNOTATION_DELETE + assert annotation.action == AnnotationAction.ANNOTATION_CREATE # Original unchanged + + +# --- TAN3: from_encoded() with None data --- + +def test_from_encoded_with_none_data(): + """from_encoded should handle None data properly""" + encoded = { + 'type': 'reaction', + 'name': '👍', + 'action': 0, + } + annotation = Annotation.from_encoded(encoded) + assert annotation.data is None + assert annotation.type == 'reaction' + + +def test_from_encoded_with_data(): + """from_encoded should decode data when present""" + encoded = { + 'type': 'reaction', + 'name': '👍', + 'action': 0, + 'data': 'hello', + } + annotation = Annotation.from_encoded(encoded) + assert annotation.data == 'hello' + + +def test_from_encoded_with_json_data(): + """from_encoded should decode JSON-encoded data""" + import json + encoded = { + 'type': 'reaction', + 'action': 0, + 'data': json.dumps({'count': 5}), + 'encoding': 'json', + } + annotation = Annotation.from_encoded(encoded) + assert annotation.data == {'count': 5} + + +# --- TAN2i: __eq__ based on serial --- + +def test_annotation_eq_by_serial(): + """TAN2i: Annotations with same serial should be equal""" + a1 = Annotation(serial='s1', type='reaction', name='👍') + a2 = Annotation(serial='s1', type='different', name='👎') + assert a1 == a2 + + +def test_annotation_ne_by_serial(): + """TAN2i: Annotations with different serials should not be equal""" + a1 = Annotation(serial='s1', type='reaction', name='👍') + a2 = Annotation(serial='s2', type='reaction', name='👍') + assert a1 != a2 + + +def test_annotation_eq_fallback_includes_client_id(): + """Fallback equality should include client_id""" + a1 = Annotation(type='reaction', name='👍', client_id='user1', + message_serial='ms1', action=AnnotationAction.ANNOTATION_CREATE) + a2 = Annotation(type='reaction', name='👍', client_id='user2', + message_serial='ms1', action=AnnotationAction.ANNOTATION_CREATE) + assert a1 != a2 # Different client_id + + +def test_annotation_eq_fallback_same_fields(): + """Fallback equality with same fields should be equal""" + a1 = Annotation(type='reaction', name='👍', client_id='user1', + message_serial='ms1', action=AnnotationAction.ANNOTATION_CREATE) + a2 = Annotation(type='reaction', name='👍', client_id='user1', + message_serial='ms1', action=AnnotationAction.ANNOTATION_CREATE) + assert a1 == a2 + + +# --- as_dict serialization --- + +def test_annotation_as_dict_filters_none(): + """as_dict should not include None values""" + annotation = Annotation(type='reaction', name='👍') + d = annotation.as_dict() + assert 'serial' not in d + assert 'extras' not in d + assert 'type' in d + assert 'name' in d + + +def test_annotation_as_dict_includes_action(): + """as_dict should include action as integer""" + annotation = Annotation(type='reaction', name='👍', action=AnnotationAction.ANNOTATION_DELETE) + d = annotation.as_dict() + assert d['action'] == 1 # ANNOTATION_DELETE + + +# --- from_encoded_array --- + +def test_from_encoded_array(): + """from_encoded_array should decode multiple annotations""" + encoded_array = [ + {'type': 'reaction', 'name': '👍', 'action': 0}, + {'type': 'reaction', 'name': '👎', 'action': 1}, + ] + annotations = Annotation.from_encoded_array(encoded_array) + assert len(annotations) == 2 + assert annotations[0].name == '👍' + assert annotations[0].action == AnnotationAction.ANNOTATION_CREATE + assert annotations[1].name == '👎' + assert annotations[1].action == AnnotationAction.ANNOTATION_DELETE diff --git a/test/unit/http_test.py b/test/unit/http_test.py new file mode 100644 index 00000000..61e0d35e --- /dev/null +++ b/test/unit/http_test.py @@ -0,0 +1,19 @@ +from ably import AblyRest + + +def test_http_get_rest_hosts_works_when_fallback_realtime_host_is_set(): + ably = AblyRest(token="foo") + ably.options.fallback_host = ably.options.get_hosts()[0] + # Should not raise TypeError + hosts = ably.http.get_hosts() + assert isinstance(hosts, list) + assert all(isinstance(host, str) for host in hosts) + + +def test_http_get_rest_hosts_works_when_fallback_realtime_host_is_not_set(): + ably = AblyRest(token="foo") + ably.options.fallback_host = None + # Should not raise TypeError + hosts = ably.http.get_hosts() + assert isinstance(hosts, list) + assert all(isinstance(host, str) for host in hosts) diff --git a/test/unit/message_test.py b/test/unit/message_test.py new file mode 100644 index 00000000..4902d6b5 --- /dev/null +++ b/test/unit/message_test.py @@ -0,0 +1,47 @@ +import ably.types.message + + +# TM2a, TM2c, TM2f +def test_update_inner_message_fields_tm2(): + proto_msg: dict = { + 'id': 'abcdefg', + 'connectionId': 'custom_connection_id', + 'timestamp': 23134, + 'messages': [ + { + 'event': 'test', + 'data': 'hello there''' + } + ] + } + ably.types.message.Message.update_inner_message_fields(proto_msg) + messages: list[dict] = proto_msg.get('messages') + msg_index = 0 + for msg in messages: + assert msg.get('id') == f"abcdefg:{msg_index}" + assert msg.get('connectionId') == 'custom_connection_id' + assert msg.get('timestamp') == 23134 + msg_index = msg_index + 1 + + +# TM2a, TM2c, TM2f +def test_update_inner_message_fields_for_presence_msg_tm2(): + proto_msg: dict = { + 'id': 'abcdefg', + 'connectionId': 'custom_connection_id', + 'timestamp': 23134, + 'presence': [ + { + 'event': 'test', + 'data': 'hello there' + } + ] + } + ably.types.message.Message.update_inner_message_fields(proto_msg) + presence_messages: list[dict] = proto_msg.get('presence') + msg_index = 0 + for presence_msg in presence_messages: + assert presence_msg.get('id') == f"abcdefg:{msg_index}" + assert presence_msg.get('connectionId') == 'custom_connection_id' + assert presence_msg.get('timestamp') == 23134 + msg_index = msg_index + 1 diff --git a/test/unit/mutable_message_test.py b/test/unit/mutable_message_test.py new file mode 100644 index 00000000..8ce603b9 --- /dev/null +++ b/test/unit/mutable_message_test.py @@ -0,0 +1,173 @@ +from ably import MessageAction, MessageOperation, MessageVersion, UpdateDeleteResult +from ably.types.message import Message + + +def test_message_version_none_values_filtered(): + """Test that None values are filtered out in MessageVersion.as_dict()""" + version = MessageVersion( + serial='abc123', + timestamp=None, + client_id=None + ) + + version_dict = version.as_dict() + assert 'serial' in version_dict + assert 'timestamp' not in version_dict + assert 'clientId' not in version_dict + +def test_message_operation_none_values_filtered(): + """Test that None values are filtered out in MessageOperation.as_dict()""" + operation = MessageOperation( + client_id='client123', + description='Test', + metadata=None + ) + + op_dict = operation.as_dict() + assert 'clientId' in op_dict + assert 'description' in op_dict + assert 'metadata' not in op_dict + +def test_message_with_action_and_serial(): + """Test Message can store action and serial""" + message = Message( + name='test', + data='data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE + ) + + assert message.serial == 'abc123' + assert message.action == MessageAction.MESSAGE_UPDATE + + # Test as_dict includes action and serial + msg_dict = message.as_dict() + assert msg_dict['serial'] == 'abc123' + assert msg_dict['action'] == 1 # MESSAGE_UPDATE value + +def test_update_delete_result_from_dict(): + """Test UpdateDeleteResult can be created from dict""" + result_dict = {'versionSerial': 'abc123:v2'} + result = UpdateDeleteResult.from_dict(result_dict) + + assert result.version_serial == 'abc123:v2' + +def test_update_delete_result_empty(): + """Test UpdateDeleteResult handles None/empty correctly""" + result = UpdateDeleteResult.from_dict(None) + assert result.version_serial is None + + result2 = UpdateDeleteResult() + assert result2.version_serial is None + + +def test_message_action_enum_values(): + """Test MessageAction enum has correct values""" + assert MessageAction.MESSAGE_CREATE == 0 + assert MessageAction.MESSAGE_UPDATE == 1 + assert MessageAction.MESSAGE_DELETE == 2 + assert MessageAction.META == 3 + assert MessageAction.MESSAGE_SUMMARY == 4 + assert MessageAction.MESSAGE_APPEND == 5 + +def test_message_version_serialization(): + """Test MessageVersion can be serialized and deserialized""" + version = MessageVersion( + serial='abc123:v2', + timestamp=1234567890, + client_id='user1', + description='Test update', + metadata={'key': 'value'} + ) + + # Test as_dict + version_dict = version.as_dict() + assert version_dict['serial'] == 'abc123:v2' + assert version_dict['timestamp'] == 1234567890 + assert version_dict['clientId'] == 'user1' + assert version_dict['description'] == 'Test update' + assert version_dict['metadata'] == {'key': 'value'} + + # Test from_dict + reconstructed = MessageVersion.from_dict(version_dict) + assert reconstructed.serial == version.serial + assert reconstructed.timestamp == version.timestamp + assert reconstructed.client_id == version.client_id + assert reconstructed.description == version.description + assert reconstructed.metadata == version.metadata + +# RSL15b, RTL32b, TM2i +def test_message_extras_preserved_in_as_dict(): + """Test that extras are included when a Message with extras is serialized. + + Regression test: _send_update() in both RestChannel and RealtimeChannel + constructed a new Message without copying extras or annotations from the + user-supplied message, violating RSL15b/RTL32b which require "whatever + fields were in the user-supplied Message" to be sent. + See commits 1723f5d (REST) and 0b93c10 (Realtime). + """ + extras = {'headers': {'status': 'complete'}} + message = Message( + name='test', + data='updated data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE, + extras=extras, + ) + + msg_dict = message.as_dict() + assert msg_dict['extras'] == extras + assert msg_dict['extras']['headers']['status'] == 'complete' + + +# RSL15b, RTL32b, TM2i +def test_message_extras_none_excluded_from_as_dict(): + """Test that extras=None does not appear in as_dict output.""" + message = Message( + name='test', + data='data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE, + ) + + msg_dict = message.as_dict() + assert 'extras' not in msg_dict + + +# RSL15b, RTL32b, TM2u +def test_message_annotations_preserved_in_as_dict(): + """Test that annotations are included when a Message with annotations is serialized.""" + from ably.types.message import MessageAnnotations + annotations = MessageAnnotations(summary={'reaction:distinct.v1': {'thumbsup': 5}}) + message = Message( + name='test', + data='data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE, + annotations=annotations, + ) + + msg_dict = message.as_dict() + assert msg_dict['annotations'] is not None + assert msg_dict['annotations']['summary']['reaction:distinct.v1'] == {'thumbsup': 5} + + +def test_message_operation_serialization(): + """Test MessageOperation can be serialized and deserialized""" + operation = MessageOperation( + client_id='user1', + description='Test operation', + metadata={'key': 'value'} + ) + + # Test as_dict + op_dict = operation.as_dict() + assert op_dict['clientId'] == 'user1' + assert op_dict['description'] == 'Test operation' + assert op_dict['metadata'] == {'key': 'value'} + + # Test from_dict + reconstructed = MessageOperation.from_dict(op_dict) + assert reconstructed.client_id == operation.client_id + assert reconstructed.description == operation.description + assert reconstructed.metadata == operation.metadata diff --git a/test/unit/options_test.py b/test/unit/options_test.py new file mode 100644 index 00000000..d3ba6129 --- /dev/null +++ b/test/unit/options_test.py @@ -0,0 +1,199 @@ +import pytest + +from ably.types.options import Options +from ably.util.exceptions import AblyException + + +# REC1b1: endpoint is incompatible with deprecated options +def test_options_should_fail_early_with_incompatible_client_options(): + # REC1b1: endpoint with environment + with pytest.raises(AblyException) as exinfo: + Options(endpoint="foo", environment="foo") + assert exinfo.value.code == 40106 + + # REC1b1: endpoint with rest_host + with pytest.raises(AblyException) as exinfo: + Options(endpoint="foo", rest_host="foo") + assert exinfo.value.code == 40106 + + # REC1b1: endpoint with realtime_host + with pytest.raises(AblyException) as exinfo: + Options(endpoint="foo", realtime_host="foo") + assert exinfo.value.code == 40106 + + +# REC1a +def test_options_should_return_the_default_hostnames(): + opts = Options() + assert opts.get_host() == "main.realtime.ably.net" + assert "main.a.fallback.ably-realtime.com" in opts.get_fallback_hosts() + + +# REC1b4 +def test_options_should_return_the_correct_routing_policy_hostnames(): + opts = Options(endpoint="foo") + assert opts.get_host() == "foo.realtime.ably.net" + assert "foo.a.fallback.ably-realtime.com" in opts.get_fallback_hosts() + + +# REC1b3 +def test_options_should_return_the_correct_nonprod_routing_policy_hostnames(): + opts = Options(endpoint="nonprod:foo") + assert opts.get_host() == "foo.realtime.ably-nonprod.net" + assert "foo.a.fallback.ably-realtime-nonprod.com" in opts.get_fallback_hosts() + + +# REC1b2 +def test_options_should_return_the_correct_fqdn_hostnames(): + opts = Options(endpoint="foo.com") + assert opts.get_host() == "foo.com" + assert not opts.get_fallback_hosts() + + +# REC1b2 +def test_options_should_return_an_ipv4_address(): + opts = Options(endpoint="127.0.0.1") + assert opts.get_host() == "127.0.0.1" + assert not opts.get_fallback_hosts() + + +# REC1b2 +def test_options_should_return_an_ipv6_address(): + opts = Options(endpoint="::1") + assert opts.get_host() == "::1" + + +# REC1b2 +def test_options_should_return_localhost(): + opts = Options(endpoint="localhost") + assert opts.get_host() == "localhost" + assert not opts.get_fallback_hosts() + + +# REC1c1: environment with rest_host or realtime_host is invalid +def test_options_should_fail_with_environment_and_rest_or_realtime_host(): + # REC1c1: environment with rest_host + with pytest.raises(AblyException) as exinfo: + Options(environment="foo", rest_host="bar") + assert exinfo.value.code == 40106 + + # REC1c1: environment with realtime_host + with pytest.raises(AblyException) as exinfo: + Options(environment="foo", realtime_host="bar") + assert exinfo.value.code == 40106 + + +# REC1c2: environment defines production routing policy ID +def test_options_with_environment_should_return_routing_policy_hostnames(): + opts = Options(environment="foo") + # REC1c2: primary domain is [id].realtime.ably.net + assert opts.get_host() == "foo.realtime.ably.net" + # REC2c5: fallback domains for production routing policy ID via environment + assert "foo.a.fallback.ably-realtime.com" in opts.get_fallback_hosts() + assert "foo.e.fallback.ably-realtime.com" in opts.get_fallback_hosts() + + +# REC1d1: rest_host takes precedence for primary domain +def test_options_with_rest_host_should_return_rest_host(): + opts = Options(rest_host="custom.example.com") + # REC1d1: primary domain is the value of the restHost option + assert opts.get_host() == "custom.example.com" + # REC2c6: fallback domains for restHost is empty + assert not opts.get_fallback_hosts() + + +# REC1d2: realtime_host if rest_host not specified +def test_options_with_realtime_host_should_return_realtime_host(): + opts = Options(realtime_host="custom.example.com") + # REC1d2: primary domain is the value of the realtimeHost option + assert opts.get_host() == "custom.example.com" + # REC2c6: fallback domains for realtimeHost is empty + assert not opts.get_fallback_hosts() + + +# REC1d1: rest_host takes precedence over realtime_host +def test_options_with_rest_host_takes_precedence_over_realtime_host(): + opts = Options(rest_host="rest.example.com", realtime_host="realtime.example.com") + # REC1d1: restHost takes precedence + assert opts.get_host() == "rest.example.com" + # REC2c6: fallback domains is empty + assert not opts.get_fallback_hosts() + + +# REC2a2: fallback_hosts value is used when specified +def test_options_with_fallback_hosts_should_use_specified_hosts(): + custom_fallbacks = ["fallback1.example.com", "fallback2.example.com"] + opts = Options(fallback_hosts=custom_fallbacks) + # REC2a2: the set of fallback domains is given by the value of the fallbackHosts option + fallbacks = opts.get_fallback_hosts() + assert len(fallbacks) == 2 + assert "fallback1.example.com" in fallbacks + assert "fallback2.example.com" in fallbacks + + + +# REC2a2: empty fallback_hosts array is respected +def test_options_with_empty_fallback_hosts_should_have_no_fallbacks(): + opts = Options(fallback_hosts=[]) + # REC2a2: empty array means no fallbacks + assert opts.get_fallback_hosts() == [] + + +# REC2c1: Default fallback hosts for main endpoint +def test_options_default_fallback_hosts(): + opts = Options() + fallbacks = opts.get_fallback_hosts() + # REC2c1: default fallback hosts + assert len(fallbacks) == 5 + assert "main.a.fallback.ably-realtime.com" in fallbacks + assert "main.b.fallback.ably-realtime.com" in fallbacks + assert "main.c.fallback.ably-realtime.com" in fallbacks + assert "main.d.fallback.ably-realtime.com" in fallbacks + assert "main.e.fallback.ably-realtime.com" in fallbacks + + +# REC2c3: Non-production routing policy fallback hosts +def test_options_nonprod_fallback_hosts(): + opts = Options(endpoint="nonprod:test") + fallbacks = opts.get_fallback_hosts() + # REC2c3: nonprod fallback hosts + assert len(fallbacks) == 5 + assert "test.a.fallback.ably-realtime-nonprod.com" in fallbacks + assert "test.b.fallback.ably-realtime-nonprod.com" in fallbacks + assert "test.c.fallback.ably-realtime-nonprod.com" in fallbacks + assert "test.d.fallback.ably-realtime-nonprod.com" in fallbacks + assert "test.e.fallback.ably-realtime-nonprod.com" in fallbacks + + +# REC2c4: Production routing policy fallback hosts +def test_options_prod_routing_policy_fallback_hosts(): + opts = Options(endpoint="custom") + fallbacks = opts.get_fallback_hosts() + # REC2c4: production routing policy fallback hosts + assert len(fallbacks) == 5 + assert "custom.a.fallback.ably-realtime.com" in fallbacks + assert "custom.b.fallback.ably-realtime.com" in fallbacks + assert "custom.c.fallback.ably-realtime.com" in fallbacks + assert "custom.d.fallback.ably-realtime.com" in fallbacks + assert "custom.e.fallback.ably-realtime.com" in fallbacks + + +# REC2c2: Explicit hostname (FQDN) has empty fallback hosts +def test_options_fqdn_no_fallback_hosts(): + opts = Options(endpoint="custom.example.com") + # REC2c2: explicit hostname has empty fallback + assert opts.get_fallback_hosts() == [] + + +# REC2c2: IPv6 address has empty fallback hosts +def test_options_ipv6_no_fallback_hosts(): + opts = Options(endpoint="::1") + # REC2c2: explicit hostname has empty fallback + assert opts.get_fallback_hosts() == [] + + +# REC2c2: localhost has empty fallback hosts +def test_options_localhost_no_fallback_hosts(): + opts = Options(endpoint="localhost") + # REC2c2: explicit hostname has empty fallback + assert opts.get_fallback_hosts() == [] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 3ee1f99c..00000000 --- a/tox.ini +++ /dev/null @@ -1,7 +0,0 @@ -[tox] -envlist = py26,py27,py31,py32,py33 -[testenv] -deps= - nose - -rrequirements.txt -commands=nosetests diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..30a4df76 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1854 @@ +version = 1 +revision = 3 +requires-python = ">=3.7" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", + "python_full_version < '3.8'", +] + +[[package]] +name = "ably" +version = "3.1.2" +source = { editable = "." } +dependencies = [ + { name = "h2", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "h2", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "httpx", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "httpx", version = "0.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "msgpack", version = "1.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "msgpack", version = "1.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pyee", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "pyee", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "websockets", version = "11.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "websockets", version = "13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "pycryptodome" }, +] +dev = [ + { name = "async-case", marker = "python_full_version < '3.8'" }, + { name = "importlib-metadata" }, + { name = "mock" }, + { name = "pytest" }, + { name = "pytest-asyncio", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "pytest-asyncio", version = "0.23.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "pytest-cov" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "respx", version = "0.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "respx", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "ruff" }, + { name = "tokenize-rt", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "tokenize-rt", version = "6.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "tokenize-rt", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "vcdiff-decoder" }, +] +oldcrypto = [ + { name = "pycrypto" }, +] +vcdiff = [ + { name = "vcdiff-decoder" }, +] + +[package.metadata] +requires-dist = [ + { name = "async-case", marker = "python_full_version == '3.7.*' and extra == 'dev'", specifier = ">=10.1.0,<11.0.0" }, + { name = "h2", specifier = ">=4.1.0,<5.0.0" }, + { name = "httpx", marker = "python_full_version == '3.7.*'", specifier = ">=0.24.1,<1.0" }, + { name = "httpx", marker = "python_full_version >= '3.8'", specifier = ">=0.25.0,<1.0" }, + { name = "importlib-metadata", marker = "extra == 'dev'", specifier = ">=4.12,<5.0" }, + { name = "mock", marker = "extra == 'dev'", specifier = ">=4.0.3,<5.0.0" }, + { name = "msgpack", specifier = ">=1.0.0,<2.0.0" }, + { name = "pycrypto", marker = "extra == 'oldcrypto'", specifier = ">=2.6.1,<3.0.0" }, + { name = "pycryptodome", marker = "extra == 'crypto'" }, + { name = "pyee", marker = "python_full_version == '3.7.*'", specifier = ">=9.0.4,<10.0.0" }, + { name = "pyee", marker = "python_full_version >= '3.8'", specifier = ">=11.1.0,<14.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.1,<8.0" }, + { name = "pytest-asyncio", marker = "python_full_version == '3.7.*' and extra == 'dev'", specifier = ">=0.21.0,<0.23.0" }, + { name = "pytest-asyncio", marker = "python_full_version >= '3.8' and extra == 'dev'", specifier = ">=0.23.0,<1.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=2.4,<3.0" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.1.0,<3.0.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=1.15,<2.0" }, + { name = "respx", marker = "python_full_version == '3.7.*' and extra == 'dev'", specifier = ">=0.20.0,<0.21.0" }, + { name = "respx", marker = "python_full_version >= '3.8' and extra == 'dev'", specifier = ">=0.22.0,<0.23.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.0,<1.0.0" }, + { name = "tokenize-rt", marker = "extra == 'dev'" }, + { name = "vcdiff-decoder", marker = "extra == 'dev'", specifier = ">=0.1.0a1" }, + { name = "vcdiff-decoder", marker = "extra == 'vcdiff'", specifier = ">=0.1.0,<0.2.0" }, + { name = "websockets", marker = "python_full_version == '3.7.*'", specifier = ">=10.0,<12.0" }, + { name = "websockets", marker = "python_full_version == '3.8.*'", specifier = ">=12.0,<15.0" }, + { name = "websockets", marker = "python_full_version >= '3.9'", specifier = ">=15.0,<16.0" }, +] +provides-extras = ["oldcrypto", "crypto", "vcdiff", "dev"] + +[[package]] +name = "anyio" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.8'" }, + { name = "idna", version = "3.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "sniffio", marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/99/2dfd53fd55ce9838e6ff2d4dac20ce58263798bd1a0dbe18b3a9af3fcfce/anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", size = 142927, upload-time = "2023-07-05T16:45:02.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/24/44299477fe7dcc9cb58d0a57d5a7588d6af2ff403fdd2d47a246c91a3246/anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5", size = 80896, upload-time = "2023-07-05T16:44:59.805Z" }, +] + +[[package]] +name = "anyio" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.8.*'" }, + { name = "idna", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "sniffio", marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "idna", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "sniffio", marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "async-case" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/09/87f2a23f5696ac6deb2fff92421f8af46226ea2410d101b453d5aa63e53a/async_case-10.1.0.tar.gz", hash = "sha256:b819f68c78f6c640ab1101ecf69fac189402b490901fa2abc314c48edab5d3da", size = 3668, upload-time = "2022-03-15T21:56:16.795Z" } + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.2.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/45/8b/421f30467e69ac0e414214856798d4bc32da1336df745e49e49ae5c1e2a8/coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", size = 762575, upload-time = "2023-05-29T20:08:50.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/24/be01e62a7bce89bcffe04729c540382caa5a06bee45ae42136c93e2499f5/coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", size = 200724, upload-time = "2023-05-29T20:07:03.422Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/7060a445e1d2c9744b683dc935248613355657809d6c6b2716cdf4ca4766/coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", size = 201024, upload-time = "2023-05-29T20:07:05.694Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9d/926fce7e03dbfc653104c2d981c0fa71f0572a9ebd344d24c573bd6f7c4f/coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", size = 229528, upload-time = "2023-05-29T20:07:07.307Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/67f5d18f911abf96857f6f7e4df37ca840e38179e2cc9ab6c0b9c3380f19/coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", size = 227842, upload-time = "2023-05-29T20:07:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bd/1b2331e3a04f4cc9b7b332b1dd0f3a1261dfc4114f8479bebfcc2afee9e8/coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", size = 228717, upload-time = "2023-05-29T20:07:11.38Z" }, + { url = "https://files.pythonhosted.org/packages/2b/86/3dbf9be43f8bf6a5ca28790a713e18902b2d884bc5fa9512823a81dff601/coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", size = 234632, upload-time = "2023-05-29T20:07:13.376Z" }, + { url = "https://files.pythonhosted.org/packages/91/e8/469ed808a782b9e8305a08bad8c6fa5f8e73e093bda6546c5aec68275bff/coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", size = 232875, upload-time = "2023-05-29T20:07:15.093Z" }, + { url = "https://files.pythonhosted.org/packages/29/8f/4fad1c2ba98104425009efd7eaa19af9a7c797e92d40cd2ec026fa1f58cb/coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", size = 234094, upload-time = "2023-05-29T20:07:17.013Z" }, + { url = "https://files.pythonhosted.org/packages/94/4e/d4e46a214ae857be3d7dc5de248ba43765f60daeb1ab077cb6c1536c7fba/coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", size = 203184, upload-time = "2023-05-29T20:07:18.69Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/d6730247d8dec2a3dddc520ebe11e2e860f0f98cee3639e23de6cf920255/coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", size = 204096, upload-time = "2023-05-29T20:07:20.153Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fa/529f55c9a1029c840bcc9109d5a15ff00478b7ff550a1ae361f8745f8ad5/coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", size = 200895, upload-time = "2023-05-29T20:07:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/67/d7/cd8fe689b5743fffac516597a1222834c42b80686b99f5b44ef43ccc2a43/coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", size = 201120, upload-time = "2023-05-29T20:07:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/16eed713202406ca0a37f8ac259bbf144c9d24f9b8097a8e6ead61da2dbb/coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3", size = 233178, upload-time = "2023-05-29T20:07:25.281Z" }, + { url = "https://files.pythonhosted.org/packages/c1/49/4d487e2ad5d54ed82ac1101e467e8994c09d6123c91b2a962145f3d262c2/coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", size = 230754, upload-time = "2023-05-29T20:07:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cd/3ce94ad9d407a052dc2a74fbeb1c7947f442155b28264eb467ee78dea812/coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", size = 232558, upload-time = "2023-05-29T20:07:28.743Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a8/12cc7b261f3082cc299ab61f677f7e48d93e35ca5c3c2f7241ed5525ccea/coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", size = 241509, upload-time = "2023-05-29T20:07:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/04/fa/43b55101f75a5e9115259e8be70ff9279921cb6b17f04c34a5702ff9b1f7/coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", size = 239924, upload-time = "2023-05-29T20:07:32.065Z" }, + { url = "https://files.pythonhosted.org/packages/68/5f/d2bd0f02aa3c3e0311986e625ccf97fdc511b52f4f1a063e4f37b624772f/coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", size = 240977, upload-time = "2023-05-29T20:07:34.184Z" }, + { url = "https://files.pythonhosted.org/packages/ba/92/69c0722882643df4257ecc5437b83f4c17ba9e67f15dc6b77bad89b6982e/coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", size = 203168, upload-time = "2023-05-29T20:07:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/b1/96/c12ed0dfd4ec587f3739f53eb677b9007853fd486ccb0e7d5512a27bab2e/coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", size = 204185, upload-time = "2023-05-29T20:07:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/52fa1891d1802ab2e1b346d37d349cb41cdd4fd03f724ebbf94e80577687/coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", size = 201020, upload-time = "2023-05-29T20:07:38.724Z" }, + { url = "https://files.pythonhosted.org/packages/24/df/6765898d54ea20e3197a26d26bb65b084deefadd77ce7de946b9c96dfdc5/coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", size = 233994, upload-time = "2023-05-29T20:07:40.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/81/b108a60bc758b448c151e5abceed027ed77a9523ecbc6b8a390938301841/coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", size = 231358, upload-time = "2023-05-29T20:07:41.998Z" }, + { url = "https://files.pythonhosted.org/packages/61/90/c76b9462f39897ebd8714faf21bc985b65c4e1ea6dff428ea9dc711ed0dd/coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", size = 233316, upload-time = "2023-05-29T20:07:43.539Z" }, + { url = "https://files.pythonhosted.org/packages/04/d6/8cba3bf346e8b1a4fb3f084df7d8cea25a6b6c56aaca1f2e53829be17e9e/coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", size = 240159, upload-time = "2023-05-29T20:07:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ea/4a252dc77ca0605b23d477729d139915e753ee89e4c9507630e12ad64a80/coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", size = 238127, upload-time = "2023-05-29T20:07:46.522Z" }, + { url = "https://files.pythonhosted.org/packages/9f/5c/d9760ac497c41f9c4841f5972d0edf05d50cad7814e86ee7d133ec4a0ac8/coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", size = 239833, upload-time = "2023-05-29T20:07:47.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/8c/26a95b08059db1cbb01e4b0e6d40f2e9debb628c6ca86b78f625ceaf9bab/coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", size = 203463, upload-time = "2023-05-29T20:07:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/b7/00/14b00a0748e9eda26e97be07a63cc911108844004687321ddcc213be956c/coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", size = 204347, upload-time = "2023-05-29T20:07:51.909Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/67937c80b8fd4c909fdac29292bc8b35d9505312cff6bcab41c53c5b1df6/coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", size = 200580, upload-time = "2023-05-29T20:07:54.076Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/084864fa4bbf8106f44fb72a56e67e0cd372d3bf9d893be818338c81af5d/coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", size = 226237, upload-time = "2023-05-29T20:07:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/67/a2/6fa66a50e6e894286d79a3564f42bd54a9bd27049dc0a63b26d9924f0aa3/coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", size = 224256, upload-time = "2023-05-29T20:07:58.189Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c0/73f139794c742840b9ab88e2e17fe14a3d4668a166ff95d812ac66c0829d/coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", size = 225550, upload-time = "2023-05-29T20:08:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/03/ec/6f30b4e0c96ce03b0e64aec46b4af2a8c49b70d1b5d0d69577add757b946/coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", size = 232440, upload-time = "2023-05-29T20:08:02.495Z" }, + { url = "https://files.pythonhosted.org/packages/22/c1/2f6c1b6f01a0996c9e067a9c780e1824351dbe17faae54388a4477e6d86f/coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", size = 230897, upload-time = "2023-05-29T20:08:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d6/53e999ec1bf7498ca4bc5f3b8227eb61db39068d2de5dcc359dec5601b5a/coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", size = 232024, upload-time = "2023-05-29T20:08:06.031Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/383305500d24122dbed73e505a4d6828f8f3356d1f68ab6d32c781754b81/coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", size = 203293, upload-time = "2023-05-29T20:08:07.598Z" }, + { url = "https://files.pythonhosted.org/packages/0e/bc/7e3a31534fabb043269f14fb64e2bb2733f85d4cf39e5bbc71357c57553a/coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", size = 204040, upload-time = "2023-05-29T20:08:09.919Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fc/be19131010930a6cf271da48202c8cc1d3f971f68c02fb2d3a78247f43dc/coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", size = 200689, upload-time = "2023-05-29T20:08:11.594Z" }, + { url = "https://files.pythonhosted.org/packages/28/d7/9a8de57d87f4bbc6f9a6a5ded1eaac88a89bf71369bb935dac3c0cf2893e/coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", size = 200986, upload-time = "2023-05-29T20:08:13.228Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e4/e6182e4697665fb594a7f4e4f27cb3a4dd00c2e3d35c5c706765de8c7866/coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", size = 230648, upload-time = "2023-05-29T20:08:15.11Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/f552d5871943f747165b92a924055c5d6daa164ae659a13f9018e22f3990/coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", size = 228511, upload-time = "2023-05-29T20:08:16.877Z" }, + { url = "https://files.pythonhosted.org/packages/44/55/49f65ccdd4dfd6d5528e966b28c37caec64170c725af32ab312889d2f857/coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", size = 229852, upload-time = "2023-05-29T20:08:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/0d/31/340428c238eb506feb96d4fb5c9ea614db1149517f22cc7ab8c6035ef6d9/coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", size = 235578, upload-time = "2023-05-29T20:08:20.298Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ce/97c1dd6592c908425622fe7f31c017d11cf0421729b09101d4de75bcadc8/coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", size = 234079, upload-time = "2023-05-29T20:08:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/5a98dc9e239d0dc5f243ef5053d5b1bdcaa1dee27a691dfc12befeccf878/coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", size = 234991, upload-time = "2023-05-29T20:08:24.974Z" }, + { url = "https://files.pythonhosted.org/packages/4a/fb/78986d3022e5ccf2d4370bc43a5fef8374f092b3c21d32499dee8e30b7b6/coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", size = 203160, upload-time = "2023-05-29T20:08:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/6b3c9c363fb1433c79128e0d692863deb761b1b78162494abb9e5c328bc0/coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", size = 204085, upload-time = "2023-05-29T20:08:28.146Z" }, + { url = "https://files.pythonhosted.org/packages/88/da/495944ebf0ad246235a6bd523810d9f81981f9b81c6059ba1f56e943abe0/coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", size = 200725, upload-time = "2023-05-29T20:08:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0c/3dfeeb1006c44b911ee0ed915350db30325d01808525ae7cc8d57643a2ce/coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", size = 201022, upload-time = "2023-05-29T20:08:31.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/af/5964b8d7d9a5c767785644d9a5a63cacba9a9c45cc42ba06d25895ec87be/coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", size = 229102, upload-time = "2023-05-29T20:08:32.982Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/cd467fceb62c371f9adb1d739c92a05d4e550246daa90412e711226bd320/coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", size = 227441, upload-time = "2023-05-29T20:08:35.044Z" }, + { url = "https://files.pythonhosted.org/packages/fe/57/e4f8ad64d84ca9e759d783a052795f62a9f9111585e46068845b1cb52c2b/coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", size = 228265, upload-time = "2023-05-29T20:08:36.861Z" }, + { url = "https://files.pythonhosted.org/packages/88/8b/b0d9fe727acae907fa7f1c8194ccb6fe9d02e1c3e9001ecf74c741f86110/coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", size = 234217, upload-time = "2023-05-29T20:08:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/66/2e/c99fe1f6396d93551aa352c75410686e726cd4ea104479b9af1af22367ce/coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", size = 232466, upload-time = "2023-05-29T20:08:40.768Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e9/88747b40c8fb4a783b40222510ce6d66170217eb05d7f46462c36b4fa8cc/coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", size = 233669, upload-time = "2023-05-29T20:08:42.944Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d5/a8e276bc005e42114468d4fe03e0a9555786bc51cbfe0d20827a46c1565a/coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", size = 203199, upload-time = "2023-05-29T20:08:44.734Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0c/4a848ae663b47f1195abcb09a951751dd61f80b503303b9b9d768e0fd321/coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", size = 204109, upload-time = "2023-05-29T20:08:46.417Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/b3b1d7887e1ea25a9608b0776e480e4bbc303ca95a31fd585555ec4fff5a/coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", size = 193207, upload-time = "2023-05-29T20:08:48.153Z" }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791, upload-time = "2024-08-04T19:45:30.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690, upload-time = "2024-08-04T19:43:07.695Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127, upload-time = "2024-08-04T19:43:10.15Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654, upload-time = "2024-08-04T19:43:12.405Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598, upload-time = "2024-08-04T19:43:14.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732, upload-time = "2024-08-04T19:43:16.632Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816, upload-time = "2024-08-04T19:43:19.049Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325, upload-time = "2024-08-04T19:43:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418, upload-time = "2024-08-04T19:43:22.945Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343, upload-time = "2024-08-04T19:43:25.121Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136, upload-time = "2024-08-04T19:43:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796, upload-time = "2024-08-04T19:43:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244, upload-time = "2024-08-04T19:43:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279, upload-time = "2024-08-04T19:43:33.581Z" }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859, upload-time = "2024-08-04T19:43:35.301Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549, upload-time = "2024-08-04T19:43:37.578Z" }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477, upload-time = "2024-08-04T19:43:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134, upload-time = "2024-08-04T19:43:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910, upload-time = "2024-08-04T19:43:43.037Z" }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348, upload-time = "2024-08-04T19:43:44.787Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230, upload-time = "2024-08-04T19:43:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983, upload-time = "2024-08-04T19:43:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221, upload-time = "2024-08-04T19:43:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342, upload-time = "2024-08-04T19:43:53.746Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371, upload-time = "2024-08-04T19:43:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455, upload-time = "2024-08-04T19:43:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924, upload-time = "2024-08-04T19:44:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252, upload-time = "2024-08-04T19:44:01.713Z" }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897, upload-time = "2024-08-04T19:44:03.898Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606, upload-time = "2024-08-04T19:44:05.532Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373, upload-time = "2024-08-04T19:44:07.079Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007, upload-time = "2024-08-04T19:44:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269, upload-time = "2024-08-04T19:44:11.045Z" }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886, upload-time = "2024-08-04T19:44:12.83Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037, upload-time = "2024-08-04T19:44:15.393Z" }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038, upload-time = "2024-08-04T19:44:17.466Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690, upload-time = "2024-08-04T19:44:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765, upload-time = "2024-08-04T19:44:20.994Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611, upload-time = "2024-08-04T19:44:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671, upload-time = "2024-08-04T19:44:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368, upload-time = "2024-08-04T19:44:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758, upload-time = "2024-08-04T19:44:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035, upload-time = "2024-08-04T19:44:30.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839, upload-time = "2024-08-04T19:44:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569, upload-time = "2024-08-04T19:44:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927, upload-time = "2024-08-04T19:44:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401, upload-time = "2024-08-04T19:44:38.155Z" }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301, upload-time = "2024-08-04T19:44:39.883Z" }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598, upload-time = "2024-08-04T19:44:41.59Z" }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307, upload-time = "2024-08-04T19:44:43.301Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453, upload-time = "2024-08-04T19:44:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674, upload-time = "2024-08-04T19:44:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101, upload-time = "2024-08-04T19:44:49.32Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554, upload-time = "2024-08-04T19:44:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440, upload-time = "2024-08-04T19:44:53.464Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889, upload-time = "2024-08-04T19:44:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142, upload-time = "2024-08-04T19:44:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805, upload-time = "2024-08-04T19:44:59.033Z" }, + { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655, upload-time = "2024-08-04T19:45:01.398Z" }, + { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296, upload-time = "2024-08-04T19:45:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137, upload-time = "2024-08-04T19:45:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688, upload-time = "2024-08-04T19:45:08.358Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120, upload-time = "2024-08-04T19:45:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249, upload-time = "2024-08-04T19:45:13.202Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237, upload-time = "2024-08-04T19:45:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311, upload-time = "2024-08-04T19:45:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453, upload-time = "2024-08-04T19:45:18.672Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958, upload-time = "2024-08-04T19:45:20.63Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938, upload-time = "2024-08-04T19:45:23.062Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352, upload-time = "2024-08-04T19:45:25.042Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153, upload-time = "2024-08-04T19:45:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926, upload-time = "2024-08-04T19:45:28.875Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/4a/0dc3de1c172d35abe512332cfdcc43211b6ebce629e4cc42e6cd25ed8f4d/coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b", size = 217409, upload-time = "2025-11-18T13:31:53.122Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/086198b98db0109ad4f84241e8e9ea7e5fb2db8c8ffb787162d40c26cc76/coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c", size = 217927, upload-time = "2025-11-18T13:31:54.458Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5f/34614dbf5ce0420828fc6c6f915126a0fcb01e25d16cf141bf5361e6aea6/coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832", size = 244678, upload-time = "2025-11-18T13:31:55.805Z" }, + { url = "https://files.pythonhosted.org/packages/55/7b/6b26fb32e8e4a6989ac1d40c4e132b14556131493b1d06bc0f2be169c357/coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa", size = 246507, upload-time = "2025-11-18T13:31:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/06/42/7d70e6603d3260199b90fb48b537ca29ac183d524a65cc31366b2e905fad/coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73", size = 248366, upload-time = "2025-11-18T13:31:58.362Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4a/d86b837923878424c72458c5b25e899a3c5ca73e663082a915f5b3c4d749/coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb", size = 245366, upload-time = "2025-11-18T13:31:59.572Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c2/2adec557e0aa9721875f06ced19730fdb7fc58e31b02b5aa56f2ebe4944d/coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e", size = 246408, upload-time = "2025-11-18T13:32:00.784Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/8bd1f1148260df11c618e535fdccd1e5aaf646e55b50759006a4f41d8a26/coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777", size = 244416, upload-time = "2025-11-18T13:32:01.963Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/3a248dd6a83df90414c54a4e121fd081fb20602ca43955fbe1d60e2312a9/coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553", size = 244681, upload-time = "2025-11-18T13:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/76/30/aa833827465a5e8c938935f5d91ba055f70516941078a703740aaf1aa41f/coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d", size = 245300, upload-time = "2025-11-18T13:32:04.686Z" }, + { url = "https://files.pythonhosted.org/packages/38/24/f85b3843af1370fb3739fa7571819b71243daa311289b31214fe3e8c9d68/coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef", size = 220008, upload-time = "2025-11-18T13:32:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a2/c7da5b9566f7164db9eefa133d17761ecb2c2fde9385d754e5b5c80f710d/coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022", size = 220943, upload-time = "2025-11-18T13:32:07.166Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" }, + { url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" }, + { url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, + { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, + { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "execnet" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/c8/d382dc7a1e68a165f4a4ab612a08b20d8534a7d20cc590630b734ca0c54b/execnet-2.0.2.tar.gz", hash = "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af", size = 161098, upload-time = "2023-07-09T17:14:03.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/9c/a079946da30fac4924d92dbc617e5367d454954494cf1e71567bcc4e00ee/execnet-2.0.2-py3-none-any.whl", hash = "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41", size = 37097, upload-time = "2023-07-09T17:14:01.888Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", + "python_full_version < '3.8'", +] +dependencies = [ + { name = "hpack", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/32/fec683ddd10629ea4ea46d206752a95a2d8a48c22521edd70b142488efe1/h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb", size = 2145593, upload-time = "2021-10-05T18:27:47.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e5/db6d438da759efbb488c4f3fbdab7764492ff3c3f953132efa6b9f0e9e53/h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d", size = 57488, upload-time = "2021-10-05T18:27:39.977Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "hpack", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095", size = 49117, upload-time = "2020-08-30T10:35:57.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", size = 32611, upload-time = "2020-08-30T10:35:56.357Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "anyio", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "certifi", marker = "python_full_version < '3.8'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "sniffio", marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/ad/c98ecdbfe04417e71e143bf2f2fb29128e4787d78d1cedba21bd250c7e7a/httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", size = 62676, upload-time = "2023-07-05T12:09:31.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/2c/2bde7ff8dd2064395555220cbf7cba79991172bf5315a07eb3ac7688d9f1/httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87", size = 74513, upload-time = "2023-07-05T12:09:29.425Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.8'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.8'" }, + { name = "httpcore", version = "0.17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "idna", version = "3.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "sniffio", marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/2a/114d454cb77657dbf6a293e69390b96318930ace9cd96b51b99682493276/httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd", size = 81858, upload-time = "2023-05-19T00:50:56.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/91/e41f64f03d2a13aee7e8c819d82ee3aa7cdc484d18c0ae859742597d5aa0/httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", size = 75377, upload-time = "2023-05-19T00:50:54.91Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "anyio", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "certifi", marker = "python_full_version >= '3.8'" }, + { name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "idna", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/4747bff0a17f7281abe73e955d60d80aae537a5d203f417fa1c2e7578ebb/hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914", size = 25008, upload-time = "2021-04-17T12:11:22.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/de/85a784bcc4a3779d1753a7ec2dee5de90e18c7bcf402e71b51fcf150b129/hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15", size = 12389, upload-time = "2021-04-17T12:11:21.045Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "zipp", version = "3.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "zipp", version = "3.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/12/ab288357b884ebc807e3f4eff63ce5ba6b941ba61499071bf19f1bbc7f7f/importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d", size = 50445, upload-time = "2022-10-01T17:09:15.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/98/c277899f5aa21f6e6946e1c83f2af650cbfee982763ffb91db07ff7d3a13/importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116", size = 23010, upload-time = "2022-10-01T17:09:13.903Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mock" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/be/3ea39a8fd4ed3f9a25aae18a1bff2df7a610bca93c8ede7475e32d8b73a0/mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc", size = 72316, upload-time = "2020-12-10T07:33:13.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/03/b7e605db4a57c0f6fba744b11ef3ddf4ddebcada35022927a2b5fc623fdf/mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62", size = 28536, upload-time = "2020-12-10T07:33:11.564Z" }, +] + +[[package]] +name = "msgpack" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/a1/eba11a0d4b764bc62966a565b470f8c6f38242723ba3057e9b5098678c30/msgpack-1.0.5.tar.gz", hash = "sha256:c075544284eadc5cddc70f4757331d99dcbc16b2bbd4849d15f8aae4cf36d31c", size = 127834, upload-time = "2023-03-08T17:50:48.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4a/36d936e54cf71e23ad276564465f6a54fb129e3d61520b76e13e0bb29167/msgpack-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9", size = 129738, upload-time = "2023-03-08T17:49:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/f2/da/770118f8d48e11cc9a2c7cb60d7d3c8016266526bd42c6ff5bd21013d099/msgpack-1.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f8d8b3bf1ff2672567d6b5c725a1b347fe838b912772aa8ae2bf70338d5a198", size = 74671, upload-time = "2023-03-08T17:49:20.311Z" }, + { url = "https://files.pythonhosted.org/packages/73/99/f338ce8b69e934c04e5d9187f85de1ae395882cd56e7deb48e78a1749af8/msgpack-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdc793c50be3f01106245a61b739328f7dccc2c648b501e237f0699fe1395b81", size = 70230, upload-time = "2023-03-08T17:49:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/bc7fdb75a35bf32c7c529c247dcadfd0502aac2309e207a89b0be6fe42ea/msgpack-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb47c21a8a65b165ce29f2bec852790cbc04936f502966768e4aae9fa763cb7", size = 309410, upload-time = "2023-03-08T17:49:23.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/f992ada3b42889f1b984e5651d63ea21ca3a92049cff6d75fe0a4a63e422/msgpack-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42b9594cc3bf4d838d67d6ed62b9e59e201862a25e9a157019e171fbe672dd3", size = 316846, upload-time = "2023-03-08T17:49:24.786Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/9e004c4deb457f1ef1ad88c1188da5691ff1855e0d03a5ac3635ae1f6530/msgpack-1.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:55b56a24893105dc52c1253649b60f475f36b3aa0fc66115bffafb624d7cb30b", size = 311396, upload-time = "2023-03-08T17:49:26.075Z" }, + { url = "https://files.pythonhosted.org/packages/95/c9/560c3203c4327881c9f2de26c42dacdd9567bfe7fa43458e2a680c4bdcaf/msgpack-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1967f6129fc50a43bfe0951c35acbb729be89a55d849fab7686004da85103f1c", size = 311165, upload-time = "2023-03-08T17:49:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/10/ca/50c3a5e92d459a942169747315afd8c226d05427eccff903ddf33135c574/msgpack-1.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20a97bf595a232c3ee6d57ddaadd5453d174a52594bf9c21d10407e2a2d9b3bd", size = 348664, upload-time = "2023-03-08T17:49:28.736Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/1fb6b96aab759ab3bc05b03ba6d936b350db72aac203cde56ea6bd001237/msgpack-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d25dd59bbbbb996eacf7be6b4ad082ed7eacc4e8f3d2df1ba43822da9bfa122a", size = 316731, upload-time = "2023-03-08T17:49:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/b47f9e93fc381885624c40cbbbd0480b18ae11ca588162fe724d43495372/msgpack-1.0.5-cp310-cp310-win32.whl", hash = "sha256:382b2c77589331f2cb80b67cc058c00f225e19827dbc818d700f61513ab47bea", size = 57134, upload-time = "2023-03-08T17:49:31.365Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e5/3d436bed11849ba05d777ed3fd1a0440170bad460335ea541dd6946047ed/msgpack-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:4867aa2df9e2a5fa5f76d7d5565d25ec76e84c106b55509e78c1ede0f152659a", size = 61631, upload-time = "2023-03-08T17:49:32.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/ad/4edfe383ec3185611441179ffee8cbc8155d7575fbad73f6d31015e35451/msgpack-1.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9f5ae84c5c8a857ec44dc180a8b0cc08238e021f57abdf51a8182e915e6299f0", size = 127502, upload-time = "2023-03-08T17:49:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/01f2d8805160f559ec21d095fc7576a26fbaed2475af24ce4a135c380c14/msgpack-1.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e6ca5d5699bcd89ae605c150aee83b5321f2115695e741b99618f4856c50898", size = 73747, upload-time = "2023-03-08T17:49:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fe/8a7747ca57074307a2e8f1de58441952a9dbdf9e8a8e5873d53a5ce0835c/msgpack-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5494ea30d517a3576749cad32fa27f7585c65f5f38309c88c6d137877fa28a5a", size = 69041, upload-time = "2023-03-08T17:49:36.44Z" }, + { url = "https://files.pythonhosted.org/packages/33/0a/aa7b53ae17cf1dc1c352d705ab3162fc572c55048cc3177c1a88009c47fd/msgpack-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab2f3331cb1b54165976a9d976cb251a83183631c88076613c6c780f0d6e45a", size = 316114, upload-time = "2023-03-08T17:49:38.252Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6d/de239d77d347f1990c41b4800075a15e06f748186dd120166270dd071734/msgpack-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28592e20bbb1620848256ebc105fc420436af59515793ed27d5c77a217477705", size = 325080, upload-time = "2023-03-08T17:49:39.528Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1c/9d0fd241a4e88e1cd2f5babea4a27ac25b1b86dbbc05fa10741e82079a93/msgpack-1.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe5c63197c55bce6385d9aee16c4d0641684628f63ace85f73571e65ad1c1e8d", size = 319393, upload-time = "2023-03-08T17:49:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/b8/bc/1d5fe4732dc78ff86aaf677596da08f0ae736e60ca8ab49c1f1c7366cb1a/msgpack-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed40e926fa2f297e8a653c954b732f125ef97bdd4c889f243182299de27e2aa9", size = 316118, upload-time = "2023-03-08T17:49:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/c79ecc36cfa34d850a01773565e0fccafd69efff07172028c3a5f758b83f/msgpack-1.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b2de4c1c0538dcb7010902a2b97f4e00fc4ddf2c8cda9749af0e594d3b7fa3d7", size = 354984, upload-time = "2023-03-08T17:49:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/45/85/6b55b0cabad846d3e730226a897f878f8f63ee505668bb6c55a697b0bfb0/msgpack-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bf22a83f973b50f9d38e55c6aade04c41ddda19b00c4ebc558930d78eecc64ed", size = 323580, upload-time = "2023-03-08T17:49:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/0e/69/3d10e741dd2bbb806af5cdc76551735baab5f5f9773701eb05502c913a6e/msgpack-1.0.5-cp311-cp311-win32.whl", hash = "sha256:c396e2cc213d12ce017b686e0f53497f94f8ba2b24799c25d913d46c08ec422c", size = 56419, upload-time = "2023-03-08T17:49:46.603Z" }, + { url = "https://files.pythonhosted.org/packages/6b/79/0dec8f035160464ca88b221cc79691a71cf88dc25207c17f1d918b2c7bb0/msgpack-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c4c68d87497f66f96d50142a2b73b97972130d93677ce930718f68828b382e2", size = 60781, upload-time = "2023-03-08T17:49:47.912Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/1b591574ba71481fbf38359a8fca5108e4ad130a6dbb9b2acb3e9277d0fe/msgpack-1.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:137850656634abddfb88236008339fdaba3178f4751b28f270d2ebe77a563b6c", size = 72520, upload-time = "2023-03-08T17:50:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/62/57/170af6c6fccd2d950ea01e1faa58cae9643226fa8705baded11eca3aa8b5/msgpack-1.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c05a4a96585525916b109bb85f8cb6511db1c6f5b9d9cbcbc940dc6b4be944b", size = 289288, upload-time = "2023-03-08T17:50:04.213Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/f2c8695ae69d1425eddc5e2f849c525b562dc8409bc2979e525f3dd4fecd/msgpack-1.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a62ec00b636583e5cb6ad313bbed36bb7ead5fa3a3e38938503142c72cba4f", size = 299695, upload-time = "2023-03-08T17:50:05.622Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9c7fed4ca0235a2d7b8d15b4047c328976b97d2b227719e54cad1e47c244/msgpack-1.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef8108f8dedf204bb7b42994abf93882da1159728a2d4c5e82012edd92c9da9f", size = 293149, upload-time = "2023-03-08T17:50:06.915Z" }, + { url = "https://files.pythonhosted.org/packages/ef/13/c110d89d5079169354394dc226e6f84d818722939bc1fe3f9c25f982e903/msgpack-1.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1835c84d65f46900920b3708f5ba829fb19b1096c1800ad60bae8418652a951d", size = 292899, upload-time = "2023-03-08T17:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/2eda5af7cd1450c52d031e48c76b280eac5bb2e588678876612f95be34ab/msgpack-1.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e57916ef1bd0fee4f21c4600e9d1da352d8816b52a599c46460e93a6e9f17086", size = 334408, upload-time = "2023-03-08T17:50:10Z" }, + { url = "https://files.pythonhosted.org/packages/e8/60/78906f564804aae23eb1102eca8b8830f1e08a649c179774c05fa7dc0aad/msgpack-1.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:17358523b85973e5f242ad74aa4712b7ee560715562554aa2134d96e7aa4cbbf", size = 302791, upload-time = "2023-03-08T17:50:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/89cb1809b076a4651169851aa1f98128b75cbfe14034b914c9040b13c4cf/msgpack-1.0.5-cp37-cp37m-win32.whl", hash = "sha256:cb5aaa8c17760909ec6cb15e744c3ebc2ca8918e727216e79607b7bbce9c8f77", size = 57095, upload-time = "2023-03-08T17:50:12.741Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/be19c9c9cfdcc2ae8ee8c65dbe5f281cc1f3331f9b9523735f39b090b448/msgpack-1.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:ab31e908d8424d55601ad7075e471b7d0140d4d3dd3272daf39c5c19d936bd82", size = 62112, upload-time = "2023-03-08T17:50:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/df5814697c25bdebb14ea97d27ddca04f5d4c6e249f096d086fea521c139/msgpack-1.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b72d0698f86e8d9ddf9442bdedec15b71df3598199ba33322d9711a19f08145c", size = 126923, upload-time = "2023-03-08T17:50:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/33/52/099f0dde1283bac7bf267ab941dfa3b7c89ee701e4252973f8d3c10e68d6/msgpack-1.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:379026812e49258016dd84ad79ac8446922234d498058ae1d415f04b522d5b2d", size = 73246, upload-time = "2023-03-08T17:50:16.487Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1f/cc3e8274934c8323f6106dae22cba8bad413166f4efb3819573de58c215c/msgpack-1.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:332360ff25469c346a1c5e47cbe2a725517919892eda5cfaffe6046656f0b7bb", size = 68947, upload-time = "2023-03-08T17:50:17.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/2c3b443df88f5d400f2e19a3d867564d004b26e137f18c2f2663913987bc/msgpack-1.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:476a8fe8fae289fdf273d6d2a6cb6e35b5a58541693e8f9f019bfe990a51e4ba", size = 313568, upload-time = "2023-03-08T17:50:19.016Z" }, + { url = "https://files.pythonhosted.org/packages/0a/04/bc319ba061f6dc9077745988be288705b3f9f18c5a209772a3e8fcd419fd/msgpack-1.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9985b214f33311df47e274eb788a5893a761d025e2b92c723ba4c63936b69b1", size = 322443, upload-time = "2023-03-08T17:50:20.341Z" }, + { url = "https://files.pythonhosted.org/packages/2f/21/e488871f8e498efe14821b0c870eb95af52cfafb9b8dd41d83fad85b383b/msgpack-1.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48296af57cdb1d885843afd73c4656be5c76c0c6328db3440c9601a98f303d87", size = 315490, upload-time = "2023-03-08T17:50:22.301Z" }, + { url = "https://files.pythonhosted.org/packages/28/8f/c58c53c884217cc572c19349c7e1129b5a6eae36df0a017aae3a8f3d7aa8/msgpack-1.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:addab7e2e1fcc04bd08e4eb631c2a90960c340e40dfc4a5e24d2ff0d5a3b3edb", size = 324288, upload-time = "2023-03-08T17:50:23.739Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/44edef4a8c6f035b054c4b017c5adcb22a35ec377e17e50dd5dced279a6b/msgpack-1.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:916723458c25dfb77ff07f4c66aed34e47503b2eb3188b3adbec8d8aa6e00f48", size = 361405, upload-time = "2023-03-08T17:50:24.992Z" }, + { url = "https://files.pythonhosted.org/packages/56/50/bfcc0fad07067b6f1b09d940272ec749d5fe82570d938c2348c3ad0babf7/msgpack-1.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:821c7e677cc6acf0fd3f7ac664c98803827ae6de594a9f99563e48c5a2f27eb0", size = 329585, upload-time = "2023-03-08T17:50:26.387Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/c1fadb4e4a38fda19e35b1b6f887d72cc9c57778af43b53f64a8cd62e922/msgpack-1.0.5-cp38-cp38-win32.whl", hash = "sha256:1c0f7c47f0087ffda62961d425e4407961a7ffd2aa004c81b9c07d9269512f6e", size = 57668, upload-time = "2023-03-08T17:50:28.037Z" }, + { url = "https://files.pythonhosted.org/packages/da/46/855bdcbf004fd87b6a4451e8dcd61329439dcd9039887f71ca5085769216/msgpack-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:bae7de2026cbfe3782c8b78b0db9cbfc5455e079f1937cb0ab8d133496ac55e1", size = 62509, upload-time = "2023-03-08T17:50:29.85Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/0cfd1dc07f61a6ac606587a393f489c3ca463469d285a73c8e5e2f61b021/msgpack-1.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:20c784e66b613c7f16f632e7b5e8a1651aa5702463d61394671ba07b2fc9e025", size = 130498, upload-time = "2023-03-08T17:50:31.551Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3d/cc5eb6d69e0ecde80a78cc42f48579971ec333e509d56a4a6de1a2c40ba2/msgpack-1.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:266fa4202c0eb94d26822d9bfd7af25d1e2c088927fe8de9033d929dd5ba24c5", size = 75178, upload-time = "2023-03-08T17:50:32.78Z" }, + { url = "https://files.pythonhosted.org/packages/bf/68/032e62ad44f92ba6a4ae7c45054843cdec7f0c405ecdfd166f25123b0c47/msgpack-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18334484eafc2b1aa47a6d42427da7fa8f2ab3d60b674120bce7a895a0a85bdd", size = 70460, upload-time = "2023-03-08T17:50:34.57Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/a28120d82f8e77622a1e1efc652389c71145f6b89b47b39814a7c6038373/msgpack-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57e1f3528bd95cc44684beda696f74d3aaa8a5e58c816214b9046512240ef437", size = 313499, upload-time = "2023-03-08T17:50:36.329Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ff/ca74e519c47139b6c08fb21db5ead2bd2eed6cb1225f9be69390cdb48182/msgpack-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586d0d636f9a628ddc6a17bfd45aa5b5efaf1606d2b60fa5d87b8986326e933f", size = 322301, upload-time = "2023-03-08T17:50:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/43/87/6507d56f62b958d822ae4ffe1c4507ed7d3cf37ad61114665816adcf4adc/msgpack-1.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a740fa0e4087a734455f0fc3abf5e746004c9da72fbd541e9b113013c8dc3282", size = 316630, upload-time = "2023-03-08T17:50:39.397Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/e3ab674f4a945308362e9342297fe6b35a89dd0f648aa325aabffa5dc210/msgpack-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3055b0455e45810820db1f29d900bf39466df96ddca11dfa6d074fa47054376d", size = 316251, upload-time = "2023-03-08T17:50:41.153Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f1/45b73a9e97f702bcb5f51569b93990e456bc969363e55122374c22ed7d24/msgpack-1.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a61215eac016f391129a013c9e46f3ab308db5f5ec9f25811e811f96962599a8", size = 352781, upload-time = "2023-03-08T17:50:42.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/10/be97811782473d709d07b65a3955a5a76d47686aff3d62bb41d48aea7c92/msgpack-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:362d9655cd369b08fda06b6657a303eb7172d5279997abe094512e919cf74b11", size = 321996, upload-time = "2023-03-08T17:50:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/18/3f/3860151fbdf50e369bbe4ffd307a588417669c725025e383f3ce5893690f/msgpack-1.0.5-cp39-cp39-win32.whl", hash = "sha256:ac9dd47af78cae935901a9a500104e2dea2e253207c924cc95de149606dc43cc", size = 57827, upload-time = "2023-03-08T17:50:45.517Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/3ca00fb1e53bcacf8c186fa6aff2d2086862b12e289bcf38227d9d40bd86/msgpack-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:06f5174b5f8ed0ed919da0e62cbd4ffde676a374aba4020034da05fab67b9164", size = 62775, upload-time = "2023-03-08T17:50:47.305Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, + { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, + { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, + { url = "https://files.pythonhosted.org/packages/bd/74/b0fcaec0cea3f104c61c646f49571864f12321de7b8705e98a32d29ba2ad/msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285", size = 409181, upload-time = "2025-06-13T06:52:28.835Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a4/257806f574f8b4bfb76d428b2406cf4585d9f9b582887a0f466278bf0e2a/msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600", size = 413772, upload-time = "2025-06-13T06:52:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/96/17/46438f4848e86e2f481d46bd3f8b0b0405243b4125bac28ce86dc01e3aeb/msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9", size = 402772, upload-time = "2025-06-13T06:52:31.195Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/0ba95da893ddffb09975b4e81fd7b7e612aace0a42ce0d9bdd1a7d802cfe/msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78", size = 404650, upload-time = "2025-06-13T06:52:32.638Z" }, + { url = "https://files.pythonhosted.org/packages/85/d2/c849832b0c0bfb241efc830ccbe7fb880274bbdbc4780798b835f2cd7b3b/msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a", size = 413595, upload-time = "2025-06-13T06:52:33.882Z" }, + { url = "https://files.pythonhosted.org/packages/03/79/ea7cda493ec78afb9bd4c88e3c8bf5bffabca78d1917d8b24cddd0b9f5ee/msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6", size = 412830, upload-time = "2025-06-13T06:52:35.431Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/644311ca3064cfc9a9ecf64074e905e5359da730faefc88c6cfbbaf110ee/msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142", size = 65439, upload-time = "2025-06-13T06:52:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/27d4740fdeea71a7d559b405614b5d9b866028768a949e8dd58abed8474f/msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad", size = 72234, upload-time = "2025-06-13T06:52:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, + { url = "https://files.pythonhosted.org/packages/46/73/85469b4aa71d25e5949fee50d3c2cf46f69cea619fe97cfe309058080f75/msgpack-1.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea5405c46e690122a76531ab97a079e184c0daf491e588592d6a23d3e32af99e", size = 81529, upload-time = "2025-10-08T09:15:46.069Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3a/7d4077e8ae720b29d2b299a9591969f0d105146960681ea6f4121e6d0f8d/msgpack-1.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fba231af7a933400238cb357ecccf8ab5d51535ea95d94fc35b7806218ff844", size = 84106, upload-time = "2025-10-08T09:15:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/da451c74746ed9388dca1b4ec647c82945f4e2f8ce242c25fb7c0e12181f/msgpack-1.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8f6e7d30253714751aa0b0c84ae28948e852ee7fb0524082e6716769124bc23", size = 396656, upload-time = "2025-10-08T09:15:48.118Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a1/20486c29a31ec9f0f88377fdf7eb7a67f30bcb5e0f89b7550f6f16d9373b/msgpack-1.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94fd7dc7d8cb0a54432f296f2246bc39474e017204ca6f4ff345941d4ed285a7", size = 404722, upload-time = "2025-10-08T09:15:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ae/e613b0a526d54ce85447d9665c2ff8c3210a784378d50573321d43d324b8/msgpack-1.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:350ad5353a467d9e3b126d8d1b90fe05ad081e2e1cef5753f8c345217c37e7b8", size = 391838, upload-time = "2025-10-08T09:15:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/49/6a/07f3e10ed4503045b882ef7bf8512d01d8a9e25056950a977bd5f50df1c2/msgpack-1.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6bde749afe671dc44893f8d08e83bf475a1a14570d67c4bb5cec5573463c8833", size = 397516, upload-time = "2025-10-08T09:15:51.646Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/a86828e75986c12a3809c1e5062f5eba8e0cae3dfa2bf724ed2b1bb72b4c/msgpack-1.1.2-cp39-cp39-win32.whl", hash = "sha256:ad09b984828d6b7bb52d1d1d0c9be68ad781fa004ca39216c8a1e63c0f34ba3c", size = 64863, upload-time = "2025-10-08T09:15:53.118Z" }, + { url = "https://files.pythonhosted.org/packages/14/a7/b1992b4fb3da3b413f5fb78a63bad42f256c3be2352eb69273c3789c2c96/msgpack-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:67016ae8c8965124fdede9d3769528ad8284f14d635337ffa6a713a580f6c030", size = 71540, upload-time = "2025-10-08T09:15:55.573Z" }, +] + +[[package]] +name = "packaging" +version = "24.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/b5/b43a27ac7472e1818c4bafd44430e69605baefe1f34440593e0332ec8b4d/packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9", size = 147882, upload-time = "2024-03-10T09:39:28.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488, upload-time = "2024-03-10T09:39:25.947Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/42/8f2833655a29c4e9cb52ee8a2be04ceac61bcff4a680fb338cbd3d1e322d/pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3", size = 61613, upload-time = "2023-06-21T09:12:28.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/32/4a79112b8b87b21450b066e102d6608907f4c885ed7b04c3fdb085d4d6ae/pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", size = 17695, upload-time = "2023-06-21T09:12:27.397Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + +[[package]] +name = "pycrypto" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/db/645aa9af249f059cc3a368b118de33889219e0362141e75d4eaf6f80f163/pycrypto-2.6.1.tar.gz", hash = "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c", size = 446240, upload-time = "2014-06-20T08:10:20.813Z" } + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c4/6925ad41576d3e84f03aaf9a0411667af861f9fa2c87553c7dd5bde01518/pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a", size = 1623768, upload-time = "2025-05-17T17:21:33.418Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/d6c6a3098ddf2624068f041c5639be5092ad4ae1a411842369fd56765994/pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002", size = 1672070, upload-time = "2025-05-17T17:21:35.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/89/5d29c8f178fea7c92fd20d22f9ddd532a5e3ac71c574d555d2362aaa832a/pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be", size = 1664359, upload-time = "2025-05-17T17:21:37.551Z" }, + { url = "https://files.pythonhosted.org/packages/38/bc/a287d41b4421ad50eafb02313137d0276d6aeffab90a91e2b08f64140852/pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339", size = 1702359, upload-time = "2025-05-17T17:21:39.827Z" }, + { url = "https://files.pythonhosted.org/packages/2b/62/2392b7879f4d2c1bfa20815720b89d464687877851716936b9609959c201/pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6", size = 1802461, upload-time = "2025-05-17T17:21:41.722Z" }, +] + +[[package]] +name = "pyee" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2c/ebe4fd8213b3d720b193a62f07169607e945dd02a08edc45b28ca52fbe07/pyee-9.1.1.tar.gz", hash = "sha256:a1ebcd38d92e7d780635ab3442c0f6ce49840d9bfe0b0549921a6188860af0db", size = 22634, upload-time = "2023-06-09T06:13:29.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/53/39b67ce3841a5bb2d444f64ed969fb79ebd5bfed6867c3f88f3916407270/pyee-9.1.1-py2.py3-none-any.whl", hash = "sha256:f4a9853503d2f5a69d4350b54ba70841ebc535c53ebfaaa40c0fb47e63e78b3e", size = 15073, upload-time = "2023-06-09T06:13:27.255Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "pytest" +version = "7.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.8'" }, + { name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8' and python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", version = "24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "pluggy", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "tomli", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "tomli", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8' and python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287, upload-time = "2023-12-31T12:00:13.963Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "pytest", marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656, upload-time = "2024-04-29T13:23:24.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "pytest", marker = "python_full_version >= '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/b4/0b378b7bf26a8ae161c3890c0b48a91a04106c5713ce81b4b080ea2f4f18/pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3", size = 46920, upload-time = "2024-07-17T17:39:34.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2", size = 17663, upload-time = "2024-07-17T17:39:32.478Z" }, +] + +[[package]] +name = "pytest-cov" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.2.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "coverage", version = "7.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, + { name = "toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/3a/747e953051fd6eb5fb297907a825aad43d94c556d3b9938fc21f3172879f/pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7", size = 60395, upload-time = "2021-06-01T17:24:44.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/84/576b071aef9ac9301e5c0ff35d117e12db50b87da6f12e745e9c5f745cc2/pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a", size = 20441, upload-time = "2021-06-01T17:24:42.223Z" }, +] + +[[package]] +name = "pytest-forked" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/c9/93ad2ba2413057ee694884b88cf7467a46c50c438977720aeac26e73fdb7/pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f", size = 9977, upload-time = "2023-02-12T23:22:27.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/af/9c0bda43e486a3c9bf1e0f876d0f241bc3f229d7d65d09331a0868db9629/pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0", size = 4897, upload-time = "2023-02-12T23:22:26.022Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "execnet", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "pytest" }, + { name = "pytest-forked" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/d1/e1786c190f4010b04e7cbfbd927e0d78d9e32af9ba2cae49640fa31057cf/pytest-xdist-1.34.0.tar.gz", hash = "sha256:340e8e83e2a4c0d861bdd8d05c5d7b7143f6eea0aba902997db15c2a86be04ee", size = 66151, upload-time = "2020-07-27T23:05:25.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/fc/30821e7799bddd56989523ee003cde488c6e6053dfd29ba07db2ba934a04/pytest_xdist-1.34.0-py2.py3-none-any.whl", hash = "sha256:ba5d10729372d65df3ac150872f9df5d2ed004a3b0d499cc0164aafedd8c7b66", size = 36841, upload-time = "2020-07-27T23:05:23.851Z" }, +] + +[[package]] +name = "respx" +version = "0.20.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "httpx", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e0df26ea5c7145d95f1ab8ecb20f0778dd8af718e56747977dca9d28362a/respx-0.20.2.tar.gz", hash = "sha256:07cf4108b1c88b82010f67d3c831dae33a375c7b436e54d87737c7f9f99be643", size = 26080, upload-time = "2023-07-20T23:01:23.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/47/8c5a8b02c2144770fe353585b6db21e392c4318b8cff897738159feff562/respx-0.20.2-py2.py3-none-any.whl", hash = "sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9", size = 22849, upload-time = "2023-07-20T23:01:21.994Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "httpx", version = "0.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" }, + { url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" }, + { url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" }, + { url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" }, + { url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" }, + { url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/40/01/fb40ea8c465f680bf7aa3f5bee39c62ba8b7f52c38048c27aa95aff4f779/tokenize_rt-5.0.0.tar.gz", hash = "sha256:3160bc0c3e8491312d0485171dea861fc160a240f5f5766b72a1165408d10740", size = 5329, upload-time = "2022-10-03T23:28:00.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/12/4c7495f25b4c9131706f3aaffb185d4de32c02a6ee49d875e929c5b7c919/tokenize_rt-5.0.0-py2.py3-none-any.whl", hash = "sha256:c67772c662c6b3dc65edf66808577968fb10badfc2042e3027196bed4daf9e5a", size = 5848, upload-time = "2022-10-03T23:27:59.459Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/09/6257dabdeab5097d72c5d874f29b33cd667ec411af6667922d84f85b79b5/tokenize_rt-6.0.0.tar.gz", hash = "sha256:b9711bdfc51210211137499b5e355d3de5ec88a85d2025c520cbb921b5194367", size = 5360, upload-time = "2024-08-04T21:01:19.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c2/44486862562c6902778ccf88001ad5ea3f8da5c030c638cac8be72f65b40/tokenize_rt-6.0.0-py2.py3-none-any.whl", hash = "sha256:d4ff7ded2873512938b4f8cbb98c9b07118f01d30ac585a30d7a88353ca36d22", size = 5869, upload-time = "2024-08-04T21:01:17.84Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164, upload-time = "2022-02-08T10:54:04.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757, upload-time = "2022-02-08T10:54:02.017Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/8b/0111dd7d6c1478bf83baa1cab85c686426c7a6274119aceb2bd9d35395ad/typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2", size = 72876, upload-time = "2023-07-02T14:20:55.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6b/63cc3df74987c36fe26157ee12e09e8f9db4de771e0f3404263117e75b95/typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", size = 33232, upload-time = "2023-07-02T14:20:53.275Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "vcdiff-decoder" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/17/fb4e840967b9e734e45fef6b61280bac49aa40da675a031958010707c31b/vcdiff_decoder-0.1.0.tar.gz", hash = "sha256:905d9c39fd451331301652c16b19505c16d323446fa4dffa745b2855aff5fe69", size = 18613, upload-time = "2025-09-19T17:15:14.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/d5/0d1153f2dbaa02a11b2491d26b59f08e203409dacb91853c26c13bc28cb6/vcdiff_decoder-0.1.0-py3-none-any.whl", hash = "sha256:42f4e3d77b3bd4be881853858ee471a11d6a474fda375482d589b8576b91318f", size = 26333, upload-time = "2025-09-19T17:15:13.611Z" }, +] + +[[package]] +name = "websockets" +version = "11.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/3b/2ed38e52eed4cf277f9df5f0463a99199a04d9e29c9e227cfafa57bd3993/websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016", size = 104235, upload-time = "2023-05-07T14:25:20.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/76/88640f8aeac7eb0d058b913e7bb72682f8d569db44c7d30e576ec4777ce1/websockets-11.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac", size = 123714, upload-time = "2023-05-07T14:23:15.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6b/26b28115b46e23e74ede76d95792eedfe8c58b21f4daabfff1e9f159c8fe/websockets-11.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d", size = 120949, upload-time = "2023-05-07T14:23:17.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/2d1f3395d47fab65fa8b801e2251b324300ed8db54753b6fb7919cef0c11/websockets-11.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d27a4832cc1a0ee07cdcf2b0629a8a72db73f4cf6de6f0904f6661227f256f", size = 121032, upload-time = "2023-05-07T14:23:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ec/56bdd12d847e4fc2d0a7ba2d7f1476f79cda50599d11ffb6080b86f21ef1/websockets-11.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffd7dcaf744f25f82190856bc26ed81721508fc5cbf2a330751e135ff1283564", size = 130620, upload-time = "2023-05-07T14:23:21.545Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fb/ae5ed4be3514287cf8f6c348c87e1392a6e3f4d6eadae75c18847a2f84b6/websockets-11.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7622a89d696fc87af8e8d280d9b421db5133ef5b29d3f7a1ce9f1a7bf7fcfa11", size = 129628, upload-time = "2023-05-07T14:23:23.105Z" }, + { url = "https://files.pythonhosted.org/packages/58/0a/7570e15661a0a546c3a1152d95fe8c05480459bab36247f0acbf41f01a41/websockets-11.0.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bceab846bac555aff6427d060f2fcfff71042dba6f5fca7dc4f75cac815e57ca", size = 129938, upload-time = "2023-05-07T14:23:24.959Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6c/5c0322b2875e8395e6bf0eff11f43f3e25da7ef5b12f4d908cd3a19ea841/websockets-11.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54c6e5b3d3a8936a4ab6870d46bdd6ec500ad62bde9e44462c32d18f1e9a8e54", size = 134663, upload-time = "2023-05-07T14:23:26.382Z" }, + { url = "https://files.pythonhosted.org/packages/de/0e/d7274e4d41d7b34f204744c27a23707be2ecefaf6f7df2145655f086ecd7/websockets-11.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:41f696ba95cd92dc047e46b41b26dd24518384749ed0d99bea0a941ca87404c4", size = 133900, upload-time = "2023-05-07T14:23:28.307Z" }, + { url = "https://files.pythonhosted.org/packages/82/3c/00f051abcf88aec5e952a8840076749b0b26a30c219dcae8ba70200998aa/websockets-11.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86d2a77fd490ae3ff6fae1c6ceaecad063d3cc2320b44377efdde79880e11526", size = 134520, upload-time = "2023-05-07T14:23:30.734Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7b/4d4ecd29be7d08486e38f987a6603c491296d1e33fe55127d79aebb0333e/websockets-11.0.3-cp310-cp310-win32.whl", hash = "sha256:2d903ad4419f5b472de90cd2d40384573b25da71e33519a67797de17ef849b69", size = 124152, upload-time = "2023-05-07T14:23:33.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/a7/0ed69892981351e5acf88fac0ff4c801fabca2c3bdef9fca4c7d3fde8c53/websockets-11.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:1d2256283fa4b7f4c7d7d3e84dc2ece74d341bce57d5b9bf385df109c2a1a82f", size = 124674, upload-time = "2023-05-07T14:23:35.331Z" }, + { url = "https://files.pythonhosted.org/packages/16/49/ae616bd221efba84a3d78737b417f704af1ffa36f40dcaba5eb954dd4753/websockets-11.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e848f46a58b9fcf3d06061d17be388caf70ea5b8cc3466251963c8345e13f7eb", size = 123748, upload-time = "2023-05-07T14:23:37.977Z" }, + { url = "https://files.pythonhosted.org/packages/0a/84/68b848a373493b58615d6c10e9e8ccbaadfd540f84905421739a807704f8/websockets-11.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa5003845cdd21ac0dc6c9bf661c5beddd01116f6eb9eb3c8e272353d45b3288", size = 120975, upload-time = "2023-05-07T14:23:40.339Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a8/e81533499f84ef6cdd95d11d5b05fa827c0f097925afd86f16e6a2631d8e/websockets-11.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b58cbf0697721120866820b89f93659abc31c1e876bf20d0b3d03cef14faf84d", size = 121017, upload-time = "2023-05-07T14:23:41.874Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/65d6986665888494eca4d5435a9741c822022996f0f4200c57ce4b9242f7/websockets-11.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:660e2d9068d2bedc0912af508f30bbeb505bbbf9774d98def45f68278cea20d3", size = 131200, upload-time = "2023-05-07T14:23:43.309Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/a8a582ebeeecc8b5f332997d44c57e241748f8a9856e06a38a5a13b30796/websockets-11.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1f0524f203e3bd35149f12157438f406eff2e4fb30f71221c8a5eceb3617b6b", size = 130195, upload-time = "2023-05-07T14:23:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5e/b25c60067d700e811dccb4e3c318eeadd3a19d8b3620de9f97434af777a7/websockets-11.0.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:def07915168ac8f7853812cc593c71185a16216e9e4fa886358a17ed0fd9fcf6", size = 130569, upload-time = "2023-05-07T14:23:46.926Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/5cbbf439c925e1e184a0392ec477a30cee2fabc0e63807c1d4b6d570fb52/websockets-11.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b30c6590146e53149f04e85a6e4fcae068df4289e31e4aee1fdf56a0dead8f97", size = 136015, upload-time = "2023-05-07T14:23:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d8/a997d3546aef9cc995a1126f7d7ade96c0e16c1a0efb9d2d430aee57c925/websockets-11.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:619d9f06372b3a42bc29d0cd0354c9bb9fb39c2cbc1a9c5025b4538738dbffaf", size = 135292, upload-time = "2023-05-07T14:23:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/89/8f/707a05d5725f956c78d252a5fd73b89fa3ac57dd3959381c2d1acb41cb13/websockets-11.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:01f5567d9cf6f502d655151645d4e8b72b453413d3819d2b6f1185abc23e82dd", size = 135890, upload-time = "2023-05-07T14:23:52.707Z" }, + { url = "https://files.pythonhosted.org/packages/b5/94/ac47552208583d5dbcce468430c1eb2ae18962f6b3a694a2b7727cc60d4a/websockets-11.0.3-cp311-cp311-win32.whl", hash = "sha256:e1459677e5d12be8bbc7584c35b992eea142911a6236a3278b9b5ce3326f282c", size = 124149, upload-time = "2023-05-07T14:23:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7c/0ad6e7ef0a054d73092f616d20d3d9bd3e1b837554cb20a52d8dd9f5b049/websockets-11.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:e7837cb169eca3b3ae94cc5787c4fed99eef74c0ab9506756eea335e0d6f3ed8", size = 124670, upload-time = "2023-05-07T14:23:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/8900184ab0b06b6e620ba7e92cf2faa5caa9ba86e148541b8fff1c7b6646/websockets-11.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9f59a3c656fef341a99e3d63189852be7084c0e54b75734cde571182c087b152", size = 120868, upload-time = "2023-05-07T14:23:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/44/a8/66c3a66b70b01a6c55fde486298766177fa11dd0d3a2c1cfc6820f25b4dc/websockets-11.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2529338a6ff0eb0b50c7be33dc3d0e456381157a31eefc561771ee431134a97f", size = 130557, upload-time = "2023-05-07T14:23:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/70/fc/71377f36ef3049f3bc7db7c0f3a7696929d5f836d7a18777131d994192a9/websockets-11.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34fd59a4ac42dff6d4681d8843217137f6bc85ed29722f2f7222bd619d15e95b", size = 129640, upload-time = "2023-05-07T14:24:01.412Z" }, + { url = "https://files.pythonhosted.org/packages/36/19/0da435afb26a6c47c0c045a82e414912aa2ac10de5721276a342bd9fdfee/websockets-11.0.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:332d126167ddddec94597c2365537baf9ff62dfcc9db4266f263d455f2f031cb", size = 129903, upload-time = "2023-05-07T14:24:02.872Z" }, + { url = "https://files.pythonhosted.org/packages/38/ed/b8b133416536b6816e480594864e5950051db522714623eefc9e5275ec04/websockets-11.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6505c1b31274723ccaf5f515c1824a4ad2f0d191cec942666b3d0f3aa4cb4007", size = 135302, upload-time = "2023-05-07T14:24:04.326Z" }, + { url = "https://files.pythonhosted.org/packages/e9/26/1dfaa81788f61c485b4d65f1b28a19615e39f9c45100dce5e2cbf5ad1352/websockets-11.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f467ba0050b7de85016b43f5a22b46383ef004c4f672148a8abf32bc999a87f0", size = 134562, upload-time = "2023-05-07T14:24:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f7/1e852351e8073c32885172a6bef64c95d14c13ff3634b01d4a1086321491/websockets-11.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9d9acd80072abcc98bd2c86c3c9cd4ac2347b5a5a0cae7ed5c0ee5675f86d9af", size = 135191, upload-time = "2023-05-07T14:24:07.659Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/2ea3f95d83033675144b0848a0ae2e4998b3f763da09ec3df6bce97ea4e6/websockets-11.0.3-cp37-cp37m-win32.whl", hash = "sha256:e590228200fcfc7e9109509e4d9125eace2042fd52b595dd22bbc34bb282307f", size = 124138, upload-time = "2023-05-07T14:24:09.697Z" }, + { url = "https://files.pythonhosted.org/packages/94/8c/266155c14b7a26deca6fa4c4d5fd15b0ab32725d78a2acfcf6b24943585d/websockets-11.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:b16fff62b45eccb9c7abb18e60e7e446998093cdcb50fed33134b9b6878836de", size = 124672, upload-time = "2023-05-07T14:24:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/60eccd7e9703bbe93fc4167d1e7ada7e8e8e51544122198d63fd8e3460b7/websockets-11.0.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fb06eea71a00a7af0ae6aefbb932fb8a7df3cb390cc217d51a9ad7343de1b8d0", size = 123706, upload-time = "2023-05-07T14:24:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ec/7e2b9bebc2e9b4a48404144106bbc6a7ace781feeb0e6a3829551e725fa5/websockets-11.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8a34e13a62a59c871064dfd8ffb150867e54291e46d4a7cf11d02c94a5275bae", size = 120944, upload-time = "2023-05-07T14:24:16.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/a5e5973899d78d44a540f50a9e30b01c6771e8bf7883204ee762060cf95a/websockets-11.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4841ed00f1026dfbced6fca7d963c4e7043aa832648671b5138008dc5a8f6d99", size = 121030, upload-time = "2023-05-07T14:24:17.905Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3f/0c5cae14e9e86401105833383405787ae4caddd476a8fc5561259253dab7/websockets-11.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a073fc9ab1c8aff37c99f11f1641e16da517770e31a37265d2755282a5d28aa", size = 130811, upload-time = "2023-05-07T14:24:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/a0/39/acc3d4b15c5207ef7cca823c37eca8c74e3e1a1a63a397798986be3bdef7/websockets-11.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68b977f21ce443d6d378dbd5ca38621755f2063d6fdb3335bda981d552cfff86", size = 129876, upload-time = "2023-05-07T14:24:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/58/05/2efb520317340ece74bfc4d88e8f011dd71a4e6c263000bfffb71a343685/websockets-11.0.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a99a7a71631f0efe727c10edfba09ea6bee4166a6f9c19aafb6c0b5917d09c", size = 130158, upload-time = "2023-05-07T14:24:25.193Z" }, + { url = "https://files.pythonhosted.org/packages/30/a5/d641f2a9a4b4079cfddbb0726fc1b914be76a610aaedb45e4760899a4ce1/websockets-11.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bee9fcb41db2a23bed96c6b6ead6489702c12334ea20a297aa095ce6d31370d0", size = 134494, upload-time = "2023-05-07T14:24:26.463Z" }, + { url = "https://files.pythonhosted.org/packages/ca/20/25211be61d50189650fb0ec6084b6d6339f5c7c6436a6c217608dcb553e4/websockets-11.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4b253869ea05a5a073ebfdcb5cb3b0266a57c3764cf6fe114e4cd90f4bfa5f5e", size = 133735, upload-time = "2023-05-07T14:24:29.598Z" }, + { url = "https://files.pythonhosted.org/packages/c6/91/f36454b87edf10a95be9c7212d2dcb8c606ddbf7a183afdc498933acdd19/websockets-11.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1553cb82942b2a74dd9b15a018dce645d4e68674de2ca31ff13ebc2d9f283788", size = 134368, upload-time = "2023-05-07T14:24:30.923Z" }, + { url = "https://files.pythonhosted.org/packages/58/68/9403771de1b1c21a2e878e4841815af8c9f8893b094654934e2a5ee4dbc8/websockets-11.0.3-cp38-cp38-win32.whl", hash = "sha256:f61bdb1df43dc9c131791fbc2355535f9024b9a04398d3bd0684fc16ab07df74", size = 124148, upload-time = "2023-05-07T14:24:32.299Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/48540419005d07ed2d368a7eafb44ed4f33a2691ae4c210850bf31123c4a/websockets-11.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:03aae4edc0b1c68498f41a6772d80ac7c1e33c06c6ffa2ac1c27a07653e79d6f", size = 124665, upload-time = "2023-05-07T14:24:34.482Z" }, + { url = "https://files.pythonhosted.org/packages/c0/21/cb9dfbbea8dc0ad89ced52630e7e61edb425fb9fdc6002f8d0c5dd26b94b/websockets-11.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:777354ee16f02f643a4c7f2b3eff8027a33c9861edc691a2003531f5da4f6bc8", size = 123707, upload-time = "2023-05-07T14:24:36.007Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/8a3eb016be19743c7eb9e67c855df0fdfa5912534ffaf83a05b62667d761/websockets-11.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8c82f11964f010053e13daafdc7154ce7385ecc538989a354ccc7067fd7028fd", size = 120963, upload-time = "2023-05-07T14:24:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3da73e69ebc00649d11ed836541c92c1a2df0b8a8aa641a2c8746e7c2b9c/websockets-11.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3580dd9c1ad0701169e4d6fc41e878ffe05e6bdcaf3c412f9d559389d0c9e016", size = 121014, upload-time = "2023-05-07T14:24:39.009Z" }, + { url = "https://files.pythonhosted.org/packages/d9/36/5741e62ccf629c8e38cc20f930491f8a33ce7dba972cae93dba3d6f02552/websockets-11.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f1a3f10f836fab6ca6efa97bb952300b20ae56b409414ca85bff2ad241d2a61", size = 130408, upload-time = "2023-05-07T14:24:40.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/799f595c67b97a8a17e13d2764e088f631616bd95668aaa4c04b7cada136/websockets-11.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df41b9bc27c2c25b486bae7cf42fccdc52ff181c8c387bfd026624a491c2671b", size = 129407, upload-time = "2023-05-07T14:24:42.479Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/2356ecb952fd3992b73f7a897d65e57d784a69b94bb8d8fd5f97531e5c02/websockets-11.0.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:279e5de4671e79a9ac877427f4ac4ce93751b8823f276b681d04b2156713b9dd", size = 129712, upload-time = "2023-05-07T14:24:44.186Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f5/15998b164c183af0513bba744b51ecb08d396ff86c0db3b55d62624d1f15/websockets-11.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1fdf26fa8a6a592f8f9235285b8affa72748dc12e964a5518c6c5e8f916716f7", size = 134386, upload-time = "2023-05-07T14:24:45.702Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/a04d2911f6e2b9e781ce7ffc1e8516b54b85f985369eec8c853fd619d8e8/websockets-11.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:69269f3a0b472e91125b503d3c0b3566bda26da0a3261c49f0027eb6075086d1", size = 133639, upload-time = "2023-05-07T14:24:46.966Z" }, + { url = "https://files.pythonhosted.org/packages/72/89/0d150939f2e592ed78c071d69237ac1c872462cc62a750c5f592f3d4ab18/websockets-11.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:97b52894d948d2f6ea480171a27122d77af14ced35f62e5c892ca2fae9344311", size = 134260, upload-time = "2023-05-07T14:24:48.633Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6e/0fd7274042f46acb589161407f4b505b44c68d369437ce919bae1fa9b8c4/websockets-11.0.3-cp39-cp39-win32.whl", hash = "sha256:c7f3cb904cce8e1be667c7e6fef4516b98d1a6a0635a58a57528d577ac18a128", size = 124146, upload-time = "2023-05-07T14:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3f/65dfa50084a06ab0a05f3ca74195c2c17a1c075b8361327d831ccce0a483/websockets-11.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c792ea4eabc0159535608fc5658a74d1a81020eb35195dd63214dcf07556f67e", size = 124665, upload-time = "2023-05-07T14:24:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2f/3ad8ac4a9dc9d685e098e534180a36ed68fe2e85e82e225e00daec86bb94/websockets-11.0.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f2e58f2c36cc52d41f2659e4c0cbf7353e28c8c9e63e30d8c6d3494dc9fdedcf", size = 120795, upload-time = "2023-05-07T14:24:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/7100e9cf310fe1d83d1ae1322203f4eb2b767a7c2b301c1e70db6270306f/websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de36fe9c02995c7e6ae6efe2e205816f5f00c22fd1fbf343d4d18c3d5ceac2f5", size = 122910, upload-time = "2023-05-07T14:24:54.851Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/df5452031b02b857851139806308f2af7c749069e25bfe15f2d559ade6e7/websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ac56b661e60edd453585f4bd68eb6a29ae25b5184fd5ba51e97652580458998", size = 122516, upload-time = "2023-05-07T14:24:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/03/28/3a51ffcf51ac45746639f83128908bbb1cd212aa631e42d15a7acebce5cb/websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e052b8467dd07d4943936009f46ae5ce7b908ddcac3fda581656b1b19c083d9b", size = 122462, upload-time = "2023-05-07T14:24:57.77Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/2af7fc3ce2c3f1378d48a15802b4ff2caf6c0dfac13291e73c557caf04f7/websockets-11.0.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:42cc5452a54a8e46a032521d7365da775823e21bfba2895fb7b77633cce031bb", size = 124704, upload-time = "2023-05-07T14:24:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/20/62/5c6039c4069912adb27889ddd000403a2de9e0fe6aebe439b4e6b128a6b8/websockets-11.0.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e6316827e3e79b7b8e7d8e3b08f4e331af91a48e794d5d8b099928b6f0b85f20", size = 120795, upload-time = "2023-05-07T14:25:01.047Z" }, + { url = "https://files.pythonhosted.org/packages/38/30/01a10fbf4cc1e7ffa07be9b0401501918fc9433d71fb7da4cfcef3bd26ca/websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8531fdcad636d82c517b26a448dcfe62f720e1922b33c81ce695d0edb91eb931", size = 122908, upload-time = "2023-05-07T14:25:02.734Z" }, + { url = "https://files.pythonhosted.org/packages/99/23/43071c989c0f87f612e7bccee98d00b04bddd3aca0cdc1ffaf31f6f8a4b4/websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c114e8da9b475739dde229fd3bc6b05a6537a88a578358bc8eb29b4030fac9c9", size = 122515, upload-time = "2023-05-07T14:25:04.803Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/0d586c25d043aeab9457dad8e407251e3baf314d871215f91847e7b995c4/websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e063b1865974611313a3849d43f2c3f5368093691349cf3c7c8f8f75ad7cb280", size = 122465, upload-time = "2023-05-07T14:25:06.352Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/605b0618d0864e9be7c2a78f22bff57aba9cf56b9fccde3205db9023ae22/websockets-11.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:92b2065d642bf8c0a82d59e59053dd2fdde64d4ed44efe4870fa816c1232647b", size = 124707, upload-time = "2023-05-07T14:25:07.782Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3d/3dc77699fa4d003f2e810c321592f80f62b81d7b78483509de72ffe581fd/websockets-11.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0ee68fe502f9031f19d495dae2c268830df2760c0524cbac5d759921ba8c8e82", size = 120795, upload-time = "2023-05-07T14:25:09.785Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1b/5c83c40f8d3efaf0bb2fdf05af94fb920f74842b7aaf31d7598e3ee44d58/websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcacf2c7a6c3a84e720d1bb2b543c675bf6c40e460300b628bab1b1efc7c034c", size = 122909, upload-time = "2023-05-07T14:25:11.243Z" }, + { url = "https://files.pythonhosted.org/packages/32/2c/ab8ea64e9a7d8bf62a7ea7a037fb8d328d8bd46dbfe083787a9d452a148e/websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b67c6f5e5a401fc56394f191f00f9b3811fe843ee93f4a70df3c389d1adf857d", size = 122517, upload-time = "2023-05-07T14:25:12.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/97/34178f5f7c29e679372d597cebfeff2aa45991d741d938117d4616e81a74/websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d5023a4b6a5b183dc838808087033ec5df77580485fc533e7dab2567851b0a4", size = 122463, upload-time = "2023-05-07T14:25:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/466944e00b324ae3a1fddb305b4abf641f582e131548f07bcd970971b154/websockets-11.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ed058398f55163a79bb9f06a90ef9ccc063b204bb346c4de78efc5d15abfe602", size = 124707, upload-time = "2023-05-07T14:25:17.112Z" }, + { url = "https://files.pythonhosted.org/packages/47/96/9d5749106ff57629b54360664ae7eb9afd8302fad1680ead385383e33746/websockets-11.0.3-py3-none-any.whl", hash = "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6", size = 118056, upload-time = "2023-05-07T14:25:18.508Z" }, +] + +[[package]] +name = "websockets" +version = "13.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload-time = "2024-09-21T17:34:21.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload-time = "2024-09-21T17:32:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload-time = "2024-09-21T17:32:28.428Z" }, + { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload-time = "2024-09-21T17:32:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload-time = "2024-09-21T17:32:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload-time = "2024-09-21T17:32:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload-time = "2024-09-21T17:32:33.398Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload-time = "2024-09-21T17:32:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload-time = "2024-09-21T17:32:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload-time = "2024-09-21T17:32:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload-time = "2024-09-21T17:32:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload-time = "2024-09-21T17:32:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload-time = "2024-09-21T17:32:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload-time = "2024-09-21T17:32:43.858Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload-time = "2024-09-21T17:32:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload-time = "2024-09-21T17:32:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload-time = "2024-09-21T17:32:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload-time = "2024-09-21T17:32:48.046Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload-time = "2024-09-21T17:32:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload-time = "2024-09-21T17:32:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload-time = "2024-09-21T17:32:52.223Z" }, + { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload-time = "2024-09-21T17:32:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload-time = "2024-09-21T17:32:54.721Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload-time = "2024-09-21T17:32:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload-time = "2024-09-21T17:32:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload-time = "2024-09-21T17:32:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload-time = "2024-09-21T17:33:00.495Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload-time = "2024-09-21T17:33:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload-time = "2024-09-21T17:33:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload-time = "2024-09-21T17:33:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload-time = "2024-09-21T17:33:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload-time = "2024-09-21T17:33:06.823Z" }, + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload-time = "2024-09-21T17:33:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload-time = "2024-09-21T17:33:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload-time = "2024-09-21T17:33:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload-time = "2024-09-21T17:33:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload-time = "2024-09-21T17:33:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload-time = "2024-09-21T17:33:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload-time = "2024-09-21T17:33:17.113Z" }, + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload-time = "2024-09-21T17:33:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload-time = "2024-09-21T17:33:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload-time = "2024-09-21T17:33:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload-time = "2024-09-21T17:33:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload-time = "2024-09-21T17:33:24.196Z" }, + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload-time = "2024-09-21T17:33:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/59872420e5bce60db166d6fba39ee24c719d339fb0ae48cb2ce580129882/websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d", size = 157811, upload-time = "2024-09-21T17:33:27.379Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/0610032e0d3981758fdd6ee7c68cc02ebf668a762c5178d3d91748228849/websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23", size = 155471, upload-time = "2024-09-21T17:33:28.473Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/c43173a72ea395263a427a36d25bce2675f41c809424466a13c61a9a2d61/websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c", size = 155713, upload-time = "2024-09-21T17:33:29.795Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/8fa930c6426a56c47910792717787640329e4a0e37cdfda20cf89da67126/websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea", size = 164995, upload-time = "2024-09-21T17:33:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/27/29/50ed4c68a3f606565a2db4b13948ae7b6f6c53aa9f8f258d92be6698d276/websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7", size = 164057, upload-time = "2024-09-21T17:33:31.862Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0e/60da63b1c53c47f389f79312b3356cb305600ffad1274d7ec473128d4e6b/websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54", size = 164340, upload-time = "2024-09-21T17:33:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/20/ef/d87c5fc0aa7fafad1d584b6459ddfe062edf0d0dd64800a02e67e5de048b/websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db", size = 164222, upload-time = "2024-09-21T17:33:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c4/7916e1f6b5252d3dcb9121b67d7fdbb2d9bf5067a6d8c88885ba27a9e69c/websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295", size = 163647, upload-time = "2024-09-21T17:33:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/2ebebb807f10993c35c10cbd3628a7944b66bd5fb6632a561f8666f3a68e/websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96", size = 163590, upload-time = "2024-09-21T17:33:37.61Z" }, + { url = "https://files.pythonhosted.org/packages/b5/82/d48911f56bb993c11099a1ff1d4041d9d1481d50271100e8ee62bc28f365/websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf", size = 158701, upload-time = "2024-09-21T17:33:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b3/945aacb21fc89ad150403cbaa974c9e846f098f16d9f39a3dd6094f9beb1/websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6", size = 159146, upload-time = "2024-09-21T17:33:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/61/26/5f7a7fb03efedb4f90ed61968338bfe7c389863b0ceda239b94ae61c5ae4/websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d", size = 157810, upload-time = "2024-09-21T17:33:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d4/9b4814a07dffaa7a79d71b4944d10836f9adbd527a113f6675734ef3abed/websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7", size = 155467, upload-time = "2024-09-21T17:33:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/2abdc7ce3b56429ae39d6bfb48d8c791f5a26bbcb6f44aabcf71ffc3fda2/websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a", size = 155714, upload-time = "2024-09-21T17:33:43.128Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/189d7cf232753a719b2726ec55e7922522632248d5d830adf078e3f612be/websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa", size = 164587, upload-time = "2024-09-21T17:33:44.27Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2b/fb77cedf3f9f55ef8605238c801eef6b9a5269b01a396875a86896aea3a6/websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa", size = 163588, upload-time = "2024-09-21T17:33:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b7/070481b83d2d5ac0f19233d9f364294e224e6478b0762f07fa7f060e0619/websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79", size = 163894, upload-time = "2024-09-21T17:33:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/eb/be/d6e1cff7d441cfe5eafaacc5935463e5f14c8b1c0d39cb8afde82709b55a/websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17", size = 164315, upload-time = "2024-09-21T17:33:48.432Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/ffa234473e46ab2d3f9fd9858163d5db3ecea1439e4cb52966d78906424b/websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6", size = 163714, upload-time = "2024-09-21T17:33:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/cc/92/cea9eb9d381ca57065a5eb4ec2ce7a291bd96c85ce742915c3c9ffc1069f/websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5", size = 163673, upload-time = "2024-09-21T17:33:51.056Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f1/279104fff239bfd04c12b1e58afea227d72fd1acf431e3eed3f6ac2c96d2/websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c", size = 158702, upload-time = "2024-09-21T17:33:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/25/0b/b87370ff141375c41f7dd67941728e4b3682ebb45882591516c792a2ebee/websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d", size = 159146, upload-time = "2024-09-21T17:33:53.781Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload-time = "2024-09-21T17:33:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload-time = "2024-09-21T17:33:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload-time = "2024-09-21T17:33:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload-time = "2024-09-21T17:33:59.061Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload-time = "2024-09-21T17:34:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload-time = "2024-09-21T17:34:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a1/5ae6d0ef2e61e2b77b3b4678949a634756544186620a728799acdf5c3482/websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b", size = 155433, upload-time = "2024-09-21T17:34:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/addd33f85600d210a445f817ff0d79d2b4d0eb6f3c95b9f35531ebf8f57c/websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51", size = 155733, upload-time = "2024-09-21T17:34:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/74/0b/f8ec74ac3b14a983289a1b42dc2c518a0e2030b486d0549d4f51ca11e7c9/websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7", size = 157093, upload-time = "2024-09-21T17:34:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4c/aa5cc2f718ee4d797411202f332c8281f04c42d15f55b02f7713320f7a03/websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d", size = 156701, upload-time = "2024-09-21T17:34:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/7c5b2d0d0f0f1a54f27c60107cf1f201bee1f88c5508f87408b470d09a9c/websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027", size = 156648, upload-time = "2024-09-21T17:34:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/f3/63/35f3fb073884a9fd1ce5413b2dcdf0d9198b03dac6274197111259cbde06/websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978", size = 159188, upload-time = "2024-09-21T17:34:10.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/fd/e4bf9a7159dba6a16c59ae9e670e3e8ad9dcb6791bc0599eb86de32d50a9/websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e", size = 155499, upload-time = "2024-09-21T17:34:11.3Z" }, + { url = "https://files.pythonhosted.org/packages/74/42/d48ede93cfe0c343f3b552af08efc60778d234989227b16882eed1b8b189/websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09", size = 155731, upload-time = "2024-09-21T17:34:13.151Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f2/2ef6bff1c90a43b80622a17c0852b48c09d3954ab169266ad7b15e17cdcb/websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842", size = 157093, upload-time = "2024-09-21T17:34:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/6f20bbaeeb350f155edf599aad949c554216f90e5d4ae7373d1f2e5931fb/websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb", size = 156701, upload-time = "2024-09-21T17:34:15.692Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/38279dfefecd035e22b79c38722d4f87c4b6196f1556b7a631d0a3095ca7/websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20", size = 156649, upload-time = "2024-09-21T17:34:17.335Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c5/12c6859a2eaa8c53f59a647617a27f1835a226cd7106c601067c53251d98/websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678", size = 159187, upload-time = "2024-09-21T17:34:18.538Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload-time = "2024-09-21T17:34:19.904Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" }, + { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" }, + { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "zipp" +version = "3.15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/00/27/f0ac6b846684cecce1ee93d32450c45ab607f65c2e0255f0092032d91f07/zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", size = 18454, upload-time = "2023-02-25T02:17:22.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/fa/c9e82bbe1af6266adf08afb563905eb87cab83fde00a0a08963510621047/zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556", size = 6758, upload-time = "2023-02-25T02:17:20.807Z" }, +] + +[[package]] +name = "zipp" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", size = 24199, upload-time = "2024-09-13T13:44:16.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", size = 9200, upload-time = "2024-09-13T13:44:14.38Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]