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 index cf0c87d3..42f6972d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -5,40 +5,49 @@ # 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: - - runs-on: ubuntu-latest + permissions: + contents: read + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: - python-version: [3.5, 3.6, 3.7, 3.8, 3.9] - + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements-test.txt - - name: Initialize and update submodules - run: | - git submodule init - git submodule update - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=15 --statistics - - name: Test with pytest - run: | - pytest + - 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 d902fd24..75ec0f34 100644 --- a/.gitignore +++ b/.gitignore @@ -48,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/.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 index 21f18fe3..793f50c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,443 @@ # 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) 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 index f40cc374..6717bc41 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1 +1 @@ -Copyright 2015-2021 Ably Real-time Ltd (ably.com) +Copyright 2015-2022 Ably Real-time Ltd (ably.com) diff --git a/LONG_DESCRIPTION.rst b/LONG_DESCRIPTION.rst index 37ef5618..3e4a6aed 100644 --- a/LONG_DESCRIPTION.rst +++ b/LONG_DESCRIPTION.rst @@ -1,7 +1,7 @@ Official Ably Bindings for Python ================================== -A Python client library for ably.io realtime messaging +A Python client library for Ably Realtime messaging. Setup @@ -15,6 +15,6 @@ You can install this package by using the pip tool and installing: Using Ably for Python --------------------- -- Sign up for Ably at https://www.ably.io/ +- Sign up for Ably at https://ably.com/sign-up - Get usage examples at https://github.com/ably/ably-python -- Visit https://www.ably.io/documentation for a complete API reference and more examples. +- Visit https://ably.com/docs for a complete API reference and more examples diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index e8657073..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include LICENSE LONG_DESCRIPTION.rst setup.cfg diff --git a/README.md b/README.md index 98f9418a..4ee29fd5 100644 --- a/README.md +++ b/README.md @@ -1,190 +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) -![.github/workflows/check.yml](https://github.com/ably/ably-python/workflows/.github/workflows/check.yml/badge.svg) -[![PyPI version](https://badge.fury.io/py/ably.svg)](https://badge.fury.io/py/ably) -A Python client library for [www.ably.io](https://www.ably.io), the realtime messaging service. This library currently targets the [Ably 1.1 client library specification](https://www.ably.io/documentation/client-lib-development-guide/features/). You can jump to the '[Known Limitations](#known-limitations)' section to see the features this client library does not yet support (if any) or [view our client library SDKs feature support matrix](https://www.ably.io/download/sdk-feature-support-matrix) to see the list of all the available features. +# Ably Pub/Sub Python SDK -## Supported platforms - -This SDK supports Python 3.5+. - -We regression-test the SDK against a selection of Python versions (which we update over time, but usually consists of mainstream and widely used versions). Please refer to [check.yml](.github/workflows/check.yml) for the set of versions that currently undergo CI testing. - -If you find any compatibility issues, please [do raise an issue](https://github.com/ably/ably-python/issues/new) in this repository or [contact Ably customer support](https://support.ably.io/) for advice. - -## Known Limitations - -Currently, this SDK only supports [Ably REST](https://www.ably.io/documentation/rest). However, you can use the [MQTT adapter](https://www.ably.io/documentation/mqtt) to implement [Ably's Realtime](https://www.ably.io/documentation/realtime) features using Python. - -## Documentation - -Visit https://www.ably.io/documentation for a complete API reference and more examples. - -## Installation - -The client library is available as a [PyPI package](https://pypi.python.org/pypi/ably). +Build any realtime experience using Ably’s Pub/Sub Python SDK. -### From PyPI +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. - pip install ably +Find out more: -Or, if you need encryption features: +* [Ably Pub/Sub docs.](https://ably.com/docs/basics) +* [Ably Pub/Sub examples.](https://ably.com/examples?product=pubsub) - pip install 'ably[crypto]' +--- -### Locally +## Getting started - git clone https://github.com/ably/ably-python.git - cd ably-python - python setup.py install +Everything you need to get started with Ably: -## Using the REST API +* [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) -All examples assume a client and/or channel has been created as follows: +--- -```python -from ably import AblyRest -client = AblyRest('api:key') -channel = client.channels.get('channel_name') -``` - -You can define the logging level for the whole library, and override for an -specific module: - - import logging - import ably - - logging.getLogger('ably').setLevel(logging.WARNING) - logging.getLogger('ably.rest.auth').setLevel(logging.INFO) - -You need to add a handler to see any output: - - logger = logging.getLogger('ably') - logger.addHandler(logging.StreamHandler()) - -### Publishing a message to a channel - -```python -channel.publish('event', 'message') -``` +## Supported platforms -### Querying the History +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 -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 -``` +The following platforms are supported: -### Current presence members on a channel +| Platform | Support | +|----------|--------------------------| +| Python | Python 3.7+ through 3.14 | -```python -members_page = channel.presence.get() # Returns a PaginatedResult -members_page.items -members_page.items[0].client_id # client_id of first member present -``` +> [!NOTE] +> This SDK works across all major operating platforms (Linux, macOS, Windows) as long as Python 3.7+ is available. -### Querying the presence history +> [!IMPORTANT] +> SDK versions < 2.0.0 are [deprecated](https://ably.com/docs/platform/deprecate/protocol-v1). -```python -presence_page = channel.presence.history() # Returns a PaginatedResult -presence_page.items -presence_page.items[0].client_id # client_id of first member -``` - -### Symmetric end-to-end encrypted payloads on a channel - -When a 128 bit or 256 bit key is provided to the library, all payloads are encrypted and decrypted automatically using that key on the channel. The secret key is never transmitted to Ably and thus it is the developer's responsibility to distribute a secret key to both publishers and subscribers. - -```ruby -key = ably.util.crypto.generate_random_key() -channel = rest.channels.get('communication', cipher={'key': key}) -channel.publish(u'unencrypted', u'encrypted secret payload') -messages_page = channel.history() -messages_page.items[0].data #=> "sensitive data" -``` +--- -### Generate a Token +## Installation -Tokens are issued by Ably and are readily usable by any client to connect to Ably: +To get started with your project, install the package: -```python -token_details = client.auth.request_token() -token_details.token # => "xVLyHw.CLchevH3hF....MDh9ZC_Q" -new_client = AblyRest(token=token_details) +```sh +pip install ably ``` -### Generate a TokenRequest - -Token requests are issued by your servers and signed using your private API key. This is the preferred method of authentication as no secrets are ever shared, and the token request can be issued to trusted clients without communicating with Ably. +> [!NOTE] +Install [Python](https://www.python.org/downloads/) version 3.8 or greater. -```python -token_request = client.auth.create_token_request( - { - 'client_id': 'jim', - 'capability': {'channel1': '"*"'}, - 'ttl': 3600 * 1000, # ms - } -) -# => {"id": ..., -# "clientId": "jim", -# "ttl": 3600000, -# "timestamp": ..., -# "capability": "{\"*\":[\"*\"]}", -# "nonce": ..., -# "mac": ...} - -new_client = AblyRest(token=token_request) -``` +## Usage -### Fetching your application's stats +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 -stats = client.stats() # Returns a PaginatedResult -stats.items +# 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') ``` -### Fetching the Ably service time +## Releases -```python -client.time() -``` - -## Support, feedback and troubleshooting +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). -Please visit http://support.ably.io/ for access to our knowledgebase and to ask for any assistance. +--- -You can also view the [community reported Github issues](https://github.com/ably/ably-python/issues). +## Contribute -To see what has changed in recent versions of Bundler, see the [CHANGELOG](CHANGELOG.md). +Read the [CONTRIBUTING.md](./CONTRIBUTING.md) guidelines to contribute to Ably. -## Running the test suite +--- -```python -git submodule init -git submodule update -pip install -r requirements-test.txt -pytest test -``` +## Support, feedback, and troubleshooting -## Contributing +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. -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Ensure you have added suitable tests and the test suite is passing(`py.test`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request +### Full Realtime support unavailable -## Release Process +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). -1. Update [`setup.py`](./setup.py) and [`ably/__init__.py`](./ably/__init__.py) with the new version number -2. Run [`github_changelog_generator`](https://github.com/skywinder/Github-Changelog-Generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). Once the CHANGELOG has completed, manually change the `Unreleased` heading and link with the current version number such as `v1.0.0`. Also ensure that the `Full Changelog` link points to the new version tag instead of the `HEAD`. -3. Commit -4. Run `python setup.py sdist upload -r ably` to build and upload this new package to PyPi -5. Tag the new version such as `git tag v1.0.0` -6. Visit https://github.com/ably/ably-python/tags and add release notes for the release including links to the changelog entry. -7. Push the tag to origin `git push origin v1.0.0` 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 a230a74b..e050b7c5 100644 --- a/ably/__init__.py +++ b/ably/__init__.py @@ -1,21 +1,24 @@ import logging - -logger = logging.getLogger(__name__) -logger.addHandler(logging.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.channelsubscription import PushChannelSubscription from ably.types.device import DeviceDetails -from ably.types.options import Options +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, AblyAuthException, IncompatibleClientIdException +from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.vcdiff.defaultvcdiffdecoder import AblyVCDiffDecoder + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) -api_version = '1.1' -lib_version = '1.1.1' +api_version = '5' +lib_version = '3.1.2' diff --git a/ably/http/http.py b/ably/http/http.py index 8cccd472..d21a9386 100644 --- a/ably/http/http.py +++ b/ably/http/http.py @@ -1,60 +1,62 @@ import functools +import json import logging import time -import json from urllib.parse import urljoin -import requests +import httpx import msgpack -from ably.rest.auth import Auth from ably.http.httputils import HttpUtils +from ably.rest.auth import Auth from ably.transport.defaults import Defaults -from ably.util.exceptions import AblyException, AblyAuthException +from ably.util.exceptions import AblyException +from ably.util.helper import extract_url_params, is_token_error log = logging.getLogger(__name__) def reauth_if_expired(func): @functools.wraps(func) - def wrapper(rest, *args, **kwargs): + async def wrapper(rest, *args, **kwargs): if kwargs.get("skip_auth"): - return func(rest, *args, **kwargs) + return await func(rest, *args, **kwargs) # 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(): - rest.reauth() + await auth.authorize() retried = True else: retried = False try: - return func(rest, *args, **kwargs) + return await func(rest, *args, **kwargs) except AblyException as e: - if 40140 <= e.code < 40150 and not retried: - rest.reauth() - return func(rest, *args, **kwargs) + if is_token_error(e) and not retried: + await auth.authorize() + return await func(rest, *args, **kwargs) - raise + raise e return wrapper class Request: - def __init__(self, method='GET', url='/', headers=None, body=None, + 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): url = urljoin(self.url, relative_url) - return Request(self.method, url, self.headers, self.body, + return Request(self.method, url, self.version, self.headers, self.body, self.skip_auth, self.raise_on_error) @property @@ -77,10 +79,14 @@ def body(self): def skip_auth(self): return self.__skip_auth + @property + def version(self): + return self.__version + class Response: """ - Composition for requests.Response with delegation + Composition for httpx.Response with delegation """ def __init__(self, response): @@ -92,12 +98,13 @@ def to_native(self): return None content_type = self.__response.headers.get('content-type') - if content_type == 'application/x-msgpack': - return msgpack.unpackb(content) - elif content_type == 'application/json': - return self.__response.json() - else: - raise ValueError("Unsuported 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() + + raise ValueError("Unsupported content type") @property def response(self): @@ -114,8 +121,6 @@ class Http: 'http_max_retry_duration': 15, } - __session = requests.Session() - def __init__(self, ably, options): options = options or {} self.__ably = ably @@ -124,6 +129,10 @@ def __init__(self, ably, options): # 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: @@ -131,22 +140,14 @@ def dump_body(self, body): else: return json.dumps(body, separators=(',', ':')) - def reauth(self): - try: - self.auth.authorize() - except AblyAuthException as e: - if e.code == 40101: - e.message = ("The provided token is not renewable and there is" - " no means to generate a new token") - raise e - - def get_rest_hosts(self): - hosts = self.options.get_rest_hosts() - host = self.__host + def get_hosts(self): + hosts = self.options.get_hosts() + host = self.__host or self.options.fallback_host if host is None: return hosts - if time.time() > self.__host_expires: + # 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 @@ -157,18 +158,18 @@ def get_rest_hosts(self): return hosts @reauth_if_expired - def make_request(self, method, path, headers=None, body=None, - skip_auth=False, timeout=None, raise_on_error=True): + async def make_request(self, method, path, version=None, headers=None, body=None, + skip_auth=False, timeout=None, raise_on_error=True): if body is not None and type(body) not in (bytes, str): body = self.dump_body(body) if body: - all_headers = HttpUtils.default_post_headers( - self.options.use_binary_protocol, self.__ably.variant) + 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, self.__ably.variant) + all_headers = HttpUtils.default_get_headers(self.options.use_binary_protocol, version=version) + + params = HttpUtils.get_query_params(self.options) if not skip_auth: if self.auth.auth_mechanism == Auth.Method.BASIC and self.preferred_scheme.lower() == 'http': @@ -176,7 +177,8 @@ def make_request(self, method, path, headers=None, body=None, "Cannot use Basic Auth over non-TLS connections", 401, 40103) - all_headers.update(self.auth._get_auth_headers()) + auth_headers = await self.auth._get_auth_headers() + all_headers.update(auth_headers) if headers: all_headers.update(headers) @@ -184,63 +186,82 @@ def make_request(self, method, path, headers=None, body=None, http_max_retry_duration = self.http_max_retry_duration requested_at = time.time() - hosts = self.get_rest_hosts() + hosts = self.get_hosts() for retry_count, host in enumerate(hosts): - base_url = "%s://%s:%d" % (self.preferred_scheme, - host, - self.preferred_port) + 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) - request = requests.Request(method, url, data=body, headers=all_headers) - prepped = self.__session.prepare_request(request) + + (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 = self.__session.send(prepped, timeout=timeout) + response = await self.__client.send(request) except Exception as e: - # Need to catch `Exception`, see: - # https://github.com/kennethreitz/requests/issues/1236#issuecomment-133312626 - - # if last try or cumulative timeout is done, throw exception up - time_passed = time.time() - requested_at - if retry_count == len(hosts) - 1 or time_passed > http_max_retry_duration: + 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_rest_host(): + 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 not e.is_server_error: - raise e - - # if last try or cumulative timeout is done, throw exception up - time_passed = time.time() - requested_at - if retry_count == len(hosts) - 1 or time_passed > http_max_retry_duration: + if should_stop_retrying() or not should_fallback: raise e - 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) + 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 - 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) + 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 - def patch(self, url, headers=None, body=None, skip_auth=False, timeout=None): - return self.make_request('PATCH', url, headers=headers, body=body, - skip_auth=skip_auth, timeout=timeout) + 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 - 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) + 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 - def put(self, url, headers=None, body=None, skip_auth=False, timeout=None): - return self.make_request('PUT', url, headers=headers, body=body, - skip_auth=skip_auth, timeout=timeout) + 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): @@ -256,7 +277,7 @@ def options(self): @property def preferred_host(self): - return self.options.get_rest_host() + return self.options.get_host() @property def preferred_port(self): diff --git a/ably/http/httputils.py b/ably/http/httputils.py index 2d4a2f92..aca46b0f 100644 --- a/ably/http/httputils.py +++ b/ably/http/httputils.py @@ -1,3 +1,7 @@ +import base64 +import os +import platform + import ably @@ -12,16 +16,8 @@ class HttpUtils: } @staticmethod - def default_get_headers(binary=False, variant=None): - if variant is not None: - lib_version = 'python.%s-%s' % (variant, ably.lib_version) - else: - lib_version = 'python-%s' % ably.lib_version - - headers = { - "X-Ably-Version": ably.api_version, - "X-Ably-Lib": lib_version, - } + def default_get_headers(binary=False, version=None): + headers = HttpUtils.default_headers(version=version) if binary: headers["Accept"] = HttpUtils.mime_types['binary'] else: @@ -29,7 +25,31 @@ def default_get_headers(binary=False, variant=None): return headers @staticmethod - def default_post_headers(binary=False, variant=None): - headers = HttpUtils.default_get_headers(binary=binary, variant=variant) + 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 2a6923be..a034d9d1 100644 --- a/ably/http/paginatedresult.py +++ b/ably/http/paginatedresult.py @@ -10,10 +10,11 @@ def format_time_param(t): try: - return '%d' % (calendar.timegm(t.utctimetuple()) * 1000) + 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 = {} @@ -32,7 +33,7 @@ def format_params(params=None, direction=None, start=None, end=None, limit=None, if limit: if limit > 1000: raise ValueError("The maximum allowed limit is 1000") - params['limit'] = '%d' % limit + 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'") @@ -64,33 +65,33 @@ def has_next(self): def is_last(self): return not self.has_next() - def first(self): - return self.__get_rel(self.__rel_first) if self.__rel_first else None + async def first(self): + return await self.__get_rel(self.__rel_first) if self.__rel_first else None - def next(self): - return self.__get_rel(self.__rel_next) if self.__rel_next else None + 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 self.paginated_query_with_request(self.__http, rel_req, self.__response_processor) + return await self.paginated_query_with_request(self.__http, rel_req, self.__response_processor) @classmethod - def paginated_query(cls, http, method='GET', url='/', body=None, - headers=None, response_processor=None, - raise_on_error=True): + 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, body=body, headers=headers, skip_auth=False, + req = Request(method, url, version=version, body=body, headers=headers, skip_auth=False, raise_on_error=raise_on_error) - return cls.paginated_query_with_request(http, req, response_processor) + return await cls.paginated_query_with_request(http, req, response_processor) @classmethod - def paginated_query_with_request(cls, http, request, response_processor, - raise_on_error=True): - response = http.make_request( - request.method, request.url, headers=request.headers, - body=request.body, skip_auth=request.skip_auth, - raise_on_error=request.raise_on_error) + 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) @@ -118,7 +119,7 @@ def status_code(self): @property def success(self): status_code = self.status_code - return status_code >= 200 and status_code < 300 + return 200 <= status_code < 300 @property def error_code(self): 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 c3cd3730..d2057533 100644 --- a/ably/rest/auth.py +++ b/ably/rest/auth.py @@ -1,16 +1,25 @@ +from __future__ import annotations + import base64 -from datetime import timedelta import logging import time import uuid -import warnings +from datetime import timedelta +from typing import TYPE_CHECKING + +import httpx -import requests +from ably.types.options import Options + +if TYPE_CHECKING: + from ably.realtime.realtime import AblyRealtime + from ably.rest.rest import AblyRest from ably.types.capability import Capability from ably.types.tokendetails import TokenDetails from ably.types.tokenrequest import TokenRequest -from ably.util.exceptions import AblyException, IncompatibleClientIdException +from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.util.helper import extract_url_params __all__ = ["Auth"] @@ -18,34 +27,36 @@ 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 - if options.token_details: - self.__client_id = options.token_details.client_id - else: + + if not self.ably._is_realtime: self.__client_id = options.client_id - self.__client_id_validated = False + 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 = None - self.__auth_params = None - self.__token_details = None - self.__time_offset = None + 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 and options.client_id is None + 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_mechanism = Auth.Method.BASIC - basic_key = "%s:%s" % (options.key_name, options.key_secret) + 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 @@ -76,9 +87,29 @@ def __init__(self, ably, options): raise ValueError("Can't authenticate via token, must provide " "auth_callback, auth_url, key, token or a TokenDetail") - def __authorize_when_necessary(self, token_params=None, auth_options=None, force=False): - self.__auth_mechanism = Auth.Method.TOKEN + 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: @@ -97,8 +128,9 @@ def __authorize_when_necessary(self, token_params=None, auth_options=None, force token_details.expires) return token_details - self.__token_details = self.request_token(token_params, **auth_options) + self.__token_details = await self.request_token(token_params, **auth_options) self._configure_client_id(self.__token_details.client_id) + return self.__token_details def token_details_has_expired(self): @@ -106,6 +138,9 @@ def token_details_has_expired(self): if token_details is None: return True + if not self.__time_offset: + return False + expires = token_details.expires if expires is None: return False @@ -116,28 +151,23 @@ def token_details_has_expired(self): return expires < timestamp + token_details.TOKEN_EXPIRY_BUFFER - def authorize(self, token_params=None, auth_options=None): - return self.__authorize_when_necessary(token_params, auth_options, force=True) - - def authorise(self, *args, **kwargs): - warnings.warn( - "authorise is deprecated and will be removed in v2.0, please use authorize", - DeprecationWarning) - return self.authorize(*args, **kwargs) + async def authorize(self, token_params: dict | None = None, auth_options=None): + return await self.__authorize_when_necessary(token_params, auth_options, force=True) - def request_token(self, token_params=None, - # auth_options - key_name=None, key_secret=None, auth_callback=None, - auth_url=None, auth_method=None, auth_headers=None, - auth_params=None, query_time=None): + 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("Auth callback: %s" % auth_callback) - log.debug("Auth options: %s" % self.auth_options) + 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) @@ -150,31 +180,47 @@ def request_token(self, token_params=None, 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") - 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") - token_request = self.token_request_from_auth_url( + token_request = await self.token_request_from_auth_url( auth_method, auth_url, token_params, auth_headers, auth_params) - else: - token_request = self.create_token_request( + 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: + 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): - token_request = TokenRequest.from_json(token_request) + 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 = "/keys/%s/requestToken" % token_request.key_name + token_path = f"/keys/{token_request.key_name}/requestToken" - response = self.ably.http.post( + response = await self.ably.http.post( token_path, headers=auth_headers, body=token_request.to_dict(), @@ -183,11 +229,11 @@ def request_token(self, token_params=None, AblyException.raise_for_response(response) response_dict = response.to_native() - log.debug("Token: %s" % str(response_dict.get("token"))) + log.debug("Token: {}".format(str(response_dict.get("token")))) return TokenDetails.from_dict(response_dict) - def create_token_request(self, token_params=None, - key_name=None, key_secret=None, query_time=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 = {} @@ -206,7 +252,7 @@ def create_token_request(self, token_params=None, if query_time: if self.__time_offset is None: - server_time = self.ably.time() + server_time = await self.ably.time() local_time = self._timestamp() self.__time_offset = server_time - local_time token_request['timestamp'] = server_time @@ -228,8 +274,7 @@ def create_token_request(self, token_params=None, if capability is not None: token_request['capability'] = str(Capability(capability)) - token_request["client_id"] = ( - token_params.get('client_id') or self.client_id) + token_request["client_id"] = token_params.get('client_id') or self.client_id # Note: There is no expectation that the client # specifies the nonce; this is done by the library @@ -237,18 +282,18 @@ def create_token_request(self, token_params=None, # simply for testing purposes token_request["nonce"] = token_params.get('nonce') or self._random_nonce() - token_request = TokenRequest(**token_request) + token_req = TokenRequest(**token_request) 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. - token_request.sign_request(key_secret.encode('utf8')) + token_req.sign_request(key_secret.encode('utf8')) else: - token_request.mac = token_params['mac'] + token_req.mac = token_params['mac'] - return token_request + return token_req @property def ably(self): @@ -290,38 +335,50 @@ 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 self.client_id != '*' and new_client_id == '*': + 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 self.client_id is not None and self.client_id != '*' and new_client_id != self.client_id: + 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. " - "Client ID cannot be changed to '{}'".format(new_client_id), 400, 40012) + 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 self.client_id is None or self.client_id == '*': + elif original_client_id is None or original_client_id == '*': return True # client ID is unknown else: - return self.client_id == assumed_client_id + return original_client_id == assumed_client_id - def _get_auth_headers(self): + 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: - self.__authorize_when_necessary() + await self.__authorize_when_necessary() return { - 'Authorization': 'Bearer %s' % self.token_credentials, + 'Authorization': f'Bearer {self.token_credentials}', } def _timestamp(self): @@ -331,22 +388,50 @@ def _timestamp(self): def _random_nonce(self): return uuid.uuid4().hex[:16] - def token_request_from_auth_url(self, method, url, token_params, - headers, auth_params): + 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 = {} - params = dict(auth_params, **token_params) + # 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': - params = {} + 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 - response = Response(requests.request( - method, url, headers=headers, params=params, data=body)) + 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) AblyException.raise_for_response(response) - try: + + 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() - except ValueError: + 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 b24235fc..32cc7e7e 100644 --- a/ably/rest/channel.py +++ b/ably/rest/channel.py @@ -1,40 +1,55 @@ import base64 -from collections import OrderedDict -import logging import json +import logging import os +from collections import OrderedDict +from typing import Iterator, Optional from urllib import parse -import warnings -from methoddispatch import SingleDispatch, singledispatch import msgpack from ably.http.paginatedresult import PaginatedResult, format_params -from ably.types.message import Message, make_message_response_handler +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, IncompatibleClientIdException +from ably.util.exceptions import ( + AblyException, + IncompatibleClientIdException, + catch_all, +) log = logging.getLogger(__name__) -class Channel(SingleDispatch): +class Channel: + __annotations: RestAnnotations + def __init__(self, ably, name, options): self.__ably = ably self.__name = name - self.__base_path = '/channels/%s/' % parse.quote_plus(name, safe=':') + self.__base_path = '/channels/{}/'.format(parse.quote_plus(name, safe=':')) self.__cipher = None self.options = options self.__presence = Presence(self) + 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 = format_params({}, direction=direction, start=start, end=end, limit=limit) path = self.__base_path + 'messages' + params message_handler = make_message_response_handler(self.__cipher) - return PaginatedResult.paginated_query( + return await PaginatedResult.paginated_query( self.ably.http, url=path, response_processor=message_handler) def __publish_request_body(self, messages): @@ -47,7 +62,7 @@ def __publish_request_body(self, messages): 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 = '{}:{}'.format(base_id, serial) + message.id = f'{base_id}:{serial}' request_body_list = [] for m in messages: @@ -57,8 +72,8 @@ def __publish_request_body(self, messages): 400, 40012) elif m.client_id is not None and not self.ably.auth.can_assume_client_id(m.client_id): raise IncompatibleClientIdException( - 'Cannot publish with client_id \'{}\' as it is incompatible with the ' - 'current configured client_id \'{}\''.format(m.client_id, self.ably.auth.client_id), + 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: @@ -75,16 +90,20 @@ def __publish_request_body(self, messages): return request_body - @singledispatch - def _publish(self, arg, *args, **kwargs): - raise TypeError('Unexpected type %s' % type(arg)) + 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)}') - @_publish.register(Message) - def publish_message(self, message, params=None, timeout=None): - return self.publish_messages([message], params, timeout=timeout) + async def publish_message(self, message, params=None, timeout=None): + return await self.publish_messages([message], params, timeout=timeout) - @_publish.register(list) - def publish_messages(self, messages, params=None, timeout=None): + 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=(',', ':')) @@ -93,24 +112,21 @@ def publish_messages(self, messages, params=None, timeout=None): path = self.__base_path + 'messages' if params: - params = {k: str(v).lower() if type(v) is bool else v for k, v in params.items()} + 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) - return self.ably.http.post(path, body=request_body, timeout=timeout) - - @_publish.register(str) - def publish_name_data(self, name, data, client_id=None, extras=None, timeout=None): - # RSL1h - if client_id or extras: - warnings.warn( - "Support for client_id and extras will be removed in 2.0", - DeprecationWarning - ) + # 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() - messages = [Message(name, data, client_id, extras=extras)] - return self.publish_messages(messages, timeout=timeout) + async def publish_name_data(self, name, data, timeout=None): + messages = [Message(name, data)] + return await self.publish_messages(messages, timeout=timeout) - def publish(self, *args, **kwargs): + async def publish(self, *args, **kwargs): """Publishes a message on this channel. :Parameters: @@ -125,18 +141,209 @@ def publish(self, *args, **kwargs): # For backwards compatibility if len(args) == 0: if len(kwargs) == 0: - return self.publish_name_data(None, None) + 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 self.publish_name_data(name, data, **kwargs) + return await self.publish_name_data(name, data, **kwargs) if 'messages' in kwargs: messages = kwargs.pop('messages') - return self.publish_messages(messages, **kwargs) + 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, + ) + + # Encrypt if needed + if self.cipher: + update_message.encrypt(self.__cipher) + + # Serialize the message + request_body = update_message.as_dict(binary=self.ably.options.use_binary_protocol) + + if not self.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 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. + + 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) - return self._publish(*args, **kwargs) + 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): @@ -162,6 +369,10 @@ def options(self): def presence(self): return self.__presence + @property + def annotations(self) -> RestAnnotations: + return self.__annotations + @options.setter def options(self, options): self.__options = options @@ -176,16 +387,16 @@ def options(self, options): class Channels: def __init__(self, rest): self.__ably = rest - self.__attached = OrderedDict() + self.__all: dict = OrderedDict() def get(self, name, **kwargs): if isinstance(name, bytes): name = name.decode('ascii') - if name not in self.__attached: - result = self.__attached[name] = Channel(self.__ably, name, kwargs) + if name not in self.__all: + result = self.__all[name] = Channel(self.__ably, name, kwargs) else: - result = self.__attached[name] + result = self.__all[name] if len(kwargs) != 0: result.options = kwargs @@ -195,10 +406,7 @@ def __getitem__(self, key): return self.get(key) def __getattr__(self, name): - try: - return super().__getattr__(name) - except AttributeError: - return self.get(name) + return self.get(name) def __contains__(self, item): if isinstance(item, Channel): @@ -208,13 +416,24 @@ def __contains__(self, item): else: name = item - return name in self.__attached + return name in self.__all - def __iter__(self): - return iter(self.__attached.values()) + def __iter__(self) -> Iterator[str]: + return iter(self.__all.values()) - def release(self, key): - del self.__attached[key] + # 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 + """ - def __delitem__(self, key): - return self.release(key) + if name not in self.__all: + return + del self.__all[name] diff --git a/ably/rest/push.py b/ably/rest/push.py index be9400cd..f99b2b1d 100644 --- a/ably/rest/push.py +++ b/ably/rest/push.py @@ -1,7 +1,12 @@ +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 -from ably.types.channelsubscription import PushChannelSubscription, channel_subscriptions_response_processor -from ably.types.channelsubscription import channels_response_processor class Push: @@ -34,7 +39,7 @@ def device_registrations(self): def channel_subscriptions(self): return self.__channel_subscriptions - def publish(self, recipient, data, timeout=None): + async def publish(self, recipient: dict, data: dict, timeout: Optional[float] = None): """Publish a push notification to a single device. :Parameters: @@ -42,10 +47,10 @@ def publish(self, recipient, data, timeout=None): - `data`: the data of the notification """ if not isinstance(recipient, dict): - raise TypeError('Unexpected %s recipient, expected a dict' % type(recipient)) + raise TypeError(f'Unexpected {type(recipient)} recipient, expected a dict') if not isinstance(data, dict): - raise TypeError('Unexpected %s data, expected a dict' % type(recipient)) + raise TypeError(f'Unexpected {type(data)} data, expected a dict') if not recipient: raise ValueError('recipient is empty') @@ -55,7 +60,7 @@ def publish(self, recipient, data, timeout=None): body = data.copy() body.update({'recipient': recipient}) - self.ably.http.post('/push/publish', body=body, timeout=timeout) + await self.ably.http.post('/push/publish', body=body, timeout=timeout) class PushDeviceRegistrations: @@ -67,19 +72,19 @@ def __init__(self, ably): def ably(self): return self.__ably - def get(self, device_id): + 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 = '/push/deviceRegistrations/%s' % device_id - response = self.ably.http.get(path) + path = f'/push/deviceRegistrations/{device_id}' + response = await self.ably.http.get(path) obj = response.to_native() return DeviceDetails.from_dict(obj) - def list(self, **params): + async def list(self, **params): """Returns a PaginatedResult object with the list of DeviceDetails objects, filtered by the given parameters. @@ -87,40 +92,40 @@ def list(self, **params): - `**params`: the parameters used to filter the list """ path = '/push/deviceRegistrations' + format_params(params) - return PaginatedResult.paginated_query( + return await PaginatedResult.paginated_query( self.ably.http, url=path, response_processor=device_details_response_processor) - def save(self, device): + 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 = '/push/deviceRegistrations/%s' % device_details.id + path = f'/push/deviceRegistrations/{device_details.id}' body = device_details.as_dict() - response = self.ably.http.put(path, body=body) + response = await self.ably.http.put(path, body=body) obj = response.to_native() return DeviceDetails.from_dict(obj) - def remove(self, device_id): + 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 = '/push/deviceRegistrations/%s' % device_id - return self.ably.http.delete(path) + path = f'/push/deviceRegistrations/{device_id}' + return await self.ably.http.delete(path) - def remove_where(self, **params): + 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 self.ably.http.delete(path) + return await self.ably.http.delete(path) class PushChannelSubscriptions: @@ -132,7 +137,7 @@ def __init__(self, ably): def ably(self): return self.__ably - def list(self, **params): + async def list(self, **params): """Returns a PaginatedResult object with the list of PushChannelSubscription objects, filtered by the given parameters. @@ -140,11 +145,10 @@ def list(self, **params): - `**params`: the parameters used to filter the list """ path = '/push/channelSubscriptions' + format_params(params) - return PaginatedResult.paginated_query( - self.ably.http, url=path, - response_processor=channel_subscriptions_response_processor) + return await PaginatedResult.paginated_query(self.ably.http, url=path, + response_processor=channel_subscriptions_response_processor) - def list_channels(self, **params): + async def list_channels(self, **params): """Returns a PaginatedResult object with the list of PushChannelSubscription objects, filtered by the given parameters. @@ -152,11 +156,10 @@ def list_channels(self, **params): - `**params`: the parameters used to filter the list """ path = '/push/channels' + format_params(params) - return PaginatedResult.paginated_query( - self.ably.http, url=path, - response_processor=channels_response_processor) + return await PaginatedResult.paginated_query(self.ably.http, url=path, + response_processor=channels_response_processor) - def save(self, subscription): + async def save(self, subscription: dict): """Creates or updates the subscription. Returns a PushChannelSubscription object. @@ -166,11 +169,11 @@ def save(self, subscription): subscription = PushChannelSubscription.factory(subscription) path = '/push/channelSubscriptions' body = subscription.as_dict() - response = self.ably.http.post(path, body=body) + response = await self.ably.http.post(path, body=body) obj = response.to_native() return PushChannelSubscription.from_dict(obj) - def remove(self, subscription): + async def remove(self, subscription: dict): """Deletes the given subscription. :Parameters: @@ -178,13 +181,13 @@ def remove(self, subscription): """ subscription = PushChannelSubscription.factory(subscription) params = subscription.as_dict() - return self.remove_where(**params) + return await self.remove_where(**params) - def remove_where(self, **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 self.ably.http.delete(path) + return await self.ably.http.delete(path) diff --git a/ably/rest/rest.py b/ably/rest/rest.py index 0389f8d8..bc84e638 100644 --- a/ably/rest/rest.py +++ b/ably/rest/rest.py @@ -1,16 +1,16 @@ import logging +from typing import Optional from urllib.parse import urlencode from ably.http.http import Http -from ably.http.paginatedresult import PaginatedResult, HttpPaginatedResponse -from ably.http.paginatedresult import format_params +from ably.http.paginatedresult import HttpPaginatedResponse, PaginatedResult, format_params from ably.rest.auth import Auth from ably.rest.channel import Channels from ably.rest.push import Push -from ably.util.exceptions import AblyException, catch_all 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__) @@ -18,9 +18,8 @@ class AblyRest: """Ably Rest Client""" - variant = None - - def __init__(self, key=None, token=None, token_details=None, **kwargs): + def __init__(self, key: Optional[str] = None, token: Optional[str] = None, + token_details: Optional[TokenDetails] = None, **kwargs): """Create an AblyRest instance. :Parameters: @@ -33,8 +32,14 @@ def __init__(self, key=None, token=None, token_details=None, **kwargs): **Optional Parameters** - `client_id`: Undocumented - - `rest_host`: The host to connect to. Defaults to rest.ably.io - - `environment`: The environment to use. Defaults to 'production' + - `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 @@ -62,6 +67,9 @@ def __init__(self, key=None, token=None, token_details=None, **kwargs): else: options = Options(**kwargs) + if not hasattr(self, '_is_realtime'): + self._is_realtime = False + self.__http = Http(self, options) self.__auth = Auth(self, options) self.__http.auth = self.__auth @@ -70,29 +78,27 @@ def __init__(self, key=None, token=None, token_details=None, **kwargs): self.__options = options self.__push = Push(self) - def set_variant(self, variant): - """Sets library variant as per RSC7b""" - self.variant = variant + async def __aenter__(self): + return self @catch_all - def stats(self, direction=None, start=None, end=None, params=None, - limit=None, paginated=None, unit=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 = format_params(params, direction=direction, start=start, end=end, limit=limit, unit=unit) - url = '/stats' + params - - return PaginatedResult.paginated_query( + 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.to_native()[0] @property - def client_id(self): + def client_id(self) -> Optional[str]: return self.options.client_id @property @@ -116,7 +122,11 @@ def options(self): def push(self): return self.__push - def request(self, method, path, params=None, body=None, headers=None): + 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) @@ -129,7 +139,13 @@ def response_processor(response): items = [items] return items - return HttpPaginatedResponse.paginated_query( - self.http, method, url, body=body, headers=headers, + 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 110eb786..40d73e08 100644 --- a/ably/transport/defaults.py +++ b/ably/transport/defaults.py @@ -1,16 +1,8 @@ class Defaults: - 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", - ] - - rest_host = "rest.ably.io" - realtime_host = "realtime.ably.io" - environment = 'production' + protocol_version = "5" + + connectivity_check_url = "https://internet-up.ably-realtime.com/is-the-internet-up.txt" + endpoint = 'main' port = 80 tls_port = 443 @@ -19,6 +11,11 @@ class Defaults: 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"] @@ -45,3 +42,36 @@ def get_scheme(options): 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 f61a57f5..7ee06af7 100644 --- a/ably/types/authoptions.py +++ b/ably/types/authoptions.py @@ -34,9 +34,9 @@ def set_key(self, key): self.auth_options['key_name'] = key_name self.auth_options['key_secret'] = key_secret except ValueError: - raise AblyException("key of not len 2 parameters: {0}" + raise AblyException("key of not len 2 parameters: {}" .format(key.split(':')), - 401, 40101) + 401, 40101) from None def replace(self, auth_options): if type(auth_options) is dict: diff --git a/ably/types/capability.py b/ably/types/capability.py index d113684b..4f931466 100644 --- a/ably/types/capability.py +++ b/ably/types/capability.py @@ -1,15 +1,25 @@ -from collections.abc import MutableMapping import json import logging - +from collections.abc import MutableMapping +from typing import Optional, Union log = logging.getLogger(__name__) class Capability(MutableMapping): - def __init__(self, obj={}): - self.__dict = dict(obj) - for k, v in obj.items(): + 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 = {} + + self.__dict = dict(capability) + for k, v in capability.items(): self[k] = v def __eq__(self, other): @@ -58,7 +68,9 @@ def setdefault(self, key, default): self[key] = default return self[key] - def add_resource(self, resource, operations=[]): + def add_resource(self, resource, operations=None): + if operations is None: + operations = [] if isinstance(operations, str): operations = [operations] self[resource] = list(operations) 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 new file mode 100644 index 00000000..3e5052c6 --- /dev/null +++ b/ably/types/channeloptions.py @@ -0,0 +1,86 @@ +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 params(self) -> dict[str, str] | None: + """Get channel parameters""" + return self.__params + + @property + 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 index 2fbc72c1..b4c0dbf8 100644 --- a/ably/types/channelsubscription.py +++ b/ably/types/channelsubscription.py @@ -64,6 +64,7 @@ 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 index 67c03971..c2b84ee5 100644 --- a/ably/types/device.py +++ b/ably/types/device.py @@ -1,6 +1,5 @@ from ably.util import case - DevicePushTransportType = {'fcm', 'gcm', 'apns', 'web'} DevicePlatform = {'android', 'ios', 'browser'} DeviceFormFactor = {'phone', 'tablet', 'desktop', 'tv', 'watch', 'car', 'embedded', 'other'} @@ -10,20 +9,20 @@ 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): + 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('unexpected transport type {}'.format(transport_type)) + raise ValueError(f'unexpected transport type {transport_type}') if platform is not None and platform not in DevicePlatform: - raise ValueError('unexpected platform {}'.format(platform)) + raise ValueError(f'unexpected platform {platform}') if form_factor is not None and form_factor not in DeviceFormFactor: - raise ValueError('unexpected form factor {}'.format(form_factor)) + raise ValueError(f'unexpected form factor {form_factor}') self.__id = id self.__client_id = client_id @@ -34,6 +33,8 @@ def __init__(self, id, client_id=None, form_factor=None, metadata=None, 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): @@ -71,9 +72,17 @@ def app_id(self): 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'] + 'push', 'update_token', 'app_id', 'device_identity_token', 'modified', 'device_secret'] obj = {} for key in keys: 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 6a18cff7..2442a587 100644 --- a/ably/types/message.py +++ b/ably/types/message.py @@ -1,24 +1,135 @@ -import base64 -import json import logging +from enum import IntEnum +from ably.types.mixins import DeltaExtras, EncodeDataMixin from ably.types.typedbuffer import TypedBuffer -from ably.types.mixins import EncodeDataMixin 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__) -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("expected string or bytes, not %s" % type(value)) +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): @@ -33,6 +144,10 @@ def __init__(self, encoding='', # TM2e timestamp=None, # TM2f extras=None, # TM2i + serial=None, # TM2r + action=None, # TM2j + version=None, # TM2s + annotations=None, # TM2t ): super().__init__(encoding) @@ -45,6 +160,10 @@ def __init__(self, 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): @@ -97,6 +216,22 @@ def timestamp(self): def extras(self): return self.__extras + @property + def version(self): + return self.__version + + @property + def serial(self): + return self.__serial + + @property + def action(self): + return self.__action + + @property + def annotations(self): + return self.__annotations + def encrypt(self, channel_cipher): if isinstance(self.data, CipherData): return @@ -130,55 +265,28 @@ def decrypt(self, channel_cipher): self.__data = decrypted_data def as_dict(self, binary=False): - data = self.data - data_type = None - encoding = self._encoding_array[:] - - if isinstance(data, (dict, list)): - encoding.append('json') - data = json.dumps(data) - data = str(data) - 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) - data_type = data.type - 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) - - if not (isinstance(data, (bytes, str, list, dict, bytearray)) or data is None): - raise AblyException("Invalid data payload", 400, 40011) - request_body = { 'name': self.name, - 'data': data, 'timestamp': self.timestamp or None, - 'type': data_type 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'] = '/'.join(encoding).strip('/') - # 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): + def from_encoded(obj, cipher=None, context=None): id = obj.get('id') name = obj.get('name') data = obj.get('data') @@ -187,8 +295,56 @@ def from_encoded(obj, cipher=None): timestamp = obj.get('timestamp') encoding = obj.get('encoding', '') extras = obj.get('extras', None) - - decoded_data = Message.decode(data, encoding, cipher) + 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, @@ -197,12 +353,47 @@ def from_encoded(obj, cipher=None): client_id=client_id, timestamp=timestamp, extras=extras, + serial=serial, + action=action, + version=version, + annotations=annotations, **decoded_data ) + @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_single_message_response_handler(cipher): + def encrypted_message_response_handler(response): + 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 index 0756ea0d..2d2b6041 100644 --- a/ably/types/mixins.py +++ b/ably/types/mixins.py @@ -3,10 +3,28 @@ 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: @@ -25,10 +43,12 @@ def encoding(self, encoding): self._encoding_array = encoding.strip('/').split('/') @staticmethod - def decode(data, encoding='', cipher=None): + 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: @@ -46,11 +66,44 @@ def decode(data, encoding='', cipher=None): if isinstance(data, list) or isinstance(data, dict): continue data = json.loads(data) - elif encoding == 'base64' and isinstance(data, bytes): - data = bytearray(base64.b64decode(data)) elif encoding == 'base64': - data = bytearray(base64.b64decode(data.encode('utf-8'))) - elif encoding.startswith('%s+' % CipherData.ENCODING_ID): + 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') @@ -63,13 +116,15 @@ def decode(data, encoding='', cipher=None): pass else: log.error('Message cannot be decoded. ' - "Unsupported encoding type: '%s'" % encoding) + 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): - return [cls.from_encoded(obj, cipher=cipher) for obj in objs] + 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 4475bd00..1dad41fb 100644 --- a/ably/types/options.py +++ b/ably/types/options.py @@ -1,52 +1,135 @@ +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, rest_host=None, - realtime_host=None, port=0, tls_port=0, use_binary_protocol=True, - queue_messages=False, recover=False, environment=None, - http_open_timeout=None, http_request_timeout=None, - http_max_retry_count=None, http_max_retry_duration=None, - fallback_hosts=None, fallback_hosts_use_default=None, fallback_retry_timeout=None, - idempotent_rest_publishing=None, - **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 ValueError('specify rest_host or environment, not both') + 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.__rest_host = rest_host - self.__realtime_host = realtime_host self.__port = port self.__tls_port = tls_port self.__use_binary_protocol = use_binary_protocol self.__queue_messages = queue_messages self.__recover = recover - self.__environment = environment + 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_hosts_use_default = fallback_hosts_use_default 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.__rest_hosts = self.__get_rest_hosts() + 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): @@ -72,22 +155,6 @@ def tls(self): def tls(self, value): self.__tls = value - @property - def rest_host(self): - return self.__rest_host - - @rest_host.setter - def rest_host(self, value): - self.__rest_host = value - - @property - def realtime_host(self): - return self.__realtime_host - - @realtime_host.setter - def realtime_host(self, value): - self.__realtime_host = value - @property def port(self): return self.__port @@ -129,8 +196,8 @@ def recover(self, value): self.__recover = value @property - def environment(self): - return self.__environment + def endpoint(self): + return self.__endpoint @property def http_open_timeout(self): @@ -144,6 +211,10 @@ def http_open_timeout(self, value): 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 @@ -168,63 +239,110 @@ def http_max_retry_duration(self, value): def fallback_hosts(self): return self.__fallback_hosts - @property - def fallback_hosts_use_default(self): - return self.__fallback_hosts_use_default - @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 - def __get_rest_hosts(self): + @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. """ - # Defaults - host = self.rest_host - if host is None: - host = Defaults.rest_host - - environment = self.environment - if environment is None: - environment = Defaults.environment + 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 - # Prepend environment - if environment != 'production': - host = '%s-%s' % (environment, host) - - # Fallback hosts - fallback_hosts = self.fallback_hosts - if fallback_hosts is None: - if host == Defaults.rest_host or self.fallback_hosts_use_default: - fallback_hosts = Defaults.fallback_hosts - else: - fallback_hosts = [] - # 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_rest_hosts(self): - return self.__rest_hosts + def get_hosts(self): + return self.__hosts + + def get_host(self): + return self.__hosts[0] - def get_rest_host(self): - return self.__rest_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 - def get_fallback_rest_hosts(self): - return self.__rest_hosts[1:] + # 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 1dc02369..7d1a3c05 100644 --- a/ably/types/presence.py +++ b/ably/types/presence.py @@ -3,6 +3,9 @@ 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 def _ms_since_epoch(dt): @@ -38,12 +41,13 @@ def __init__(self, extras=None, # TP3i (functionality not specified) ): + super().__init__(encoding or '') + self.__id = id self.__action = action self.__client_id = client_id self.__connection_id = connection_id self.__data = data - self.__encoding = encoding self.__timestamp = timestamp self.__member_key = member_key self.__extras = extras @@ -68,10 +72,6 @@ def connection_id(self): def data(self): return self.__data - @property - def encoding(self): - return self.__encoding - @property def timestamp(self): return self.__timestamp @@ -79,14 +79,97 @@ def timestamp(self): @property def member_key(self): if self.connection_id and self.client_id: - return "%s:%s" % (self.connection_id, 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_encoded(obj, cipher=None): + def from_encoded(obj, cipher=None, context=None): id = obj.get('id') action = obj.get('action', PresenceAction.ENTER) client_id = obj.get('clientId') @@ -112,10 +195,17 @@ def from_encoded(obj, cipher=None): **decoded_data ) + @staticmethod + 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] + class Presence: def __init__(self, channel): - self.__base_path = '/channels/%s/' % parse.quote_plus(channel.name) + 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 @@ -126,7 +216,7 @@ def _path_with_qs(self, rel_path, qs=None): path += ('?' + parse.urlencode(qs)) return path - def get(self, limit=None): + async def get(self, limit=None): qs = {} if limit: if limit > 1000: @@ -135,10 +225,10 @@ def get(self, limit=None): path = self._path_with_qs(self.__base_path + 'presence', qs) presence_handler = make_presence_response_handler(self.__cipher) - return PaginatedResult.paginated_query( + return await PaginatedResult.paginated_query( self.__http, url=path, response_processor=presence_handler) - def history(self, limit=None, direction=None, start=None, end=None): + async def history(self, limit=None, direction=None, start=None, end=None): qs = {} if limit: if limit > 1000: @@ -163,7 +253,7 @@ def history(self, limit=None, direction=None, start=None, end=None): path = self._path_with_qs(self.__base_path + 'presence/history', qs) presence_handler = make_presence_response_handler(self.__cipher) - return PaginatedResult.paginated_query( + return await PaginatedResult.paginated_query( self.__http, url=path, response_processor=presence_handler) diff --git a/ably/types/stats.py b/ably/types/stats.py index e14f816a..ead5e548 100644 --- a/ably/types/stats.py +++ b/ably/types/stats.py @@ -4,137 +4,28 @@ log = logging.getLogger(__name__) -class ResourceCount: - def __init__(self, opened=0, peak=0, mean=0, min=0, refused=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 {} - expected = ['opened', 'peak', 'mean', 'min', 'refused'] - kwargs = {k: rc_dict[k] for k in rc_dict if (k in expected)} - - return ResourceCount(**kwargs) - - -class ConnectionTypes: - def __init__(self, all=None, plain=None, tls=None): - self.all = all or ResourceCount() - self.plain = plain or ResourceCount() - self.tls = tls or 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: - def __init__(self, count=0, data=0): - self.count = count - self.data = data - - @staticmethod - def from_dict(mc_dict): - mc_dict = mc_dict or {} - expected = ['count', 'data'] - kwargs = {k: mc_dict[k] for k in mc_dict if (k in expected)} - return MessageCount(**kwargs) - - -class MessageTypes: - def __init__(self, all=None, messages=None, presence=None): - self.all = all or MessageCount() - self.messages = messages or MessageCount() - self.presence = presence or MessageCount() - - @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")), - } - return MessageTypes(**kwargs) - - -class MessageTraffic: - def __init__(self, all=None, realtime=None, rest=None, webhook=None): - self.all = all or MessageTypes() - self.realtime = realtime or MessageTypes() - self.rest = rest or MessageTypes() - self.webhook = webhook or MessageTypes() - - @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")), - "webhook": MessageTypes.from_dict(mt_dict.get("webhook")), - } - return MessageTraffic(**kwargs) - - -class RequestCount: - def __init__(self, succeeded=0, failed=0, refused=0): - self.succeeded = succeeded - self.failed = failed - self.refused = refused - - @staticmethod - def from_dict(rc_dict): - rc_dict = rc_dict or {} - expected = ['succeeded', 'failed', 'refused'] - kwargs = {k: rc_dict[k] for k in rc_dict if (k in expected)} - return RequestCount(**kwargs) - - class Stats: - def __init__(self, all=None, inbound=None, outbound=None, persisted=None, - connections=None, channels=None, api_requests=None, - token_requests=None, interval_granularity=None, - interval_id=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() + 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.interval_granularity = (interval_granularity or - granularity_from_interval_id(self.interval_id)) + 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 @classmethod def from_dict(cls, stats_dict): stats_dict = stats_dict or {} 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.get("channels")), - "api_requests": RequestCount.from_dict(stats_dict.get("apiRequests")), - "token_requests": RequestCount.from_dict(stats_dict.get("tokenRequests")), - "interval_granularity": stats_dict.get("unit"), - "interval_id": stats_dict.get("intervalId") + "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 cls(**kwargs) @@ -168,7 +59,7 @@ def granularity_from_interval_id(interval_id): return key except ValueError: pass - raise ValueError("Unsuported intervalId") + raise ValueError("Unsupported intervalId") def interval_from_interval_id(interval_id): diff --git a/ably/types/tokendetails.py b/ably/types/tokendetails.py index 63a1e8dc..771b29ec 100644 --- a/ably/types/tokendetails.py +++ b/ably/types/tokendetails.py @@ -20,10 +20,7 @@ def __init__(self, token=None, expires=None, issued=0, self.__expires = expires self.__token = token self.__issued = issued - if capability and isinstance(capability, str): - self.__capability = Capability(json.loads(capability)) - else: - self.__capability = Capability(capability or {}) + self.__capability = Capability(capability or {}) self.__client_id = client_id @property diff --git a/ably/types/tokenrequest.py b/ably/types/tokenrequest.py index d10a5eb3..3998175a 100644 --- a/ably/types/tokenrequest.py +++ b/ably/types/tokenrequest.py @@ -22,7 +22,7 @@ def sign_request(self, key_secret): self.ttl or "", self.capability or "", self.client_id or "", - "%d" % (self.timestamp or 0), + f"{self.timestamp or 0}", self.nonce or "", "", # to get the trailing new line ]]) diff --git a/ably/types/typedbuffer.py b/ably/types/typedbuffer.py index 8deef016..656f8947 100644 --- a/ably/types/typedbuffer.py +++ b/ably/types/typedbuffer.py @@ -25,16 +25,15 @@ class Limits: 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: @@ -56,42 +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, (bytes, bytearray)): - type = DataType.BUFFER + data_type = DataType.BUFFER buffer = obj elif isinstance(obj, str): - type = DataType.STRING + 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, int): - if obj >= Limits.INT32_MIN and obj <= Limits.INT32_MAX: - type = DataType.INT32 + 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: - raise ValueError('Number too large %d' % obj) + 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 + data_type = DataType.JSONARRAY buffer = json.dumps(obj, separators=(',', ':')).encode('utf-8') elif isinstance(obj, dict): - type = DataType.JSONOBJECT + data_type = DataType.JSONOBJECT buffer = json.dumps(obj, separators=(',', ':')).encode('utf-8') else: - raise TypeError('Unexpected object type %s' % type(obj)) + raise TypeError(f'Unexpected object type {type(obj)}') - return TypedBuffer(buffer, type) + return TypedBuffer(buffer, data_type) @property def buffer(self): @@ -105,4 +101,4 @@ def decode(self): decoder = _decoders.get(self.type) if decoder is not None: return decoder(self.buffer) - raise ValueError('Unsupported data type %s' % self.type) + raise ValueError(f'Unsupported data type {self.type}') diff --git a/ably/util/case.py b/ably/util/case.py index 28d80374..1cfff585 100644 --- a/ably/util/case.py +++ b/ably/util/case.py @@ -1,8 +1,9 @@ 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() diff --git a/ably/util/crypto.py b/ably/util/crypto.py index df2d0072..8d8ddfd9 100644 --- a/ably/util/crypto.py +++ b/ably/util/crypto.py @@ -2,8 +2,8 @@ import logging try: - from Crypto.Cipher import AES from Crypto import Random + from Crypto.Cipher import AES except ImportError: from .nocrypto import AES, Random @@ -116,8 +116,7 @@ def iv(self): @property def cipher_type(self): - return ("%s-%s-%s" % (self.__algorithm, self.__key_length, - self.__mode)).lower() + return (f"{self.__algorithm}-{self.__key_length}-{self.__mode}").lower() class CipherData(TypedBuffer): @@ -131,18 +130,19 @@ def __init__(self, buffer, type, cipher_type=None, **kwargs): def encoding_str(self): return self.ENCODING_ID + '+' + self.__cipher_type + DEFAULT_KEYLENGTH = 256 DEFAULT_BLOCKLENGTH = 16 + def generate_random_key(length=DEFAULT_KEYLENGTH): rndfile = Random.new() return rndfile.read(length // 8) + def get_default_params(params=None): - # Backwards compatibility if type(params) in [str, bytes]: - log.warn("Calling get_default_params with a key directly is deprecated, it expects a params dict") - return get_default_params({'key': params}) + 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' @@ -152,13 +152,14 @@ def get_default_params(params=None): if not key: raise ValueError("Crypto.get_default_params: a key is required") - if type(key) == str: + 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(params): if isinstance(params, CipherParams): cipher_params = params @@ -166,11 +167,12 @@ def get_cipher(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( - 'Unsupported key length %s for aes-cbc encryption. Encryption key must be 128 or 256 bits' - ' (16 or 32 ASCII characters)' % key_length) + 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 4fdf4e21..a8bbae39 100644 --- a/ably/util/exceptions.py +++ b/ably/util/exceptions.py @@ -1,24 +1,29 @@ import functools import logging +import msgpack log = logging.getLogger(__name__) class AblyException(Exception): - def __new__(cls, message, status_code, code): + def __new__(cls, message, status_code, code, cause=None): if cls == AblyException and status_code == 401: - return AblyAuthException(message, status_code, code) - return super().__new__(cls, message, status_code, code) + return AblyAuthException(message, status_code, code, cause) + return super().__new__(cls, message, status_code, code, cause) - def __init__(self, message, status_code, code): + 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 __str__(self): - return '%s %s %s' % (self.code, self.status_code, self.message) + 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): @@ -26,22 +31,22 @@ def is_server_error(self): @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() + decoded_response = AblyException.decode_error_response(response) except Exception: - log.debug("Response not json: %d %s", + 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) + code=response.status_code * 100) from None - if json_response and 'error' in json_response: - error = json_response['error'] + if decoded_response and 'error' in decoded_response: + error = decoded_response['error'] try: raise AblyException( message=error['message'], @@ -51,27 +56,48 @@ def raise_for_response(response): except KeyError: msg = "Unexpected exception decoding server response: %s" msg = msg % response.text - raise AblyException(message=msg, status_code=500, code=50000) + 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 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 index bfd2083d..a66669b3 100644 --- a/ably/util/nocrypto.py +++ b/ably/util/nocrypto.py @@ -5,4 +5,5 @@ def __getattr__(self, name): "This requires to install ably with crypto support: pip install 'ably[crypto]'" ) + AES = Random = InstallPycrypto() 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-test.txt b/requirements-test.txt deleted file mode 100644 index 4964b387..00000000 --- a/requirements-test.txt +++ /dev/null @@ -1,16 +0,0 @@ -methoddispatch>=3.0.2,<4 -msgpack>=1.0.0,<2 -pycryptodome -requests>=2.7.0,<3 - -mock>=1.3.0,<2.0 -pep8-naming>=0.4.1 -pytest>=4.4 -pytest-cov>=2.4.0,<3 -pytest-flake8 -#pytest-mock>=1.5.0,<2 -#pytest-timeout>=1.2.0,<2 -pytest-xdist>=1.15.0,<2 -responses>=0.5.0,<1.0 - -requests-toolbelt 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 index d95ce934..6171d1aa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,5 @@ [coverage:run] branch=True -[flake8] -max-line-length = 115 -ignore = E114,E121,E123,E126,E127,E128,E241,E226,E231,E251,E302,E305,E306,E402,F401,F821,F841,I100,I101,I201,N802,W291,W293,W391,W503,W504 - [tool:pytest] #log_level = DEBUG diff --git a/setup.py b/setup.py deleted file mode 100644 index a0f1dff4..00000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -from setuptools import setup - -with open('LONG_DESCRIPTION.rst') as f: - long_description = f.read() - -setup( - name='ably', - version='1.1.1', - 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.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - packages=['ably', 'ably.http', 'ably.rest', 'ably.transport', - 'ably.types', 'ably.util'], - install_requires=['methoddispatch>=3.0.2,<4', - 'msgpack>=1.0.0,<2', - 'requests>=2.7.0,<3'], - extras_require={ - 'oldcrypto': ['pycrypto>=2.6.1'], - 'crypto': ['pycryptodome'], - }, - author="Ably", - author_email='support@ably.io', - url='https://github.com/ably/ably-python', - description="A Python client library for ably.io realtime messaging", - long_description=long_description, -) diff --git a/test/ably/__init__.py b/test/ably/__init__.py index 0aa32c4a..e69de29b 100644 --- a/test/ably/__init__.py +++ b/test/ably/__init__.py @@ -1,20 +0,0 @@ -from requests.adapters import HTTPAdapter - -real_send = HTTPAdapter.send -def send(*args, **kw): - response = real_send(*args, **kw) - - from requests_toolbelt.utils import dump - data = dump.dump_all(response) - for line in data.splitlines(): - try: - line = line.decode('utf-8') - except UnicodeDecodeError: - line = bytes(line) - print(line) - - return response - - -# Uncomment this to print request/response -# HTTPAdapter.send = send diff --git a/test/ably/conftest.py b/test/ably/conftest.py index 8bd1b41d..01483272 100644 --- a/test/ably/conftest.py +++ b/test/ably/conftest.py @@ -1,9 +1,10 @@ -import pytest -from test.ably.restsetup import RestSetup +import pytest_asyncio +from test.ably.testapp import TestApp -@pytest.fixture(scope='session', autouse=True) -def setup(): - RestSetup.get_test_vars() + +@pytest_asyncio.fixture(scope='session', autouse=True) +async def test_app_setup(): + await TestApp.get_test_vars() yield - RestSetup.clear_test_vars() + 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/encoders_test.py b/test/ably/rest/encoders_test.py similarity index 62% rename from test/ably/encoders_test.py rename to test/ably/rest/encoders_test.py index 9ac1a36f..f8023c5d 100644 --- a/test/ably/encoders_test.py +++ b/test/ably/rest/encoders_test.py @@ -1,124 +1,137 @@ import base64 import json import logging +import sys +from unittest import mock -import mock import msgpack +import pytest from ably import CipherParams -from ably.util.crypto import get_cipher 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 -from test.ably.restsetup import RestSetup -from test.ably.utils import BaseTestCase +if sys.version_info >= (3, 8): + from unittest.mock import AsyncMock +else: + from mock import AsyncMock log = logging.getLogger(__name__) -class TestTextEncodersNoEncryption(BaseTestCase): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest(use_binary_protocol=False) +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() - def test_text_utf8(self): + async def test_text_utf8(self): channel = self.ably.channels["persisted:publish"] - with mock.patch('ably.rest.rest.Http.post') as post_mock: - channel.publish('event', 'foó') + 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', '') - def test_str(self): + 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') as post_mock: - channel.publish('event', 'foo') + 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', '') - def test_with_binary_type(self): + async def test_with_binary_type(self): channel = self.ably.channels["persisted:publish"] - with mock.patch('ably.rest.rest.Http.post') as post_mock: - channel.publish('event', bytearray(b'foo')) + 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' - def test_with_bytes_type(self): + async def test_with_bytes_type(self): channel = self.ably.channels["persisted:publish"] - with mock.patch('ably.rest.rest.Http.post') as post_mock: - channel.publish('event', b'foo') + 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' - def test_with_json_dict_data(self): + 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') as post_mock: - channel.publish('event', data) + 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' - def test_with_json_list_data(self): + 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') as post_mock: - channel.publish('event', data) + 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' - def test_text_utf8_decode(self): + async def test_text_utf8_decode(self): channel = self.ably.channels["persisted:stringdecode"] - channel.publish('event', 'fóo') - message = channel.history().items[0] + 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 - def test_text_str_decode(self): + async def test_text_str_decode(self): channel = self.ably.channels["persisted:stringnonutf8decode"] - channel.publish('event', 'foo') - message = channel.history().items[0] + 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 - def test_with_binary_type_decode(self): + async def test_with_binary_type_decode(self): channel = self.ably.channels["persisted:binarydecode"] - channel.publish('event', bytearray(b'foob')) - message = channel.history().items[0] + 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 - def test_with_json_dict_data_decode(self): + async def test_with_json_dict_data_decode(self): channel = self.ably.channels["persisted:jsondict"] data = {'foó': 'bár'} - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding - def test_with_json_list_data_decode(self): + async def test_with_json_list_data_decode(self): channel = self.ably.channels["persisted:jsonarray"] data = ['foó', 'bár'] - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding @@ -130,44 +143,47 @@ def test_decode_with_invalid_encoding(self): assert decoded_data['encoding'] == 'foo/bar' -class TestTextEncodersEncryption(BaseTestCase): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest(use_binary_protocol=False) - cls.cipher_params = CipherParams(secret_key='keyfordecrypt_16', - algorithm='aes') +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={}): + 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) - def test_text_utf8(self): + 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') as post_mock: - channel.publish('event', 'fóo') + 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' - def test_str(self): + 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') as post_mock: - channel.publish('event', 'foo') + 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', '') - def test_with_binary_type(self): + 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') as post_mock: - channel.publish('event', bytearray(b'foo')) + 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' @@ -175,182 +191,197 @@ def test_with_binary_type(self): assert data == bytearray(b'foo') assert isinstance(data, bytearray) - def test_with_json_dict_data(self): + 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') as post_mock: - channel.publish('event', data) + 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 - def test_with_json_list_data(self): + 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') as post_mock: - channel.publish('event', data) + 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 - def test_text_utf8_decode(self): + async def test_text_utf8_decode(self): channel = self.ably.channels.get("persisted:enc_stringdecode", cipher=self.cipher_params) - channel.publish('event', 'foó') - message = channel.history().items[0] + 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 - def test_with_binary_type_decode(self): + async def test_with_binary_type_decode(self): channel = self.ably.channels.get("persisted:enc_binarydecode", cipher=self.cipher_params) - channel.publish('event', bytearray(b'foob')) - message = channel.history().items[0] + 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 - def test_with_json_dict_data_decode(self): + 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'} - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding - def test_with_json_list_data_decode(self): + 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'] - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding -class TestBinaryEncodersNoEncryption(BaseTestCase): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() +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) - def test_text_utf8(self): + 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: - channel.publish('event', 'foó') + 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('/') == '' - def test_with_binary_type(self): + 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: - channel.publish('event', bytearray(b'foo')) + 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('/') == '' - def test_with_json_dict_data(self): + 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: - channel.publish('event', data) + 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' - def test_with_json_list_data(self): + 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: - channel.publish('event', data) + 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' - def test_text_utf8_decode(self): + async def test_text_utf8_decode(self): channel = self.ably.channels["persisted:stringdecode-bin"] - channel.publish('event', 'fóo') - message = channel.history().items[0] + 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 - def test_with_binary_type_decode(self): + async def test_with_binary_type_decode(self): channel = self.ably.channels["persisted:binarydecode-bin"] - channel.publish('event', bytearray(b'foob')) - message = channel.history().items[0] + 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 - def test_with_json_dict_data_decode(self): + async def test_with_json_dict_data_decode(self): channel = self.ably.channels["persisted:jsondict-bin"] data = {'foó': 'bár'} - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding - def test_with_json_list_data_decode(self): + async def test_with_json_list_data_decode(self): channel = self.ably.channels["persisted:jsonarray-bin"] data = ['foó', 'bár'] - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding -class TestBinaryEncodersEncryption(BaseTestCase): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - cls.cipher_params = CipherParams(secret_key='keyfordecrypt_16', - algorithm='aes') +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={}): + 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) - def test_text_utf8(self): + 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: - channel.publish('event', 'fóo') + 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' - def test_with_binary_type(self): + 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: - channel.publish('event', bytearray(b'foo')) + await channel.publish('event', bytearray(b'foo')) _, kwargs = post_mock.call_args assert self.decode(kwargs['body'])['encoding'].strip('/') == 'cipher+aes-128-cbc' @@ -358,63 +389,67 @@ def test_with_binary_type(self): assert data == bytearray(b'foo') assert isinstance(data, bytearray) - def test_with_json_dict_data(self): + 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: - channel.publish('event', data) + 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 - def test_with_json_list_data(self): + 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: - channel.publish('event', data) + 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 - def test_text_utf8_decode(self): + async def test_text_utf8_decode(self): channel = self.ably.channels.get("persisted:enc_stringdecode-bin", cipher=self.cipher_params) - channel.publish('event', 'foó') - message = channel.history().items[0] + 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 - def test_with_binary_type_decode(self): + async def test_with_binary_type_decode(self): channel = self.ably.channels.get("persisted:enc_binarydecode-bin", cipher=self.cipher_params) - channel.publish('event', bytearray(b'foob')) - message = channel.history().items[0] + 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 - def test_with_json_dict_data_decode(self): + 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'} - channel.publish('event', data) - message = channel.history().items[0] + await channel.publish('event', data) + history = await channel.history() + message = history.items[0] assert message.data == data assert not message.encoding - def test_with_json_list_data_decode(self): + 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'] - channel.publish('event', data) - message = channel.history().items[0] + 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/restcapability_test.py b/test/ably/rest/restcapability_test.py similarity index 63% rename from test/ably/restcapability_test.py rename to test/ably/rest/restcapability_test.py index 326eaa6d..c95c651d 100644 --- a/test/ably/restcapability_test.py +++ b/test/ably/rest/restcapability_test.py @@ -2,33 +2,33 @@ 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 -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase -test_vars = RestSetup.get_test_vars() +class TestRestCapability(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - -class TestRestCapability(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() + @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 - def test_blanket_intersection_with_key(self): - key = test_vars['keys'][1] - token_details = self.ably.auth.request_token(key_name=key['key_name'], - key_secret=key['key_secret']) + 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." - def test_equal_intersection_with_key(self): - key = test_vars['keys'][1] + async def test_equal_intersection_with_key(self): + key = self.test_vars['keys'][1] - token_details = self.ably.auth.request_token( + token_details = await self.ably.auth.request_token( key_name=key['key_name'], key_secret=key['key_secret'], token_params={'capability': key['capability']}) @@ -39,25 +39,25 @@ def test_equal_intersection_with_key(self): assert expected_capability == token_details.capability, "Unexpected capability" @dont_vary_protocol - def test_empty_ops_intersection(self): - key = test_vars['keys'][1] + async def test_empty_ops_intersection(self): + key = self.test_vars['keys'][1] with pytest.raises(AblyException): - self.ably.auth.request_token( + await self.ably.auth.request_token( key_name=key['key_name'], key_secret=key['key_secret'], token_params={'capability': {'testchannel': ['subscribe']}}) @dont_vary_protocol - def test_empty_paths_intersection(self): - key = test_vars['keys'][1] + async def test_empty_paths_intersection(self): + key = self.test_vars['keys'][1] with pytest.raises(AblyException): - self.ably.auth.request_token( + await self.ably.auth.request_token( key_name=key['key_name'], key_secret=key['key_secret'], token_params={'capability': {"testchannelx": ["publish"]}}) - def test_non_empty_ops_intersection(self): - key = test_vars['keys'][4] + async def test_non_empty_ops_intersection(self): + key = self.test_vars['keys'][4] token_params = {"capability": { "channel2": ["presence", "subscribe"] @@ -71,13 +71,13 @@ def test_non_empty_ops_intersection(self): "channel2": ["subscribe"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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" - def test_non_empty_paths_intersection(self): - key = test_vars['keys'][4] + async def test_non_empty_paths_intersection(self): + key = self.test_vars['keys'][4] token_params = { "capability": { "channel2": ["presence", "subscribe"], @@ -94,13 +94,13 @@ def test_non_empty_paths_intersection(self): "channel2": ["subscribe"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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" - def test_wildcard_ops_intersection(self): - key = test_vars['keys'][4] + async def test_wildcard_ops_intersection(self): + key = self.test_vars['keys'][4] token_params = { "capability": { @@ -116,13 +116,13 @@ def test_wildcard_ops_intersection(self): "channel2": ["subscribe", "publish"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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" - def test_wildcard_ops_intersection_2(self): - key = test_vars['keys'][4] + async def test_wildcard_ops_intersection_2(self): + key = self.test_vars['keys'][4] token_params = { "capability": { @@ -138,13 +138,13 @@ def test_wildcard_ops_intersection_2(self): "channel6": ["subscribe", "publish"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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" - def test_wildcard_resources_intersection(self): - key = test_vars['keys'][2] + async def test_wildcard_resources_intersection(self): + key = self.test_vars['keys'][2] token_params = { "capability": { @@ -160,13 +160,13 @@ def test_wildcard_resources_intersection(self): "cansubscribe": ["subscribe"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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" - def test_wildcard_resources_intersection_2(self): - key = test_vars['keys'][2] + async def test_wildcard_resources_intersection_2(self): + key = self.test_vars['keys'][2] token_params = { "capability": { @@ -182,13 +182,13 @@ def test_wildcard_resources_intersection_2(self): "cansubscribe:check": ["subscribe"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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" - def test_wildcard_resources_intersection_3(self): - key = test_vars['keys'][2] + async def test_wildcard_resources_intersection_3(self): + key = self.test_vars['keys'][2] token_params = { "capability": { @@ -205,15 +205,15 @@ def test_wildcard_resources_intersection_3(self): "cansubscribe:*": ["subscribe"] }) - token_details = self.ably.auth.request_token(token_params, **kwargs) + 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 - def test_invalid_capabilities(self): + async def test_invalid_capabilities(self): with pytest.raises(AblyException) as excinfo: - self.ably.auth.request_token( + await self.ably.auth.request_token( token_params={'capability': {"channel0": ["publish_"]}}) the_exception = excinfo.value @@ -221,9 +221,9 @@ def test_invalid_capabilities(self): assert 40000 == the_exception.code @dont_vary_protocol - def test_invalid_capabilities_2(self): + async def test_invalid_capabilities_2(self): with pytest.raises(AblyException) as excinfo: - self.ably.auth.request_token( + await self.ably.auth.request_token( token_params={'capability': {"channel0": ["*", "publish"]}}) the_exception = excinfo.value @@ -231,11 +231,25 @@ def test_invalid_capabilities_2(self): assert 40000 == the_exception.code @dont_vary_protocol - def test_invalid_capabilities_3(self): + async def test_invalid_capabilities_3(self): with pytest.raises(AblyException) as excinfo: - self.ably.auth.request_token( + 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/restchannelpublish_test.py b/test/ably/rest/restchannelpublish_test.py similarity index 59% rename from test/ably/restchannelpublish_test.py rename to test/ably/rest/restchannelpublish_test.py index fff21a3b..41c2018b 100644 --- a/test/ably/restchannelpublish_test.py +++ b/test/ably/rest/restchannelpublish_test.py @@ -4,84 +4,90 @@ import logging import os import uuid +from unittest import mock -import mock +import httpx import msgpack import pytest -import requests -from ably import api_version -from ably import AblyException, IncompatibleClientIdException +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 -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase - -test_vars = RestSetup.get_test_vars() log = logging.getLogger(__name__) -class TestRestChannelPublish(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - def setUp(self): - self.ably = RestSetup.get_ably_rest() +# 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 = RestSetup.get_ably_rest(client_id=self.client_id) + 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 - def test_publish_various_datatypes_text(self): + async def test_publish_various_datatypes_text(self): publish0 = self.ably.channels[ self.get_channel_name('persisted:publish0')] - publish0.publish("publish0", "This is a string message payload") - publish0.publish("publish1", b"This is a byte[] message payload") - publish0.publish("publish2", {"test": "This is a JSONObject message payload"}) - publish0.publish("publish3", ["This is a JSONArray message payload"]) + 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 = publish0.history() + 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 = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) + 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)" + "Expect publish0 to be expected String)" assert message_contents["publish1"] == b"This is a byte[] message payload", \ - "Expect publish1 to be expected byte[]. Actual: %s" % str(message_contents['publish1']) + "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" + "Expect publish2 to be expected JSONObject" assert message_contents["publish3"] == ["This is a JSONArray message payload"], \ - "Expect publish3 to be expected JSONObject" + "Expect publish3 to be expected JSONObject" @dont_vary_protocol - def test_unsuporsed_payload_must_raise_exception(self): + 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): - channel.publish('event', data) + await channel.publish('event', data) - def test_publish_message_list(self): + async def test_publish_message_list(self): channel = self.ably.channels[ self.get_channel_name('persisted:message_list_channel')] - expected_messages = [Message("name-{}".format(i), str(i)) for i in range(3)] + expected_messages = [Message(f"name-{i}", str(i)) for i in range(3)] - channel.publish(messages=expected_messages) + await channel.publish(messages=expected_messages) # Get the history for this channel - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -91,15 +97,15 @@ def test_publish_message_list(self): assert m.name == expected_m.name assert m.data == expected_m.data - def test_message_list_generate_one_request(self): + 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("name-{}".format(i), str(i)) for i in range(3)] + 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: - channel.publish(messages=expected_messages) + await channel.publish(messages=expected_messages) assert post_mock.call_count == 1 if self.use_binary_protocol: @@ -111,26 +117,27 @@ def test_message_list_generate_one_request(self): assert message['name'] == 'name-' + str(i) assert message['data'] == str(i) - def test_publish_error(self): - ably = RestSetup.get_ably_rest(use_binary_protocol=self.use_binary_protocol) - ably.auth.authorize( + 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: - ably.channels["only_subscribe"].publish() + await ably.channels["only_subscribe"].publish() assert 401 == excinfo.value.status_code assert 40160 == excinfo.value.code + await ably.close() - def test_publish_message_null_name(self): + async def test_publish_message_null_name(self): channel = self.ably.channels[ self.get_channel_name('persisted:message_null_name_channel')] data = "String message" - channel.publish(name=None, data=data) + await channel.publish(name=None, data=data) # Get the history for this channel - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -138,15 +145,15 @@ def test_publish_message_null_name(self): assert messages[0].name is None assert messages[0].data == data - def test_publish_message_null_data(self): + async def test_publish_message_null_data(self): channel = self.ably.channels[ self.get_channel_name('persisted:message_null_data_channel')] name = "Test name" - channel.publish(name=name, data=None) + await channel.publish(name=name, data=None) # Get the history for this channel - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -155,15 +162,15 @@ def test_publish_message_null_data(self): assert messages[0].name == name assert messages[0].data is None - def test_publish_message_null_name_and_data(self): + async def test_publish_message_null_name_and_data(self): channel = self.ably.channels[ self.get_channel_name('persisted:null_name_and_data_channel')] - channel.publish(name=None, data=None) - channel.publish() + await channel.publish(name=None, data=None) + await channel.publish() # Get the history for this channel - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -173,15 +180,15 @@ def test_publish_message_null_name_and_data(self): assert m.name is None assert m.data is None - def test_publish_message_null_name_and_data_keys_arent_sent(self): + 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: - channel.publish(name=None, data=None) + await channel.publish(name=None, data=None) - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -197,17 +204,17 @@ def test_publish_message_null_name_and_data_keys_arent_sent(self): assert 'name' not in posted_body assert 'data' not in posted_body - def test_message_attr(self): + 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')] - publish0.publish(messages=messages) + await publish0.publish(messages=messages) # Get the history for this channel - history = publish0.history() + history = await publish0.history() message = history.items[0] assert isinstance(message, Message) assert message.id @@ -217,30 +224,31 @@ def test_message_attr(self): assert message.client_id == 'client_id' assert isinstance(message.timestamp, int) - def test_token_is_bound_to_options_client_id_after_publish(self): + 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')] - channel.publish(name='publish', data='test') + 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 - assert channel.history().items[0].client_id == self.client_id + history = await channel.history() + assert history.items[0].client_id == self.client_id - def test_publish_message_without_client_id_on_identified_client(self): + 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: - channel.publish(name='publish', data='test') + await channel.publish(name='publish', data='test') - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -258,7 +266,7 @@ def test_publish_message_without_client_id_on_identified_client(self): assert 'client_id' not in posted_body # Get the history for this channel - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -266,14 +274,14 @@ def test_publish_message_without_client_id_on_identified_client(self): assert messages[0].client_id == self.ably_with_client_id.client_id - def test_publish_message_with_client_id_on_identified_client(self): + 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')] - channel.publish(name='publish', data='test', - client_id=self.ably_with_client_id.client_id) + message = Message(name='publish', data='test', client_id=self.ably_with_client_id.client_id) + await channel.publish(message) - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -281,28 +289,32 @@ def test_publish_message_with_client_id_on_identified_client(self): 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): - channel.publish(name='publish', data='test', client_id='invalid') + 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) - def test_publish_message_with_wrong_client_id_on_implicit_identified_client(self): - new_token = self.ably.auth.authorize( - token_params={'client_id': uuid.uuid4().hex}) - new_ably = RestSetup.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: - channel.publish(name='publish', data='test', client_id='invalid') + await channel.publish(message) assert 400 == excinfo.value.status_code assert 40012 == excinfo.value.code + await new_ably.close() # RSA15b - def test_wildcard_client_id_can_publish_as_others(self): - wildcard_token_details = self.ably.auth.request_token({'client_id': '*'}) - wildcard_ably = RestSetup.get_ably_rest( + 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) @@ -310,12 +322,12 @@ def test_wildcard_client_id_can_publish_as_others(self): assert wildcard_ably.auth.client_id == '*' channel = wildcard_ably.channels[ self.get_channel_name('persisted:wildcard_client_id')] - channel.publish(name='publish1', data='no client_id') + await channel.publish(name='publish1', data='no client_id') some_client_id = uuid.uuid4().hex - channel.publish(name='publish2', data='some client_id', - client_id=some_client_id) + message = Message(name='publish2', data='some client_id', client_id=some_client_id) + await channel.publish(message) - history = channel.history() + history = await channel.history() messages = history.items assert messages is not None, "Expected non-None messages" @@ -324,19 +336,21 @@ def test_wildcard_client_id_can_publish_as_others(self): assert messages[0].client_id == some_client_id assert messages[1].client_id is None + await wildcard_ably.close() + # TM2h @dont_vary_protocol - def test_invalid_connection_key(self): + 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: - channel.publish(messages=[message]) + await channel.publish(messages=[message]) assert 400 == excinfo.value.status_code assert 40006 == excinfo.value.code # TM2i, RSL6a2, RSL1h - def test_publish_extras(self): + async def test_publish_extras(self): channel = self.ably.channels[ self.get_channel_name('canpublish:extras_channel')] extras = { @@ -344,22 +358,23 @@ def test_publish_extras(self): 'notification': {"title": "Testing"}, } } - channel.publish(name='test-name', data='test-data', extras=extras) + message = Message(name='test-name', data='test-data', extras=extras) + await channel.publish(message) # Get the history for this channel - history = channel.history() + history = await channel.history() message = history.items[0] assert message.name == 'test-name' assert message.data == 'test-data' assert message.extras == extras # RSL6a1 - def test_interoperability(self): + async def test_interoperability(self): name = self.get_channel_name('persisted:interoperability_channel') channel = self.ably.channels[name] - url = 'https://%s/channels/%s/messages' % (test_vars["host"], name) - key = test_vars['keys'][0] + 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 = { @@ -369,12 +384,11 @@ def test_interoperability(self): 'binary': bytearray, } - root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - path = os.path.join(root_dir, 'submodules', 'test-resources', 'messages-encoding.json') + 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']: - data = input_msg['data'] + msg_data = input_msg['data'] encoding = input_msg['encoding'] expected_type = input_msg['expectedType'] if expected_type == 'binary': @@ -385,51 +399,62 @@ def test_interoperability(self): expected_value = input_msg.get('expectedValue') # 1) - channel.publish(data=expected_value) - r = requests.get(url, auth=auth) - item = r.json()[0] - assert item.get('encoding') == encoding - if encoding == 'json': - assert json.loads(item['data']) == json.loads(data) - else: - assert item['data'] == data + 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) - channel.publish(messages=[Message(data=data, encoding=encoding)]) - history = channel.history() - message = history.items[0] - assert message.data == expected_value - assert type(message.data) == type_mapping[expected_type] + 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 - def test_publish_slash(self): + async def test_publish_slash(self): channel = self.ably.channels.get(self.get_channel_name('persisted:widgets/')) name, data = 'Name', 'Data' - channel.publish(name, data) - history = channel.history().items - assert len(history) == 1 - assert history[0].name == name - assert history[0].data == 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 - def test_publish_params(self): + 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: - channel.publish(message, {'_forceNack': True}) + await channel.publish(message, {'_forceNack': True}) assert 400 == excinfo.value.status_code assert 40099 == excinfo.value.code -class TestRestChannelPublishIdempotent(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): +class TestRestChannelPublishIdempotent(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - cls.ably_idempotent = RestSetup.get_ably_rest(idempotent_rest_publishing=True) + @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 @@ -437,7 +462,7 @@ def per_protocol_setup(self, use_binary_protocol): # TO3n @dont_vary_protocol - def test_idempotent_rest_publishing(self): + async def test_idempotent_rest_publishing(self): # Test default value if api_version < '1.2': assert self.ably.options.idempotent_rest_publishing is False @@ -445,15 +470,17 @@ def test_idempotent_rest_publishing(self): assert self.ably.options.idempotent_rest_publishing is True # Test setting value explicitly - ably = RestSetup.get_ably_rest(idempotent_rest_publishing=True) + ably = await TestApp.get_ably_rest(idempotent_rest_publishing=True) assert ably.options.idempotent_rest_publishing is True + await ably.close() - ably = RestSetup.get_ably_rest(idempotent_rest_publishing=False) + 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 - def test_message_serialization(self): + async def test_message_serialization(self): channel = self.get_channel() data = { @@ -465,7 +492,7 @@ def test_message_serialization(self): } message = Message(**data) request_body = channel._Channel__publish_request_body(messages=[message]) - input_keys = set(case.snake_to_camel(x) for x in data.keys()) + input_keys = {case.snake_to_camel(x) for x in data.keys()} assert input_keys - set(request_body) == set() # RSL1k1 @@ -503,39 +530,44 @@ def test_idempotent_mixed_ids(self): def get_ably_rest(self, *args, **kwargs): kwargs['use_binary_protocol'] = self.use_binary_protocol - return RestSetup.get_ably_rest(*args, **kwargs) + return TestApp.get_ably_rest(*args, **kwargs) # RSL1k4 - def test_idempotent_library_generated_retry(self): - ably = self.get_ably_rest(idempotent_rest_publishing=True) - if not ably.options.fallback_hosts: - host = ably.options.get_rest_host() - ably = self.get_ably_rest(idempotent_rest_publishing=True, fallback_hosts=[host] * 3) + 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} - send = requests.sessions.Session.send - def side_effect(self, *args, **kwargs): - x = send(self, *args, **kwargs) + 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('requests.sessions.Session.send', side_effect=side_effect, autospec=True): - channel.publish(messages=messages) + with mock.patch('httpx.AsyncClient.send', side_effect=side_effect, autospec=True): + await channel.publish(messages=messages) assert state['failures'] == 2 - assert len(channel.history().items) == 1 + history = await channel.history() + assert len(history.items) == 1 + await client.aclose() + await ably.close() # RSL1k5 - def test_idempotent_client_supplied_publish(self): - ably = self.get_ably_rest(idempotent_rest_publishing=True) + 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')] - channel.publish(messages=messages) - channel.publish(messages=messages) - channel.publish(messages=messages) - assert len(channel.history().items) == 1 + 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/restchannels_test.py b/test/ably/rest/restchannels_test.py similarity index 75% rename from test/ably/restchannels_test.py rename to test/ably/rest/restchannels_test.py index ef18c50c..b5e59957 100644 --- a/test/ably/restchannels_test.py +++ b/test/ably/rest/restchannels_test.py @@ -5,18 +5,19 @@ from ably import AblyException from ably.rest.channel import Channel, Channels, Presence from ably.util.crypto import generate_random_key - -from test.ably.restsetup import RestSetup -from test.ably.utils import BaseTestCase - -test_vars = RestSetup.get_test_vars() +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase # makes no request, no need to use different protocols -class TestChannels(BaseTestCase): +class TestChannels(BaseAsyncTestCase): - def setUp(self): - self.ably = RestSetup.get_ably_rest() + @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') @@ -60,7 +61,7 @@ def test_channels_in(self): assert new_channel_2 in self.ably.channels def test_channels_iteration(self): - channel_names = ['channel_{}'.format(i) for i in range(5)] + 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) @@ -68,29 +69,22 @@ def test_channels_iteration(self): 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') - - with pytest.raises(KeyError): - self.ably.channels.release('new_channel') - - def test_channels_del(self): - self.ably.channels.get('new_channel') - del self.ably.channels['new_channel'] - - with pytest.raises(KeyError): - del self.ably.channels['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) - def test_without_permissions(self): - key = test_vars["keys"][2] - ably = RestSetup.get_ably_rest(key=key["key_str"]) + 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: - ably.channels['test_publish_without_permission'].publish('foo', 'woop') + await ably.channels['test_publish_without_permission'].publish('foo', 'woop') - assert 'not permitted' in excinfo.value.message + 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/restcrypto_test.py b/test/ably/rest/restcrypto_test.py similarity index 64% rename from test/ably/restcrypto_test.py rename to test/ably/rest/restcrypto_test.py index 6149886b..94812b29 100644 --- a/test/ably/restcrypto_test.py +++ b/test/ably/rest/restcrypto_test.py @@ -1,28 +1,31 @@ +import base64 import json -import os import logging -import base64 +import os import pytest +from Crypto import Random from ably import AblyException from ably.types.message import Message -from ably.util.crypto import CipherParams, get_cipher, generate_random_key, get_default_params +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 -from Crypto import Random - -from test.ably.restsetup import RestSetup -from test.ably.utils import dont_vary_protocol, VaryByProtocolTestsMetaclass, BaseTestCase - -test_vars = RestSetup.get_test_vars() log = logging.getLogger(__name__) -class TestRestCrypto(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): +class TestRestCrypto(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - def setUp(self): - self.ably = RestSetup.get_ably_rest() - self.ably2 = RestSetup.get_ably_rest() + @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 @@ -40,8 +43,8 @@ def test_cbc_channel_cipher(self): b'\x28\x4c\xe4\x8d\x4b\xdc\x9d\x42' b'\x8a\x77\x6b\x53\x2d\xc7\xb5\xc0') - log.debug("KEY_LEN: %d" % len(key)) - log.debug("IV_LEN: %d" % len(iv)) + 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" @@ -57,28 +60,28 @@ def test_cbc_channel_cipher(self): assert expected_ciphertext == actual_ciphertext - def test_crypto_publish(self): + 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()}) - publish0.publish("publish3", "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"]) + 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 = publish0.history() + 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 = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) + 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: %s" % str(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" @@ -86,7 +89,7 @@ def test_crypto_publish(self): assert ["This is a JSONArray message payload"] == message_contents["publish6"],\ "Expect publish6 to be expected JSONObject" - def test_crypto_publish_256(self): + async def test_crypto_publish_256(self): rndfile = Random.new() key = rndfile.read(32) channel_name = 'persisted:crypto_publish_text_256' @@ -94,24 +97,24 @@ def test_crypto_publish_256(self): publish0 = self.ably.channels.get(channel_name, cipher={'key': key}) - publish0.publish("publish3", "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"]) + 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 = publish0.history() + 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 = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) + 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: %s" % str(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" @@ -119,48 +122,48 @@ def test_crypto_publish_256(self): assert ["This is a JSONArray message payload"] == message_contents["publish6"],\ "Expect publish6 to be expected JSONObject" - def test_crypto_publish_key_mismatch(self): + 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()}) - publish0.publish("publish3", "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"]) + 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: - rx_channel.history() + await rx_channel.history() message = excinfo.value.message assert 'invalid-padding' == message or "codec can't decode" in message - def test_crypto_send_unencrypted(self): + async def test_crypto_send_unencrypted(self): channel_name = self.get_channel_name('persisted:crypto_send_unencrypted') publish0 = self.ably.channels[channel_name] - publish0.publish("publish3", "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"]) + 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 = rx_channel.history() + 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 = dict((m.name, m.data) for m in messages) - log.debug("message_contents: %s" % str(message_contents)) + 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: %s" % str(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" @@ -168,16 +171,16 @@ def test_crypto_send_unencrypted(self): assert ["This is a JSONArray message payload"] == message_contents["publish6"],\ "Expect publish6 to be expected JSONObject" - def test_crypto_encrypted_unhandled(self): + 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}) - publish0.publish("publish0", data) + await publish0.publish("publish0", data) rx_channel = self.ably2.channels[channel_name] - history = rx_channel.history() + history = await rx_channel.history() message = history.items[0] cipher = get_cipher(get_default_params({'key': key})) assert cipher.decrypt(message.data).decode() == data @@ -198,19 +201,20 @@ def test_cipher_params(self): class AbstractTestCryptoWithFixture: - @classmethod - def setUpClass(cls): - with open(os.path.dirname(__file__) + '/../../submodules/test-resources/%s' % cls.fixture_file, 'r') as f: - cls.fixture = json.loads(f.read()) - cls.params = { - 'secret_key': base64.b64decode(cls.fixture['key'].encode('ascii')), - 'mode': cls.fixture['mode'], - 'algorithm': cls.fixture['algorithm'], - 'iv': base64.b64decode(cls.fixture['iv'].encode('ascii')), + @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')), } - cls.cipher_params = CipherParams(**cls.params) - cls.cipher = get_cipher(cls.cipher_params) - cls.items = cls.fixture['items'] + 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': 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/restinit_test.py b/test/ably/rest/restinit_test.py similarity index 61% rename from test/ably/restinit_test.py rename to test/ably/rest/restinit_test.py index eccc09c0..25e7c5af 100644 --- a/test/ably/restinit_test.py +++ b/test/ably/rest/restinit_test.py @@ -1,24 +1,26 @@ -from mock import patch +from unittest.mock import patch + import pytest -from requests import Session +from httpx import AsyncClient -from ably import AblyRest -from ably import AblyException +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 -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase -test_vars = RestSetup.get_test_vars() +class TestRestInit(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): + @pytest.fixture(autouse=True) + async def setup(self): + self.test_vars = await TestApp.get_test_vars() -class TestRestInit(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): @dont_vary_protocol def test_key_only(self): - ably = AblyRest(key=test_vars["keys"][0]["key_str"]) - assert ably.options.key_name == test_vars["keys"][0]["key_name"], "Key name does not match" - assert ably.options.key_secret == test_vars["keys"][0]["key_secret"], "Key secret does not match" + 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 @@ -43,9 +45,9 @@ def token_callback(**params): @dont_vary_protocol def test_ambiguous_key_raises_value_error(self): with pytest.raises(ValueError, match="mutually exclusive"): - AblyRest(key=test_vars["keys"][0]["key_str"], key_name='x') + AblyRest(key=self.test_vars["keys"][0]["key_str"], key_name='x') with pytest.raises(ValueError, match="mutually exclusive"): - AblyRest(key=test_vars["keys"][0]["key_str"], key_secret='x') + AblyRest(key=self.test_vars["keys"][0]["key_str"], key_secret='x') @dont_vary_protocol def test_with_key_name_or_secret_only(self): @@ -67,24 +69,24 @@ def test_with_options_auth_url(self): # RSC11 @dont_vary_protocol def test_rest_host_and_environment(self): - # rest host - ably = AblyRest(token='foo', rest_host="some.other.host") - assert "some.other.host" == ably.options.rest_host, "Unexpected host mismatch" + # endpoint host + ably = AblyRest(token='foo', endpoint="some.other.host") + assert "some.other.host" == ably.options.get_host(), "Unexpected host mismatch" - # environment: production - ably = AblyRest(token='foo', environment="production") - host = ably.options.get_rest_host() - assert "rest.ably.io" == host, "Unexpected host mismatch %s" % host + # endpoint: main + ably = AblyRest(token='foo', endpoint="main") + host = ably.options.get_host() + assert "main.realtime.ably.net" == host, f"Unexpected host mismatch {host}" - # environment: other - ably = AblyRest(token='foo', environment="sandbox") - host = ably.options.get_rest_host() - assert "sandbox-rest.ably.io" == host, "Unexpected host mismatch %s" % 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(ValueError): + with pytest.raises(AblyException): ably = AblyRest(token='foo', rest_host="some.other.host", - environment="some.other.environment") + endpoint="some.other.environment") # RSC15 @dont_vary_protocol @@ -95,19 +97,19 @@ def test_fallback_hosts(self): [], ] + # Fallback hosts specified (RSC15g1) for aux in fallback_hosts: ably = AblyRest(token='foo', fallback_hosts=aux) - assert sorted(aux) == sorted(ably.options.get_fallback_rest_hosts()) + assert sorted(aux) == sorted(ably.options.get_fallback_hosts()) - # Specify environment - ably = AblyRest(token='foo', environment='sandbox') - assert [] == sorted(ably.options.get_fallback_rest_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()) - # Specify environment and fallback_hosts_use_default - # We specify http_max_retry_count=10 so all the fallback hosts get in the list - ably = AblyRest(token='foo', environment='sandbox', fallback_hosts_use_default=True, - http_max_retry_count=10) - assert sorted(Defaults.fallback_hosts) == sorted(ably.options.get_fallback_rest_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') @@ -116,27 +118,27 @@ def test_fallback_hosts(self): assert 1000 == ably.options.fallback_retry_timeout @dont_vary_protocol - def test_specified_realtime_host(self): - ably = AblyRest(token='foo', realtime_host="some.other.host") - assert "some.other.host" == ably.options.realtime_host, "Unexpected host mismatch" + 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),\ - "Unexpected port mismatch. Expected: 9999. Actual: %d" % ably.options.tls_port + 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),\ - "Unexpected port mismatch. Expected: 9999. Actual: %d" % ably.options.tls_port + 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),\ - "Unexpected port mismatch. Expected: 9999. Actual: %d" % ably.options.tls_port + f"Unexpected port mismatch. Expected: 9999. Actual: {ably.options.tls_port}" @dont_vary_protocol def test_tls_defaults_to_true(self): @@ -161,54 +163,57 @@ def test_with_no_auth_params(self): AblyRest(port=111) # RSA10k - def test_query_time_param(self): - ably = RestSetup.get_ably_rest(query_time=True, - use_binary_protocol=self.use_binary_protocol) + 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: - ably.auth.request_token() + await ably.auth.request_token() assert local_time.call_count == 1 assert server_time.call_count == 1 - ably.auth.request_token() + 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://rest.ably.io' == '{0}://{1}'.format(ably.http.preferred_scheme, ably.http.preferred_host) + 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://rest.ably.io' == '{0}://{1}'.format(ably.http.preferred_scheme, ably.http.preferred_host) + assert 'http://main.realtime.ably.net' == f'{ably.http.preferred_scheme}://{ ably.http.preferred_host}' assert ably.http.preferred_port == 80 @dont_vary_protocol - def test_request_basic_auth_over_http_fails(self): + 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: - ably.http.get('/time', skip_auth=False) + 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 - def test_enviroment(self): - ably = AblyRest(token='token', environment='custom') - with patch.object(Session, 'prepare_request', - wraps=ably.http._Http__session.prepare_request) as get_mock: + 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: - ably.time() + await ably.time() except AblyException: pass request = get_mock.call_args_list[0][0][0] - assert request.url == 'https://custom-rest.ably.io:443/time' + assert request.url == 'https://custom.realtime.ably.net:443/time' + + await ably.close() @dont_vary_protocol def test_accepts_custom_http_timeouts(self): 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/resttoken_test.py b/test/ably/rest/resttoken_test.py similarity index 52% rename from test/ably/resttoken_test.py rename to test/ably/rest/resttoken_test.py index c16cd90b..5052f1be 100644 --- a/test/ably/resttoken_test.py +++ b/test/ably/rest/resttoken_test.py @@ -1,145 +1,146 @@ import datetime import json import logging +from unittest.mock import patch -from mock import patch import pytest -from ably import AblyException -from ably import AblyRest -from ably import Capability +from ably import AblyException, AblyRest, Capability from ably.types.tokendetails import TokenDetails from ably.types.tokenrequest import TokenRequest - -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol log = logging.getLogger(__name__) -class TestRestToken(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): +class TestRestToken(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - def server_time(self): - return self.ably.time() + async def server_time(self): + return await self.ably.time() - def setUp(self): + @pytest.fixture(autouse=True) + async def setup(self): capability = {"*": ["*"]} self.permit_all = str(Capability(capability)) - self.ably = RestSetup.get_ably_rest() + 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 - def test_request_token_null_params(self): - pre_time = self.server_time() - token_details = self.ably.auth.request_token() - post_time = self.server_time() + 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 >= pre_time, "Unexpected issued time" - assert token_details.issued <= post_time, "Unexpected issued time" + 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" - 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() + 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 >= pre_time, "Unexpected issued time" + 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" - def test_request_token_explicit_invalid_timestamp(self): - request_time = self.server_time() + 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): - self.ably.auth.request_token(token_params={'timestamp': explicit_timestamp}) + await 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() + 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" - def test_request_token_with_duplicate_nonce(self): - request_time = self.server_time() + async def test_request_token_with_duplicate_nonce(self): + request_time = await self.server_time() token_params = { 'timestamp': request_time, 'nonce': '1234567890123456' } - token_details = self.ably.auth.request_token(token_params) + token_details = await self.ably.auth.request_token(token_params) assert token_details.token is not None, "Expected token" with pytest.raises(AblyException): - self.ably.auth.request_token(token_params) + await self.ably.auth.request_token(token_params) - def test_request_token_with_capability_that_subsets_key_capability(self): + async def test_request_token_with_capability_that_subsets_key_capability(self): capability = Capability({ "onlythischannel": ["subscribe"] }) - token_details = self.ably.auth.request_token( + 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" - def test_request_token_with_specified_key(self): - key = RestSetup.get_test_vars()["keys"][1] - token_details = self.ably.auth.request_token( + 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 - def test_request_token_with_invalid_mac(self): + async def test_request_token_with_invalid_mac(self): with pytest.raises(AblyException): - self.ably.auth.request_token(token_params={'mac': "thisisnotavalidmac"}) + await 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}) + 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 - def test_token_with_excessive_ttl(self): + async def test_token_with_excessive_ttl(self): excessive_ttl = 365 * 24 * 60 * 60 * 1000 with pytest.raises(AblyException): - self.ably.auth.request_token(token_params={'ttl': excessive_ttl}) + await self.ably.auth.request_token(token_params={'ttl': excessive_ttl}) @dont_vary_protocol - def test_token_generation_with_invalid_ttl(self): + async def test_token_generation_with_invalid_ttl(self): with pytest.raises(AblyException): - self.ably.auth.request_token(token_params={'ttl': -1}) + await self.ably.auth.request_token(token_params={'ttl': -1}) - def test_token_generation_with_local_time(self): + 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: - self.ably.auth.request_token() + await self.ably.auth.request_token() assert local_time.called assert not server_time.called # RSA10k - def test_token_generation_with_server_time(self): + 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: - self.ably.auth.request_token(query_time=True) + await self.ably.auth.request_token(query_time=True) assert local_time.call_count == 1 assert server_time.call_count == 1 - self.ably.auth.request_token(query_time=True) + await self.ably.auth.request_token(query_time=True) assert local_time.call_count == 2 assert server_time.call_count == 1 # TD7 - def test_toke_details_from_json(self): - token_details = self.ably.auth.request_token() + 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) @@ -148,86 +149,97 @@ def test_toke_details_from_json(self): # Issue #71 @dont_vary_protocol - def test_request_token_float_and_timedelta(self): + async def test_request_token_float_and_timedelta(self): lifetime = datetime.timedelta(hours=4) - self.ably.auth.request_token({'ttl': lifetime.total_seconds() * 1000}) - self.ably.auth.request_token({'ttl': lifetime}) + await self.ably.auth.request_token({'ttl': lifetime.total_seconds() * 1000}) + await self.ably.auth.request_token({'ttl': lifetime}) -class TestCreateTokenRequest(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): +class TestCreateTokenRequest(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - def setUp(self): - self.ably = RestSetup.get_ably_rest() + @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 - def test_key_name_and_secret_are_required(self): - ably = RestSetup.get_ably_rest(key=None, token='not a real token') + 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"): - ably.auth.create_token_request() + await ably.auth.create_token_request() with pytest.raises(AblyException, match="40101 401 No key specified"): - ably.auth.create_token_request(key_name=self.key_name) + await ably.auth.create_token_request(key_name=self.key_name) with pytest.raises(AblyException, match="40101 401 No key specified"): - ably.auth.create_token_request(key_secret=self.key_secret) + await ably.auth.create_token_request(key_secret=self.key_secret) @dont_vary_protocol - def test_with_local_time(self): + 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: - self.ably.auth.create_token_request( + 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 - def test_with_server_time(self): + 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: - self.ably.auth.create_token_request( + 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 - self.ably.auth.create_token_request( + 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 - def test_token_request_can_be_used_to_get_a_token(self): - token_request = self.ably.auth.create_token_request( + 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) - ably = RestSetup.get_ably_rest(key=None, - auth_callback=lambda x: token_request, - use_binary_protocol=self.use_binary_protocol) + 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 = ably.auth.authorize() + token = await ably.auth.authorize() assert isinstance(token, TokenDetails) + await ably.close() - def test_token_request_dict_can_be_used_to_get_a_token(self): - token_request = self.ably.auth.create_token_request( + 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) - ably = RestSetup.get_ably_rest(key=None, - auth_callback=lambda x: token_request.to_dict(), - use_binary_protocol=self.use_binary_protocol) + 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 = ably.auth.authorize() + token = await ably.auth.authorize() assert isinstance(token, TokenDetails) + await ably.close() # TE6 @dont_vary_protocol - def test_token_request_from_json(self): - token_request = self.ably.auth.create_token_request( + 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) @@ -238,12 +250,12 @@ def test_token_request_from_json(self): assert token_request == TokenRequest.from_json(token_request_str) @dont_vary_protocol - def test_nonce_is_random_and_longer_than_15_characters(self): - token_request = self.ably.auth.create_token_request( + 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 = self.ably.auth.create_token_request( + 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 @@ -251,20 +263,20 @@ def test_nonce_is_random_and_longer_than_15_characters(self): # RSA5 @dont_vary_protocol - def test_ttl_is_optional_and_specified_in_ms(self): - token_request = self.ably.auth.create_token_request( + 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 - def test_capability_is_optional(self): - token_request = self.ably.auth.create_token_request( + 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 - def test_accept_all_token_params(self): + async def test_accept_all_token_params(self): token_params = { 'ttl': 1000, 'capability': Capability({'channel': ['publish']}), @@ -272,7 +284,7 @@ def test_accept_all_token_params(self): 'timestamp': 1000, 'nonce': 'a_nonce', } - token_request = self.ably.auth.create_token_request( + token_request = await self.ably.auth.create_token_request( token_params, key_name=self.key_name, key_secret=self.key_secret, ) @@ -282,25 +294,26 @@ def test_accept_all_token_params(self): assert token_request.timestamp == token_params['timestamp'] assert token_request.nonce == token_params['nonce'] - def test_capability(self): + async def test_capability(self): capability = Capability({'channel': ['publish']}) - token_request = self.ably.auth.create_token_request( + 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) - def auth_callback(token_params): + async def auth_callback(token_params): return token_request - ably = RestSetup.get_ably_rest(key=None, auth_callback=auth_callback, - use_binary_protocol=self.use_binary_protocol) + ably = await TestApp.get_ably_rest(key=None, auth_callback=auth_callback, + use_binary_protocol=self.use_binary_protocol) - token = ably.auth.authorize() + token = await ably.auth.authorize() assert str(token.capability) == str(capability) + await ably.close() @dont_vary_protocol - def test_hmac(self): + async def test_hmac(self): ably = AblyRest(key_name='a_key_name', key_secret='a_secret') token_params = { 'ttl': 1000, @@ -308,6 +321,19 @@ def test_hmac(self): 'client_id': 'a_id', 'timestamp': 1000, } - token_request = ably.auth.create_token_request( + 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/restauth_test.py b/test/ably/restauth_test.py deleted file mode 100644 index 9830589a..00000000 --- a/test/ably/restauth_test.py +++ /dev/null @@ -1,599 +0,0 @@ -import logging -import time -import json -import uuid -import base64 -import responses -import warnings -from urllib.parse import parse_qs, urlparse - -import mock -import pytest -from requests import Session - -import ably -from ably import AblyRest -from ably import Auth -from ably import AblyAuthException -from ably.types.tokendetails import TokenDetails - -from test.ably.restsetup import RestSetup -from test.ably.utils import BaseTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol - -test_vars = RestSetup.get_test_vars() - - -log = logging.getLogger(__name__) - - -# does not make any request, no need to vary by protocol -class TestAuth(BaseTestCase): - - def test_auth_init_key_only(self): - ably = AblyRest(key=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 == test_vars["keys"][0]['key_name'] - assert ably.auth.auth_options.key_secret == 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 - - 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 = RestSetup.get_ably_rest( - key=None, - key_name=test_vars["keys"][0]["key_name"], - auth_callback=token_callback) - - try: - 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=test_vars["keys"][0]["key_str"], client_id='testClientId') - - assert Auth.Method.TOKEN == ably.auth.auth_mechanism, "Unexpected Auth method mismatch" - assert ably.auth.client_id == 'testClientId' - - def test_auth_init_with_token(self): - ably = RestSetup.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 - def test_request_basic_auth_header(self): - ably = AblyRest(key_secret='foo', key_name='bar') - - with mock.patch.object(Session, 'prepare_request') as get_mock: - try: - 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 %s' % base64.b64encode('bar:foo'.encode('ascii')).decode('utf-8') - - def test_request_token_auth_header(self): - ably = AblyRest(token='not_a_real_token') - - with mock.patch.object(Session, 'prepare_request') as get_mock: - try: - 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 == 'Bearer %s' % base64.b64encode('not_a_real_token'.encode('ascii')).decode('utf-8') - - 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=test_vars["keys"][0]["key_str"]) - assert ably.auth.auth_mechanism == Auth.Method.TOKEN - - def test_with_client_id(self): - ably = AblyRest(client_id='client_id', key=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=test_vars["keys"][0]["key_str"], - default_token_params={'ttl': 12345}) - assert ably.auth.auth_options.default_token_params == {'ttl': 12345} - - -class TestAuthAuthorize(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - def setUp(self): - self.ably = RestSetup.get_ably_rest() - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - self.use_binary_protocol = use_binary_protocol - - def test_if_authorize_changes_auth_mechanism_to_token(self): - - assert Auth.Method.BASIC == self.ably.auth.auth_mechanism, "Unexpected Auth method mismatch" - - self.ably.auth.authorize() - - assert Auth.Method.TOKEN == self.ably.auth.auth_mechanism, "Authorise should change the Auth method" - - # RSA10a - @dont_vary_protocol - def test_authorize_always_creates_new_token(self): - self.ably.auth.authorize({'capability': {'test': ['publish']}}) - self.ably.channels.test.publish('event', 'data') - - self.ably.auth.authorize({'capability': {'test': ['subscribe']}}) - with pytest.raises(AblyAuthException): - self.ably.channels.test.publish('event', 'data') - - def test_authorize_create_new_token_if_expired(self): - token = self.ably.auth.authorize() - with mock.patch('ably.rest.auth.Auth.token_details_has_expired', - return_value=True): - new_token = self.ably.auth.authorize() - - assert token is not new_token - - def test_authorize_returns_a_token_details(self): - token = self.ably.auth.authorize() - assert isinstance(token, TokenDetails) - - @dont_vary_protocol - 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') as request_mock: - self.ably.auth.authorize(token_params, auth_params) - - token_called, auth_called = request_mock.call_args - assert token_called[0] == token_params - - # Authorise may call request_token with some default auth_options. - for arg, value in auth_params.items(): - assert auth_called[arg] == value, "%s called with wrong value: %s" % (arg, value) - - def test_with_token_str_https(self): - token = self.ably.auth.authorize() - token = token.token - ably = RestSetup.get_ably_rest(key=None, token=token, tls=True, - use_binary_protocol=self.use_binary_protocol) - ably.channels.test_auth_with_token_str.publish('event', 'foo_bar') - - def test_with_token_str_http(self): - token = self.ably.auth.authorize() - token = token.token - ably = RestSetup.get_ably_rest(key=None, token=token, tls=False, - use_binary_protocol=self.use_binary_protocol) - ably.channels.test_auth_with_token_str.publish('event', 'foo_bar') - - def test_if_default_client_id_is_used(self): - ably = RestSetup.get_ably_rest(client_id='my_client_id', - use_binary_protocol=self.use_binary_protocol) - token = ably.auth.authorize() - assert token.client_id == 'my_client_id' - - # RSA10j - 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'} - 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: - 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 - self.ably.auth.authorize({}, auth_options) - with mock.patch('ably.rest.auth.Auth.request_token', - wraps=self.ably.auth.request_token) as request_mock: - self.ably.auth.authorize() - - token_called, auth_called = request_mock.call_args - assert token_called[0] == {} - assert auth_called['auth_headers'] is None - - # RSA10g - 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 = 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 = 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 = 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 = self.ably.auth.authorize() - assert isinstance(token_4, TokenDetails) - assert token_2 != token_4 - assert tr_mock.call_args[1]['timestamp'] != timestamp - - def test_client_id_precedence(self): - client_id = uuid.uuid4().hex - overridden_client_id = uuid.uuid4().hex - ably = RestSetup.get_ably_rest( - use_binary_protocol=self.use_binary_protocol, - client_id=client_id, - default_token_params={'client_id': overridden_client_id}) - token = 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')] - channel.publish('test', 'data') - assert channel.history().items[0].client_id == client_id - - # RSA10l - @dont_vary_protocol - def test_authorise(self): - with warnings.catch_warnings(record=True) as ws: - # Cause all warnings to always be triggered - warnings.simplefilter("always") - - token = self.ably.auth.authorise() - assert isinstance(token, TokenDetails) - - # Verify warning is raised - ws = [w for w in ws if issubclass(w.category, DeprecationWarning)] - assert len(ws) == 1 - - -class TestRequestToken(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - def per_protocol_setup(self, use_binary_protocol): - self.use_binary_protocol = use_binary_protocol - - def test_with_key(self): - self.ably = RestSetup.get_ably_rest(use_binary_protocol=self.use_binary_protocol) - - token_details = self.ably.auth.request_token() - assert isinstance(token_details, TokenDetails) - - ably = RestSetup.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') - - ably.channels[channel].publish('event', 'foo') - - assert ably.channels[channel].history().items[0].data == 'foo' - - @dont_vary_protocol - @responses.activate - def test_with_auth_url_headers_and_params_POST(self): - url = 'http://www.example.com' - headers = {'foo': 'bar'} - self.ably = RestSetup.get_ably_rest(key=None, auth_url=url) - - auth_params = {'foo': 'auth', 'spam': 'eggs'} - token_params = {'foo': 'token'} - - responses.add(responses.POST, url, body='token_string') - token_details = self.ably.auth.request_token( - token_params=token_params, auth_url=url, auth_headers=headers, - auth_method='POST', auth_params=auth_params) - - assert isinstance(token_details, TokenDetails) - assert len(responses.calls) == 1 - request = responses.calls[0].request - assert request.headers['content-type'] == 'application/x-www-form-urlencoded' - assert headers['foo'] == request.headers['foo'] - assert urlparse(request.url).query == '' # No querystring! - assert parse_qs(request.body) == {'foo': ['token'], 'spam': ['eggs']} # TokenParams has precedence - assert 'token_string' == token_details.token - - @dont_vary_protocol - @responses.activate - def test_with_auth_url_headers_and_params_GET(self): - - url = 'http://www.example.com' - headers = {'foo': 'bar'} - self.ably = RestSetup.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'} - - responses.add(responses.GET, url, json={'issued': 1, 'token': - 'another_token_string'}) - token_details = self.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 - request = responses.calls[0].request - assert request.headers['foo'] == 'bar' - assert 'this' not in request.headers - assert parse_qs(urlparse(request.url).query) == {'foo': ['token'], 'spam': ['eggs']} - assert not request.body - - @dont_vary_protocol - def test_with_callback(self): - called_token_params = {'ttl': '3600000'} - def callback(token_params): - assert token_params == called_token_params - return 'token_string' - - self.ably = RestSetup.get_ably_rest(key=None, auth_callback=callback) - - token_details = self.ably.auth.request_token( - token_params=called_token_params, auth_callback=callback) - assert isinstance(token_details, TokenDetails) - assert 'token_string' == token_details.token - - def callback(token_params): - assert token_params == called_token_params - return TokenDetails(token='another_token_string') - - token_details = self.ably.auth.request_token( - token_params=called_token_params, auth_callback=callback) - assert 'another_token_string' == token_details.token - - @dont_vary_protocol - @responses.activate - def test_when_auth_url_has_query_string(self): - url = 'http://www.example.com?with=query' - headers = {'foo': 'bar'} - self.ably = RestSetup.get_ably_rest(key=None, auth_url=url) - - responses.add(responses.GET, 'http://www.example.com', - body='token_string') - self.ably.auth.request_token(auth_url=url, - auth_headers=headers, - auth_params={'spam': 'eggs'}) - assert responses.calls[0].request.url.endswith('?with=query&spam=eggs') - - @dont_vary_protocol - def test_client_id_null_for_anonymous_auth(self): - ably = RestSetup.get_ably_rest( - key=None, - key_name=test_vars["keys"][0]["key_name"], - key_secret=test_vars["keys"][0]["key_secret"]) - token = ably.auth.authorize() - - assert isinstance(token, TokenDetails) - assert token.client_id is None - assert ably.auth.client_id is None - - @dont_vary_protocol - def test_client_id_null_until_auth(self): - client_id = uuid.uuid4().hex - token_ably = RestSetup.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 = 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 - - -class TestRenewToken(BaseTestCase): - - def setUp(self): - host = test_vars['host'] - self.ably = RestSetup.get_ably_rest(use_binary_protocol=False) - # with headers - self.token_requests = 0 - self.publish_attempts = 0 - self.tokens = ['a_token', 'another_token'] - self.channel = uuid.uuid4().hex - - def call_back(request): - headers = {'Content-Type': 'application/json'} - body = {} - self.token_requests += 1 - body['token'] = self.tokens[self.token_requests - 1] - body['expires'] = (time.time() + 60) * 1000 - return (200, headers, json.dumps(body)) - - responses.add_callback( - responses.POST, - 'https://{}:443/keys/{}/requestToken'.format( - host, test_vars["keys"][0]['key_name']), - call_back) - - def call_back(request): - headers = {'Content-Type': 'application/json'} - self.publish_attempts += 1 - if self.publish_attempts in [1, 3]: - body = '[]' - status = 201 - else: - body = {'error': {'message': 'Authentication failure', 'statusCode': 401, 'code': 40140}} - status = 401 - - return (status, headers, json.dumps(body)) - - responses.add_callback( - responses.POST, - 'https://{}:443/channels/{}/messages'.format(host, self.channel), - call_back) - responses.start() - - def tearDown(self): - responses.stop() - responses.reset() - - # RSA4b - def test_when_renewable(self): - self.ably.auth.authorize() - self.ably.channels[self.channel].publish('evt', 'msg') - assert 1 == self.token_requests - assert 1 == self.publish_attempts - - # Triggers an authentication 401 failure which should automatically request a new token - self.ably.channels[self.channel].publish('evt', 'msg') - assert 2 == self.token_requests - assert 3 == self.publish_attempts - - # RSA4a - def test_when_not_renewable(self): - self.ably = RestSetup.get_ably_rest( - key=None, - token='token ID cannot be used to create a new token', - use_binary_protocol=False) - self.ably.channels[self.channel].publish('evt', 'msg') - assert 1 == self.publish_attempts - - publish = self.ably.channels[self.channel].publish - - match = "The provided token is not renewable and there is no means to generate a new token" - with pytest.raises(AblyAuthException, match=match): - publish('evt', 'msg') - - assert 0 == self.token_requests - - # RSA4a - def test_when_not_renewable_with_token_details(self): - token_details = TokenDetails(token='a_dummy_token') - self.ably = RestSetup.get_ably_rest( - key=None, - token_details=token_details, - use_binary_protocol=False) - self.ably.channels[self.channel].publish('evt', 'msg') - assert 1 == self.publish_attempts - - publish = self.ably.channels[self.channel].publish - - match = "The provided token is not renewable and there is no means to generate a new token" - with pytest.raises(AblyAuthException, match=match): - publish('evt', 'msg') - - assert 0 == self.token_requests - - -class TestRenewExpiredToken(BaseTestCase): - - def setUp(self): - self.publish_attempts = 0 - self.channel = uuid.uuid4().hex - - host = test_vars['host'] - key = test_vars["keys"][0]['key_name'] - base_url = 'https://{}:443'.format(host) - headers = {'Content-Type': 'application/json'} - - def cb_request_token(request): - body = { - 'token': 'a_token', - 'expires': int(time.time() * 1000), # Always expires - } - return (200, headers, json.dumps(body)) - - def cb_publish(request): - self.publish_attempts += 1 - if self.publish_fail: - self.publish_fail = False - body = {'error': {'message': 'Authentication failure', 'statusCode': 401, 'code': 40140}} - status = 401 - else: - body = '[]' - status = 201 - - return (status, headers, json.dumps(body)) - - def cb_time(request): - body = [int(time.time() * 1000)] - return (200, headers, json.dumps(body)) - - add_callback = responses.add_callback - add_callback(responses.POST, '{}/keys/{}/requestToken'.format(base_url, key), cb_request_token) - add_callback(responses.POST, '{}/channels/{}/messages'.format(base_url, self.channel), cb_publish) - add_callback(responses.GET, '{}/time'.format(base_url), cb_time) - - responses.start() - - def tearDown(self): - responses.stop() - responses.reset() - - # RSA4b1 - def test_query_time_false(self): - ably = RestSetup.get_ably_rest() - ably.auth.authorize() - self.publish_fail = True - ably.channels[self.channel].publish('evt', 'msg') - assert self.publish_attempts == 2 - - # RSA4b1 - def test_query_time_true(self): - ably = RestSetup.get_ably_rest(query_time=True) - ably.auth.authorize() - self.publish_fail = False - ably.channels[self.channel].publish('evt', 'msg') - assert self.publish_attempts == 1 diff --git a/test/ably/restchannelhistory_test.py b/test/ably/restchannelhistory_test.py deleted file mode 100644 index 43ce3c77..00000000 --- a/test/ably/restchannelhistory_test.py +++ /dev/null @@ -1,328 +0,0 @@ -import logging - -import pytest -import responses - -from ably import AblyException -from ably.http.paginatedresult import PaginatedResult - -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase - -test_vars = RestSetup.get_test_vars() -log = logging.getLogger(__name__) - - -class TestRestChannelHistory(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - - def test_channel_history_types(self): - history0 = self.get_channel('persisted:channelhistory_types') - - history0.publish('history0', 'This is a string message payload') - history0.publish('history1', b'This is a byte[] message payload') - history0.publish('history2', {'test': 'This is a JSONObject message payload'}) - history0.publish('history3', ['This is a JSONArray message payload']) - - history = 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" - - def test_channel_history_multi_50_forwards(self): - history0 = self.get_channel('persisted:channelhistory_multi_50_f') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(50)] - assert messages == expected_messages, 'Expect messages in forward order' - - def test_channel_history_multi_50_backwards(self): - history0 = self.get_channel('persisted:channelhistory_multi_50_b') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % 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 test_vars['tls'] else 'http', - 'host': test_vars['host'], - 'channel_name': channel_name - } - port = test_vars['tls_port'] if 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) - - @responses.activate - @dont_vary_protocol - 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.responses_add_empty_msg_pack(url) - channel.history() - assert 'limit=' not in responses.calls[0].request.url.split('?')[-1] - - @responses.activate - @dont_vary_protocol - 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.responses_add_empty_msg_pack(url) - channel.history(limit=500) - assert 'limit=500' in responses.calls[0].request.url.split('?')[-1] - channel.history(limit=1000) - assert 'limit=1000' in responses.calls[1].request.url.split('?')[-1] - - @dont_vary_protocol - def test_channel_history_max_limit_is_1000(self): - channel = self.ably.channels['persisted:channelhistory_limit'] - with pytest.raises(AblyException): - channel.history(limit=1001) - - def test_channel_history_limit_forwards(self): - history0 = self.get_channel('persisted:channelhistory_limit_f') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(25)] - assert messages == expected_messages, 'Expect messages in forward order' - - def test_channel_history_limit_backwards(self): - history0 = self.get_channel('persisted:channelhistory_limit_b') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(49, 24, -1)] - assert messages == expected_messages, 'Expect messages in forward order' - - def test_channel_history_time_forwards(self): - history0 = self.get_channel('persisted:channelhistory_time_f') - - for i in range(20): - history0.publish('history%d' % i, str(i)) - - interval_start = self.ably.time() - - for i in range(20, 40): - history0.publish('history%d' % i, str(i)) - - interval_end = self.ably.time() - - for i in range(40, 60): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(20, 40)] - assert expected_messages == messages, 'Expect messages in forward order' - - def test_channel_history_time_backwards(self): - history0 = self.get_channel('persisted:channelhistory_time_b') - - for i in range(20): - history0.publish('history%d' % i, str(i)) - - interval_start = self.ably.time() - - for i in range(20, 40): - history0.publish('history%d' % i, str(i)) - - interval_end = self.ably.time() - - for i in range(40, 60): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(39, 19, -1)] - assert expected_messages, messages == 'Expect messages in reverse order' - - def test_channel_history_paginate_forwards(self): - history0 = self.get_channel('persisted:channelhistory_paginate_f') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(0, 10)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.next() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.next() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - def test_channel_history_paginate_backwards(self): - history0 = self.get_channel('persisted:channelhistory_paginate_b') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(49, 39, -1)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.next() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.next() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - def test_channel_history_paginate_forwards_first(self): - history0 = self.get_channel('persisted:channelhistory_paginate_first_f') - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(0, 10)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.next() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.first() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - def test_channel_history_paginate_backwards_rel_first(self): - history0 = self.get_channel('persisted:channelhistory_paginate_first_b') - - for i in range(50): - history0.publish('history%d' % i, str(i)) - - history = 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['history%d' % i] for i in range(49, 39, -1)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.next() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' - - history = history.first() - messages = history.items - assert 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)] - assert expected_messages == messages, 'Expected 10 messages' diff --git a/test/ably/resthttp_test.py b/test/ably/resthttp_test.py deleted file mode 100644 index 73c55595..00000000 --- a/test/ably/resthttp_test.py +++ /dev/null @@ -1,198 +0,0 @@ -import re -import time - -import mock -import pytest -import requests -from urllib.parse import urljoin, urlparse - -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.restsetup import RestSetup -from test.ably.utils import BaseTestCase - - -class TestRestHttp(BaseTestCase): - 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('requests.sessions.Session.send', - side_effect=requests.exceptions.RequestException) as send_mock: - with pytest.raises(requests.exceptions.RequestException): - ably.http.make_request('GET', '/', skip_auth=True) - - assert send_mock.call_count == Defaults.http_max_retry_count - timeout = ( - ably.http.CONNECTION_RETRY_DEFAULTS['http_open_timeout'], - ably.http.CONNECTION_RETRY_DEFAULTS['http_request_timeout'], - ) - assert send_mock.call_args == mock.call(mock.ANY, timeout=timeout) - - 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 requests.exceptions.RequestException - - with mock.patch('requests.sessions.Session.send', - side_effect=sleep_and_raise) as send_mock: - with pytest.raises(requests.exceptions.RequestException): - ably.http.make_request('GET', '/', skip_auth=True) - - assert send_mock.call_count == 1 - - def test_host_fallback(self): - ably = AblyRest(token="foo") - - def make_url(host): - base_url = "%s://%s:%d" % (ably.http.preferred_scheme, - host, - ably.http.preferred_port) - return urljoin(base_url, '/') - - with mock.patch('requests.Request', wraps=requests.Request) as request_mock: - with mock.patch('requests.sessions.Session.send', - side_effect=requests.exceptions.RequestException) as send_mock: - with pytest.raises(requests.exceptions.RequestException): - 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_rest_hosts() - } - for ((_, url), _) in request_mock.call_args_list: - assert url in expected_urls_set - expected_urls_set.remove(url) - - def test_no_host_fallback_nor_retries_if_custom_host(self): - custom_host = 'example.org' - ably = AblyRest(token="foo", rest_host=custom_host) - - custom_url = "%s://%s:%d/" % ( - ably.http.preferred_scheme, - custom_host, - ably.http.preferred_port) - - with mock.patch('requests.Request', wraps=requests.Request) as request_mock: - with mock.patch('requests.sessions.Session.send', - side_effect=requests.exceptions.RequestException) as send_mock: - with pytest.raises(requests.exceptions.RequestException): - ably.http.make_request('GET', '/', skip_auth=True) - - assert send_mock.call_count == 1 - assert request_mock.call_args == mock.call(mock.ANY, custom_url, data=mock.ANY, headers=mock.ANY) - - # RSC15f - def test_cached_fallback(self): - timeout = 2000 - ably = RestSetup.get_ably_rest(fallback_hosts_use_default=True, fallback_retry_timeout=timeout) - host = ably.options.get_rest_host() - - state = {'errors': 0} - send = requests.sessions.Session.send - def side_effect(self, prepped, *args, **kwargs): - if urlparse(prepped.url).hostname == host: - state['errors'] += 1 - raise RuntimeError - return send(self, prepped, *args, **kwargs) - - with mock.patch('requests.sessions.Session.send', side_effect=side_effect, autospec=True): - # The main host is called and there's an error - ably.time() - assert state['errors'] == 1 - - # The cached host is used: no error - ably.time() - ably.time() - ably.time() - assert state['errors'] == 1 - - # The cached host has expired, we've an error again - time.sleep(timeout / 1000.0) - ably.time() - assert state['errors'] == 2 - - def test_no_retry_if_not_500_to_599_http_code(self): - default_host = Options().get_rest_host() - ably = AblyRest(token="foo") - - default_url = "%s://%s:%d/" % ( - ably.http.preferred_scheme, - default_host, - ably.http.preferred_port) - - def raise_ably_exception(*args, **kwagrs): - raise AblyException(message="", status_code=600, code=50500) - - with mock.patch('requests.Request', wraps=requests.Request) as request_mock: - with mock.patch('ably.util.exceptions.AblyException.raise_for_response', - side_effect=raise_ably_exception) as send_mock: - with pytest.raises(AblyException): - ably.http.make_request('GET', '/', skip_auth=True) - - assert send_mock.call_count == 1 - assert request_mock.call_args == mock.call(mock.ANY, default_url, data=mock.ANY, headers=mock.ANY) - - def test_500_errors(self): - """ - Raise error if all the servers reply with a 5xx error. - https://github.com/ably/ably-python/issues/160 - """ - default_host = Options().get_rest_host() - ably = AblyRest(token="foo") - - default_url = "%s://%s:%d/" % ( - ably.http.preferred_scheme, - default_host, - ably.http.preferred_port) - - def raise_ably_exception(*args, **kwagrs): - raise AblyException(message="", status_code=500, code=50000) - - with mock.patch('requests.Request', wraps=requests.Request) as request_mock: - with mock.patch('ably.util.exceptions.AblyException.raise_for_response', - side_effect=raise_ably_exception) as send_mock: - with pytest.raises(AblyException): - ably.http.make_request('GET', '/', skip_auth=True) - - assert send_mock.call_count == 3 - - 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 - def test_request_headers(self): - ably = RestSetup.get_ably_rest() - r = 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'] == '1.1' - - # Lib - assert 'X-Ably-Lib' in r.request.headers - expr = r"^python-1\.1\.\d+(-\w+)?$" - assert re.search(expr, r.request.headers['X-Ably-Lib']) - - # Lib Variant - ably.set_variant('django') - r = ably.http.make_request('HEAD', '/time', skip_auth=True) - expr = r"^python.django-1\.1\.\d+(-\w+)?$" - assert re.search(expr, r.request.headers['X-Ably-Lib']) diff --git a/test/ably/restpaginatedresult_test.py b/test/ably/restpaginatedresult_test.py deleted file mode 100644 index be981248..00000000 --- a/test/ably/restpaginatedresult_test.py +++ /dev/null @@ -1,82 +0,0 @@ -import re - -import responses - -from ably.http.paginatedresult import PaginatedResult - -from test.ably.restsetup import RestSetup -from test.ably.utils import BaseTestCase - - -class TestPaginatedResult(BaseTestCase): - - def get_response_callback(self, headers, body, status): - def callback(request): - res = re.search(r'page=(\d+)', request.url) - if res: - return (status, headers, '[{"page": %i}]' % int(res.group(1))) - return (status, headers, body) - - return callback - - def setUp(self): - self.ably = RestSetup.get_ably_rest(use_binary_protocol=False) - - # Mocked responses - # without headers - responses.add(responses.GET, - 'http://rest.ably.io/channels/channel_name/ch1', - body='[{"id": 0}, {"id": 1}]', status=200, - content_type='application/json') - # with headers - responses.add_callback( - responses.GET, - 'http://rest.ably.io/channels/channel_name/ch2', - self.get_response_callback( - headers={ - 'link': - '; rel="first",' - ' ; rel="next"' - }, - body='[{"id": 0}, {"id": 1}]', - status=200), - content_type='application/json') - - # start intercepting requests - responses.start() - - self.paginated_result = PaginatedResult.paginated_query( - self.ably.http, - url='http://rest.ably.io/channels/channel_name/ch1', - response_processor=lambda response: response.to_native()) - self.paginated_result_with_headers = PaginatedResult.paginated_query( - self.ably.http, - url='http://rest.ably.io/channels/channel_name/ch2', - response_processor=lambda response: response.to_native()) - - def tearDown(self): - responses.stop() - responses.reset() - - def test_items(self): - assert len(self.paginated_result.items) == 2 - - def test_with_no_headers(self): - assert self.paginated_result.first() is None - assert 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() - - def test_first(self): - pag = self.paginated_result_with_headers - pag = pag.first() - assert pag.items[0]['page'] == 1 - - def test_next(self): - pag = self.paginated_result_with_headers - pag = pag.next() - assert pag.items[0]['page'] == 2 diff --git a/test/ably/restpresence_test.py b/test/ably/restpresence_test.py deleted file mode 100644 index 4e020205..00000000 --- a/test/ably/restpresence_test.py +++ /dev/null @@ -1,218 +0,0 @@ -from datetime import datetime, timedelta - -import pytest -import responses - -from ably.http.paginatedresult import PaginatedResult -from ably.types.presence import PresenceMessage - -from test.ably.utils import dont_vary_protocol, VaryByProtocolTestsMetaclass, BaseTestCase -from test.ably.restsetup import RestSetup - -test_vars = RestSetup.get_test_vars() - - -class TestPresence(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - cls.channel = cls.ably.channels.get('persisted:presence_fixtures') - - @classmethod - def tearDownClass(cls): - cls.ably.channels.release('persisted:presence_fixtures') - - def setUp(self): - self.ably.options.use_binary_protocol = True - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - - def test_channel_presence_get(self): - presence_page = 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 - - def test_channel_presence_history(self): - presence_history = 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 - - def test_presence_get_encoded(self): - presence_history = 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"}} - - def test_timestamp_is_datetime(self): - presence_page = self.channel.presence.get() - member = presence_page.items[0] - assert isinstance(member.timestamp, datetime) - - def test_presence_message_has_correct_member_key(self): - presence_page = self.channel.presence.get() - member = presence_page.items[0] - - assert member.member_key == "%s:%s" % (member.connection_id, member.client_id) - - def presence_mock_url(self): - kwargs = { - 'scheme': 'https' if test_vars['tls'] else 'http', - 'host': test_vars['host'] - } - port = test_vars['tls_port'] if 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 test_vars['tls'] else 'http', - 'host': test_vars['host'] - } - port = test_vars['tls_port'] if 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 - @responses.activate - def test_get_presence_default_limit(self): - url = self.presence_mock_url() - self.responses_add_empty_msg_pack(url) - self.channel.presence.get() - assert 'limit=' not in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - def test_get_presence_with_limit(self): - url = self.presence_mock_url() - self.responses_add_empty_msg_pack(url) - self.channel.presence.get(300) - assert 'limit=300' in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - def test_get_presence_max_limit_is_1000(self): - url = self.presence_mock_url() - self.responses_add_empty_msg_pack(url) - with pytest.raises(ValueError): - self.channel.presence.get(5000) - - @dont_vary_protocol - @responses.activate - def test_history_default_limit(self): - url = self.history_mock_url() - self.responses_add_empty_msg_pack(url) - self.channel.presence.history() - assert 'limit=' not in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - def test_history_with_limit(self): - url = self.history_mock_url() - self.responses_add_empty_msg_pack(url) - self.channel.presence.history(300) - assert 'limit=300' in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - def test_history_with_direction(self): - url = self.history_mock_url() - self.responses_add_empty_msg_pack(url) - self.channel.presence.history(direction='backwards') - assert 'direction=backwards' in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - def test_history_max_limit_is_1000(self): - url = self.history_mock_url() - self.responses_add_empty_msg_pack(url) - with pytest.raises(ValueError): - self.channel.presence.history(5000) - - @dont_vary_protocol - @responses.activate - def test_with_milisecond_start_end(self): - url = self.history_mock_url() - self.responses_add_empty_msg_pack(url) - self.channel.presence.history(start=100000, end=100001) - assert 'start=100000' in responses.calls[0].request.url.split('?')[-1] - assert 'end=100001' in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - 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.responses_add_empty_msg_pack(url) - self.channel.presence.history(start=start, end=end) - assert 'start=' + str(start_ms) in responses.calls[0].request.url.split('?')[-1] - assert 'end=' + str(end_ms) in responses.calls[0].request.url.split('?')[-1] - - @dont_vary_protocol - @responses.activate - 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.responses_add_empty_msg_pack(url) - with pytest.raises(ValueError, match="'end' parameter has to be greater than or equal to 'start'"): - self.channel.presence.history(start=start, end=end) - - -class TestPresenceCrypt(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - key = b'0123456789abcdef' - cls.channel = cls.ably.channels.get('persisted:presence_fixtures', cipher={'key': key}) - - @classmethod - def tearDownClass(cls): - cls.ably.channels.release('persisted:presence_fixtures') - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - - def test_presence_history_encrypted(self): - presence_history = self.channel.presence.history() - assert presence_history.items[0].data == {'foo': 'bar'} - - def test_presence_get_encrypted(self): - messages = 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/restpush_test.py b/test/ably/restpush_test.py deleted file mode 100644 index b9786a01..00000000 --- a/test/ably/restpush_test.py +++ /dev/null @@ -1,360 +0,0 @@ -import itertools -import random -import string -import time - -import pytest - -from ably import AblyException, AblyAuthException -from ably import DeviceDetails, PushChannelSubscription -from ably.http.paginatedresult import PaginatedResult - -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, BaseTestCase -from test.ably.utils import new_dict, random_string, get_random_key - - -DEVICE_TOKEN = '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad' - - -class TestPush(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - - # Register several devices for later use - cls.devices = {} - for i in range(10): - cls.save_device() - - # Register several subscriptions for later use - cls.channels = {'canpublish:test1': [], 'canpublish:test2': [], 'canpublish:test3': []} - for key, channel in zip(cls.devices, itertools.cycle(cls.channels)): - device = cls.devices[key] - cls.save_subscription(channel, device_id=device.id) - assert len(list(itertools.chain(*cls.channels.values()))) == len(cls.devices) - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - - @classmethod - def get_client_id(cls): - return random_string(12) - - @classmethod - def get_device_id(cls): - return random_string(26, string.ascii_uppercase + string.digits) - - @classmethod - def gen_device_data(cls, data=None, **kw): - if data is None: - data = { - 'id': cls.get_device_id(), - 'clientId': cls.get_client_id(), - 'platform': random.choice(['android', 'ios']), - 'formFactor': 'phone', - 'push': { - 'recipient': { - 'transportType': 'apns', - 'deviceToken': DEVICE_TOKEN, - } - }, - } - else: - data = data.copy() - - data.update(kw) - return data - - @classmethod - def save_device(cls, 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 = cls.gen_device_data(data, **kw) - device = cls.ably.push.admin.device_registrations.save(data) - cls.devices[device.id] = device - return device - - @classmethod - def remove_device(cls, device_id): - result = cls.ably.push.admin.device_registrations.remove(device_id) - cls.devices.pop(device_id, None) - return result - - @classmethod - def remove_device_where(cls, **kw): - remove_where = cls.ably.push.admin.device_registrations.remove_where - result = remove_where(**kw) - - aux = {'deviceId': 'id', 'clientId': 'client_id'} - for device in list(cls.devices.values()): - for key, value in kw.items(): - key = aux[key] - if getattr(device, key) == value: - del cls.devices[device.id] - - return result - - @classmethod - def get_device(cls): - key = get_random_key(cls.devices) - return cls.devices[key] - - @classmethod - def get_channel(cls): - key = get_random_key(cls.channels) - return key, cls.channels[key] - - @classmethod - def save_subscription(cls, 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 = cls.ably.push.admin.channel_subscriptions.save(subscription) - cls.channels.setdefault(channel, []).append(subscription) - return subscription - - # RSH1a - def test_admin_publish(self): - recipient = {'clientId': 'ablyChannel'} - data = { - 'data': {'foo': 'bar'}, - } - - publish = self.ably.push.admin.publish - with pytest.raises(TypeError): - publish('ablyChannel', data) - with pytest.raises(TypeError): - publish(recipient, 25) - with pytest.raises(ValueError): - publish({}, data) - with pytest.raises(ValueError): - publish(recipient, {}) - - with pytest.raises(AblyException): - publish(recipient, {'xxx': 5}) - - assert publish(recipient, data) is None - - # RSH1b1 - def test_admin_device_registrations_get(self): - get = self.ably.push.admin.device_registrations.get - - # Not found - with pytest.raises(AblyException): - get('not-found') - - # Found - device = self.get_device() - device_details = get(device.id) - assert device_details.id == device.id - assert device_details.platform == device.platform - assert device_details.form_factor == device.form_factor - - # RSH1b2 - def test_admin_device_registrations_list(self): - list_devices = self.ably.push.admin.device_registrations.list - - response = list_devices() - assert type(response) is PaginatedResult - assert type(response.items) is list - assert type(response.items[0]) is DeviceDetails - - # limit - assert len(list_devices(limit=5000).items) == len(self.devices) - assert len(list_devices(limit=2).items) == 2 - - # Filter by device id - device = self.get_device() - assert len(list_devices(deviceId=device.id).items) == 1 - assert len(list_devices(deviceId=self.get_device_id()).items) == 0 - - # Filter by client id - assert len(list_devices(clientId=device.client_id).items) == 1 - assert len(list_devices(clientId=self.get_client_id()).items) == 0 - - # RSH1b3 - def test_admin_device_registrations_save(self): - # Create - data = self.gen_device_data() - device = self.save_device(data) - assert type(device) is DeviceDetails - - # Update - self.save_device(data, formFactor='tablet') - - # Invalid values - with pytest.raises(ValueError): - push = {'recipient': new_dict(data['push']['recipient'], transportType='xyz')} - self.save_device(data, push=push) - with pytest.raises(ValueError): - self.save_device(data, platform='native') - with pytest.raises(ValueError): - self.save_device(data, formFactor='fridge') - - # Fail - with pytest.raises(AblyException): - self.save_device(data, push={'color': 'red'}) - - # RSH1b4 - def test_admin_device_registrations_remove(self): - get = self.ably.push.admin.device_registrations.get - - device = self.get_device() - - # Remove - assert get(device.id).id == device.id # Exists - assert self.remove_device(device.id).status_code == 204 - with pytest.raises(AblyException): # Doesn't exist - get(device.id) - - # Remove again, it doesn't fail - assert self.remove_device(device.id).status_code == 204 - - # RSH1b5 - def test_admin_device_registrations_remove_where(self): - get = self.ably.push.admin.device_registrations.get - - # Remove by device id - device = self.get_device() - assert get(device.id).id == device.id # Exists - assert self.remove_device_where(deviceId=device.id).status_code == 204 - with pytest.raises(AblyException): # Doesn't exist - get(device.id) - - # Remove by client id - device = self.get_device() - assert get(device.id).id == device.id # Exists - assert self.remove_device_where(clientId=device.client_id).status_code == 204 - # Doesn't exist (Deletion is async: wait up to a few seconds before giving up) - with pytest.raises(AblyException): - for i in range(5): - time.sleep(1) - get(device.id) - - # Remove with no matching params - assert self.remove_device_where(clientId=device.client_id).status_code == 204 - - # RSH1c1 - def test_admin_channel_subscriptions_list(self): - list_ = self.ably.push.admin.channel_subscriptions.list - - channel, subscriptions = self.get_channel() - - response = list_(channel=channel) - assert type(response) is PaginatedResult - assert type(response.items) is list - assert type(response.items[0]) is PushChannelSubscription - - # limit - assert len(list_(channel=channel, limit=5000).items) == len(subscriptions) - assert len(list_(channel=channel, limit=2).items) == 2 - - # Filter by device id - device_id = subscriptions[0].device_id - items = list_(channel=channel, deviceId=device_id).items - assert len(items) == 1 - assert items[0].device_id == device_id - assert items[0].channel == channel - - assert len(list_(channel=channel, deviceId=self.get_device_id()).items) == 0 - - # Filter by client id - device = self.get_device() - assert len(list_(channel=channel, clientId=device.client_id).items) == 0 - - # RSH1c2 - def test_admin_channels_list(self): - list_ = self.ably.push.admin.channel_subscriptions.list_channels - - response = list_() - assert type(response) is PaginatedResult - assert type(response.items) is list - assert type(response.items[0]) is str - - # limit - assert len(list_(limit=5000).items) == len(self.channels) - assert len(list_(limit=1).items) == 1 - - # RSH1c3 - def test_admin_channel_subscriptions_save(self): - save = self.ably.push.admin.channel_subscriptions.save - - # Subscribe - device = self.get_device() - channel = 'canpublish:testsave' - subscription = 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): - save(subscription) - - subscription = PushChannelSubscription(channel, device_id='notregistered') - with pytest.raises(AblyException): - save(subscription) - - # RSH1c4 - 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 = save(PushChannelSubscription(channel, device_id=device.id)) - assert device.id in (x.device_id for x in list_(channel=channel).items) - assert remove(subscription).status_code == 204 - assert device.id not in (x.device_id for x in list_(channel=channel).items) - - # Subscribe client - client_id = self.get_client_id() - subscription = save(PushChannelSubscription(channel, client_id=client_id)) - assert client_id in (x.client_id for x in list_(channel=channel).items) - assert remove(subscription).status_code == 204 - assert client_id not in (x.client_id for x in list_(channel=channel).items) - - # Remove again, it doesn't fail - assert remove(subscription).status_code == 204 - - # RSH1c5 - 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() - save(PushChannelSubscription(channel, device_id=device.id)) - assert device.id in (x.device_id for x in list_(channel=channel).items) - assert remove(channel=channel, device_id=device.id).status_code == 204 - assert device.id not in (x.device_id for x in list_(channel=channel).items) - - # Subscribe client - client_id = self.get_client_id() - save(PushChannelSubscription(channel, client_id=client_id)) - assert client_id in (x.client_id for x in list_(channel=channel).items) - assert remove(channel=channel, client_id=client_id).status_code == 204 - assert client_id not in (x.client_id for x in list_(channel=channel).items) - - # Remove again, it doesn't fail - assert remove(channel=channel, client_id=client_id).status_code == 204 diff --git a/test/ably/restrequest_test.py b/test/ably/restrequest_test.py deleted file mode 100644 index 5c2d2872..00000000 --- a/test/ably/restrequest_test.py +++ /dev/null @@ -1,119 +0,0 @@ -import pytest -import requests - -from ably import AblyRest -from ably.http.paginatedresult import HttpPaginatedResponse -from test.ably.restsetup import RestSetup -from test.ably.utils import BaseTestCase -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol - -test_vars = RestSetup.get_test_vars() - - -# RSC19 -class TestRestRequest(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def setUpClass(cls): - cls.ably = RestSetup.get_ably_rest() - - # Populate the channel (using the new api) - cls.channel = cls.get_channel_name() - cls.path = '/channels/%s/messages' % cls.channel - for i in range(20): - body = {'name': 'event%s' % i, 'data': 'lorem ipsum %s' % i} - cls.ably.request('POST', cls.path, body=body) - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - self.use_binary_protocol = use_binary_protocol - - def test_post(self): - body = {'name': 'test-post', 'data': 'lorem ipsum'} - result = self.ably.request('POST', self.path, body=body) - - 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] - - def test_get(self): - params = {'limit': 10, 'direction': 'forwards'} - result = self.ably.request('GET', self.path, params=params) - - assert isinstance(result, HttpPaginatedResponse) # RSC19d - - # HP2 - assert isinstance(result.next(), HttpPaginatedResponse) - assert isinstance(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 - def test_not_found(self): - result = self.ably.request('GET', '/not-found') - assert isinstance(result, HttpPaginatedResponse) # RSC19d - assert result.status_code == 404 # HP4 - assert result.success is False # HP5 - - @dont_vary_protocol - def test_error(self): - params = {'limit': 'abc'} - result = self.ably.request('GET', self.path, params=params) - assert isinstance(result, HttpPaginatedResponse) # RSC19d - assert result.status_code == 400 # HP4 - assert not result.success - assert result.error_code - assert result.error_message - - def test_headers(self): - key = 'X-Test' - value = 'lorem ipsum' - result = self.ably.request('GET', '/time', headers={key: value}) - assert result.response.request.headers[key] == value - - # RSC19e - @dont_vary_protocol - 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(requests.exceptions.ReadTimeout): - ably.request('GET', '/time') - - # Bad host, use fallback - ably = AblyRest(key=test_vars["keys"][0]["key_str"], - rest_host='some.other.host', - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"], - fallback_hosts_use_default=True) - result = ably.request('GET', '/time') - assert isinstance(result, HttpPaginatedResponse) - assert len(result.items) == 1 - assert isinstance(result.items[0], int) - - # Bad host, no Fallback - ably = AblyRest(key=test_vars["keys"][0]["key_str"], - rest_host='some.other.host', - port=test_vars["port"], - tls_port=test_vars["tls_port"], - tls=test_vars["tls"]) - with pytest.raises(requests.exceptions.ConnectionError): - ably.request('GET', '/time') diff --git a/test/ably/restsetup.py b/test/ably/restsetup.py deleted file mode 100644 index b783f0ee..00000000 --- a/test/ably/restsetup.py +++ /dev/null @@ -1,92 +0,0 @@ -import json -import os -import logging - -from ably.rest.rest import AblyRest -from ably.types.capability import Capability -from ably.types.options import Options -from ably.util.exceptions import AblyException - -log = logging.getLogger(__name__) - -app_spec_local = None -with open(os.path.dirname(__file__) + '/../assets/testAppSpec.json', 'r') as f: - app_spec_local = json.loads(f.read()) - -tls = (os.environ.get('ABLY_TLS') or "true").lower() == "true" -host = os.environ.get('ABLY_HOST', 'sandbox-rest.ably.io') -environment = os.environ.get('ABLY_ENV') - -port = 80 -tls_port = 443 - -if host and not host.endswith("rest.ably.io"): - tls = tls and not host.equals("localhost") - port = 8080 - tls_port = 8081 - - -ably = AblyRest(token='not_a_real_token', rest_host=host, - port=port, tls_port=tls_port, tls=tls, - environment=environment, - use_binary_protocol=False) - - -class RestSetup: - __test_vars = None - - @staticmethod - def get_test_vars(sender=None): - if not RestSetup.__test_vars: - r = 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, - "host": host, - "port": port, - "tls_port": tls_port, - "tls": tls, - "environment": environment, - "keys": [{ - "key_name": "%s.%s" % (app_id, k.get("id", "")), - "key_secret": 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 - - @classmethod - def get_ably_rest(cls, **kw): - test_vars = RestSetup.get_test_vars() - options = { - 'key': test_vars["keys"][0]["key_str"], - 'rest_host': test_vars["host"], - 'port': test_vars["port"], - 'tls_port': test_vars["tls_port"], - 'tls': test_vars["tls"], - 'environment': test_vars["environment"], - } - options.update(kw) - return AblyRest(**options) - - @classmethod - def clear_test_vars(cls): - test_vars = RestSetup.__test_vars - options = Options(key=test_vars["keys"][0]["key_str"]) - options.rest_host = test_vars["host"] - options.port = test_vars["port"] - options.tls_port = test_vars["tls_port"] - options.tls = test_vars["tls"] - ably = cls.get_ably_rest() - ably.http.delete('/apps/' + test_vars['app_id']) - RestSetup.__test_vars = None diff --git a/test/ably/reststats_test.py b/test/ably/reststats_test.py deleted file mode 100644 index 39ec3e80..00000000 --- a/test/ably/reststats_test.py +++ /dev/null @@ -1,271 +0,0 @@ -from datetime import datetime -from datetime import timedelta -import logging - -import pytest - -from ably.types.stats import Stats -from ably.util.exceptions import AblyException -from ably.http.paginatedresult import PaginatedResult - -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase - -log = logging.getLogger(__name__) - - -class TestRestAppStatsSetup: - - @classmethod - def get_params(cls): - return { - 'start': cls.last_interval, - 'end': cls.last_interval, - 'unit': 'minute', - 'limit': 1 - } - - @classmethod - def setUpClass(cls): - RestSetup._RestSetup__test_vars = None - cls.ably = RestSetup.get_ably_rest() - cls.ably_text = RestSetup.get_ably_rest(use_binary_protocol=False) - - cls.last_year = datetime.now().year - 1 - cls.previous_year = datetime.now().year - 2 - cls.last_interval = datetime(cls.last_year, 2, 3, 15, 5) - cls.previous_interval = datetime(cls.previous_year, 2, 3, 15, 5) - previous_year_stats = 120 - stats = [ - { - 'intervalId': Stats.to_interval_id(cls.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(cls.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(cls.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(cls.previous_interval - - timedelta(minutes=i), - 'minute'), - 'inbound': {'realtime': {'messages': {'count': i}}} - } - ) - - cls.ably.http.post('/stats', body=stats + previous_stats) - - def per_protocol_setup(self, use_binary_protocol): - self.ably.options.use_binary_protocol = use_binary_protocol - self.stats_pages = self.ably.stats(**self.get_params()) - self.stats = self.stats_pages.items - self.stat = self.stats[0] - - -class TestDirectionForwards(TestRestAppStatsSetup, BaseTestCase, - metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def get_params(cls): - return { - 'start': cls.last_interval - timedelta(minutes=2), - 'end': cls.last_interval, - 'unit': 'minute', - 'direction': 'forwards', - 'limit': 1 - } - - def test_stats_are_forward(self): - assert self.stat.inbound.realtime.all.count == 50 - - def test_three_pages(self): - assert not self.stats_pages.is_last() - page3 = self.stats_pages.next().next() - assert page3.items[0].inbound.realtime.all.count == 70 - - -class TestDirectionBackwards(TestRestAppStatsSetup, BaseTestCase, - metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def get_params(cls): - return { - 'end': cls.last_interval, - 'unit': 'minute', - 'direction': 'backwards', - 'limit': 1 - } - - def test_stats_are_forward(self): - assert self.stat.inbound.realtime.all.count == 70 - - def test_three_pages(self): - assert not self.stats_pages.is_last() - page3 = self.stats_pages.next().next() - assert page3.items[0].inbound.realtime.all.count == 50 - - -class TestOnlyLastYear(TestRestAppStatsSetup, BaseTestCase, - metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def get_params(cls): - return { - 'end': cls.last_interval, - 'unit': 'minute', - 'limit': 3 - } - - def test_default_is_backwards(self): - assert self.stats[0].inbound.realtime.messages.count == 70 - assert self.stats[-1].inbound.realtime.messages.count == 50 - - -class TestPreviousYear(TestRestAppStatsSetup, BaseTestCase, - metaclass=VaryByProtocolTestsMetaclass): - - @classmethod - def get_params(cls): - return { - 'end': cls.previous_interval, - 'unit': 'minute', - } - - def test_default_100_pagination(self): - assert len(self.stats) == 100 - next_page = self.stats_pages.next().items - assert len(next_page) == 20 - - -class TestRestAppStats(TestRestAppStatsSetup, BaseTestCase, - metaclass=VaryByProtocolTestsMetaclass): - - @dont_vary_protocol - def test_protocols(self): - self.stats_pages = self.ably.stats(**self.get_params()) - self.stats_pages1 = self.ably_text.stats(**self.get_params()) - assert len(self.stats_pages.items) == len(self.stats_pages1.items) - - def test_paginated_response(self): - assert isinstance(self.stats_pages, PaginatedResult) - assert isinstance(self.stats_pages.items[0], Stats) - - 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 = self.ably.stats(**params) - stat = stats_pages.items[0] - assert len(stats_pages.items) == 1 - assert stat.all.messages.count == 50 + 20 + 60 + 10 + 70 + 40 - assert stat.all.messages.data == 5000 + 2000 + 6000 + 1000 + 7000 + 4000 - - @dont_vary_protocol - 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'"): - self.ably.stats(**params) - - @dont_vary_protocol - 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"): - self.ably.stats(**params) - - def test_no_arguments(self): - params = { - 'end': self.last_interval, - } - self.stats_pages = self.ably.stats(**params) - self.stat = self.stats_pages.items[0] - assert self.stat.interval_granularity == 'minute' - - def test_got_1_record(self): - assert 1 == len(self.stats_pages.items), "Expected 1 record" - - def test_zero_by_default(self): - assert self.stat.channels.refused == 0 - assert self.stat.outbound.webhook.all.count == 0 - - def test_return_aggregated_message_data(self): - # returns aggregated message data - assert self.stat.all.messages.count == 70 + 40 - assert self.stat.all.messages.data == 7000 + 4000 - - def test_inbound_realtime_all_data(self): - # returns inbound realtime all data - assert self.stat.inbound.realtime.all.count == 70 - assert self.stat.inbound.realtime.all.data == 7000 - - def test_inboud_realtime_message_data(self): - # returns inbound realtime message data - assert self.stat.inbound.realtime.messages.count == 70 - assert self.stat.inbound.realtime.messages.data == 7000 - - def test_outbound_realtime_all_data(self): - # returns outboud realtime all data - assert self.stat.outbound.realtime.all.count == 40 - assert self.stat.outbound.realtime.all.data == 4000 - - def test_persisted_data(self): - # returns persisted presence all data - assert self.stat.persisted.all.count == 20 - assert self.stat.persisted.all.data == 2000 - - def test_connections_data(self): - # returns connections all data - assert self.stat.connections.tls.peak == 20 - assert self.stat.connections.tls.opened == 10 - - def test_channels_all_data(self): - # returns channels all data - assert self.stat.channels.peak == 50 - assert self.stat.channels.opened == 30 - - def test_api_requests_data(self): - # returns api_requests data - assert self.stat.api_requests.succeeded == 50 - assert self.stat.api_requests.failed == 10 - - def test_token_requests(self): - # returns token_requests data - assert self.stat.token_requests.succeeded == 60 - assert self.stat.token_requests.failed == 20 - - def test_inverval(self): - # interval - assert self.stat.interval_granularity == 'minute' - assert self.stat.interval_id == self.last_interval.strftime('%Y-%m-%d:%H:%M') - assert self.stat.interval_time == self.last_interval diff --git a/test/ably/resttime_test.py b/test/ably/resttime_test.py deleted file mode 100644 index edae7cc4..00000000 --- a/test/ably/resttime_test.py +++ /dev/null @@ -1,39 +0,0 @@ -import time - -import pytest - -from ably import AblyException - -from test.ably.restsetup import RestSetup -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseTestCase - - -class TestRestTime(BaseTestCase, metaclass=VaryByProtocolTestsMetaclass): - - def per_protocol_setup(self, use_binary_protocol): - self.use_binary_protocol = use_binary_protocol - - def test_time_accuracy(self): - ably = RestSetup.get_ably_rest(use_binary_protocol=self.use_binary_protocol) - - reported_time = ably.time() - actual_time = time.time() * 1000.0 - - seconds = 10 - assert abs(actual_time - reported_time) < seconds * 1000, "Time is not within %s seconds" % seconds - - def test_time_without_key_or_token(self): - ably = RestSetup.get_ably_rest(key=None, token='foo', - use_binary_protocol=self.use_binary_protocol) - - reported_time = ably.time() - actual_time = time.time() * 1000.0 - - seconds = 10 - assert abs(actual_time - reported_time) < seconds * 1000, "Time is not within %s seconds" % seconds - - @dont_vary_protocol - def test_time_fails_without_valid_host(self): - ably = RestSetup.get_ably_rest(key=None, token='foo', rest_host="this.host.does.not.exist") - with pytest.raises(AblyException): - ably.time() 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 index 4677b0a5..ae19e0b5 100644 --- a/test/ably/utils.py +++ b/test/ably/utils.py @@ -1,20 +1,27 @@ +import asyncio import functools +import os import random import string -import unittest +import time +from typing import Awaitable, Callable +from unittest import mock import msgpack -import mock -import responses +import respx +from httpx import Response from ably.http.http import Http -class BaseTestCase(unittest.TestCase): +class BaseTestCase: - def responses_add_empty_msg_pack(self, url, method=responses.GET): - responses.add(responses.GET, url, body=msgpack.packb({}), - content_type='application/x-msgpack') + 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=''): @@ -26,6 +33,24 @@ def get_channel(cls, 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. @@ -44,8 +69,8 @@ def test_something(self): def patch(): original = Http.make_request - def fake_make_request(self, *args, **kwargs): - response = original(self, *args, **kwargs) + async def fake_make_request(self, *args, **kwargs): + response = await original(self, *args, **kwargs) responses.append(response) return response @@ -58,25 +83,29 @@ def unpatch(patcher): def test_decorator(fn): @functools.wraps(fn) - def test_decorated(self, *args, **kwargs): + async def test_decorated(self, *args, **kwargs): patcher = patch() - fn(self, *args, **kwargs) + 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" + 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': - assert response.headers['content-type'] == 'application/json' + if response.status_code != 204: + assert response.headers['content-type'] == 'application/json' if response.content: response.json() else: - assert response.headers['content-type'] == 'application/x-msgpack' + 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 @@ -92,11 +121,11 @@ def per_protocol_setup(self, use_binary_protocol): 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) @@ -111,10 +140,11 @@ def wrap_as(ttype, old_name, old_func): expected_content = {'bin': 'msgpack', 'text': 'json'} @assert_responses_type(expected_content[ttype]) - def wrapper(self): + async def wrapper(self): if hasattr(self, 'per_protocol_setup'): self.per_protocol_setup(ttype == 'bin') - old_func(self) + await old_func(self) + wrapper.__name__ = old_name + '_' + ttype return wrapper @@ -127,10 +157,111 @@ def dont_vary_protocol(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 6af43268..90f1655e 100644 --- a/test/assets/testAppSpec.json +++ b/test/assets/testAppSpec.json @@ -26,7 +26,11 @@ { "id": "canpublish", "pushEnabled": true - } + }, + { + "id": "mutable", + "mutableMessages": true + } ], "channels": [ { 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 1485848f..00000000 --- a/tox.ini +++ /dev/null @@ -1,15 +0,0 @@ -[tox] -envlist = - py{35,36,37,38} - flake8 - -[testenv] -deps = - -rrequirements-test.txt - -commands = - py.test -n auto --tb=long test - -[testenv:flake8] -commands = - flake8 setup.py ably test 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" }, +]